// jz-* 元素工廠與還原(架構 §4.4/§5)。 // // 所有注入結構皆帶 data-jz="" 標記(冪等 + 可定址還原,I6),並以 // data-jz-kind 區分「marker(純注入,還原時移除)」與「wrap(包裝原內容, // 還原時解包)」。視覺尺寸一律在 CSS(I2);本模組只造結構。 /** jz-* 自訂元素名(皆含連字號,合法 custom element,預設 inline)。 */ export type JzTag = | "jz-hws" | "jz-char" | "jz-inner" | "jz-cs" | "jz-jinze" | "jz-orphan" | "jz-em" | "jz-ruby" | "jz-rb" | "jz-rt"; // createJz 之 kind:marker(純注入,還原時移除)/wrap(包裝原內容,還原時解包)。 // data-jz-kind 另有第三種值 "splitoff"(克隆切分之邊界詞元),由 core/split.ts 直接以 // setAttribute 設定、不經 createJz;revertPass 於 wrap 全解包後之第二階段併回(見下)。 type JzKind = "marker" | "wrap"; export interface CreateOptions { /** 追加之 class(空白分隔或陣列)。 */ classes?: string | string[]; /** 文本內容。 */ text?: string; } const KIND_ATTR = "data-jz-kind"; const PASS_ATTR = "data-jz"; function doc(): Document { if (typeof document === "undefined") { throw new Error("juzhen: 無 document(需於瀏覽器或 jsdom 環境執行)。"); } return document; } /** 造一個帶 pass 標記之 jz-* 元素。 */ export function createJz( tag: JzTag, pass: string, kind: JzKind, opts: CreateOptions = {}, ): HTMLElement { const el = doc().createElement(tag); el.setAttribute(PASS_ATTR, pass); el.setAttribute(KIND_ATTR, kind); if (opts.classes) { const list = Array.isArray(opts.classes) ? opts.classes : [ opts.classes ]; for (const c of list) { if (c) { el.classList.add(c); } } } if (opts.text !== undefined) { el.textContent = opts.text; } return el; } /** * 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後 * 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。 * * splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回: * 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於 * 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。 */ export function revertPass(pass: string, root: ParentNode): void { const nodes = Array.from( root.querySelectorAll('[' + PASS_ATTR + '="' + pass + '"]'), ); // 逆序:子元素先於父元素處理,避免解包父後子引用失效。 const touched = new Set(); for (let i = nodes.length - 1; i >= 0; i -= 1) { const el = nodes[i]!; const parent = el.parentNode; if (!parent) { continue; } const kind = el.getAttribute(KIND_ATTR); if (kind === "splitoff") { continue; } // 延後階段處理。 touched.add(parent); if (kind === "marker") { parent.removeChild(el); } else { // wrap:把子節點搬出後移除外殼。 while (el.firstChild) { parent.insertBefore(el.firstChild, el); } parent.removeChild(el); } } // 第二階段:splitoff 併回(此時 wrap 已全解包,前兄弟即原元素)。文件序,使同一 // 原元素之多個 splitoff 依序併入。 const splitoffs = Array.from( root.querySelectorAll( '[' + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]', ), ); for (const so of splitoffs) { const parent = so.parentNode; if (!parent) { continue; } touched.add(parent); const prev = so.previousSibling; if (prev && prev.nodeType === 1 && (prev as Element).nodeName === so.nodeName) { // 併入前兄弟(同標籤原元素)尾部。 while (so.firstChild) { prev.appendChild(so.firstChild); } parent.removeChild(so); } else { // 前兄弟非預期同標籤元素(罕見,結構曾被外力改動):原地解包,仍移除標記。 while (so.firstChild) { parent.insertBefore(so.firstChild, so); } parent.removeChild(so); } } for (const p of touched) { if ((p as Element).normalize) { (p as Element).normalize(); } } } const BD_CLASSES = [ "bd-open", "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga" ]; /** 讀取 jz-char 之 bd-* 子類(無則 null)。 */ export function bdClassOf(el: Element): string | null { for (const c of BD_CLASSES) { if (el.classList.contains(c)) { return c; } } return null; } /** 該元素是否為某/任一 pass 之 jz 產物。 */ export function isJz(node: Node | null, pass?: string): boolean { if (!node || node.nodeType !== 1) { return false; } const el = node as Element; if (!el.hasAttribute(PASS_ATTR)) { return false; } return pass === undefined || el.getAttribute(PASS_ATTR) === pass; }