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

前言

        目前Web PPT编辑比较好的库有PPTist(PPTist体验地址),是基于DOM 的渲染方案,相比 Canvas 渲染的方案,在复杂场景下性能会存在一定的差距。不过确实已经很不错了,本应用在一些实现思路、难点攻克上也参考了pptist的思想,使用konva进行搭建,大家自行查看哈~

        Konva 是一个HTML5 Canvas JavaScript 框架,它通过对 2d context 的扩展实现了在桌面端和移动端的可交互性。Konva 提供了高性能的动画,补间,节点嵌套,布局,滤镜,缓存,事件绑定(桌面/移动端)等等功能,本应用基于 konva 实现Web PPT 编辑器,以实现设计编辑、预览、切换、动画等核心功能,下图给出系统架构图(参考实现了Canvas-Editor优秀的架构思路):

核心对象

class Pptx {
  public command: Command;
  public eventBus: EventBus<EventBusMap>;
  public register: Register;
  public destroy: () => void;
  constructor(options: IPptxOptions) {
    // 创建 eventbus
    this.eventBus = new EventBus<EventBusMap>();

    // 创建 draw
    const draw = new Draw({ ...options, eventbus: this.eventBus });

    // 创建 command
    this.command = new Command(new CommandAdapt(draw));

    // 创建快捷键
    const shortcut = new ShortCut(this.command);

    // 提供用户注册方法
    this.register = new Register({ shortcut });

    // 提供 destroy 方法
    this.destroy = () => {
      shortcut.removeEvent();
      draw.destroy();
    };
  }
}

        如上架构图,通过 const pptx = new Pptx({...}),获取操作对象,可调用 command API 实现对数据的获取、操作指令等,通过 eventbus 实现对事件的监听、register 注册快捷键等。

页面布局

         用户提供的 container,需要在内部构建TopMenu、SlidePreview、FooterMenu及KonvaBox等结构,创建 konva时,注意宽高比保持 16:9 :

    // 处理宽高(始终保持 16:9 即可)
    const { width, height } = getKonvaBoxSize(konvaBox);
    const stageOption = { container: konvaBox, width, height };
    this.stage = new Konva.Stage(stageOption);

    // 创建默认的幻灯片提示
    const group = new Konva.Group({ x: 0, y: 0 });

    const { width, height } = this.stage.size();

    const { rectoption, textOption } = getDefaultSlideOptions(width, height);

    // 创建矩形
    const rect = new Konva.Rect({ ...rectoption, stroke });
    // 创建文字
    const text = new Konva.Text({ ...textOption, fill });

 Konva 基类设计

        为了满足页面设计中的文本编辑、拖拽缩放等多场景需求,因此,需要重写Konva图形基类,以满足统一的事件处理(就是给每一个基类都添加Group)。

        如上,我们大致采用 layer - group - shape(layer - group - group - shape)的模式,每一个原件都会包裹一个 Group ,通过Group进行统一的事件处理,讲解下大致的原因哈:

 const rect1 = this.konvaGraph.Rect({
      x: 10,
      y: 10,
      width: 100,
      height: 100,
      fill: "red",
      draggable: true,
    });

这里我们创建了一个可拖拽的矩形,双击的时候添加了一个文本:

graph.on("dblclick", (e) => {
      const group = e.target.parent;
      const text = new Konva.Text({ text: "13" });
      group!.add(text);
    });

如果我们采用单独的处理,则会出现如下情况(矩形拖动而文本不跟随):

因此,我们将公共的事件处理,统一封装为 group 即可。

const group = new Konva.Group({ draggable: true });

文本输入

        konva 自身是通过创建 text area实现的:

因此,我们在创建框架结构的时候,就创建一个 contenteditable,避免重复的DOM 操作(不用textarea有自己的考虑哈);

    // 这里还需要创建一个 contenteditable box
    // 多创建一个 div 是为了实现水平垂直居中哈
    const textareaBox = document.createElement("div");
    textareaBox.className = "konva-root-middle-textareaBox";
    const textarea = document.createElement("div");
    textarea.className = "konva-root-middle-textareaBox-textarea";
    textarea.setAttribute("contenteditable", "true");
    textareaBox.appendChild(textarea);
    const konvaSelector = ".konvajs-content";
    const konvaBoxParent = <HTMLDivElement>rootBox.querySelector(konvaSelector);
    konvaBoxParent.appendChild(textareaBox);

图片处理

        konva 的图片创建是基于 Image.onload 事件实现的,我们需要按照这个思路,进行统一处理,同时,还将支持 File | Blob | URL 的图片类型:

  // Image 图片
  public Image(payload: IKonvaImage) {
    return new Promise<Konva.Group>(async (resolve) => {
      // 解析图片资源 File、Blob 均创建 FileReader 读取,string 则默认url
      const source = await getImageSource(payload.source);

      const image = new Image();
      image.src = source;
      // 图片的处理需要基于 image.onload 事件回调
      image.onload = () => {
        const { width, height } = image;
        /**
         * 解析 payload 中的参数对象,
         * 判断 x,y,width,height,
         * 后面的参数会直接覆盖前面,不需要 || 判断
         * 注意参数的顺序!后面的覆盖前面的,因此,Image x,y 都应该是0
         *
         * */
        const groupOption = { x: 0, y: 0, width, height };
        const group = this.getGroup({ ...groupOption, ...payload });
        const result = new Konva.Image({
          width,
          height,
          ...payload,
          image,
          x: 0,
          y: 0,
        });
        this.overwriteGraph(group);
        group.add(result);
        resolve(group);
      };
    });
  }

具体用法如下:


/** 重写 konva image 参数 */
export type IKonvaImage = {
  source: string | File | Blob; // 图片来源
} & Konva.ImageConfig;

// File 类型
const input = document.createElement("input");
input.type = "file";
input.setAttribute("id", "file");
document.querySelector("body")?.appendChild(input);
input.onchange = async (e: Event) => {
    const source = (e.target as HTMLInputElement).files![0];
    const image = await this.konvaGraph.Image({
    source,
    image: undefined,
    });
    console.log(image);
};

// URL 类型
const image = await this.konvaGraph.Image({
    x: 100,
    y: 100,
    width: 100,
    height: 100,
    image: new Image(), // 需要是 Konva.ImageConfig 的类型 CanvasImageSource( HTMLOrSVGImageElement | HTMLVideoElement | HTMLCanvasElement | ImageBitmap | OffscreenCanvas | VideoFrame) | undefined 
    source:
      "https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png",
});

 图层管理

        图层管理的核心,就是对当前layer的管理,创建幻灯片之前,需要将上一个幻灯片的layer 进行缓存,同时,创建当前幻灯片的时候,也要将当前的图层进行缓存;在图形更新之后,调用 render,重新渲染更新图层信息:

private layer!: Konva.Layer; // 始终指向当前编辑区

  /** 添加幻灯片 */
  public addSlide() {
    // 都需要处理背景颜色
    // 创建新幻灯片之前,需要进行图层管理
    this.layerManager.cacheLayer();
    this.clearLayer();

    // 始终指向当前编辑器layer
    this.layer = new Konva.Layer({ id: getUniqueId() });
    const { width, height } = this.stage.size();
    const slideOption = getSlideOptions(width, height);
    const rect = new Konva.Rect({ ...slideOption });
    this.layer.add(rect);
    this.stage.add(this.layer);
    this.render(); // 这个render是缓存当前图层
  }

         创建专门的 layerManager 进行管理,包括缓存、更新、删除、上一个、下一个、指定某一个、获取全部等方法(代码有点多哈,不粘出来了)


/**
 * 图层管理器 - layerList :Konva.Layer[]
 *  1. 添加图层
 *    1.1 新建图层后 addSlide
 *    1.2 更新元素后 render
 *    1.3 新建另一个图层前 addSlide
 *  2. 通过绑定唯一的 layer ID 识别图层
 *    2.1 如果添加已经存在的图层,则更新图层
 *    2.2 如果添加不存在的图层,则添加图层
 *  3. 切换至指定图层
 *    3.1 先清空所有图层
 *    3.2 将指定图层添加至 stage
 *    3.3 重新渲染 stage(重新渲染会重新更新图层)
 */
export class LayerManager {

  private layerList: Konva.Layer[] = [];

}

实现预览 

        预览的核心,就是创建一个 与stage 宽高一致的全屏元素,创建新的 stage ,以图层列表依次进行展示即可:

  /** 预览 - 通过 layerManage 实现 */
  public preview(mode?: PreviewMode) {
    // 如果mode存在,则更新mode
    mode && this.setPreviewMode(mode);
    // 创建容器
    const previewBox = document.createElement("div");
    previewBox.className = "konva-root-preview";
    document.querySelector("body")?.appendChild(previewBox);
    // 设置与stage一致的宽高
    const { width, height } = this.stage.size();
    previewBox.style.width = width + "px";
    previewBox.style.height = height + "px";
    // 进入全屏
    previewBox.requestFullscreen();
  }


export function fullscreenchange(e: Event, draw: Draw) {
  // 监听全屏事件
  const previewBoxSelector = ".konva-root-preview";
  const previewBox = <HTMLDivElement>document.querySelector(previewBoxSelector);
  if (document.fullscreenElement && previewBox) {
    // 如果处于全屏状态,并且全屏元素存在,才能执行预览操作
    const layerList = draw.getLayerManager().getLayerList();
    // 创建新的 stage
    const stage = new Konva.Stage({
      container: previewBox,
      width: window.innerWidth,
      height: window.innerHeight,
    });
    stage.add(layerList[0]);
  } else {
    // 退出全屏后,删除元素
    previewBox?.remove();
    // 恢复默认预览模式
    draw.setPreviewMode(PreviewMode.start);
  }
}

        因为 原来的 layer 是与原来的stage的宽高保持一致的,但是现在全屏预览后,尺寸肯定是比原来的大,因此,需要将 layer 等比放大,需要计算缩放比例:

    const { innerWidth, innerHeight } = window; // 全屏后最佳的预览尺寸
    const { width, height } = draw.getStage().size();

    const scaleX = innerWidth / width;
    const scaleY = innerHeight / height;
    const scale = Math.min(scaleX, scaleY);

    // 被预览元素与全屏最优尺寸一致
    const endWidth = scale * width;
    const endHeight = scale * height;
    previewBox.style.width = endWidth + "px";
    previewBox.style.height = endHeight + "px";

    // 创建新的 stage
    const stage = new Konva.Stage({
      container: previewBox,
      width: endWidth,
      height: endHeight,
    });

    // 为了达到最优的效果,采用最小的缩放比例
    const layerList = draw.getLayerManager().getLayerList();
    const layer = layerList[0].clone().scale({ x: scale, y: scale });
    stage.add(layer);

        预览期间是不能移动元素的哈, 直接 layer.children.forEach(group=>group.draggable=false)即可,这也是我们重写 Konva.Node 的好处。

总结

        初步实现了元素添加、拖拽、缩放,搭建了基本的框架结构,实现了基本的编辑预览功能,下一篇我们重点讲述动画系统实现等其他功能点哈~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值