作为一个重度拖延症,常常会把第一天的事情放到第二天干,同时又会因为许许多多的琐事导致很多第一天想要做的事情给忘记掉,所以TODO是每一天不可获取的事情,凡事要有安排,提前规划才能事半功倍,不管是工作任务,还是自己的各种日常琐事,都理应需要合理安排,于是,便自己开发了这样一个web端的日常TODO管理工具,用于记录日常的待办和工作安排。
关于此项目
简单介绍一下这个项目,首先项目是参考了github上的其他开源项目的ui开发的,主要是为了打造一个灵活性高,使用方便简介的todo工具,具体的体验地址是这样:
TodoList在线体验地址 具体使用非常简单,点击鼠标任意拖动即可创建一个便签面板,接下来就可以在里面进行书写了,非常的简单,同时所有的便签都知道随意拖动位置和调整大小。并且可以覆盖叠加,可以随时删除,支持白天黑夜不同主题,同时是一个纯前端存储项目等等特点。
简要声明
首先要声明的是市面上已经存在了许许多多的类似场景的工具,这类工具的开发主要目的是为了方便和提效,很多人会觉得花费自己的时间去做这样的事情是无意义的,但是每个人的学习方法不同,就像很多人反对重复遭轮子一样,但我还是觉得,很多东西,会用并不代表会,即使这样重复的事情,别人做和自己做是不一样的感觉,所以我更愿意自己动手来实现一次,我会觉得这样的过程一定是有收获的,所以第一,如果你并不觉得这样的项目有实际意义,那么也请轻喷(手动狗头),第二如果你觉得工具很好用,那么希望你能为这个项目点个start,并可以提出你的需求,后续可以完善上,第三,如果你也对这个项目感兴趣,那么很好,接下来,我将会手摸手带你从零到一完成这个项目的全部内容,当然,在你想要学习前,建议你想体验这个项目,然后可以更快明白其实现场景。
项目线上体验地址 todolist-panel
项目github地址 todolist
技术栈选型
日常来讲,对于这类小项目的选型特别是个人项目,对我个人而言是想用到更多尝试,首先这类工具类的东西我一般习惯于用服务端渲染来实现,因为这样会有更好的seo如果真的会有用户使用的场景,我们可以对此更好的进行seo的优化,目前主流的两大SSR服务端渲染框架,一个是以vue类别的NuxtJs,一个是以React类别的NextJs。这两个的技术选型很简单,看过之前内容的朋友会发现,前面的项目已经使用过NuxtJs了,所以这次为了尝鲜,这个项目选用了NextJs,个人对于ReactJs的开发经验比较少,所以如果是做个人项目,有这样的场景的情况下,会更愿意去使用自己接触少的技术栈,所以对我而言,这也是一个从零开始的项目,初次之外,我们并没有用到任何类库。
准备工作
为了快速开发项目,直接采用NextJs的脚手架create-next-app,快速生成项目,作为官方脚手架,已经为我们配置了很完善的初期工程化内容,我们可以快速开始工作,初次运行项目,打开之后发现就是一些简短的介绍内容没什么不同,其次就是其自带了css module,其他来看也没有携带很多东西,所以我们直接对其项目进行简化,删除不必要的东西,为我们的项目留下一个空白的页面即可,接下来我们开始正式的编码工作了,我们来逐步开始进行接下来的内容,带你弄明白各个功能的详细实现。
进入开发
首先我们要进行最基础的项目布局,但是这段内容太过于基础,我们可以看到,整个内容就只有一页,那么很明显,我们无需过于复杂的布局,点开控制台可以看到总共就两层,一层是canvas背景一层就是我们的主要绘制区域了,所以我们的整个结构分为三层, body > canas > main
,那么body先不必关注,我们先进行canvas的绘制吧。
绘制canvas的网格背景
首先我们要知道网格背景有什么用处,细心的朋友会发现,每一次我们拖动便签的时候,一次移动的是一个格子,而并不是我们日常的拖动事件可以随意拖动,这也就是我们绘制这个网格背景的作用,我们希望我们的便签可以整齐的贴在上面,所以我们为其绘制一个宽高都是10的网格出来:
如何绘制这样的网格呢,思路非常简单,也比较接近前面的这篇内容,(你的代码怎么下起了雨? - 掘金,其次我们还是常规的需要对四周进行一定的padding,所以我们得到网格的宽高就定位屏幕的宽高减去50,其次我们希望一个格子的距离为10,
那么横排可以绘制Math.floor(width / 10)数量的点
同理纵向可以绘制Math.floor(height/ 10)数量行的点
那么首先我们在页面放置一个canvas元素然后就可以开始工作了,我们先写一个绘制点的方法,那么绘制这样的点其实很简单,你可以绘制宽高都是1的矩形,也可以绘制半径为1的圆都可以达到点的效果,那么先来写一个绘制点的方法
/* 绘制点 */
function drawPoint({ x, y }, color) {
ctx.fillStyle = color
ctx.beginPath();
ctx.rect(x, y, 1, 1);
ctx.fill();
}
非常easy,画一个宽高为1的点即可,好了,我们开始绘制整个网格盘,思路就是上面的思路,利用双层for循环就可以完成了
/* 绘制背景 */
function draw() {
const { innerWidth, innerHeight } = window;
cvsRef.current.width = innerWidth - 50;
cvsRef.current.height = innerHeight - 50;
ctx = cvsRef.current.getContext("2d");
const [width, height] = [cvsRef.current.width, cvsRef.current.height];
const singel = 10;
const wCount = Math.floor(width / singel);
const hCount = Math.floor(height / singel);
const theme = getStorageItem(THEME) || LIGHT
const color ="#00000080";
for (let i = 0; i < hCount; i++) {
for (let j = 0; j < wCount; j++) {
drawPoint({ x: j * singel + 1, y: i * singel + 1 }, color);
}
}
}
因为我们使用了框架,所以上面的cvsRef节点其实就是我们获取的canvas的dom节点,然后拿到可以绘制的宽高数量,进行绘制即可,此时我们已经成功绘制出来了这个网格,那么接下来,我们开始下一步的工作,我们可看到最基础的工作是从鼠标按下拉动开始的,所以我们需要完成鼠标按下创建便签的这个操作。
动态创建滑动中的过渡节点
我们已经用到了框架,首先我们知道的目前主流框架都是数据驱动视图,我们我们不管做什么操作实际都是驱动视图,比如现在的low code低代码或者无代码平台,本质上都是对数据的操作,我们的核心思想也是一样,只需要维护一个todolist的数据源,然后通过框架渲染这个数据即可,思路已经有了,我们明白了,不管看起来怎么操作,本质是改变数据,那么如何完成我们所看到的鼠标点下拉动抬起就创建的一整个过程呢?
第一我们看到的是按下拉动过程中的样子并不是抬起鼠标之后创建的东西,所以我们应该之后在拖动过程中的这个视图是临时的,当我们抬起的时候显示的才是我们真正的需要的元素,所以此时我们 思路已经确定,按下鼠标动态创建一个节点,就是我们拖动过程中的样子,当鼠标抬起的时候,移除掉这个节点并且把我们拖动的这块区域替换为我们需要的便签节点。
这一过程也是整个页面相对核心的功能,所以会花费更多的时间带大家理解这个过程,按下,拖动,抬起,对应的就是onmousedown、onmousemove、onmoseup
三个事件,我们在按下的时候,创建一个div节点,并赋予一定的样式,然后让其定位,在给定一个id赋予其唯一身份,在其他任何地方都可以通过id找到他,那么位置就是我们按下鼠标的(x,y)坐标:
/* 创建盒子 */
function handlerMainMousedown(e) {
e.stopPropagation()
if (e.target.id !== 'main') return;
const main = document.getElementById('main')
/* 记录起始点 */
const mousedownPoint = {};
({ clientX: mousedownPoint.x, clientY: mousedownPoint.y } = e);
const activeDiv = document.createElement("div");
activeDiv.id = "createIn";
activeDiv.style.backgroundColor = `#c4bebe80`;
activeDiv.style.position = "absolute";
activeDiv.style.zIndex = 99999;
document.body.appendChild(activeDiv);
}
那么此时我们移动盒子,移动中(x,y)不断变化,我们拿到移动中的(x,y)减去起对应的(x,y)就可以拿到当前盒子的大了,将其赋值给创建的节点即可:
但是这里我们需要考虑一个问题这些问题
-
如果从左往右滑动鼠标,我们可以确定的是相减(x)一定是正的
-
如果是鼠标从上往下滑动,那么我们可以确定的是(y)一定是正的
正的情况赋值给创建节点的宽高很合理,也确实是达到了我们的目标,但是如果我们不满足上面的情况怎么办呢?给一个负值到宽高显然不合理,我们可以使用绝对值进行赋值
activeDiv.style.width = `${Math.abs(x - clientX)}px`;
activeDiv.style.height = `${Math.abs(y - clientY)}px`;
看似合理对么,实际上依然有问题,我们想象刚刚的场景,鼠标从左上角往右下角划动,我们的定位是通过left和top实现的,那么如果我们从右下角往左上角来滑动呢,我们的定位的初始坐标不就变成了,右下角么对应的是right和bottom此时我们发现了,原来鼠标拖动创建的场景居然是四种,我们需要对其划分。
再次思考一下,我们的整个创建过程,结合上面的初始按下处的代码,可以总结下面几点
-
鼠标按下创建一个div节点
-
移动过程中,我们通过移动的坐标计算出来移动的差值,diffX、diffY,
-
我们差值的绝对值是盒子的宽高
-
我们点击的时候使用过绝对定位,赋予盒子的left和top为当前坐标,
-
造成了如果我们不是从左上角往右下角滑动开始的(x,y)坐标就不能成为其定位的left和top
那么我们问题卡到第五步了,我们有什么思路处理他呢,一种上述说到,总共四种场景,判断方向就可以知道我们可以拿left、top、right、bottom其中的哪两个方向作为定位点,这样可以实现,但是并不方便,这个方案我已经实践过了,这样做导致,四个值都有,我们的源数据会有点乱,不是很方便,具体的细节我们在下面通过这样一幅图具体来实现
判断鼠标方向确定动态创建的起始点位
我们总结下如果永远都是按left和top定位,也就是左上角的定位,那么我们四种拖动场景的点的坐标方式将计算的不同,我们总结下四种滑动方向:
-
从左上角滑动拉到右小角
-
从左下角滑动拉到右上角
-
从右下角滑动拉到左上角
-
从右上角滑动拉到左下角
我们看看下面这副图,红点代表鼠标初始按下的位置,黑点代表抬起的位置,也包含移动的方向,同时我们用(oldX,oldY)表示按下点坐标,(newX,newY)代表移动中和最终抬起的坐标,然后看看四种场景下,这个左上角的坐标如何计算,赋值给我们的dom节点。
可以看到上图,用户可以按下鼠标往四个方向拉,而这种时候,我们的左上角的点的计算方式也不同,所以我们在用户拖动的过程中,需要通过diffX和diffY的差距判断用户的滑动方向,进而拿到左上角的准确的点的位置。
/* 判断四种方向下left top的不同坐标 */
if (diffX > 0 && diffY > 0) {
Object.assign(cacheCreateManifestItem, { position: { left: x, top: y } })
}
if (diffX > 0 && diffY < 0) {
Object.assign(cacheCreateManifestItem, { position: { left: x, top: clientY }})
}
if (diffX < 0 && diffY > 0) {
Object.assign(cacheCreateManifestItem, { position: { left: clientX, top: y }})
}
if (diffX < 0 && diffY < 0) {
Object.assign(cacheCreateManifestItem, { position: { left: clientX, top: clientY }})
}
上面的cacheCreateManifestItem就是我们定义的一个变量,记录本次从拖动到结束的全部数据,最终拿到的数据就记录了我们一个便签的位置大小信息,一个便签,有对应的定位坐标,有宽高大小,有用户记录的类容,一个便签的格式为:
export const defaultTodo = [
{
position: {
left: 70,
top: 70,
},
size: {
width: 379,
height: 399,
},
id: "5111941277-31218-4030-81129-2651543101115215",
zIndex: 1000,
text: "",
},
];
一项便签我们上述只需要用户自定义text ,其他数据在拖动完成的时候就都可以拿到了,刚刚我们已经说到了left和top的获取方式,
width和height上面也提到了,通过newX - oldX的方式获取的diffX或是diffY再通过Math.abs() 获取其绝对值的方式拿到宽高,此时,我们拥有了创建一个便签的必要数据了,
创建便签
在鼠标塔器之前,我们需要销毁掉此次在按下鼠标过程中创建的过度元素,然后拿到数据将数据添加到我们所定义的todolist当中,然后将数据交由React让数据渲染为视图即可,那么到了这里我们已经实现了随意拖动创建一个便签的功能了,当然我们也可以在这里加上一些限制,比如宽度和高度没有达到多少的时候,我们只移除掉过度元素,但是并不创建便签,同时为了区分唯一性,可以在这个阶段为其增加上我们随机创建的id,用于其循环渲染时候的key,同时这个时间我们要为其设置一个Zindex保证其层级为当前最高层级即可。
/* 抬起鼠标, */
document.onmouseup = (e) => {
document.onmousemove = null
main.style.cursor = "pointer";
const activeDiv = document.getElementById("createIn");
if (!activeDiv) return
document.body.removeChild(activeDiv)
const { width, height } = cacheCreateManifestItem.size
if (width < 80 || height < 80) return;
const id = getRandomId()
const updateData = [...manifestList, ...[{ ...cacheCreateManifestItem, id, zIndex: zIndex }]]
setManifestList(updateData)
setStorageItem(TODOLIST, updateData)
setZIndex(zIndex + 1)
};
对便签进行放大缩小
可以看到右小角有个小方块可以对其进行放大缩小,这个如何实现呢,和上面大同小异了,当我们点击的时候会获取到当前的(x,y)坐标,然后移动过程中拿到和开始相同逻辑的diffX,diffY然后将这个差值赋值到当前拖动的便签的数据width和height然后,然后触发更新视图即可,那么,我们的放大缩小就这么简单的实现了。接下来我们需要拖动移动便签了。
拖动移动便签
在顶部有一块拖动区域,只需要对其拖动就可以随意移动了,这里的逻辑和放大缩小相似,我们只需要改变其便签数据的left和top值即可修改其位置,当然这里存在一个问题,就是当我们创建了很多的便签的时候,有的会盖住别的便签,我们拖动的时候可能会跑到别的后面,显然不合理,所以我们一定要在拖动的时候保证其层级是最高的,不仅仅是拖动的场景,我们去编辑某个便签,点击的时候也需要其先到最高层级,这个如何实现呢?
如何管理层级问题?
我们默认给了zIndex为1000,每创建一个便签,我们就让这个值加一,在这种场景下,后创建的优先级始终是最高的,那么点击的时候,我们只需要改变其最zIndex值为最大,就可以让其不管在哪一层都跑到最上层来,这就是点击便签的时候应该做的事情
/* 点击便签 */
function hanlderClickManifest({id}) {
setActiveId(id)
manifestList.forEach( item => {
if(item.id === id ){
item.zIndex = zIndex
setZIndex(zIndex + 1)
}
})
setManifestList(manifestList)
}
此时层级问题已经解决,那么我们回到上面的移动问题,当我们按下鼠标移动的时候就需要提前调用一下点击事件的方法,我们就可以保证拖动前当前拖动的便签一定回到最顶层,那么这个问题就解决掉了,接下来的拖动和放大缩小是相似的。
但是我们会发现,我们真正拖动的时候感觉有一格一格拖动的感觉,并不是1px一次的感觉,这样的交互就像我们在网格上面一格一格移动一样,这一点如何实现呢,我们知道,格子的大小是10x10,如果要像是在格子上走动的感觉,那么我们一次移动的距离必须是10的倍数,知道这个逻辑后,就简单了,我们需要保证我们每次在移动过程中赋值的时候一定要是10的倍数即可,我们可以这样取
const diffX = Math.floor((e.clientX - clientX) / 10) * 10
const diffY = Math.floor((e.clientY - clientY) / 10) * 10
此时我们就有了一格一格移动的感觉了,当然与之对应的是要想贴合格子,我们在创建的开始和结束也要做相同的操作,保证定位位置是10的倍数的整数,包括宽高都做这里相同的操作就可以完美实现与格子贴合了。
边界问题
当然此时拖动虽然已经正常使用了,但我们发现随意拖动可能导致会拖到屏幕外边,我们需要对其添加边界,当拖动格子的过程中,left和top小于0或者大于屏幕的宽高都说明出了边界,需要对其限制,这样才能保证其不会超出边界,当然因为我们限制便签的最小宽高是80,相对的我们也可以运行他拖出屏幕超出这个距离:
const diffX = Math.floor((e.clientX - clientX) / 10) * 10
const diffY = Math.floor((e.clientY - clientY) / 10) * 10
let curX = left + diffX
let curY = top + diffY
curX < 0 && (curX = 0)
curY < 0 && (curY = 0)
const { innerHeight, innerWidth } = window
curX > innerWidth - 80 && (curX = innerWidth - 80)
curY > innerHeight - 80 && (curY = innerHeight - 80)
那么此时我们也就解决了边界问题了,此时我们的创建过程已经结束了,剩下的就是对创建完成的便签进行布局,这里的操作就无需解释了,相信每一位前端同学都可以轻松解决了,其次就是对于便签的赋值,这些都是一些基本操作了,我们的功能开发此时已经可以告一段落了,最后,传统的网站都会有两个主题,亮色和暗色,那么我们进行主题色的适配。
主题开发
一般来讲我们分两个主题 ,对于电脑的系统主题,白天的亮色和夜晚的暗色主题,所以首先我们需要定义好两套样式主题的颜色,如果不涉及到定制化很高的主题,现在的css变量是可以轻松帮我们实现这个效果的,我们定义两套变量,只需要在不同场景下使用不同的变量即可完成,我们先简单定义几个变量,一般默认都是亮色主题,我们同理,只需要在默认的情况下再定义一套亮色的主题:
body{
background: var(--background);
transition: background .5s ease-in-out;
}
:root{
--color: #000;
--background: #fff;
--header-ac-bg: rgba(0, 0, 0, 0.05);
}
.DARK{
--color: #fff;
--background: #000;
--header-ac-bg: rgba(255, 255, 255, 0.1);
}
比如我们默认定义了三个变量,但是又在DARK的类名上覆盖了它,所以,我们在切换主题的时候只需要让双方的权重发生变化即可,那么这套简单的方案应该很多人已经使用过了,就是给body添加这个class名即可,所以,我们这里的策略很简单:
如果用户没有自定义主题,我们会获取系统主题,是亮色就是默认,不是亮色,我们为body添加DARK类名让其优先级更高即可,所以控制主题的变化实际就是添加移除此类名的操作,当然如果用户手动通过快捷键变更了主题,我们就在localstorage中存下用户的选择,初始化的时候直接通过判断用户有无更改过默认主题来决定显示什么颜色的主题:
/* 初始化主题色 */
function initThemeMode(){
const body = document.querySelector("body");
const storageTheme = getStorageItem(THEME)
if(storageTheme){
if (storageTheme === DARK) {
body.classList.add(DARK);
setStorageItem(THEME, DARK);
} else {
body.classList.remove(DARK);
setStorageItem(THEME, LIGHT);
}
return;
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
body.classList.add(DARK);
setStorageItem(THEME, DARK);
}
}
此时我们便拥有了控制主题的能力,项目的切换主题快捷键是Alt + c
,所以我们在初始化阶段还需要监听快捷键,然后对快捷键做不同的事情,我们目前只有两个快捷键,一个变更主题,一个则是Alt + R
清空所有便签。
快捷键设置
这个非常基础了,监听按键,不同的快捷键做不同的事情即可
function handlerKeydown(e){
if ((e.code === "KeyC" || e.keyCode === 67) && e.altKey) {
return toggleTheme();
}
if ((e.code === "KeyR" || e.keyCode === 82) && e.altKey) {
const bool = window.confirm('Are you sure you want to remove all to dos')
if(!bool) return;
setManifestList([])
setStorageItem(TODOLIST, [])
}
}
好了此时我们也完成了快捷键的绑定
总结
如果你耐心看到了这里,那么先感谢你的支持,其实这是一篇项目的功能讲解,也是开发一个项目的新路历程,包括开发一个功能的先后顺序的考量,其难度并不大,所以希望大佬留情,其次,如果你也喜欢这样的思路,希望可以在GitHub - longyanjiang/todolist: 一个基于ReactJs的服务端渲染框架NextJs打造的便签项目,让你可以灵活的创建你想要的便签,专属个人提效工具!!!上点上宝贵的start对我进行支持
这一篇是真手摸手教程或者说文档,作为一个小白过来人,也会经常迷失在各种大佬的高深技术中,常常因为看不懂而陷入迷茫,所以想尽量把自己的每一篇博客写的足够详细,用白话的形式来梳理脉络,一是作为笔记自己记录,二则是希望能帮助到更多和我一样最开始小白进阶路上能够获取更多的方便,各位,一起加油。