打字機(jī)效果-支持ckeditor5、框架無(wú)關(guān)

AI應(yīng)用越來(lái)越多,為了實(shí)現(xiàn)更好地實(shí)現(xiàn)打字機(jī)效果,尤其是輸出對(duì)象為富文本的場(chǎng)景,特模仿element-plus-x的typewriter的能力,實(shí)現(xiàn)相關(guān)功能,其中forCKEditor5.ts 是針對(duì)輸出對(duì)象為CKEditor5富文本的情景,forDom.ts是針對(duì)輸出對(duì)象為普通元素(如div)的情景。

特點(diǎn):
1、兩種方案基于typescript實(shí)現(xiàn),可用于react、vue等框架中。
2、屬性有:

  • container:定義輸出對(duì)象,在forDom.ts中是html元素,如某個(gè)div元素;在forCKEditor5.ts中是CKEditor對(duì)象
  • mode:定義輸入類型,支持純文本(text)、markdwon和html字符串
  • interval:每次打字的間隔時(shí)間 單位( ms )
  • step:每次打字吐多少字符
  • cursor:forDomts支持,定義末尾光標(biāo),默認(rèn)為"|"
  • doneMarker和doneMarker:doneMarker用于定義結(jié)束符(默認(rèn)為[DONE]),當(dāng)檢測(cè)到結(jié)束符后,聽(tīng)停止輸出并隱藏光標(biāo),detectDoneMarker定義是否開(kāi)啟檢測(cè)

3、方法有:

  • append:核心防范,用于追加內(nèi)容,支持鏈?zhǔn)讲僮?/li>
  • pause:暫停輸出
  • resume: 恢復(fù)輸出
  • stop:停止打字
  • destroy:銷(xiāo)毀操作
  • clear:清空內(nèi)容
  • showCursor/hideCursor:forDomts支持,顯示/隱藏末尾光標(biāo)(cursor定義末尾光標(biāo))
  • onComplete:forCKEditor5.ts支持,用于傳遞回調(diào),在完成輸出時(shí)調(diào)用

4、簡(jiǎn)單示例

// forCKEditor5.ts的使用示例
const typewriter = new CKEditorTypewriterEngine({
        editor, // 某CKEditor5實(shí)例
        mode: 'html',
        interval: 40,
        step: 2,
        detectDoneMarker: true,
        doneMarker: '[done]'
});

typewriter.append(content).append('[done]');
typewriter.onComplete(() => {
        // 處理一些邏輯
})
// 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('# 關(guān)于中國(guó)蘋(píng)果產(chǎn)業(yè)發(fā)展的調(diào)研報(bào)告\n\n**報(bào)送單位:農(nóng)業(yè)農(nóng)村部發(fā)展規(guī)劃司**  \n**報(bào)送時(shí)間:xxxx年xx月xx日**\n\n為全面掌握我國(guó)蘋(píng)果產(chǎn)業(yè)發(fā)展現(xiàn)狀,深入分析存在問(wèn)題與發(fā)展趨勢(shì),進(jìn)一步優(yōu)化產(chǎn)業(yè)布局、提升產(chǎn)品質(zhì)量、增強(qiáng)市場(chǎng)競(jìng)爭(zhēng)力,我司組織開(kāi)展了全國(guó)蘋(píng)果產(chǎn)業(yè)專題調(diào)研工作。現(xiàn)將有關(guān)情況報(bào)告如下:\n\n---\n\n## 一、產(chǎn)業(yè)發(fā)展總體情況\n\n近年來(lái),我國(guó)蘋(píng)果產(chǎn)業(yè)穩(wěn)步發(fā)展,種植面積和產(chǎn)量持續(xù)增長(zhǎng),已成為全球最大的蘋(píng)果生產(chǎn)國(guó)和消費(fèi)國(guó)。2023年數(shù)據(jù)顯示,全國(guó)蘋(píng)果種植面積約為**4500萬(wàn)畝**,總產(chǎn)量達(dá)到**4800萬(wàn)噸**,占全球總產(chǎn)量的**55%以上**。主要產(chǎn)區(qū)集中在陜西、山東、甘肅、河南、山西等省份。\n\n| 地區(qū) | 種植面積(萬(wàn)畝) | 年產(chǎn)量(萬(wàn)噸) | 占全國(guó)比重 |\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| 其他地區(qū) | 900              | 900            | 18.7%       |\n\n從品種結(jié)構(gòu)來(lái)看,富士系蘋(píng)果占據(jù)主導(dǎo)地位,占比超過(guò)**70%**,其他如嘎啦、金帥、秦冠等傳統(tǒng)品種逐步被優(yōu)質(zhì)高產(chǎn)新品種替代。\n\n---\n\n## 二、產(chǎn)業(yè)發(fā)展中存在的問(wèn)題\n\n### (一)產(chǎn)業(yè)結(jié)構(gòu)不合理,區(qū)域集中度高\(yùn)n\n蘋(píng)果主產(chǎn)區(qū)高度集中于黃土高原及華北平原地區(qū),易受氣候、病蟲(chóng)害等自然因素影響,抗風(fēng)險(xiǎn)能力較弱。部分老果園老化嚴(yán)重,缺乏更新?lián)Q代機(jī)制,導(dǎo)致品質(zhì)下降、產(chǎn)量波動(dòng)大。\n\n### (二)產(chǎn)業(yè)鏈條短,加工轉(zhuǎn)化率低\n\n目前,我國(guó)蘋(píng)果主要用于鮮果銷(xiāo)售,深加工比例較低。2023年數(shù)據(jù)顯示,蘋(píng)果加工轉(zhuǎn)化率僅為**12%**,遠(yuǎn)低于發(fā)達(dá)國(guó)家**30%以上**的平均水平。果汁、果干、果酒等產(chǎn)品仍處于起步階段,品牌化程度不高。\n\n### (三)銷(xiāo)售渠道單一,流通體系不健全\n\n蘋(píng)果銷(xiāo)售以批發(fā)市場(chǎng)為主,電商渠道發(fā)展較快但尚未形成規(guī)模效應(yīng)。冷鏈物流體系建設(shè)滯后,部分地區(qū)存在“賣(mài)難”“爛市”現(xiàn)象,價(jià)格波動(dòng)頻繁,農(nóng)民收入不穩(wěn)定。\n\n### (四)科技創(chuàng)新能力不足,標(biāo)準(zhǔn)化水平偏低\n\n盡管我國(guó)在蘋(píng)果育種、栽培技術(shù)等方面取得一定進(jìn)展,但整體科技支撐能力仍顯薄弱,優(yōu)質(zhì)新品種推廣速度慢,標(biāo)準(zhǔn)化生產(chǎn)覆蓋率不足**30%**,制約了產(chǎn)業(yè)高質(zhì)量發(fā)展。\n\n---\n\n## 三、典型地區(qū)調(diào)研情況\n\n### (一)陜西省渭南市\(zhòng)n\n渭南市是全國(guó)重要的蘋(píng)果生產(chǎn)基地之一,種植面積達(dá)**200萬(wàn)畝**,年產(chǎn)量**250萬(wàn)噸**。該市通過(guò)“龍頭企業(yè)+合作社+農(nóng)戶”的模式推動(dòng)產(chǎn)業(yè)升級(jí),建設(shè)高標(biāo)準(zhǔn)示范園,引進(jìn)優(yōu)良品種,并建立蘋(píng)果質(zhì)量追溯系統(tǒng),有效提升了產(chǎn)品質(zhì)量和市場(chǎng)競(jìng)爭(zhēng)力。\n\n### (二)山東省煙臺(tái)市\(zhòng)n\n煙臺(tái)市擁有百年蘋(píng)果種植歷史,是中國(guó)蘋(píng)果出口的重要口岸。近年來(lái),煙臺(tái)市依托地理標(biāo)志保護(hù),打造“煙臺(tái)蘋(píng)果”區(qū)域公用品牌,積極拓展國(guó)際市場(chǎng)。2023年出口量達(dá)**45萬(wàn)噸**,占全國(guó)出口總量的**25%**。\n\n### (三)甘肅省慶陽(yáng)市\(zhòng)n\n慶陽(yáng)市地處黃土高原腹地,光照充足、晝夜溫差大,適宜蘋(píng)果生長(zhǎng)。當(dāng)?shù)卣雠_(tái)多項(xiàng)扶持政策,推動(dòng)蘋(píng)果產(chǎn)業(yè)規(guī)?;⒓s化發(fā)展,蘋(píng)果種植面積達(dá)**300萬(wàn)畝**,年產(chǎn)值超**50億元**。但仍面臨基礎(chǔ)設(shè)施薄弱、人才短缺等問(wèn)題。\n\n---\n\n## 四、對(duì)策建議\n\n### (一)優(yōu)化產(chǎn)業(yè)布局,推進(jìn)結(jié)構(gòu)調(diào)整\n\n科學(xué)規(guī)劃產(chǎn)區(qū)布局,引導(dǎo)優(yōu)勢(shì)產(chǎn)區(qū)適度擴(kuò)張,非優(yōu)勢(shì)產(chǎn)區(qū)有序退出,鼓勵(lì)發(fā)展山地、丘陵等地塊種植蘋(píng)果,提高土地利用效率。\n\n### (二)加強(qiáng)科技創(chuàng)新,提升標(biāo)準(zhǔn)化水平\n\n加大科研投入,加快新品種選育和配套栽培技術(shù)推廣,推動(dòng)蘋(píng)果種植標(biāo)準(zhǔn)化、機(jī)械化、智能化發(fā)展,提高生產(chǎn)效率和果實(shí)品質(zhì)。\n\n### (三)完善加工體系,延伸產(chǎn)業(yè)鏈條\n\n支持建設(shè)蘋(píng)果深加工項(xiàng)目,重點(diǎn)發(fā)展果汁、果醋、果酒等產(chǎn)品,培育一批具有自主知識(shí)產(chǎn)權(quán)的品牌企業(yè),提升附加值和市場(chǎng)占有率。\n\n### (四)健全流通體系,拓寬銷(xiāo)售渠道\n\n加快推進(jìn)冷鏈物流體系建設(shè),鼓勵(lì)發(fā)展電商、社區(qū)團(tuán)購(gòu)等新型銷(xiāo)售模式,構(gòu)建覆蓋全國(guó)、連接國(guó)際的現(xiàn)代蘋(píng)果流通網(wǎng)絡(luò),增強(qiáng)產(chǎn)業(yè)抗風(fēng)險(xiǎn)能力。\n\n### (五)強(qiáng)化政策扶持,保障農(nóng)民利益\n\n加大對(duì)蘋(píng)果產(chǎn)業(yè)的財(cái)政、金融支持力度,完善農(nóng)業(yè)保險(xiǎn)制度,建立健全產(chǎn)銷(xiāo)對(duì)接機(jī)制,確保農(nóng)民穩(wěn)定增收。\n\n---\n\n## 附件\n\n1. 中國(guó)蘋(píng)果產(chǎn)業(yè)主要產(chǎn)區(qū)分布圖  \n2. 2023年全國(guó)蘋(píng)果產(chǎn)量及結(jié)構(gòu)統(tǒng)計(jì)表  \n3. 典型地區(qū)蘋(píng)果產(chǎn)業(yè)發(fā)展情況匯總表  \n\n---\n\n**聯(lián)系人:XXX**  \n**聯(lián)系電話:XXXX-XXXXXXXX**  \n**電子郵箱:XXXX@agri.gov.cn**\n\n農(nóng)業(yè)農(nóng)村部發(fā)展規(guī)劃司  \nxxxx年xx月xx日');
      tw.append('[done]');
}, 1000);

一、forCKEditor5.ts

// 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; // 對(duì)文本節(jié)點(diǎn),表示已輸出長(zhǎng)度
}

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) {
    // 檢查編輯器上是否已有打字機(jī)實(shí)例
    const existingInstance = (options.container as any).__typewriterInstance;
    if (existingInstance) {
      existingInstance.destroy();
    }
    
    // 將當(dāng)前實(shí)例存儲(chǔ)在編輯器上
    (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]';
  }

  /** 簡(jiǎn)單 Markdown 轉(zhuǎn) HTML */
  private markdownToHtml(md: string): string {
    return marked.parse(md) as string;
  }

  /** 將 HTML / Markdown / Text 轉(zhuǎn)成模型隊(duì)列 */
  public append(input: string): CKEditorTypewriterEngine {
    const pos = this.container?.model?.document?.selection?.getFirstPosition(); 
    if (!pos) {
      return this;
    }

    let processedInput = input;
    let shouldHideCursorAfter = false;

    // 檢測(cè)并處理 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 轉(zhuǎn)到到模型 fragment
    const modelFragment: ModelFragment = this.container.data.parse(html);

    // 將 fragment 的頂層子節(jié)點(diǎn)壓入隊(duì)列
    this.enqueueFragment(modelFragment, pos.parent);

    // 啟動(dòng)打字機(jī)
    if (!this.timer) this.start();

    // 完成后隱藏光標(biāo)
    if (shouldHideCursorAfter) {
      const checkCompletion = () => {
        if (!this.queue.length && !this.timer) {
          this.isCompleted = true;
          this.onCompleteCallback?.();
        } else {
          requestAnimationFrame(checkCompletion);
        }
      };
      checkCompletion();
    }

    return this;
  }

  /** 將 fragment 子節(jié)點(diǎn)壓入隊(duì)列,保留父引用 */
  private enqueueFragment(frag: ModelFragment | ModelElement, parent: ModelElement | ModelFragment) {
    for (const child of frag.getChildren()) {
      this.queue.push({ node: child as ModelText | ModelElement, parent });
    }
  }

  /** 啟動(dòng)定時(shí)器 */
  private start() {
    if (this.timer) return;
    this.isPaused = false;

    this.timer = window.setInterval(() => {
      if (this.isPaused) return;
      this.processNext();
    }, this.interval);
  }

  /** 逐步處理隊(duì)列 */
  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;
        // 克隆元素但不帶子節(jié)點(diǎn)
        const clone = writer.createElement(el.name, el.getAttributes());
        writer.insert(clone, item.parent, 'end');

        this.queue.shift();

        // 將子節(jié)點(diǎn)壓入隊(duì)列,父節(jié)點(diǎn)為 clone
        for (const child of Array.from(el.getChildren()).reverse()) {
          this.queue.unshift({ node: child as ModelText | ModelElement, parent: clone });
        }
      } else {
        this.queue.shift();
      }
      // 光標(biāo)移到最后
      writer.setSelection(this.container.model.document.getRoot() as ModelRootElement, 'end');
    });

    // 滾動(dòng)到編輯器底部,確保新內(nèi)容可見(jiàn)
    if (!this.isCompleted) {
      this.container.editing.view.scrollToTheSelection();
    }
  }

  /** 暫停 */
  public pause() {
    this.isPaused = true;
  }

  /** 恢復(fù) */
  public resume() {
    this.isPaused = false;
  }

  /** 設(shè)置完成回調(diào)函數(shù) */
  public onComplete(callback: () => void): CKEditorTypewriterEngine {
    this.onCompleteCallback = callback;
    return this;
  }

  /** 停止打字機(jī) */
  public stop() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
    this.queue = [];
    // 解決輸出后,點(diǎn)擊輸出內(nèi)容任意位置,光標(biāo)會(huì)被強(qiáng)制跳轉(zhuǎn)到末尾的問(wèn)題(比較hack)
    this.container.setData(this.container.getData() + '');
  }

  /** 銷(xiāo)毀 */
  public destroy() {
    this.stop();
    this.clear();
    // 從編輯器上移除實(shí)例引用
    delete (this.containeras any).__typewriterInstance;
  }

  public clear() {
    // 通過(guò)model方式清空container的內(nèi)容
    this.container.model.change((writer) => {
      const root = this.container.model.document.getRoot() as ModelRootElement;
      const range = writer.createRangeIn(root);
      // 不要直接remove(root),這樣會(huì)破壞結(jié)構(gòu)
      writer.remove(range);
    });
    this.queue = [];
  }

  /** 簡(jiǎn)單轉(zhuǎn)義 HTML */
  private escapeHtml(text: string) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
  }
}

二、forDom.ts

//  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; // 對(duì)文本節(jié)點(diǎn),表示已輸出長(zhǎng)度
}

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]';

    // 檢查容器是否已有打字機(jī)實(shí)例,若有則銷(xiāo)毀
    const existingInstance = (this.container as any).__typewriterInstance;
    if (existingInstance) {
      existingInstance.destroy();
    }
    // 將當(dāng)前實(shí)例存儲(chǔ)在容器上
    (this.container as any).__typewriterInstance = this;

    // 清除容器現(xiàn)有內(nèi)容
    while (this.container.firstChild) {
      this.container.removeChild(this.container.firstChild);
    }

    // 初始化光標(biāo)
    this.cursorEl = document.createElement('span');
    this.cursorEl.className = 'tw-cursor';
    this.cursorEl.textContent = this.cursorChar;
    this.container.appendChild(this.cursorEl);

    this.startCursorBlink();
  }

  /** 光標(biāo)閃爍 */
  private startCursorBlink() {
    setInterval(() => {
      this.cursorEl.style.visibility =
        this.cursorEl.style.visibility === 'hidden' ? 'visible' : 'hidden';
    }, 500);
  }

  /** 簡(jiǎn)單 Markdown 轉(zhuǎn) HTML */
  private markdownToHtml(md: string): string {
    return marked.parse(md) as string;
  }

  /** 追加內(nèi)容 */
  public append(input: string): TypewriterEngine {
    let processedInput = input;
    let shouldHideCursorAfter = false;

    // 檢測(cè)并處理 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();

    // 完成后隱藏光標(biāo)
    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 });
    });
  }

  /** 啟動(dòng)打字 */
  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);
  }

  /** 轉(zhuǎn)義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節(jié)點(diǎn)只能有一個(gè)父節(jié)點(diǎn),每次調(diào)用 appendChild 都會(huì)將節(jié)點(diǎn)從 temp 中移除;
    // 當(dāng) temp 的所有子節(jié)點(diǎn)都被轉(zhuǎn)移后, temp.firstChild 變?yōu)?null ,循環(huán)終止
    while (temp.firstChild) this.container.appendChild(temp.firstChild);
    this.container.appendChild(this.cursorEl);

    if (this.textQueue[0].length === 0) this.textQueue.shift();
  }

  /** HTML模式逐節(jié)點(diǎn)打字 */
  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;

      // 復(fù)制所有屬性
      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;
  }

  /** 恢復(fù) */
  public resume() {
    this.isPaused = false;
    // 繼續(xù)輸出時(shí)重新添加光標(biāo)
    this.container.appendChild(this.cursorEl);
  }

  /** 隱藏光標(biāo) */
  public hideCursor() {
    this.cursorEl.remove();
  }

  /** 顯示光標(biāo) */
  public showCursor() {
    if (!this.container.contains(this.cursorEl)) {
      this.container.appendChild(this.cursorEl);
    }
  }

  /** 停止打字 */
  public stop() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }

  /** 銷(xiāo)毀 */
  public destroy() {
    this.stop();
    this.cursorEl?.remove();
    // 清空容器內(nèi)容,避免重復(fù)輸出
    this.clear();
    // 清空隊(duì)列,防止殘留的任務(wù)繼續(xù)執(zhí)行
    this.textQueue = [];
    this.htmlQueue = [];
    // 移除容器上的實(shí)例引用
    delete (this.container as any).__typewriterInstance;
  }

  public clear() {
    this.container.innerHTML = '';
  }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容