b5ac89b687
CHANGELOG 記新項:Pass C 預設啟用之動機(避開原模板 wbr 每 6 字元亂 插之兩個觀感問題:斷點位置與音節無關、斷處不顯連字符),實作要點, 與字型陷阱(CJK serif 把 U+2010 設計為全形 1 em,須消費端加 hyphenate-character: "-" 強制 U+002D)。 index 加 Pass C 完整章節(演算法、消費端 CSS、選取潔淨性、不在範圍 之識別符 / URL);lede 與 attribution 段同步更新雙 pass → 三 pass。
4694 lines
169 KiB
HTML
4694 lines
169 KiB
HTML
<!-- 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><span lang></code> 包裝」(給瀏覽器原生 <code>hyphens: auto</code> 用)。工廠函式設計,由消費端注入 pill 選擇器、樣式 / 段落 / skip 標籤集等配置。輸出 <code><span class="cjk-autospace" aria-hidden="true">(空白)</span></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 <script> / 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><strong>是</strong> 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><p>本資料夾<strong>不是</strong> Neovim 使用者目錄。</p></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><strong></code> 之 nextSibling 處)</li>
|
||
<li>位置 2:原文「m」與「 」之間,「 」被 strip → marker 插在「m」後(同一 text node 內,splitText 後插入)</li>
|
||
</ul>
|
||
|
||
<p>最終 DOM(簡化):<code>本資料夾<strong>不是</strong><span class="cjk-autospace"> </span>Neovim<span class="cjk-autospace"> </span>使用者目錄。</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>命令 <code>git</code> 更新</code></p>
|
||
|
||
<ul>
|
||
<li>Pass A run 因 pill 中斷:「命令 」(trailing space 為 run 末尾,不觸發 panguSpace 之 STRIP rule,因 rule 要求兩側都是內容字而 run 末尾沒有右側字)</li>
|
||
<li>Pass B 處理 pill 之 prev:text 末字元是 <code>' '</code>,前一字元是 <code>'令'</code>(CJK),命中 strip 規則 → 移除空格 + 在 pill 前插 marker</li>
|
||
<li>Pass B 處理 pill 之 next:text 首字元是 <code>' '</code>,後一字元是 <code>'更'</code>(CJK),同理 strip + 插 marker</li>
|
||
</ul>
|
||
|
||
<p>結果:<code>命令<span class="cjk-autospace"> </span><code>git</code><span class="cjk-autospace"> </span>更新</code></p>
|
||
|
||
<h2>Pass C — long English word wrap(預設啟用)</h2>
|
||
|
||
<p>解 CJK 兩端對齊正文中「行末長英文詞」之斷行問題:原靠 <code><wbr></code> 每 N 字元亂插之路徑會產生「斷點位置與音節無關 + 斷處不顯連字符」兩個觀感問題。Pass C 改採 <code><span lang="en"></code> 包裝,把斷字決定權交給瀏覽器原生 <code>hyphens: auto</code> 用內建英文字典處理。</p>
|
||
|
||
<ol>
|
||
<li>對 root 內所有 text node 走 TreeWalker(SHOW_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><span lang></code> → skip)</li>
|
||
<li>對通過篩選之 node:用 <code>[A-Za-z]{minLength,}</code> 收所有匹配,把每個匹配位置之子串裹進 <code><span lang="en"></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><span class="cjk-autospace" aria-hidden="true"> </span></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 是「不可見」之 span;user-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 '<script>\n'
|
||
cat "$LIB/cjk-autospace.js" # 工廠函式
|
||
printf '\n'
|
||
sed '$d' "$RUNTIME/runtime.js" # 應用程式 runtime
|
||
cat "$MODE_BEHAVIOR_JS"
|
||
printf '\n})();\n'
|
||
printf '</script>\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 = "<script>" .. lib .. "\n" .. entry .. "</script>"
|
||
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><text></code> 不會被處理。</li>
|
||
<li><strong>contentEditable</strong>:用戶輸入區整 subtree skip,避免影響游標 / 選取。</li>
|
||
<li><strong>已存在 marker</strong>:Pass A 走 DFS 時,<code>.cjk-autospace</code> 中斷 run;Pass 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><p>是</p><p>Neovim</p></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-autospace;skip 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-pass:strip 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 三類都中斷 run(pill 與 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 或作者標
|
||
語的英文段都會被 catch;e.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 C(opt-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` 顯式關閉。
|
||
|
||
對應 CSS(runtime.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>
|