打字机效果-支持ckeditor5、框架无关

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 = '';
  }
}
相关推荐
前端婴幼儿1 小时前
前端直接下载到本地(实时显示下载进度)
前端
三小河1 小时前
前端 Class 语法从 0 开始学起
前端
hjt_未来可期1 小时前
js实现复制、粘贴文字
前端·javascript·html
米诺zuo1 小时前
Next.js 路由与中间件
前端
小明记账簿_微信小程序1 小时前
webpack实用配置dev--react(一)
前端
ohyeah1 小时前
AI First 时代:用大模型构建轻量级后台管理系统
前端·llm
Apeng_09191 小时前
vue实现页面不断插入内容并且自动滚动功能
前端·javascript·vue.js
孟祥_成都2 小时前
Prompt 还能哄女朋友!你真的知道如何问 ai 问题吗?
前端·人工智能
前端涂涂2 小时前
第3讲:BTC-数据结构
前端