fix(web): 修復排版邊界與增量生命週期

This commit is contained in:
2026-10-02 19:08:02 +08:00
parent 1c2fb0ed59
commit b1a16c4969
39 changed files with 4763 additions and 673 deletions
+336
View File
@@ -62,6 +62,46 @@
<p id="observer-case">观察初始内容</p>
</main>
<section id="halfwidth-fixture" class="panel juzhen" lang="zh-Hant" data-jz-level="text">
<div data-jz-halfwidth="halt">
<p id="halt-to-margin" data-jz-halfwidth="margin" data-jz-style="banjiao">甲,乙</p>
</div>
<div data-jz-halfwidth="margin">
<div data-jz-halfwidth="halt">
<p id="margin-to-halt" data-jz-style="banjiao">甲,乙</p>
<p id="nested-margin" data-jz-halfwidth="margin" data-jz-style="banjiao" style="text-decoration:underline">甲,乙</p>
</div>
<p id="fullwidth-bias" data-jz-style="quanjiao">甲,乙</p>
</div>
</section>
<section id="lifecycle-fixture" class="panel juzhen" lang="zh-Hans" data-jz-level="text">
<div id="partial-root"><p id="pending-a">原文,甲。</p><p id="pending-b">原文,乙。</p></div>
</section>
<section id="module-fixture" class="panel juzhen" lang="zh-Hant" data-jz-level="text">
<p id="source-pua">漢&#xE000;&#xE003;&#xE001;0&#xE002;GPT-4中</p>
<div class="module-pill"><p>甲ABC乙,丙。</p></div>
<p id="module-boundary" data-jz-level="paragraph"><span data-jz-skip>自管</span>,正常文字<span data-jz-level="text">界面文字</span></p>
<p id="pill-ivs-space"><strong>&#x20000;</strong><em>&#xE0100;</em> <code>x</code> <span>文</span></p>
<p id="jinze-ivs" data-jz-level="paragraph">前文<strong>&#x20000;</strong><em>&#xE0100;</em>,</p>
<p id="surrogate-spacing"><strong></strong><em></em><i></i><b></b>A</p>
<p id="locale-subtag-hans" lang="zh-CN-x-shant">甲,乙</p>
<p id="locale-subtag-hant" lang="zh-TW-x-hansford">甲,乙</p>
</section>
<section class="panel juzhen"><p id="inline-scope">甲<strong id="inline-root">,乙。</strong>後文</p></section>
<section id="layout-fixture" class="panel juzhen" lang="zh-Hant">
<p id="gap-small-neighbor" style="font-size:32px;text-align:center;white-space:nowrap">漢<span style="font-size:8px">A</span></p>
<p id="gap-sub-neighbor" style="font-size:32px;text-align:center;white-space:nowrap">漢<sub>A</sub></p>
</section>
<section id="punctuation-atom-fixture" class="panel juzhen" lang="zh-Hant">
<p id="punctuation-vs">甲<strong id="punctuation-base">,</strong><em id="punctuation-selector">&#xFE00;</em>「乙丙」</p>
<p id="punctuation-surrogate"><strong></strong><em></em><i></i><b></b>「甲</p>
<p id="punctuation-isolated"></p>
</section>
<script src="../dist/juzhen.iife.js"></script>
<script>
(function ()
@@ -137,6 +177,256 @@
});
jz.render(fixture);
var modeFixture = document.getElementById("halfwidth-fixture");
var modeJz = Juzhen.createJuzhen();
modeJz.render(modeFixture);
[ "halt-to-margin", "nested-margin" ].forEach(function (id)
{
var punctuation = document.getElementById(id).querySelector("jz-char");
var innerStyle = getComputedStyle(punctuation.querySelector("jz-inner"));
var widthEm = punctuation.getBoundingClientRect().width
/ parseFloat(getComputedStyle(punctuation).fontSize);
record(
id + ":最近 margin 标记控制半形宽度与字形",
Math.abs(widthEm - 0.5) <= 0.01
&& innerStyle.fontFeatureSettings === "normal"
&& innerStyle.display === "inline-block",
"width=" + widthEm.toFixed(3) + "em,features=" + innerStyle.fontFeatureSettings,
);
});
var haltInnerStyle = getComputedStyle(
document.getElementById("margin-to-halt").querySelector("jz-inner"),
);
record(
"margin-to-halt:恢复 halt 时撤销 margin 内盒与偏移",
haltInnerStyle.display === "inline"
&& parseFloat(haltInnerStyle.marginLeft) === 0
&& haltInnerStyle.fontFeatureSettings.includes('"halt"'),
);
var underline = getComputedStyle(
document.getElementById("nested-margin").querySelector("jz-inner"),
).textDecorationLine;
record(
"嵌套 margin 内盒保留段落下划线",
underline.includes("underline"),
"text-decoration-line=" + underline,
);
var fullwidth = document.getElementById("fullwidth-bias").querySelector("jz-char");
record(
"margin 模式不移动未压缩的全形标点",
!fullwidth.classList.contains("jz-half")
&& !fullwidth.classList.contains("jz-half-le")
&& parseFloat(getComputedStyle(fullwidth.querySelector("jz-inner")).marginLeft) === 0,
);
var atomFixture = document.getElementById("punctuation-atom-fixture");
var atomSurrogate = document.getElementById("punctuation-surrogate");
[ "\uD83D", "\uDE00", "\uDB40", "\uDD00" ].forEach(function (part, index)
{
atomSurrogate.children[index].textContent = part;
});
document.getElementById("punctuation-isolated").textContent = "\uD83DA\uDE00";
var atomOriginal = atomFixture.innerHTML;
var atomSelector = document.getElementById("punctuation-selector");
var atomClicks = 0;
atomSelector.addEventListener("click", function () { atomClicks += 1; });
var atomJz = Juzhen.createJuzhen({ charClass: { biaodian: { pause: "😀" } } });
atomJz.render(atomFixture);
var atomComma = document.getElementById("punctuation-vs").querySelector("jz-char.bd-pause");
record("跨节点标点与 VS 为完整原子并参与邻接挤压",
atomComma.textContent === ",\uFE00" && atomComma.classList.contains("jz-half")
&& document.getElementById("punctuation-vs").querySelector("jz-char.bd-open").classList.contains("jz-half"));
record("跨节点 surrogate 标点和 VS 不生成半字原子",
atomSurrogate.querySelector("jz-char.bd-pause").textContent === "😀\uDB40\uDD00"
&& !document.getElementById("punctuation-isolated").querySelector("jz-char"));
atomSelector.click();
record("跨标点原子保留作者元素身份及监听器",
document.getElementById("punctuation-selector") === atomSelector && atomClicks === 1);
atomJz.revert(atomFixture);
record("跨标点原子回退恢复来源分布", atomFixture.innerHTML === atomOriginal);
atomJz.disconnect();
var spacingOnlyOptions = { jiya: false, jinze: false, orphan: false, longWord: false };
var interactionRoot = document.createElement("p");
interactionRoot.setAttribute("data-jz-level", "text");
interactionRoot.innerHTML = '<span data-juzhen-off="spacing">甲 ABC 乙</span>'
+ '<code>x</code><span data-juzhen-off="spacing"> 丙</span>';
var interactionJz = Juzhen.createJuzhen(spacingOnlyOptions);
var interactionOriginal = interactionRoot.innerHTML;
interactionJz.render(interactionRoot);
record("行内 spacing 关闭时保留原文、作者空格与 pill 邻界",
interactionRoot.innerHTML === interactionOriginal);
interactionJz.revert(interactionRoot);
interactionRoot.innerHTML = '外ABC<span class="prose">甲DEF</span>文XYZ';
var includeJz = Juzhen.createJuzhen(Object.assign({}, spacingOnlyOptions,
{ scope: { include: ".prose" } }));
includeJz.render(interactionRoot);
record("行内 scope.include 子树获得间隙,外部保持原样",
interactionRoot.querySelectorAll("jz-hws").length === 1
&& interactionRoot.querySelector(".prose jz-hws").textContent === "甲"
&& interactionRoot.firstChild.textContent === "外ABC"
&& interactionRoot.lastChild.textContent === "文XYZ");
includeJz.revert(interactionRoot);
includeJz.disconnect();
interactionRoot.innerHTML = ',<span data-juzhen-off="jiya"></span>「';
interactionRoot.setAttribute("lang", "zh-Hant");
var disabledJiya = Juzhen.createJuzhen({ spacing: false, jinze: false, orphan: false, longWord: false });
disabledJiya.render(interactionRoot);
record("空功能禁用元素中断标点邻接",
interactionRoot.querySelectorAll("jz-char").length === 2
&& !interactionRoot.querySelector("jz-char.jz-half"));
disabledJiya.revert(interactionRoot);
disabledJiya.disconnect();
var carrierFailures = [];
[ "clone", "remove" ].forEach(function (change)
{
interactionRoot.innerHTML = '<strong>禰</strong><em>\uDB40\uDD00</em>A';
interactionJz.render(interactionRoot);
var gap = interactionRoot.querySelector("jz-hws");
if (change === "clone") { gap.replaceWith(gap.cloneNode(true)); }
else { gap.remove(); }
var authorText = interactionRoot.textContent;
interactionJz.revert(interactionRoot);
if (interactionRoot.textContent !== authorText) { carrierFailures.push(change); }
});
record("间隙载体克隆与删除后不重复或复活文字", carrierFailures.length === 0,
carrierFailures.join(","));
interactionJz.disconnect();
var ownerFailures = [];
[ [0,1,2], [1,0,2], [0,2,1], [1,2,0], [2,0,1], [2,1,0] ].forEach(function (order)
{
var paragraph = document.createElement("p");
[ "甲", "「", "禰", "\uFE00", "A" ].forEach(function (text)
{
paragraph.appendChild(document.createTextNode(text));
});
var owners = [
Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false }),
Juzhen.createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false }),
Juzhen.createJuzhen(spacingOnlyOptions),
];
owners.forEach(function (owner) { owner.render(paragraph); });
order.forEach(function (index) { owners[index].revert(paragraph); });
if (paragraph.textContent !== "甲「禰\uFE00A" || paragraph.querySelector("[data-jz]"))
{
ownerFailures.push(order.join("→"));
}
owners.forEach(function (owner) { owner.disconnect(); });
});
record("多实例传递解包的六种回退顺序保留 VS", ownerFailures.length === 0,
ownerFailures.join(","));
var reusedRoot = document.createElement("p");
[ "甲", "「", "禰", "\uFE00", "A" ].forEach(function (text)
{
reusedRoot.appendChild(document.createTextNode(text));
});
var reusedBinding = Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false });
var reusedOrphan = Juzhen.createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false });
var reusedSpacing = Juzhen.createJuzhen(spacingOnlyOptions);
reusedBinding.render(reusedRoot);
var retiredBinding = reusedRoot.querySelector("jz-jinze");
reusedOrphan.render(reusedRoot);
var retiredOrphan = reusedRoot.querySelector("jz-orphan");
reusedSpacing.render(reusedRoot);
reusedBinding.revert(reusedRoot);
retiredBinding.appendChild(retiredOrphan);
reusedOrphan.revert(retiredBinding);
var reusedText = retiredBinding.textContent;
reusedSpacing.revert(retiredBinding);
record("重新使用已撤回容器时回退有限结束并保留当前文字",
retiredBinding.textContent === reusedText);
[ reusedBinding, reusedOrphan, reusedSpacing ].forEach(function (owner) { owner.disconnect(); });
var layoutFixture = document.getElementById("layout-fixture");
var layoutJz = Juzhen.createJuzhen({ jiya: false, orphan: false, jinze: false, longWord: false });
layoutJz.render(layoutFixture);
[ "gap-small-neighbor", "gap-sub-neighbor" ].forEach(function (id)
{
var paragraph = document.getElementById(id);
var gap = paragraph.querySelector("jz-hws");
var neighbor = paragraph.querySelector("span, sub");
var gapRect = gap.getBoundingClientRect();
var nextRect = neighbor.getBoundingClientRect();
var overlaps = nextRect.top < gapRect.bottom - 2 && nextRect.bottom > gapRect.top + 2;
record(id + ":同行不同字号或基线保留间隙",
overlaps && !gap.classList.contains("jz-hws-trim"),
"gap=" + gapRect.top.toFixed(1) + ".." + gapRect.bottom.toFixed(1)
+ ",next=" + nextRect.top.toFixed(1) + ".." + nextRect.bottom.toFixed(1));
});
layoutJz.revert(layoutFixture);
layoutJz.disconnect();
var moduleFixture = document.getElementById("module-fixture");
var splitSurrogate = document.getElementById("surrogate-spacing");
[ "\uD840", "\uDC00", "\uDB40", "\uDD00" ].forEach(function (part, index)
{
splitSurrogate.children[index].textContent = part;
});
var moduleHtml = moduleFixture.innerHTML
.replace("</em> <code>", "</em><code>")
.replace("</code> <span>", "</code><span>");
var puaSource = document.getElementById("source-pua").textContent;
var modulePill = moduleFixture.querySelector(".module-pill");
var modulePillHtml = modulePill.innerHTML;
var moduleJz = Juzhen.createJuzhen({ pillSelector: "code, kbd, .module-pill" });
moduleJz.render(moduleFixture);
record("作者私用区字符与伪 placeholder 保持原文",
document.getElementById("source-pua").textContent === puaSource);
record("pill 内部的块不被排版", modulePill.innerHTML === modulePillHtml);
var protectedRegion = document.getElementById("module-boundary");
record("段落绑定不进入 avoid 或 text 子树",
!protectedRegion.querySelector("[data-jz-skip]").closest("jz-jinze")
&& !protectedRegion.querySelector('[data-jz-level="text"]').closest("jz-orphan"));
var pillIvs = document.getElementById("pill-ivs-space");
record("pill 两侧跨透明节点空格与完整 IVS 间隙",
pillIvs.textContent === "\uD840\uDC00\uDB40\uDD00x文"
&& pillIvs.querySelectorAll("jz-hws").length === 2
&& pillIvs.querySelector("jz-hws").textContent === "\uD840\uDC00\uDB40\uDD00");
record("禁则连同跨节点 base 与 VS 绑定到闭标点",
document.getElementById("jinze-ivs").querySelector("jz-jinze").textContent
=== "\uD840\uDC00\uDB40\uDD00,");
record("跨节点 surrogate 的间隙位于完整原子之后",
splitSurrogate.querySelector("jz-hws").textContent === "\uD840\uDC00\uDB40\uDD00"
&& splitSurrogate.textContent === "\uD840\uDC00\uDB40\uDD00A");
record("语言脚本按完整子标签匹配",
document.getElementById("locale-subtag-hans").querySelector("jz-char").classList.contains("jz-half")
&& !document.getElementById("locale-subtag-hant").querySelector("jz-char").classList.contains("jz-half"));
moduleJz.revert(moduleFixture);
record("模块回退精确恢复作者结构,保留已规范化空格规则",
moduleFixture.innerHTML === moduleHtml);
var inlineRoot = document.getElementById("inline-root");
var inlineParent = inlineRoot.parentNode;
var inlineHtml = inlineParent.innerHTML;
var inlineJz = Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false });
inlineJz.render(inlineRoot);
var inlineContained = inlineRoot.parentNode === inlineParent
&& !inlineParent.querySelector(":scope > jz-jinze");
inlineJz.revert(inlineRoot);
record("行内 render 根的禁则不越界并可完整回退",
inlineContained && inlineParent.innerHTML === inlineHtml);
modeJz.revert(modeFixture);
var lifecycleFixture = document.getElementById("lifecycle-fixture");
var partialRoot = document.getElementById("partial-root");
var pendingA = document.getElementById("pending-a");
var pendingB = document.getElementById("pending-b");
var lifecycleJz = Juzhen.createJuzhen();
lifecycleJz.render(lifecycleFixture);
lifecycleJz.render(pendingB);
lifecycleJz.revert(partialRoot);
record(
"局部 revert 同时撤回祖根与子根生成的产物",
!partialRoot.querySelector("[data-jz]")
&& !partialRoot.querySelector("[data-jz-block-level]"),
);
lifecycleJz.render(lifecycleFixture);
lifecycleJz.observe(lifecycleFixture);
pendingA.textContent = "改后,新文。";
lifecycleJz.rerender(pendingB);
await delay();
record(
"手动局部重排保留兄弟块待交付的外部变化",
pendingA.querySelectorAll("jz-char").length === 2,
);
lifecycleJz.disconnect();
lifecycleJz.revert(lifecycleFixture);
var firstGapCount = fixture.querySelectorAll("jz-hws").length;
var firstCharCount = fixture.querySelectorAll("jz-char").length;
record("已生成中西间隙", firstGapCount > 0, firstGapCount + " 个");
@@ -326,6 +616,52 @@
document.getElementById("observer-case").textContent = "观察Observer恢复";
await delay();
await delay();
var detachedRoot = document.createElement("main");
detachedRoot.innerHTML = '<p lang="zh-Hant">原文,甲乙。</p>';
var detachedJz = Juzhen.createJuzhen();
detachedJz.render(detachedRoot);
detachedJz.observe(detachedRoot);
detachedRoot.querySelector("p").textContent = "修改ABC,丙丁。";
var scopeRoot = document.createElement("main");
var scopeOutside = document.createElement("aside");
document.body.append(scopeRoot, scopeOutside);
var scopeJz = Juzhen.createJuzhen();
scopeJz.observe(scopeRoot);
var escaped = document.createElement("p");
escaped.textContent = "移出ABC,作者內容。";
scopeRoot.appendChild(escaped);
scopeOutside.appendChild(escaped);
var escapedOriginal = escaped.outerHTML;
await delay();
await delay();
record("未挂入 document 的观察根仍增量排版",
!!detachedRoot.querySelector("jz-hws") && !!detachedRoot.querySelector("jz-char"));
record("同批移出观察根的段落不被新增排版", escaped.outerHTML === escapedOriginal);
detachedJz.revert(detachedRoot);
detachedJz.disconnect();
scopeJz.revert(scopeRoot);
scopeJz.disconnect();
scopeRoot.remove();
scopeOutside.remove();
var relocateRoot = document.createElement("main");
relocateRoot.innerHTML = '<section><p>文字ABCDEF,甲乙。</p></section>'
+ '<section data-jz-level="text"></section>';
var relocateJz = Juzhen.createJuzhen();
relocateJz.render(relocateRoot);
relocateJz.observe(relocateRoot);
var relocatedParagraph = relocateRoot.querySelector("p");
relocateRoot.lastElementChild.appendChild(relocatedParagraph);
await delay();
record("已排版段落移入 text 范围后撤回旧段落约束",
relocatedParagraph.getAttribute("data-jz-block-level") === "text"
&& !relocatedParagraph.querySelector("jz-jinze,jz-orphan,[data-jz=longWord]")
&& !!relocatedParagraph.querySelector("jz-hws"));
var relocatedResult = relocatedParagraph.outerHTML;
relocateJz.disconnect();
relocateJz.rerender(relocateRoot);
record("移动段落的增量结果与完整重排一致",
relocatedResult === relocatedParagraph.outerHTML);
relocateJz.revert(relocateRoot);
record(
"MutationObserver 增量重排",
document.getElementById("observer-case").querySelectorAll("jz-hws").length > 0,