Files
cjk-autospace/index.html
admin b5ac89b687 docs: Pass C —— 長英文段 lang 包裝
CHANGELOG 記新項:Pass C 預設啟用之動機(避開原模板 wbr 每 6 字元亂
插之兩個觀感問題:斷點位置與音節無關、斷處不顯連字符),實作要點,
與字型陷阱(CJK serif 把 U+2010 設計為全形 1 em,須消費端加
hyphenate-character: "-" 強制 U+002D)。

index 加 Pass C 完整章節(演算法、消費端 CSS、選取潔淨性、不在範圍
之識別符 / URL);lede 與 attribution 段同步更新雙 pass → 三 pass。
2026-05-26 16:54:02 +08:00

4694 lines
169 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- built with theme=dracula, mode=report, at=2026-05-26T08:53:50Z -->
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cjk-autospace — 詳細文檔</title>
<meta name="generated-at" content="2026-05-22">
<meta name="source" content="cjk-autospace docs branch">
<style>
/* ---------- Core tokens ----------
Theme-agnostic CSS custom properties. Values that DO depend on the
active theme (font-sans, palette colors, tints) live in
`themes/<name>.css`. Only ONE theme file is inlined per build, so
these core tokens layer beneath whichever theme was selected.
Layered cascade order at build time:
core/tokens.css ← this file (non-color, partial font)
themes/<active>.css ← font-sans + palette + dark + print
(rest of runtime.css) ← CJK refinements, reset, layout, …
`--font-mono` references `var(--font-sans)` so its CJK fallback
tracks whichever sans family the active theme installed — Orbit
Gothic for Dracula, Noto Sans for RUC. CSS custom property var()
resolution is lazy (at use site), so the forward reference to
--font-sans is safe even though it's declared later in the
cascade. */
:root
{
/* Shared font-serif chain — both themes use Noto Serif for body. */
--font-serif: "Noto Serif CJK SC", "Noto Serif SC", "Noto Serif TC", "Noto Serif KR", "Noto Serif JP", "Noto Serif", serif;
/* Shared mono chain — falls through to active theme's sans for CJK. */
--font-mono: "Maple Mono", "Maple Mono NF CN", monospace, var(--font-sans);
/* Mono OT feature suppression (kern/liga/calt + CJK width-substitution
chws/halt/palt) keeps diff / code columns byte-aligned regardless
of the body's typography opt-ins. */
--mono-features: "kern" off, "liga" off, "calt" off, "chws" off, "halt" off, "palt" off;
/* Layout */
--radius: 4px;
--gap: 16px;
--gap-lg: 24px;
--gap-xl: 40px;
/* Motion */
--ease-pickup: cubic-bezier(0.2, 0.7, 0.2, 1);
--ease-settle: cubic-bezier(0.22, 1, 0.36, 1);
--dur-pickup: 120ms;
--dur-reorder: 220ms;
--dur-settle: 180ms;
--dur-indicator: 80ms;
color-scheme: light dark;
}
/* Region-specific font-serif. Reorders the Noto Serif regional cuts so
the current locale's variant sits first. font-sans :lang() overrides
(if any) live in the active theme file. */
:lang(zh-TW),
:lang(zh-Hant),
:lang(zh-HK),
:lang(zh-MO)
{
--font-serif: "Noto Serif CJK SC", "Noto Serif TC", "Noto Serif SC", "Noto Serif KR", "Noto Serif JP", "Noto Serif", serif;
}
:lang(ja)
{
--font-serif: "Noto Serif CJK SC", "Noto Serif JP", "Noto Serif SC", "Noto Serif TC", "Noto Serif KR", "Noto Serif", serif;
}
:lang(ko)
{
--font-serif: "Noto Serif CJK SC", "Noto Serif KR", "Noto Serif SC", "Noto Serif TC", "Noto Serif JP", "Noto Serif", serif;
}
/* ---------- Theme: Dracula ----------
Dracula Pro family — Alucard (light) + Pro (dark). Palette values
from `Dracula Pro/design/palette.md`; element role assignments from
the Sublime tmTheme `Markdown Tweaks` section.
Active theme selection happens at build time (build.sh `--theme`
flag or `<!-- theme: ... -->` source comment). Only ONE theme's CSS
is inlined per build, so the selectors here use plain `:root` with
no `[data-theme-family]` qualifier — there is no other theme in
the document to disambiguate against.
Within this theme, light↔dark is a runtime mode controlled by
`<html data-theme-mode="light|dark">` (or OS preference, or the
legacy `<html data-theme="dark|light">` alias). */
:root
{
/* Sans = Orbit Gothic CJK SC → Orbit Gothic (Latin) → sans-serif.
CJK SC is the only CJK variant string we declare — Orbit Gothic
carries the full pan-CJK set in one file and selects SC / TC /
JP / KR glyph shapes via OpenType `locl` based on `<html lang>`. */
--font-sans: "Orbit Gothic CJK SC", "Orbit Gothic", sans-serif;
/* Alucard (light) — palette.md */
--bg: #F5F5F5;
--bg-elev: #FBFBFB;
--fg: #1F1F1F;
--muted: #635D97;
--subtle: #E8E8EF;
--subtle-2: #EFEFF3;
--selection: #CFCFDE;
--border: #D5D5DE;
--border-strong: #B5B5C6;
--rule: #1F1F1F;
--accent: #644AC9; /* purple — links / structural marker (tmTheme markup.underline) */
--info: #036A96; /* cyan — headings / fenced delimiters (tmTheme markup.heading) */
--success: #14710A; /* green — inline code, additions (tmTheme markup.raw.inline) */
--warn: #846E15; /* yellow */
--error: #CB3A2A; /* red — deletions */
--emphasis: #A3144D; /* pink — list markers, h3 (tmTheme list-item punctuation) */
--orange: #A34D14; /* orange — bold/italic/strike, h4 (tmTheme markup.bold/italic/strike) */
/* Tinted backgrounds — derived from semantic colors via color-mix.
Used for callout fills, diff lines, hover states, etc. */
--tint-accent: color-mix(in srgb, var(--accent) 8%, transparent);
--tint-info: color-mix(in srgb, var(--info) 8%, transparent);
--tint-success: color-mix(in srgb, var(--success) 8%, transparent);
--tint-warn: color-mix(in srgb, var(--warn) 10%, transparent);
--tint-error: color-mix(in srgb, var(--error) 8%, transparent);
--tint-emphasis: color-mix(in srgb, var(--emphasis) 8%, transparent);
--tint-orange: color-mix(in srgb, var(--orange) 8%, transparent);
--tint-row: color-mix(in srgb, var(--accent) 5%, transparent);
--add-bg: color-mix(in srgb, var(--success) 10%, transparent);
--del-bg: color-mix(in srgb, var(--error) 10%, transparent);
}
/* === Pro (dark) =====================================================
Applied when OS prefers dark AND mode isn't manually pinned to
light. The `:not()` chain lets a manually-set light mode survive
the OS preference. */
@media (prefers-color-scheme: dark)
{
:root:not([data-theme="light"]):not([data-theme-mode="light"])
{
--bg: #22212C;
--bg-elev: #2D2B3C;
--fg: #F8F8F2;
--muted: #7970A9;
--subtle: #2D2B3C;
--subtle-2: #3A3950;
--selection: #454158;
--border: #3A3950;
--border-strong: #57536D;
--rule: #F8F8F2;
--accent: #9580FF; /* purple (Pro neon) */
--info: #80FFEA; /* cyan */
--success: #8AFF80; /* green */
--warn: #FFFF80; /* yellow */
--error: #FF9580; /* red */
--emphasis: #FF80BF; /* pink */
--orange: #FFCA80; /* orange */
--tint-accent: color-mix(in srgb, var(--accent) 14%, transparent);
--tint-info: color-mix(in srgb, var(--info) 14%, transparent);
--tint-success: color-mix(in srgb, var(--success) 14%, transparent);
--tint-warn: color-mix(in srgb, var(--warn) 14%, transparent);
--tint-error: color-mix(in srgb, var(--error) 14%, transparent);
--tint-emphasis: color-mix(in srgb, var(--emphasis) 14%, transparent);
--tint-orange: color-mix(in srgb, var(--orange) 14%, transparent);
--tint-row: color-mix(in srgb, var(--accent) 9%, transparent);
--add-bg: color-mix(in srgb, var(--success) 16%, transparent);
--del-bg: color-mix(in srgb, var(--error) 16%, transparent);
}
}
/* Manual override — Pro dark regardless of OS preference. Covers the
new `data-theme-mode="dark"` and the legacy `data-theme="dark"`
alias. (No `[data-theme-family]` qualifier needed — theme is
build-time, not runtime.) */
:root[data-theme="dark"],
:root[data-theme-mode="dark"]
{
--bg: #22212C;
--bg-elev: #2D2B3C;
--fg: #F8F8F2;
--muted: #7970A9;
--subtle: #2D2B3C;
--subtle-2: #3A3950;
--selection: #454158;
--border: #3A3950;
--border-strong: #57536D;
--rule: #F8F8F2;
--accent: #9580FF;
--info: #80FFEA;
--success: #8AFF80;
--warn: #FFFF80;
--error: #FF9580;
--emphasis: #FF80BF;
--orange: #FFCA80;
--tint-accent: color-mix(in srgb, var(--accent) 14%, transparent);
--tint-info: color-mix(in srgb, var(--info) 14%, transparent);
--tint-success: color-mix(in srgb, var(--success) 14%, transparent);
--tint-warn: color-mix(in srgb, var(--warn) 14%, transparent);
--tint-error: color-mix(in srgb, var(--error) 14%, transparent);
--tint-emphasis: color-mix(in srgb, var(--emphasis) 14%, transparent);
--tint-orange: color-mix(in srgb, var(--orange) 14%, transparent);
--tint-row: color-mix(in srgb, var(--accent) 9%, transparent);
--add-bg: color-mix(in srgb, var(--success) 16%, transparent);
--del-bg: color-mix(in srgb, var(--error) 16%, transparent);
}
/* === Print: force Alucard light =====================================
Ensures print output is legible on white paper regardless of the
active mode. Covers every selector path that swaps to dark. */
@media print
{
:root,
:root[data-theme="dark"],
:root[data-theme="light"],
:root[data-theme-mode="dark"],
:root[data-theme-mode="light"]
{
--bg: #FFFFFF;
--bg-elev: #FFFFFF;
--fg: #1F1F1F;
--muted: #635D97;
--subtle: #E8E8EF;
--subtle-2: #EFEFF3;
--selection: #CFCFDE;
--border: #B5B5C6;
--border-strong: #635D97;
--rule: #1F1F1F;
--accent: #644AC9;
--info: #036A96;
--success: #14710A;
--warn: #846E15;
--error: #CB3A2A;
--emphasis: #A3144D;
--orange: #A34D14;
}
}
/* ---------- CJK typography refinements ----------
CSS-native primitives applied via :where(:lang(...)) at zero specificity.
Latin-only pages get no behavioural change.
Trim style: 全角式 (fullwidth glyphs preserved) + optical sidebearing
collapse at three positions. NOT 開明式 — that's a glyph-substitution
scheme (sentence-end fullwidth, non-terminal halfwidth) requiring
`chws`/`halt` OT features, not achievable via CSS sidebearing alone.
Here every punctuation keeps its fullwidth glyph; only the leading /
trailing /inner sidebearings of certain positions are collapsed per
W3C clreq §3.1.3 + the rule "if a fullwidth punctuation's sidebearing
borders another punctuation on that side, collapse it." Achieved via
`text-spacing-trim: trim-all`, which explicitly:
- Trims sidebearings between adjacent fullwidth punctuation pairs
(`);`, `。」`, `:「`, etc.) — combined width ~1.5em not 2em.
- Trims opening fullwidth at line start (each line, including
paragraph start — web typography convention is space-separated
paragraphs rather than first-line indent).
- Trims closing fullwidth at line end (so the closing punct sits
flush with the right edge instead of leaving a half-em hole).
`normal` was tried but proved equivalent to `space-first` in current
browsers (no adjacent trim). `trim-all` is the only spec value that
guarantees the adjacent-pair behavior we want.
Hanging punctuation: `hanging-punctuation: first allow-end`. Safari
honors fully; Firefox 145+ honors `force-end / allow-end`; Chromium
doesn't yet implement (silent no-op).
`text-autospace`: inserts ~1/4em between CJK and Latin/digits at
render time WITHOUT modifying the DOM, so the Markdown twin
(`<script id="md-source">`) and `textContent` stay clean.
`line-break: strict` enables full kinsoku processing (JIS X 4051 /
clreq §3.1.2). `overflow-wrap: anywhere` is the escape hatch for
unbreakable URLs / hashes / very-long English words that would
otherwise overflow.
`word-break` stays at the UA default (`normal`). Setting it to
`keep-all` forbids ALL inter-Han breaks, leaving up to one
punctuation-chunk of width empty on the right.
OT features `chws` / `halt` / `palt` are NOT enabled at body level —
they push toward halfwidth and break the trim style. Apply locally if
a dense component needs tighter set. */
:where(:lang(zh), :lang(ja), :lang(ko))
{
text-spacing-trim: trim-all;
text-autospace: no-autospace; /* native disabled; JS shim (runtime.js, Pangu.js rules) inserts <span class="cjk-autospace"> ( ) at boundaries; user-select: none on the span keeps the inserted space out of selection & clipboard */
hanging-punctuation: first allow-end;
line-break: strict;
overflow-wrap: anywhere;
}
/* Helper span injected by runtime.js's autospace shim. The span
contains a real ASCII space so layout treats it as genuine
whitespace: collapses at line ends / starts, allows line break
opportunities, and participates in justify expansion. None of
which an inline-block with a fixed em width can do — that was
the original shape and it broke two-end justify when a digit
ran up against the line edge.
`user-select: none` makes the browser skip this element when
computing the selection range, so neither the visual selection
rectangle nor the clipboard payload includes the inserted space. */
.cjk-autospace
{
-webkit-user-select: none;
user-select: none;
}
/* Pass C 配套:cjk-autospace lib 把長英文段包進 <span lang="en">,此處
讓瀏覽器原生 hyphens: auto 用 en 字典做斷詞 + 渲染連字符。連字符純
視覺(DOM 不存在),複製返回原文無污染。
hyphenate-limit-chars: 6 3 3 — 詞長 ≥6、斷點前/後各 ≥3 字符,避免
把 "before" 斷成 "be-fore" 之類粒度過細之結果。Safari/Firefox 對此
屬性支援不全,未生效時退至各家瀏覽器之默認啟發。
hyphenate-character: "-" — 強制斷字符為 U+002D HYPHEN-MINUS(窄拉丁
寬度)而非預設之 U+2010 HYPHEN。Noto Serif CJK SC 把 U+2010 設計為
全形 CJK 標點(1 em,等於一個中文字寬度)以服務 CJK 範圍表達,但
用在英文斷字會視覺斷裂;U+002D 在同字型中為窄拉丁,與作者鍵入的 -
一致。仍為純視覺渲染,DOM 與剪貼簿不受影響。 */
[lang|="en"]
{
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3;
hyphenate-character: "-";
-webkit-hyphenate-character: "-";
}
/* Default two-sided justified alignment for any multi-line prose / data
container in CJK lang. Auto-applies — no class required. Principle:
text that wraps to 2+ lines should justify regardless of tag (table
cells, list items, definition descriptions, captions, etc.).
`text-align-last: left` keeps the paragraph-final line and any
single-line content (which IS its own last line) naturally left-
aligned, so only true middle lines stretch.
Code / mono content (`<pre>`, `<code>`, `<kbd>`, `<samp>`) opts out
via the mono rule far above (text-align: revert added there). */
:where(:lang(zh), :lang(ja), :lang(ko))
:is(p, li, blockquote, td, dd, summary, figcaption, caption, .justify, .justify *)
{
text-align: justify;
text-justify: inter-character;
text-align-last: left;
}
/* Agent escape hatches.
`.justify` — force-ON. Apply to any container to make the container
itself (if it carries direct text) and every text-bearing descendant
render justified. Already part of the default selector above via
`.justify, .justify *`.
`.no-justify` — force-OFF. Apply to any container to revert the
container and all descendants to natural left alignment. Use for
layouts where the default multi-line justify is undesirable (dense
captions, narrow data cells with fixed wraps, etc.). Specificity
0,1,1 on the descendant selector beats the default 0,0,1. */
:where(:lang(zh), :lang(ja), :lang(ko)) :is(.no-justify, .no-justify *)
{
text-align: start;
text-justify: auto;
text-align-last: auto;
}
/* Explicit-alignment utilities.
Three classes — `.text-left`, `.text-center`, `.text-right` — for
when the default left-justified prose alignment is wrong. Primary
use: numeric / right-aligned table columns (`<td class="text-right">
$1,234</td>`) and centered headers (`<th class="text-center">
Status</th>`). They also work on `<th>` (which otherwise sticks
at `text-align: left`), on `<caption>`, on any block-level
container, and propagate to descendants via the `*` selector so
`<table class="text-center">` centers all cells. Specificity is
(0,1,0) on the class and (0,1,0) on the descendant form, both
beating the default justify / th-left rules at (0,0,1).
`text-justify: auto` + `text-align-last: auto` also win over the
`inter-character` / `left` values from the default rule, so the
alignment is genuinely explicit — no residual inter-character
stretching. */
.text-left, .text-left *
{
text-align: left;
text-justify: auto;
text-align-last: auto;
}
.text-center, .text-center *
{
text-align: center;
text-justify: auto;
text-align-last: auto;
}
.text-right, .text-right *
{
text-align: right;
text-justify: auto;
text-align-last: auto;
}
/* ---------- Reset ---------- */
* { box-sizing: border-box; }
::selection { background: var(--selection); color: var(--fg); }
html { -webkit-text-size-adjust: 100%; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
body
{
font-family: var(--font-serif);
font-size: 16px;
font-weight: 500; /* Medium — CJK serif renders too thin at 400 on screen */
line-height: 1.7;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "kern", "liga";
}
/* Mono opt-out: lock weight back to Regular (body inherits Medium 500),
disable all CJK width-substitution / typography features so diff /
code / table columns stay byte-aligned, and revert any inherited
justify (the default CJK rule justifies many tags; mono must not). */
code, kbd, pre, pre code,
.diff-block, .diff-block .line .lno, .diff-block .line .code,
.timeline .ts, .marginalia
{
font-weight: 400;
font-feature-settings: var(--mono-features);
text-autospace: no-autospace;
text-spacing-trim: space-all;
text-align: start;
text-justify: auto;
text-align-last: auto;
}
/* ---------- Typography ----------
Body is serif; headings are sans. The contrast is intentional — the page
reads like a typeset essay with sans display marks for structure.
Heading colors derive from tmTheme:
h1 = cyan (markup.heading) with a purple bar as structural marker
h2 = purple (markup.underline) with a 2px underline
h3 = pink (list-item punctuation)
h4 = orange (bold/italic/strike)
h5/h6 = muted (quote / separator), uppercase */
h1, h2, h3, h4, h5, h6
{
font-family: var(--font-sans);
line-height: 1.2;
}
h1
{
position: relative;
margin: 0 0 20px;
padding: 0 0 10px;
font-size: 32px;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.015em;
color: var(--info);
width: fit-content;
max-width: 100%;
}
/* Underline matches the title's fit-content width — same mechanism
as slides' cover deck-title. Cleaner than a left-side bar for
centred or short titles. */
h1::after
{
content: "";
display: block;
width: 100%;
height: 4px;
background: var(--accent);
margin-top: 10px;
}
h2
{
position: relative;
margin: 56px 0 16px;
padding-bottom: 5px;
padding-left: 18px;
width: fit-content;
max-width: 100%;
border-bottom: 2px solid var(--accent);
font-size: 22px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.01em;
}
h2::before
{
content: "";
position: absolute;
left: 0;
top: 50%;
width: 10px;
height: 10px;
background: var(--accent);
transform: translateY(-65%);
}
main > h2:first-child,
section > h2:first-child,
article > h2:first-child { margin-top: 0; }
h3
{
margin: 32px 0 8px;
font-size: 17px;
font-weight: 700;
color: var(--emphasis);
letter-spacing: -0.005em;
}
h4
{
margin: 24px 0 6px;
font-size: 14px;
font-weight: 700;
color: var(--orange);
letter-spacing: 0.02em;
}
h5, h6
{
margin: 20px 0 6px;
font-size: 11px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.14em;
}
p { margin: 0 0 14px; }
strong { font-weight: 700; color: var(--orange); } /* tmTheme markup.bold */
em { font-style: italic; color: var(--orange); } /* tmTheme markup.italic */
del, s
{
text-decoration: line-through;
text-decoration-color: var(--orange);
color: var(--orange);
}
a
{
color: var(--accent); /* tmTheme markup.underline */
text-decoration: underline;
text-decoration-thickness: 1px;
text-decoration-color: var(--accent);
text-underline-offset: 3px;
transition: color 0.14s, text-decoration-color 0.14s, text-decoration-thickness 0.14s;
}
a:hover
{
color: var(--emphasis);
text-decoration-color: var(--emphasis);
text-decoration-thickness: 2px;
}
a:visited { color: var(--emphasis); }
a:visited:hover { color: var(--orange); text-decoration-color: var(--orange); }
blockquote
{
margin: 24px 0;
padding: 8px 16px 8px 20px;
background: var(--tint-info);
border-left: 4px solid var(--info);
border-radius: 0 var(--radius) var(--radius) 0;
font-family: var(--font-serif);
font-style: italic;
font-size: 17px;
color: var(--muted); /* tmTheme markup.quote = muted italic */
line-height: 1.55;
}
blockquote > p:last-child { margin-bottom: 0; }
blockquote cite
{
display: block;
margin-top: 10px;
font-family: var(--font-sans);
font-style: normal;
font-size: 11px;
color: var(--muted);
letter-spacing: 0.12em;
text-transform: uppercase;
}
blockquote cite::before { content: "— "; }
ul, ol { margin: 12px 0; padding-left: 1.5em; }
li { margin: 4px 0; }
li::marker { color: var(--emphasis); } /* tmTheme list-item punctuation */
ol > li::marker
{
font-family: var(--font-mono);
font-feature-settings: "tnum";
font-size: 0.85em;
color: var(--emphasis);
}
ul ul li::marker,
ol ol li::marker { color: var(--muted); }
hr
{
border: none;
height: 32px;
margin: 40px 0;
text-align: center;
line-height: 32px;
font-family: var(--font-serif);
font-size: 22px;
}
hr::before
{
content: "✦ ✦ ✦";
color: var(--info);
letter-spacing: 0.4em;
}
hr.ornament::before
{
content: "❦";
color: var(--accent);
letter-spacing: 0;
font-size: 24px;
}
hr.line
{
height: 1px;
background: var(--border);
margin: 32px 0;
}
hr.line::before { content: none; }
.lede::first-letter
{
font-family: var(--font-serif);
float: left;
font-size: 4.2em;
line-height: 0.85;
padding: 8px 14px 0 0;
color: var(--accent);
font-weight: 700;
}
.marginalia
{
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.06em;
}
/* ---------- Buttons ---------- */
.btn
{
font-family: var(--font-sans);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 12px;
cursor: pointer;
transition: color 0.14s, border-color 0.14s, background 0.14s;
white-space: nowrap;
}
.btn:hover { color: var(--accent); border-color: var(--accent); }
.btn:active { background: var(--subtle); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---------- Code ---------- */
code
{
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--subtle);
padding: 0.05em 0.4em;
border-radius: var(--radius);
color: var(--success); /* tmTheme markup.raw.inline */
/* Same opt-out as the composite mono rule above — inline `code` would
otherwise win the cascade (later + same specificity) and leak the
body's CJK features into mono spans. */
font-feature-settings: var(--mono-features);
}
pre
{
position: relative;
margin: 18px 0;
padding: 14px 16px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-left: 3px solid var(--info); /* fenced delimiter strip */
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
}
pre code
{
background: transparent;
padding: 0;
font-size: inherit;
color: var(--fg); /* tmTheme markup.raw.block (fg) */
}
pre .copy-btn
{
position: absolute;
top: 8px;
right: 8px;
padding: 2px 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--font-sans);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.18s, color 0.14s, border-color 0.14s;
}
pre:hover .copy-btn,
pre .copy-btn:focus-visible { opacity: 1; }
pre .copy-btn:hover { color: var(--accent); border-color: var(--accent); }
kbd
{
display: inline-block;
padding: 0 6px;
background: var(--subtle);
border: 1px solid var(--border-strong);
border-bottom-width: 2px;
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg);
}
/* ---------- Tables ---------- */
table
{
width: 100%;
border-collapse: collapse;
margin: 24px 0;
font-family: var(--font-sans);
font-size: 14px;
}
caption
{
caption-side: bottom;
margin-top: 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
text-align: left;
letter-spacing: 0.06em;
}
th
{
padding: 10px 14px;
background: var(--subtle);
border-bottom: 1px solid var(--border);
text-align: left;
font-weight: 700;
font-size: 11px;
color: var(--info);
letter-spacing: 0.12em;
text-transform: uppercase;
vertical-align: bottom;
}
th:first-child { border-top-left-radius: var(--radius); }
th:last-child { border-top-right-radius: var(--radius); }
td
{
padding: 10px 14px;
border: none;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
tbody tr:nth-child(even) td { background: var(--bg-elev); }
tbody tr:hover td { background: var(--tint-row); }
tbody tr:last-child td { border-bottom: none; }
td code { font-size: 12px; }
/* ---------- Details ---------- */
details
{
margin: 12px 0;
padding: 0;
border: none;
}
details > summary
{
cursor: pointer;
outline: none;
list-style: none;
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 8px;
margin: 0 -8px;
border-radius: var(--radius);
font-family: var(--font-sans);
font-weight: 700;
color: var(--info);
transition: color 0.14s, background 0.14s;
}
details > summary:hover { background: var(--tint-info); }
details > summary::-webkit-details-marker { display: none; }
details > summary::before
{
content: "▸";
flex-shrink: 0;
width: 14px;
font-size: 0.82em;
color: var(--info);
transition: color 0.14s;
}
details[open] > summary::before { content: "▾"; }
details > summary:hover,
details > summary:hover::before { color: var(--accent); }
details > *:not(summary)
{
margin: 8px 0 12px 22px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
details > *:not(summary):last-child { margin-bottom: 4px; }
/* ---------- Callouts ----------
Editor's-note treatment: left rule + uppercase label, tinted body fill
keyed to the severity color via color-mix. */
.callout
{
margin: 24px 0;
padding: 12px 16px 12px 18px;
background: color-mix(in srgb, var(--muted) 6%, transparent);
border-left: 4px solid var(--muted);
border-radius: 0 var(--radius) var(--radius) 0;
}
.callout > .label
{
display: block;
margin: 0 0 6px;
font-family: var(--font-sans);
font-weight: 700;
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.callout > p { margin: 0 0 8px; font-family: var(--font-serif); }
.callout > p:last-child { margin-bottom: 0; }
.callout.info { background: var(--tint-info); border-left-color: var(--info); }
.callout.info > .label { color: var(--info); }
.callout.success { background: var(--tint-success); border-left-color: var(--success); }
.callout.success > .label { color: var(--success); }
.callout.warn { background: var(--tint-warn); border-left-color: var(--warn); }
.callout.warn > .label { color: var(--warn); }
.callout.error { background: var(--tint-error); border-left-color: var(--error); }
.callout.error > .label { color: var(--error); }
/* ---------- Badges ---------- */
.badge
{
display: inline-block;
padding: 2px 8px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--font-sans);
font-size: 10px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.14em;
vertical-align: 0.10em;
line-height: 1.6;
}
.badge.sev-low { color: var(--success); border-color: var(--success); }
.badge.sev-med { color: var(--warn); border-color: var(--warn); }
.badge.sev-high { color: var(--error); border-color: var(--error); }
.badge.accent { color: var(--accent); border-color: var(--accent); }
/* ---------- Progress track (segmented phase overview) ----------
Solid-color segments — louder than badges by design, since this is
the template's first true data-viz element and needs glanceability.
Restraint lives in the typography (sans 11px uppercase letter-spaced,
not bold) and the slim 1.75em height.
Author API:
<div class="progress-track">
<div class="progress-seg done" style="--weight: 14">label</div>
<div class="progress-seg active" style="--weight: 10">label</div>
</div>
<div class="progress-legend">
<span class="l-done">已完成</span>
<span class="l-active">進行中</span>
</div>
`--weight` is a CSS custom property used as a *parameter* (not a
style declaration) — it maps to `flex` inside the CSS rule. This is
the sanctioned form for passing per-segment numeric data; CSS custom
properties via inline style are treated as data attributes for
parameter purposes, not as inline styling.
`color: var(--bg)` makes segment labels auto-invert with the theme:
light mode → near-white on saturated bg; dark mode → near-black on
neon bg. Both directions stay legible without per-theme overrides. */
.progress-track
{
display: flex;
height: 1.75em;
margin: var(--gap-lg) 0 8px;
overflow: hidden;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.progress-track > .progress-seg
{
flex: var(--weight, 1);
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
background: var(--muted);
color: var(--bg);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-track > .progress-seg.done { background: var(--success); }
.progress-track > .progress-seg.active { background: var(--accent); }
.progress-track > .progress-seg.future { background: var(--info); }
.progress-track > .progress-seg.prep { background: var(--muted); }
.progress-legend
{
display: flex;
flex-wrap: wrap;
gap: 18px;
justify-content: center;
margin: 0 0 var(--gap-lg);
font-family: var(--font-sans);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.04em;
}
.progress-legend > span
{
display: inline-flex;
align-items: center;
gap: 6px;
}
.progress-legend > span::before
{
content: "";
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
background: currentColor;
}
.progress-legend > .l-done { color: var(--success); }
.progress-legend > .l-active { color: var(--accent); }
.progress-legend > .l-future { color: var(--info); }
.progress-legend > .l-prep { color: var(--muted); }
/* ---------- Timeline ---------- */
.timeline
{
list-style: none;
margin: 24px 0 24px 104px;
padding: 0;
border-left: 1px dashed color-mix(in srgb, var(--info) 35%, var(--border));
}
.timeline > li
{
position: relative;
padding: 0 0 22px 22px;
min-height: 30px;
font-family: var(--font-serif);
}
.timeline > li:last-child { padding-bottom: 0; }
.timeline > li::before
{
content: "";
position: absolute;
left: -7px;
top: 5px;
width: 13px;
height: 13px;
background: var(--bg);
border: 2px solid var(--info);
border-radius: 50%;
box-sizing: border-box;
}
.timeline > li.sev-high::before { border-color: var(--error); background: var(--error); }
.timeline > li.sev-med::before { border-color: var(--warn); }
.timeline > li.sev-low::before { border-color: var(--success); }
.timeline > li.muted::before { border-color: var(--muted); background: var(--muted); }
.timeline > li.filled::before { border-color: var(--accent); background: var(--accent); }
.timeline .ts
{
position: absolute;
left: -104px;
top: 5px;
width: 92px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
text-align: right;
letter-spacing: 0.04em;
font-feature-settings: "tnum";
white-space: nowrap;
}
.timeline > li > p { margin: 0 0 6px; }
.timeline > li > p:last-child { margin-bottom: 0; }
.timeline > li > details { margin-top: 6px; }
.timeline > li > details > summary { padding-top: 0; }
@media (max-width: 720px)
{
.timeline { margin-left: 0; }
.timeline .ts
{
position: static;
display: block;
margin-bottom: 4px;
width: auto;
left: auto;
text-align: left;
}
}
/* ---------- Compare columns ----------
Each column wears a top color band: A=purple, B=pink, C=cyan — three
distinct emphasis colors from tmTheme. */
.compare-cols
{
display: grid;
gap: var(--gap);
margin: 24px 0;
counter-reset: col;
}
.compare-cols.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.compare-cols.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.compare-cols > .col
{
position: relative;
padding: 36px 18px 18px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-top: 4px solid var(--accent);
border-radius: var(--radius);
counter-increment: col;
}
.compare-cols > .col:nth-child(2) { border-top-color: var(--emphasis); }
.compare-cols > .col:nth-child(3) { border-top-color: var(--info); }
.compare-cols > .col::after
{
content: counter(col, upper-alpha);
position: absolute;
top: 10px;
right: 14px;
font-family: var(--font-sans);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--accent);
}
.compare-cols > .col:nth-child(2)::after { color: var(--emphasis); }
.compare-cols > .col:nth-child(3)::after { color: var(--info); }
.compare-cols > .col.chosen
{
border-top-width: 6px;
padding-top: 34px; /* 6px border + 34px padding = same content top as non-chosen's 4 + 36 */
background: color-mix(in srgb, var(--accent) 4%, var(--bg-elev));
}
.compare-cols > .col:nth-child(2).chosen { background: color-mix(in srgb, var(--emphasis) 4%, var(--bg-elev)); }
.compare-cols > .col:nth-child(3).chosen { background: color-mix(in srgb, var(--info) 4%, var(--bg-elev)); }
.compare-cols > .col.chosen::before
{
content: "CHOSEN";
position: absolute;
top: 10px;
left: 14px;
font-family: var(--font-sans);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--accent);
}
.compare-cols > .col:nth-child(2).chosen::before { color: var(--emphasis); }
.compare-cols > .col:nth-child(3).chosen::before { color: var(--info); }
.compare-cols > .col > h4
{
margin: 0 0 12px;
padding: 0 36px 8px 0;
border-bottom: 1px solid var(--border);
font-size: 16px;
color: var(--fg);
text-transform: none;
letter-spacing: -0.005em;
}
.compare-cols > .col > p
{
margin: 0 0 10px;
font-family: var(--font-serif);
font-size: 14px;
line-height: 1.6;
color: var(--fg);
}
.compare-cols > .col > ul,
.compare-cols > .col > ol { margin: 8px 0; padding-left: 18px; font-size: 14px; font-family: var(--font-serif); }
@media (max-width: 720px)
{
.compare-cols.cols-2,
.compare-cols.cols-3 { grid-template-columns: 1fr; }
}
/* ---------- Tabs (underline) ---------- */
.tabs { margin: 24px 0; }
.tabs [role="tablist"]
{
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.tabs [role="tab"]
{
margin-bottom: -1px;
padding: 10px 18px 9px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.14em;
cursor: pointer;
white-space: nowrap;
transition: color 0.18s, border-color 0.18s;
}
.tabs [role="tab"]:hover { color: var(--fg); background: color-mix(in srgb, var(--info) 5%, transparent); }
.tabs [role="tab"][aria-selected="true"]
{
color: var(--info);
background: var(--tint-info);
border-bottom-color: var(--info);
border-bottom-width: 3px;
}
.tabs [role="tab"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tabs [role="tabpanel"] { padding: 18px 0 6px; }
.tabs [role="tabpanel"][hidden] { display: none; }
/* ---------- Diff review ---------- */
.diff-review
{
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: var(--gap-lg);
margin: 24px 0;
}
@media (max-width: 880px) { .diff-review { grid-template-columns: 1fr; } }
.diff-block
{
overflow: hidden;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 13px;
}
.diff-block .file
{
padding: 8px 14px;
background: var(--subtle);
border-bottom: 1px solid var(--border);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 700;
color: var(--info);
text-transform: uppercase;
letter-spacing: 0.14em;
}
.diff-block .hunk
{
padding: 4px 14px;
background: var(--subtle-2);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.06em;
}
.diff-block .line
{
position: relative;
display: grid;
grid-template-columns: 48px 1fr;
align-items: baseline;
line-height: 1.6;
white-space: pre;
transition: background 0.08s;
}
.diff-block .line:hover { background: var(--subtle-2); }
.diff-block .line .lno
{
padding: 1px 10px 1px 0;
border-right: 1px solid var(--border);
color: var(--muted);
font-size: 11px;
font-feature-settings: "tnum";
text-align: right;
user-select: none;
}
.diff-block .line .code { padding: 1px 0 1px 12px; }
.diff-block .line.add { background: var(--add-bg); }
.diff-block .line.del { background: var(--del-bg); }
.diff-block .line.add::before,
.diff-block .line.del::before
{
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
}
.diff-block .line.add::before { background: var(--success); }
.diff-block .line.del::before { background: var(--error); }
.diff-block .line.add .code { color: var(--success); }
.diff-block .line.add .lno { color: var(--success); border-right-color: color-mix(in srgb, var(--success) 35%, transparent); }
.diff-block .line.del .code { color: var(--error); }
.diff-block .line.del .lno { color: var(--error); border-right-color: color-mix(in srgb, var(--error) 35%, transparent); }
.notes { display: flex; flex-direction: column; gap: 12px; }
.note
{
padding: 12px 14px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-left: 4px solid var(--muted);
border-radius: var(--radius);
font-family: var(--font-serif);
font-size: 13px;
line-height: 1.6;
}
.note.sev-high { border-left-color: var(--error); }
.note.sev-med { border-left-color: var(--warn); }
.note.sev-low { border-left-color: var(--success); }
.note > header
{
display: flex;
gap: 8px;
align-items: center;
margin: 0;
padding: 0 0 6px;
border: none;
max-width: unset;
background: transparent;
font-family: var(--font-sans);
}
.note > header .target
{
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted);
letter-spacing: 0.04em;
}
.note > p:last-child { margin: 0; }
/* ---------- Diagrams ---------- */
.diagram
{
display: block;
max-width: 100%;
height: auto;
margin: 24px 0;
font-family: var(--font-sans);
font-size: 13px;
color: var(--fg);
}
.diagram .node
{
fill: var(--bg-elev);
stroke: var(--info);
stroke-width: 1.5;
}
.diagram .node.accent { stroke: var(--accent); }
.diagram .node.muted { fill: var(--subtle); stroke: var(--muted); }
.diagram .node.success { stroke: var(--success); }
.diagram .node.warn { stroke: var(--warn); }
.diagram .node.error { stroke: var(--error); }
.diagram .label
{
fill: var(--fg);
font-family: var(--font-sans);
font-weight: 600;
text-anchor: middle;
dominant-baseline: central;
}
.diagram .label.sub
{
fill: var(--muted);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 400;
}
.diagram .label.left { text-anchor: start; }
.diagram .label.right { text-anchor: end; }
.diagram .edge { fill: none; stroke: var(--muted); stroke-width: 1.4; }
.diagram .edge.accent { stroke: var(--accent); stroke-width: 1.6; }
.diagram .edge.dashed { stroke-dasharray: 5 3; }
.diagram .cluster { fill: none; stroke: var(--border); stroke-dasharray: 4 3; }
svg.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
/* ---------- Phase 3: SVG node drag (snap-to-grid) ---------- */
body[data-interactive] svg.diagram[data-key] .grid-line
{
stroke: var(--border);
stroke-width: 0.5;
opacity: 0.45;
pointer-events: none;
shape-rendering: crispEdges;
}
body[data-interactive] svg.diagram[data-key] .node[data-drag]
{
cursor: grab;
touch-action: none;
transition:
stroke-width var(--dur-pickup) var(--ease-pickup),
stroke var(--dur-pickup) var(--ease-pickup);
}
body[data-interactive] svg.diagram[data-key] .node[data-drag]:hover { stroke-width: 2; }
body[data-interactive] svg.diagram[data-key] .node[data-drag]:focus-visible
{
outline: none;
stroke: var(--accent);
stroke-width: 2;
}
body[data-interactive] svg.diagram[data-key] .node[data-drag].dragging
{
cursor: grabbing;
stroke: var(--accent);
stroke-width: 2;
opacity: 0.92;
}
body[data-interactive] svg.diagram[data-key] .snap-guides { pointer-events: none; }
body[data-interactive] svg.diagram[data-key] .snap-line
{
stroke: var(--accent);
stroke-width: 1;
stroke-dasharray: 4 3;
opacity: 0.85;
shape-rendering: crispEdges;
}
body[data-interactive] svg.diagram[data-key] .snap-edge
{
fill: none;
stroke: var(--accent);
stroke-width: 2.5;
stroke-linecap: butt;
opacity: 1;
}
/* ---------- Mode: report — Interactive ----------
Phase 1 interactive: Submit button, contenteditable cells, form
`data-pick` radios / checkboxes — all controls used by the Submit
feedback flow. Drag-sort visuals for cardsets and [data-sort]
containers. Slides mode does not include these; slides are
typically presentational. */
/* ---------- Phase 1: Interactive ---------- */
.btn[data-action="submit"] { display: none; }
body[data-interactive] .btn[data-action="submit"]
{
display: inline-block;
color: var(--accent);
border-color: var(--accent);
}
body[data-interactive] .btn[data-action="submit"]:hover
{
color: var(--bg);
background: var(--accent);
border-color: var(--accent);
}
body[data-interactive] [contenteditable][data-md-key]
{
outline: 1px dashed transparent;
outline-offset: 3px;
border-radius: var(--radius);
transition: outline-color 0.14s, background 0.14s;
}
body[data-interactive] [contenteditable][data-md-key]:hover { outline-color: var(--border-strong); }
body[data-interactive] [contenteditable][data-md-key]:focus
{
outline: 1px solid var(--accent);
background: var(--subtle);
}
body[data-interactive] [data-pick]
{
display: flex;
flex-direction: column;
gap: 6px;
margin: 12px 0;
}
body[data-interactive] [data-pick].inline { flex-direction: row; flex-wrap: wrap; gap: 16px; }
body[data-interactive] [data-pick] label
{
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-family: var(--font-serif);
font-size: 14px;
}
body[data-interactive] [data-pick] input { accent-color: var(--accent); }
body[data-interactive] textarea[data-md-key],
body[data-interactive] input[type="text"][data-md-key]
{
width: 100%;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.55;
color: var(--fg);
}
body[data-interactive] textarea[data-md-key] { min-height: 80px; resize: vertical; }
body[data-interactive] textarea[data-md-key]:focus,
body[data-interactive] input[type="text"][data-md-key]:focus
{
outline: none;
background: var(--subtle);
border-color: var(--accent);
}
/* ---------- Drag-sort: shared ---------- */
body[data-interactive] [data-sort] > [data-id],
body[data-interactive] .cardset > .card:not(.add)
{
touch-action: none;
}
body.dragging-active,
body.dragging-active * { user-select: none !important; -webkit-user-select: none !important; }
body[data-interactive] [data-sort].sorting,
body[data-interactive] .cardset.is-sorting
{
user-select: none;
-webkit-user-select: none;
position: relative;
}
/* ---------- Plain [data-sort] drag visual ---------- */
body[data-interactive] [data-sort] > [data-id] { cursor: grab; }
body[data-interactive] [data-sort] > [data-id]:active { cursor: grabbing; }
body[data-interactive] [data-sort] > [data-id].dragging
{
z-index: 5;
position: relative;
pointer-events: none;
cursor: grabbing;
opacity: 0.7;
outline: 1px dashed var(--accent);
outline-offset: 4px;
transition: none;
}
body[data-interactive] [data-sort] > [data-id].settling
{
z-index: 5;
pointer-events: none;
transition:
transform var(--dur-settle) var(--ease-settle),
opacity var(--dur-settle) var(--ease-settle);
opacity: 1;
}
/* Grip glyph on hover for plain sortable rows. */
body[data-interactive] .timeline[data-sort] > [data-id],
body[data-interactive] .compare-cols[data-sort] > [data-id]
{
position: relative;
}
body[data-interactive] .timeline[data-sort] > [data-id]::after,
body[data-interactive] .compare-cols[data-sort] > [data-id]::after
{
content: "⠿";
position: absolute;
color: var(--border-strong);
font-size: 14px;
opacity: 0;
pointer-events: none;
transition: opacity 0.14s, color 0.14s;
}
body[data-interactive] .timeline[data-sort] > [data-id]::after { right: 4px; top: 6px; }
body[data-interactive] .compare-cols[data-sort] > [data-id]::after { right: 6px; bottom: 6px; }
body[data-interactive] .timeline[data-sort] > [data-id]:hover::after,
body[data-interactive] .compare-cols[data-sort] > [data-id]:hover::after { opacity: 1; }
body[data-interactive] [data-sort] > [data-id].dragging::after { color: var(--accent); opacity: 1; }
/* ---------- Cardset drag visual ---------- */
body[data-interactive] .cardset > .card.dragging
{
z-index: 5;
position: relative;
pointer-events: none;
cursor: grabbing;
background: var(--bg);
border-color: var(--accent);
border-top-color: var(--accent);
opacity: 0.92;
transition: none;
will-change: transform;
}
body[data-interactive] .cardset > .card.settling
{
z-index: 5;
pointer-events: none;
transition:
transform var(--dur-settle) var(--ease-pickup),
opacity var(--dur-settle) var(--ease-pickup),
border-color var(--dur-settle) var(--ease-pickup),
border-top-color var(--dur-settle) var(--ease-pickup),
background var(--dur-settle) var(--ease-pickup);
}
/* ---------- Drop indicator ---------- */
.cardset > .drop-indicator
{
position: absolute;
box-sizing: border-box;
background: transparent;
border: 1.5px dashed var(--accent);
border-radius: var(--radius);
opacity: 0;
pointer-events: none;
z-index: 4;
transition:
opacity var(--dur-indicator) var(--ease-pickup),
top var(--dur-pickup) var(--ease-settle),
left var(--dur-pickup) var(--ease-settle),
width var(--dur-pickup) var(--ease-settle),
height var(--dur-pickup) var(--ease-settle);
}
.cardset > .drop-indicator.visible { opacity: 1; }
/* ---------- Cardsets (square / bar) ----------
Each card wears a 3px top-edge color band. Default = neutral, hover =
accent (purple), recommended = success (green), selected = accent,
user-added = emphasis (pink). The body border stays light to keep the
color signal at the top edge. */
.cardset
{
display: grid;
gap: var(--gap);
margin: 24px 0;
}
.cardset.square { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.cardset.bar { grid-template-columns: 1fr; }
.cardset > .card
{
position: relative;
padding: 18px 18px 16px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-top: 3px solid var(--border-strong);
border-radius: var(--radius);
transition: border-color 0.16s, border-top-color 0.16s, background 0.14s;
}
.cardset.square > .card { min-height: 140px; }
.cardset.bar > .card { min-height: 64px; padding: 14px 18px 12px; }
.cardset > .card > h4
{
margin: 0 0 8px;
padding: 0 80px 0 0;
border: none;
font-family: var(--font-sans);
font-size: 15px;
font-weight: 700;
color: var(--fg);
text-transform: none;
letter-spacing: -0.005em;
}
.cardset.bar > .card > h4 { display: inline-block; margin-right: 12px; margin-bottom: 4px; padding-right: 12px; }
.cardset > .card > p
{
margin: 0 0 6px;
font-family: var(--font-serif);
font-size: 14px;
line-height: 1.55;
color: var(--fg);
}
.cardset > .card > p:last-of-type { margin-bottom: 0; }
.cardset > .card .rationale
{
display: none;
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--border);
font-family: var(--font-serif);
font-style: italic;
font-size: 12px;
color: var(--muted);
}
.cardset > .card.recommended
{
background: color-mix(in srgb, var(--success) 5%, var(--bg-elev));
border-top-color: var(--success);
}
.cardset > .card.recommended .rationale { display: block; }
.cardset > .card.recommended::before
{
content: "RECOMMENDED";
position: absolute;
top: 8px;
right: 14px;
font-family: var(--font-sans);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--success);
}
body[data-interactive] .cardset[data-key][data-select="single"] > .card:not(.add),
body[data-interactive] .cardset[data-key][data-select="multi"] > .card:not(.add) { cursor: pointer; }
body[data-interactive] .cardset[data-key][data-select="none"] > .card:not(.add) { cursor: grab; }
body[data-interactive] .cardset[data-reorder="false"][data-select="none"] > .card:not(.add) { cursor: default; }
body[data-interactive] .cardset > .card:focus-visible
{
outline: 2px solid var(--accent);
outline-offset: 2px;
}
body[data-interactive] .cardset > .card.keyboard-moving
{
border-style: dashed;
border-color: var(--accent);
border-top-color: var(--accent);
animation: cardset-pulse 900ms ease-in-out infinite;
}
@keyframes cardset-pulse
{
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.sr-only
{
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
body[data-interactive] .cardset > .card.selected
{
background: var(--tint-accent);
border-color: var(--accent);
border-top-color: var(--accent);
}
body[data-interactive] .cardset > .card.selected::after
{
content: "✓";
position: absolute;
bottom: 8px;
right: 12px;
font-family: var(--font-sans);
font-size: 14px;
font-weight: 700;
color: var(--accent);
}
body[data-interactive] .cardset > .card:hover
{
border-top-color: var(--accent);
background: color-mix(in srgb, var(--accent) 4%, var(--bg-elev));
}
body[data-interactive] .cardset > .card.selected:hover
{
border-color: var(--accent);
border-top-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
body[data-interactive] .cardset > .card.recommended:hover
{
border-top-color: var(--success);
background: color-mix(in srgb, var(--success) 5%, var(--bg-elev));
}
body[data-interactive] .cardset > .card[data-user-added]:hover
{
border-top-color: var(--emphasis);
background: color-mix(in srgb, var(--emphasis) 5%, var(--bg-elev));
}
/* "Add" card */
.cardset > .card.add
{
display: flex;
align-items: center;
justify-content: center;
min-height: 64px;
padding: 14px;
background: transparent;
border: 1px dashed var(--border-strong);
color: var(--muted);
font-family: var(--font-sans);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
text-align: center;
cursor: pointer;
transition: color 0.14s, border-color 0.14s, background 0.14s;
}
.cardset.square > .card.add { min-height: 140px; }
.cardset > .card.add:hover { color: var(--accent); border-color: var(--accent); }
.cardset > .card.add::before,
.cardset > .card.add::after { content: none !important; }
/* User-added card */
.cardset > .card[data-user-added] { border-top-color: var(--emphasis); }
.cardset > .card[data-user-added]::before
{
content: "USER";
position: absolute;
top: 8px;
right: 14px;
font-family: var(--font-sans);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--emphasis);
}
.cardset > .card[data-user-added] [contenteditable]
{
outline: 1px dashed transparent;
outline-offset: 2px;
transition: outline-color 0.14s;
}
.cardset > .card[data-user-added] [contenteditable]:hover { outline-color: var(--border-strong); }
.cardset > .card[data-user-added] [contenteditable]:focus
{
background: var(--bg);
outline-color: var(--accent);
}
@media (max-width: 720px)
{
.cardset.square { grid-template-columns: 1fr; }
}
/* ---------- Mode: report — Layout ----------
The report mode is the long-form HTML output: a `<header class="page">`
+ `<main>` + `<footer class="page">` shape with constrained max-width.
Layout primitives, entrance animation, and the dynamic single-page
print logic (in `print.css` + `behavior.js`) are mode-specific —
the slides mode has its own layout under modes/slides/. */
/* ---------- Layout ---------- */
header.page,
main,
footer.page
{
max-width: 920px;
margin-left: auto;
margin-right: auto;
padding-left: var(--gap-xl);
padding-right: var(--gap-xl);
}
body[data-wide] header.page,
body[data-wide] main,
body[data-wide] footer.page { max-width: min(100%, 1280px); }
@media (max-width: 720px)
{
header.page,
main,
footer.page { padding-left: 20px; padding-right: 20px; }
}
header.page
{
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--gap-xl);
align-items: start;
padding-top: var(--gap-xl);
padding-bottom: var(--gap-lg);
border-bottom: 1px solid var(--rule);
}
header.page::after
{
content: "";
position: absolute;
left: var(--gap-xl);
bottom: -2px;
width: 72px;
height: 3px;
background: var(--accent);
}
@media (max-width: 720px)
{
header.page::after { left: 20px; }
}
header.page .meta h1
{
font-size: 36px;
line-height: 1.05;
letter-spacing: -0.02em;
margin: 0;
}
header.page .meta .subtitle
{
margin: 12px 0 0;
font-family: var(--font-serif);
font-style: italic;
font-size: 18px;
line-height: 1.45;
color: var(--muted);
max-width: 56ch;
}
header.page .meta .timestamp
{
margin: 18px 0 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.06em;
font-feature-settings: "tnum";
text-transform: uppercase;
}
header.page .meta .timestamp time,
header.page .meta .timestamp span { white-space: nowrap; }
header.page .actions
{
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
min-width: 116px;
}
@media (max-width: 720px)
{
header.page { grid-template-columns: 1fr; }
header.page .actions { flex-direction: row; flex-wrap: wrap; align-items: flex-start; }
}
main { padding-top: var(--gap-xl); padding-bottom: var(--gap-xl); }
/* ---------- Table of Contents (opt in via body[data-toc]) ----------
Runtime 由 behavior.js 之 setupToc 注入 <nav class="toc">,置於 <main>
首位。h2 顯主序、h3 縮排掛二級。預設 <details open>summary 可點收折。 */
main > nav.toc
{
margin: 0 0 var(--gap-xl) 0;
padding: var(--gap) var(--gap-lg);
border-left: 3px solid var(--accent);
background: var(--bg-elev);
font-size: 14px;
}
main > nav.toc summary
{
cursor: pointer;
list-style: none;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
user-select: none;
}
main > nav.toc summary::-webkit-details-marker { display: none; }
main > nav.toc summary::before
{
content: "▸";
display: inline-block;
width: 1.2em;
transition: transform 0.15s;
}
main > nav.toc details[open] summary::before { transform: rotate(90deg); }
main > nav.toc details[open] summary
{
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--rule);
}
main > nav.toc ol
{
list-style: none;
margin: 0;
padding: 0;
counter-reset: toc-section;
}
main > nav.toc li.toc-h2
{
counter-increment: toc-section;
margin: 6px 0;
line-height: 1.4;
}
main > nav.toc li.toc-h2::before
{
content: counter(toc-section, decimal-leading-zero);
display: inline-block;
width: 2.4em;
color: var(--accent);
font-family: var(--font-mono);
font-size: 12px;
font-feature-settings: "tnum";
letter-spacing: 0.04em;
}
main > nav.toc li.toc-h3
{
margin: 3px 0 3px 2.4em;
font-size: 13px;
line-height: 1.4;
color: var(--muted);
}
main > nav.toc li.toc-h3::before
{
content: "—";
display: inline-block;
width: 1.5em;
color: var(--rule);
}
main > nav.toc a
{
color: var(--fg);
text-decoration: none;
border-bottom: 1px solid transparent;
}
main > nav.toc a:hover
{
color: var(--accent);
border-bottom-color: var(--accent);
}
main > nav.toc li.toc-h3 a { color: var(--muted); }
main > nav.toc li.toc-h3 a:hover { color: var(--accent); }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce)
{
html { scroll-behavior: auto; }
}
footer.page
{
margin-top: 64px;
padding-top: 16px;
padding-bottom: 32px;
border-top: 1px solid var(--border);
color: var(--muted);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
}
/* ---------- Entrance animation ---------- */
@keyframes lift
{
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
header.page .meta > * { animation: lift 0.5s ease-out backwards; }
header.page .meta > *:nth-child(1) { animation-delay: 0.05s; }
header.page .meta > *:nth-child(2) { animation-delay: 0.12s; }
header.page .meta > *:nth-child(3) { animation-delay: 0.18s; }
header.page .actions { animation: lift 0.5s ease-out 0.22s backwards; }
main > * { animation: lift 0.5s ease-out 0.28s backwards; }
main > *:nth-child(n+2) { animation-delay: 0.34s; }
main > *:nth-child(n+4) { animation-delay: 0.40s; }
main > *:nth-child(n+6) { animation-delay: 0.46s; }
@media (prefers-reduced-motion: reduce)
{
*, *::before, *::after
{
animation-duration: 0.001ms !important;
animation-delay: 0ms !important;
transition-duration: 0.001ms !important;
}
}
/* ---------- Mode: report — Print / PDF ----------
Dynamic single-page PDF: runtime.js setupDynamicPrintPage() measures
content height before print and injects @page sized to fit. Slides
mode uses per-slide pagination (page-break-after: always) instead. */
/* ---------- Print ---------- */
/* ---------- Print / PDF export ----------
Target: "Save as PDF" produces a **single-page** document — the entire
artifact on one tall virtual page, with generous editorial margins, no
pagination cuts mid-component. Decisions:
- `@page { size: 210mm auto; }` — A4 width, height auto-grows to fit
content. Chrome's PDF engine honors this; Firefox / Safari may
fall back to the default page size, in which case the
`page-break-inside: avoid` defenses below still prevent splits at
ugly positions.
- Page-margin boxes (@top-right, @bottom-center) dropped — they'd
appear exactly once on the giant page, reading as floating boxes
rather than running headers. Single-page PDFs don't need page
numbers.
- 25mm top/bottom × 22mm left/right margins — editorial.
- Force Alucard palette regardless of OS / data-theme. Pro (dark)
wastes ink and reads poorly on paper.
- `print-color-adjust: exact` keeps designed tints (callouts, table
headers, progress segments, diff lines, top-edge bands).
- `page-break-inside: avoid` retained on cohesive blocks as a
defense for browsers that don't honor `size: auto` height.
- Headings `page-break-after: avoid` keeps them with their content.
- Details always rendered open; summaries flatten to plain headings.
- Links expand to show the URL (footnote-style).
- Screen-only UI (action buttons, copy buttons, theme toggle,
drag-grid lines, snap guides) hidden. */
@page
{
/* A4 fallback. Real page size is set dynamically at runtime by
`setupDynamicPrintPage()` in runtime.js — it measures the rendered
height before print and injects a fresh `@page` rule sized to fit
content exactly.
Margin: 0 here. The real margin lives in `body` padding (see the
@media print rule below), which the user's print dialog cannot
override. Putting margin INSIDE `body` keeps the content's
horizontal width stable across dialog choices — only that lets
the JS page-height heuristic stay accurate (dialog squeezing
content would otherwise force unpredictable extra line wraps). */
size: A4;
margin: 0;
}
@media print
{
/* Palette pinning to the active theme's light values lives in
`themes/<active>.css` (each theme file ships its own @media
print block). Below this point: layout-only print rules
(font-size, body padding, hidden chrome, page-break-avoid). */
html, body
{
background: #FFFFFF;
color: #1F1F1F;
font-size: 10.5pt;
font-family: var(--font-serif);
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
/* Real margin lives here, not in @page margin. Two reasons:
1. Dialog "Margins: None" overrides @page margin to 0; body
padding survives that override.
2. Pinning the horizontal width stabilises print line-breaks so
the JS page-height calc stays accurate regardless of which
dialog Margins option the user picks.
Top padding is intentionally larger — printed page heads read
better with extra breathing room above the title. */
body
{
padding: 1.5cm 1cm 1cm 1cm;
box-sizing: border-box;
}
/* Hide screen-only chrome */
header.page .actions,
header.page::after,
pre .copy-btn,
#theme-toggle,
svg.diagram .grid-line,
svg.diagram .snap-guides { display: none !important; }
/* Full-bleed layout — give @page margin the responsibility */
header.page,
main,
footer.page
{
max-width: none;
padding-left: 0;
padding-right: 0;
margin-left: 0;
margin-right: 0;
}
header.page
{
padding-top: 0;
padding-bottom: 10pt;
margin-bottom: 10pt;
border-bottom: 0.5pt solid var(--rule);
}
main { padding-top: 0; padding-bottom: 0; }
footer.page
{
margin-top: 18pt;
padding-top: 6pt;
border-top: 0.25pt solid var(--border);
font-size: 8.5pt;
}
/* Typography in pt for print */
h1 { font-size: 22pt; margin-top: 0; margin-bottom: 10pt; }
h2 { font-size: 16pt; margin: 18pt 0 8pt; }
h3 { font-size: 13pt; margin: 14pt 0 6pt; }
h4 { font-size: 11pt; margin: 10pt 0 4pt; }
h5, h6 { font-size: 9pt; margin: 8pt 0 4pt; }
h1, h2, h3, h4, h5, h6
{
page-break-after: avoid;
break-after: avoid;
}
p, li
{
orphans: 3;
widows: 3;
}
/* Cohesive blocks: never split across pages */
pre, table,
.callout, .note,
.compare-cols > .col,
.cardset > .card,
.progress-track,
.timeline > li,
.diff-review,
svg.diagram
{
page-break-inside: avoid;
break-inside: avoid;
}
/* Repeat table headers / footers on every printed page */
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
/* Details always expanded in print */
details { padding: 6pt 0; }
details > summary { background: transparent !important; padding: 0; margin: 0; }
details > summary::before { content: ""; }
details > *:not(summary)
{
display: revert !important;
margin: 6pt 0 0 0;
padding-left: 0;
border-left: none;
}
/* Links: footnote-style URL expansion for external links */
a { color: inherit; text-decoration: underline; }
a[href^="http"]::after,
a[href^="mailto:"]::after
{
content: " (" attr(href) ")";
font-size: 0.85em;
color: var(--muted);
}
a[href^="#"]::after,
a[href^="./"]::after,
a[href^="../"]::after { content: ""; }
/* Timeline: tighter left margin in print */
.timeline { margin-left: 80pt; }
/* Progress segments: keep solid colors with the inverted (white)
label that the screen rule sets via `color: var(--bg)`. Since
--bg is forced to white above, label color stays white. */
.progress-track > .progress-seg { color: #FFFFFF; }
/* Disable all transitions / animations during print */
*, *::before, *::after
{
animation: none !important;
transition: none !important;
}
}
</style>
</head>
<body>
<header class="page">
<div class="meta">
<h1>cjk-autospace</h1>
<p class="subtitle">CJK ↔ Latin 邊界自動間距 shim — 工廠模式、跨樣式透明、行內塊邊界、Pangu.js v7.2.1 規則 port</p>
<p class="timestamp"><time>2026-05-22</time> · <span>docs branch</span></p>
</div>
<div class="actions">
<button class="btn" type="button" data-action="export-md" title="Copy Markdown to clipboard">Copy MD</button>
<button class="btn" type="button" data-action="download-md" title="Download as .md">Download</button>
<button class="btn" type="button" data-action="expand-all" title="Expand all sections">Expand</button>
<button class="btn" type="button" data-action="collapse-all" title="Collapse all sections">Collapse</button>
<button class="btn" type="button" data-action="submit" title="Copy structured response to clipboard">Submit</button>
</div>
</header>
<main>
<p class="lede">Pangu.js v7.2.1 之規則 port,加上「跨樣式標籤透明 + 行內塊邊界」雙 pass 架構,再加 Pass C 之「長英文段 <code>&lt;span lang&gt;</code> 包裝」(給瀏覽器原生 <code>hyphens: auto</code> 用)。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <code>&lt;span class="cjk-autospace" aria-hidden="true"&gt;(空白)&lt;/span&gt;</code>,靠 CSS <code>user-select: none</code> 讓選取與剪貼板跳過。textContent 不變 → Copy 與序列化都乾淨。</p>
<h2>Quick start</h2>
<pre><code>// 1) 載入 shim(以下任一)
import { createCjkAutospace } from "./cjk-autospace.js"; // ESM / TS
// 或 inline &lt;script&gt; / require / globalThis.createCjkAutospace
// 2) 配置(pill 選擇器是唯一需要消費端自訂的;其餘有合理預設)
const autospace = createCjkAutospace({
pillSelector: "code, kbd, .your-pill-class",
});
// 3) 套用
autospace.apply(document.body);
</code></pre>
<p>套用後 DOM 內每個 Pangu 認定之邊界都被加上 <code>.cjk-autospace</code> marker。在已套用之 DOM 上重跑為冪等。</p>
<h3>必備 CSS</h3>
<pre><code>.cjk-autospace {
-webkit-user-select: none;
user-select: none;
/* 視覺寬度由消費端決定,例如 0.25em */
}</code></pre>
<h2>標籤三分類</h2>
<table>
<thead>
<tr><th>類別</th><th>典型成員</th><th>shim 對待</th></tr>
</thead>
<tbody>
<tr>
<td><strong>STYLE_INLINES</strong></td>
<td><code>strong</code> / <code>em</code> / <code>a</code> / <code>b</code> / <code>i</code> / <code>u</code> / <code>s</code> / <code>mark</code> / <code>ins</code> / <code>del</code> / <code>sub</code> / <code>sup</code> / <code>small</code> / <code>abbr</code> / <code>cite</code> / <code>dfn</code> / <code>q</code> / <code>span</code> / <code>time</code> / <code>ruby</code> / <code>wbr</code> / …</td>
<td><strong>透明</strong>。shim 在 Pass A 走 DFS 時穿過去,把內外文本當成連續文本判斷 Pangu 邊界。</td>
</tr>
<tr>
<td><strong>PILL</strong></td>
<td><code>pillSelector</code> 指定,預設 <code>code, kbd</code></td>
<td><strong>opaque 行內塊</strong>。Pass A 視為 run 邊界;Pass B 處理其與鄰字之介面。</td>
</tr>
<tr>
<td><strong>BLOCK</strong></td>
<td><code>p</code> / <code>div</code> / <code>h1</code>-<code>h6</code> / <code>li</code> / <code>pre</code> / <code>table</code> / <code>tr</code> / <code>td</code> / <code>br</code> / <code>hr</code> / …</td>
<td><strong>段落級</strong>。Pass A 走到此即中斷 run;子 block 各自一次 <code>processBlockText</code> 呼叫。</td>
</tr>
</tbody>
</table>
<div class="callout info">
<span class="label">關鍵設計</span> 樣式標籤透明 → shim 看穿 <code>&lt;strong&gt;&lt;/strong&gt; Neovim</code> 這類跨元素邊界,把「是」(CJK)+ 空格 + 「N」(ANS)視為 Pangu 觸發位置;而非僅在單一 text-node 內找匹配。
</div>
<h2>Pass A — per-block Pangu on tag-stripped text</h2>
<p>對 root 之每個 BLOCK 後代執行一次。流程:</p>
<ol>
<li>DFS 走 block 內部子節點:
<ul>
<li>遇 text node:把每個 char 累積到 <code>curText</code>,同步把 <code>{ textNode, offset }</code> 推入 <code>curMap</code></li>
<li>遇 STYLE_INLINES:透明遞迴,curText 繼續累積</li>
<li>遇 PILL / 子 BLOCK / 既有 <code>.cjk-autospace</code><code>flushRun()</code>,把累積之 (text, map) 作為一個 run 推入 runs,重置</li>
</ul>
</li>
<li>對每個 run,套 <code>panguSpace(text)</code>,得到 <code>spaced</code> 字串(含 MARK PUA 字符 U+E007</li>
<li>平行掃描 <code>spaced</code><code>text</code>,把 MARK 對應到原文之插入位置:
<ul>
<li><code>text[origIdx]</code> 是 ASCII space → MARK 替換了該空格。動作:strip 該空格 + 在前一字後插 marker</li>
<li>否則 → MARK 純插入。動作:在前一字後插 marker</li>
</ul>
</li>
<li>actions 倒序執行:保證前面動作之 textNode offset 不被後面動作(splitText / data slicing)影響</li>
</ol>
<h3></h3>
<p><code>&lt;p&gt;本資料夾&lt;strong&gt;不是&lt;/strong&gt; Neovim 使用者目錄。&lt;/p&gt;</code></p>
<p>DFS 收集到的 <code>curText</code>(樣式標籤剝離後):<code>"本資料夾不是 Neovim 使用者目錄。"</code></p>
<p><code>panguSpace</code> 應用:<code>是 N</code> 命中 <code>STRIP_CJK_SPACE_ANS</code>CJK + 空格 + ANS)→ 「<code><sub>MARK</sub>N</code>」(空格被 MARK 取代);<code>m 使</code> 命中 <code>STRIP_ANS_SPACE_CJK</code> → 同理。</p>
<p>反映射:</p>
<ul>
<li>位置 1:原文「是」與「 」之間,「 」被 strip → marker 插在「是」後(DOM 上是 <code>&lt;strong&gt;</code> 之 nextSibling 處)</li>
<li>位置 2:原文「m」與「 」之間,「 」被 strip → marker 插在「m」後(同一 text node 內,splitText 後插入)</li>
</ul>
<p>最終 DOM(簡化):<code>本資料夾&lt;strong&gt;不是&lt;/strong&gt;&lt;span class="cjk-autospace"&gt; &lt;/span&gt;Neovim&lt;span class="cjk-autospace"&gt; &lt;/span&gt;使用者目錄。</code></p>
<h2>Pass B — pill boundary, style-inline aware</h2>
<p>對 root 內每個匹配 <code>pillSelector</code> 之元素執行:</p>
<ol>
<li><code>adjacentLogicalChar(pill, -1)</code>:走 prev sibling 鏈,遇 text 取末字元;遇 STYLE_INLINES 遞迴進入找其最末 text;遇 PILL / BLOCK / 既有 marker / skip ancestor → null</li>
<li>若回傳邏輯鄰字是 CJK / 字母數字 → 在 pill 前插 marker</li>
<li>若邏輯鄰字是 1 個 ASCII space 且該 space 另一側(同 text node 內)是內容字 → strip 該 space + 插 marker</li>
<li><code>+1</code> 方向(pill 後)做對稱處理</li>
</ol>
<h3></h3>
<p><code>命令 &lt;code&gt;git&lt;/code&gt; 更新</code></p>
<ul>
<li>Pass A run 因 pill 中斷:「命令 」(trailing space 為 run 末尾,不觸發 panguSpace 之 STRIP rule,因 rule 要求兩側都是內容字而 run 末尾沒有右側字)</li>
<li>Pass B 處理 pill 之 prevtext 末字元是 <code>' '</code>,前一字元是 <code>'令'</code>(CJK),命中 strip 規則 → 移除空格 + 在 pill 前插 marker</li>
<li>Pass B 處理 pill 之 nexttext 首字元是 <code>' '</code>,後一字元是 <code>'更'</code>CJK),同理 strip + 插 marker</li>
</ul>
<p>結果:<code>命令&lt;span class="cjk-autospace"&gt; &lt;/span&gt;&lt;code&gt;git&lt;/code&gt;&lt;span class="cjk-autospace"&gt; &lt;/span&gt;更新</code></p>
<h2>Pass C — long English word wrap(預設啟用)</h2>
<p>解 CJK 兩端對齊正文中「行末長英文詞」之斷行問題:原靠 <code>&lt;wbr&gt;</code> 每 N 字元亂插之路徑會產生「斷點位置與音節無關 + 斷處不顯連字符」兩個觀感問題。Pass C 改採 <code>&lt;span lang="en"&gt;</code> 包裝,把斷字決定權交給瀏覽器原生 <code>hyphens: auto</code> 用內建英文字典處理。</p>
<ol>
<li>對 root 內所有 text node 走 TreeWalkerSHOW_TEXT</li>
<li>對每個 node:先 <code>isSkipped</code>(共用 Pass A / B 之 skip 鏈:CODE / KBD / PRE / SAMP / TT / VAR / SCRIPT / STYLE / TEXTAREA / INPUT / SVG / contentEditable / <code>data-md-key</code>+ 冪等檢查(parent 為 <code>&lt;span lang&gt;</code> → skip</li>
<li>對通過篩選之 node:用 <code>[A-Za-z]{minLength,}</code> 收所有匹配,把每個匹配位置之子串裹進 <code>&lt;span lang="en"&gt;</code>(其餘原文保留為純 text node),整個用 fragment 一次性 replaceChild</li>
</ol>
<h3>消費端 CSS(與 lib 解耦但必須配套)</h3>
<pre><code>[lang|="en"] {
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3; /* 詞長 / 前 / 後最少字符 */
hyphenate-character: "-"; /* 強制 U+002D,避免 CJK 字型把 U+2010 渲成全形 */
-webkit-hyphenate-character: "-";
}</code></pre>
<div class="callout warn">
<span class="label">字型陷阱</span> Noto Serif CJK SC 等 CJK serif 字型把 U+2010 HYPHEN 設計為全形 1 em(與漢字同寬),以服務 CJK 範圍表達;而 <code>hyphens: auto</code> 預設正是插 U+2010。直接用會看到全形連字符突兀地接在英文詞末。<code>hyphenate-character: "-"</code> 強制改用 U+002D(同字型內為窄拉丁,與作者鍵入的 <code>-</code> 一致),仍為純視覺渲染、DOM 內不存在。
</div>
<h3>選取 / 複製潔淨性</h3>
<p>連字符純由 CSS 渲染(不存在於 DOM),<code>Selection.toString()</code><code>Cmd+C</code> 返回原文無污染。包裝 span 本身是 inline<code>textContent</code> 視為原樣,序列化 / 複製不受影響。</p>
<h3>不在範圍</h3>
<ul>
<li><strong>CamelCase / 標識符</strong><code>getUserByEmailAddress</code><code>OpenAIWhisperAPI</code>):不在英文字典 → 瀏覽器不斷詞;本實作未開 <code>overflow-wrap: anywhere</code> fallback(會破壞「連字符必須存在」之原則),故識別符 overflow 時整塊保留</li>
<li><strong>URL / 純數字串</strong>:同上,<code>https://example.com/very/long/path</code> 此類超長 token 不被處理</li>
<li><strong>非英文之 ASCII 拉丁變體</strong>café、naïve):正則 <code>[A-Za-z]</code> 不匹配重音字符;如需擴充可自訂 <code>longWordWrap.minLength</code> 之外再 patch lib</li>
</ul>
<h2>空白規則</h2>
<p>所有觸發點(Pangu 命中或 PILL 邊界),對「插入位置之原有空白」的處理一致:</p>
<table>
<thead>
<tr><th>原有空白</th><th>動作</th></tr>
</thead>
<tbody>
<tr><td>0 個</td><td>純插 marker</td></tr>
<tr><td>1 個 ASCII space</td><td>strip 該 space + 插 marker(取代)</td></tr>
<tr><td>2 個或更多 space</td><td>跳過(視為刻意 structural whitespace</td></tr>
<tr><td>tab / newline / 其他空白</td><td>跳過(panguSpace 之 regex 只匹配 single ASCII space</td></tr>
<tr><td>標點隔開</td><td>跳過(panguSpace 命中要求兩端是內容字)</td></tr>
<tr><td>既有 <code>.cjk-autospace</code> marker</td><td>跳過(避免雙重插入;冪等性)</td></tr>
</tbody>
</table>
<h2>配置選項</h2>
<table>
<thead>
<tr><th>選項</th><th>預設</th><th>說明</th></tr>
</thead>
<tbody>
<tr><td><code>pillSelector</code></td><td><code>"code, kbd"</code></td><td>CSS 選擇器;行內塊範圍。常見擴充:<code>code, kbd, .cjk-pill</code>(按 class opt-in)或 <code>code, kbd, mark, samp</code>(更廣)</td></tr>
<tr><td><code>styleInlines</code></td><td>樣式標籤預設集</td><td>tagName 大寫之陣列;自訂可改寫</td></tr>
<tr><td><code>blockTags</code></td><td>段落標籤預設集</td><td>tagName 大寫之陣列</td></tr>
<tr><td><code>skipTags</code></td><td><code>["CODE", "KBD", "PRE", "SAMP", "TT", "VAR", "SCRIPT", "STYLE", "TEXTAREA", "INPUT"]</code></td><td>整 subtree skip,連祖先 chain 一併判斷</td></tr>
<tr><td><code>autospaceClass</code></td><td><code>"cjk-autospace"</code></td><td>marker span 之 class 名</td></tr>
<tr><td><code>skipAttribute</code></td><td><code>"data-md-key"</code></td><td>任一祖先帶此屬性 → 整 subtree skip</td></tr>
<tr><td><code>longWordWrap</code></td><td><code>true</code>(預設啟用)</td><td>Pass C 開關。傳 <code>false</code> 顯式關閉;<code>{ minLength, lang }</code> 自訂粒度與標語;<code>true</code> / 省略等同 <code>{ minLength: 6, lang: "en" }</code></td></tr>
<tr><td><code>isSkipped</code></td><td>(無)</td><td>自訂 callback;先於內建判定執行;回傳 true 即 skip</td></tr>
</tbody>
</table>
<h2>Marker span 解剖</h2>
<pre><code>&lt;span class="cjk-autospace" aria-hidden="true"&gt; &lt;/span&gt;</code></pre>
<dl>
<dt><code>class="cjk-autospace"</code></dt>
<dd>由消費端之 CSS 添加 <code>user-select: none</code>(選取時跳過);視覺寬度也由消費端決定(典型 0.25em)。</dd>
<dt><code>aria-hidden="true"</code></dt>
<dd>讓 screen reader 忽略,避免「空格、空格、空格」朗讀干擾。</dd>
<dt>內含真實 ASCII space</dt>
<dd>不是 0 寬 span — 真實字符。layout 視為一般 whitespace:行末可 collapse、容許在此換行、justify 可拉伸該空白。<br>對比方案(inline-block + fixed em width)破壞 line-end 行為與 justify 之分配;不採用。</dd>
<dt>textContent 不變</dt>
<dd>從 DOM serialization 或選取與複製角度看,marker 是「不可見」之 spanuser-select: none 讓 selection range 跳過該 span;故剪貼板 / Copy MD 之輸出無空白污染。</dd>
</dl>
<h2>Pangu 規則簡表</h2>
<p>panguSpace 內部依序套 30+ 條 regex。簡列各觸發類別:</p>
<table>
<thead>
<tr><th>類別</th><th></th><th>動作</th></tr>
</thead>
<tbody>
<tr><td>Pre-pass strip</td><td><code>2023 年</code><code>2023年</code></td><td>strip CJK ↔ ANS 之間單 ASCII space,後續 pass 把它當 flush 寫法處理</td></tr>
<tr><td>CJK + Latin / 數字</td><td><code>使用 GPT</code></td><td>插 MARK</td></tr>
<tr><td>CJK + ASCII 標點</td><td><code>是,沒錯</code></td><td>標點後插 MARK</td></tr>
<tr><td>CJK + 引號 / 括號</td><td><code>是(實驗)</code></td><td>插 MARK</td></tr>
<tr><td>CJK + 運算子 + ANS</td><td><code>是+1</code></td><td>運算子兩側各插 MARK</td></tr>
<tr><td>compound word 保護</td><td><code>GPT-4</code> / <code>state-of-the-art</code></td><td>以 PUA 占位替換,避免運算子 pass wedge 入空白;bracket / CJK_ANS pass 前還原</td></tr>
</tbody>
</table>
<h2>整合範例</h2>
<h3>HTML build script 串接</h3>
<pre><code># build.sh 把 lib 串到 runtime.js 之前
{
printf '&lt;script&gt;\n'
cat "$LIB/cjk-autospace.js" # 工廠函式
printf '\n'
sed '$d' "$RUNTIME/runtime.js" # 應用程式 runtime
cat "$MODE_BEHAVIOR_JS"
printf '\n})();\n'
printf '&lt;/script&gt;\n'
}</code></pre>
<p>由 runtime.js 之 init 呼叫:<code>createCjkAutospace({}).apply();</code></p>
<h3>Lua 內嵌 JS 字串</h3>
<pre><code>-- local.lua build hook 讀 lib 內容,sprintf 注入 selector 配置
local lib = read_file("lua/user/lib/cjk-autospace/cjk-autospace.js")
local entry = string.format(
"createCjkAutospace({ pillSelector: %q }).apply(document.getElementById('peek-markdown-body'));",
"code, kbd, mark, samp"
)
local script = "&lt;script&gt;" .. lib .. "\n" .. entry .. "&lt;/script&gt;"
inject_into_html(script)</code></pre>
<h3>TypeScript / Vite</h3>
<pre><code>// cjkAutospace.ts
import { createCjkAutospace } from "./lib/cjk-autospace/cjk-autospace.js";
const autospace = createCjkAutospace({
pillSelector: "code, kbd, .cjk-pill",
});
let pending = false;
const obs = new MutationObserver(() => {
if (pending) return;
pending = true;
queueMicrotask(() => {
pending = false;
obs.disconnect();
try { autospace.apply(); }
finally { obs.observe(document.body, { childList: true, subtree: true, characterData: true }); }
});
});
autospace.apply();
obs.observe(document.body, { childList: true, subtree: true, characterData: true });</code></pre>
<h2>邊界情境與限制</h2>
<ul>
<li><strong>SVG namespace</strong>jdgement <code>p.namespaceURI === 'http://www.w3.org/2000/svg'</code> 整 subtree skip。SVG <code>&lt;text&gt;</code> 不會被處理。</li>
<li><strong>contentEditable</strong>:用戶輸入區整 subtree skip,避免影響游標 / 選取。</li>
<li><strong>已存在 marker</strong>Pass A 走 DFS 時,<code>.cjk-autospace</code> 中斷 runPass B <code>adjacentLogicalChar</code> 遇 marker 即停。故重複呼叫 <code>apply()</code> 不會雙倍插入。</li>
<li><strong>動態 DOM</strong>shim 自身不訂閱 mutation;增量更新由消費端用 <code>MutationObserver</code> 包裝(範例見上)。建議處理時暫斷 observer 避免自反饋。</li>
<li><strong>跨 BLOCK 邊界</strong>Pangu 規則不跨段落觸發。<code>&lt;p&gt;&lt;/p&gt;&lt;p&gt;Neovim&lt;/p&gt;</code> 不會插 marker(兩段獨立)。</li>
<li><strong>Compound word 保護限制</strong>:僅匹配 <code>[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+</code> 等模式;無 dash 之純大寫縮寫(<code>HTML</code><code>API</code>)不被特殊保護,但本來就沒問題(不會被運算子規則 wedge)。</li>
</ul>
<h2>授權與來源</h2>
<p>規則表 port 自 <a href="https://github.com/vinta/pangu.js">Pangu.js v7.2.1</a>(MIT 授權)。本 lib 之三 pass 架構(樣式透明 + 行內塊邊界 + 長英文 lang 包裝)為原創設計,前兩 pass 解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題;Pass C 把 CJK 兩端對齊正文中之斷英文詞責任交給瀏覽器原生 <code>hyphens: auto</code></p>
</main>
<footer class="page">
<p>cjk-autospace · docs branch · 2026-05-22</p>
</footer>
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arrow-filled" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
</marker>
<marker id="arrow-open" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,1 L9,5 L0,9" fill="none" stroke="currentColor"
stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</marker>
<marker id="dot" viewBox="0 0 6 6" refX="3" refY="3"
markerWidth="6" markerHeight="6">
<circle cx="3" cy="3" r="2" fill="currentColor"/>
</marker>
</defs>
</svg>
<script type="text/markdown" id="md-source">
# cjk-autospace
CJK Latin 邊界自動間距 shim工廠模式由消費端注入配置
## Quick start
```js
import { createCjkAutospace } from "./cjk-autospace.js";
const autospace = createCjkAutospace({
pillSelector: "code, kbd, .cjk-pill",
});
autospace.apply(document.body);
```
## 標籤三分類
- STYLE_INLINES 樣式標籤shim 透明走過
- PILL 行內塊邊界由 Pass B 處理
- BLOCK 段落級Pass A 把它們當作 run 邊界
## Pass A
對每個 BLOCK DFS 收集邏輯文本樣式透明PILL / BLOCK 中斷 run整段套 panguSpace反映射 MARK textNode / offset
## Pass B
對每個 PILL 透過樣式鏈遞迴找邏輯鄰字 0/1/+特殊規則處理0 個或 1 ASCII space marker其餘跳過
</script>
<script>
"use strict";
/* cjk-autospace — Pangu.js v7.2.1 風格 CJK ↔ Latin 邊界自動間距 shim。
*
* 工廠模式:`createCjkAutospace(options).apply(root)`。配置由消費端注入;
* 預設值對應 HTML 模板 runtime 之原狀(pill = code, kbd;標籤三分類;
* marker class 為 .cjk-autospaceskip ancestor 為 mono / SVG /
* contentEditable / [data-md-key])。
*
* 標籤三分類:
* - STYLE_INLINES — 樣式標籤,shim 透明走過。跨樣式邊界(如
* <strong>是</strong> Neovim)由 Pass A 在剝離樣式後識別。
* - PILL — 行內塊(有獨立背景,如 <code>),邊界由 Pass B
* 處理。預設 'code, kbd',可由消費端擴充。
* - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。
*
* 三 pass
* Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子
* BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/
* offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker
* 否則純插。actions 倒序執行確保前面偏移不被後面動作影響。
* Pass B — 對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(adjacentLogical-
* Char),按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插
* marker;其餘跳過)。
* Pass C — 可選(options.longWordWrap)。把 [A-Za-z]{N,} 之長英文段
* 包進 <span lang="en">;配合消費端 CSS `[lang|="en"]{hyphens:auto}`
* 讓瀏覽器原生連字斷詞。連字符純由 CSS 渲染(DOM 內不存在),複製
* 不污染原文。預設關閉以保持向後相容。
*
* Marker = <span class="cjk-autospace" aria-hidden="true">(空白)</span>。
* 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許
* 換行 / justify 可拉伸);配 CSS user-select: none 讓選取與剪貼板跳
* 過該 span。textContent 不變(無視 span 內容)→ Copy / Paste / 序列
* 化都乾淨。
*/
function createCjkAutospace(options)
{
options = options || {};
/* ----- 預設配置 ----- */
const DEFAULT_STYLE_INLINES = [
"STRONG", "EM", "A", "B", "I", "U", "S", "MARK", "INS", "DEL",
"SUB", "SUP", "SMALL", "ABBR", "CITE", "DFN", "Q", "SPAN",
"VAR", "SAMP", "TIME", "BDO", "BDI", "RUBY", "RB", "RT", "RP", "WBR",
];
const DEFAULT_BLOCK_TAGS = [
"BODY", "P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "LI",
"DT", "DD", "BLOCKQUOTE", "FIGURE", "FIGCAPTION", "TD", "TH",
"CAPTION", "SUMMARY", "DETAILS", "HEADER", "FOOTER", "SECTION",
"ARTICLE", "ASIDE", "NAV", "MAIN", "ADDRESS", "PRE", "UL", "OL",
"DL", "TABLE", "TR", "THEAD", "TBODY", "TFOOT", "FORM",
"FIELDSET", "LEGEND", "LABEL", "BR", "HR",
];
const DEFAULT_SKIP_TAGS = [
"CODE", "KBD", "PRE", "SAMP", "TT", "VAR",
"SCRIPT", "STYLE", "TEXTAREA", "INPUT",
];
const STYLE_SET = new Set(options.styleInlines || DEFAULT_STYLE_INLINES);
const BLOCK_SET = new Set(options.blockTags || DEFAULT_BLOCK_TAGS);
const SKIP_SET = new Set(options.skipTags || DEFAULT_SKIP_TAGS);
const pillSelector = options.pillSelector || "code, kbd";
const autospaceCls = options.autospaceClass || "cjk-autospace";
const skipAttr = options.skipAttribute || "data-md-key";
const userIsSkipped = typeof options.isSkipped === "function" ? options.isSkipped : null;
/* Pass C 設定。預設啟用(minLength=6 / lang="en");顯式傳 false 才
關閉。可給 { minLength, lang } 局部自訂。
注意:斷點前後最少字符數由消費端 CSS `hyphenate-limit-chars` 控
制(建議 `6 3 3`),lib 不涉入。 */
const longWordCfg = (function ()
{
const v = options.longWordWrap;
if (v === false) { return null; }
if (v === undefined || v === true || v === null) { return { minLength: 6, lang: "en" }; }
return {
minLength: typeof v.minLength === "number" && v.minLength >= 2 ? v.minLength : 6,
lang: typeof v.lang === "string" && v.lang ? v.lang : "en",
};
}());
/* ----- Pangu 字符類與規則表 ----- */
const PANGU_CJK = "⺀-⻿⼀-⿟"
+ "぀-ゟ゠-ヺー-ヿ"
+ "㄀-ㄯ㈀-㋿"
+ "㐀-䶿一-鿿豈-﫿";
const AN_CHARS = "A-Za-z0-9";
const A_CHARS = "A-Za-z";
const OPS_HYPHEN = "\\+\\*=&\\-";
const OPS_GRADE = "\\+\\-\\*";
const QUOTES_CLS = "`\"״";
const LBR_BASIC = "\\(\\[\\{";
const RBR_BASIC = "\\)\\]\\}";
const LBR_EXT = "\\(\\[\\{<>";
const RBR_EXT = "\\)\\]\\}<>";
const ANS_CJK_AFTER = A_CHARS + "Ͱ-Ͽ0-9@\\$%\\^&\\*\\-\\+\\\\=¡-ÿ⅐-↏✀-➿";
const ANS_BEFORE_CJK = A_CHARS + "Ͱ-Ͽ0-9\\$%\\^&\\*\\-\\+\\\\=¡-ÿ⅐-↏✀-➿";
const ANY_CJK = new RegExp("[" + PANGU_CJK + "]");
/* PUA marker chars. 不會撞到正常 prose 中之 glyph。 */
const MARK = "";
const PH_OPEN = "";
const PH_CLOSE = "";
const STRIP_CJK_SPACE_ANS = new RegExp("([" + PANGU_CJK + "]) ([" + ANS_CJK_AFTER + "])", "g");
const STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + PANGU_CJK + "])", "g");
const DOTS_CJK = new RegExp("([\\.]{2,}|…)([" + PANGU_CJK + "])", "g");
const CJK_PUNCTUATION = new RegExp("([" + PANGU_CJK + "])([!;,\\?:]+)(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
const AN_PUNCTUATION_CJK = new RegExp("([" + AN_CHARS + "])([!;,\\?]+)([" + PANGU_CJK + "])", "g");
const CJK_TILDE = new RegExp("([" + PANGU_CJK + "])(~+)(?!=)(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
const CJK_TILDE_EQUALS = new RegExp("([" + PANGU_CJK + "])(~=)", "g");
const CJK_PERIOD = new RegExp("([" + PANGU_CJK + "])(\\.)(?![" + AN_CHARS + "\\./])(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
const AN_PERIOD_CJK = new RegExp("([" + AN_CHARS + "])(\\.)([" + PANGU_CJK + "])", "g");
const AN_COLON_CJK = new RegExp("([" + AN_CHARS + "])(:)([" + PANGU_CJK + "])", "g");
const CJK_QUOTE = new RegExp("([" + PANGU_CJK + "])([" + QUOTES_CLS + "])", "g");
const QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + PANGU_CJK + "])", "g");
const QUOTE_AN = new RegExp("([”])([" + AN_CHARS + "])", "g");
const CJK_QUOTE_AN = new RegExp("([" + PANGU_CJK + "])(\")([" + AN_CHARS + "])", "g");
const CJK_HASH = new RegExp("([" + PANGU_CJK + "])(#([^ ]))", "g");
const HASH_CJK = new RegExp("(([^ ])#)([" + PANGU_CJK + "])", "g");
const SINGLE_LETTER_GRADE = new RegExp("\\b([" + A_CHARS + "])([" + OPS_GRADE + "])([" + PANGU_CJK + "])", "g");
const CJK_OPERATOR_ANS = new RegExp("([" + PANGU_CJK + "])([" + OPS_HYPHEN + "])([" + AN_CHARS + "])", "g");
const ANS_OPERATOR_CJK = new RegExp("([" + AN_CHARS + "])([" + OPS_HYPHEN + "])([" + PANGU_CJK + "])", "g");
const CJK_LESS_THAN = new RegExp("([" + PANGU_CJK + "])(<)([" + AN_CHARS + "])", "g");
const LESS_THAN_CJK = new RegExp("([" + AN_CHARS + "])(<)([" + PANGU_CJK + "])", "g");
const CJK_GREATER_THAN = new RegExp("([" + PANGU_CJK + "])(>)([" + AN_CHARS + "])", "g");
const GREATER_THAN_CJK = new RegExp("([" + AN_CHARS + "])(>)([" + PANGU_CJK + "])", "g");
const CJK_LEFT_BRACKET = new RegExp("([" + PANGU_CJK + "])([" + LBR_EXT + "])", "g");
const RIGHT_BRACKET_CJK = new RegExp("([" + RBR_EXT + "])([" + PANGU_CJK + "])", "g");
const AN_LEFT_BRACKET = new RegExp("([" + AN_CHARS + "])(?<!\\.[" + AN_CHARS + "]*)([" + LBR_BASIC + "])", "g");
const RIGHT_BRACKET_AN = new RegExp("([" + RBR_BASIC + "])([" + AN_CHARS + "])", "g");
const CJK_ANS = new RegExp("([" + PANGU_CJK + "])([" + ANS_CJK_AFTER + "])", "g");
const ANS_CJK = new RegExp("([" + ANS_BEFORE_CJK + "])([" + PANGU_CJK + "])", "g");
const S_A = new RegExp("(%)([" + A_CHARS + "])", "g");
/* Compound words like GPT-4 / state-of-the-art mustn't get operator
spaces wedged into them. Mask before operator passes, restore
before bracket / CJK_ANS / ANS_CJK passes (so the boundary
between 是 and GPT-4 still gets spaced). */
const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
function panguSpace(text)
{
if (text.length <= 1 || !ANY_CJK.test(text)) { return text; }
let s = text;
/* Pre-passstrip authored single spaces at CJK↔ANS boundaries so
the rest of the pipeline can treat the text as if it had been
written flush. */
s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2");
s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2");
s = s.replace(DOTS_CJK, "$1" + MARK + "$2");
s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK);
s = s.replace(AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_TILDE, "$1$2" + MARK);
s = s.replace(CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
s = s.replace(CJK_PERIOD, "$1$2" + MARK);
s = s.replace(AN_PERIOD_CJK, "$1$2" + MARK + "$3");
s = s.replace(AN_COLON_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_QUOTE, "$1" + MARK + "$2");
s = s.replace(QUOTE_CJK, "$1" + MARK + "$2");
s = s.replace(QUOTE_AN, "$1" + MARK + "$2");
s = s.replace(CJK_QUOTE_AN, "$1$2" + MARK + "$3");
s = s.replace(CJK_HASH, "$1" + MARK + "$2");
s = s.replace(HASH_CJK, "$1" + MARK + "$3");
const compounds = [];
s = s.replace(COMPOUND_WORD, function (m)
{
compounds.push(m);
return PH_OPEN + (compounds.length - 1) + PH_CLOSE;
});
s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3");
s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
/* Restore compound words BEFORE bracket / CJK_ANS / ANS_CJK
passes — otherwise their PUA placeholders look like neither
CJK nor AN and the boundaries silently skip. */
s = s.replace(PH_PATTERN, function (_m, idx) { return compounds[+idx] || ""; });
s = s.replace(CJK_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_CJK, "$1" + MARK + "$2");
s = s.replace(AN_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_AN, "$1" + MARK + "$2");
s = s.replace(CJK_ANS, "$1" + MARK + "$2");
s = s.replace(ANS_CJK, "$1" + MARK + "$2");
s = s.replace(S_A, "$1" + MARK + "$2");
return s;
}
/* ----- skip ancestor 判定 ----- */
function isSkipped(node)
{
if (userIsSkipped && userIsSkipped(node)) { return true; }
let p = node.parentNode;
while (p && p.nodeType === 1)
{
if (SKIP_SET.has(p.nodeName)) { return true; }
if (p.namespaceURI === "http://www.w3.org/2000/svg") { return true; }
if (p.isContentEditable) { return true; }
if (skipAttr && p.hasAttribute && p.hasAttribute(skipAttr)) { return true; }
p = p.parentNode;
}
return false;
}
/* ----- helpers ----- */
function insertSpanAt(parent, refNode)
{
const span = document.createElement("span");
span.className = autospaceCls;
span.setAttribute("aria-hidden", "true");
span.textContent = " ";
parent.insertBefore(span, refNode);
}
function insertMarkerAfterPosition(pos)
{
const tn = pos.textNode;
const afterIdx = pos.offset + 1;
if (afterIdx >= tn.data.length)
{
insertSpanAt(tn.parentNode, tn.nextSibling);
}
else
{
const split = tn.splitText(afterIdx);
insertSpanAt(split.parentNode, split);
}
}
function isAutospaceSpan(node)
{
return node && node.nodeType === 1
&& node.classList && node.classList.contains(autospaceCls);
}
/* ----- Pass A: per-block Pangu on tag-stripped text ----- */
function processBlockText(block)
{
const runs = [];
let curText = "";
let curMap = [];
function flushRun()
{
if (curText.length >= 2) { runs.push({ text: curText, map: curMap }); }
curText = "";
curMap = [];
}
function visit(node)
{
if (!node) { return; }
if (node.nodeType === 3)
{
if (isSkipped(node)) { return; }
const data = node.data;
for (let i = 0; i < data.length; i += 1)
{
curText += data.charAt(i);
curMap.push({ textNode: node, offset: i });
}
return;
}
if (node.nodeType !== 1) { return; }
if (isSkipped(node)) { return; }
if (isAutospaceSpan(node)) { flushRun(); return; }
const tag = node.nodeName;
/* PILL / BLOCK / SKIP_SET 三類都中斷 runpill 與 block 邊界
另由 Pass B / 各自子 block 處理)。 */
if (SKIP_SET.has(tag) || BLOCK_SET.has(tag))
{
flushRun();
return;
}
if (node.matches && node.matches(pillSelector))
{
flushRun();
return;
}
/* 樣式標籤或未知元素:透明遞迴。 */
let c = node.firstChild;
while (c) { visit(c); c = c.nextSibling; }
}
let c = block.firstChild;
while (c) { visit(c); c = c.nextSibling; }
flushRun();
for (let r = 0; r < runs.length; r += 1)
{
const text = runs[r].text;
const map = runs[r].map;
const spaced = panguSpace(text);
if (spaced === text) { continue; }
/* Walk spaced + text in parallel. 每個 MARK 在 spaced 中要麼
(a) 取代了原文同位之 single space,要麼 (b) 是純插入。 */
const actions = [];
let origIdx = 0;
let spacedIdx = 0;
while (spacedIdx < spaced.length)
{
const sc = spaced.charAt(spacedIdx);
if (sc === MARK)
{
const oc = origIdx < text.length ? text.charAt(origIdx) : null;
if (oc === " ")
{
actions.push({ before: map[origIdx - 1], space: map[origIdx] });
origIdx += 1;
}
else
{
actions.push({ before: map[origIdx - 1], space: null });
}
spacedIdx += 1;
continue;
}
origIdx += 1;
spacedIdx += 1;
}
/* 倒序執行,避免 text-node 修改影響前面動作之 offset。 */
for (let i = actions.length - 1; i >= 0; i -= 1)
{
const act = actions[i];
if (!act.before) { continue; }
if (act.space)
{
const tn = act.space.textNode;
tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1);
}
insertMarkerAfterPosition(act.before);
}
}
}
/* ----- Pass B: pill boundary, style-inline aware ----- */
function adjacentLogicalChar(el, direction)
{
function pickEnd(tn)
{
if (direction === -1)
{
return { textNode: tn, offset: tn.data.length - 1, ch: tn.data.charAt(tn.data.length - 1) };
}
return { textNode: tn, offset: 0, ch: tn.data.charAt(0) };
}
function descend(into)
{
let n = direction === -1 ? into.lastChild : into.firstChild;
while (n)
{
if (n.nodeType === 3 && n.data.length > 0) { return pickEnd(n); }
if (n.nodeType === 1 && STYLE_SET.has(n.nodeName) && !isAutospaceSpan(n))
{
const r = descend(n);
if (r) { return r; }
}
n = direction === -1 ? n.previousSibling : n.nextSibling;
}
return null;
}
let n = direction === -1 ? el.previousSibling : el.nextSibling;
let p = el.parentNode;
while (true)
{
while (n)
{
if (n.nodeType === 3)
{
if (n.data.length > 0) { return pickEnd(n); }
n = direction === -1 ? n.previousSibling : n.nextSibling;
continue;
}
if (n.nodeType === 1)
{
if (isSkipped(n)) { return null; }
if (isAutospaceSpan(n)) { return null; }
if (STYLE_SET.has(n.nodeName))
{
const r = descend(n);
if (r) { return r; }
n = direction === -1 ? n.previousSibling : n.nextSibling;
continue;
}
/* Pill 或 block — 停。 */
return null;
}
n = direction === -1 ? n.previousSibling : n.nextSibling;
}
if (!p || !STYLE_SET.has(p.nodeName)) { return null; }
n = direction === -1 ? p.previousSibling : p.nextSibling;
p = p.parentNode;
}
}
const PILL_NEIGHBOR = new RegExp("[A-Za-z0-9" + PANGU_CJK + "]");
function processPills(root)
{
root.querySelectorAll(pillSelector).forEach(function (pill)
{
if (isSkipped(pill)) { return; }
const parent = pill.parentNode;
if (!parent) { return; }
const prev = adjacentLogicalChar(pill, -1);
if (prev)
{
if (PILL_NEIGHBOR.test(prev.ch))
{
insertSpanAt(parent, pill);
}
else if (prev.ch === " " && prev.offset >= 1)
{
const tn = prev.textNode;
const bc = tn.data.charAt(prev.offset - 1);
if (bc !== " " && PILL_NEIGHBOR.test(bc))
{
tn.data = tn.data.slice(0, prev.offset) + tn.data.slice(prev.offset + 1);
insertSpanAt(parent, pill);
}
}
}
const next = adjacentLogicalChar(pill, +1);
if (next)
{
if (PILL_NEIGHBOR.test(next.ch))
{
insertSpanAt(parent, pill.nextSibling);
}
else if (next.ch === " " && next.textNode.data.length >= 2)
{
const ac = next.textNode.data.charAt(1);
if (ac !== " " && PILL_NEIGHBOR.test(ac))
{
next.textNode.data = next.textNode.data.slice(1);
insertSpanAt(parent, pill.nextSibling);
}
}
}
});
}
/* ----- Pass C: long English word wrap ----- */
/* 冪等性:parent 為 <span lang="…"> 直接跳過。已被本 pass 或作者標
語的英文段都會被 catche.g. <span lang="en-US">…</span> 雖然 lang
不等於 "en",但 caller 已意圖標語,重複包裝無實益。 */
function isInLangSpan(node)
{
const p = node.parentNode;
if (!p || p.nodeType !== 1) { return false; }
if (p.nodeName !== "SPAN") { return false; }
return p.hasAttribute && p.hasAttribute("lang");
}
function wrapLongWordsIn(textNode, cfg)
{
const re = new RegExp("[A-Za-z]{" + cfg.minLength + ",}", "g");
const data = textNode.data;
const matches = [];
let m;
while ((m = re.exec(data)) !== null)
{
matches.push({ start: m.index, end: m.index + m[0].length });
}
if (matches.length === 0) { return; }
const frag = document.createDocumentFragment();
let cursor = 0;
for (let i = 0; i < matches.length; i += 1)
{
const mm = matches[i];
if (mm.start > cursor)
{
frag.appendChild(document.createTextNode(data.slice(cursor, mm.start)));
}
const span = document.createElement("span");
span.setAttribute("lang", cfg.lang);
span.textContent = data.slice(mm.start, mm.end);
frag.appendChild(span);
cursor = mm.end;
}
if (cursor < data.length)
{
frag.appendChild(document.createTextNode(data.slice(cursor)));
}
textNode.parentNode.replaceChild(frag, textNode);
}
function processLongWords(root, cfg)
{
const reTest = new RegExp("[A-Za-z]{" + cfg.minLength + ",}");
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
const targets = [];
let n = walker.nextNode();
while (n)
{
if (!isSkipped(n) && !isInLangSpan(n) && reTest.test(n.data))
{
targets.push(n);
}
n = walker.nextNode();
}
for (let i = 0; i < targets.length; i += 1)
{
wrapLongWordsIn(targets[i], cfg);
}
}
/* ----- 對外 API ----- */
function apply(root)
{
root = root || (typeof document !== "undefined" ? document.body : null);
if (!root) { return; }
/* Pass A:對每個 block 逐個處理。把 BLOCK_TAGS 轉成 selector
並對 root 之 descendants 查詢;若 root 本身是 block 也處理。 */
const blockSel = Array.from(BLOCK_SET).map(function (t) { return t.toLowerCase(); }).join(",");
if (blockSel)
{
root.querySelectorAll(blockSel).forEach(processBlockText);
}
if (root.nodeType === 1 && BLOCK_SET.has(root.nodeName))
{
processBlockText(root);
}
/* Pass B */
processPills(root);
/* Pass Copt-in */
if (longWordCfg) { processLongWords(root, longWordCfg); }
}
return { apply: apply };
}
/* ----- UMD-ish export ----- */
if (typeof module !== "undefined" && module.exports)
{
module.exports = { createCjkAutospace: createCjkAutospace };
}
if (typeof globalThis !== "undefined" && !globalThis.createCjkAutospace)
{
globalThis.createCjkAutospace = createCjkAutospace;
}
"use strict";
(function ()
{
const SVG_NS = "http://www.w3.org/2000/svg";
const EDGE_STATE = new WeakMap();
const LABEL_STATE = new WeakMap();
/* ---------- Action dispatcher ---------- */
document.addEventListener("click", (e) =>
{
const target = e.target.closest("[data-action]");
if (!target) { return; }
switch (target.getAttribute("data-action"))
{
case "export-md": { copyMarkdown(target); return; }
case "download-md": { downloadMarkdown(); return; }
case "expand-all": { setAllDetails(true); return; }
case "collapse-all": { setAllDetails(false); return; }
case "submit": { submitResponse(target); return; }
case "add-card": { addCardFromButton(target); return; }
}
});
function setAllDetails(open)
{
document.querySelectorAll("details").forEach(d => { d.open = open; });
}
/* ---------- Markdown / clipboard / blob ---------- */
function getMarkdown()
{
const node = document.getElementById("md-source");
if (!node) { return ""; }
return node.textContent.replace(/^\s*\n/, "").replace(/\n\s*$/, "");
}
function copyMarkdown(btn)
{
const md = getMarkdown();
if (!md) { return; }
copyText(md).then(() => { flash(btn, "Copied"); });
}
function downloadMarkdown()
{
const md = getMarkdown();
if (!md) { return; }
const raw = document.title || "export";
const safe = raw
.replace(/[\\/:*?"<>|\x00-\x1f]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-_.]+|[-_.]+$/g, "");
downloadBlob(md, (safe || "export") + ".md", "text/markdown");
}
function copyText(text)
{
if (navigator.clipboard && navigator.clipboard.writeText)
{
return navigator.clipboard.writeText(text).catch(() => { fallbackCopy(text); });
}
fallbackCopy(text);
return Promise.resolve();
}
function fallbackCopy(text)
{
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (_) { /* ignore */ }
document.body.removeChild(ta);
}
function downloadBlob(content, filename, mime)
{
const blob = new Blob([ content ], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function flash(el, msg)
{
if (!el) { return; }
const original = el.textContent;
el.textContent = msg;
setTimeout((() => { el.textContent = original; }), 900);
}
/* ---------- Phase 3: SVG node drag with snap-to-grid ---------- */
function setupDiagrams()
{
if (!document.body.matches("[data-interactive]")) { return; }
document.querySelectorAll("svg.diagram[data-key]").forEach((svg) =>
{
const step = parseFloat(svg.getAttribute("data-grid") || "40");
ensureGridLayer(svg, step);
ensureSnapGuides(svg);
cacheEdgeInitials(svg);
autoAnchorLabels(svg);
cacheLabelOffsets(svg);
svg.querySelectorAll(".node[data-drag]").forEach((node) =>
{
bindNodeDrag(svg, node, step);
});
});
}
/* Authoring forgiveness: attach a label to a node when the label's
anchor point sits strictly inside exactly one .node[data-drag]
bbox. Skips when ambiguous (zero or multiple matches) so explicit
data-anchor on edge labels / external annotations still wins. */
function autoAnchorLabels(svg)
{
const nodes = Array.from(svg.querySelectorAll(".node[data-drag]"));
svg.querySelectorAll(".label:not([data-anchor])").forEach((label) =>
{
const lx = parseFloat(label.getAttribute("x") || "0");
const ly = parseFloat(label.getAttribute("y") || "0");
let match = null;
let count = 0;
for (const node of nodes)
{
const x = parseFloat(node.getAttribute("x") || "0");
const y = parseFloat(node.getAttribute("y") || "0");
const w = parseFloat(node.getAttribute("width") || "0");
const h = parseFloat(node.getAttribute("height") || "0");
if (lx > x && lx < x + w && ly > y && ly < y + h)
{
match = node.getAttribute("data-drag");
count += 1;
}
}
if (count === 1 && match) { label.setAttribute("data-anchor", match); }
});
}
function ensureGridLayer(svg, step)
{
let grid = svg.querySelector(":scope > .grid");
if (!grid)
{
grid = document.createElementNS(SVG_NS, "g");
grid.setAttribute("class", "grid");
grid.setAttribute("aria-hidden", "true");
if (svg.firstChild) { svg.insertBefore(grid, svg.firstChild); }
else { svg.appendChild(grid); }
}
grid.innerHTML = "";
const vb = svg.viewBox.baseVal;
const w = vb && vb.width ? vb.width : parseFloat(svg.getAttribute("width") || "800");
const h = vb && vb.height ? vb.height : parseFloat(svg.getAttribute("height") || "480");
for (let x = 0; x <= w + 0.001; x += step)
{
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("class", "grid-line");
line.setAttribute("x1", String(x));
line.setAttribute("x2", String(x));
line.setAttribute("y1", "0");
line.setAttribute("y2", String(h));
grid.appendChild(line);
}
for (let y = 0; y <= h + 0.001; y += step)
{
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("class", "grid-line");
line.setAttribute("x1", "0");
line.setAttribute("x2", String(w));
line.setAttribute("y1", String(y));
line.setAttribute("y2", String(y));
grid.appendChild(line);
}
}
function ensureSnapGuides(svg)
{
let g = svg.querySelector(":scope > .snap-guides");
if (!g)
{
g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", "snap-guides");
g.setAttribute("aria-hidden", "true");
svg.appendChild(g);
}
return g;
}
function cacheEdgeInitials(svg)
{
svg.querySelectorAll(".edge[data-from], .edge[data-to]").forEach((edge) =>
{
const fromId = edge.getAttribute("data-from");
const toId = edge.getAttribute("data-to");
const fromNode = fromId ? svg.querySelector(`.node[data-drag="${fromId}"]`) : null;
const toNode = toId ? svg.querySelector(`.node[data-drag="${toId}"]`) : null;
EDGE_STATE.set(edge, {
d: edge.getAttribute("d") || "",
fromId, toId,
fromInitialX: fromNode ? parseFloat(fromNode.getAttribute("x") || "0") : 0,
fromInitialY: fromNode ? parseFloat(fromNode.getAttribute("y") || "0") : 0,
toInitialX: toNode ? parseFloat(toNode.getAttribute("x") || "0") : 0,
toInitialY: toNode ? parseFloat(toNode.getAttribute("y") || "0") : 0,
});
});
}
function cacheLabelOffsets(svg)
{
svg.querySelectorAll(".label[data-anchor]").forEach((label) =>
{
const id = label.getAttribute("data-anchor");
const node = svg.querySelector(`.node[data-drag="${id}"]`);
if (!node) { return; }
const nodeX = parseFloat(node.getAttribute("x") || "0");
const nodeY = parseFloat(node.getAttribute("y") || "0");
const labelX = parseFloat(label.getAttribute("x") || "0");
const labelY = parseFloat(label.getAttribute("y") || "0");
LABEL_STATE.set(label, {
id,
offsetX: labelX - nodeX,
offsetY: labelY - nodeY,
});
});
}
function clientToSvg(svg, clientX, clientY)
{
const pt = svg.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const ctm = svg.getScreenCTM();
if (!ctm) { return { x: clientX, y: clientY }; }
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
/* Always-snap: each axis picks the edge (top/bottom or left/right) closest
to a grid line, applies that delta, and reports which edge(s) landed on
a grid line (used to render the snap guides). When the node's dimension
is a multiple of the step, both edges of that axis land simultaneously. */
function snapNode(x, y, w, h, step)
{
const topSnap = Math.round(y / step) * step;
const botSnap = Math.round((y + h) / step) * step;
const topDelta = topSnap - y;
const botDelta = botSnap - (y + h);
let dy, snappedTop = false, snappedBottom = false;
if (Math.abs(topDelta) <= Math.abs(botDelta))
{
dy = topDelta;
snappedTop = true;
}
else
{
dy = botDelta;
snappedBottom = true;
}
// If the other Y edge also lands on the grid after applying dy, flag it too.
const newTop = y + dy;
const newBot = newTop + h;
if (!snappedTop && Math.abs(Math.round(newTop / step) * step - newTop) < 0.001) { snappedTop = true; }
if (!snappedBottom && Math.abs(Math.round(newBot / step) * step - newBot) < 0.001) { snappedBottom = true; }
const leftSnap = Math.round(x / step) * step;
const rightSnap = Math.round((x + w) / step) * step;
const leftDelta = leftSnap - x;
const rightDelta = rightSnap - (x + w);
let dx, snappedLeft = false, snappedRight = false;
if (Math.abs(leftDelta) <= Math.abs(rightDelta))
{
dx = leftDelta;
snappedLeft = true;
}
else
{
dx = rightDelta;
snappedRight = true;
}
const newLeft = x + dx;
const newRight = newLeft + w;
if (!snappedLeft && Math.abs(Math.round(newLeft / step) * step - newLeft) < 0.001) { snappedLeft = true; }
if (!snappedRight && Math.abs(Math.round(newRight / step) * step - newRight) < 0.001) { snappedRight = true; }
return { dx, dy, snappedTop, snappedBottom, snappedLeft, snappedRight };
}
function bindNodeDrag(svg, node, step)
{
let pointerId = null;
let pickup = null;
let rafId = null;
let lastEvent = null;
let docHandlers = null;
node.setAttribute("tabindex", "0");
node.setAttribute("role", "img");
node.addEventListener("pointerdown", (e) =>
{
if (pointerId !== null) { return; }
if (e.button !== undefined && e.button !== 0) { return; }
pointerId = e.pointerId;
const local = clientToSvg(svg, e.clientX, e.clientY);
const x = parseFloat(node.getAttribute("x") || "0");
const y = parseFloat(node.getAttribute("y") || "0");
node.classList.add("dragging");
/* Read stroke-width AFTER .dragging is added so the clamp uses
the dragging-state width — keeps the full stroke inside the
viewBox at the edges instead of clipping a few pixels. */
const sw = parseFloat(getComputedStyle(node).strokeWidth) || 0;
pickup = { offsetX: local.x - x, offsetY: local.y - y, strokePad: sw / 2 };
document.body.classList.add("dragging-active");
// Raise the dragging node within its group so it renders above peers.
const parent = node.parentElement;
if (parent) { parent.appendChild(node); }
docHandlers = { move: onMove, up: onUp, cancel: onUp };
document.addEventListener("pointermove", docHandlers.move);
document.addEventListener("pointerup", docHandlers.up);
document.addEventListener("pointercancel", docHandlers.cancel);
e.preventDefault();
});
function onMove(e)
{
if (pointerId !== e.pointerId) { return; }
lastEvent = e;
if (rafId) { return; }
rafId = requestAnimationFrame(() =>
{
rafId = null;
if (pointerId === null || !lastEvent) { return; }
const ev = lastEvent;
const local = clientToSvg(svg, ev.clientX, ev.clientY);
const w = parseFloat(node.getAttribute("width") || "0");
const h = parseFloat(node.getAttribute("height") || "0");
let nx = local.x - pickup.offsetX;
let ny = local.y - pickup.offsetY;
const snap = snapNode(nx, ny, w, h, step);
nx += snap.dx;
ny += snap.dy;
const vb = svg.viewBox.baseVal;
const pad = pickup ? pickup.strokePad : 0;
if (vb && vb.width) { nx = Math.max(pad, Math.min(vb.width - w - pad, nx)); }
if (vb && vb.height) { ny = Math.max(pad, Math.min(vb.height - h - pad, ny)); }
moveNode(svg, node, nx, ny);
showSnapGuides(svg, node, snap);
});
}
function onUp(e)
{
if (pointerId !== e.pointerId) { return; }
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (docHandlers)
{
document.removeEventListener("pointermove", docHandlers.move);
document.removeEventListener("pointerup", docHandlers.up);
document.removeEventListener("pointercancel", docHandlers.cancel);
docHandlers = null;
}
node.classList.remove("dragging");
document.body.classList.remove("dragging-active");
clearSnapGuides(svg);
pointerId = null;
}
bindNodeKeyboard(svg, node, step);
}
function bindNodeKeyboard(svg, node, step)
{
node.addEventListener("keydown", (e) =>
{
if (e.target !== node) { return; }
if (!(e.altKey || e.shiftKey)) { return; }
let dx = 0, dy = 0;
if (e.key === "ArrowLeft") { dx = -step; }
else if (e.key === "ArrowRight") { dx = step; }
else if (e.key === "ArrowUp") { dy = -step; }
else if (e.key === "ArrowDown") { dy = step; }
else { return; }
e.preventDefault();
const w = parseFloat(node.getAttribute("width") || "0");
const h = parseFloat(node.getAttribute("height") || "0");
const x = parseFloat(node.getAttribute("x") || "0") + dx;
const y = parseFloat(node.getAttribute("y") || "0") + dy;
const sw = parseFloat(getComputedStyle(node).strokeWidth) || 0;
const pad = sw / 2;
const vb = svg.viewBox.baseVal;
const nx = vb && vb.width ? Math.max(pad, Math.min(vb.width - w - pad, x)) : x;
const ny = vb && vb.height ? Math.max(pad, Math.min(vb.height - h - pad, y)) : y;
moveNode(svg, node, nx, ny);
announce(`${node.getAttribute("data-drag")} moved to x=${nx}, y=${ny}.`);
});
}
function moveNode(svg, node, x, y)
{
node.setAttribute("x", String(x));
node.setAttribute("y", String(y));
const id = node.getAttribute("data-drag");
if (id)
{
updateEdgesForNode(svg, id);
updateLabelsForNode(svg, id);
}
}
function updateEdgesForNode(svg, nodeId)
{
svg.querySelectorAll(`.edge[data-from="${nodeId}"], .edge[data-to="${nodeId}"]`).forEach((edge) =>
{
const state = EDGE_STATE.get(edge);
if (!state) { return; }
const fromNode = state.fromId ? svg.querySelector(`.node[data-drag="${state.fromId}"]`) : null;
const toNode = state.toId ? svg.querySelector(`.node[data-drag="${state.toId}"]`) : null;
const fromDx = fromNode ? parseFloat(fromNode.getAttribute("x") || "0") - state.fromInitialX : 0;
const fromDy = fromNode ? parseFloat(fromNode.getAttribute("y") || "0") - state.fromInitialY : 0;
const toDx = toNode ? parseFloat(toNode.getAttribute("x") || "0") - state.toInitialX : 0;
const toDy = toNode ? parseFloat(toNode.getAttribute("y") || "0") - state.toInitialY : 0;
edge.setAttribute("d", shiftPath(state.d, fromDx, fromDy, toDx, toDy));
});
}
/* Shift the FIRST x,y pair of a path d (the M command's start) by the
from-node's delta, and the LAST x,y pair (the path's endpoint) by the
to-node's delta. Intermediate numbers (Bezier control points) are left
alone — they ride along with whichever endpoint they cluster near, and
in practice straight + simple curves render well enough. */
function shiftPath(d, fromDx, fromDy, toDx, toDy)
{
const tokens = d.match(/-?\d+(?:\.\d+)?/g);
if (!tokens || tokens.length < 4) { return d; }
const nums = tokens.map(parseFloat);
nums[0] = nums[0] + fromDx;
nums[1] = nums[1] + fromDy;
nums[nums.length - 2] = nums[nums.length - 2] + toDx;
nums[nums.length - 1] = nums[nums.length - 1] + toDy;
let i = 0;
return d.replace(/-?\d+(?:\.\d+)?/g, () => String(nums[i++]));
}
function updateLabelsForNode(svg, nodeId)
{
svg.querySelectorAll(`.label[data-anchor="${nodeId}"]`).forEach((label) =>
{
const state = LABEL_STATE.get(label);
if (!state) { return; }
const node = svg.querySelector(`.node[data-drag="${nodeId}"]`);
if (!node) { return; }
const nx = parseFloat(node.getAttribute("x") || "0");
const ny = parseFloat(node.getAttribute("y") || "0");
label.setAttribute("x", String(nx + state.offsetX));
label.setAttribute("y", String(ny + state.offsetY));
});
}
function showSnapGuides(svg, node, snap)
{
const guides = svg.querySelector(":scope > .snap-guides");
if (!guides) { return; }
guides.innerHTML = "";
const x = parseFloat(node.getAttribute("x") || "0");
const y = parseFloat(node.getAttribute("y") || "0");
const w = parseFloat(node.getAttribute("width") || "0");
const h = parseFloat(node.getAttribute("height") || "0");
const vb = svg.viewBox.baseVal;
const vw = vb && vb.width ? vb.width : 800;
const vh = vb && vb.height ? vb.height : 480;
if (snap.snappedTop)
{
appendSnapLine(guides, 0, y, vw, y);
appendSnapEdge(guides, x, y, x + w, y);
}
if (snap.snappedBottom)
{
appendSnapLine(guides, 0, y + h, vw, y + h);
appendSnapEdge(guides, x, y + h, x + w, y + h);
}
if (snap.snappedLeft)
{
appendSnapLine(guides, x, 0, x, vh);
appendSnapEdge(guides, x, y, x, y + h);
}
if (snap.snappedRight)
{
appendSnapLine(guides, x + w, 0, x + w, vh);
appendSnapEdge(guides, x + w, y, x + w, y + h);
}
}
function appendSnapLine(parent, x1, y1, x2, y2)
{
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("class", "snap-line");
line.setAttribute("x1", String(x1));
line.setAttribute("y1", String(y1));
line.setAttribute("x2", String(x2));
line.setAttribute("y2", String(y2));
parent.appendChild(line);
}
function appendSnapEdge(parent, x1, y1, x2, y2)
{
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("class", "snap-edge");
line.setAttribute("x1", String(x1));
line.setAttribute("y1", String(y1));
line.setAttribute("x2", String(x2));
line.setAttribute("y2", String(y2));
parent.appendChild(line);
}
function clearSnapGuides(svg)
{
const guides = svg.querySelector(":scope > .snap-guides");
if (guides) { guides.innerHTML = ""; }
}
/* ---------- Live region for a11y ---------- */
function ensureLiveRegion()
{
let region = document.getElementById("aria-live-region");
if (region) { return region; }
region = document.createElement("div");
region.id = "aria-live-region";
region.className = "sr-only";
region.setAttribute("role", "status");
region.setAttribute("aria-live", "polite");
document.body.appendChild(region);
return region;
}
function announce(msg)
{
const region = ensureLiveRegion();
region.textContent = msg;
}
/* ---------- pre[copy] / tabs ---------- */
document.querySelectorAll("pre").forEach((pre) =>
{
if (pre.querySelector(".copy-btn")) { return; }
const btn = document.createElement("button");
btn.type = "button";
btn.className = "copy-btn";
btn.textContent = "copy";
btn.addEventListener("click", () =>
{
const code = pre.querySelector("code");
const text = ( code ? code.textContent : pre.textContent );
copyText(text).then(() => { flash(btn, "✓"); });
});
pre.appendChild(btn);
});
document.querySelectorAll(".tabs").forEach((tabs) =>
{
const tablist = tabs.querySelector("[role=tablist]");
if (!tablist) { return; }
const tabsList = Array.from(tablist.querySelectorAll("[role=tab]"));
const panels = Array.from(tabs.querySelectorAll("[role=tabpanel]"));
function activate(i)
{
tabsList.forEach((t, j) =>
{
const selected = ( j === i );
t.setAttribute("aria-selected", String(selected));
t.setAttribute("tabindex", selected ? "0" : "-1");
if (panels[j]) { panels[j].hidden = !selected; }
});
}
const initial = tabsList.findIndex(t => t.getAttribute("aria-selected") === "true");
activate(initial >= 0 ? initial : 0);
tabsList.forEach((tab, i) =>
{
tab.addEventListener("click", () => { activate(i); tab.focus(); });
tab.addEventListener("keydown", (e) =>
{
let next = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") { next = ( i + 1 ) % tabsList.length; }
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { next = ( i - 1 + tabsList.length ) % tabsList.length; }
else if (e.key === "Home") { next = 0; }
else if (e.key === "End") { next = tabsList.length - 1; }
if (next >= 0)
{
e.preventDefault();
activate(next);
tabsList[next].focus();
}
});
});
});
/* ---------- CJK typography shims ----------
Pass A / B(跨樣式 Pangu autospace + pill 邊界)+ Pass C(長英文
段包 `<span lang="en">` 給瀏覽器原生 `hyphens: auto` 用)皆由
cjk-autospace lib 提供(build.sh 將 lib 之 cjk-autospace.js 串接
至此 IIFE 之前)。Pass C 預設啟用;不需要時於 createCjkAutospace
options 加 `longWordWrap: false` 顯式關閉。
對應 CSSruntime.css):
[lang|="en"] { hyphens: auto; -webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3; } */
/* ---------- Init ---------- */
ensureLiveRegion();
setupDiagrams();
createCjkAutospace({}).apply();
/* ---------- Mode: report — Behavior ----------
Concatenated INTO the same IIFE as runtime.js by build.sh, so
helpers like flash, copyText, ensureLiveRegion (in core runtime.js)
are accessible via closure. The action dispatcher in core
references submitResponse / addCardFromButton declared here —
function declaration hoisting + IIFE-shared scope makes this work.
Slides mode (Phase 2) gets its own modes/slides/behavior.js with
no Submit / cardset / dynamic print logic. */
/* ---------- Constants (report-only) ---------- */
const SLOP = 4;
const SNAPSHOT = new Map();
let addCounter = 0;
/* ---------- Initial-state snapshot ---------- */
function snapshotInitial()
{
SNAPSHOT.clear();
document.querySelectorAll("[data-pick]").forEach((form) =>
{
const key = form.getAttribute("data-pick");
const vals = Array.from(form.querySelectorAll("input:checked"))
.map(i => i.value).sort().join("|");
SNAPSHOT.set(`pick:${key}`, vals);
});
document.querySelectorAll("[data-md-key]").forEach((el) =>
{
const key = el.getAttribute("data-md-key");
SNAPSHOT.set(`edit:${key}`, currentEditValue(el));
});
document.querySelectorAll("[data-sort], .cardset[data-key]").forEach((c) =>
{
const key = c.getAttribute("data-sort") || c.getAttribute("data-key");
if (!key) { return; }
SNAPSHOT.set(`order:${key}`, originalIds(c).join("|"));
});
document.querySelectorAll(".cardset[data-key]").forEach((set) =>
{
const key = set.getAttribute("data-key");
const selected = Array.from(set.querySelectorAll(".card.selected"))
.map(c => c.getAttribute("data-id")).sort().join("|");
SNAPSHOT.set(`selection:${key}`, selected);
});
document.querySelectorAll("svg.diagram[data-key]").forEach((svg) =>
{
const key = svg.getAttribute("data-key");
svg.querySelectorAll(".node[data-drag]").forEach((node) =>
{
const id = node.getAttribute("data-drag");
const x = parseFloat(node.getAttribute("x") || "0");
const y = parseFloat(node.getAttribute("y") || "0");
SNAPSHOT.set(`pos:${key}:${id}`, `${x},${y}`);
});
});
}
function currentEditValue(el)
{
if (el.tagName === "TEXTAREA" || el.tagName === "INPUT") { return el.value; }
return ( el.innerText || el.textContent || "" ).trim();
}
function originalIds(container)
{
return Array.from(container.children)
.filter(c => c.hasAttribute("data-id")
&& !c.classList.contains("add")
&& !c.hasAttribute("data-user-added"))
.map(c => c.getAttribute("data-id"));
}
function draggableChildren(container)
{
return Array.from(container.children)
.filter(c => c.hasAttribute("data-id") && !c.classList.contains("add"));
}
/* ---------- Submit ---------- */
function submitResponse(btn)
{
const md = buildResponse();
copyText(md).then(() => { flash(btn, "Copied response"); });
}
function buildResponse()
{
const title = document.title || "Response";
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
const out = [];
out.push(`# Response — ${title}`);
out.push("");
out.push(`_Generated ${now} · paste this block into the conversation._`);
out.push("");
const picks = collectPicks();
if (picks.length > 0)
{
out.push("## Choices");
out.push("");
picks.forEach(({ key, values }) =>
{
out.push(`- **${key}**: ${values.map(v => `\`${v}\``).join(", ")}`);
});
out.push("");
}
const edits = collectEdits();
if (edits.length > 0)
{
out.push("## Edits");
out.push("");
edits.forEach(({ key, value }) =>
{
out.push(`### ${key}`);
out.push("");
value.split(/\r?\n/).forEach(line => out.push(`> ${line}`));
out.push("");
});
}
const orders = collectOrder();
if (orders.length > 0)
{
out.push("## Order");
out.push("");
orders.forEach(({ key, ids }) =>
{
out.push(`- **${key}**: ${ids.map(id => `\`${id}\``).join(" → ")}`);
});
out.push("");
}
const added = collectAdded();
if (added.length > 0)
{
out.push("## Added");
out.push("");
added.forEach(({ key, id, title: cardTitle, desc, selected }) =>
{
const sel = selected ? " · selected" : "";
out.push(`### ${key}\`${id}\`${sel}`);
out.push("");
if (cardTitle) { out.push(`**${cardTitle}**`); out.push(""); }
if (desc)
{
desc.split(/\r?\n/).forEach(line => out.push(`> ${line}`));
out.push("");
}
});
}
const drags = collectDrags();
if (drags.length > 0)
{
out.push("## Positions");
out.push("");
drags.forEach(({ key, nodes }) =>
{
out.push(`- **${key}**:`);
nodes.forEach(({ id, x, y }) =>
{
out.push(` - ${id}: x=${x}, y=${y}`);
});
});
out.push("");
}
if (picks.length + edits.length + orders.length + added.length + drags.length === 0)
{
out.push("_All values match defaults; no changes captured._");
}
return out.join("\n");
}
function collectPicks()
{
const result = [];
document.querySelectorAll("[data-pick]").forEach((form) =>
{
const key = form.getAttribute("data-pick");
const current = Array.from(form.querySelectorAll("input:checked")).map(i => i.value);
const currentKey = [ ...current ].sort().join("|");
const snap = SNAPSHOT.get(`pick:${key}`) || "";
if (current.length > 0 && currentKey !== snap)
{
result.push({ key, values: current });
}
});
document.querySelectorAll(".cardset[data-key]").forEach((set) =>
{
const key = set.getAttribute("data-key");
const mode = set.getAttribute("data-select") || "none";
if (mode === "none") { return; }
const current = Array.from(set.querySelectorAll(".card.selected"))
.map(c => c.getAttribute("data-id"));
const currentKey = [ ...current ].sort().join("|");
const snap = SNAPSHOT.get(`selection:${key}`) || "";
if (current.length > 0 && currentKey !== snap)
{
result.push({ key, values: current });
}
});
return result;
}
function collectEdits()
{
const result = [];
document.querySelectorAll("[data-md-key]").forEach((el) =>
{
const key = el.getAttribute("data-md-key");
const value = currentEditValue(el);
const snap = SNAPSHOT.get(`edit:${key}`) || "";
if (value !== "" && value !== snap)
{
result.push({ key, value });
}
});
return result;
}
function collectOrder()
{
const result = [];
document.querySelectorAll("[data-sort], .cardset[data-key]").forEach((c) =>
{
const key = c.getAttribute("data-sort") || c.getAttribute("data-key");
if (!key) { return; }
const ids = originalIds(c);
const orderKey = ids.join("|");
const snap = SNAPSHOT.get(`order:${key}`) || "";
if (ids.length > 0 && orderKey !== snap)
{
result.push({ key, ids });
}
});
return result;
}
function collectAdded()
{
const result = [];
document.querySelectorAll(".cardset .card[data-user-added]").forEach((card) =>
{
const set = card.closest(".cardset");
const key = set ? set.getAttribute("data-key") || "(unkeyed)" : "(unkeyed)";
const id = card.getAttribute("data-id");
const titleEl = card.querySelector("h4");
const descEl = card.querySelector("p");
const titleText = titleEl ? ( titleEl.innerText || titleEl.textContent || "" ).trim() : "";
const descText = descEl ? ( descEl.innerText || descEl.textContent || "" ).trim() : "";
result.push({
key, id,
title: titleText,
desc: descText,
selected: card.classList.contains("selected"),
});
});
return result;
}
function collectDrags()
{
const result = [];
document.querySelectorAll("svg.diagram[data-key]").forEach((svg) =>
{
const key = svg.getAttribute("data-key");
const nodes = [];
svg.querySelectorAll(".node[data-drag]").forEach((node) =>
{
const id = node.getAttribute("data-drag");
const x = parseFloat(node.getAttribute("x") || "0");
const y = parseFloat(node.getAttribute("y") || "0");
const snap = SNAPSHOT.get(`pos:${key}:${id}`);
if (snap !== `${x},${y}`)
{
nodes.push({ id, x, y });
}
});
if (nodes.length > 0) { result.push({ key, nodes }); }
});
return result;
}
/* ---------- Drag mechanics ---------- */
/* Layout detection: "y" (vertical column), "x" (single row), "grid" (wrap).
Decided by looking at how the first card relates to the others on the
y axis — same row vs. different row. */
function detectAxis(container)
{
const kids = draggableChildren(container);
if (kids.length < 2) { return "y"; }
const rects = kids.map(c => c.getBoundingClientRect());
const r0 = rects[0];
const sameRow = rects.filter(r => Math.abs(r.top - r0.top) < Math.max(8, r0.height / 2)).length;
if (sameRow === rects.length) { return "x"; }
if (sameRow === 1) { return "y"; }
return "grid";
}
/* Compute the target index for `draggingCard` from its CURRENT VISUAL
CENTER (the rect includes the inline transform), then do at most ONE
insert. For each non-dragging sibling, decide whether the dragging
card is "after" it in document flow:
- y : card center above/below the sibling's vertical midpoint
- x : card center left/right of the horizontal midpoint
- grid : row first — card center above the sibling's top → before;
below its bottom → after; same row → split by horizontal mid.
Using card center (not pointer) means the predicted landing position
follows the visual card, which is what the user is steering. */
function tryReorderWithFlip(container, draggingCard, axis)
{
const all = draggableChildren(container);
const currentIdx = all.indexOf(draggingCard);
if (currentIdx < 0) { return false; }
const others = all.filter(c => c !== draggingCard);
const cardRect = draggingCard.getBoundingClientRect();
const cx = cardRect.left + cardRect.width / 2;
const cy = cardRect.top + cardRect.height / 2;
let target = 0;
for (const sib of others)
{
const r = sib.getBoundingClientRect();
let after = false;
if (axis === "y")
{
after = cy > r.top + r.height / 2;
}
else if (axis === "x")
{
after = cx > r.left + r.width / 2;
}
else // grid
{
if (cy < r.top) { after = false; }
else if (cy > r.bottom) { after = true; }
else { after = cx > r.left + r.width / 2; }
}
if (after) { target += 1; }
}
if (target === currentIdx) { return false; }
// FLIP: capture, mutate, then invert+play on the affected siblings.
const firstRects = new Map(others.map(c => [ c, c.getBoundingClientRect() ]));
const anchor = others[target] || null;
if (anchor)
{
container.insertBefore(draggingCard, anchor);
}
else
{
const addCard = container.querySelector(":scope > .card.add");
container.insertBefore(draggingCard, addCard || null);
}
applyFlip(others, firstRects);
return true;
}
function applyFlip(cards, firstRects)
{
cards.forEach((c) =>
{
const last = c.getBoundingClientRect();
const first = firstRects.get(c);
if (!first) { return; }
const dx = first.left - last.left;
const dy = first.top - last.top;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) { return; }
c.style.transition = "none";
c.style.transform = `translate(${dx}px, ${dy}px)`;
void c.offsetWidth; // force reflow
c.style.transition = "transform var(--dur-reorder) var(--ease-settle)";
c.style.transform = "";
});
}
function updateDragTransform(card, e, offsetX, offsetY)
{
const previous = card.style.transform;
card.style.transform = "";
const natural = card.getBoundingClientRect();
const dx = ( e.clientX - offsetX ) - natural.left;
const dy = ( e.clientY - offsetY ) - natural.top;
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.025)`;
}
function settleCard(card)
{
card.classList.remove("dragging");
card.classList.add("settling");
card.style.transform = ""; // CSS .settling provides the transition
setTimeout(() =>
{
card.classList.remove("settling");
card.style.transition = "";
card.style.transform = "";
}, 220);
}
/* ---------- Drop indicator ---------- */
function createDropIndicator(container)
{
let indicator = container.querySelector(":scope > .drop-indicator");
if (!indicator)
{
indicator = document.createElement("div");
indicator.className = "drop-indicator";
container.appendChild(indicator);
}
// Reflow before transitioning opacity
void indicator.offsetWidth;
indicator.classList.add("visible");
return indicator;
}
/* Position the indicator as a card-sized dashed frame at the dragging
card's *natural* slot (DOM position without the visual translate /
scale). Width / height / top / left are all set inline so the
indicator looks like the empty slot waiting to receive the card. */
function positionDropIndicator(container, indicator, draggingCard)
{
if (!indicator) { return; }
const containerRect = container.getBoundingClientRect();
const previousTransform = draggingCard.style.transform;
draggingCard.style.transform = "";
const naturalRect = draggingCard.getBoundingClientRect();
draggingCard.style.transform = previousTransform;
indicator.style.top = `${naturalRect.top - containerRect.top}px`;
indicator.style.left = `${naturalRect.left - containerRect.left}px`;
indicator.style.width = `${naturalRect.width}px`;
indicator.style.height = `${naturalRect.height}px`;
}
function removeDropIndicator(indicator, container)
{
indicator.classList.remove("visible");
setTimeout(() =>
{
if (indicator.parentElement === container) { container.removeChild(indicator); }
}, 120);
}
/* ---------- Cardset binding (new motion) ---------- */
function setupCardsets()
{
if (!document.body.matches("[data-interactive]")) { return; }
document.querySelectorAll(".cardset").forEach((set) =>
{
const hasSelected = set.querySelector(".card.selected");
if (!hasSelected)
{
set.querySelectorAll(".card.recommended").forEach(c => c.classList.add("selected"));
}
const mode = set.getAttribute("data-select") || "none";
const reorderable = set.getAttribute("data-reorder") !== "false";
draggableChildren(set).forEach(card => bindCard(set, card, mode, reorderable));
});
}
/* The drag uses *document-level* pointermove / pointerup listeners
attached on pointerdown — NOT setPointerCapture. Pointer capture has
been observed to drop when the captured element is moved across the
DOM by insertBefore in some browsers, which would end the drag
prematurely. Document listeners survive any DOM reshuffling. */
function bindCard(set, card, mode, reorderable)
{
let pointerId = null;
let startedDrag = false;
let pickupOffsetX = 0;
let pickupOffsetY = 0;
let startX = 0;
let startY = 0;
let axis = "y";
let rafId = null;
let lastEvent = null;
let indicator = null;
let docHandlers = null;
card.setAttribute("tabindex", "0");
card.setAttribute("role", mode === "none" ? "listitem" : "option");
card.addEventListener("pointerdown", (e) =>
{
if (pointerId !== null) { return; }
if (e.target.closest("input, textarea, button, select, a, [contenteditable]")) { return; }
if (e.button !== undefined && e.button !== 0) { return; }
pointerId = e.pointerId;
startedDrag = false;
startX = e.clientX;
startY = e.clientY;
if (reorderable)
{
axis = detectAxis(set);
const rect = card.getBoundingClientRect();
pickupOffsetX = e.clientX - rect.left;
pickupOffsetY = e.clientY - rect.top;
}
docHandlers = {
move: onPointerMove,
up: onPointerUp,
cancel: onPointerUp,
};
document.addEventListener("pointermove", docHandlers.move);
document.addEventListener("pointerup", docHandlers.up);
document.addEventListener("pointercancel", docHandlers.cancel);
e.preventDefault();
});
function onPointerMove(e)
{
if (pointerId !== e.pointerId) { return; }
if (!startedDrag)
{
if (Math.abs(e.clientX - startX) < SLOP && Math.abs(e.clientY - startY) < SLOP) { return; }
startedDrag = true;
if (reorderable)
{
set.setAttribute("data-axis", axis);
set.classList.add("is-sorting");
card.classList.add("dragging");
document.body.classList.add("dragging-active");
indicator = createDropIndicator(set);
positionDropIndicator(set, indicator, card);
}
}
if (!reorderable || !startedDrag) { return; }
lastEvent = e;
if (rafId) { return; }
rafId = requestAnimationFrame(() =>
{
rafId = null;
if (!startedDrag || !lastEvent) { return; }
const ev = lastEvent;
updateDragTransform(card, ev, pickupOffsetX, pickupOffsetY);
if (tryReorderWithFlip(set, card, axis))
{
positionDropIndicator(set, indicator, card);
}
});
}
function onPointerUp(e)
{
if (pointerId !== e.pointerId) { return; }
const wasDrag = startedDrag;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (docHandlers)
{
document.removeEventListener("pointermove", docHandlers.move);
document.removeEventListener("pointerup", docHandlers.up);
document.removeEventListener("pointercancel", docHandlers.cancel);
docHandlers = null;
}
if (reorderable && wasDrag)
{
settleCard(card);
if (indicator) { removeDropIndicator(indicator, set); indicator = null; }
set.classList.remove("is-sorting");
set.removeAttribute("data-axis");
document.body.classList.remove("dragging-active");
}
pointerId = null;
startedDrag = false;
if (!wasDrag && mode !== "none")
{
toggleSelection(set, card, mode);
}
}
bindCardKeyboard(set, card, mode, reorderable);
}
function bindCardKeyboard(set, card, mode, reorderable)
{
card.addEventListener("keydown", (e) =>
{
if (e.target !== card) { return; }
if (( e.key === " " || e.key === "Enter" ) && mode !== "none")
{
e.preventDefault();
toggleSelection(set, card, mode);
return;
}
if (e.altKey && reorderable)
{
const siblings = draggableChildren(set);
const idx = siblings.indexOf(card);
if (idx < 0) { return; }
let newIdx = -1;
if (e.key === "ArrowDown" || e.key === "ArrowRight") { newIdx = Math.min(idx + 1, siblings.length - 1); }
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { newIdx = Math.max(idx - 1, 0); }
else if (e.key === "Home") { newIdx = 0; }
else if (e.key === "End") { newIdx = siblings.length - 1; }
else { return; }
e.preventDefault();
if (newIdx === idx) { return; }
keyboardReorder(set, card, idx, newIdx, siblings);
card.focus();
}
});
}
function keyboardReorder(set, card, fromIdx, toIdx, siblings)
{
const firstRects = new Map(siblings.map(c => [ c, c.getBoundingClientRect() ]));
const target = siblings[toIdx];
if (toIdx > fromIdx)
{
set.insertBefore(card, target.nextSibling);
}
else
{
set.insertBefore(card, target);
}
applyFlip(siblings, firstRects);
announce(`${cardLabel(card)} moved to position ${toIdx + 1} of ${siblings.length}.`);
}
function cardLabel(card)
{
const h = card.querySelector("h4");
const t = h ? ( h.innerText || h.textContent || "" ).trim() : "";
return t || card.getAttribute("data-id") || "card";
}
function toggleSelection(set, card, mode)
{
if (mode === "single")
{
set.querySelectorAll(".card.selected").forEach(c => c.classList.remove("selected"));
card.classList.add("selected");
}
else if (mode === "multi")
{
card.classList.toggle("selected");
}
const state = card.classList.contains("selected") ? "selected" : "deselected";
announce(`${cardLabel(card)} ${state}.`);
}
function addCardFromButton(btn)
{
const set = btn.closest(".cardset");
if (!set) { return; }
addCounter += 1;
const newCard = document.createElement("article");
newCard.className = "card";
newCard.setAttribute("data-id", `user-${addCounter}`);
newCard.setAttribute("data-user-added", "1");
const h4 = document.createElement("h4");
h4.contentEditable = "true";
h4.textContent = "New option";
const p = document.createElement("p");
p.contentEditable = "true";
p.textContent = "Describe this option.";
newCard.appendChild(h4);
newCard.appendChild(p);
const addBtn = set.querySelector(".card.add");
if (addBtn) { set.insertBefore(newCard, addBtn); }
else { set.appendChild(newCard); }
const mode = set.getAttribute("data-select") || "none";
const reorderable = set.getAttribute("data-reorder") !== "false";
bindCard(set, newCard, mode, reorderable);
h4.focus();
try
{
const range = document.createRange();
range.selectNodeContents(h4);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
catch (_) { /* ignore */ }
}
/* ---------- Plain [data-sort] containers (timeline / ol / ul) ---------- */
function setupSortables()
{
if (!document.body.matches("[data-interactive]")) { return; }
document.querySelectorAll("[data-sort]").forEach((c) =>
{
if (c.classList.contains("cardset")) { return; }
draggableChildren(c).forEach(child => bindSimpleDraggable(c, child));
});
}
function bindSimpleDraggable(container, child)
{
let pointerId = null;
let startedDrag = false;
let startX = 0;
let startY = 0;
let axis = "y";
let pickupOffsetX = 0;
let pickupOffsetY = 0;
let rafId = null;
let lastEvent = null;
let docHandlers = null;
child.addEventListener("pointerdown", (e) =>
{
if (pointerId !== null) { return; }
if (e.target.closest("input, textarea, button, select, a, [contenteditable]")) { return; }
if (e.button !== undefined && e.button !== 0) { return; }
pointerId = e.pointerId;
startedDrag = false;
startX = e.clientX;
startY = e.clientY;
axis = detectAxis(container);
const rect = child.getBoundingClientRect();
pickupOffsetX = e.clientX - rect.left;
pickupOffsetY = e.clientY - rect.top;
docHandlers = { move: onPointerMove, up: onPointerUp, cancel: onPointerUp };
document.addEventListener("pointermove", docHandlers.move);
document.addEventListener("pointerup", docHandlers.up);
document.addEventListener("pointercancel", docHandlers.cancel);
e.preventDefault();
});
function onPointerMove(e)
{
if (pointerId !== e.pointerId) { return; }
if (!startedDrag)
{
if (Math.abs(e.clientX - startX) < SLOP && Math.abs(e.clientY - startY) < SLOP) { return; }
startedDrag = true;
container.classList.add("sorting");
child.classList.add("dragging");
document.body.classList.add("dragging-active");
}
lastEvent = e;
if (rafId) { return; }
rafId = requestAnimationFrame(() =>
{
rafId = null;
if (!startedDrag || !lastEvent) { return; }
updateDragTransform(child, lastEvent, pickupOffsetX, pickupOffsetY);
tryReorderWithFlip(container, child, axis);
});
}
function onPointerUp(e)
{
if (pointerId !== e.pointerId) { return; }
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (docHandlers)
{
document.removeEventListener("pointermove", docHandlers.move);
document.removeEventListener("pointerup", docHandlers.up);
document.removeEventListener("pointercancel", docHandlers.cancel);
docHandlers = null;
}
if (startedDrag)
{
child.classList.add("settling");
child.style.transform = "";
setTimeout(() =>
{
child.classList.remove("dragging");
child.classList.remove("settling");
child.style.transition = "";
child.style.transform = "";
}, 220);
container.classList.remove("sorting");
document.body.classList.remove("dragging-active");
}
pointerId = null;
startedDrag = false;
}
}
/* ---------- Dynamic print page sizing ----------
Measure the document's rendered height and inject an @page rule
sized to fit content exactly. Produces a single-page PDF on "Save
as PDF" with no trailing blank space.
Page margin: 0. Real margins live in `body` padding (runtime.css
@media print). That keeps content width stable regardless of the
print dialog's Margins selector — and stable width is what makes
the screen-to-print height heuristic reliable.
Page-height heuristic: with horizontal padding fixed (1cm each
side), print rendering height roughly matches screen rendering
height. Ratio = 1.0 (was 0.85 — too aggressive an under-estimate;
caused tail clipping). The 50mm buffer absorbs the residual
variance plus whatever vertical margin a dialog like Chrome's
Default might add on top.
Vertical padding (body padding-top 1.5cm + padding-bottom 1cm =
25mm total) is reserved inside the page so the breathing room
above the title and below the footer is preserved.
Escape hatch: set `window.disablePrintAutoSize = true` (in the
DevTools console or before page load) to disable. The injected
rule is cleared and printing falls back to the static A4 in
runtime.css (multi-page). */
function setupDynamicPrintPage()
{
let styleEl = null;
function ensureStyle()
{
if (!styleEl)
{
styleEl = document.createElement("style");
styleEl.setAttribute("data-runtime-print", "");
document.head.appendChild(styleEl);
}
return styleEl;
}
function update()
{
const el = ensureStyle();
if (window.disablePrintAutoSize)
{
el.textContent = ""; /* fall back to runtime.css @page */
return;
}
const PX_TO_MM = 25.4 / 96;
const RATIO = 1.0;
const PAD_V_MM = 25; /* body padding-top 15 + padding-bottom 10 */
const BUFFER_MM = 0; /* absorbs dialog margin + heuristic variance */
const screenH = document.documentElement.scrollHeight;
const printContentH = Math.ceil(screenH * PX_TO_MM * RATIO);
const pageH = printContentH + PAD_V_MM + BUFFER_MM;
el.textContent = "@page { size: 210mm " + pageH + "mm; margin: 0; }";
}
update();
window.addEventListener("resize", update);
window.addEventListener("beforeprint", update);
}
/* ---------- Table of Contents (opt in via body[data-toc]) ----------
長文 report 可選掛載 TOC。掃 <main> 內所有 h2 / h3,缺 id 者補
toc-N,組成 <nav class="toc"><details open><summary>…<ol>…。
lang 取自 <html lang>,控 summary 之語系預設文字;作者可
data-toc="自訂標題" 覆蓋。少於兩個 heading 則不掛(無實益)。 */
function tocLabel()
{
const lang = (document.documentElement.lang || "").toLowerCase();
if (lang.startsWith("zh-hant") || lang.startsWith("zh-tw") || lang.startsWith("zh-hk")) { return "目錄"; }
if (lang.startsWith("zh")) { return "目录"; }
if (lang.startsWith("ja")) { return "目次"; }
if (lang.startsWith("ko")) { return "목차"; }
return "Contents";
}
function setupToc()
{
const attr = document.body.getAttribute("data-toc");
if (attr === null) { return; }
const main = document.querySelector("main");
if (!main) { return; }
const headings = Array.from(main.querySelectorAll("h2, h3"));
if (headings.length < 2) { return; }
const customLabel = (attr && attr !== "1" && attr !== "") ? attr : null;
const nav = document.createElement("nav");
nav.className = "toc";
nav.setAttribute("aria-label", customLabel || tocLabel());
const details = document.createElement("details");
details.open = true;
const summary = document.createElement("summary");
summary.textContent = customLabel || tocLabel();
details.appendChild(summary);
const ol = document.createElement("ol");
headings.forEach((h, i) =>
{
if (!h.id) { h.id = "toc-" + (i + 1); }
const li = document.createElement("li");
li.className = "toc-" + h.tagName.toLowerCase();
const a = document.createElement("a");
a.href = "#" + h.id;
a.textContent = h.textContent.trim();
li.appendChild(a);
ol.appendChild(li);
});
details.appendChild(ol);
nav.appendChild(details);
main.insertBefore(nav, main.firstElementChild);
}
/* ---------- Init (mode: report) ---------- */
setupToc();
setupCardsets();
setupSortables();
setupDynamicPrintPage();
snapshotInitial();
})();
</script>
</body>
</html>