fix(split): 攀升對稱守衛——切分前驗整條祖先鏈可切,opaque 中間祖先不再被克隆肢解

下游 .selection-bar-message 病例:段末實義字落於 opaque <button>(巢於可切 <span>),
splitElementAt 僅驗頂層 span 可切;splitTreeAfter 逐層克隆 textNode→top 祖先鏈時,中間
opaque button 從未複查即被克隆肢解(複製出 splitoff button、破壞互動元素)。firstText/
lastText 守下潛(遇非可切回 null),但攀升無對稱守衛——此即真正病根,A7 之 per-instance
.selection-bar-message 迴避僅治標。

修復:splitTreeAfter 切分前先驗「textNode.parentNode…top(含 top)」整條祖先鏈皆 canSplit,
任一層 opaque/pill/isolate/帶 id 即回 null → 呼叫端綁整個 top(退化但安全,與下潛對稱)。
純新增守衛、無新克隆邏輯;jinze 之 isolateBoundaryToken 因下潛已守,行為不變。附帶亦杜絕
中間祖先帶 id 之克隆重複 id。

測試(129 全綠,+2):末實義字在 opaque button(巢於可切 span)→ 綁整 span、無 splitoff、
button 完整無缺;退化路徑 render→revert 還原一致。亲驗:暫禁守衛則該測試於「無 splitoff」
斷言失敗(證明非空通過、病根屬實)。

文檔:split.ts 檔頭+splitTreeAfter JSDoc+ARCHITECTURE §6.5.1 補「canSplit 須對整條祖先鏈
成立、下潛+攀升雙向守衛」之不變量。dist 重建。
This commit is contained in:
2026-07-11 13:31:42 +08:00
parent efad638a04
commit 3fbb2904ef
5 changed files with 75 additions and 2 deletions
+5
View File
@@ -539,6 +539,11 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
整元素綁定(安全)——pill 即「作為原子鄰字單位整體綁入 `jz-jinze`」之期望行為(下游問題六: 整元素綁定(安全)——pill 即「作為原子鄰字單位整體綁入 `jz-jinze`」之期望行為(下游問題六:
KaTeX 全樹皆 SPAN,舊版僅憑標籤白名單曾整棵下潛肢解公式);isolate(如背景色 `mark`)切分 KaTeX 全樹皆 SPAN,舊版僅憑標籤白名單曾整棵下潛肢解公式);isolate(如背景色 `mark`)切分
有可見接縫故整綁;`MARK` 並自白名單移除。 有可見接縫故整綁;`MARK` 並自白名單移除。
`canSplit` 須對「切點 `textNode` 至 `top` 之**整條**祖先鏈」成立、非僅 `top`:下潛由
`firstText``lastText` 守(遇非可切回 `null`),**攀升由 `splitTreeAfter` 之對稱守衛守**
(切分前先驗祖先鏈全可切,任一層 opaquepillisolate/帶 id 即放棄、退化整綁)。二者
缺一則 `splitElementAt`orphan:切點取自 `collectSubst`,可穿透 opaque `button`)會令
中間 opaque 祖先被逐層克隆肢解——下游 `.selection-bar-message` 病例。
- **切片**:切片 1 完成行首行末避頭尾(閉類/點號避頭、開類避尾);行首縮排聯動、 - **切片**:切片 1 完成行首行末避頭尾(閉類/點號避頭、開類避尾);行首縮排聯動、
更細之禁則對象列後續,沿用同一 `jz-jinze` 結構。 更細之禁則對象列後續,沿用同一 `jz-jinze` 結構。
- **已知交互(待瀏覽器精修)**:`lineedge` 之行端偵測需跨 `jz-jinze` 群組邊界向上 - **已知交互(待瀏覽器精修)**:`lineedge` 之行端偵測需跨 `jz-jinze` 群組邊界向上
+8
View File
@@ -1049,6 +1049,14 @@ var Juzhen = (() => {
return s; return s;
} }
function splitTreeAfter(top, textNode, offset, pass, finder) { function splitTreeAfter(top, textNode, offset, pass, finder) {
for (let a = textNode.parentNode; a && a.nodeType === 1; a = a.parentNode) {
if (!canSplit(a, finder)) {
return null;
}
if (a === top) {
break;
}
}
if (offset <= 0 && firstText(top, finder) === textNode) { if (offset <= 0 && firstText(top, finder) === textNode) {
return null; return null;
} }
+8
View File
@@ -1022,6 +1022,14 @@ function trailingTokenStart(d, anyCjk) {
return s; return s;
} }
function splitTreeAfter(top, textNode, offset, pass, finder) { function splitTreeAfter(top, textNode, offset, pass, finder) {
for (let a = textNode.parentNode; a && a.nodeType === 1; a = a.parentNode) {
if (!canSplit(a, finder)) {
return null;
}
if (a === top) {
break;
}
}
if (offset <= 0 && firstText(top, finder) === textNode) { if (offset <= 0 && firstText(top, finder) === textNode) {
return null; return null;
} }
+19 -2
View File
@@ -17,6 +17,11 @@
// finder 三分類(§4.1):pill(整體,如 .katex——KaTeX 全樹皆 SPAN,僅憑標籤名會 // finder 三分類(§4.1):pill(整體,如 .katex——KaTeX 全樹皆 SPAN,僅憑標籤名會
// 整棵下潛肢解;下游問題六)、isolate(隔離,帶盒樣式切分有可見接縫)、avoid 自身 // 整棵下潛肢解;下游問題六)、isolate(隔離,帶盒樣式切分有可見接縫)、avoid 自身
// 命中者,皆不可切——回退整元素綁定。 // 命中者,皆不可切——回退整元素綁定。
//
// 可切性須對「切點 textNode 至 top 之**整條**祖先鏈」成立,非僅 top:下潛由 firstText
// lastText 守(遇非可切回 null),攀升由 splitTreeAfter 之對稱守衛守(切分前驗祖先鏈全可
// 切)。二者缺一則反例(splitElementAt 之切點取自 orphan 之 collectSubst,可穿透 opaque
// <button>)會令中間 opaque 祖先被逐層克隆肢解——下游 A7 之 .selection-bar-message 病例。
import type { Finder } from "./finder.js"; import type { Finder } from "./finder.js";
@@ -107,8 +112,9 @@ function trailingTokenStart(d: string, anyCjk: RegExp): number
/** /**
* 把 top 自切點(textNode, offset)起至末尾之內容剝入一個淺克隆鏈,克隆作為 top 之 * 把 top 自切點(textNode, offset)起至末尾之內容剝入一個淺克隆鏈,克隆作為 top 之
* 後兄弟插入並標記 splitoff。回傳該頂層克隆;若切點位於 top 內容之最起點(左側無內容、 * 後兄弟插入並標記 splitoff。回傳該頂層克隆;下列任一情形回 null(呼叫端據此綁整個
* 即整個 top 都在切點之後)則回 null(呼叫端據此綁整個 top)。 * top):切點位於 top 內容之最起點(左側無內容、即整個 top 都在切點之後);或 textNode
* 至 top 之祖先鏈含不可切層(opaquepill/isolate/帶 id——見攀升守衛)。
* *
* 沿祖先鏈逐層剝離:每層把「切點側之首節點及其後兄弟」搬入該層元素之淺克隆,空克隆 * 沿祖先鏈逐層剝離:每層把「切點側之首節點及其後兄弟」搬入該層元素之淺克隆,空克隆
* 跳過(避免殘留空元素破壞 revert 還原)。 * 跳過(避免殘留空元素破壞 revert 還原)。
@@ -121,6 +127,17 @@ function splitTreeAfter(
finder: Finder, finder: Finder,
): Element | null ): Element | null
{ {
// 攀升對稱守衛:firstTextlastText 守下潛(遇非可切回 null),但 splitElementAt
// orphan)之切點取自 collectSubst,其 textNode→top 祖先鏈未必全可切——中間祖先若
// 為 opaque(如 <button>)/pillisolate/帶 id,逐層淺克隆會肢解該邊界元素(複製
// <button>、重複 id、破壞 pillisolate 整體)。故切分前先驗整條祖先鏈(textNode.parentNode
// …top,含 top)皆可切;任一層不可切即放棄(回 null → 綁整個 top,退化但安全,與下潛對稱)。
for (let a: Node | null = textNode.parentNode; a && a.nodeType === 1; a = a.parentNode)
{
if (!canSplit(a as Element, finder)) { return null; }
if (a === top) { break; }
}
// 切點位於 top 最起點(首文本節點之 offset 0)→ 左側無內容,不切。 // 切點位於 top 最起點(首文本節點之 offset 0)→ 左側無內容,不切。
if (offset <= 0 && firstText(top, finder) === textNode) { return null; } if (offset <= 0 && firstText(top, finder) === textNode) { return null; }
+35
View File
@@ -1042,6 +1042,41 @@ test("垂懸字 元素最小綁定 revert:兩個 <strong> 併回單一、文
assert.equal(p.querySelector("strong").textContent, "一段很長的加粗文字", "strong 還原"); assert.equal(p.querySelector("strong").textContent, "一段很長的加粗文字", "strong 還原");
}); });
// 攀升對稱守衛(下游 .selection-bar-message 病例):段末實義字落於 opaque <button>
// 而 button 巢於可切 <span>(塊級直接子)內。splitElementAt 僅驗頂層 span 可切,若無攀升
// 守衛,splitTreeAfter 逐層克隆 textNode→span 時會把中間 opaque button 一併克隆肢解
// (複製出 splitoff button)。守衛須沿整條祖先鏈驗可切,遇 button 即放棄、退化為綁整 span。
test("垂懸字 攀升守衛:末實義字在 opaque <button>(巢於可切 span)→ 綁整 span、絕不肢解 button", () =>
{
const { document } = setupDom(
'<p lang="zh-Hant">開頭較長的一段文字內容之後<span>接續<button>結尾文字</button></span></p>',
);
createJuzhen().render(document.body);
const p = document.querySelector("p");
const orphan = p.querySelector("jz-orphan");
assert.ok(orphan, "應產生 jz-orphan");
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0, "無克隆切分殼層(button 未被肢解)");
assert.equal(p.querySelectorAll("button").length, 1, "button 未被複製(仍單一)");
assert.equal(p.querySelector("button").textContent, "結尾文字", "button 內容完整無缺");
assert.equal(orphan.textContent, "接續結尾文字", "退化綁定整個 span(含 button");
assert.equal(p.textContent, "開頭較長的一段文字內容之後接續結尾文字", "textContent 不變");
});
test("垂懸字 攀升守衛 revert:綁整 span 之退化路徑 render→revert 還原一致", () =>
{
const { document } = setupDom(
'<p lang="zh-Hant">開頭較長的一段文字內容之後<span>接續<button>結尾文字</button></span></p>',
);
const jz = createJuzhen();
jz.render(document.body);
jz.revert(document.body);
const p = document.querySelector("p");
assert.equal(countTag(p, "jz-orphan"), 0, "無殘留 jz-orphan");
assert.equal(p.querySelectorAll("span").length, 1, "span 單一");
assert.equal(p.querySelectorAll("button").length, 1, "button 單一");
assert.equal(p.textContent, "開頭較長的一段文字內容之後接續結尾文字", "textContent 還原");
});
// ----- 標點懸掛已移除(§6.5.6):hanging 選項保留為 no-op,永不產生 jz-hang-* ----- // ----- 標點懸掛已移除(§6.5.6):hanging 選項保留為 no-op,永不產生 jz-hang-* -----
// 純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);原生 // 純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);原生
// hanging-punctuation 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥。 // hanging-punctuation 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥。