基于 Konva 实现Web PPT 编辑器(二)

打印 上一主题 下一主题

主题 1625|帖子 1625|积分 4875

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

x
动画体系

        为了实现演示中复杂的动画结果,使用 Animation 类统一管理;切换动画通过 css animation 实现,并且是应用在 konvajs-content 上,动画则通过 gsap 实现,应用在 Konva.Node 上,实现思路如下:
  1. /**
  2. * 动画相关实现
  3. *  1. 切换动画通过 css animation 实现
  4. *      1.1 应用在 konvajs-content 上
  5. *      1.2 对于 animation 来说,动画就是个类名,通过 style 来设置动画
  6. *      1.3 动画结束后,需要移除动画类名,通过 style.animation = '' 实现
  7. *      1.4 动画应用是通过 Layer.setAttrs 记录状态,在预览时,动态添加即可
  8. *
  9. *  2. 元素动画则通过 gsap 库实现,应用在 Konva.Node 上
  10. *      2.1 这里的动画其实是一个时间线,从某个节点状态运行到另一个节点状态
  11. *      2.2 官网: https://gsap.com/docs/v3/
  12. *      2.3 官网上提供了很多动画,这里只使用 gsap 的 timeline 来实现动画
  13. *      2.4 同样需要记录 动画状态,在预览时,通过动画状态来应用动画;
  14. *
  15. *  3. 切换应用动画的流程:
  16. *      3.1 调用 setAnimation 给当前layer标记动画属性;
  17. *      3.2 在预览时 调用 applyAnimation 应用动画;
  18. *      3.3 动画结束后,调用clearTimeOut清除动画
  19. *      3.4 如果是应用全部动画的话,通过 global 标识,每次新增幻灯片,都判断有没有全局动画,如果有,则应用全局动画
  20. */
复制代码

        应用到实例上结果如下:

        经过研究探究,发现原生的 Konva.Tween 难以满足应用中复杂的强调、入场、退场动画,对节点的属性控制、播放节点控制能力较弱,而Konva.Animation 则是通过request Animation实现的动画,对时间的控制不够精确,整个属性过渡的结果需要自行实现,因此,决定接纳 gsap 动画实现。gasp 官网​​​​​​​ 具体的API我就不先容了哈,大家自行前往官网检察,我就举个例子说明项目中的应用:
  1. const timeline = gsap.timeline();
  2. timeline.pause();
  3. // 淡入
  4. [KonvaAnimationMap.fadeIn]: () => {
  5.   const tween = gsap.fromTo(
  6.     node,
  7.     {
  8.       opacity: 0,
  9.       scaleX: 0,
  10.       scaleY: 0,
  11.       x: node.x() + node.width() / 2,
  12.       y: node.y() + node.height() / 2,
  13.     },
  14.     {
  15.       opacity: 1,
  16.       scaleX: 1,
  17.       scaleY: 1,
  18.       x: node.x(),
  19.       y: node.y(),
  20.       onComplete,
  21.     }
  22.   );
  23.   timeline.add(tween);
  24.   return timeline;
  25. }
复制代码
        这里只是创建了补间动画实时间轴,并且默认是停息状态,因为幻灯片中的元素动画应用是有’单击时‘、’上个动画同时‘、’上个动画结束‘,因此,将动画对象返回,根据合适的时机进行执行是最合适的(这里仅是对动画进行应用展示哈,后续的如安在预览时,根据时机播放,我们放在预览中说明


对齐辅助线

        我们参考官网的实现案例:How to snap canvas shape to other shapes,通过监听 dragmove dragend 来实现对齐辅助线:


        大抵思路就是取拖动的六条线坐标,与当前画布节点进行比较,看是否有满足条件的节点,绘制直线即可,konva中的宽高在缩放后是不会改变的,因此,需要取其缩放比例,手动盘算现实的宽高:
  1.   // 元素真实的宽高与缩放比例也有关系
  2.   const node = e.target;
  3.   const scaleX = node.scaleX() || 1;
  4.   const scaleY = node.scaleY() || 1;
  5.   const x = node.x();
  6.   const y = node.y();
  7.   const width = node.width() * scaleX;
  8.   const height = node.height() * scaleY;
复制代码
 

        而磁吸结果实现的焦点,就是在一定范围内,主动修正节点位置:

单选、多选、框选

        单选通过监听 group.click 事件实现,多选就是用户按住 Ctrl Shift 键的时候,不清空之前的形变节点即可。
  1. // 左键选中元素
  2.   if (e.evt.button === 0) {
  3.     const layer = draw.getLayer();
  4.     if (!layer) return;
  5.     const group = e.target.parent!;
  6.     const { shiftKey, ctrlKey } = e.evt;
  7.     // 是否多选
  8.     if (shiftKey || ctrlKey) {
  9.       // 标记激活状态
  10.       group.setAttr("selected", true);
  11.       groupMultiple(e, draw);
  12.       return;
  13.     }
  14.     // 清空所有的形变 包括取消选中节点属性
  15.     draw.clearTransformer({ selected: true });
  16.     // 标记激活状态
  17.     group.setAttr("selected", true);
  18.     // 为当前节点创建形变节点
  19.     groupTransformer(draw, group);
  20.   }
复制代码
通过创建的 Konva.Transformer ,会主动识别宽高尺寸,旋转角度等,不需要我们处置惩罚:
 

        框选实现,则通过监听鼠标 mousedown mousemove mouseup 事件,绘制合适的选区,盘算位置关系,得出被框选的元素:
  1. // mousedown
  2. //   记录初始位置
  3. const ssx = e.evt.offsetX;
  4. const ssy = e.evt.offsetY;
  5. stage.setAttrs({
  6.   selecting: true,
  7.   ssx, // stage start x
  8.   ssy, // stage start y
  9. });
  10. const selectBoxCss = ".konva-root-container-frame_selected";
  11. const selectBox = root.querySelector(selectBoxCss) as HTMLDivElement;
  12. selectBox.style.left = ssx + "px";
  13. selectBox.style.top = ssy + "px";
复制代码
  1. // mousemove
  2. // 解析位置
  3. const { offsetX, offsetY } = e.evt;
  4. //   计算偏移量
  5. const dx = offsetX - ssx;
  6. const dy = offsetY - ssy;
  7. const selectBoxCss = ".konva-root-container-frame_selected";
  8. const selectBox = root.querySelector(selectBoxCss) as HTMLDivElement;
  9. if (dx > 0) selectBox.style.width = dx + "px";
  10. else {
  11.   // 如果小于 0 则反向框选,需要修改 left width
  12.   selectBox.style.left = offsetX + "px";
  13.   selectBox.style.width = Math.abs(dx) + "px";
  14. }
  15. if (dy > 0) selectBox.style.height = dy + "px";
  16. else {
  17.   selectBox.style.top = offsetY + "px";
  18.   selectBox.style.height = Math.abs(dy) + "px";
  19. }
复制代码
  1. // mouseup
  2. stage.setAttrs({
  3.   selecting: null,
  4.   ssx: null,
  5.   ssy: null,
  6. });
  7. const selectBoxCss = ".konva-root-container-frame_selected";
  8. const selectBox = root.querySelector(selectBoxCss) as HTMLDivElement;
  9. selectBox.style.top = "0px";
  10. selectBox.style.left = "0px";
  11. selectBox.style.width = "0";
  12. selectBox.style.height = "0";
复制代码


         框选中判断节点是否被选中实现如下,则直接使用 Konva.Util.haveIntersection 工具函数即可:
  1. /**
  2. * 判断节点是否在范围内
  3. * @param node 传入需要判断的Konva节点
  4. * @param range 传入范围 {x,y,width,height}
  5. */
  6. export function haveInterSection(
  7.   node: Konva.Node,
  8.   range: { x: number; y: number; width: number; height: number }
  9. ) {
  10.   return Konva.Util.haveIntersection(
  11.     { x: node.x(), y: node.y(), width: node.width(), height: node.height() },
  12.     range
  13.   );
  14. }
  15. // mouseup 判断节点是否选中
  16. layer.find(".konva-base-group").filter((node) => {
  17.       const range = { x: left, y: top, width, height };
  18.       const isIntersection = haveInterSection(node, range);
  19.       if (isIntersection) {
  20.         // 给节点添加selected状态
  21.         node.setAttrs({ selected: true });
  22.         // 选中节点
  23.         groupTransformer(draw, node);
  24.       }
  25.     });
复制代码

拓展konva类型

图片

        图片的处置惩罚上篇已经讲述过实现原理了哈,这里不再赘述;
媒体资源

        媒体资源实现的方案是Konva.Image 实现的,因为Image的image属性支持类型如下,可参考官网案例:How to display video on Canvas:


表格

        表格实现是基于Konva自界说图形,通过界说形状绘制方法与事件相应范围来实现:
  1. // table - 表格
  2.   public Table(payload: IKonvaTableConfig & Konva.ShapeConfig) {
  3.     const group = this.getGroup(payload);
  4.     const data = [
  5.       ["Name", "Age", "Gender"],
  6.       ["Alice", 25, "Female"],
  7.       ["Bob", 30, "Male"],
  8.       ["Charlie", 35, "Male"],
  9.       ["Diana", 28, "Female"],
  10.     ];
  11.     const columnWidths = [100, 100, 100]; // 列宽
  12.     const rowHeight = 30; // 行高
  13.     const width = 300;
  14.     const height = 150;
  15.     // 自定义表格
  16.     var table = new Konva.Shape({
  17.       ...payload,
  18.       width,
  19.       height,
  20.       fill: "#fff",
  21.       stroke: "#000",
  22.       /**
  23.        * 定义绘制方法 数据属性放置在 attrs 上,调用 render 即可重新渲染
  24.        * @param ctx
  25.        * @param shape
  26.        */
  27.       sceneFunc: (ctx, shape) => {
  28.         ctx.beginPath();
  29.         for (let i = 0; i < data.length; i++) {
  30.           for (let j = 0; j < data[i].length; j++) {
  31.             const cellX = 100 * j;
  32.             const cellY = i * rowHeight;
  33.             const cellWidth = columnWidths[j];
  34.             const cellHeight = rowHeight;
  35.             // 绘制单元格边框 - 奇偶行实现斑马纹
  36.             ctx.fillStyle = i % 2 ? "#fafafa" : "#fff";
  37.             if (i === 0) ctx.fillStyle = "rgba(0,0,0,0.2)";
  38.             ctx.fillRect(cellX, cellY, cellWidth, cellHeight);
  39.             // 是否启用边框
  40.             ctx.strokeRect(cellX, cellY, cellWidth, cellHeight);
  41.             // 绘制单元格内容;
  42.             ctx.fillStyle = "#000"; // 设置文本颜色为黑色
  43.             ctx.font = "14px Arial"; // 设置字体样式
  44.             ctx.fillText(data[i][j].toString(), cellX + 5, cellY + 20); // 绘制文本内容
  45.           }
  46.         }
  47.         ctx.fillStrokeShape(shape); // (!) Konva specific method, it is very important
  48.       },
  49.       // 绘制事件响应区域
  50.       hitFunc: function (ctx, shape) {
  51.         ctx.beginPath();
  52.         ctx.rect(0, 0, width, height);
  53.         ctx.closePath();
  54.         ctx.fillStrokeShape(shape);
  55.       },
  56.     });
  57.     // 处理事件 - 双击进行数据编辑
  58.     group.on("dblclick", () => {
  59.       console.log("table dblclick");
  60.     });
  61.     group.add(table);
  62.     this.overwriteGraph(group);
  63.     return group;
  64.   }
复制代码

表格的更新,通过自界说属性,记录初始数据,动态生成table,确认后再转为 shape 的属性:

统计图

        本应用使用的统计图较为简单,就不引入其他库了,手动绘制实现,照旧通过 Konva.Shaph 自界说图形哈,具体的绘制过程就不展示了,就是基础的canvas画图能力。





文本

        文本这里的处置惩罚有一个难点哈,也是前期的一个坑,如今来填一下,在我们最初设计的时候,文本是跟随group一起移动的对吧,但是缩放也会跟着缩放!如下:

        但是,WPS 的结果可不是如许的哦:

        因此,实现文本自适应是最紧张的!网上查阅了相干资料,都没有好的办法处置惩罚,都说需要动态设置文本的缩放比例,太过复杂! 既然是缩放过程中文本被迫缩放了,那么,不让他跟随缩放不就完了嘛



        大家要明白这里面的层级关系哈:外层 Group 负责draggable,Rect 负责缩放,文本才能自适应.
公式

        公式的实现比较难,大家有什么好的库推荐下,这里使用的是 mathlive,很多的插件,都是提供 latex 编辑器,然后提供预览功能,其实如许是非常难用的,我们无法直观的检察及编辑公式内容;而 mathlive 则可以或许直接提供直观的公式编辑页面:

mathLive功能演示:

        但是目前没有比较美满的文章将mathlive的使用、导出等功能进行讲解,只能自我探索,边看文档边实践;同时,本文也不重点关注这块,如果感兴趣,后期可以出一篇文章讲解mathlive 的使用哈,我只重点讲述遇到的难点:
隐藏菜单、键盘:
        我是不使用默认的功能哈,公式的插入手动实现,当然,大家也可以直接使用默认功能。
  1. /** 隐藏公示栏的菜单及键盘按钮 **/
  2. @media not (pointer: coarse) {
  3.     math-field::part(virtual-keyboard-toggle) {
  4.         display: none;
  5.     }
  6.     math-field::part(menu-toggle) {
  7.         display: none;
  8.     }
  9. }
复制代码
引入样式文件:

转成 html:
        这里是为了转图片做准备的哈
  1. const html= convertLatexToMarkup(mfe.value);
复制代码
转图片:
        这里使用的库是mathlive官网使用的:html-to-image :
  1. toPng(div).then(function (dataUrl) {
  2.       /* do something */
  3.       console.log(dataUrl);
  4.     });
复制代码
插入公式:
        官网给我们提供了 command 命令,可以执行一些常用的操作,比例插入,撤销重做等。同时,还提供了全部 LaTex command,将常见的公式也封装了,只需要执行相应的命令,就可以插入公式:

        比如如今要插入(根号3):
  1. mfe.executeCommand("insert", "\\sqrt{3}");
复制代码
 
注意:一定是 \ 转义一次哈,不然得到的可就不是 根号3 而是字符串了

 
结果如下:


历史记录

        历史记录的实现,大家可以参考官网给出的案例,在大型应用中,如果直接使用 toJSON 生存历史记录的话,会丢失很多关键信息,比方事件、过滤器等,因此我们不适用json生存。

        看了官网的案例,是通过生存整个stage,然后撤销重做时,重新进行事件绑定,这种方案在我们的应用中,也是不可取的。我们封装了group ,为group 添加了这么多事件,如果都重写,那工作量可多了。

       【办理方案】:在图层管理中,缓存的是layer数据,layer是可以直接进行赋值操作,事件也不会丢失,因此,接纳layer进行历史记录缓存。
  1.   // undo 栈
  2.   private undoStack: Konva.Layer[] = [];
  3.   // redo 栈
  4.   private redoStack: Konva.Layer[] = [];
  5.   // 最大历史记录数
  6.   private maxRecordCount = maxRecordCount;
复制代码
  1.   /** 添加记录 */
  2.   public patchHistory() {
  3.     const layer = this.draw.getLayer()?.clone()!;
  4.     // 当前图层的JSON串 - 不直接使用 toJSON(),避免后续修改
  5.     const layerJson = JSON.stringify(layer?.toObject());
  6.     // 被添加图层与最后缓存的记录是否一致
  7.     const lastLayer = this.undoStack[this.undoStack.length - 1];
  8.     const lastLayerJson = JSON.stringify(lastLayer?.toObject());
  9.     if (layerJson === lastLayerJson) return console.error("记录已存在");
  10.     layer.find("Transformer").forEach((tr) => tr.destroy());
  11.     // 不然直接添加到 undoStack
  12.     console.log("patchHistory");
  13.     this.undoStack.push(layer);
  14.     // 如果记录数大于 maxRecordCount,则删除最前的记录
  15.     while (this.undoStack.length > this.maxRecordCount) this.undoStack.shift();
  16.   }
复制代码
  1.   // 撤销动作
  2.   public undo() {
  3.     // 需要保留背景图层,因此 撤销栈不能为0
  4.     if (this.undoStack.length <= 1) return console.log("不可撤销");
  5.     // 获取当前图层,放到 redoStack,并删除 undoStack
  6.     const layer = this.undoStack.pop()!;
  7.     this.redoStack.push(layer);
  8.     // 重新渲染图层
  9.     this.render();
  10.   }
复制代码
  1.   // 重做动作
  2.   public redo() {
  3.     if (this.redoStack.length <= 0) return console.log("不可重做");
  4.     // 获取当前图层,放到 undoStack,并删除 redoStack
  5.     const layer = this.redoStack.pop()!.clone();
  6.     this.undoStack.push(layer.clone());
  7.     // 重新渲染图层
  8.     this.render();
  9.   }
复制代码
  1.   /** 重新渲染图层 */
  2.   private render() {
  3.     // 取出上一次的图层,并添加到当前图层
  4.     const lastLayer = this.undoStack[this.undoStack.length - 1];
  5.     const layerClone = lastLayer.clone(); // 这里一定要 clone 避免图层引用导致的历史记录异常问题
  6.     // 进行图层更新
  7.     this.draw.clearLayer(); // 删除layer
  8.     this.draw.setLayer(layerClone); // 修正 layer
  9.     this.draw.getStage().add(layerClone);
  10.     this.draw.render({ patchHistory: false });
  11.   }
复制代码

总结

        经过这么多天的沉淀,如今已经大体实现了幻灯片新增、删除,支持元素-矩形、箭头、文字、统计图、表格、图片多媒体、公式类型的新增删除编辑操作;实现了基本的动画体系、历史记录管理器。
        下一步将是对预览体系进行美满,并思量预览中的动画播放题目,同时,还将提供设计功能,为整个幻灯片提供基础的配色方案及预设功能;将美满相干功能,优化用户体验。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

小秦哥

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表