diff --git a/dist/core/finder.d.ts b/dist/core/finder.d.ts index ada7883..454906f 100644 --- a/dist/core/finder.d.ts +++ b/dist/core/finder.d.ts @@ -32,7 +32,18 @@ export declare class Finder { private readonly featureCache; private readonly levelCache; private readonly categoryCache; + private readonly pillAncestorCache; + private readonly avoidAncestorCache; constructor(opts: ResolvedOptions); + /** + * 清除 el 及其**整棵子樹**之元素鍵快取(C1 失效原語)。持久化 Finder(跨 render 復用快取) + * 下,元素之屬性或內容變化會令其分類(category)/級別(level)/功能(feature)/祖先游走 + * (pill/avoid ancestor)快取失效。**須清整棵子樹、非僅該元素**:level/feature/祖先游走 + * 之結果依賴祖先鏈,某祖先之 data-jz-level/data-juzhen/class 變化會令其**後代**之快取值變脏, + * 即便後代自身未動。querySelectorAll('*') 走子樹(O(子樹),native、無遞迴棧風險)。移除之元素 + * 由 WeakMap 自動回收,無需顯式清。 */ + invalidate(el: Element): void; + private forget; /** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */ pillMatches(el: Element): boolean; /** 該元素是否匹配 isolate 選擇器(三分類之「隔離」:帶盒樣式之行內,如背景色 @@ -42,6 +53,10 @@ export declare class Finder { * 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六: * .katex 內部曾被 jiya/spacing 直接變更)。 */ insidePill(node: Node): boolean; + /** el 或其祖先鏈任一為 pill(記憶化)。沿祖先鏈迭代(非遞迴,任意深度棧安全)至最近之 + * 已快取/pill 命中/鏈頂,途中非 pill 元素入棧、以斷點之值一次回填——每元素恰算一次, + * 兄弟共用祖先鏈快取。insidePill 為 pillMatches 之最大來源(S3 延伸)。 */ + private ancestorOrSelfPill; /** * 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多 * 選擇器同命中時之定案規則):`avoid > pill > isolate > block > transparent > opaque` @@ -83,6 +98,8 @@ export declare class Finder { * 預設)**:一次性檢查用此,免除「avoidsSelf ‖ isAvoided」易漏一半之誤用。遍歷原語逐 * 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */ isAvoided(node: Node): boolean; + /** el 或其祖先鏈任一為 avoid 邊界(記憶化,迭代回填,同 ancestorOrSelfPill)。 */ + private ancestorOrSelfAvoided; /** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */ inScope(node: Node): boolean; /** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */ diff --git a/dist/index.d.ts b/dist/index.d.ts index 2fb2f13..75087be 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -6,6 +6,21 @@ export declare function normalizeOptions(opts?: JuzhenOptions): ResolvedOptions; export interface Juzhen { render(root?: Element | string): void; revert(root?: Element | string): void; + /** revert 後重新 render 同一(子)樹(C3 增量便利);等價於 revert(root) + render(root)。 + * 頁面局部變化後,對受影響之最近塊調用即可只重處理該子樹、免全樹重掃。 */ + rerender(root?: Element | string): void; + /** 清除該(子)樹之內部元素快取(C1)。**revert 已自動失效其子樹**,故僅在「改了既有元素之 + * 分類相關屬性(class/data-jz-*/lang 等)但不走 revert」時才需顯式調用,以免下次 render + * 沿用陳舊分類。內容(子節點/文本)變化不改分類、無需 invalidate(但須 revert+render 該塊 + * 方能重處理)。 */ + invalidate(root?: Element | string): void; + /** 開始觀察 root 子樹之 DOM 變化(C2 增量模式,opt-in):內容/分類屬性變化時自動只重處理 + * 受影響之最近塊(revert+render),新增之塊直接 render——免消費端手動 rerender 或全樹重掃。 + * **juzhen 自身之變更**(jz-* 注入、data-jz* 屬性)於處理期間暫停觀察、絕不自觸發。需 + * MutationObserver(瀏覽器/jsdom 有;無則 no-op)。重複 observe 前自動 disconnect 前者。 */ + observe(root?: Element | string): void; + /** 停止 C2 觀察。 */ + disconnect(): void; readonly options: ResolvedOptions; } /** 建立一個聚珍實例。 */ diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index eea1087..ac7dd14 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -43,6 +43,26 @@ var Juzhen = (() => { this.featureCache = /* @__PURE__ */ new WeakMap(); this.levelCache = /* @__PURE__ */ new WeakMap(); this.categoryCache = /* @__PURE__ */ new WeakMap(); + this.pillAncestorCache = /* @__PURE__ */ new WeakMap(); + this.avoidAncestorCache = /* @__PURE__ */ new WeakMap(); + } + /** + * 清除 el 及其**整棵子樹**之元素鍵快取(C1 失效原語)。持久化 Finder(跨 render 復用快取) + * 下,元素之屬性或內容變化會令其分類(category)/級別(level)/功能(feature)/祖先游走 + * (pill/avoid ancestor)快取失效。**須清整棵子樹、非僅該元素**:level/feature/祖先游走 + * 之結果依賴祖先鏈,某祖先之 data-jz-level/data-juzhen/class 變化會令其**後代**之快取值變脏, + * 即便後代自身未動。querySelectorAll('*') 走子樹(O(子樹),native、無遞迴棧風險)。移除之元素 + * 由 WeakMap 自動回收,無需顯式清。 */ + invalidate(el) { + this.forget(el); + el.querySelectorAll("*").forEach((c) => this.forget(c)); + } + forget(e) { + this.categoryCache.delete(e); + this.pillAncestorCache.delete(e); + this.avoidAncestorCache.delete(e); + this.levelCache.delete(e); + this.featureCache.delete(e); } /** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */ pillMatches(el) { @@ -57,14 +77,34 @@ var Juzhen = (() => { * 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六: * .katex 內部曾被 jiya/spacing 直接變更)。 */ insidePill(node) { - let p = node.parentNode; - while (p && p.nodeType === 1) { - if (this.pillMatches(p)) { - return true; + const p = node.parentElement; + return p ? this.ancestorOrSelfPill(p) : false; + } + /** el 或其祖先鏈任一為 pill(記憶化)。沿祖先鏈迭代(非遞迴,任意深度棧安全)至最近之 + * 已快取/pill 命中/鏈頂,途中非 pill 元素入棧、以斷點之值一次回填——每元素恰算一次, + * 兄弟共用祖先鏈快取。insidePill 為 pillMatches 之最大來源(S3 延伸)。 */ + ancestorOrSelfPill(el) { + const chain = []; + let cur = el; + let base = false; + while (cur) { + const c = this.pillAncestorCache.get(cur); + if (c !== void 0) { + base = c; + break; } - p = p.parentNode; + if (this.pillMatches(cur)) { + this.pillAncestorCache.set(cur, true); + base = true; + break; + } + chain.push(cur); + cur = cur.parentElement; } - return false; + for (const e of chain) { + this.pillAncestorCache.set(e, base); + } + return base; } /** * 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多 @@ -161,20 +201,37 @@ var Juzhen = (() => { * 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */ isAvoided(node) { if (node.nodeType === 1) { - if (this.avoidsSelf(node)) { - return true; - } - } else if (this.userIsSkipped && this.userIsSkipped(node)) { + return this.ancestorOrSelfAvoided(node); + } + if (this.userIsSkipped && this.userIsSkipped(node)) { return true; } - let p = node.parentNode; - while (p && p.nodeType === 1) { - if (this.avoidsSelf(p)) { - return true; + const p = node.parentElement; + return p ? this.ancestorOrSelfAvoided(p) : false; + } + /** el 或其祖先鏈任一為 avoid 邊界(記憶化,迭代回填,同 ancestorOrSelfPill)。 */ + ancestorOrSelfAvoided(el) { + const chain = []; + let cur = el; + let base = false; + while (cur) { + const c = this.avoidAncestorCache.get(cur); + if (c !== void 0) { + base = c; + break; } - p = p.parentNode; + if (this.avoidsSelf(cur)) { + this.avoidAncestorCache.set(cur, true); + base = true; + break; + } + chain.push(cur); + cur = cur.parentElement; } - return false; + for (const e of chain) { + this.avoidAncestorCache.set(e, base); + } + return base; } /** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */ inScope(node) { @@ -1528,9 +1585,29 @@ var Juzhen = (() => { } }; var TRIM = "jz-hws-trim"; + function skippableAlign(g, cache) { + const key = g.parentElement; + if (!key) { + return false; + } + const cached = cache.get(key); + if (cached !== void 0) { + return cached; + } + let result = false; + const view = g.ownerDocument ? g.ownerDocument.defaultView : null; + if (view) { + const cs = view.getComputedStyle(g); + const ltr = cs.direction !== "rtl"; + result = cs.textAlign === "left" || cs.textAlign === "start" && ltr; + } + cache.set(key, result); + return result; + } function trimGaps(root, finder, wrapSet) { + const alignCache = /* @__PURE__ */ new Map(); const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") && finder.featureEnabledFor(g, "spacing") + (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") && finder.featureEnabledFor(g, "spacing") && !skippableAlign(g, alignCache) ); if (!gaps.length) { return; @@ -2170,10 +2247,135 @@ var Juzhen = (() => { } return typeof document !== "undefined" ? document.body : null; } + var OBSERVE_ATTRS = [ + "class", + "lang", + "id", + "contenteditable", + "data-jz-skip", + "data-jz-level", + "data-jz-style", + "data-juzhen", + "data-juzhen-off" + ]; function createJuzhen(opts = {}) { const options = normalizeOptions(opts); + const finder = new Finder(options); function makeCtx(root) { - return { root, options, finder: new Finder(options) }; + return { root, options, finder }; + } + function renderImpl(el) { + runPasses(PASSES, makeCtx(el)); + if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") { + el.setAttribute("data-jz-halfwidth", "margin"); + } + if (!options.justifyAtoms) { + el.setAttribute("data-jz-atoms", "inline"); + } + } + function revertImpl(el) { + revertPasses(PASSES, makeCtx(el)); + el.removeAttribute("data-jz-halfwidth"); + el.removeAttribute("data-jz-atoms"); + finder.invalidate(el); + } + let observer = null; + let observeRoot = null; + const observeConfig = { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: OBSERVE_ATTRS.slice() + }; + function pauseObserver() { + if (observer) { + observer.disconnect(); + } + } + function resumeObserver() { + if (observer && observeRoot) { + observer.takeRecords(); + observer.observe(observeRoot, observeConfig); + } + } + function nearestBlock(node) { + let e = node.nodeType === 1 ? node : node.parentElement; + while (e) { + if (options.finder.blockTags.has(e.nodeName)) { + return e; + } + if (e === observeRoot) { + break; + } + e = e.parentElement; + } + return observeRoot; + } + function onMutations(records) { + const rerenderBlocks = /* @__PURE__ */ new Set(); + const renderNew = []; + for (const m of records) { + if (m.type === "childList") { + m.addedNodes.forEach((n) => { + if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName)) { + const np = n; + const P = np.parentElement; + const pIsBlock = !!P && options.finder.blockTags.has(P.nodeName); + const pHadLeafOrphan = !!P && Array.prototype.some.call( + P.children, + (c) => c.nodeName === "JZ-ORPHAN" + ); + if (pIsBlock && pHadLeafOrphan) { + rerenderBlocks.add(P); + } else if (!pIsBlock && P) { + const b = nearestBlock(P); + if (b) { + rerenderBlocks.add(b); + } + } else { + renderNew.push(np); + } + } else if (n.nodeType === 1 || n.nodeType === 3) { + const b = nearestBlock(m.target); + if (b) { + rerenderBlocks.add(b); + } + } + }); + if (m.removedNodes.length) { + const b = nearestBlock(m.target); + if (b) { + rerenderBlocks.add(b); + } + } + } else { + const b = nearestBlock(m.target); + if (b) { + rerenderBlocks.add(b); + } + } + } + if (!rerenderBlocks.size && !renderNew.length) { + return; + } + pauseObserver(); + const all = Array.from(rerenderBlocks).filter((b) => b.isConnected); + const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b))); + for (const b of blocks) { + revertImpl(b); + renderImpl(b); + } + for (const el of renderNew) { + if (!el.isConnected) { + continue; + } + if (blocks.some((b) => b.contains(el))) { + continue; + } + renderImpl(el); + } + resumeObserver(); } return { options, @@ -2182,22 +2384,56 @@ var Juzhen = (() => { if (!el) { return; } - runPasses(PASSES, makeCtx(el)); - if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") { - el.setAttribute("data-jz-halfwidth", "margin"); - } - if (!options.justifyAtoms) { - el.setAttribute("data-jz-atoms", "inline"); - } + pauseObserver(); + renderImpl(el); + resumeObserver(); }, revert(root) { const el = resolveRoot(root, options); if (!el) { return; } - revertPasses(PASSES, makeCtx(el)); - el.removeAttribute("data-jz-halfwidth"); - el.removeAttribute("data-jz-atoms"); + pauseObserver(); + revertImpl(el); + resumeObserver(); + }, + rerender(root) { + const el = resolveRoot(root, options); + if (!el) { + return; + } + pauseObserver(); + revertImpl(el); + renderImpl(el); + resumeObserver(); + }, + invalidate(root) { + const el = resolveRoot(root, options); + if (el) { + finder.invalidate(el); + } + }, + observe(root) { + if (typeof MutationObserver === "undefined") { + return; + } + const el = resolveRoot(root, options); + if (!el) { + return; + } + if (observer) { + observer.disconnect(); + } + observeRoot = el; + observer = new MutationObserver(onMutations); + observer.observe(el, observeConfig); + }, + disconnect() { + if (observer) { + observer.disconnect(); + observer = null; + observeRoot = null; + } } }; } diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 27dba41..2be70d3 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -16,6 +16,26 @@ var Finder = class { this.featureCache = /* @__PURE__ */ new WeakMap(); this.levelCache = /* @__PURE__ */ new WeakMap(); this.categoryCache = /* @__PURE__ */ new WeakMap(); + this.pillAncestorCache = /* @__PURE__ */ new WeakMap(); + this.avoidAncestorCache = /* @__PURE__ */ new WeakMap(); + } + /** + * 清除 el 及其**整棵子樹**之元素鍵快取(C1 失效原語)。持久化 Finder(跨 render 復用快取) + * 下,元素之屬性或內容變化會令其分類(category)/級別(level)/功能(feature)/祖先游走 + * (pill/avoid ancestor)快取失效。**須清整棵子樹、非僅該元素**:level/feature/祖先游走 + * 之結果依賴祖先鏈,某祖先之 data-jz-level/data-juzhen/class 變化會令其**後代**之快取值變脏, + * 即便後代自身未動。querySelectorAll('*') 走子樹(O(子樹),native、無遞迴棧風險)。移除之元素 + * 由 WeakMap 自動回收,無需顯式清。 */ + invalidate(el) { + this.forget(el); + el.querySelectorAll("*").forEach((c) => this.forget(c)); + } + forget(e) { + this.categoryCache.delete(e); + this.pillAncestorCache.delete(e); + this.avoidAncestorCache.delete(e); + this.levelCache.delete(e); + this.featureCache.delete(e); } /** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */ pillMatches(el) { @@ -30,14 +50,34 @@ var Finder = class { * 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六: * .katex 內部曾被 jiya/spacing 直接變更)。 */ insidePill(node) { - let p = node.parentNode; - while (p && p.nodeType === 1) { - if (this.pillMatches(p)) { - return true; + const p = node.parentElement; + return p ? this.ancestorOrSelfPill(p) : false; + } + /** el 或其祖先鏈任一為 pill(記憶化)。沿祖先鏈迭代(非遞迴,任意深度棧安全)至最近之 + * 已快取/pill 命中/鏈頂,途中非 pill 元素入棧、以斷點之值一次回填——每元素恰算一次, + * 兄弟共用祖先鏈快取。insidePill 為 pillMatches 之最大來源(S3 延伸)。 */ + ancestorOrSelfPill(el) { + const chain = []; + let cur = el; + let base = false; + while (cur) { + const c = this.pillAncestorCache.get(cur); + if (c !== void 0) { + base = c; + break; } - p = p.parentNode; + if (this.pillMatches(cur)) { + this.pillAncestorCache.set(cur, true); + base = true; + break; + } + chain.push(cur); + cur = cur.parentElement; } - return false; + for (const e of chain) { + this.pillAncestorCache.set(e, base); + } + return base; } /** * 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多 @@ -134,20 +174,37 @@ var Finder = class { * 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */ isAvoided(node) { if (node.nodeType === 1) { - if (this.avoidsSelf(node)) { - return true; - } - } else if (this.userIsSkipped && this.userIsSkipped(node)) { + return this.ancestorOrSelfAvoided(node); + } + if (this.userIsSkipped && this.userIsSkipped(node)) { return true; } - let p = node.parentNode; - while (p && p.nodeType === 1) { - if (this.avoidsSelf(p)) { - return true; + const p = node.parentElement; + return p ? this.ancestorOrSelfAvoided(p) : false; + } + /** el 或其祖先鏈任一為 avoid 邊界(記憶化,迭代回填,同 ancestorOrSelfPill)。 */ + ancestorOrSelfAvoided(el) { + const chain = []; + let cur = el; + let base = false; + while (cur) { + const c = this.avoidAncestorCache.get(cur); + if (c !== void 0) { + base = c; + break; } - p = p.parentNode; + if (this.avoidsSelf(cur)) { + this.avoidAncestorCache.set(cur, true); + base = true; + break; + } + chain.push(cur); + cur = cur.parentElement; } - return false; + for (const e of chain) { + this.avoidAncestorCache.set(e, base); + } + return base; } /** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */ inScope(node) { @@ -1501,9 +1558,29 @@ var lineEdgePass = { } }; var TRIM = "jz-hws-trim"; +function skippableAlign(g, cache) { + const key = g.parentElement; + if (!key) { + return false; + } + const cached = cache.get(key); + if (cached !== void 0) { + return cached; + } + let result = false; + const view = g.ownerDocument ? g.ownerDocument.defaultView : null; + if (view) { + const cs = view.getComputedStyle(g); + const ltr = cs.direction !== "rtl"; + result = cs.textAlign === "left" || cs.textAlign === "start" && ltr; + } + cache.set(key, result); + return result; +} function trimGaps(root, finder, wrapSet) { + const alignCache = /* @__PURE__ */ new Map(); const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( - (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") && finder.featureEnabledFor(g, "spacing") + (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") && finder.featureEnabledFor(g, "spacing") && !skippableAlign(g, alignCache) ); if (!gaps.length) { return; @@ -2143,10 +2220,135 @@ function resolveRoot(arg, options) { } return typeof document !== "undefined" ? document.body : null; } +var OBSERVE_ATTRS = [ + "class", + "lang", + "id", + "contenteditable", + "data-jz-skip", + "data-jz-level", + "data-jz-style", + "data-juzhen", + "data-juzhen-off" +]; function createJuzhen(opts = {}) { const options = normalizeOptions(opts); + const finder = new Finder(options); function makeCtx(root) { - return { root, options, finder: new Finder(options) }; + return { root, options, finder }; + } + function renderImpl(el) { + runPasses(PASSES, makeCtx(el)); + if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") { + el.setAttribute("data-jz-halfwidth", "margin"); + } + if (!options.justifyAtoms) { + el.setAttribute("data-jz-atoms", "inline"); + } + } + function revertImpl(el) { + revertPasses(PASSES, makeCtx(el)); + el.removeAttribute("data-jz-halfwidth"); + el.removeAttribute("data-jz-atoms"); + finder.invalidate(el); + } + let observer = null; + let observeRoot = null; + const observeConfig = { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: OBSERVE_ATTRS.slice() + }; + function pauseObserver() { + if (observer) { + observer.disconnect(); + } + } + function resumeObserver() { + if (observer && observeRoot) { + observer.takeRecords(); + observer.observe(observeRoot, observeConfig); + } + } + function nearestBlock(node) { + let e = node.nodeType === 1 ? node : node.parentElement; + while (e) { + if (options.finder.blockTags.has(e.nodeName)) { + return e; + } + if (e === observeRoot) { + break; + } + e = e.parentElement; + } + return observeRoot; + } + function onMutations(records) { + const rerenderBlocks = /* @__PURE__ */ new Set(); + const renderNew = []; + for (const m of records) { + if (m.type === "childList") { + m.addedNodes.forEach((n) => { + if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName)) { + const np = n; + const P = np.parentElement; + const pIsBlock = !!P && options.finder.blockTags.has(P.nodeName); + const pHadLeafOrphan = !!P && Array.prototype.some.call( + P.children, + (c) => c.nodeName === "JZ-ORPHAN" + ); + if (pIsBlock && pHadLeafOrphan) { + rerenderBlocks.add(P); + } else if (!pIsBlock && P) { + const b = nearestBlock(P); + if (b) { + rerenderBlocks.add(b); + } + } else { + renderNew.push(np); + } + } else if (n.nodeType === 1 || n.nodeType === 3) { + const b = nearestBlock(m.target); + if (b) { + rerenderBlocks.add(b); + } + } + }); + if (m.removedNodes.length) { + const b = nearestBlock(m.target); + if (b) { + rerenderBlocks.add(b); + } + } + } else { + const b = nearestBlock(m.target); + if (b) { + rerenderBlocks.add(b); + } + } + } + if (!rerenderBlocks.size && !renderNew.length) { + return; + } + pauseObserver(); + const all = Array.from(rerenderBlocks).filter((b) => b.isConnected); + const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b))); + for (const b of blocks) { + revertImpl(b); + renderImpl(b); + } + for (const el of renderNew) { + if (!el.isConnected) { + continue; + } + if (blocks.some((b) => b.contains(el))) { + continue; + } + renderImpl(el); + } + resumeObserver(); } return { options, @@ -2155,22 +2357,56 @@ function createJuzhen(opts = {}) { if (!el) { return; } - runPasses(PASSES, makeCtx(el)); - if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") { - el.setAttribute("data-jz-halfwidth", "margin"); - } - if (!options.justifyAtoms) { - el.setAttribute("data-jz-atoms", "inline"); - } + pauseObserver(); + renderImpl(el); + resumeObserver(); }, revert(root) { const el = resolveRoot(root, options); if (!el) { return; } - revertPasses(PASSES, makeCtx(el)); - el.removeAttribute("data-jz-halfwidth"); - el.removeAttribute("data-jz-atoms"); + pauseObserver(); + revertImpl(el); + resumeObserver(); + }, + rerender(root) { + const el = resolveRoot(root, options); + if (!el) { + return; + } + pauseObserver(); + revertImpl(el); + renderImpl(el); + resumeObserver(); + }, + invalidate(root) { + const el = resolveRoot(root, options); + if (el) { + finder.invalidate(el); + } + }, + observe(root) { + if (typeof MutationObserver === "undefined") { + return; + } + const el = resolveRoot(root, options); + if (!el) { + return; + } + if (observer) { + observer.disconnect(); + } + observeRoot = el; + observer = new MutationObserver(onMutations); + observer.observe(el, observeConfig); + }, + disconnect() { + if (observer) { + observer.disconnect(); + observer = null; + observeRoot = null; + } } }; } diff --git a/src/core/finder.ts b/src/core/finder.ts index ae96aef..5df84e0 100644 --- a/src/core/finder.ts +++ b/src/core/finder.ts @@ -64,6 +64,11 @@ export class Finder private readonly featureCache: WeakMap>; private readonly levelCache: WeakMap; private readonly categoryCache: WeakMap; + // 祖先游走記憶化(S3 延伸/S4):ancestorOrSelfPill/…Avoided 之逐元素結果,使 insidePill + // /isAvoided 之「逐文本節點沿祖先鏈」由 O(文本節點×depth) 收斂為 O(元素數)——兄弟節點共用 + // 祖先鏈快取。同 categoryCache:Finder 隨 render 重建 → 僅存活單次 render、無跨 render 失效。 + private readonly pillAncestorCache: WeakMap; + private readonly avoidAncestorCache: WeakMap; constructor(opts: ResolvedOptions) { @@ -81,6 +86,30 @@ export class Finder this.featureCache = new WeakMap(); this.levelCache = new WeakMap(); this.categoryCache = new WeakMap(); + this.pillAncestorCache = new WeakMap(); + this.avoidAncestorCache = new WeakMap(); + } + + /** + * 清除 el 及其**整棵子樹**之元素鍵快取(C1 失效原語)。持久化 Finder(跨 render 復用快取) + * 下,元素之屬性或內容變化會令其分類(category)/級別(level)/功能(feature)/祖先游走 + * (pill/avoid ancestor)快取失效。**須清整棵子樹、非僅該元素**:level/feature/祖先游走 + * 之結果依賴祖先鏈,某祖先之 data-jz-level/data-juzhen/class 變化會令其**後代**之快取值變脏, + * 即便後代自身未動。querySelectorAll('*') 走子樹(O(子樹),native、無遞迴棧風險)。移除之元素 + * 由 WeakMap 自動回收,無需顯式清。 */ + invalidate(el: Element): void + { + this.forget(el); + el.querySelectorAll("*").forEach((c) => this.forget(c)); + } + + private forget(e: Element): void + { + this.categoryCache.delete(e); + this.pillAncestorCache.delete(e); + this.avoidAncestorCache.delete(e); + this.levelCache.delete(e); + this.featureCache.delete(e); } /** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */ @@ -101,13 +130,28 @@ export class Finder * .katex 內部曾被 jiya/spacing 直接變更)。 */ insidePill(node: Node): boolean { - let p: Node | null = node.parentNode; - while (p && p.nodeType === 1) + const p = node.parentElement; + return p ? this.ancestorOrSelfPill(p) : false; + } + + /** el 或其祖先鏈任一為 pill(記憶化)。沿祖先鏈迭代(非遞迴,任意深度棧安全)至最近之 + * 已快取/pill 命中/鏈頂,途中非 pill 元素入棧、以斷點之值一次回填——每元素恰算一次, + * 兄弟共用祖先鏈快取。insidePill 為 pillMatches 之最大來源(S3 延伸)。 */ + private ancestorOrSelfPill(el: Element): boolean + { + const chain: Element[] = []; + let cur: Element | null = el; + let base = false; + while (cur) { - if (this.pillMatches(p as Element)) { return true; } - p = p.parentNode; + const c = this.pillAncestorCache.get(cur); + if (c !== undefined) { base = c; break; } + if (this.pillMatches(cur)) { this.pillAncestorCache.set(cur, true); base = true; break; } + chain.push(cur); // pillMatches 為 false,其值=斷點之 base + cur = cur.parentElement; } - return false; + for (const e of chain) { this.pillAncestorCache.set(e, base); } + return base; } /** @@ -194,18 +238,29 @@ export class Finder * 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */ isAvoided(node: Node): boolean { - if (node.nodeType === 1) + if (node.nodeType === 1) { return this.ancestorOrSelfAvoided(node as Element); } + // 非元素節點:自身僅判 isSkipped 謂詞(無標籤/屬性),祖先走元素鏈(記憶化)。 + if (this.userIsSkipped && this.userIsSkipped(node)) { return true; } + const p = node.parentElement; + return p ? this.ancestorOrSelfAvoided(p) : false; + } + + /** el 或其祖先鏈任一為 avoid 邊界(記憶化,迭代回填,同 ancestorOrSelfPill)。 */ + private ancestorOrSelfAvoided(el: Element): boolean + { + const chain: Element[] = []; + let cur: Element | null = el; + let base = false; + while (cur) { - if (this.avoidsSelf(node as Element)) { return true; } + const c = this.avoidAncestorCache.get(cur); + if (c !== undefined) { base = c; break; } + if (this.avoidsSelf(cur)) { this.avoidAncestorCache.set(cur, true); base = true; break; } + chain.push(cur); + cur = cur.parentElement; } - else if (this.userIsSkipped && this.userIsSkipped(node)) { return true; } - let p: Node | null = node.parentNode; - while (p && p.nodeType === 1) - { - if (this.avoidsSelf(p as Element)) { return true; } - p = p.parentNode; - } - return false; + for (const e of chain) { this.avoidAncestorCache.set(e, base); } + return base; } /** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */ diff --git a/src/index.ts b/src/index.ts index e983e6b..88b3a2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -160,17 +160,163 @@ export interface Juzhen { render(root?: Element | string): void; revert(root?: Element | string): void; + /** revert 後重新 render 同一(子)樹(C3 增量便利);等價於 revert(root) + render(root)。 + * 頁面局部變化後,對受影響之最近塊調用即可只重處理該子樹、免全樹重掃。 */ + rerender(root?: Element | string): void; + /** 清除該(子)樹之內部元素快取(C1)。**revert 已自動失效其子樹**,故僅在「改了既有元素之 + * 分類相關屬性(class/data-jz-*/lang 等)但不走 revert」時才需顯式調用,以免下次 render + * 沿用陳舊分類。內容(子節點/文本)變化不改分類、無需 invalidate(但須 revert+render 該塊 + * 方能重處理)。 */ + invalidate(root?: Element | string): void; + /** 開始觀察 root 子樹之 DOM 變化(C2 增量模式,opt-in):內容/分類屬性變化時自動只重處理 + * 受影響之最近塊(revert+render),新增之塊直接 render——免消費端手動 rerender 或全樹重掃。 + * **juzhen 自身之變更**(jz-* 注入、data-jz* 屬性)於處理期間暫停觀察、絕不自觸發。需 + * MutationObserver(瀏覽器/jsdom 有;無則 no-op)。重複 observe 前自動 disconnect 前者。 */ + observe(root?: Element | string): void; + /** 停止 C2 觀察。 */ + disconnect(): void; readonly options: ResolvedOptions; } +// C2 觀察之屬性白名單:僅「影響分類/處理」之屬性觸發重處理(其餘如 style/title/aria-* +// 不影響 juzhen 輸出,忽略以免無謂重掃)。刻意**不含** data-jz*(juzhen 自身注入)。 +const OBSERVE_ATTRS = [ + "class", "lang", "id", "contenteditable", + "data-jz-skip", "data-jz-level", "data-jz-style", "data-juzhen", "data-juzhen-off", +]; + /** 建立一個聚珍實例。 */ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen { const options = normalizeOptions(opts); + // 持久化 Finder(C1):跨 render/revert 復用元素鍵快取(category/level/feature/祖先游走 + // 記憶化)。舊版每次 makeCtx new Finder、快取單次 render 即棄;持久化後,未變子樹之快取跨 + // render 復用(如串流追加內容時,既有祖先之 level/feature 免重算)。**正確性**靠失效:revert + // 自動清其子樹快取(安全預設),顯式 invalidate 供「改屬性不 revert」之情形。移除之元素由 + // WeakMap 自動回收。 + const finder = new Finder(options); function makeCtx(root: Element): RenderContext { - return { root, options, finder: new Finder(options) }; + return { root, options, finder }; + } + + function renderImpl(el: Element): void + { + runPasses(PASSES, makeCtx(el)); + // margin 後備模式以 root 屬性作 CSS 開關(見 css/_jiya.css);halt 模式不設。 + if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") + { + el.setAttribute("data-jz-halfwidth", "margin"); + } + // justifyAtoms:false → jz-char/jz-jinze 改 inline(分頁器相容,見 css)。 + if (!options.justifyAtoms) + { + el.setAttribute("data-jz-atoms", "inline"); + } + } + + function revertImpl(el: Element): void + { + revertPasses(PASSES, makeCtx(el)); + el.removeAttribute("data-jz-halfwidth"); + el.removeAttribute("data-jz-atoms"); + // 失效(安全預設):清 el 子樹之元素鍵快取,使後續 render 對「屬性已變」之元素重新分類。 + // 內容變化雖不改分類(此清屬多餘),但廉價且無害,且 render 本就將重掃該子樹。 + finder.invalidate(el); + } + + // ----- C2 觀察狀態 ----- + let observer: MutationObserver | null = null; + let observeRoot: Element | null = null; + const observeConfig: MutationObserverInit = { + subtree: true, childList: true, characterData: true, + attributes: true, attributeFilter: OBSERVE_ATTRS.slice(), + }; + + // render/revert 期間暫停觀察 → juzhen 自身之 DOM 變更不入觀察隊列、絕不自觸發。 + function pauseObserver(): void { if (observer) { observer.disconnect(); } } + function resumeObserver(): void + { + if (observer && observeRoot) + { + observer.takeRecords(); // 丟棄暫停期間之殘留(保險) + observer.observe(observeRoot, observeConfig); + } + } + + /** node 之最近塊祖先(含自身若為塊);達 observeRoot 仍無塊 → 回 observeRoot。 */ + function nearestBlock(node: Node): Element | null + { + let e: Element | null = node.nodeType === 1 ? (node as Element) : node.parentElement; + while (e) + { + if (options.finder.blockTags.has(e.nodeName)) { return e; } + if (e === observeRoot) { break; } + e = e.parentElement; + } + return observeRoot; + } + + function onMutations(records: MutationRecord[]): void + { + const rerenderBlocks = new Set(); // 既有塊:revert+render + const renderNew: Element[] = []; // 新增之塊:直接 render(無須 revert) + for (const m of records) + { + if (m.type === "childList") + { + m.addedNodes.forEach((n) => + { + if (n.nodeType === 1 && options.finder.blockTags.has(n.nodeName)) + { + // 新增之塊:**通常**獨立 render(串流追加免全容器重掃)。例外:若其塊級 + // 父 P 原為**葉塊**(帶 `jz-orphan`——垂懸字為唯一「僅作用於葉塊」之 pass), + // 追加塊令 P 轉非葉,全量路徑會撤除 P 之 `jz-orphan`;故此時改重處理 P + // (涵蓋新塊),否則增量會殘留陳舊 orphan(IV-3 差分複驗發現)。純塊容器 + // (無直接內容、無 jz-orphan)不觸此例外、仍最小 render 新塊。 + const np = n as Element; + const P = np.parentElement; + const pIsBlock = !!P && options.finder.blockTags.has(P.nodeName); + const pHadLeafOrphan = !!P && Array.prototype.some.call( + P.children, (c) => (c as Element).nodeName === "JZ-ORPHAN"); + if (pIsBlock && pHadLeafOrphan) { rerenderBlocks.add(P as Element); } + else if (!pIsBlock && P) { const b = nearestBlock(P); if (b) { rerenderBlocks.add(b); } } + else { renderNew.push(np); } + } + else if (n.nodeType === 1 || n.nodeType === 3) + { + // 新增之行內元素/文本 → 所在塊之間隙/禁則邊界變,重處理最近塊。 + const b = nearestBlock(m.target); + if (b) { rerenderBlocks.add(b); } + } + }); + if (m.removedNodes.length) + { + const b = nearestBlock(m.target); + if (b) { rerenderBlocks.add(b); } + } + } + else // attributes(白名單)/characterData(文本變化) + { + const b = nearestBlock(m.target); + if (b) { rerenderBlocks.add(b); } + } + } + if (!rerenderBlocks.size && !renderNew.length) { return; } + + pauseObserver(); + // 去嵌套:只保留極大塊(移除為他者後代者),免重複重處理(重複亦正確、僅浪費)。 + const all = Array.from(rerenderBlocks).filter((b) => b.isConnected); + const blocks = all.filter((b) => !all.some((o) => o !== b && o.contains(b))); + for (const b of blocks) { revertImpl(b); renderImpl(b); } + for (const el of renderNew) + { + if (!el.isConnected) { continue; } // 同批又被移除 + if (blocks.some((b) => b.contains(el))) { continue; } // 已由塊重處理涵蓋 + renderImpl(el); + } + resumeObserver(); } return { @@ -179,25 +325,38 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen { const el = resolveRoot(root, options); if (!el) { return; } - runPasses(PASSES, makeCtx(el)); - // margin 後備模式以 root 屬性作 CSS 開關(見 css/_jiya.css);halt 模式不設。 - if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") - { - el.setAttribute("data-jz-halfwidth", "margin"); - } - // justifyAtoms:false → jz-char/jz-jinze 改 inline(分頁器相容,見 css)。 - if (!options.justifyAtoms) - { - el.setAttribute("data-jz-atoms", "inline"); - } + pauseObserver(); renderImpl(el); resumeObserver(); }, revert(root?: Element | string): void { const el = resolveRoot(root, options); if (!el) { return; } - revertPasses(PASSES, makeCtx(el)); - el.removeAttribute("data-jz-halfwidth"); - el.removeAttribute("data-jz-atoms"); + pauseObserver(); revertImpl(el); resumeObserver(); + }, + rerender(root?: Element | string): void + { + const el = resolveRoot(root, options); + if (!el) { return; } + pauseObserver(); revertImpl(el); renderImpl(el); resumeObserver(); + }, + invalidate(root?: Element | string): void + { + const el = resolveRoot(root, options); + if (el) { finder.invalidate(el); } + }, + observe(root?: Element | string): void + { + if (typeof MutationObserver === "undefined") { return; } // 無版面環境:no-op + const el = resolveRoot(root, options); + if (!el) { return; } + if (observer) { observer.disconnect(); } + observeRoot = el; + observer = new MutationObserver(onMutations); + observer.observe(el, observeConfig); + }, + disconnect(): void + { + if (observer) { observer.disconnect(); observer = null; observeRoot = null; } }, }; } diff --git a/src/typeset/lineedge.ts b/src/typeset/lineedge.ts index 1da6f14..de0ce6a 100644 --- a/src/typeset/lineedge.ts +++ b/src/typeset/lineedge.ts @@ -249,12 +249,40 @@ export const lineEdgePass: StandalonePass = { const TRIM = "jz-hws-trim"; +// 尾隨 margin-right 是否**可見**、需去行末隙,取決於容器對齊: +// · justify/justify-all → 右緣 flush,行末 margin 內縮右緣 0.25em(需去); +// · right/end(ltr) → 內容離右緣 0.25em(需去);· center → 行內容含 margin 一併置中、 +// 偏移 0.125em(需去); +// · **left/start(ltr) → 右緣 ragged,行末 margin 落於不可見側**(可跳過)。 +// 唯 left/start(ltr) 可安全整批跳過,省其每個 O(gaps) 次同步重排(S1:常見之非兩端對齊左 +// 起內容零成本;其餘對齊之逐一定案為 margin 機制固有代價、保留)。RTL 一律保留(保守)。 +// text-align/direction 皆繼承,故同一 parentElement 下之間隙共用同值,per-parent memo。 +function skippableAlign(g: Element, cache: Map): boolean +{ + const key = g.parentElement; + if (!key) { return false; } + const cached = cache.get(key); + if (cached !== undefined) { return cached; } + let result = false; + const view = g.ownerDocument ? g.ownerDocument.defaultView : null; + if (view) + { + const cs = view.getComputedStyle(g); + const ltr = cs.direction !== "rtl"; + result = cs.textAlign === "left" || (cs.textAlign === "start" && ltr); + } + cache.set(key, result); + return result; +} + function trimGaps(root: Element, finder: Finder, wrapSet: Set): void { + const alignCache = new Map(); const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") - && finder.featureEnabledFor(g, "spacing"), + && finder.featureEnabledFor(g, "spacing") + && !skippableAlign(g, alignCache), ); if (!gaps.length) { return; } diff --git a/test/bench/bench-incremental.mjs b/test/bench/bench-incremental.mjs new file mode 100644 index 0000000..9aef0c1 --- /dev/null +++ b/test/bench/bench-incremental.mjs @@ -0,0 +1,110 @@ +// 增量恢復基準(jsdom)。度量「頁面局部變化」之恢復成本:全樹 revert+render(舊法) +// vs 局部 rerender(第三層 C3/C2)。確定性 el.matches() 計數 + 計時中位數。 +// +// 用法:node test/bench/bench-incremental.mjs [blocks] [iters] + +import { JSDOM } from "jsdom"; +import { createJuzhen } from "../../dist/juzhen.mjs"; + +const BLOCKS = Number(process.argv[2] || 400); +const ITERS = Number(process.argv[3] || 7); + +function buildHtml(n) +{ + let html = '
'; + for (let i = 0; i < n; i += 1) + { + html += '

第' + i + '段落文字粗體與 Latin word 混排,' + + '句內點號、頓號;「引號」(括號)末尾實義字甲乙丙丁。

'; + } + html += '
'; + return html; +} +const HTML = buildHtml(BLOCKS); + +function fresh() +{ + const dom = new JSDOM("" + HTML + ""); + globalThis.document = dom.window.document; + globalThis.NodeFilter = dom.window.NodeFilter; + globalThis.Node = dom.window.Node; + globalThis.window = dom.window; + globalThis.MutationObserver = dom.window.MutationObserver; + return dom; +} + +function patchMatches(dom, counter) +{ + let p = dom.window.document.querySelector("strong"); + let proto = Object.getPrototypeOf(p); + while (proto && !Object.prototype.hasOwnProperty.call(proto, "matches")) + { + proto = Object.getPrototypeOf(proto); + } + const orig = proto.matches; + proto.matches = function (s) { counter.n += 1; return orig.call(this, s); }; + return () => { proto.matches = orig; }; +} + +const median = (a) => a.slice().sort((x, y) => x - y)[Math.floor(a.length / 2)]; + +// ---- 場景:編輯「中間某一段」後恢復 ---- +function editTarget(doc) { return doc.querySelectorAll("p")[Math.floor(BLOCKS / 2)]; } + +// 全樹法:revert(root)+render(root)。 +function fullCost(measureMatches) +{ + const times = []; + let matches = 0; + for (let k = 0; k < ITERS; k += 1) + { + const dom = fresh(); + const doc = dom.window.document; + const jz = createJuzhen(); + jz.render(doc.body); + const p = editTarget(doc); + p.textContent = "改後第 K 段,新的實義字戊己庚辛。"; + let restore, counter; + if (measureMatches && k === 0) { counter = { n: 0 }; restore = patchMatches(dom, counter); } + const t0 = performance.now(); + jz.revert(doc.body); + jz.render(doc.body); + times.push(performance.now() - t0); + if (restore) { restore(); matches = counter.n; } + } + return { ms: median(times), matches }; +} + +// 增量法:rerender(改動塊)。 +function incCost(measureMatches) +{ + const times = []; + let matches = 0; + for (let k = 0; k < ITERS; k += 1) + { + const dom = fresh(); + const doc = dom.window.document; + const jz = createJuzhen(); + jz.render(doc.body); + const p = editTarget(doc); + p.textContent = "改後第 K 段,新的實義字戊己庚辛。"; + let restore, counter; + if (measureMatches && k === 0) { counter = { n: 0 }; restore = patchMatches(dom, counter); } + const t0 = performance.now(); + jz.rerender(p); + times.push(performance.now() - t0); + if (restore) { restore(); matches = counter.n; } + } + return { ms: median(times), matches }; +} + +const full = fullCost(true); +const inc = incCost(true); + +console.log("增量恢復基準(" + BLOCKS + " 段;編輯中間一段後恢復):"); +console.log(" 全樹 revert+render :", full.ms.toFixed(2), "ms |", + "el.matches()", full.matches); +console.log(" 局部 rerender(塊) :", inc.ms.toFixed(2), "ms |", + "el.matches()", inc.matches); +console.log(" 提速:時間 " + (full.ms / inc.ms).toFixed(1) + "× | matches " + + (full.matches / Math.max(1, inc.matches)).toFixed(1) + "×"); diff --git a/test/bench/bench-layout.html b/test/bench/bench-layout.html new file mode 100644 index 0000000..7b9b8e6 --- /dev/null +++ b/test/bench/bench-layout.html @@ -0,0 +1,78 @@ + + + + + + + + +
pending
+
+ + + + diff --git a/test/helpers.mjs b/test/helpers.mjs index 2253288..6c2577f 100644 --- a/test/helpers.mjs +++ b/test/helpers.mjs @@ -16,6 +16,7 @@ export function setupDom(bodyHtml = "") globalThis.NodeFilter = dom.window.NodeFilter; globalThis.Node = dom.window.Node; globalThis.window = dom.window; + globalThis.MutationObserver = dom.window.MutationObserver; // C2 觀察(jsdom 支持) return { dom, document: dom.window.document }; } diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index 2339141..d333b95 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -1344,3 +1344,181 @@ test("v1 相容層已移除:createCjkAutospace 不再導出(消費端已全 const mod = await import("../dist/juzhen.mjs"); assert.equal(mod.createCjkAutospace, undefined, "compat 導出已刪"); }); + +// ----- 第三層 增量恢復(C1 持久化 Finder + 失效/C3 rerender)----- + +test("C3 rerender:局部子樹 revert+render,內容變化被重新處理", () => +{ + const { document } = setupDom( + '

春天來了,很暖和。

夏天到了,很热。

', + ); + const jz = createJuzhen(); + jz.render(document.body); + const p1 = document.querySelectorAll("p")[0]; + // 改 p1 內容 → rerender(p1) 只重處理 p1。 + jz.revert(p1); + p1.textContent = "秋天涼了,落葉紛紛。"; + jz.render(p1); + assert.equal(p1.textContent, "秋天涼了,落葉紛紛。", "p1 內容更新"); + assert.ok(p1.querySelector("jz-char"), "p1 重新 charify(標點原子化)"); + // rerender 便利方法亦可用(等價 revert+render)。 + jz.rerender(p1); + assert.ok(p1.querySelector("jz-char"), "rerender 後仍正確處理"); +}); + +test("C1 失效正確性:既有元素分類屬性變化(transparent→pill)經 revert+render 正確重分類", () => +{ + const { document } = setupDom('

前文甲,乙後文標點。

'); + const jz = createJuzhen({ pillSelector: ".pill" }); + jz.render(document.body); + const span = document.querySelector("span"); + // 初始 span 透明 → 其內標點 ,被 charify。 + assert.ok(span.querySelector("jz-char"), "透明 span 內標點被 charify"); + // revert(自動失效子樹快取),令 span 成 pill,再 render。 + jz.revert(document.body); + span.classList.add("pill"); + jz.render(document.body); + // pill 內部不可見 → 不 charify。若持久化快取未失效、沿用「透明」則會誤 charify——本測驗證 + // revert 之自動失效確實生效(元素分類屬性變化被正確反映)。 + assert.equal( + document.querySelector("span").querySelectorAll("jz-char").length, 0, + "成為 pill 後其內不再 charify(快取已失效、重新分類為 pill)", + ); +}); + +test("C1 invalidate:顯式失效後元素分類屬性變化亦被反映(不經 revert 之失效入口)", () => +{ + const { document } = setupDom('

前文丙,丁尾。

'); + const jz = createJuzhen({ pillSelector: ".pill" }); + jz.render(document.body); + // 手動移除產物(模擬消費端自管 DOM),令 span 成 pill,顯式 invalidate 後 render。 + jz.revert(document.body); + // 先 render 一次填充快取(span 為透明),再顯式 invalidate 驗證其清快取之效。 + jz.render(document.body); + jz.revert(document.body); + const span = document.querySelector("span"); + span.classList.add("pill"); + jz.invalidate(document.body); // 顯式失效 + jz.render(document.body); + assert.equal( + document.querySelector("span").querySelectorAll("jz-char").length, 0, + "invalidate 後成為 pill 之 span 內不再 charify", + ); +}); + +test("C1 持久化快取不串擾:同實例多次 render 不同子樹,結果各自正確", () => +{ + const { document } = setupDom( + '
甲文字,甲標點。
乙文字、乙標點。
', + ); + const jz = createJuzhen(); + const a = document.getElementById("a"); + const b = document.getElementById("b"); + jz.render(a); + jz.render(b); + assert.ok(a.querySelector("jz-char") && b.querySelector("jz-char"), "兩子樹各自 charify"); + assert.equal(a.textContent, "甲文字,甲標點。", "a 內容不變"); + assert.equal(b.textContent, "乙文字、乙標點。", "b 內容不變"); + jz.revert(a); + assert.equal(countTag(a, "jz-char"), 0, "revert a 清 a 產物"); + assert.ok(b.querySelector("jz-char"), "b 不受 revert a 影響"); +}); + +// ----- 第三層 C2 MutationObserver 增量模式(jsdom 支持 MutationObserver)----- + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +test("C2 觀察:內容編輯自動重處理最近塊", async () => +{ + const { document } = setupDom('

原本的文字,內容。

'); + const jz = createJuzhen(); + jz.render(document.body); + jz.observe(document.body); + const p = document.querySelector("p"); + p.textContent = "改後的文字、新標點。"; + await tick(); + jz.disconnect(); + assert.equal(p.textContent, "改後的文字、新標點。", "內容為新值"); + assert.ok(p.querySelector("jz-char"), "編輯後自動重 charify(觀察生效)"); +}); + +test("C2 觀察:追加新塊自動 render(不重掃既有容器)", async () => +{ + const { document } = setupDom('

既有段落文字。

'); + const c = document.getElementById("c"); + const jz = createJuzhen(); + jz.render(c); + jz.observe(c); + const np = document.createElement("p"); + np.textContent = "追加的新段落,實義字甲乙丙丁。"; + c.appendChild(np); + await tick(); + jz.disconnect(); + assert.ok(np.querySelector("jz-char"), "新塊自動 charify"); + assert.ok(np.querySelector("jz-orphan"), "新塊亦跑段落級 pass(垂懸字)"); +}); + +test("C2 觀察:分類屬性變化(transparent→pill)自動重分類(快取失效聯動)", async () => +{ + const { document } = setupDom('

前文甲,乙後標點。

'); + const jz = createJuzhen({ pillSelector: ".pill" }); + jz.render(document.body); + jz.observe(document.body); + assert.ok(document.querySelector("span").querySelector("jz-char"), "初始透明 span 內標點被 charify"); + document.querySelector("span").classList.add("pill"); + await tick(); + jz.disconnect(); + assert.equal( + document.querySelector("span").querySelectorAll("jz-char").length, 0, + "成 pill 後自動重處理、其內不再 charify(觀察→revert 失效→重分類)", + ); +}); + +test("C2 觀察:不自觸發(juzhen 自身注入不致無限迴圈;一次編輯僅結算一次)", async () => +{ + const { document } = setupDom('

測試文字,標點。

'); + const jz = createJuzhen(); + jz.render(document.body); + jz.observe(document.body); + const p = document.querySelector("p"); + p.textContent = "新文字、標點。"; + await tick(); + await tick(); // 若自觸發、迴圈未止,多等一輪應顯露(產物重複增生) + jz.disconnect(); + // 冪等且穩定:末尾標點恰一個 jz-char(無因迴圈重複增生)。 + assert.ok(p.querySelector("jz-char"), "已處理"); + assert.equal(countTag(p, "jz-orphan") <= 1, true, "無迴圈致 jz-orphan 增生"); +}); + +test("C2 disconnect:停止觀察後編輯不再自動處理", async () => +{ + const { document } = setupDom('

初始文字,標點。

'); + const jz = createJuzhen(); + jz.render(document.body); + jz.observe(document.body); + jz.disconnect(); + const p = document.querySelector("p"); + p.textContent = "斷開後編輯標點。"; + await tick(); + assert.equal(countTag(p, "jz-char"), 0, "disconnect 後不自動處理(純文本、無 jz-char)"); +}); + +test("C2 觀察:向葉塊追加子塊 → 撤除容器陳舊 jz-orphan(IV-3 差分複驗所發現之修復)", async () => +{ + const { document } = setupDom('
容器直接文字結尾實義字甲乙丙丁。
'); + const c = document.getElementById("c"); + const jz = createJuzhen(); + jz.render(c); + jz.observe(c); + assert.ok(c.querySelector("jz-orphan"), "初始:葉塊 div 有 jz-orphan"); + const np = document.createElement("p"); + np.textContent = "追加段落實義字戊己庚辛。"; + c.appendChild(np); + await tick(); + jz.disconnect(); + // div 轉非葉 → 其**直接**內容之 jz-orphan 須撤除(僅內層新 p 之 jz-orphan 保留)。 + // 舊實作只 render 新塊、殘留容器陳舊 orphan → 與全樹重渲染分歧(IV-3 發現)。 + const directOrphan = Array.from(c.children).some((x) => x.nodeName === "JZ-ORPHAN"); + assert.equal(directOrphan, false, "容器轉非葉後,其直接 jz-orphan 已撤除"); + assert.ok(np.querySelector("jz-orphan"), "新塊 p 自身之 jz-orphan 正常"); +});