全国免费咨询:

13245491521

VR图标白色 VR图标黑色
X

中高端软件定制开发服务商

与我们取得联系

13245491521     13245491521

2024-11-02_canvas库 konva 实现腾讯文档 [日历视图]

您的位置:首页 >> 新闻 >> 行业资讯

canvas库 konva 实现腾讯文档 [日历视图] 点击关注公众号,“技术干货”及时达! 效果图实现展示为什么实现这个功能?canvas大部分用于画图设计之类的功能,大厂用 canvas 用于业务实现,新颖性。大厂的这些用于商用的功能实现都不开源,自我实现具有挑战性。功能分析主体展示 :日历表的绘制功能增强 :任务分配进度的绘制用户交互 :拖动调整任务时段 | 模拟 div 的 hover 效果等等...技术选型腾讯文档用的 konva,我们复刻。使用 konva本身进行实现,不使用 react-konva 和 vue 相关的库,可以跨框架接入。实现设计传递基础配置实例化对象,利用发布订阅暴露出去重要的事件,用于使用者接收。 export interface KonvaCalendarConfig { // 模式 可读 | 可修改 ( 影响拖动是否可修改日期 ) mode: 'read' | 'edit'; // 挂载节点 container: string; // 初始时间 initDate?: Date | 'string' } const bootstrap = new CanvasKonvaCalendar({ mode: 'edit', container: '.smart-table-root',initDate : new Date('2024-10-20') }) bootstrap.setData([{startTime: '2024-09-30', endTime: '2024-09-30', fill: 'rgba(49, 116, 173,0.8)', description: '1',id: uuid()}]) bootstrap.on('ADDTASKRANGE', (day: string) = { console.log('点添加日期', day); }) bootstrap.on('CLICKRANGE', (day: string) = { console.log('选择日期', day); }) 内部实现详情 | 初始化数据export class CanvasKonvaCalendar { static readonly daysOfWeek = ['周一', '周二', '周三', '周四', '周五', '周六', '周天']; // 渲染日历 layer ( 节点稳定 性能损失较小 ) private readonly layer: Konva.Layer; // 用户交互 layer ( 节点变更频繁 ) private readonly featureLayer: Konva.Layer; // 画布 private readonly stage: Konva.Stage; // horve group 实例 private hoverGroup!: Konva.Group; private readonly cellWidth: number; private readonly cellHeight: number; // x轴绘制起点 private readonly startX = 20; private readonly emitter = new CalendarEvent(); private readonly hoverRect = { x: 0, y: 0, id: '' } // 渲染任务进度的数据源 taskRanges =[]; // 当前日期 private date: Date = new Date(); private stringDate: string; private month: number; private year: number; // 记录 拖动任务group 一些坐标信息 private recordsDragGroupRect: DragRect = { // 鼠标点击开始拖动位置与 range x 差值 differenceX: 0, // 鼠标点击开始拖动位置与 range y 差值 differenceY: 0, // 拖动源 原始值x sourceX: 0, // 拖动源 原始值y sourceY: 0, // 拖动源 targetGroup: null, // 鼠标开始拖动位置 startX: 0, // 鼠标开始拖动位置 startY: 0 } // 拖动 任务group实例 private dragGroup: Konva.Group | null = null; private readonly stageHeight: number; constructor( private readonly config: KonvaCalendarConfig, ) { this.stage = new Konva.Stage({ width: innerWidth - 350, height: innerHeight - 170, x: 0, y: 0, container: this.config.container }); this.layer = new Konva.Layer({}); this.featureLayer = new Konva.Layer({}); this.stage.add(this.layer); this.stage.add(this.featureLayer); this.date = new Date(this.config.initDate || new Date()); this.stringDate = formatDate(this.date); this.month = this.date.getMonth(); this.year = this.date.getFullYear(); (this.stage.container().children[0] as HTMLDivElement).style.background = '#FFF'; const { width, height } = this.stageRect; this.stageHeight = height; this.cellWidth = (width - 40) / 7; this.cellHeight = (height - 60 - 30) / 5; this.registerEvents(); this.draw(); } // 初始绘制 private draw(): this { this.drawCalendar(this.month, this.year); this.drawHoverGroup(); this.drawTaskProgress(); return this; } 绘制日历采用周一至周天的顺序进行绘制,一列七天 一个月显示 5行的形式。这样大概率会出现三个月的时间交叉,所以要以本月为基础同时绘制上月和下月在这个视图中出现的日期 // 绘制日历 private drawCalendar(month: number, year: number): void { // 清空图层 this.layer.removeChildren(); const { firstDay, daysInMonth } = this.getDaysInMonth(month, year); // 绘制当前显示的年份 const headerGroup = new Konva.Group({ name: 'header' }) const yearRect = new Konva.Rect({ x: 0, y: 0, width: this.stage.width(), height: 40, fill: 'white', strokeWidth: 1, }); const yearText = new Konva.Text({ x: 0, y: 10, text: `${year}年${month + 1}月`, fontSize: 20, fontFamily: 'Calibri', fontStyle: 'bold', width: 120, align: 'center', }) headerGroup.add(yearRect, yearText); this.layer.add(headerGroup); // 绘制每个星期的标题 CanvasKonvaCalendar.daysOfWeek.forEach((day, index) = { const backgroudRect = new Konva.Rect({ x: index * this.cellWidth + this.startX, y: 40, width: this.cellWidth, height: 30, fill: 'white', strokeWidth: 1, }) const text = new Konva.Text({ x: index * this.cellWidth + this.startX, y: 50, text: day, fontSize: 13, fontFamily: 'Calibri', // fill: 'black', fill: 'rgba(0,0,0,0.9)', width: this.cellWidth, align: 'center', }); this.layer.add(backgroudRect, text); }); // 计算偏移量 const startOffset = (firstDay.getDay() + 6) % 7; // 计算偏移,周一为0 const lastMonth = month === 0 ? 11 : month - 1; const lastMonthYear = month === 0 ? year - 1 : year; const { daysInMonth: lastDaysInMonth } = this.getDaysInMonth(lastMonth, lastMonthYear); // 渲染上一个月的日期 for (let i = 0; i startOffset; i++) { const day = lastDaysInMonth - startOffset + 1 + i; // 计算上一个月的日期 const x = i * this.cellWidth + this.startX; const id = `${year}-${month}-${(day)}`.replace(/-(\d)-/g, '-0\$1-').replace(/-(\d)$/g, '-0\$1'); const { dayInChinese, monthInChinese } = getChineseCalendar(new Date(id)); const group = new Konva.Group({ name: 'dateCell', id: id, x: x, y: 70 }); const rect = new Konva.Rect({ x: 0, y: 0, width: this.cellWidth, height: this.cellHeight, fill: '#fff', stroke: '#E1E2E3', strokeWidth: 1, }); const text = new Konva.Text({ x: 10, y: 10, text: day.toString(), fontSize: 20, fontFamily: 'Calibri', fill: 'gray', // 用灰色标记上个月的日期 }); const chineseText = new Konva.Text({ x: this.cellWidth - 40, y: 13, text: dayInChinese === '初一' ? `${monthInChinese}月` : dayInChinese, fontSize: 13, fontFamily: 'Calibri', fill: 'rgba(0,0,0,0.4)', }); group.add(rect, text, chineseText); this.layer.add(group); } // 渲染当前月份的日期 for (let i = 0; i daysInMonth; i++) { const x = (i + startOffset) % 7 * this.cellWidth + this.startX; const y = Math.floor((i + startOffset) / 7) * this.cellHeight + 40 + 30; // + cellHeight 为下移一行 const id = `${year}-${month + 1}-${(i + 1)}`.replace(/-(\d)-/g, '-0\$1-').replace(/-(\d)$/g, '-0\$1'); const group = new Konva.Group({ name: 'dateCell', id, x, y }); const { dayInChinese, monthInChinese } = getChineseCalendar(new Date(id)); const activeDate = this.stringDate === id; const rect = new Konva.Rect({ x: 0, y: 0, width: this.cellWidth, height: this.cellHeight, fill: '#fff', stroke: '#EEE', strokeWidth: 1, }); let Circlex = 20; let Circley = 20; let CircleRadius = 13; let fontSize = 20; let textContext = (i + 1).toString(); if (textContext === '1') { textContext = month + 1 + '月' + (i + 1) + '日'; fontSize = 15; CircleRadius = 15 } // 命中当前日期 const circle = new Konva.Circle({ x: Circlex, y: Circley, radius: CircleRadius, fill: activeDate ? '#1f6df6' : '#FFF', stroke: activeDate ? '#1f6df6' : '#FFF', strokeWidth: 1, }); const text = new Konva.Text({ x: textContext?.length 1 ? 10 : 15, y: textContext?.length 1 ? 12 : 10, text: textContext, fontSize: fontSize, fontFamily: 'Calibri', fill: activeDate ? '#FFF' : 'black', width: this.cellWidth - 20, fontStyle: 'bold', // align: 'center', }); // 添加月份名称 const monthText = new Konva.Text({ x: x, y: y + this.cellHeight / 2, // 调整位置以显示月份 text: new Date(year, month).toLocaleString('default', { month: 'long' }), fontSize: 14, fontFamily: 'Calibri', fill: 'black', width: this.cellWidth, align: 'center', }); const chineseText = new Konva.Text({ x: this.cellWidth - 40, y: 13, text: dayInChinese === '初一' ? `${monthInChinese}月` : dayInChinese, fontSize: 13, fontFamily: 'Calibri', fill: 'rgba(0,0,0,0.4)', }); group.add(rect, circle, text, chineseText); const { y: groupY, height } = group.getClientRect(); if (groupY + height this.stageHeight) { this.layer.add(group); group.moveToTop(); } } // 渲染下一个月的日期 const endOffset = (daysInMonth + startOffset) % 7; for (let i = 0; i (7 - endOffset) % 7; i++) { const day = i + 1; // 下个月的日期 const x = (daysInMonth + startOffset + i) % 7 * this.cellWidth + this.startX; const y = Math.floor((daysInMonth + startOffset) / 7) * this.cellHeight + 40 + 30; const id = `${year}-${month + 2}-${(day).toString().padStart(2, '0')}`; const group = new Konva.Group({ name: 'dateCell', id, x, y }); const { dayInChinese, monthInChinese } = getChineseCalendar(new Date(id)); const rect = new Konva.Rect({ x: 0, y: 0, width: this.cellWidth, height: this.cellHeight, fill: '#fff', stroke: '#E1E2E3', strokeWidth: 1, }); const text = new Konva.Text({ x: 10, y: 10, text: day.toString(), fontSize: 24, fontFamily: 'Calibri', // 用灰色标记下个月的日期 fill: 'gray', align: 'center', }); const chineseText = new Konva.Text({ x: this.cellWidth - 40, y: 13, text: dayInChinese === '初一' ? `${monthInChinese}月` : dayInChinese, fontSize: 13, fontFamily: 'Calibri', fill: 'rgba(0,0,0,0.4)', }); group.add(rect, text, chineseText); const { y: groupY, height } = group.getClientRect(); if (groupY + height this.stageHeight) { this.layer.add(group); } } } 着重讲解一下 任务进度的渲染 (最复杂的部分) // 假如任务队列中数据 下面贴出任务的展示形式 taskRanges = { startTime: '2024-10-01', endTime: '2024-10-20', fill: '#fff5cc', description: '1', id: '12345' || uuid() }, 任务进度的渲染使用的 konva.rect, 那么一个 rect 只能表示某一周中的时间范围。而视图中却显示了三个 rect。由此我们能分析出,每一个跨周的时间任务需要切割成一周一周的任务来渲染。由此上面taskRanges就需要被切割。分割成不同的小块 range 后,需要与原始 range 形成关联 ,"origin": "12345"标注出父节点的 id,用于后续修改|拖动|点击 | 查找 | 删除 原始range。具体如何分割的不是重点,后面可以查看 github 源码。 [ { "startTime": "2024-10-01", "endTime": "2024-10-06", "fill": "rgba(0, 0, 255, 0.3)", "description": "3 ", "origin": "12345", "id": "bb47c948-6ab0-4a47-8adf-13f8ed952643", "day": 19 }, { "startTime": "2024-10-07", "endTime": "2024-10-13", "fill": "rgba(0, 0, 255, 0.3)", "description": "3 ", "origin": "12345", "id": "bb47c948-6ab0-4a47-8adf-13f8ed952643", "day": 19 }, { "startTime": "2024-10-14", "endTime": "2024-10-20", "fill": "rgba(0, 0, 255, 0.3)", "description": "3 ", "origin": "12345", "id": "bb47c948-6ab0-4a47-8adf-13f8ed952643", "day": 19 } ] 接下来是循环渲染分割块任务,需要确定任务块的起始坐标和宽度。通过去日历渲染 layer 层查找 range 的起点时间为 id 去 找到对应的 group 就能拿到在画布中的起始坐标 ,宽度很好计算,只需要知道每天占用的宽度 * range 的结束时间 - 开始时间的天数 const width = dayCount * this.cellWidth; // 绘制日历的时候确定好层级关系 Group 包裹 (rect text) const id = `${year}-${month + 1}-${(i + 1)}`.replace(/-(\d)-/g, '-0\$1-').replace(/-(\d)$/g, '-0\$1'); // 用当天的时间作为 id 方便后续查找 const group = new Konva.Group({ name: 'dateCell', id, x, y }); // 查找 const group = this.layer.find(`#${range.startTime}`)[0]; if (!group) return; const groupRect = group.getClientRect(); // 遍历任务数据 expectedResult.forEach((range) = { const startDate = new Date(range.startTime).toISOString().split('T')[0]; const endDate = new Date(range.endTime).toISOString().split('T')[0]; // 计算任务跨越的天数,用于绘制宽度 const dayCount = Math.abs(this.calculateDaysDifference(range.endTime, range.startTime)) + 1; const width = dayCount * this.cellWidth; // 查找任务开始日期的组 const group = this.layer.find(`#${range.startTime}`)[0]; if (!group) return; const groupRect = group.getClientRect(); // 查找合适的 yOffset let yOffset = 0; for (let [offset, dates] of sizeMap) { let overlap = false; // 检查当前 yOffset 是否有日期重叠 for (let dateRange of dates) { if ((startDate = dateRange[0] && startDate = dateRange[1]) || (endDate = dateRange[0] && endDate = dateRange[1]) || (startDate = dateRange[0] && endDate = dateRange[1])) { overlap = true; break; } } // 如果没有重叠,使用当前的 yOffset,并将日期插入该 yOffset 的数组 if (!overlap) { yOffset = offset; dates.push([startDate, endDate]); break; } } // 绘制任务 const rect = new Konva.Rect({ x: groupRect.x + 10, y: groupRect.y + yOffset, width: width - 10, height: 20, fill: range.fill || '#f3d4d4', stroke: range.fill || '#f3d4d4', opacity: 1, strokeWidth: 1, cornerRadius: [3, 3, 3, 3] }); const text = new Konva.Text({ x: groupRect.x + 15, y: groupRect.y + yOffset + 5, text: range.description || '无', fontSize: 12, fill: 'rgba(0,0,0,0.8)' // fill : 'white' }); // 创建 Konva 组并添加任务矩形和文本 const taskProgressGroup = new Konva.Group({ name: `task-progress-group ${range.origin}`, id: range.id, day: range.day }); taskProgressGroup.add(rect, text); this.featureLayer.add(taskProgressGroup); taskProgressGroup.moveToTop(); }); 同一周存在多个时间段的处理 ( 也是有点难度的 ) 有些任务的时间范围跨度比较长,经过的时间多,所以我决定对任务进行时间的先后顺序排序 并且优先绘制时间跨度的长的任务。但是仅仅如此的话 多个任务相交的时间点任务显示会被重叠 所以还要处理任务的 y轴 位置。仔细看下面代码中的注释解释: 假如数据源为 taskRanges =[ { startTime: '2024-10-01', endTime: '2024-10-20', fill: 'rgba(0, 0, 255, 0.3)', description: '3 ', id: uuid() }, { startTime: '2024-10-04', endTime: '2024-10-05', fill: 'pink', description: '2 ', id: uuid() }, { startTime: '2024-10-10', endTime: '2024-10-12', fill: '#caeadb', description: '4', id: uuid() }, { startTime: '2024-10-04', endTime: '2024-10-04', fill: 'rgba(214,241,255,0.6)', description: '555', id: uuid() }, ]; // 在渲染任务进度的代码中 我定义了 // 初始化 sizeMap,用于管理不同 yOffset 对应的日期范围 const sizeMap的作用 假如 range= new Mapnumber, string[][]([ [35, []], [65, []], [95, []], [125, []] ]); 我简单解释下这个sizeMap的作用 假如 range1 = { startTime: '2024-10-01', endTime: '2024-10-04' } 那么在渲染的时候 我会将经过的时间都存储进去 结果就是 range= new Mapnumber, string[][]([ [35, [ '2024-10-01' , '2024-10-02' ,'2024-10-03' ,'2024-10-04' ]], [65, []], [95, []], [125, []] ]); 渲染第二条 range的时候 假如 range2 = { startTime: '2024-10-03', endTime: '2024-10-04' } 我就会先去找到 y 坐标为 35 中是否存在 startTime 如果存在那么就会赋值 y :75 并且得到新的 range= new Mapnumber, string[][]([ [35, [ '2024-10-01' , '2024-10-02' ,'2024-10-03' ,'2024-10-04' ]], [65, [ '2024-10-03' , '2024-10-04'], [95, []], [125, []] ]); 以此类推 多个 range 就算交叉也不会重叠 注释:一个时间的 cell 最多展示 3 个 range 便可, 超过就不要渲染了 可在左下方渲染一个文字提示,这块我目前还没去实现 也不是很复杂。只需要 判断某个range的起点时间 在 rangeMap 中 35 65 95 都存在了 就不渲染了 用户交互部分 拖动调整时间范围 (借助几个事件 )mousedown 确定目标 rang 的信息dragMousemove 将已经渲染的 目标range 设置一个低透明度,并且 this.recordsDragGroupRect.targetGroup!.clone()克隆一个 range 对象,添加到用户交互的layer 涂层上 this.featureLayer.add(this.dragGroup); 只需要控制这个克隆后的 range 在画布中移动的 x y 距离就行。mouseup 还原一些临时数据,和确定时间更变的信息 进行修改 并且发布事件 range 调用者下面的代码中有好几处调用了这个函数 我来解释下通过鼠标的xy 坐标 | 指定 某个 xy 坐标在 哪个layer层去查找 name 的 group mouseup就需要接住这个函数 鼠标抬起 拿到 xy 去判断在当前哪个时间上。 const sorceDate = this.findGroup(this.layer, '.dateCell', { x: this.recordsDragGroupRect.startX, y: this.recordsDragGroupRect.startY }); // 通过鼠标坐标 查找某个图层的元素 private findGroup( layer: Konva.Layer, findkey: string, pointerParam?: Vector2d | null ) { const pointer = pointerParam || this.stage.getPointerPosition()!; const taskGroups = layer.find(findkey) as Konva.Group[]; if (!taskGroups.length) { return; } for (let i = 0; i taskGroups.length; i++) { const group = taskGroups[i]; const rect = group.getClientRect(); if (haveIntersection(rect, pointer)) { return { group, rect, pointer }; } } } private mousedown(): void { if (this.config.mode === 'read') { return; } const result = this.findGroup(this.featureLayer, '.task-progress-group'); if (!result) { return; } const { group, pointer, rect } = result; this.recordsDragGroupRect = { differenceX: pointer.x - rect.x, differenceY: pointer.y - rect.y, sourceX: rect.x, sourceY: rect.y, targetGroup: group, startX: pointer.x, startY: pointer.y } } private dragMousemove(): void { if (this.config.mode === 'read') { return; } if (this.recordsDragGroupRect.differenceX === 0 && this.recordsDragGroupRect.differenceY === 0) { return; } // 拖动中 if (!this.dragGroup) { this.dragGroup = this.recordsDragGroupRect.targetGroup!.clone(); this.recordsDragGroupRect.targetGroup!.opacity(0.3); this.hoverGroup.children[0].setAttr('fill', 'rgba(237,244,255,0.8)'); this.hoverGroup.moveToBottom(); this.featureLayer.add(this.dragGroup); } const pointer = this.stage.getPointerPosition()!; this.dragGroup.setAttrs({ x: pointer.x - this.recordsDragGroupRect.differenceX - this.recordsDragGroupRect.sourceX, y: pointer.y - this.recordsDragGroupRect.differenceY - this.recordsDragGroupRect.sourceY }) } private mouseup(): void { if (this.config.mode === 'read') { return; } if (this.dragGroup) { // this.hoverGroup.children[0].setAttr('fill', 'rgba(0, 0, 0, 0.053)'); // 拖动结束 const sorceDate = this.findGroup(this.layer, '.dateCell', { x: this.recordsDragGroupRect.startX, y: this.recordsDragGroupRect.startY }); const targetDate = this.findGroup(this.layer, '.dateCell'); if (!targetDate || !sorceDate) { return; } const sorceDateId = sorceDate.group.attrs.id; const targetDateId = targetDate.group.attrs.id; // 选择时间相同 if (sorceDateId === targetDateId) { this.recordsDragGroupRect.targetGroup!.opacity(1); } else { console.log('this.recordsDragGroupRect', this.recordsDragGroupRect); const { day = 0, id } = this.recordsDragGroupRect.targetGroup?.attrs const arratItem = this.taskRanges.findIndex((item) = item.id === id); if (arratItem = 0) { const endTime = this.addDays(targetDateId, day); console.log('=====', day, sorceDateId, targetDateId, targetDateId, endTime); this.taskRanges[arratItem].startTime = targetDateId; this.taskRanges[arratItem].endTime = endTime; this.drawTaskProgress(); } } this.dragGroup.remove(); } this.recordsDragGroupRect = { differenceX: 0, differenceY: 0, sourceX: 0, sourceY: 0, startX: 0, startY: 0, targetGroup: null } this.dragGroup = null; } 向外界暴露一些功能 ,还有一些用户交互的代码 比如鼠标移入某个时间可以高亮 | 还能在某个时间点击添加任务。这些不多做赘述 看一后面参考代码还有一些功能 例如拖动延长任务时间什么的 可以在一有代码的参考下 自行实现下。也算是给大家一点挑战性。 //. 更新任务 setData(ranges: Range[]): this { this.taskRanges = ranges; this.draw(); return this; } // 自定义的内部事件派发 事件监听 on(key: EventType, callback: any): this { this.emitter.on(key, callback); return this; } // 将任务表 转成图片 downImage(config: ParametersKonva.Stage['toImage'][number]): void { this.stage.toImage({ pixelRatio: 2, callback: (image) = { const link = document.createElement('a'); link.href = image.src; link.download = 'image.png'; link.click(); }, ...config }) } // 下一个月 nextMonth(): void { this.month++; if (this.month 11) { this.month = 0; this.year++; } this.featureLayer.removeChildren(); this.draw(); } // 今天 today(): void { this.month = new Date().getMonth(); this.year = new Date().getFullYear(); this.featureLayer.removeChildren(); this.draw(); } // 上一个月 prevMonth(): void { this.month--; if (this.month 0) { this.month = 11; this.year--; } this.featureLayer.removeChildren(); this.draw() } 结束语某些逻辑实现可能不是最佳实践,多多包涵。可以等开源后自行修改源码。 目前这个代码我考虑优化后 近几天 开源发布 npm 包。 更新 : npm包 :https://www.npmjs.com/package/konva-calendar github :https://github.com/ayuechuan/konva-calendar 点击关注公众号,“技术干货”及时达! 阅读原文

上一篇:2025-06-13_新来的技术总监,把DDD落地的那叫一个高级优雅! 下一篇:2025-05-07_发现宝藏新搭子!抖音商城解锁音乐节营销新方式

TAG标签:

16
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设网站改版域名注册主机空间手机网站建设网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。
项目经理在线

相关阅读 更多>>

猜您喜欢更多>>

我们已经准备好了,你呢?
2022我们与您携手共赢,为您的企业营销保驾护航!

不达标就退款

高性价比建站

免费网站代备案

1对1原创设计服务

7×24小时售后支持

 

全国免费咨询:

13245491521

业务咨询:13245491521 / 13245491521

节假值班:13245491521()

联系地址:

Copyright © 2019-2025      ICP备案:沪ICP备19027192号-6 法律顾问:律师XXX支持

在线
客服

技术在线服务时间:9:00-20:00

在网站开发,您对接的直接是技术员,而非客服传话!

电话
咨询

13245491521
7*24小时客服热线

13245491521
项目经理手机

微信
咨询

加微信获取报价