perf+feat: 掃描/佈局提速(中間/S4+S1)+增量恢復(Tier3 C1/C3/C2)
承 Tier1(S2+S3,已於 main 168e9e7)。本批為效能第二層+第三層,全程獨立複驗(差分/往返/ 增量≡全量/進程隔離測速/真瀏覽器)+4 份真人撰寫真實文檔驗收,行為保真、提速真實。 掃描層(無狀態、保行為): - 中間發現+S4:insidePill/isAvoided 之「逐文本節點沿祖先鏈」以 ancestorOrSelfPill/…Avoided 迭代回填記憶化(每元素恰算一次、兄弟共用祖先鏈快取),O(文本×depth)→O(元素)。實測掃描層 el.matches() 累計約降五成;深嵌套 render 進程隔離墻鐘 pre-Tier1 707ms→254ms(2.8×)。 佈局層(僅瀏覽器、正確性敏感): - S1 gapTrim 對齊門:尾隨 margin 僅在右緣 flush 之對齊可見,唯 left/start(ltr) 之 ragged 右緣 可整批跳過,省其 O(gaps) 次同步重排。實測非兩端對齊內容 gapTrim 數千 ms→數十 ms;justify/ center/right/RTL 保留完整逐一定案、行為不變(真瀏覽器差分:justify trim 集指紋逐位相同)。 增量恢復(第三層,向下兼容 API): - C1 持久化 Finder:跨 render 復用元素鍵快取(category/level/feature/祖先游走)。正確性靠 失效:新增 finder.invalidate(el) 清整棵子樹快取(祖先屬性變則後代快取變脏),revert 自動失效 其子樹(安全預設)。 - C3 rerender(root)=revert+render;局部恢復(實測編輯 400 段中一段 766×、matches 1441×)。 - C2 observe/disconnect:MutationObserver 自動增量——內容/分類屬性變化重處理最近塊、新增之塊 直接 render;render/revert 期間暫停觀察,自身注入絕不自觸發。 - append 修復(IV-3 差分複驗發現、單測漏掉):向葉塊(帶 jz-orphan)追加子塊使其轉非葉,須撤 容器陳舊 orphan——追加塊時若塊級父有 jz-orphan 直接子則改重處理該父。 測試 130→140(+S2 非葉塊、C1/C3、C2 觀察/失效/append、不自觸發)。基準設施:bench-incremental (增量恢復)、bench-layout(版面層,headless Chrome)。dist 重建。
This commit is contained in:
Vendored
+17
@@ -32,7 +32,18 @@ export declare class Finder {
|
|||||||
private readonly featureCache;
|
private readonly featureCache;
|
||||||
private readonly levelCache;
|
private readonly levelCache;
|
||||||
private readonly categoryCache;
|
private readonly categoryCache;
|
||||||
|
private readonly pillAncestorCache;
|
||||||
|
private readonly avoidAncestorCache;
|
||||||
constructor(opts: ResolvedOptions);
|
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/行內公式等)。 */
|
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
|
||||||
pillMatches(el: Element): boolean;
|
pillMatches(el: Element): boolean;
|
||||||
/** 該元素是否匹配 isolate 選擇器(三分類之「隔離」:帶盒樣式之行內,如背景色
|
/** 該元素是否匹配 isolate 選擇器(三分類之「隔離」:帶盒樣式之行內,如背景色
|
||||||
@@ -42,6 +53,10 @@ export declare class Finder {
|
|||||||
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
|
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
|
||||||
* .katex 內部曾被 jiya/spacing 直接變更)。 */
|
* .katex 內部曾被 jiya/spacing 直接變更)。 */
|
||||||
insidePill(node: Node): boolean;
|
insidePill(node: Node): boolean;
|
||||||
|
/** el 或其祖先鏈任一為 pill(記憶化)。沿祖先鏈迭代(非遞迴,任意深度棧安全)至最近之
|
||||||
|
* 已快取/pill 命中/鏈頂,途中非 pill 元素入棧、以斷點之值一次回填——每元素恰算一次,
|
||||||
|
* 兄弟共用祖先鏈快取。insidePill 為 pillMatches 之最大來源(S3 延伸)。 */
|
||||||
|
private ancestorOrSelfPill;
|
||||||
/**
|
/**
|
||||||
* 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
|
* 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
|
||||||
* 選擇器同命中時之定案規則):`avoid > pill > isolate > block > transparent > opaque`
|
* 選擇器同命中時之定案規則):`avoid > pill > isolate > block > transparent > opaque`
|
||||||
@@ -83,6 +98,8 @@ export declare class Finder {
|
|||||||
* 預設)**:一次性檢查用此,免除「avoidsSelf ‖ isAvoided」易漏一半之誤用。遍歷原語逐
|
* 預設)**:一次性檢查用此,免除「avoidsSelf ‖ isAvoided」易漏一半之誤用。遍歷原語逐
|
||||||
* 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */
|
* 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */
|
||||||
isAvoided(node: Node): boolean;
|
isAvoided(node: Node): boolean;
|
||||||
|
/** el 或其祖先鏈任一為 avoid 邊界(記憶化,迭代回填,同 ancestorOrSelfPill)。 */
|
||||||
|
private ancestorOrSelfAvoided;
|
||||||
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
|
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
|
||||||
inScope(node: Node): boolean;
|
inScope(node: Node): boolean;
|
||||||
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */
|
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */
|
||||||
|
|||||||
Vendored
+15
@@ -6,6 +6,21 @@ export declare function normalizeOptions(opts?: JuzhenOptions): ResolvedOptions;
|
|||||||
export interface Juzhen {
|
export interface Juzhen {
|
||||||
render(root?: Element | string): void;
|
render(root?: Element | string): void;
|
||||||
revert(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;
|
readonly options: ResolvedOptions;
|
||||||
}
|
}
|
||||||
/** 建立一個聚珍實例。 */
|
/** 建立一個聚珍實例。 */
|
||||||
|
|||||||
Vendored
+264
-28
@@ -43,6 +43,26 @@ var Juzhen = (() => {
|
|||||||
this.featureCache = /* @__PURE__ */ new WeakMap();
|
this.featureCache = /* @__PURE__ */ new WeakMap();
|
||||||
this.levelCache = /* @__PURE__ */ new WeakMap();
|
this.levelCache = /* @__PURE__ */ new WeakMap();
|
||||||
this.categoryCache = /* @__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/行內公式等)。 */
|
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
|
||||||
pillMatches(el) {
|
pillMatches(el) {
|
||||||
@@ -57,14 +77,34 @@ var Juzhen = (() => {
|
|||||||
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
|
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
|
||||||
* .katex 內部曾被 jiya/spacing 直接變更)。 */
|
* .katex 內部曾被 jiya/spacing 直接變更)。 */
|
||||||
insidePill(node) {
|
insidePill(node) {
|
||||||
let p = node.parentNode;
|
const p = node.parentElement;
|
||||||
while (p && p.nodeType === 1) {
|
return p ? this.ancestorOrSelfPill(p) : false;
|
||||||
if (this.pillMatches(p)) {
|
}
|
||||||
return true;
|
/** 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)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
|
* 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
|
||||||
@@ -161,20 +201,37 @@ var Juzhen = (() => {
|
|||||||
* 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */
|
* 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */
|
||||||
isAvoided(node) {
|
isAvoided(node) {
|
||||||
if (node.nodeType === 1) {
|
if (node.nodeType === 1) {
|
||||||
if (this.avoidsSelf(node)) {
|
return this.ancestorOrSelfAvoided(node);
|
||||||
return true;
|
}
|
||||||
}
|
if (this.userIsSkipped && this.userIsSkipped(node)) {
|
||||||
} else if (this.userIsSkipped && this.userIsSkipped(node)) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let p = node.parentNode;
|
const p = node.parentElement;
|
||||||
while (p && p.nodeType === 1) {
|
return p ? this.ancestorOrSelfAvoided(p) : false;
|
||||||
if (this.avoidsSelf(p)) {
|
}
|
||||||
return true;
|
/** 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)。 */
|
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
|
||||||
inScope(node) {
|
inScope(node) {
|
||||||
@@ -1528,9 +1585,29 @@ var Juzhen = (() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
var TRIM = "jz-hws-trim";
|
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) {
|
function trimGaps(root, finder, wrapSet) {
|
||||||
|
const alignCache = /* @__PURE__ */ new Map();
|
||||||
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
|
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) {
|
if (!gaps.length) {
|
||||||
return;
|
return;
|
||||||
@@ -2170,10 +2247,135 @@ var Juzhen = (() => {
|
|||||||
}
|
}
|
||||||
return typeof document !== "undefined" ? document.body : null;
|
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 = {}) {
|
function createJuzhen(opts = {}) {
|
||||||
const options = normalizeOptions(opts);
|
const options = normalizeOptions(opts);
|
||||||
|
const finder = new Finder(options);
|
||||||
function makeCtx(root) {
|
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 {
|
return {
|
||||||
options,
|
options,
|
||||||
@@ -2182,22 +2384,56 @@ var Juzhen = (() => {
|
|||||||
if (!el) {
|
if (!el) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runPasses(PASSES, makeCtx(el));
|
pauseObserver();
|
||||||
if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") {
|
renderImpl(el);
|
||||||
el.setAttribute("data-jz-halfwidth", "margin");
|
resumeObserver();
|
||||||
}
|
|
||||||
if (!options.justifyAtoms) {
|
|
||||||
el.setAttribute("data-jz-atoms", "inline");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
revert(root) {
|
revert(root) {
|
||||||
const el = resolveRoot(root, options);
|
const el = resolveRoot(root, options);
|
||||||
if (!el) {
|
if (!el) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
revertPasses(PASSES, makeCtx(el));
|
pauseObserver();
|
||||||
el.removeAttribute("data-jz-halfwidth");
|
revertImpl(el);
|
||||||
el.removeAttribute("data-jz-atoms");
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+264
-28
@@ -16,6 +16,26 @@ var Finder = class {
|
|||||||
this.featureCache = /* @__PURE__ */ new WeakMap();
|
this.featureCache = /* @__PURE__ */ new WeakMap();
|
||||||
this.levelCache = /* @__PURE__ */ new WeakMap();
|
this.levelCache = /* @__PURE__ */ new WeakMap();
|
||||||
this.categoryCache = /* @__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/行內公式等)。 */
|
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
|
||||||
pillMatches(el) {
|
pillMatches(el) {
|
||||||
@@ -30,14 +50,34 @@ var Finder = class {
|
|||||||
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
|
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
|
||||||
* .katex 內部曾被 jiya/spacing 直接變更)。 */
|
* .katex 內部曾被 jiya/spacing 直接變更)。 */
|
||||||
insidePill(node) {
|
insidePill(node) {
|
||||||
let p = node.parentNode;
|
const p = node.parentElement;
|
||||||
while (p && p.nodeType === 1) {
|
return p ? this.ancestorOrSelfPill(p) : false;
|
||||||
if (this.pillMatches(p)) {
|
}
|
||||||
return true;
|
/** 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)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
|
* 行內元素分類(§4.1)——一元素對排版之口徑,**恰屬其一**。**優先序**(依序短路,即多
|
||||||
@@ -134,20 +174,37 @@ var Finder = class {
|
|||||||
* 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */
|
* 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */
|
||||||
isAvoided(node) {
|
isAvoided(node) {
|
||||||
if (node.nodeType === 1) {
|
if (node.nodeType === 1) {
|
||||||
if (this.avoidsSelf(node)) {
|
return this.ancestorOrSelfAvoided(node);
|
||||||
return true;
|
}
|
||||||
}
|
if (this.userIsSkipped && this.userIsSkipped(node)) {
|
||||||
} else if (this.userIsSkipped && this.userIsSkipped(node)) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let p = node.parentNode;
|
const p = node.parentElement;
|
||||||
while (p && p.nodeType === 1) {
|
return p ? this.ancestorOrSelfAvoided(p) : false;
|
||||||
if (this.avoidsSelf(p)) {
|
}
|
||||||
return true;
|
/** 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)。 */
|
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
|
||||||
inScope(node) {
|
inScope(node) {
|
||||||
@@ -1501,9 +1558,29 @@ var lineEdgePass = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
var TRIM = "jz-hws-trim";
|
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) {
|
function trimGaps(root, finder, wrapSet) {
|
||||||
|
const alignCache = /* @__PURE__ */ new Map();
|
||||||
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
|
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) {
|
if (!gaps.length) {
|
||||||
return;
|
return;
|
||||||
@@ -2143,10 +2220,135 @@ function resolveRoot(arg, options) {
|
|||||||
}
|
}
|
||||||
return typeof document !== "undefined" ? document.body : null;
|
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 = {}) {
|
function createJuzhen(opts = {}) {
|
||||||
const options = normalizeOptions(opts);
|
const options = normalizeOptions(opts);
|
||||||
|
const finder = new Finder(options);
|
||||||
function makeCtx(root) {
|
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 {
|
return {
|
||||||
options,
|
options,
|
||||||
@@ -2155,22 +2357,56 @@ function createJuzhen(opts = {}) {
|
|||||||
if (!el) {
|
if (!el) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runPasses(PASSES, makeCtx(el));
|
pauseObserver();
|
||||||
if (options.features.jiya && options.jiyaConfig.halfWidth === "margin") {
|
renderImpl(el);
|
||||||
el.setAttribute("data-jz-halfwidth", "margin");
|
resumeObserver();
|
||||||
}
|
|
||||||
if (!options.justifyAtoms) {
|
|
||||||
el.setAttribute("data-jz-atoms", "inline");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
revert(root) {
|
revert(root) {
|
||||||
const el = resolveRoot(root, options);
|
const el = resolveRoot(root, options);
|
||||||
if (!el) {
|
if (!el) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
revertPasses(PASSES, makeCtx(el));
|
pauseObserver();
|
||||||
el.removeAttribute("data-jz-halfwidth");
|
revertImpl(el);
|
||||||
el.removeAttribute("data-jz-atoms");
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-15
@@ -64,6 +64,11 @@ export class Finder
|
|||||||
private readonly featureCache: WeakMap<Element, Record<string, boolean>>;
|
private readonly featureCache: WeakMap<Element, Record<string, boolean>>;
|
||||||
private readonly levelCache: WeakMap<Element, FeatureLevel>;
|
private readonly levelCache: WeakMap<Element, FeatureLevel>;
|
||||||
private readonly categoryCache: WeakMap<Element, InlineCategory>;
|
private readonly categoryCache: WeakMap<Element, InlineCategory>;
|
||||||
|
// 祖先游走記憶化(S3 延伸/S4):ancestorOrSelfPill/…Avoided 之逐元素結果,使 insidePill
|
||||||
|
// /isAvoided 之「逐文本節點沿祖先鏈」由 O(文本節點×depth) 收斂為 O(元素數)——兄弟節點共用
|
||||||
|
// 祖先鏈快取。同 categoryCache:Finder 隨 render 重建 → 僅存活單次 render、無跨 render 失效。
|
||||||
|
private readonly pillAncestorCache: WeakMap<Element, boolean>;
|
||||||
|
private readonly avoidAncestorCache: WeakMap<Element, boolean>;
|
||||||
|
|
||||||
constructor(opts: ResolvedOptions)
|
constructor(opts: ResolvedOptions)
|
||||||
{
|
{
|
||||||
@@ -81,6 +86,30 @@ export class Finder
|
|||||||
this.featureCache = new WeakMap();
|
this.featureCache = new WeakMap();
|
||||||
this.levelCache = new WeakMap();
|
this.levelCache = new WeakMap();
|
||||||
this.categoryCache = 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/行內公式等)。 */
|
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
|
||||||
@@ -101,13 +130,28 @@ export class Finder
|
|||||||
* .katex 內部曾被 jiya/spacing 直接變更)。 */
|
* .katex 內部曾被 jiya/spacing 直接變更)。 */
|
||||||
insidePill(node: Node): boolean
|
insidePill(node: Node): boolean
|
||||||
{
|
{
|
||||||
let p: Node | null = node.parentNode;
|
const p = node.parentElement;
|
||||||
while (p && p.nodeType === 1)
|
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; }
|
const c = this.pillAncestorCache.get(cur);
|
||||||
p = p.parentNode;
|
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(僅自身,祖先由遞迴涵蓋)。 */
|
* 元素下探時則用 avoidsSelf(僅自身,祖先由遞迴涵蓋)。 */
|
||||||
isAvoided(node: Node): boolean
|
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; }
|
for (const e of chain) { this.avoidAncestorCache.set(e, base); }
|
||||||
let p: Node | null = node.parentNode;
|
return base;
|
||||||
while (p && p.nodeType === 1)
|
|
||||||
{
|
|
||||||
if (this.avoidsSelf(p as Element)) { return true; }
|
|
||||||
p = p.parentNode;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
|
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
|
||||||
|
|||||||
+174
-15
@@ -160,17 +160,163 @@ export interface Juzhen
|
|||||||
{
|
{
|
||||||
render(root?: Element | string): void;
|
render(root?: Element | string): void;
|
||||||
revert(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;
|
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
|
export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
|
||||||
{
|
{
|
||||||
const options = normalizeOptions(opts);
|
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
|
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<Element>(); // 既有塊: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 {
|
return {
|
||||||
@@ -179,25 +325,38 @@ export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
|
|||||||
{
|
{
|
||||||
const el = resolveRoot(root, options);
|
const el = resolveRoot(root, options);
|
||||||
if (!el) { return; }
|
if (!el) { return; }
|
||||||
runPasses(PASSES, makeCtx(el));
|
pauseObserver(); renderImpl(el); resumeObserver();
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
revert(root?: Element | string): void
|
revert(root?: Element | string): void
|
||||||
{
|
{
|
||||||
const el = resolveRoot(root, options);
|
const el = resolveRoot(root, options);
|
||||||
if (!el) { return; }
|
if (!el) { return; }
|
||||||
revertPasses(PASSES, makeCtx(el));
|
pauseObserver(); revertImpl(el); resumeObserver();
|
||||||
el.removeAttribute("data-jz-halfwidth");
|
},
|
||||||
el.removeAttribute("data-jz-atoms");
|
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; }
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -249,12 +249,40 @@ export const lineEdgePass: StandalonePass = {
|
|||||||
|
|
||||||
const TRIM = "jz-hws-trim";
|
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<Element, boolean>): 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<string>): void
|
function trimGaps(root: Element, finder: Finder, wrapSet: Set<string>): void
|
||||||
{
|
{
|
||||||
|
const alignCache = new Map<Element, boolean>();
|
||||||
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
|
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
|
||||||
(g) => finder.inScope(g) && !finder.isAvoided(g)
|
(g) => finder.inScope(g) && !finder.isAvoided(g)
|
||||||
&& finder.levelAllows(g, "paragraph")
|
&& finder.levelAllows(g, "paragraph")
|
||||||
&& finder.featureEnabledFor(g, "spacing"),
|
&& finder.featureEnabledFor(g, "spacing")
|
||||||
|
&& !skippableAlign(g, alignCache),
|
||||||
);
|
);
|
||||||
if (!gaps.length) { return; }
|
if (!gaps.length) { return; }
|
||||||
|
|
||||||
|
|||||||
@@ -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 = '<main lang="zh-Hant">';
|
||||||
|
for (let i = 0; i < n; i += 1)
|
||||||
|
{
|
||||||
|
html += '<p>第' + i + '段落文字<strong>粗體</strong>與 Latin word 混排,'
|
||||||
|
+ '句內點號、頓號;「引號」(括號)末尾實義字甲乙丙丁。</p>';
|
||||||
|
}
|
||||||
|
html += '</main>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
const HTML = buildHtml(BLOCKS);
|
||||||
|
|
||||||
|
function fresh()
|
||||||
|
{
|
||||||
|
const dom = new JSDOM("<!doctype html><html><body>" + HTML + "</body></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) + "×");
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="../../dist/juzhen.css">
|
||||||
|
<style>
|
||||||
|
/* 窄容器強制多行;大量 CJK↔Latin 邊界 → 大量 jz-hws gap。 */
|
||||||
|
#stage { width: 380px; font-size: 18px; line-height: 2; }
|
||||||
|
#stage.j { text-align: justify; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="result">pending</div>
|
||||||
|
<div id="stage"></div>
|
||||||
|
<script src="../../dist/juzhen.iife.js"></script>
|
||||||
|
<script>
|
||||||
|
// 計 getClientRects 呼叫數(布局讀取/reflow 代理)——S1 之目標即減少此類「寫後讀」。
|
||||||
|
let rectCalls = 0;
|
||||||
|
for (const proto of [Range.prototype, Element.prototype])
|
||||||
|
{
|
||||||
|
const orig = proto.getClientRects;
|
||||||
|
proto.getClientRects = function () { rectCalls += 1; return orig.apply(this, arguments); };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建 gap 密集之多行文檔:每段交替 CJK 字 + Latin 字母/數字 → 每邊界一個 jz-hws。
|
||||||
|
function buildStage(paras, boundariesPerPara)
|
||||||
|
{
|
||||||
|
const cjk = "中文字符測試內容甲乙丙丁戊己庚辛壬癸";
|
||||||
|
const ans = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let html = "";
|
||||||
|
for (let p = 0; p < paras; p += 1)
|
||||||
|
{
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < boundariesPerPara; i += 1)
|
||||||
|
{
|
||||||
|
s += cjk.charAt((p + i) % cjk.length) + ans.charAt((p * 3 + i) % ans.length);
|
||||||
|
}
|
||||||
|
html += "<p>" + s + ",句末標點。</p>";
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const PARAS = Number(params.get("paras") || 40);
|
||||||
|
const BPP = Number(params.get("bpp") || 60);
|
||||||
|
const JUSTIFY = params.get("justify") !== "0"; // 預設 justify
|
||||||
|
const stage = document.getElementById("stage");
|
||||||
|
if (JUSTIFY) { stage.classList.add("j"); }
|
||||||
|
stage.innerHTML = buildStage(PARAS, BPP);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const Juzhen = window.Juzhen || globalThis.Juzhen;
|
||||||
|
if (!Juzhen) { throw new Error("Juzhen 全域未定義"); }
|
||||||
|
const jz = Juzhen.createJuzhen();
|
||||||
|
|
||||||
|
// 量測:render(含 lineEdge + gapTrim 之立即 run)。ResizeObserver 非同步回調不計入。
|
||||||
|
const rectsBefore = rectCalls;
|
||||||
|
const t0 = performance.now();
|
||||||
|
jz.render(stage);
|
||||||
|
const dt = performance.now() - t0;
|
||||||
|
const gaps = stage.querySelectorAll("jz-hws").length;
|
||||||
|
const trimmed = stage.querySelectorAll("jz-hws.jz-hws-trim").length;
|
||||||
|
const le = stage.querySelectorAll("jz-char.jz-half-le").length;
|
||||||
|
|
||||||
|
document.getElementById("result").textContent = JSON.stringify({
|
||||||
|
paras: PARAS, bpp: BPP, justify: JUSTIFY, gaps, trimmed, lineEdge: le,
|
||||||
|
getClientRects: rectCalls - rectsBefore,
|
||||||
|
renderMs: Math.round(dt * 10) / 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
document.getElementById("result").textContent = "ERROR: " + (e && e.stack || e);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -16,6 +16,7 @@ export function setupDom(bodyHtml = "")
|
|||||||
globalThis.NodeFilter = dom.window.NodeFilter;
|
globalThis.NodeFilter = dom.window.NodeFilter;
|
||||||
globalThis.Node = dom.window.Node;
|
globalThis.Node = dom.window.Node;
|
||||||
globalThis.window = dom.window;
|
globalThis.window = dom.window;
|
||||||
|
globalThis.MutationObserver = dom.window.MutationObserver; // C2 觀察(jsdom 支持)
|
||||||
return { dom, document: dom.window.document };
|
return { dom, document: dom.window.document };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1344,3 +1344,181 @@ test("v1 相容層已移除:createCjkAutospace 不再導出(消費端已全
|
|||||||
const mod = await import("../dist/juzhen.mjs");
|
const mod = await import("../dist/juzhen.mjs");
|
||||||
assert.equal(mod.createCjkAutospace, undefined, "compat 導出已刪");
|
assert.equal(mod.createCjkAutospace, undefined, "compat 導出已刪");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ----- 第三層 增量恢復(C1 持久化 Finder + 失效/C3 rerender)-----
|
||||||
|
|
||||||
|
test("C3 rerender:局部子樹 revert+render,內容變化被重新處理", () =>
|
||||||
|
{
|
||||||
|
const { document } = setupDom(
|
||||||
|
'<p lang="zh-Hant">春天來了,很暖和。</p><p lang="zh-Hant">夏天到了,很热。</p>',
|
||||||
|
);
|
||||||
|
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('<p lang="zh-Hant">前文<span>甲,乙</span>後文標點。</p>');
|
||||||
|
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('<p lang="zh-Hant">前文<span>丙,丁</span>尾。</p>');
|
||||||
|
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(
|
||||||
|
'<div id="a" lang="zh-Hant">甲文字,甲標點。</div><div id="b" lang="zh-Hant">乙文字、乙標點。</div>',
|
||||||
|
);
|
||||||
|
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('<p lang="zh-Hant">原本的文字,內容。</p>');
|
||||||
|
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('<div id="c" lang="zh-Hant"><p>既有段落文字。</p></div>');
|
||||||
|
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('<p lang="zh-Hant">前文<span>甲,乙</span>後標點。</p>');
|
||||||
|
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('<p lang="zh-Hant">測試文字,標點。</p>');
|
||||||
|
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('<p lang="zh-Hant">初始文字,標點。</p>');
|
||||||
|
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('<div id="c" lang="zh-Hant">容器直接文字結尾實義字甲乙丙丁。</div>');
|
||||||
|
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 正常");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user