feat(levelmark): 塊級分類 CSS 鉤子 data-jz-block-level+增量塊粒度契約(下游建議七/說明八)
建議七:新增 levelMark pass(standalone,order 10,恆開)——render 時把 resolved 功能級別寫回「直接承載可處理文本」之塊級元素:data-jz-block-level="paragraph|text", revert 全數移除(I6 之 data-jz-* 清單涵蓋)。消費端段落級 CSS(首要 text-align: justify)自此與 jinze/orphan 共用單一真相源,免另立必然漂移之選擇器白名單。 - 僅標直接承載文本之塊:純容器(body/ul/table…,直接文本僅空白)不標——否則 body 恆判 paragraph、justify 鉤子經繼承污染全頁;此集合恰為段落級 pass 實際 作用之塊集。avoid 子樹/pill 內部文本不計,未被處理之塊一致不標。 - 輸入/輸出分離:作者輸入 data-jz-level 只讀不寫;輸出屬性本庫絕不讀取、亦不在 C2 OBSERVE_ATTRS 白名單(外部改動不觸發);增量路徑 revert 即清、render 即重標。 - 實現:一次 eachTextNode 走訪+短程上攀定塊(per-run 快取),O(文本);levelFor 已記憶化。上攀止於 ctx.root,不越界外標。 說明八:README/ARCHITECTURE §3.1 明載增量粒度契約——增量須以塊為粒度(換塊 內容、增刪塊、改分類屬性皆與全量重繪一致);不支持對聚珍已切分文本節點之外科式 characterData 直改(charify 以 fragment 替換原節點,舊引用已脫離文檔)——後處理 排版器之固有限制、非缺陷,以塊為單位替換內容即可。 測試 140→145(標記/純容器不標、text 解析三路(選擇器/屬性/祖先)、avoid/ pill 不標、revert 全清含 root、C2 class 翻轉自動刷新);tsc 聲明構建通過; headless Chrome 整管線煙測零報錯、段落屬性實際落 DOM。
This commit is contained in:
@@ -92,6 +92,14 @@ jz.disconnect(); // 停止觀察
|
|||||||
**暫停觀察**,故 juzhen 自身之 jz-* 注入絕不自觸發。實測編輯 400 段中之一段,局部恢復較全樹快數
|
**暫停觀察**,故 juzhen 自身之 jz-* 注入絕不自觸發。實測編輯 400 段中之一段,局部恢復較全樹快數
|
||||||
百倍。**元素變化正確性**見 §4.1 末「快取生命週期與失效」。
|
百倍。**元素變化正確性**見 §4.1 末「快取生命週期與失效」。
|
||||||
|
|
||||||
|
**增量粒度契約(下游說明八)**:增量更新須以**塊**為粒度——換塊內容(`textContent`/
|
||||||
|
`innerHTML`)、增刪塊、改分類屬性,皆與全量重繪一致(差分複驗逐位元相同)。**不支持**對聚珍
|
||||||
|
已切分之文本節點做外科式 `characterData` 直改(如持有 render **前**之 `p.firstChild` 引用、
|
||||||
|
事後直改其 `.data`):charify 以 fragment **替換**原文本節點(§3.4),舊引用已脫離文檔或僅指
|
||||||
|
碎片,直改令 `revert` 合併出之內容偏離作者意圖。此為後處理排版器之固有限制、非缺陷——聚珍對
|
||||||
|
當前 DOM 之處理始終自洽;外科式文本 patch 之框架(vdom 等)須以塊為單位替換,或對受管區域
|
||||||
|
`revert` 後再 patch。
|
||||||
|
|
||||||
`options` 形如:
|
`options` 形如:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -654,6 +662,18 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
|
|||||||
**與本批關係**:先落地分級(pass 標 level、`levelFor`、`data-jz-level`/選項)→ orphan/hanging
|
**與本批關係**:先落地分級(pass 標 level、`levelFor`、`data-jz-level`/選項)→ orphan/hanging
|
||||||
註冊為段落級,自動只作用段落級元素、跳過 text-level 者。實作順序插於 6.5.5 之首。
|
註冊為段落級,自動只作用段落級元素、跳過 text-level 者。實作順序插於 6.5.5 之首。
|
||||||
|
|
||||||
|
**Resolved 分類之 CSS 鉤子(下游建議七,`typeset/levelmark.ts`)**:消費端之段落級 CSS(首要
|
||||||
|
`text-align:justify`)語義上與段落級 pass 應作用於**同一集合**,但 level 判定原僅存內部快取,
|
||||||
|
消費端只能另立一份選擇器白名單、兩份分類必然漂移(實例:`.callout` 綁了避頭尾卻不在 justify
|
||||||
|
名單)。`levelMark` pass(standalone,order 10,恆開)於 render 時把 resolved 級別寫回塊級元素:
|
||||||
|
`data-jz-block-level="paragraph|text"`,`revert` 全數移除(I6)。**僅標「直接承載可處理文本」
|
||||||
|
之塊**(存在非空白文本節點、其最近塊祖先即該塊、非 avoid/pill 內/域外)——純容器(body/ul
|
||||||
|
/table…)不標,否則 body 恆被判 paragraph、justify 鉤子經繼承污染全頁;此集合恰為段落級 pass
|
||||||
|
實際作用之塊集(`collectRuns` 對純容器收不到 run)。**輸入/輸出分離**:作者輸入 `data-jz-level`
|
||||||
|
只讀不寫;輸出屬性本庫絕不讀取、亦不在 C2 `OBSERVE_ATTRS` 白名單(外部改動不觸發重處理);
|
||||||
|
增量路徑(rerender/observe)revert 即清、render 即重標,天然刷新。本庫只供分類、不介入樣式
|
||||||
|
(justify 非段落之純函數——窄欄邊注可 opt-out,由消費端 CSS 決定)。
|
||||||
|
|
||||||
#### 6.5.0 四機制之分工與邊界(核心約定,實作前必讀)
|
#### 6.5.0 四機制之分工與邊界(核心約定,實作前必讀)
|
||||||
|
|
||||||
| 機制 | 作用層 | 唯一職責 | 手段 | 狀態 |
|
| 機制 | 作用層 | 唯一職責 | 手段 | 狀態 |
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ jz.observe(document.body); // 之後 DOM 變動即自動增量恢復
|
|||||||
> 須經 `revert`/`rerender`/`invalidate`/`observe` 任一使該子樹快取失效**,否則沿用舊分類。
|
> 須經 `revert`/`rerender`/`invalidate`/`observe` 任一使該子樹快取失效**,否則沿用舊分類。
|
||||||
> 新增/移除元素與純內容編輯無此顧慮(新元素重新計算、移除者自動回收)。
|
> 新增/移除元素與純內容編輯無此顧慮(新元素重新計算、移除者自動回收)。
|
||||||
|
|
||||||
|
> **增量之粒度為「塊」**:換塊內容(`textContent`/`innerHTML`)、增刪塊、改分類屬性——皆與
|
||||||
|
> 全量重繪一致。**不支持**對聚珍已切分之文本節點做外科式 `characterData` 直改(如持有 render
|
||||||
|
> **前**之 `p.firstChild` 引用、事後直改其 `.data`):render 後原文本節點已被 `jz-*` 切分/
|
||||||
|
> 替換,該引用已非原整段,直改單一碎片會令 `revert` 合併出之內容偏離作者意圖。此為後處理排版
|
||||||
|
> 器之固有限制(任何外科式文本 patch 之框架皆會與注入後之 DOM 失步)、非缺陷——聚珍對當前
|
||||||
|
> DOM 之處理始終自洽。欲改文字,以塊為單位替換內容即可。
|
||||||
|
|
||||||
> **`revert` 之範圍**:`revert` 乾淨移除聚珍注入之全部結構(`jz-*`/`data-jz-*`),DOM 回到**邏輯
|
> **`revert` 之範圍**:`revert` 乾淨移除聚珍注入之全部結構(`jz-*`/`data-jz-*`),DOM 回到**邏輯
|
||||||
> 等價**態;但**不還原** `spacing` 依契約規範化之贅餘作者空格(CJK↔西文吸收為 margin 間隙、
|
> 等價**態;但**不還原** `spacing` 依契約規範化之贅餘作者空格(CJK↔西文吸收為 margin 間隙、
|
||||||
> CJK↔CJK 剝除;見上表「中西間隙」)。即 `render→revert` 對含 CJK↔西文作者空格之源,還原後語義
|
> CJK↔CJK 剝除;見上表「中西間隙」)。即 `render→revert` 對含 CJK↔西文作者空格之源,還原後語義
|
||||||
@@ -311,6 +318,37 @@ web component 等)採**嚴格白名單**、一律當**邊界**(語義同隔
|
|||||||
createJuzhen({ level: { text: "h1,h2,h3,button,.ui" } }); // 選擇器一鍵標記
|
createJuzhen({ level: { text: "h1,h2,h3,button,.ui" } }); // 選擇器一鍵標記
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### CSS 可查之 resolved 分類:`data-jz-block-level`(輸出屬性)
|
||||||
|
|
||||||
|
`render` 時,聚珍把 **resolved** 級別寫回每個「直接承載可處理文本」之塊級元素:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="callout" data-jz-block-level="paragraph">…</div> <!-- 聚珍判為段落級 -->
|
||||||
|
<h2 data-jz-block-level="text">…</h2> <!-- 聚珍判為文本級 -->
|
||||||
|
<ul> <!-- 純容器:不標 -->
|
||||||
|
<li data-jz-block-level="paragraph">…</li>
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
消費端之**段落級 CSS**(首要:CJK 兩端對齊)從此與聚珍的段落級 pass(禁則/垂懸)共用
|
||||||
|
**單一真相源**,免另行維護一份必然漂移之選擇器白名單:
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-jz-block-level="paragraph"] { text-align: justify; }
|
||||||
|
.marginalia[data-jz-block-level="paragraph"] { text-align: start; } /* 窄欄 opt-out */
|
||||||
|
```
|
||||||
|
|
||||||
|
規則:
|
||||||
|
|
||||||
|
- **僅標直接承載文本之塊**——純容器(`body`/`ul`/`table`…,直接文本僅空白)不標,
|
||||||
|
否則 `body` 恆被判段落級,justify 鉤子經繼承污染全頁。此集合恰為段落級 pass 實際
|
||||||
|
作用之塊集。avoid 子樹/pill 內部之文本不計,故未被處理之塊一致不標。
|
||||||
|
- **輸出專用、輸入輸出分離**:作者輸入屬性 `data-jz-level` 只讀不寫;本屬性由聚珍寫入、
|
||||||
|
聚珍**絕不讀取**(消費端手寫無效),`revert` 時全數移除。增量模式(`observe`)下分類
|
||||||
|
屬性變化自動刷新。
|
||||||
|
- 是否對齊仍由消費端 CSS 決定(justify 非段落之純函數——窄欄邊注可 opt-out);聚珍
|
||||||
|
只供分類、不介入樣式。
|
||||||
|
|
||||||
### 標點懸掛(`hanging`)— 已移除
|
### 標點懸掛(`hanging`)— 已移除
|
||||||
|
|
||||||
原計劃之標點懸掛(行端標點凸出版心)經實測為**機制級死路、已移除**(負 margin 程式碼與
|
原計劃之標點懸掛(行端標點凸出版心)經實測為**機制級死路、已移除**(負 margin 程式碼與
|
||||||
|
|||||||
Vendored
+59
-1
@@ -1413,6 +1413,63 @@ var Juzhen = (() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// src/typeset/levelmark.ts
|
||||||
|
var ATTR = "data-jz-block-level";
|
||||||
|
var levelMarkPass = {
|
||||||
|
name: "levelMark",
|
||||||
|
kind: "standalone",
|
||||||
|
order: 10,
|
||||||
|
level: "text",
|
||||||
|
// 不受段落級閘限制:text 級之塊亦須標記(標記本身即分級輸出)
|
||||||
|
enabled: () => true,
|
||||||
|
// 分類鉤子非排版功能,恆開(純屬性輸出、零版面影響)
|
||||||
|
render(ctx) {
|
||||||
|
const { finder, options } = ctx;
|
||||||
|
const blockSet = options.finder.blockTags;
|
||||||
|
const nearest = /* @__PURE__ */ new Map();
|
||||||
|
const nearestBlock = (p) => {
|
||||||
|
const cached = nearest.get(p);
|
||||||
|
if (cached !== void 0) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
let e = p;
|
||||||
|
let block = null;
|
||||||
|
while (e) {
|
||||||
|
if (blockSet.has(e.nodeName)) {
|
||||||
|
block = e;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (e === ctx.root) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
e = e.parentElement;
|
||||||
|
}
|
||||||
|
nearest.set(p, block);
|
||||||
|
return block;
|
||||||
|
};
|
||||||
|
finder.eachTextNode(ctx.root, (t) => {
|
||||||
|
if (!/\S/.test(t.data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = t.parentElement;
|
||||||
|
const block = p ? nearestBlock(p) : null;
|
||||||
|
if (!block) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const level = finder.levelFor(block);
|
||||||
|
if (block.getAttribute(ATTR) !== level) {
|
||||||
|
block.setAttribute(ATTR, level);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
revert(ctx) {
|
||||||
|
if (ctx.root.hasAttribute(ATTR)) {
|
||||||
|
ctx.root.removeAttribute(ATTR);
|
||||||
|
}
|
||||||
|
ctx.root.querySelectorAll("[" + ATTR + "]").forEach((el) => el.removeAttribute(ATTR));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// src/typeset/lineedge.ts
|
// src/typeset/lineedge.ts
|
||||||
var LE = "jz-half-le";
|
var LE = "jz-half-le";
|
||||||
var ALL_EDGE = [LE];
|
var ALL_EDGE = [LE];
|
||||||
@@ -2156,7 +2213,8 @@ var Juzhen = (() => {
|
|||||||
jinzePass,
|
jinzePass,
|
||||||
orphanPass,
|
orphanPass,
|
||||||
lineEdgePass,
|
lineEdgePass,
|
||||||
gapTrimPass
|
gapTrimPass,
|
||||||
|
levelMarkPass
|
||||||
];
|
];
|
||||||
function feature(v, dflt) {
|
function feature(v, dflt) {
|
||||||
return v === void 0 ? dflt : !!v;
|
return v === void 0 ? dflt : !!v;
|
||||||
|
|||||||
Vendored
+59
-1
@@ -1386,6 +1386,63 @@ var jinzePass = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// src/typeset/levelmark.ts
|
||||||
|
var ATTR = "data-jz-block-level";
|
||||||
|
var levelMarkPass = {
|
||||||
|
name: "levelMark",
|
||||||
|
kind: "standalone",
|
||||||
|
order: 10,
|
||||||
|
level: "text",
|
||||||
|
// 不受段落級閘限制:text 級之塊亦須標記(標記本身即分級輸出)
|
||||||
|
enabled: () => true,
|
||||||
|
// 分類鉤子非排版功能,恆開(純屬性輸出、零版面影響)
|
||||||
|
render(ctx) {
|
||||||
|
const { finder, options } = ctx;
|
||||||
|
const blockSet = options.finder.blockTags;
|
||||||
|
const nearest = /* @__PURE__ */ new Map();
|
||||||
|
const nearestBlock = (p) => {
|
||||||
|
const cached = nearest.get(p);
|
||||||
|
if (cached !== void 0) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
let e = p;
|
||||||
|
let block = null;
|
||||||
|
while (e) {
|
||||||
|
if (blockSet.has(e.nodeName)) {
|
||||||
|
block = e;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (e === ctx.root) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
e = e.parentElement;
|
||||||
|
}
|
||||||
|
nearest.set(p, block);
|
||||||
|
return block;
|
||||||
|
};
|
||||||
|
finder.eachTextNode(ctx.root, (t) => {
|
||||||
|
if (!/\S/.test(t.data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = t.parentElement;
|
||||||
|
const block = p ? nearestBlock(p) : null;
|
||||||
|
if (!block) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const level = finder.levelFor(block);
|
||||||
|
if (block.getAttribute(ATTR) !== level) {
|
||||||
|
block.setAttribute(ATTR, level);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
revert(ctx) {
|
||||||
|
if (ctx.root.hasAttribute(ATTR)) {
|
||||||
|
ctx.root.removeAttribute(ATTR);
|
||||||
|
}
|
||||||
|
ctx.root.querySelectorAll("[" + ATTR + "]").forEach((el) => el.removeAttribute(ATTR));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// src/typeset/lineedge.ts
|
// src/typeset/lineedge.ts
|
||||||
var LE = "jz-half-le";
|
var LE = "jz-half-le";
|
||||||
var ALL_EDGE = [LE];
|
var ALL_EDGE = [LE];
|
||||||
@@ -2129,7 +2186,8 @@ var PASSES = [
|
|||||||
jinzePass,
|
jinzePass,
|
||||||
orphanPass,
|
orphanPass,
|
||||||
lineEdgePass,
|
lineEdgePass,
|
||||||
gapTrimPass
|
gapTrimPass,
|
||||||
|
levelMarkPass
|
||||||
];
|
];
|
||||||
function feature(v, dflt) {
|
function feature(v, dflt) {
|
||||||
return v === void 0 ? dflt : !!v;
|
return v === void 0 ? dflt : !!v;
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
import type { StandalonePass } from "../types.js";
|
||||||
|
export declare const levelMarkPass: StandalonePass;
|
||||||
+4
-3
@@ -11,6 +11,7 @@ import { Finder } from "./core/finder.js";
|
|||||||
import { runPasses, revertPasses } from "./core/pass.js";
|
import { runPasses, revertPasses } from "./core/pass.js";
|
||||||
import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.js";
|
import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.js";
|
||||||
import { jinzePass } from "./typeset/jinze.js";
|
import { jinzePass } from "./typeset/jinze.js";
|
||||||
|
import { levelMarkPass } from "./typeset/levelmark.js";
|
||||||
import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js";
|
import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js";
|
||||||
import { longWordPass } from "./typeset/longword.js";
|
import { longWordPass } from "./typeset/longword.js";
|
||||||
import { orphanPass } from "./typeset/orphan.js";
|
import { orphanPass } from "./typeset/orphan.js";
|
||||||
@@ -45,11 +46,11 @@ const DEFAULT_SKIP_TAGS = [
|
|||||||
|
|
||||||
// 執行序**不由本陣列決定**:runPasses(core/pass.ts)先分 charify/standalone 兩群、
|
// 執行序**不由本陣列決定**:runPasses(core/pass.ts)先分 charify/standalone 兩群、
|
||||||
// 各群再按 pass.order 排序(charify 群整體先於 standalone 群)。故此處排列僅供閱讀分組,
|
// 各群再按 pass.order 排序(charify 群整體先於 standalone 群)。故此處排列僅供閱讀分組,
|
||||||
// 真實序為 order:jiya(20)→jiyaAdjacency(25)→jinze(40)→spacing(50)→longWord(60)→
|
// 真實序:charify 群 jiya(20)→longWord(60)(共享單次 P1 走訪),再 standalone 群
|
||||||
// orphan(70)→lineEdge(90)→gapTrim(92)。
|
// levelMark(10)→jiyaAdjacency(25)→jinze(40)→spacing(50)→orphan(70)→lineEdge(90)→gapTrim(92)。
|
||||||
const PASSES: Pass[] = [
|
const PASSES: Pass[] = [
|
||||||
jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass,
|
jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass,
|
||||||
orphanPass, lineEdgePass, gapTrimPass,
|
orphanPass, lineEdgePass, gapTrimPass, levelMarkPass,
|
||||||
];
|
];
|
||||||
|
|
||||||
function feature(v: unknown, dflt: boolean): boolean
|
function feature(v: unknown, dflt: boolean): boolean
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// 塊級分類鉤子(下游建議七):把 resolved 功能級別寫回 DOM,供消費端 CSS 查詢。
|
||||||
|
//
|
||||||
|
// 消費端的段落級樣式(首要:CJK 兩端對齊 text-align:justify)語義上應與本庫的
|
||||||
|
// 段落級 pass(jinze 避頭尾/orphan 垂懸)作用於同一集合;但 level 判定原本只存
|
||||||
|
// 於內部快取、不寫回 DOM,消費端只能另行維護一份 CSS 白名單,兩份分類必然漂移
|
||||||
|
// (下游實例:.callout 綁了避頭尾卻不在 justify 名單,避頭尾與兩端對齊不一致)。
|
||||||
|
// 本 pass 把單一真相源暴露出去:
|
||||||
|
//
|
||||||
|
// render → 對每個「直接承載可處理文本」之塊寫 data-jz-block-level="paragraph|text"
|
||||||
|
// revert → 全數移除(I6)
|
||||||
|
//
|
||||||
|
// **僅標直接承載文本之塊**:純容器(body/ul/table…——直接文本僅空白)不標。
|
||||||
|
// 否則 body 恆被判 paragraph,消費端 `[data-jz-block-level="paragraph"]` 之
|
||||||
|
// justify 經繼承污染全頁,鉤子即廢。此集合恰為段落級 pass 實際作用之塊集
|
||||||
|
// (collectRuns 對純容器收不到 run)。avoid 子樹/pill 內部/域外文本不計
|
||||||
|
// (eachTextNode 同口徑),故未被處理之塊一致不標。
|
||||||
|
//
|
||||||
|
// 輸入/輸出分離:作者輸入屬性 data-jz-level 只讀不寫;本屬性為 resolved 輸出、
|
||||||
|
// 本庫絕不讀之(亦不在 C2 OBSERVE_ATTRS 白名單,外部改動不觸發重處理)。是否
|
||||||
|
// 對齊仍由消費端 CSS 決定(justify 非段落之純函數——窄欄邊注可 opt-out),本庫
|
||||||
|
// 只供分類、不介入樣式。
|
||||||
|
//
|
||||||
|
// order=10:standalone 群之首(於 charify 群後執行;charify 之 jz-* 包裹皆行內
|
||||||
|
// 元素,不改變「文本之最近塊祖先」,故時點無影響)。
|
||||||
|
|
||||||
|
import type { RenderContext, StandalonePass } from "../types.js";
|
||||||
|
|
||||||
|
const ATTR = "data-jz-block-level";
|
||||||
|
|
||||||
|
export const levelMarkPass: StandalonePass = {
|
||||||
|
name: "levelMark",
|
||||||
|
kind: "standalone",
|
||||||
|
order: 10,
|
||||||
|
level: "text", // 不受段落級閘限制:text 級之塊亦須標記(標記本身即分級輸出)
|
||||||
|
enabled: () => true, // 分類鉤子非排版功能,恆開(純屬性輸出、零版面影響)
|
||||||
|
render(ctx: RenderContext): void
|
||||||
|
{
|
||||||
|
const { finder, options } = ctx;
|
||||||
|
const blockSet = options.finder.blockTags;
|
||||||
|
// 近塊定位之 per-run 快取(兄弟文本節點共用 parentElement,免重複上攀)。
|
||||||
|
// 上攀止於 ctx.root:eachTextNode 之文本必在 root 內、鏈上元素亦然,故命中
|
||||||
|
// 之塊恆在 render 作用域內,revert(root) 必能移除;root 非塊時不越界外標。
|
||||||
|
const nearest = new Map<Element, Element | null>();
|
||||||
|
const nearestBlock = (
|
||||||
|
(p: Element): Element | null =>
|
||||||
|
{
|
||||||
|
const cached = nearest.get(p);
|
||||||
|
if (cached !== undefined) { return cached; }
|
||||||
|
let e: Element | null = p;
|
||||||
|
let block: Element | null = null;
|
||||||
|
while (e)
|
||||||
|
{
|
||||||
|
if (blockSet.has(e.nodeName)) { block = e; break; }
|
||||||
|
if (e === ctx.root) { break; }
|
||||||
|
e = e.parentElement;
|
||||||
|
}
|
||||||
|
nearest.set(p, block);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
finder.eachTextNode(ctx.root, (t) =>
|
||||||
|
{
|
||||||
|
if (!/\S/.test(t.data)) { return; } // 純空白(縮排/換行)不算承載文本
|
||||||
|
const p = t.parentElement;
|
||||||
|
const block = p ? nearestBlock(p) : null;
|
||||||
|
if (!block) { return; }
|
||||||
|
const level = finder.levelFor(block);
|
||||||
|
if (block.getAttribute(ATTR) !== level) { block.setAttribute(ATTR, level); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
revert(ctx: RenderContext): void
|
||||||
|
{
|
||||||
|
if (ctx.root.hasAttribute(ATTR)) { ctx.root.removeAttribute(ATTR); }
|
||||||
|
ctx.root.querySelectorAll("[" + ATTR + "]")
|
||||||
|
.forEach((el) => el.removeAttribute(ATTR));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1522,3 +1522,89 @@ test("C2 觀察:向葉塊追加子塊 → 撤除容器陳舊 jz-orphan(IV-3
|
|||||||
assert.equal(directOrphan, false, "容器轉非葉後,其直接 jz-orphan 已撤除");
|
assert.equal(directOrphan, false, "容器轉非葉後,其直接 jz-orphan 已撤除");
|
||||||
assert.ok(np.querySelector("jz-orphan"), "新塊 p 自身之 jz-orphan 正常");
|
assert.ok(np.querySelector("jz-orphan"), "新塊 p 自身之 jz-orphan 正常");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ----- 塊級分類鉤子 data-jz-block-level(下游建議七)-----
|
||||||
|
|
||||||
|
test("levelMark:直接承載文本之塊標 data-jz-block-level,純容器不標", () =>
|
||||||
|
{
|
||||||
|
const { document } = setupDom(
|
||||||
|
'<div id="m" lang="zh-Hant">容器直接文字。<p>子段文字。</p></div>'
|
||||||
|
+ "<ul><li>列表項文字。</li></ul>",
|
||||||
|
);
|
||||||
|
createJuzhen().render(document.body);
|
||||||
|
const m = document.getElementById("m");
|
||||||
|
assert.equal(m.getAttribute("data-jz-block-level"), "paragraph", "混合塊有直接文本 → 標");
|
||||||
|
assert.equal(m.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
|
||||||
|
assert.equal(document.querySelector("li").getAttribute("data-jz-block-level"), "paragraph");
|
||||||
|
assert.equal(
|
||||||
|
document.querySelector("ul").hasAttribute("data-jz-block-level"), false,
|
||||||
|
"純容器(直接文本僅空白)不標",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
document.body.hasAttribute("data-jz-block-level"), false,
|
||||||
|
"body 無直接文本不標(justify 鉤子不經繼承污染全頁)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("levelMark:level.text 選擇器/data-jz-level 屬性 → 標 text;作者輸入屬性不被觸碰", () =>
|
||||||
|
{
|
||||||
|
const { document } = setupDom(
|
||||||
|
'<h1>標題文字</h1><p data-jz-level="text">說明文字。</p>'
|
||||||
|
+ '<div data-jz-level="text"><p id="n">內層段落。</p></div>',
|
||||||
|
);
|
||||||
|
createJuzhen({ level: { text: "h1" } }).render(document.body);
|
||||||
|
assert.equal(document.querySelector("h1").getAttribute("data-jz-block-level"), "text", "選擇器命中");
|
||||||
|
const p = document.querySelector("p[data-jz-level]");
|
||||||
|
assert.equal(p.getAttribute("data-jz-block-level"), "text", "屬性命中");
|
||||||
|
assert.equal(p.getAttribute("data-jz-level"), "text", "作者輸入 data-jz-level 原樣保留");
|
||||||
|
assert.equal(
|
||||||
|
document.getElementById("n").getAttribute("data-jz-block-level"), "text",
|
||||||
|
"祖先 data-jz-level=text → 後代塊 resolved 為 text",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("levelMark:avoid 子樹與 pill 內部文本不計——僅含此類內容之塊不標", () =>
|
||||||
|
{
|
||||||
|
const { document } = setupDom(
|
||||||
|
"<div><code>onlyPill</code></div><p data-jz-skip>跳過內容。</p>",
|
||||||
|
);
|
||||||
|
createJuzhen().render(document.body);
|
||||||
|
assert.equal(
|
||||||
|
document.querySelector("div").hasAttribute("data-jz-block-level"), false,
|
||||||
|
"pill 內部不可見 → 塊不標",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
document.querySelector("p").hasAttribute("data-jz-block-level"), false,
|
||||||
|
"avoid 塊不標(與「未被處理」一致)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("levelMark:revert 全數移除 data-jz-block-level(I6),root 自身亦清", () =>
|
||||||
|
{
|
||||||
|
const { document } = setupDom("直接文字。<p>段落文字。</p>");
|
||||||
|
const jz = createJuzhen();
|
||||||
|
jz.render(document.body);
|
||||||
|
assert.equal(
|
||||||
|
document.body.getAttribute("data-jz-block-level"), "paragraph",
|
||||||
|
"body 有直接文本 → root 自身被標",
|
||||||
|
);
|
||||||
|
assert.ok(document.querySelector("p[data-jz-block-level]"), "段落已標");
|
||||||
|
jz.revert(document.body);
|
||||||
|
assert.equal(document.querySelectorAll("[data-jz-block-level]").length, 0, "子樹全清");
|
||||||
|
assert.equal(document.body.hasAttribute("data-jz-block-level"), false, "root 自身已清");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("levelMark C2:class 變化令 level 翻轉 → 觀察自動刷新 resolved 標記", async () =>
|
||||||
|
{
|
||||||
|
const { document } = setupDom('<p lang="zh-Hant">正文文字,標點。</p>');
|
||||||
|
const jz = createJuzhen({ level: { text: ".chrome" } });
|
||||||
|
jz.render(document.body);
|
||||||
|
jz.observe(document.body);
|
||||||
|
const p = document.querySelector("p");
|
||||||
|
assert.equal(p.getAttribute("data-jz-block-level"), "paragraph");
|
||||||
|
p.classList.add("chrome");
|
||||||
|
await tick();
|
||||||
|
jz.disconnect();
|
||||||
|
assert.equal(p.getAttribute("data-jz-block-level"), "text", "翻轉後 resolved 標記已刷新");
|
||||||
|
assert.equal(countTag(p, "jz-jinze"), 0, "text 級不再跑段落級禁則(重分類一致)");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user