前言
目前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 的好处。
总结
初步实现了元素添加、拖拽、缩放,搭建了基本的框架结构,实现了基本的编辑预览功能,下一篇我们重点讲述动画系统实现等其他功能点哈~