终于下定决心构建一个属于自己的读书摘抄站点了!
此前的读书摘抄一直都是写在一个Markdown文件里,然后通过OneDrive同步。这种原始的办法问题不大,但很低效,也谈不上视觉效果;面对偶发的要把某些极好的片段分享给好友的需求更是无能为力,只能截个图勉强作数。
从现在开始呢,我希望一步步构建一下属于自己的读书摘抄站点;此前买的好几个月的腾讯云服务器一直闲置,近期可能就有他的用武之地了。
开工大吉! ☀️
总体规划
这次要做的肯定是一个动态网站(In fact, 人生中第一个动态网站 /(ㄒoㄒ)/~~),大概的实施要点是:
- 前后端分离(Maybe)
- 不设置账号了,因为使用范围比较私人;发布文章的接口是暴露的,但是发布摘抄之前要进行身份认证
- 加密压缩存储(看后期技术能力)
- 数据库定期备份
- HTTPS
- 实现分享功能
- 前端尽量适应多设备
- 动态加载+加载时的页面动画
已经物色好了后端开发的框架: drogonframework/drogon: Drogon: A C++14/17/20 based HTTP web application framework running on Linux/macOS/Unix/Windows (github.com) ,感觉用C++开发后端非常酷炫,本身需要实现的功能也不是很多 ,大有可为! 😼
Step01 主体框架
在我的构思中,读书摘抄页面的主体应该是一个很简单的结构:
- 摘抄列表类似于我个人博客的文章列表就行;
- 点击某个文段之后,触发缩放效果,文段成为页面中心,同时背景虚化。
正在我犹豫迷茫的时候,发现了 Steven 大佬的 【CSS】App Store 卡片展开效果 视频!Amazing!
我很快决定页面主体就基于他的这个视频了。下面的内容假设你已经实现了Steven在视频中介绍的所有效果。
我照着视频实现的总的代码为:
<!DOCTYPE html>
<html lang="en-us">
<head>
<title>Test</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Long+Cang&family=Ma+Shan+Zheng&family=Noto+Sans+SC:wght@300&family=Noto+Serif+SC:wght@300&family=Zhi+Mang+Xing&display=swap" rel="stylesheet">
<style>
:root {
font-size: 15px;
font-family: 'Zhi Mang Xing', cursive;
--body-width: 100%;
--card-width: 420px;
--card-height: 280px;
--img-height: 226px;
--img-height-expended: 320px;
background-color: #333;
}
body {
width: var(--body-width);
background-color: #eee;
margin: auto;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 1rem 0;
}
body.noscroll {
overflow: hidden;
}
.card {
width: var(--card-width);
height: var(--card-height);
background-color: #fff;
border-radius: 1rem;
box-shadow: 0 .2rem 2rem rgba(0, 0, 0, .1);
margin: 1rem 0;
transition: .3s all cubic-bezier(0, 1, 0.95, 1.05);
}
.card img {
display: block;
width: 100%;
height: var(--img-height);
object-fit: cover;
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
}
.card h4 {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
padding: .8rem 1.2rem;
background-color: #fff;
line-height: 2rem;
letter-spacing: -.5px;
padding-bottom: 0;
}
.card .content-wrapper {
height: 0;
overflow: hidden;
transition: .3s all ease-out;
opacity: .8;
}
.card .content-wrapper .content {
padding: 0 1.2rem;
background-color: #fff;
overflow: auto;
}
.card p {
font-size: 1.2rem;
line-height: 1.5rem;
}
/* active classes */
.card.active {
transform: translateY(var(--data-offset-top)) scale(calc(480/420));
transform-origin: 50% 0;
border-radius: 0;
}
.card.active h4 {
padding-bottom: .8rem;
}
.card.active img {
border-top-left-radius: 0;
border-top-right-radius: 0;
height: var(--img-height-expended);
}
.card.active .content-wrapper {
height: 100vh;
transition: .3s all ease-in;
opacity: 1;
}
::-webkit-scrollbar {
width: 20px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #d6dee1;
border-radius: 20px;
border: 6px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: #a8bbbf;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script>
<script type="text/javascript">
window.onload = function() {
$('.card').on('click', function (e) {
let card = $(e.currentTarget);
let card_offset_scrolltop = $(card).offset().top - $(window).scrollTop();
$(card).css('--data-offset-top', card_offset_scrolltop * -1 + 'px');
$(card).toggleClass('active');
let height = $(window).height();
let ratio = 480/420;
height -= $(card).find('img').outerHeight() * ratio;
height -= $(card).find('h4').outerHeight() * ratio;
height /= ratio;
$(card).find('.content').css('height', height);
if ($(card).hasClass('active')) {
$('body').addClass('noscroll');
} else {
$('body').removeClass('noscroll');
}
});
}
</script>
</head>
<body>
<div class="card">
<img src="https://source.unsplash.com/900x600/?nature,water,1" />
<h4>Title1</h4>
<div class="content-wrapper">
<div class="content">
<p></p>
<p></p>
</div>
</div>
</div>
<div class="card">
<img src="https://source.unsplash.com/900x600/?nature,water,2" />
<h4>Title2</h4>
<div class="content-wrapper">
<div class="content">
<p></p>
<p></p>
</div>
</div>
</div>
<div class="card">
<img src="https://source.unsplash.com/900x600/?nature,water,3" />
<h4>Title3</h4>
<div class="content-wrapper">
<div class="content">
<p></p>
<p></p>
</div>
</div>
</div>
<div class="card">
<img src="https://source.unsplash.com/900x600/?nature,water,4" />
<h4>Title4</h4>
<div class="content-wrapper">
<div class="content">
<p></p>
<p></p>
</div>
</div>
</div>
</body>
</html>
Step02 移除图片&样式调整
Steven大佬做出来的效果,对 img
的操作占了比较大的部分;不过我的需求是文字摘抄,并不需要这么多的图片,因此决定将这些图片移除掉。同时,把CSS中关于图片的样式也全部移除。
此外,在demo中限定了 --card-width: 420px
,这是因为作者假设了使用场景是小屏幕;我的使用场景不是小屏幕,因此将这个数值改大一些: --card-width: 80%
。
现有的布局结构是一个 card
下有一个标题 h4
和内容部分 content-wrapper
,我决定将他们全部保留,不过移除了图片之后,样式变得有点奇怪,需要进行一些调整。
- 卡片高度要缩小,因为已经没有图片了;
- 内容展示的时候不能展示完全,只呈现出三行,超过部分用省略号替代;同时,点开内容之后要把这些限制去掉;
- 保留圆角(调了蛮久才搞出来,感觉最终的代码并不是很简洁);
- 滚动条美化(老技能了)。
调整之后的CSS变为:
:root {
font-size: 15px;
font-family: 'Zhi Mang Xing', cursive;
--body-width: 100%;
--card-width: 80%;
--card-height: 150px;
background-color: #333;
}
::-webkit-scrollbar {
width: 20px;
background-color: #eee;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #d6dee1;
border-radius: 20px;
border: 6px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: #a8bbbf;
}
body {
width: var(--body-width);
background-color: #eee;
margin: auto;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 1rem 0;
}
body.noscroll {
overflow: hidden;
}
.card {
width: var(--card-width);
height: var(--card-height);
background-color: #fff;
border-radius: 1rem;
box-shadow: 0 .2rem 2rem rgba(0, 0, 0, .1);
margin: 1rem 0;
transition: .3s all cubic-bezier(0, 1, 0.95, 1.05);
}
.card h4 {
margin: 0;
font-size: 1.8rem;
font-weight: bold;
padding: .8rem 1.2rem;
background-color: #fff;
line-height: 2rem;
letter-spacing: -.5px;
padding-bottom: 0;
border-radius: 1rem;
}
.card .content-wrapper {
transition: .3s all ease-out;
opacity: .8;
}
.card .content-wrapper .content {
padding: 0 1.2rem;
background-color: #fff;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
border-radius: 1rem;
}
.card p {
font-size: 1.5rem;
line-height: 2rem;
}
/* active classes */
.card.active {
transform: translateY(var(--data-offset-top)) scale(calc(480/420));
transform-origin: 50% 0;
border-radius: 0;
z-index: 99;
}
.card.active h4 {
padding-bottom: .8rem;
}
.card.active .content-wrapper {
height: 100vh;
transition: .3s all ease-in;
opacity: 1;
}
.card.active .content-wrapper .content {
display: block;
height: 80%;
}
完成了以上的微调之后,需要进行细节部分的大改动了,我将分为几个细节来进行处理。
滚动条美化
滚动条美化是此前对个人博客进行优化时接触过的东西,总体来说就是使用CSS一系列选择器来选中滚动条的对应部分。在上面的CSS中,我将滚动条背景颜色设置为了 #eee
,即正常情况下的背景颜色,而这导致了一个问题:当我点开一个具体的摘抄,其中文字内容需要进行滚动时,滚动条的颜色跟卡片颜色有点不协调,很出戏。
优化的思路很简单,使用一个CSS变量来设置颜色;配合此前已经有了的jQuery对点击事件的判断,改变这个背景颜色即可。
既然已经提到了使用变量,我就 统一将各个背景颜色全部使用变量进行控制 ,作出的相应修改在此不表。
使用 --scrollbar-bg-color: #eee;
作为滚动条默认背景颜色(与 body
背景颜色一致),在jQuery判断点击的那部分代码中加上对这个颜色变量的控制:
if ($(card).hasClass('active')) {
$('body').addClass('noscroll');
$(':root').css('--scrollbar-bg-color', '#fff');
} else {
$('body').removeClass('noscroll');
$(':root').css('--scrollbar-bg-color', '#eee');
}
搞定!
卡片缩放美化
Steven大佬的卡片缩放,由于有一张尺寸较大的图,使得原本“顶天立地”的缩放效果就显得很出色。在我的使用场景中,已经将图片去掉了,如果继续“顶天立地”下去,就会造成卡片下端的大量空白。
毫无疑问,这样的效果是很不好的,即便我后续将要对卡片的结构进行一些调整,读书摘抄这种有限的内容仍然很难铺满一个浏览器窗口。
我的理想放大效果是:内容基本位于窗口中心,上下左右都留出一些空白。
基于原有的代码,要实现内容居中较为容易。原本的代码会计算 --data-offset-top
,然后对卡片进行一个 translateY
的操作,将卡片移到页面顶部;我不希望卡片“头顶天”,因此将这个 --data-offset-top
的值修改一下即可。
具体的操作是:先获取 $(window).height()
即窗口高度,然后把 --data-offset-top
加上窗口高度的一定比例。
实现出来的效果类似于为卡片加上了 margin-top
。
而为了不让卡片“脚着地”,只需要把卡片的 height
减小即可。在原本的代码中,这个高度通过 .card.active .content-wrapper
来控制,我将其改为一个变量 var(--content-wrapper-height-expended)
,在卡片展开的时候,设置这个变量的值为窗口高度的一定比例。
同时,为了不让 content
把 content-wrapper
撑大,要为这个元素设置 height
,使它至多跟父元素同样高。
实现出来的效果类似于为卡片加上了 margin-bottom
。
对卡片展开的比例进行调整之后,大小是合理了,不过出现了新的问题:
这个问题实际上花了我一段时间,最后的解决方案是:新增一个全屏的“遮罩”元素,背景颜色设置好透明度;遮罩在正常情况下 z-index
较小且 display
为 none
,卡片展开之后把 z-index
抬升到 展开的卡片之下、未展开的卡片之上 的位置,然后 display
呈现出来。
注意这边展开之后看起来右边没有间隙,实际上是GIF录制过程中的范围有限所致。
点击事件优化
原本的卡片缩放实现方案是:在 click
事件中进行 toggleClass
的操作来添加/移除 active
这个类,逻辑很简单,但并不完全实用。
其中很关键的一点是:当我选中一段文字时,也算作触发 click
事件;而毫无疑问,当读者选中某些文字的时候希望进行的是复制之类的操作,假如触发了 click
事件,卡片就会缩回去,选中的文字也就丢失了。
修复它的做法比较简单:在 click
事件中判断一下当前是否选中了文本,若存在,则说明卡片缩放的步骤不应该执行,否则再按照原有的流程执行卡片缩放。
let txt = '';
if (window.getSelection) {
txt = window.getSelection().toString();
} else {
txt = document.selection.createRange().text;
}
if (txt == '') {
// 原有的卡片缩放步骤
}
缩放事件优化
卡片展开之后缩回,存在一个不到位的细节是:内容没有回到首行。
这使得体验很割裂,尤其当我添加了 书籍名称 、 发布日期 等元素之后,出现了重叠。
解决的办法是:在卡片回弹的函数中,选中 content-wrapper
并将使用 scrollTop(0)
将它的偏移量重置为 0
。
Step03 添加伸缩侧边栏
在一个读书摘抄的站点中,根据书籍进行分类应该是最基本的需求。
我的构思是添加一个可伸缩的侧边栏,侧边栏上呈现出所有的书籍,点击时进行异步通信,后台根据书籍名称筛选读书摘抄。
实现这个侧边栏需要对页面结构进行如下的调整:
- 在原有的若干卡片外层再添加一个
div
包裹起来,实现完整性; - 添加一个
position: fixed
的元素,放置在页面右侧; - 添加一个可点击的元素,点击之后触发侧边栏伸缩。
侧边栏元素的添加比较简单:假定侧边栏的宽度是 10%
,由于使用了 fixed
属性使得宽度比例的计算按照外层窗体进行,因此 body
要为侧边栏让出 10%
的空间,使用 padding-right: 10%
实现;
按照相同办法添加一个 fixed
元素充当按钮,注意计算按钮位置即可。按钮点击时判断侧边栏是否有宽度,若有,说明应该将侧边栏收缩,修改侧边栏宽度和 body
的 padding-right
为零;若侧边栏无宽度,说明应该展开,将二者改回原有的 10%
即可。
侧边栏中要放置书籍名称,使用类似于主体部分的卡片的布局进行实现;书籍名称的文字部分使用 writing-mode: vertical-rl;
和 text-align: center;
来使文字居中垂直排列。
Step04 侧边栏进一步完善
原本以为上一步添加的侧边栏就很够用了,可惜需求总是随着人类的异想天开而不断增加。
这一步骤主要完善两点功能:
- 站点需要对用户的登录状态进行判定,因此决定将用户的登录状态和登录接口放置在侧边栏上;
- 原有的侧边栏卡片有一些小问题,修复。
第一步相当于在侧边栏上又新增一个元素了。在若干个书籍卡片 之前 新增一个 <div id="authentication">
,我的设想是 用户未登录之前显示登录入口、用户登录之后显示一个头像图片 ,当然,目前肯定是不支持更换头像的,因此图片按我的喜好来 😸
未登录状态就使用 未登录 三个字来表示即可,用一个 <a>
标签把它做成链接;设置这个标签的 position: relative
,然后使用 ::before
伪元素来实现鼠标 hover
的效果:
登录状态使用一张正方形图片,然后 border-radius: 100%
改为圆形,不表。注意正常情况下图片总是能吸引用户点击,因此我希望将这个图片作为发布读书摘抄的入口。
接着是一个极其重要的改动:侧边栏滚动的启用。此前的布局看起来没有什么毛病,但当书籍多起来之后,就会:
卡片被压缩了!这不是我的本意,我希望书籍多起来之后侧边栏可以向下滚动,不要挤在这么一个小空间里。
修改的办法是:
- 在卡片样式中(即
#right-sidebar .book-card
选择器)加上flex-shrink: 0;
; - 侧边栏样式中(即
#right-sidebar
选择器)加上overflow: scroll;
; - 侧边栏滚动条隐藏(使用
#right-sidebar::-webkit-scrollbar
选择器设置display: none
)。
至此先告一段落啦!