AI应用越来越多,为了实现更好地实现打字机效果,尤其是输出对象为富文本的场景,特模仿element-plus-x的typewriter的能力,实现相关功能,其中forCKEditor5.ts 是针对输出对象为CKEditor5富文本的情景,forDom.ts是针对输出对象为普通元素(如div)的情景。
特点:
1、两种方案基于typescript实现,可用于react、vue等框架中。
2、属性有:
- container:定义输出对象,在forDom.ts中是html元素,如某个div元素;在forCKEditor5.ts中是
CKEditor对象 - mode:定义输入类型,支持纯文本(text)、markdwon和html字符串
- interval:每次打字的间隔时间 单位( ms )
- step:每次打字吐多少字符
- cursor:
仅forDomts支持,定义末尾光标,默认为"|" - doneMarker和doneMarker:doneMarker用于定义结束符(默认为[DONE]),当检测到结束符后,听停止输出并隐藏光标,detectDoneMarker定义是否开启检测
3、方法有:
- append:核心防范,用于追加内容,支持链式操作
- pause:暂停输出
- resume: 恢复输出
- stop:停止打字
- destroy:销毁操作
- clear:清空内容
- showCursor/hideCursor:
仅forDomts支持,显示/隐藏末尾光标(cursor定义末尾光标) - onComplete:
仅forCKEditor5.ts支持,用于传递回调,在完成输出时调用
4、简单示例
go
// forCKEditor5.ts的使用示例
const typewriter = new CKEditorTypewriterEngine({
editor, // 某CKEditor5实例
mode: 'html',
interval: 40,
step: 2,
detectDoneMarker: true,
doneMarker: '[done]'
});
typewriter.append(content).append('[done]');
typewriter.onComplete(() => {
// 处理一些逻辑
})
arduino
// forDom.ts的使用示例
const tw = new TypewriterEngine({
container: typeWiriterRef.current!,
mode: 'markdown',
interval: 40,
step: 2,
cursor: '|',
detectDoneMarker: true,
doneMarker: '[done]'
});
const timeout = setTimeout(() => {
tw.append('# 关于中国苹果产业发展的调研报告\n\n**报送单位:农业农村部发展规划司** \n**报送时间:xxxx年xx月xx日**\n\n为全面掌握我国苹果产业发展现状,深入分析存在问题与发展趋势,进一步优化产业布局、提升产品质量、增强市场竞争力,我司组织开展了全国苹果产业专题调研工作。现将有关情况报告如下:\n\n---\n\n## 一、产业发展总体情况\n\n近年来,我国苹果产业稳步发展,种植面积和产量持续增长,已成为全球最大的苹果生产国和消费国。2023年数据显示,全国苹果种植面积约为**4500万亩**,总产量达到**4800万吨**,占全球总产量的**55%以上**。主要产区集中在陕西、山东、甘肃、河南、山西等省份。\n\n| 地区 | 种植面积(万亩) | 年产量(万吨) | 占全国比重 |\n|------|------------------|----------------|-------------|\n| 陕西省 | 1200 | 1200 | 25% |\n| 山东省 | 900 | 1000 | 20.8% |\n| 甘肃省 | 600 | 700 | 14.6% |\n| 河南省 | 500 | 550 | 11.5% |\n| 山西省 | 400 | 450 | 9.4% |\n| 其他地区 | 900 | 900 | 18.7% |\n\n从品种结构来看,富士系苹果占据主导地位,占比超过**70%**,其他如嘎啦、金帅、秦冠等传统品种逐步被优质高产新品种替代。\n\n---\n\n## 二、产业发展中存在的问题\n\n### (一)产业结构不合理,区域集中度高\n\n苹果主产区高度集中于黄土高原及华北平原地区,易受气候、病虫害等自然因素影响,抗风险能力较弱。部分老果园老化严重,缺乏更新换代机制,导致品质下降、产量波动大。\n\n### (二)产业链条短,加工转化率低\n\n目前,我国苹果主要用于鲜果销售,深加工比例较低。2023年数据显示,苹果加工转化率仅为**12%**,远低于发达国家**30%以上**的平均水平。果汁、果干、果酒等产品仍处于起步阶段,品牌化程度不高。\n\n### (三)销售渠道单一,流通体系不健全\n\n苹果销售以批发市场为主,电商渠道发展较快但尚未形成规模效应。冷链物流体系建设滞后,部分地区存在"卖难""烂市"现象,价格波动频繁,农民收入不稳定。\n\n### (四)科技创新能力不足,标准化水平偏低\n\n尽管我国在苹果育种、栽培技术等方面取得一定进展,但整体科技支撑能力仍显薄弱,优质新品种推广速度慢,标准化生产覆盖率不足**30%**,制约了产业高质量发展。\n\n---\n\n## 三、典型地区调研情况\n\n### (一)陕西省渭南市\n\n渭南市是全国重要的苹果生产基地之一,种植面积达**200万亩**,年产量**250万吨**。该市通过"龙头企业+合作社+农户"的模式推动产业升级,建设高标准示范园,引进优良品种,并建立苹果质量追溯系统,有效提升了产品质量和市场竞争力。\n\n### (二)山东省烟台市\n\n烟台市拥有百年苹果种植历史,是中国苹果出口的重要口岸。近年来,烟台市依托地理标志保护,打造"烟台苹果"区域公用品牌,积极拓展国际市场。2023年出口量达**45万吨**,占全国出口总量的**25%**。\n\n### (三)甘肃省庆阳市\n\n庆阳市地处黄土高原腹地,光照充足、昼夜温差大,适宜苹果生长。当地政府出台多项扶持政策,推动苹果产业规模化、集约化发展,苹果种植面积达**300万亩**,年产值超**50亿元**。但仍面临基础设施薄弱、人才短缺等问题。\n\n---\n\n## 四、对策建议\n\n### (一)优化产业布局,推进结构调整\n\n科学规划产区布局,引导优势产区适度扩张,非优势产区有序退出,鼓励发展山地、丘陵等地块种植苹果,提高土地利用效率。\n\n### (二)加强科技创新,提升标准化水平\n\n加大科研投入,加快新品种选育和配套栽培技术推广,推动苹果种植标准化、机械化、智能化发展,提高生产效率和果实品质。\n\n### (三)完善加工体系,延伸产业链条\n\n支持建设苹果深加工项目,重点发展果汁、果醋、果酒等产品,培育一批具有自主知识产权的品牌企业,提升附加值和市场占有率。\n\n### (四)健全流通体系,拓宽销售渠道\n\n加快推进冷链物流体系建设,鼓励发展电商、社区团购等新型销售模式,构建覆盖全国、连接国际的现代苹果流通网络,增强产业抗风险能力。\n\n### (五)强化政策扶持,保障农民利益\n\n加大对苹果产业的财政、金融支持力度,完善农业保险制度,建立健全产销对接机制,确保农民稳定增收。\n\n---\n\n## 附件\n\n1. 中国苹果产业主要产区分布图 \n2. 2023年全国苹果产量及结构统计表 \n3. 典型地区苹果产业发展情况汇总表 \n\n---\n\n**联系人:XXX** \n**联系电话:XXXX-XXXXXXXX** \n**电子邮箱:XXXX@agri.gov.cn**\n\n农业农村部发展规划司 \nxxxx年xx月xx日');
tw.append('[done]');
}, 1000);
一、forCKEditor5.ts
kotlin
// forCKEditor5.ts
import { marked } from 'marked';
// import type { Editor, ViewDocumentFragment, ViewElement, ViewNode, ViewText } from 'ckeditor5';
import type { Editor, ModelElement, ModelText, ModelRootElement, ModelDocumentFragment as ModelFragment } from 'ckeditor5';
type TypewriterMode = 'text' | 'markdown' | 'html';
interface CKEditorTypewriterOptions {
container: Editor;
mode?: TypewriterMode;
interval?: number; // ms
step?: number;
detectDoneMarker?: boolean;
doneMarker?: string;
}
interface QueueItem {
node: ModelText | ModelElement;
parent: ModelElement | ModelFragment;
offset?: number; // 对文本节点,表示已输出长度
}
export class CKEditorTypewriterEngine {
private container: Editor;
private mode: TypewriterMode;
private interval: number;
private step: number;
private queue: QueueItem[] = [];
private timer: number | null = null;
private isPaused = false;
private detectDoneMarker: boolean;
private doneMarker: string;
private isCompleted = false;
private onCompleteCallback?: () => void;
constructor(options: CKEditorTypewriterOptions) {
// 检查编辑器上是否已有打字机实例
const existingInstance = (options.container as any).__typewriterInstance;
if (existingInstance) {
existingInstance.destroy();
}
// 将当前实例存储在编辑器上
(options.container as any).__typewriterInstance = this;
this.container = options.container;
this.mode = options.mode ?? 'html';
this.interval = options.interval ?? 40;
this.step = options.step ?? 1;
this.detectDoneMarker = options.detectDoneMarker ?? false;
this.doneMarker = options.doneMarker ?? '[DONE]';
}
/** 简单 Markdown 转 HTML */
private markdownToHtml(md: string): string {
return marked.parse(md) as string;
}
/** 将 HTML / Markdown / Text 转成模型队列 */
public append(input: string): CKEditorTypewriterEngine {
const pos = this.container?.model?.document?.selection?.getFirstPosition();
if (!pos) {
return this;
}
let processedInput = input;
let shouldHideCursorAfter = false;
// 检测并处理 doneMarker
if (this.detectDoneMarker) {
const doneIndex = processedInput.indexOf(this.doneMarker);
if (doneIndex !== -1) {
processedInput = processedInput.substring(0, doneIndex);
shouldHideCursorAfter = true;
}
}
let html = processedInput;
if (this.mode === 'text') {
html = this.escapeHtml(processedInput);
} else if (this.mode === 'markdown') {
html = this.markdownToHtml(processedInput);
}
// 将 HTML 转到到模型 fragment
const modelFragment: ModelFragment = this.container.data.parse(html);
// 将 fragment 的顶层子节点压入队列
this.enqueueFragment(modelFragment, pos.parent);
// 启动打字机
if (!this.timer) this.start();
// 完成后隐藏光标
if (shouldHideCursorAfter) {
const checkCompletion = () => {
if (!this.queue.length && !this.timer) {
this.isCompleted = true;
this.onCompleteCallback?.();
} else {
requestAnimationFrame(checkCompletion);
}
};
checkCompletion();
}
return this;
}
/** 将 fragment 子节点压入队列,保留父引用 */
private enqueueFragment(frag: ModelFragment | ModelElement, parent: ModelElement | ModelFragment) {
for (const child of frag.getChildren()) {
this.queue.push({ node: child as ModelText | ModelElement, parent });
}
}
/** 启动定时器 */
private start() {
if (this.timer) return;
this.isPaused = false;
this.timer = window.setInterval(() => {
if (this.isPaused) return;
this.processNext();
}, this.interval);
}
/** 逐步处理队列 */
private processNext() {
if (!this.queue.length) {
this.stop();
return;
}
const item = this.queue[0];
this.container.model.change((writer) => {
if (item.node.is('$text')) {
const textNode = item.node as ModelText;
const offset = item.offset ?? 0;
const chunk = textNode.data.slice(offset, offset + this.step);
if (chunk) {
// 获取textNode的属性
const textAttributes = textNode.getAttributes();
// 插入文本并保留原有属性
writer.insertText(chunk, textAttributes, item.parent as ModelElement, 'end');
item.offset = offset + chunk.length;
} else {
this.queue.shift();
}
} else if (item.node.is('element')) {
const el = item.node as ModelElement;
// 克隆元素但不带子节点
const clone = writer.createElement(el.name, el.getAttributes());
writer.insert(clone, item.parent, 'end');
this.queue.shift();
// 将子节点压入队列,父节点为 clone
for (const child of Array.from(el.getChildren()).reverse()) {
this.queue.unshift({ node: child as ModelText | ModelElement, parent: clone });
}
} else {
this.queue.shift();
}
// 光标移到最后
writer.setSelection(this.container.model.document.getRoot() as ModelRootElement, 'end');
});
// 滚动到编辑器底部,确保新内容可见
if (!this.isCompleted) {
this.container.editing.view.scrollToTheSelection();
}
}
/** 暂停 */
public pause() {
this.isPaused = true;
}
/** 恢复 */
public resume() {
this.isPaused = false;
}
/** 设置完成回调函数 */
public onComplete(callback: () => void): CKEditorTypewriterEngine {
this.onCompleteCallback = callback;
return this;
}
/** 停止打字机 */
public stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.queue = [];
// 解决输出后,点击输出内容任意位置,光标会被强制跳转到末尾的问题(比较hack)
this.container.setData(this.container.getData() + '');
}
/** 销毁 */
public destroy() {
this.stop();
this.clear();
// 从编辑器上移除实例引用
delete (this.containeras any).__typewriterInstance;
}
public clear() {
// 通过model方式清空container的内容
this.container.model.change((writer) => {
const root = this.container.model.document.getRoot() as ModelRootElement;
const range = writer.createRangeIn(root);
// 不要直接remove(root),这样会破坏结构
writer.remove(range);
});
this.queue = [];
}
/** 简单转义 HTML */
private escapeHtml(text: string) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
二、forDom.ts
kotlin
// forDom.ts
import { marked } from 'marked';
type TypewriterMode = 'text' | 'markdown' | 'html';
interface TypewriterOptions {
container: HTMLElement;
mode?: TypewriterMode;
interval?: number; // ms
step?: number;
cursor?: string;
detectDoneMarker?: boolean;
doneMarker?: string;
}
interface QueueItem {
node: Node;
parent: HTMLElement;
offset?: number; // 对文本节点,表示已输出长度
}
export class TypewriterEngine {
private container: HTMLElement;
private mode: TypewriterMode;
private interval: number;
private step: number;
private cursorChar: string;
private detectDoneMarker: boolean;
private doneMarker: string;
private textQueue: string[] = [];
private htmlQueue: QueueItem[] = [];
private timer: number | null = null;
private isPaused = false;
private cursorEl: HTMLElement;
constructor(options: TypewriterOptions) {
this.container = options.container;
this.mode = options.mode ?? 'text';
this.interval = options.interval ?? 40;
this.step = options.step ?? 1;
this.cursorChar = options.cursor ?? '|';
this.detectDoneMarker = options.detectDoneMarker ?? false;
this.doneMarker = options.doneMarker ?? '[DONE]';
// 检查容器是否已有打字机实例,若有则销毁
const existingInstance = (this.container as any).__typewriterInstance;
if (existingInstance) {
existingInstance.destroy();
}
// 将当前实例存储在容器上
(this.container as any).__typewriterInstance = this;
// 清除容器现有内容
while (this.container.firstChild) {
this.container.removeChild(this.container.firstChild);
}
// 初始化光标
this.cursorEl = document.createElement('span');
this.cursorEl.className = 'tw-cursor';
this.cursorEl.textContent = this.cursorChar;
this.container.appendChild(this.cursorEl);
this.startCursorBlink();
}
/** 光标闪烁 */
private startCursorBlink() {
setInterval(() => {
this.cursorEl.style.visibility =
this.cursorEl.style.visibility === 'hidden' ? 'visible' : 'hidden';
}, 500);
}
/** 简单 Markdown 转 HTML */
private markdownToHtml(md: string): string {
return marked.parse(md) as string;
}
/** 追加内容 */
public append(input: string): TypewriterEngine {
let processedInput = input;
let shouldHideCursorAfter = false;
// 检测并处理 doneMarker
if (this.detectDoneMarker) {
const doneIndex = processedInput.indexOf(this.doneMarker);
if (doneIndex !== -1) {
processedInput = processedInput.substring(0, doneIndex);
shouldHideCursorAfter = true;
}
}
if (this.mode === 'text') {
const html = this.escapeHtml(processedInput);
this.textQueue.push(html);
} else if (this.mode === 'markdown') {
const html = this.markdownToHtml(processedInput);
const fragment = this.parseHTML(html);
this.enqueueFragment(fragment, this.container);
} else {
const fragment = this.parseHTML(processedInput);
this.enqueueFragment(fragment, this.container);
}
if (!this.timer) this.start();
// 完成后隐藏光标
if (shouldHideCursorAfter) {
const checkCompletion = () => {
if (!this.textQueue.length && !this.htmlQueue.length && !this.timer) {
this.hideCursor();
} else {
requestAnimationFrame(checkCompletion);
}
};
checkCompletion();
}
return this;
}
/** HTML 字符串解析成 fragment */
private parseHTML(html: string): DocumentFragment {
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
const frag = document.createDocumentFragment();
while (doc.body.firstChild) frag.appendChild(doc.body.firstChild);
return frag;
}
/** 将 fragment 压入 htmlQueue */
private enqueueFragment(frag: DocumentFragment, parent: HTMLElement) {
frag.childNodes.forEach((child) => {
this.htmlQueue.push({ node: child, parent });
});
}
/** 启动打字 */
private start() {
if (this.timer) return;
this.isPaused = false;
this.timer = window.setInterval(() => {
if (this.isPaused) return;
if (this.mode === 'text') {
this.processTextMode();
} else {
this.processHtmlMode();
}
}, this.interval);
}
/** 转义HTML字符 */
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/** 文本/Markdown模式处理 */
private processTextMode() {
if (!this.textQueue.length) {
this.stop();
return;
}
const current = this.textQueue[0];
const chunk = current.slice(0, this.step);
this.textQueue[0] = current.slice(this.step);
this.cursorEl.remove();
const temp = document.createElement('div');
temp.innerHTML = chunk;
// 由于DOM节点只能有一个父节点,每次调用 appendChild 都会将节点从 temp 中移除;
// 当 temp 的所有子节点都被转移后, temp.firstChild 变为 null ,循环终止
while (temp.firstChild) this.container.appendChild(temp.firstChild);
this.container.appendChild(this.cursorEl);
if (this.textQueue[0].length === 0) this.textQueue.shift();
}
/** HTML模式逐节点打字 */
private processHtmlMode() {
if (!this.htmlQueue.length) {
this.stop();
return;
}
const item = this.htmlQueue[0];
if (item.node.nodeType === Node.TEXT_NODE) {
const text = item.node.textContent ?? '';
const offset = item.offset ?? 0;
const chunk = text.slice(offset, offset + this.step);
if (chunk) {
const tn = document.createTextNode(chunk);
item.parent.appendChild(tn); // ✅ appendChild 替代 insertBefore
this.container.appendChild(this.cursorEl);
item.offset = offset + this.step;
return;
} else {
this.htmlQueue.shift(); // 文本输出完
}
} else if (item.node.nodeType === Node.ELEMENT_NODE) {
const el = item.node as HTMLElement;
const clone = el.cloneNode(false) as HTMLElement;
// 复制所有属性
for (const attr of Array.from(el.attributes)) {
clone.setAttribute(attr.name, attr.value);
}
item.parent.appendChild(clone); // ✅ appendChild 替代 insertBefore
this.htmlQueue.shift();
for (let i = el.childNodes.length - 1; i >= 0; i--) {
this.htmlQueue.unshift({ node: el.childNodes[i], parent: clone });
}
} else {
this.htmlQueue.shift();
}
}
/** 暂停 */
public pause() {
this.isPaused = true;
}
/** 恢复 */
public resume() {
this.isPaused = false;
// 继续输出时重新添加光标
this.container.appendChild(this.cursorEl);
}
/** 隐藏光标 */
public hideCursor() {
this.cursorEl.remove();
}
/** 显示光标 */
public showCursor() {
if (!this.container.contains(this.cursorEl)) {
this.container.appendChild(this.cursorEl);
}
}
/** 停止打字 */
public stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/** 销毁 */
public destroy() {
this.stop();
this.cursorEl?.remove();
// 清空容器内容,避免重复输出
this.clear();
// 清空队列,防止残留的任务继续执行
this.textQueue = [];
this.htmlQueue = [];
// 移除容器上的实例引用
delete (this.container as any).__typewriterInstance;
}
public clear() {
this.container.innerHTML = '';
}
}