3fbb2904ef
下游 .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 重建。
243 lines
10 KiB
TypeScript
243 lines
10 KiB
TypeScript
// 行內邊界詞元隔離(架構 §6.4/§6.5.1;下游 Bug:行內樣式標記須對排版透明)。
|
||
//
|
||
// 禁則(jinze)與垂懸字(orphan)須把「邊界字/詞元」綁進不可斷之群組(jz-jinze
|
||
// 之 nowrap、jz-orphan 之 nowrap)。若該邊界字落於樣式行內元素(<strong>/<em> 等)
|
||
// 內,舊版**整個元素**入綁——`<strong>很長一串粗體</strong>,` 會把整串粗體塞進
|
||
// nowrap inline-block,行末放不下即整體掉行、上行留巨大空隙;orphan 更可致長元素溢出
|
||
// 容器。本模組把邊界詞元**克隆切分**出來,只綁該克隆(最小綁定),原元素其餘部分留在
|
||
// 原位,渲染視覺無縫(相鄰同樣式行內元素)。
|
||
//
|
||
// 機制:splitTreeAfter 沿「文本節點 → 邊界元素」之祖先鏈逐層把「切點之後」之內容剝入
|
||
// 一個淺克隆(保留標籤/class/style/lang/dir,去 id),克隆作為原元素之後兄弟。克隆
|
||
// 標 data-jz-kind="splitoff"+data-jz="<pass>",供 revertPass 之延後階段併回原元素(先解
|
||
// 包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼;I6 還原一致)。
|
||
//
|
||
// 僅純樣式行內標籤可切(白名單);帶語義/引用者(A 連結會克隆出兩個錨點、ABBR 之
|
||
// title、RUBY 結構、帶 id 者)一律回退整元素綁定——退化但安全。可切性另須諮詢
|
||
// finder 三分類(§4.1):pill(整體,如 .katex——KaTeX 全樹皆 SPAN,僅憑標籤名會
|
||
// 整棵下潛肢解;下游問題六)、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";
|
||
|
||
const PASS_ATTR = "data-jz";
|
||
const KIND_ATTR = "data-jz-kind";
|
||
|
||
// 可安全克隆切分之純樣式行內標籤(切分不改變語義、相鄰兩份視覺無縫)。
|
||
// 刻意排除:A(克隆出兩個連結)、ABBR/Q(title/引號語義重複)、RUBY 系(結構性)、
|
||
// MARK(盒樣式:背景色切分有可見接縫;預設亦列為 isolate)、帶 id 者(id 重複)——
|
||
// 皆回退整元素綁定。
|
||
const SPLITTABLE = new Set([
|
||
"STRONG", "EM", "B", "I", "U", "S", "SPAN",
|
||
"SMALL", "INS", "DEL", "SUB", "SUP",
|
||
]);
|
||
|
||
interface Point
|
||
{
|
||
textNode: Text;
|
||
offset: number;
|
||
}
|
||
|
||
/** 元素是否可克隆切分 = SPLITTABLE 白名單(結構切分安全,排除 <a>/ABBR/RUBY/帶 id 等)
|
||
* ∩ transparent(非 avoid/pill/isolate/block 邊界)。SPLITTABLE ⊂ styleInlines,故命中白名單
|
||
* 且 category 為 transparent 者即可切;為邊界者(含消費端自 styleInlines 移出之標籤)不可切、
|
||
* 退化整綁。結構切分為獨立層、白名單較邏輯透明更窄(見 finder.category)。 */
|
||
function canSplit(el: Element, finder: Finder): boolean
|
||
{
|
||
return SPLITTABLE.has(el.nodeName)
|
||
&& !el.hasAttribute("id")
|
||
&& finder.category(el) === "transparent";
|
||
}
|
||
|
||
/** el 內**首**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
|
||
function firstText(el: Element, finder: Finder): Text | null
|
||
{
|
||
for (let c = el.firstChild; c; c = c.nextSibling)
|
||
{
|
||
if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; }
|
||
if (c.nodeType === 1)
|
||
{
|
||
if (!canSplit(c as Element, finder)) { return null; }
|
||
const r = firstText(c as Element, finder);
|
||
if (r) { return r; }
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** el 內**末**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
|
||
function lastText(el: Element, finder: Finder): Text | null
|
||
{
|
||
for (let c = el.lastChild; c; c = c.previousSibling)
|
||
{
|
||
if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; }
|
||
if (c.nodeType === 1)
|
||
{
|
||
if (!canSplit(c as Element, finder)) { return null; }
|
||
const r = lastText(c as Element, finder);
|
||
if (r) { return r; }
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isTokenChar(ch: string, anyCjk: RegExp): boolean
|
||
{
|
||
return !anyCjk.test(ch) && !/\s/.test(ch);
|
||
}
|
||
|
||
/** 文本節點之首詞元結束位置(CJK 字=1;連續非 CJK 非空白=整詞)。 */
|
||
function leadingTokenEnd(d: string, anyCjk: RegExp): number
|
||
{
|
||
if (anyCjk.test(d.charAt(0))) { return 1; }
|
||
let e = 1;
|
||
while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; }
|
||
return e;
|
||
}
|
||
|
||
/** 文本節點之末詞元起始位置。 */
|
||
function trailingTokenStart(d: string, anyCjk: RegExp): number
|
||
{
|
||
const last = d.length - 1;
|
||
if (anyCjk.test(d.charAt(last))) { return last; }
|
||
let s = last;
|
||
while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; }
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* 把 top 自切點(textNode, offset)起至末尾之內容剝入一個淺克隆鏈,克隆作為 top 之
|
||
* 後兄弟插入並標記 splitoff。回傳該頂層克隆;下列任一情形回 null(呼叫端據此綁整個
|
||
* top):切點位於 top 內容之最起點(左側無內容、即整個 top 都在切點之後);或 textNode
|
||
* 至 top 之祖先鏈含不可切層(opaque/pill/isolate/帶 id——見攀升守衛)。
|
||
*
|
||
* 沿祖先鏈逐層剝離:每層把「切點側之首節點及其後兄弟」搬入該層元素之淺克隆,空克隆
|
||
* 跳過(避免殘留空元素破壞 revert 還原)。
|
||
*/
|
||
function splitTreeAfter(
|
||
top: Element,
|
||
textNode: Text,
|
||
offset: number,
|
||
pass: string,
|
||
finder: Finder,
|
||
): Element | null
|
||
{
|
||
// 攀升對稱守衛:firstText/lastText 守下潛(遇非可切回 null),但 splitElementAt
|
||
// (orphan)之切點取自 collectSubst,其 textNode→top 祖先鏈未必全可切——中間祖先若
|
||
// 為 opaque(如 <button>)/pill/isolate/帶 id,逐層淺克隆會肢解該邊界元素(複製
|
||
// <button>、重複 id、破壞 pill/isolate 整體)。故切分前先驗整條祖先鏈(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)→ 左側無內容,不切。
|
||
if (offset <= 0 && firstText(top, finder) === textNode) { return null; }
|
||
|
||
// 該文本層之右側首節點。
|
||
let rightAtLevel: Node | null;
|
||
if (offset >= textNode.data.length)
|
||
{
|
||
rightAtLevel = textNode.nextSibling;
|
||
}
|
||
else if (offset <= 0)
|
||
{
|
||
rightAtLevel = textNode;
|
||
}
|
||
else
|
||
{
|
||
rightAtLevel = textNode.splitText(offset);
|
||
}
|
||
|
||
let ancestor: Node | null = textNode.parentNode;
|
||
let topClone: Element | null = null;
|
||
while (ancestor && ancestor.nodeType === 1)
|
||
{
|
||
const anc = ancestor as Element;
|
||
let clone: Element | null = null;
|
||
if (rightAtLevel)
|
||
{
|
||
clone = anc.cloneNode(false) as Element;
|
||
clone.removeAttribute("id");
|
||
clone.setAttribute(PASS_ATTR, pass);
|
||
clone.setAttribute(KIND_ATTR, "splitoff");
|
||
let n: Node | null = rightAtLevel;
|
||
while (n)
|
||
{
|
||
const next: Node | null = n.nextSibling;
|
||
clone.appendChild(n);
|
||
n = next;
|
||
}
|
||
}
|
||
if (anc === top)
|
||
{
|
||
if (!clone) { return null; }
|
||
if (top.parentNode) { top.parentNode.insertBefore(clone, top.nextSibling); }
|
||
topClone = clone;
|
||
break;
|
||
}
|
||
if (clone)
|
||
{
|
||
if (anc.parentNode) { anc.parentNode.insertBefore(clone, anc.nextSibling); }
|
||
rightAtLevel = clone; // 下一層之右側起點:克隆(緊隨 anc,原後兄弟在克隆之後)
|
||
}
|
||
else
|
||
{
|
||
rightAtLevel = anc.nextSibling; // 本子樹無右側內容;上一層右側起點為 anc 之後兄弟
|
||
}
|
||
ancestor = anc.parentNode;
|
||
}
|
||
return topClone;
|
||
}
|
||
|
||
/**
|
||
* 隔離 el 之邊界詞元供綁定(最小綁定)。side="tail" 取末詞元、="head" 取首詞元。
|
||
* 回傳**應綁定之節點**:
|
||
* · 可切且確實切分 → tail 回克隆(末詞元)、head 回原 el(已只剩首詞元,其餘入克隆);
|
||
* · 不可切/無法定位切點/整元素即該詞元 → 回 el 本身(綁整個元素,退化但安全)。
|
||
* 切出之克隆標 splitoff,由 revertPass 併回;故 render→revert 還原一致(I6)。
|
||
*/
|
||
export function isolateBoundaryToken(
|
||
el: Element,
|
||
side: "head" | "tail",
|
||
anyCjk: RegExp,
|
||
pass: string,
|
||
finder: Finder,
|
||
): Element
|
||
{
|
||
if (!canSplit(el, finder)) { return el; }
|
||
const text = side === "tail" ? lastText(el, finder) : firstText(el, finder);
|
||
if (!text) { return el; }
|
||
|
||
const point: Point = side === "tail"
|
||
? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) }
|
||
: { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) };
|
||
|
||
const clone = splitTreeAfter(el, point.textNode, point.offset, pass, finder);
|
||
if (!clone) { return el; } // 整元素即邊界詞元 → 綁整個 el。
|
||
return side === "tail" ? clone : el;
|
||
}
|
||
|
||
/**
|
||
* 把 el 之**指定切點**(精確字位)起至末尾剝入克隆並回傳(供 orphan:自實義字綁至塊末)。
|
||
* 不可切或切點在最起點 → 回 null(呼叫端綁整個直接子)。
|
||
*/
|
||
export function splitElementAt(
|
||
el: Element,
|
||
textNode: Text,
|
||
offset: number,
|
||
pass: string,
|
||
finder: Finder,
|
||
): Element | null
|
||
{
|
||
if (!canSplit(el, finder)) { return null; }
|
||
return splitTreeAfter(el, textNode, offset, pass, finder);
|
||
}
|