@helping-ai-workflow/md2doc 3.2.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -8
- package/THIRD-PARTY-NOTICES.md +44 -0
- package/lib/drawio.js +178 -0
- package/lib/editor/blockmap.js +225 -17
- package/lib/editor/cli.js +50 -1
- package/lib/editor/client.js +5376 -173
- package/lib/editor/inline-md.js +122 -12
- package/lib/editor/lineops.js +190 -3
- package/lib/editor/server.js +398 -6
- package/lib/editor/toolbar-model.js +118 -20
- package/lib/editor/wave-codec.js +2631 -0
- package/lib/editor/wave-geometry.js +320 -0
- package/lib/editor/wave-store.js +950 -0
- package/lib/editor/wave-ui.js +2124 -0
- package/lib/md2doc.js +874 -32
- package/package.json +14 -6
- package/vendor/drawio/LICENSE +201 -0
- package/vendor/drawio/NOTICE +109 -0
- package/vendor/drawio/README.md +25 -0
- package/vendor/drawio/viewer-static.min.js +8596 -0
package/lib/md2doc.js
CHANGED
|
@@ -20,11 +20,65 @@
|
|
|
20
20
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
const path = require('path');
|
|
23
|
+
const drawio = require('./drawio.js');
|
|
23
24
|
|
|
24
25
|
// How long to let client-side WaveDrom / Mermaid scripts render before we
|
|
25
26
|
// snapshot the DOM (PDF print, or --bake-svg inert-SVG bake).
|
|
26
27
|
const DIAGRAM_RENDER_WAIT_MS = 2500;
|
|
27
28
|
|
|
29
|
+
// v3.4.0 batch2 final review R9 — the deferred-diagram placeholders.
|
|
30
|
+
//
|
|
31
|
+
// A placeholder is written by the synchronous marked() pass and read back by
|
|
32
|
+
// an async post-pass roughly four thousand lines away in this same file
|
|
33
|
+
// (bakeDrawio / bakeGraphviz). The two sites used to agree only by
|
|
34
|
+
// character-for-character attribute ordering, and nothing failed if they
|
|
35
|
+
// drifted: a matcher that no longer matches collects zero jobs, the post-pass
|
|
36
|
+
// returns the string untouched, and the placeholder — which carries the
|
|
37
|
+
// base64 of the WHOLE source .drawio file or dot block — ships inside an
|
|
38
|
+
// empty div. Silent, and invisible to every test that only asserts on the
|
|
39
|
+
// baked shape.
|
|
40
|
+
//
|
|
41
|
+
// So the emitter and the matcher are both generated from one ordered field
|
|
42
|
+
// list. Adding, renaming or reordering an attribute changes both at once;
|
|
43
|
+
// there is no edit that can move one without the other.
|
|
44
|
+
//
|
|
45
|
+
// Two rules the field list relies on:
|
|
46
|
+
// - every value must already be safe inside a double-quoted attribute —
|
|
47
|
+
// base64 for anything free-form (the source, the alt text), digits for
|
|
48
|
+
// the page index;
|
|
49
|
+
// - a field is never omitted. A field with nothing to say carries the empty
|
|
50
|
+
// string, so the matcher's arity is fixed and "this one had no alt text"
|
|
51
|
+
// cannot degrade into a no-match.
|
|
52
|
+
function makePlaceholderShape(className, fields) {
|
|
53
|
+
const open = `<div class="${className}" `;
|
|
54
|
+
const quoteRe = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, (c) => '\\' + c);
|
|
55
|
+
return {
|
|
56
|
+
// `probe` is the cheap "is there anything to do at all?" test the bake
|
|
57
|
+
// passes run before doing any work. Derived from the first field rather
|
|
58
|
+
// than spelled out again, for the same no-drift reason as `re`.
|
|
59
|
+
probe: new RegExp(quoteRe(fields[0][0]) + '='),
|
|
60
|
+
html(values) {
|
|
61
|
+
return open + fields.map((f, i) => `${f[0]}="${values[i]}"`).join(' ') + '></div>';
|
|
62
|
+
},
|
|
63
|
+
re: new RegExp(
|
|
64
|
+
quoteRe(open) + fields.map((f) => quoteRe(f[0]) + '="' + f[1] + '"').join(' ') + '><\\/div>',
|
|
65
|
+
'g'),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DRAWIO_PLACEHOLDER = makePlaceholderShape('drawio', [
|
|
70
|
+
['data-drawio-src', '([^"]*)'],
|
|
71
|
+
['data-drawio-page', '(\\d+)'],
|
|
72
|
+
// v3.4.0 batch2 final review R5 — the markdown author's alt text, base64 of
|
|
73
|
+
// the already-HTML-escaped string marked hands us, so it survives the trip
|
|
74
|
+
// to bakeDrawio() and becomes the baked box's accessible name.
|
|
75
|
+
['data-drawio-alt', '([^"]*)'],
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const GRAPHVIZ_PLACEHOLDER = makePlaceholderShape('graphviz', [
|
|
79
|
+
['data-graphviz-src', '([^"]*)'],
|
|
80
|
+
]);
|
|
81
|
+
|
|
28
82
|
function firstExistingPath(candidates) {
|
|
29
83
|
for (const candidate of candidates) {
|
|
30
84
|
if (!candidate) {
|
|
@@ -321,6 +375,29 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
|
|
|
321
375
|
const src = srcPath;
|
|
322
376
|
let usesMermaid = false;
|
|
323
377
|
let usesWaveDrom = false;
|
|
378
|
+
let usesDrawio = false;
|
|
379
|
+
// Set only when some drawio reference actually resolves to 2+ pages — the
|
|
380
|
+
// sheet bar (CSS + runtime) is gated on this, not on usesDrawio alone, so a
|
|
381
|
+
// single-page .drawio file never pays for multi-page UI.
|
|
382
|
+
let usesDrawioMultiPage = false;
|
|
383
|
+
// v3.4.0 batch2 Task 6: every `.drawio`/`.xml` source path this render
|
|
384
|
+
// actually resolved and read, so a caller (lib/editor/server.js) can stat
|
|
385
|
+
// them later to notice an external edit — resolveAssetPath()'s result was
|
|
386
|
+
// previously a pure local inside drawioPlaceholderFor(), never surviving
|
|
387
|
+
// past the base64 it gets embedded as (see the investigation note this
|
|
388
|
+
// task shipped with). A Map, not an array, so the same referenced file
|
|
389
|
+
// linked from two different images is only stat'd once.
|
|
390
|
+
//
|
|
391
|
+
// Fix round 1 (review F4): the VALUE is the file's stamp taken here, at
|
|
392
|
+
// read time, not later by the caller. bakeDrawio() launches headless
|
|
393
|
+
// Chromium and bakes every page after this function returns — seconds, not
|
|
394
|
+
// microseconds, on first use — so a stamp taken by the caller AFTER the
|
|
395
|
+
// bake records bytes that were never baked, and every later comparison
|
|
396
|
+
// then answers "up to date" about a revision nobody ever rendered. Taken
|
|
397
|
+
// BEFORE the readFileSync below for the same reason in miniature: a stamp
|
|
398
|
+
// that is older than the bytes we baked costs one redundant re-render,
|
|
399
|
+
// while one that is newer loses the change permanently.
|
|
400
|
+
const drawioRefs = new Map();
|
|
324
401
|
let usesMath = false;
|
|
325
402
|
let blocks = null;
|
|
326
403
|
// ── Local image assets ───────────────────────────────────────────────────────
|
|
@@ -436,6 +513,68 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
|
|
|
436
513
|
return uri ? uri + fragment : null;
|
|
437
514
|
}
|
|
438
515
|
|
|
516
|
+
// v3.4.0 §4. drawio (.drawio / .xml) goes down the graphviz-style path — a
|
|
517
|
+
// placeholder carrying the source, baked to inline SVG by an async post-pass
|
|
518
|
+
// — instead of image inlining. Returns the placeholder markup, or null to
|
|
519
|
+
// mean "not a drawio reference, fall through to the normal image path"
|
|
520
|
+
// (missing file, non-drawio content, or an extension we don't touch at all).
|
|
521
|
+
// Must run BEFORE inlineImageSrc(): that function warnOnce()s "not a known
|
|
522
|
+
// image extension" for anything outside IMAGE_MIME_BY_EXT, and .drawio/.xml
|
|
523
|
+
// are not image extensions — by the time control reached inlineImageSrc it
|
|
524
|
+
// would already be too late.
|
|
525
|
+
function drawioPlaceholderFor(href, altText) {
|
|
526
|
+
if (!href || isExternalRef(href)) return null;
|
|
527
|
+
const { filePart, fragment } = splitAssetRef(href);
|
|
528
|
+
if (!filePart) return null;
|
|
529
|
+
const ext = path.extname(filePart).toLowerCase();
|
|
530
|
+
if (ext !== '.drawio' && ext !== '.xml') return null;
|
|
531
|
+
|
|
532
|
+
const abs = resolveAssetPath(filePart);
|
|
533
|
+
if (!abs) return null; // No such file: let the ordinary image path warn.
|
|
534
|
+
|
|
535
|
+
// Stamp BEFORE the read — see the drawioRefs comment above.
|
|
536
|
+
const stamp = drawio.drawioStampOf(abs);
|
|
537
|
+
let xml;
|
|
538
|
+
try { xml = fs.readFileSync(abs, 'utf8'); }
|
|
539
|
+
catch (e) { return null; }
|
|
540
|
+
|
|
541
|
+
if (!drawio.isDrawioXml(xml)) {
|
|
542
|
+
// Extension matched but the content didn't — worth a warning for
|
|
543
|
+
// .drawio (that extension promises mxfile/mxGraphModel content), but
|
|
544
|
+
// not for .xml (most .xml files are not diagrams, so silently falling
|
|
545
|
+
// through to the normal image/HTML path is the right default).
|
|
546
|
+
if (ext === '.drawio') {
|
|
547
|
+
warnOnce(href, `[WARN] .drawio file is not mxfile/mxGraphModel, left as-is: ${href}`);
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
usesDrawio = true;
|
|
553
|
+
drawioRefs.set(abs, stamp);
|
|
554
|
+
const names = drawio.pageNamesOf(xml);
|
|
555
|
+
if (names.length >= 2) usesDrawioMultiPage = true;
|
|
556
|
+
// splitAssetRef() keeps the leading '#' (it's re-appended verbatim to a
|
|
557
|
+
// data: URI elsewhere), but resolvePageIndex() expects the bare page
|
|
558
|
+
// name/number — strip it here rather than teach resolvePageIndex two
|
|
559
|
+
// input shapes.
|
|
560
|
+
const pageFragment = fragment ? fragment.slice(1) : '';
|
|
561
|
+
const pageIndex = drawio.resolvePageIndex(names, pageFragment);
|
|
562
|
+
const b64 = Buffer.from(xml, 'utf8').toString('base64');
|
|
563
|
+
// v3.4.0 batch2 final review R5 — carry the author's alt text through to
|
|
564
|
+
// bakeDrawio(), which turns it into the baked box's accessible name. It
|
|
565
|
+
// travels base64-encoded for the same reason the source does: the shared
|
|
566
|
+
// placeholder shape's contract is that every value is already safe inside
|
|
567
|
+
// a double-quoted attribute, and an alt text can contain anything.
|
|
568
|
+
//
|
|
569
|
+
// `altText` is marked's `token.text`, which is ALREADY HTML-escaped
|
|
570
|
+
// (MEASURED on marked 14: `` arrives as
|
|
571
|
+
// `A "q" & <b>`) — the same string the ordinary
|
|
572
|
+
// `<img alt="${text}">` path below interpolates raw. So it must not be
|
|
573
|
+
// escaped again here or on the way out.
|
|
574
|
+
const altB64 = Buffer.from(String(altText || ''), 'utf8').toString('base64');
|
|
575
|
+
return '\n' + DRAWIO_PLACEHOLDER.html([b64, pageIndex, altB64]) + '\n';
|
|
576
|
+
}
|
|
577
|
+
|
|
439
578
|
// A srcset value is a comma-separated list of `<url> <descriptor>` candidates.
|
|
440
579
|
function inlineSrcsetValue(value) {
|
|
441
580
|
const parts = String(value).split(',');
|
|
@@ -588,6 +727,8 @@ ${itemsHtml}
|
|
|
588
727
|
</ul>`;
|
|
589
728
|
}
|
|
590
729
|
|
|
730
|
+
// 契約:一律回傳純文字,永遠不帶 HTML 實體。呼叫端(TOC 標籤、
|
|
731
|
+
// slugifyHeading、startSection)三者都假設這一點。
|
|
591
732
|
function flattenTokenText(tokens) {
|
|
592
733
|
if (!Array.isArray(tokens)) {
|
|
593
734
|
return '';
|
|
@@ -598,12 +739,12 @@ ${itemsHtml}
|
|
|
598
739
|
return flattenTokenText(item.tokens);
|
|
599
740
|
}
|
|
600
741
|
if (item.type === 'codespan') {
|
|
601
|
-
return item.text || '';
|
|
742
|
+
return unescapeHtml(item.text || '');
|
|
602
743
|
}
|
|
603
744
|
if (item.tokens) {
|
|
604
745
|
return flattenTokenText(item.tokens);
|
|
605
746
|
}
|
|
606
|
-
return item.text || '';
|
|
747
|
+
return unescapeHtml(item.text || '');
|
|
607
748
|
})
|
|
608
749
|
.join('');
|
|
609
750
|
}
|
|
@@ -667,8 +808,21 @@ ${itemsHtml}
|
|
|
667
808
|
return classes;
|
|
668
809
|
}
|
|
669
810
|
|
|
811
|
+
// Real HTML tags always have a letter (optionally after a `/`) immediately
|
|
812
|
+
// after `<` — `<x>`, `</div>`, `<br/>`. This deliberately does NOT match
|
|
813
|
+
// `<[^>]*>` generically: once flattenTokenText() started handing back
|
|
814
|
+
// unescaped plain text (v3.4.0 §1), literal author text like "C < D > E"
|
|
815
|
+
// would otherwise look like a tag and get silently eaten here.
|
|
816
|
+
//
|
|
817
|
+
// HTML comments (`<!-- ... -->`) don't start with a letter either, so they
|
|
818
|
+
// need their own alternative — without it a heading like
|
|
819
|
+
// "## Section <!-- note --> Title" leaks "note" into the slug
|
|
820
|
+
// (v3.4.0 Task 1 fix round 1). Known gap, deliberately left open: this
|
|
821
|
+
// does NOT special-case `<!DOCTYPE ...>` or `<? ... ?>` (processing
|
|
822
|
+
// instructions) — same non-letter-after-`<` shape, but neither has shown
|
|
823
|
+
// up in a real heading/prose fixture. Add an alternative here if one does.
|
|
670
824
|
function stripHtmlTags(value) {
|
|
671
|
-
return String(value || '').replace(
|
|
825
|
+
return String(value || '').replace(/<!--[\s\S]*?-->|<\/?[a-zA-Z][^<>]*>/g, '');
|
|
672
826
|
}
|
|
673
827
|
|
|
674
828
|
function slugifyHeading(value) {
|
|
@@ -694,6 +848,19 @@ ${itemsHtml}
|
|
|
694
848
|
.replace(/'/g, ''');
|
|
695
849
|
}
|
|
696
850
|
|
|
851
|
+
// escapeHtml() 的精確逆函式。marked 的 inline lexer 會把 text / codespan
|
|
852
|
+
// token 的 `text` 欄位跳脫過再交出來,而 heading token 自己的 `text` 是
|
|
853
|
+
// 原始的 —— flattenTokenText() 用這一支把兩邊拉回同一個狀態。
|
|
854
|
+
// & 必須最後解:先解它會讓 `&lt;` 變成 `<` 而不是 `<`。
|
|
855
|
+
function unescapeHtml(value) {
|
|
856
|
+
return String(value || '')
|
|
857
|
+
.replace(/</g, '<')
|
|
858
|
+
.replace(/>/g, '>')
|
|
859
|
+
.replace(/"/g, '"')
|
|
860
|
+
.replace(/'/g, "'")
|
|
861
|
+
.replace(/&/g, '&');
|
|
862
|
+
}
|
|
863
|
+
|
|
697
864
|
// marked's tokenizer already escapes alt / title (its default image renderer
|
|
698
865
|
// interpolates both raw), so this must NOT escape them again. Only the src is
|
|
699
866
|
// ours; everything else — including cleanUrl() on a src we leave alone — stays
|
|
@@ -704,6 +871,13 @@ ${itemsHtml}
|
|
|
704
871
|
const href = isObj ? (token.href || '') : (token || '');
|
|
705
872
|
const title = isObj ? (token.title || '') : (arguments[1] || '');
|
|
706
873
|
const text = isObj ? (token.text || '') : (arguments[2] || '');
|
|
874
|
+
|
|
875
|
+
// v3.4.0 §4: drawio 走 graphviz 那條路(建置時烤成內嵌 SVG),不走圖片
|
|
876
|
+
// 內嵌。判別看的是檔案的根元素而不是副檔名 ——  只有在內容
|
|
877
|
+
// 真的是 mxfile / mxGraphModel 時才是圖,否則原樣落到下面既有的路徑。
|
|
878
|
+
const drawioOut = drawioPlaceholderFor(href, text);
|
|
879
|
+
if (drawioOut) return drawioOut;
|
|
880
|
+
|
|
707
881
|
const uri = inlineImageSrc(href);
|
|
708
882
|
if (!uri) return baseImage.apply(this, arguments);
|
|
709
883
|
// A data: URI is base64 (or an already-encoded payload): no quote, no '<'.
|
|
@@ -774,7 +948,7 @@ ${itemsHtml}
|
|
|
774
948
|
// base64 in a data attribute — safe for arbitrary dot syntax (quotes,
|
|
775
949
|
// angle brackets, newlines) inside an HTML attribute.
|
|
776
950
|
const b64 = Buffer.from(code, 'utf8').toString('base64');
|
|
777
|
-
return
|
|
951
|
+
return '\n' + GRAPHVIZ_PLACEHOLDER.html([b64]) + '\n';
|
|
778
952
|
}
|
|
779
953
|
// Default: syntax-highlighted code block
|
|
780
954
|
const escaped = code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
@@ -932,6 +1106,20 @@ ${itemsHtml}
|
|
|
932
1106
|
// falls entirely inside one part, so this is byte-identical to baking the
|
|
933
1107
|
// joined string.
|
|
934
1108
|
parts = await Promise.all(parts.map(bakeGraphviz));
|
|
1109
|
+
// v3.4.0 §4: 與 graphviz 同一個理由 —— 逐 part 烤,不是 join 之後才烤。
|
|
1110
|
+
// patch 路徑會把 parts[i] 直接插進 DOM,而用戶端沒有 drawio 渲染器
|
|
1111
|
+
// (__md2docInitDiagrams 只處理 mermaid 與 WaveDrom),沒烤的 part 會
|
|
1112
|
+
// 變成一個空的 <div class="drawio">。與 bakeGraphviz 不同的是 drawio
|
|
1113
|
+
// 需要一顆 headless Chromium,所以逐 part 依序(而非並行)烤,讓所有
|
|
1114
|
+
// part 共用同一顆延遲啟動的 browser,一份文件最多開一次。
|
|
1115
|
+
const drawioBrowser = makeLazyBrowserRef();
|
|
1116
|
+
try {
|
|
1117
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1118
|
+
parts[i] = await bakeDrawio(parts[i], drawioBrowser);
|
|
1119
|
+
}
|
|
1120
|
+
} finally {
|
|
1121
|
+
await drawioBrowser.closeIfOpen();
|
|
1122
|
+
}
|
|
935
1123
|
bodyHtml = parts.join('\n');
|
|
936
1124
|
} else {
|
|
937
1125
|
bodyHtml = marked.parse(mdPre);
|
|
@@ -1349,7 +1537,19 @@ ${itemsHtml}
|
|
|
1349
1537
|
it. Fixed chrome takes no layout space, so the page has to make its
|
|
1350
1538
|
own: this is the only place that offset is written. The mobile rule at
|
|
1351
1539
|
max-width:1080px above already reserves 60px for .sidebar-toggle; the
|
|
1352
|
-
bar sits above it
|
|
1540
|
+
bar sits above it. That 60px is NOT re-reserved here: T11-2 hides
|
|
1541
|
+
.sidebar-toggle in edit mode (the rule further down this stylesheet), so
|
|
1542
|
+
in edit mode there is no button under the bar to clear, and adding the
|
|
1543
|
+
two offsets left a band of empty page nothing was ever painted into.
|
|
1544
|
+
Measured on the build that did add them, at 1080/1000/800 wide:
|
|
1545
|
+
.page-layout's computed padding-top was 104px against a 44px toolbar —
|
|
1546
|
+
60px of dead space, and a 105px gap between the toolbar's bottom edge
|
|
1547
|
+
and the first block against 45px at 1200 wide, where the desktop rule
|
|
1548
|
+
applies. The @media block below therefore still has to carry an explicit
|
|
1549
|
+
value, identical to the desktop one: the READER stylesheet's own
|
|
1550
|
+
'max-width: 1080px' rule sets padding-top: 60px for the drawer toggle it
|
|
1551
|
+
still shows, and deleting this override would hand the edit page that
|
|
1552
|
+
60px straight back.
|
|
1353
1553
|
.reader-sidebar is sticky at top: 24px in the base stylesheet — pushed
|
|
1354
1554
|
down by the same amount here so the outline does not slide under the
|
|
1355
1555
|
bar, with its height reduced to match so it still ends at the viewport
|
|
@@ -1360,7 +1560,7 @@ ${itemsHtml}
|
|
|
1360
1560
|
height: calc(100vh - 48px - var(--ed-toolbar-h));
|
|
1361
1561
|
}
|
|
1362
1562
|
@media (max-width: 1080px) {
|
|
1363
|
-
.page-layout { padding-top:
|
|
1563
|
+
.page-layout { padding-top: var(--ed-toolbar-h); }
|
|
1364
1564
|
.reader-sidebar { top: 0; height: 100vh; padding-top: calc(16px + var(--ed-toolbar-h)); }
|
|
1365
1565
|
}
|
|
1366
1566
|
/* The 大綱 button's DESKTOP half, on an attribute of its OWN.
|
|
@@ -1382,6 +1582,197 @@ ${itemsHtml}
|
|
|
1382
1582
|
@media (min-width: 1081px) {
|
|
1383
1583
|
body[data-ed-outline-hidden] .reader-sidebar,
|
|
1384
1584
|
body[data-ed-outline-hidden] .sidebar-splitter { display: none; }
|
|
1585
|
+
}
|
|
1586
|
+
/* T11-2 step 3: this button sits at z-index 100 (base stylesheet, above)
|
|
1587
|
+
directly under .ed-toolbar's z-index 101. Measured at 1080x900 and
|
|
1588
|
+
800x900: document.elementFromPoint() at the button's own rect centre
|
|
1589
|
+
returns the toolbar, not the button, at both widths — a real pointer
|
|
1590
|
+
click never lands on it there, only a synthetic .click() does. The
|
|
1591
|
+
toolbar's own left/right are both 0, spanning the full viewport width,
|
|
1592
|
+
so nothing about that occlusion is a function of width; the two
|
|
1593
|
+
measured points are a sample of a band the toolbar covers edge to
|
|
1594
|
+
edge, not two lucky ones. It is safe to remove in edit mode because
|
|
1595
|
+
lib/editor/client.js's toolbar carries its own outline button
|
|
1596
|
+
(runToolbarAction()'s 'outline' case, which calls toggleOutlineSidebar(),
|
|
1597
|
+
the same function this button calls) which opens the identical drawer,
|
|
1598
|
+
and Task 8 gave that toolbar a keyboard cursor (Alt+F10) — the outline
|
|
1599
|
+
button is not among the ids Task 8 measured as stranding the caret on
|
|
1600
|
+
BODY after activation. This rule is edit-mode-only (editModeLayoutCss);
|
|
1601
|
+
reader-mode output never includes it, so the mobile drawer toggle there
|
|
1602
|
+
is unaffected. */
|
|
1603
|
+
.sidebar-toggle { display: none; }
|
|
1604
|
+
/* T11-2 step 5: enterSourceMode() (lib/editor/client.js) sets
|
|
1605
|
+
contentEl.hidden = true and mounts a plain textarea in its place, and
|
|
1606
|
+
setDocMode() writes docMode onto body as data-ed-mode. Measured on that
|
|
1607
|
+
build: with ten TOC links listed, clicking the last one moved neither
|
|
1608
|
+
the (hidden) reading position nor the textarea's own scrollTop
|
|
1609
|
+
(3924 before and after) — the click target was a normal anchor jump /
|
|
1610
|
+
scrollIntoView() reaching into a subtree rooted at .content, and
|
|
1611
|
+
.content's own display:none is what absorbed it; the same subtree
|
|
1612
|
+
relationship holds for a search result's jump target. Hiding the TOC
|
|
1613
|
+
nav and the search-results panel while data-ed-mode="source" removes
|
|
1614
|
+
the two controls whose only reachable target sits in that hidden
|
|
1615
|
+
subtree; the search input row (.reader-tools) is left as-is — driving
|
|
1616
|
+
it against the textarea's own line numbers instead is a separate
|
|
1617
|
+
feasibility question, not answered here. */
|
|
1618
|
+
body[data-ed-mode="source"] .toc,
|
|
1619
|
+
body[data-ed-mode="source"] .search-results { display: none !important; }
|
|
1620
|
+
|
|
1621
|
+
/* ── v3.4.0 batch3 Task 6: the wavedrom editing surface ───────────────
|
|
1622
|
+
Both of these are mounted on document.body by lib/editor/client.js, and
|
|
1623
|
+
that is not a styling choice: .content's children are what the client
|
|
1624
|
+
reads back as its own render and what the WYSIWYG path serialises into
|
|
1625
|
+
the user's markdown, so a panel or a button parked in there ends up in
|
|
1626
|
+
their file. Everything below is therefore positioned against the
|
|
1627
|
+
viewport rather than against .content.
|
|
1628
|
+
|
|
1629
|
+
z-index 998 sits UNDER .ed-conflict (999) on purpose — a save conflict
|
|
1630
|
+
has to stay reachable even while the wave editor is open — and over the
|
|
1631
|
+
toolbar's own band, because the editor is modal over the document. */
|
|
1632
|
+
.ed-wave-edit-btn {
|
|
1633
|
+
position: fixed; z-index: 12; padding: 3px 10px; font-size: 12px;
|
|
1634
|
+
border: 1px solid #bbb; border-radius: 4px; background: #ffffffee;
|
|
1635
|
+
color: #222; cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,.2);
|
|
1636
|
+
}
|
|
1637
|
+
.ed-wave-edit-btn:hover { background: #fff; border-color: #888; }
|
|
1638
|
+
.ed-wave-overlay {
|
|
1639
|
+
position: fixed; inset: 0; z-index: 998; background: rgba(0,0,0,.45);
|
|
1640
|
+
display: flex; align-items: center; justify-content: center;
|
|
1641
|
+
}
|
|
1642
|
+
.ed-wave-panel {
|
|
1643
|
+
background: #fff; border-radius: 6px; width: min(1200px, 94vw);
|
|
1644
|
+
height: min(760px, 90vh); display: flex; flex-direction: column;
|
|
1645
|
+
box-shadow: 0 6px 28px rgba(0,0,0,.35); overflow: hidden;
|
|
1646
|
+
}
|
|
1647
|
+
.ed-wave-head {
|
|
1648
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
1649
|
+
padding: 8px 12px; border-bottom: 1px solid #e2e2e2; font-weight: bold;
|
|
1650
|
+
}
|
|
1651
|
+
.ed-wave-close {
|
|
1652
|
+
border: 1px solid #bbb; border-radius: 4px; background: #f6f6f6;
|
|
1653
|
+
padding: 3px 12px; cursor: pointer;
|
|
1654
|
+
}
|
|
1655
|
+
/* Which way out keeps the work and which throws it away. Escape is a modal's
|
|
1656
|
+
universal close key and here it discards a whole session, so the header
|
|
1657
|
+
says so next to the button that does not. Normal weight against the bar's
|
|
1658
|
+
bold heading: it is a caption, not a warning the user has to clear. */
|
|
1659
|
+
.ed-wave-escape-hint {
|
|
1660
|
+
margin-left: auto; margin-right: 12px; font-weight: normal;
|
|
1661
|
+
font-size: 11px; color: #7a4b00;
|
|
1662
|
+
}
|
|
1663
|
+
.ed-wave-toolbar {
|
|
1664
|
+
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
|
|
1665
|
+
padding: 8px 12px; border-bottom: 1px solid #e2e2e2; background: #fafafa;
|
|
1666
|
+
}
|
|
1667
|
+
.ed-wave-brushes, .ed-wave-cycleops, .ed-wave-meta {
|
|
1668
|
+
display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
|
|
1669
|
+
}
|
|
1670
|
+
.ed-wave-brush {
|
|
1671
|
+
width: 28px; height: 28px; border: 1px solid #c4c4c4; border-radius: 4px;
|
|
1672
|
+
background: #fff; font-family: monospace; font-size: 14px; cursor: pointer;
|
|
1673
|
+
}
|
|
1674
|
+
.ed-wave-brush.is-on { background: #1a73e8; color: #fff; border-color: #1a73e8; }
|
|
1675
|
+
.ed-wave-cycleops button, .ed-wave-toolbar button {
|
|
1676
|
+
border: 1px solid #c4c4c4; border-radius: 4px; background: #fff;
|
|
1677
|
+
padding: 3px 8px; font-size: 12px; cursor: pointer;
|
|
1678
|
+
}
|
|
1679
|
+
.ed-wave-toolbar button:disabled { opacity: .45; cursor: default; }
|
|
1680
|
+
.ed-wave-metafield { display: flex; align-items: center; gap: 4px; font-size: 12px; }
|
|
1681
|
+
.ed-wave-metafield input { width: 110px; font-size: 12px; padding: 2px 4px; }
|
|
1682
|
+
/* The "this drawing is not showing you everything" notice. period / phase /
|
|
1683
|
+
config.hscale all change what the ENGINE paints and none of them is
|
|
1684
|
+
modelled by the hand drawing, so a document carrying one says so instead
|
|
1685
|
+
of drawing a confident wrong picture. Full row, so it cannot be mistaken
|
|
1686
|
+
for another toolbar control. */
|
|
1687
|
+
.ed-wave-unmodelled {
|
|
1688
|
+
flex: 1 1 100%; font-size: 12px; color: #7a4b00; background: #fff6e0;
|
|
1689
|
+
border: 1px solid #e8c987; border-radius: 4px; padding: 4px 8px;
|
|
1690
|
+
}
|
|
1691
|
+
/* The stronger of the two notices: not "this drawing is incomplete" but
|
|
1692
|
+
"this block has nothing this editor can edit" (a reg: / assign: block).
|
|
1693
|
+
Same shape as its neighbour so the two read as one family, one step
|
|
1694
|
+
louder because the information gap is bigger. */
|
|
1695
|
+
.ed-wave-nolanes { color: #7a1f00; background: #ffe9e0; border-color: #e8a987; }
|
|
1696
|
+
.ed-wave-body { display: flex; flex: 1 1 auto; min-height: 0; }
|
|
1697
|
+
/* Widths chosen so the DRAWING still gets room at the narrowest viewport this
|
|
1698
|
+
repo's browser tests use (800x600 -> panel 752): 752 - 200 - 160 = 392px,
|
|
1699
|
+
which is 8 cycles at .ed-wave-canvas's 48px pitch. The canvas is a scroll
|
|
1700
|
+
container, so a wider diagram scrolls rather than pushing the preview off —
|
|
1701
|
+
but a canvas column narrower than a couple of cycles would make the drag
|
|
1702
|
+
gesture unusable, and that was the first thing measured to go wrong here
|
|
1703
|
+
(a press landed on the preview column, 116px past the canvas's clip). */
|
|
1704
|
+
.ed-wave-lanes {
|
|
1705
|
+
flex: 0 0 200px; border-right: 1px solid #e2e2e2; overflow: auto;
|
|
1706
|
+
padding: 8px 0 8px 0;
|
|
1707
|
+
}
|
|
1708
|
+
/* The rows are absolutely positioned at index * laneHeight so they line up
|
|
1709
|
+
with the drawing beside them cell for cell. A group title is NOT a row —
|
|
1710
|
+
measured against the pinned engine in Task 2 — so it is drawn as a label in
|
|
1711
|
+
the rail spanning its lanes' rows instead, which is the shape the engine
|
|
1712
|
+
itself gives it (horizontal space in the name column, no vertical space). */
|
|
1713
|
+
.ed-wave-lane-rows { position: relative; }
|
|
1714
|
+
.ed-wave-lane-row {
|
|
1715
|
+
position: absolute; left: 0; right: 0; display: flex; align-items: center;
|
|
1716
|
+
gap: 3px; padding-right: 6px; box-sizing: border-box;
|
|
1717
|
+
}
|
|
1718
|
+
.ed-wave-lane-row.is-selected { background: #eaf2fe; }
|
|
1719
|
+
.ed-wave-lane-name { flex: 1 1 auto; min-width: 0; font-size: 12px; padding: 2px 4px; }
|
|
1720
|
+
.ed-wave-lane-row button {
|
|
1721
|
+
border: 1px solid #cfcfcf; border-radius: 3px; background: #fff;
|
|
1722
|
+
font-size: 11px; line-height: 1; padding: 3px 4px; cursor: pointer;
|
|
1723
|
+
}
|
|
1724
|
+
.ed-wave-lane-row button:disabled { opacity: .4; cursor: default; }
|
|
1725
|
+
.ed-wave-lane-add-tail { position: absolute; left: 6px; }
|
|
1726
|
+
.ed-wave-group, .ed-wave-group-input {
|
|
1727
|
+
position: absolute; width: 14px; padding: 0; font-size: 11px;
|
|
1728
|
+
writing-mode: vertical-rl; text-orientation: mixed; overflow: hidden;
|
|
1729
|
+
border: 1px solid #d8d8d8; border-radius: 3px; background: #f2f2f2;
|
|
1730
|
+
color: #555; cursor: pointer; box-sizing: border-box;
|
|
1731
|
+
}
|
|
1732
|
+
.ed-wave-group-input { width: 16px; height: 90px; cursor: text; }
|
|
1733
|
+
.ed-wave-canvas-wrap { flex: 1 1 auto; overflow: auto; padding: 8px; }
|
|
1734
|
+
.ed-wave-canvas { display: block; }
|
|
1735
|
+
.ed-wave-grid { stroke: #ececec; stroke-width: 1; }
|
|
1736
|
+
.ed-wave-line, .ed-wave-edge { stroke: #111; stroke-width: 2; fill: none; }
|
|
1737
|
+
.ed-wave-weak { stroke-dasharray: 4 3; }
|
|
1738
|
+
.ed-wave-x { fill: #d9d9d9; stroke: #111; stroke-width: 2; }
|
|
1739
|
+
.ed-wave-bus { fill: #f5e9c8; stroke: #111; stroke-width: 2; }
|
|
1740
|
+
.ed-wave-bus-3 { fill: #d8ead3; }
|
|
1741
|
+
.ed-wave-bus-4 { fill: #d6e4f7; }
|
|
1742
|
+
.ed-wave-bus-5 { fill: #f7d6e4; }
|
|
1743
|
+
.ed-wave-buslabel { font-size: 11px; fill: #222; }
|
|
1744
|
+
.ed-wave-selection { fill: rgba(26,115,232,.18); stroke: #1a73e8; stroke-width: 1; }
|
|
1745
|
+
/* The KEYBOARD's own cell, and deliberately not the same mark as the
|
|
1746
|
+
selection: the selection is the RANGE a cycle operation acts on, the
|
|
1747
|
+
cursor is the ONE cell the next keystroke paints, and after Shift+left /
|
|
1748
|
+
right both are on screen at once. pointer-events: none so the rect can
|
|
1749
|
+
never swallow the press the drag underneath it is made of. */
|
|
1750
|
+
.ed-wave-cursor {
|
|
1751
|
+
fill: none; stroke: #d93025; stroke-width: 2; pointer-events: none;
|
|
1752
|
+
}
|
|
1753
|
+
/* The drawing is in the Tab cycle, so it has to SAY when it holds the
|
|
1754
|
+
keyboard: a cell cursor alone leaves a user who just Tabbed onto it with
|
|
1755
|
+
nothing that distinguishes "the drawing has the keyboard" from "the
|
|
1756
|
+
drawing is where I left a cursor before I walked away". */
|
|
1757
|
+
.ed-wave-canvas:focus { outline: 2px solid #1a73e8; outline-offset: 2px; }
|
|
1758
|
+
/* The discontinuity marker. The engine draws one gap <use> per pipe
|
|
1759
|
+
character; drawing none left the two pictures disagreeing on a lane whose
|
|
1760
|
+
levels agreed exactly. */
|
|
1761
|
+
.ed-wave-gap { stroke: #111; stroke-width: 2; fill: none; }
|
|
1762
|
+
.ed-wave-preview {
|
|
1763
|
+
flex: 0 1 340px; min-width: 160px; border-left: 1px solid #e2e2e2;
|
|
1764
|
+
overflow: auto; padding: 8px;
|
|
1765
|
+
}
|
|
1766
|
+
.ed-wave-preview-label { font-size: 11px; color: #666; margin-bottom: 6px; }
|
|
1767
|
+
.ed-wave-status {
|
|
1768
|
+
border-top: 1px solid #e2e2e2; padding: 6px 12px; font-size: 12px;
|
|
1769
|
+
color: #444; min-height: 18px;
|
|
1770
|
+
}
|
|
1771
|
+
.ed-wave-parse-error { padding: 16px; }
|
|
1772
|
+
.ed-wave-parse-message { color: #b00020; font-weight: bold; }
|
|
1773
|
+
.ed-wave-parse-excerpt {
|
|
1774
|
+
background: #f6f6f6; border: 1px solid #e2e2e2; padding: 6px;
|
|
1775
|
+
overflow-x: auto; font-family: monospace; font-size: 12px;
|
|
1385
1776
|
}`;
|
|
1386
1777
|
|
|
1387
1778
|
const html = `<!DOCTYPE html>
|
|
@@ -1530,6 +1921,24 @@ ${itemsHtml}
|
|
|
1530
1921
|
z-index: 98;
|
|
1531
1922
|
}
|
|
1532
1923
|
body[data-sidebar-open] .sidebar-scrim { display: block; }
|
|
1924
|
+
/* The scroll LOCK for this same attribute lives inside the
|
|
1925
|
+
'@media (max-width: 1080px)' block below, next to
|
|
1926
|
+
'body[data-sidebar-open] .reader-sidebar { transform: translateX(0); }'
|
|
1927
|
+
— deliberately NOT here at base scope, unlike the rule immediately
|
|
1928
|
+
above. Reason (Ruling I1, review round 2): the mobile-drawer meaning of
|
|
1929
|
+
data-sidebar-open, including .sidebar-scrim's own display:block rule
|
|
1930
|
+
just above, already leaks past its breakpoint by design (see the
|
|
1931
|
+
⚠ comment above '@media (min-width: 1081px) { body[data-ed-outline-hidden]
|
|
1932
|
+
… }' for why the desktop 大綱 toggle needed its OWN attribute instead of
|
|
1933
|
+
reusing this one — reusing it once already painted the whole desktop
|
|
1934
|
+
viewport grey and click-dead). Nothing here clears data-sidebar-open on
|
|
1935
|
+
resize (grep this file's two resize listeners — scroll anchor and
|
|
1936
|
+
lightbox fit — neither touches it), so a lock at base scope would ALSO
|
|
1937
|
+
survive: open the drawer narrow, widen the window, and the desktop page
|
|
1938
|
+
would go unscrollable on top of the pre-existing grey scrim. The grey
|
|
1939
|
+
scrim is that already-accepted gap; an unscrollable desktop page is not
|
|
1940
|
+
— it is new, and scoping the lock to the same breakpoint the drawer
|
|
1941
|
+
itself is scoped to is what keeps it from outliving the drawer. */
|
|
1533
1942
|
.reader-tools,
|
|
1534
1943
|
.search-results,
|
|
1535
1944
|
.toc {
|
|
@@ -1951,7 +2360,29 @@ ${itemsHtml}
|
|
|
1951
2360
|
hr { border: none; border-top: 1px solid #e1e4e8; margin: 24px 0; }
|
|
1952
2361
|
.mermaid { text-align: center; margin: 20px 0; }
|
|
1953
2362
|
.graphviz { text-align: center; margin: 20px 0; }
|
|
1954
|
-
.
|
|
2363
|
+
${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pay for this —
|
|
2364
|
+
conditional injection, same pattern as usesWaveDrom / usesMath below.
|
|
2365
|
+
.drawio needs position: relative because the (conditionally injected,
|
|
2366
|
+
see below) sheet bar is its absolute child — same relationship as
|
|
2367
|
+
.lightbox-stage / .lightbox-canvas, see CLAUDE.md. */
|
|
2368
|
+
.drawio { text-align: center; margin: 20px 0; position: relative; }
|
|
2369
|
+
.drawio svg { max-width: 100%; height: auto; }
|
|
2370
|
+
.drawio-page[hidden] { display: none !important; }
|
|
2371
|
+
.drawio-failed { color: #b91c1c; }
|
|
2372
|
+
` : ''}${usesDrawioMultiPage ? ` /* Sheet bar: only when a document actually has a multi-page drawio
|
|
2373
|
+
diagram — a single-page file must not pay for it either. */
|
|
2374
|
+
.drawio-sheetbar {
|
|
2375
|
+
position: absolute; left: 50%; transform: translateX(-50%); bottom: 4px;
|
|
2376
|
+
display: none; gap: 4px; padding: 4px 6px; border-radius: 6px;
|
|
2377
|
+
background: rgba(255,255,255,.92); box-shadow: 0 1px 4px rgba(0,0,0,.18);
|
|
2378
|
+
}
|
|
2379
|
+
.drawio:hover .drawio-sheetbar { display: flex; }
|
|
2380
|
+
.drawio-sheetbar button {
|
|
2381
|
+
border: 0; background: transparent; cursor: pointer;
|
|
2382
|
+
font: inherit; padding: 2px 8px; border-radius: 4px;
|
|
2383
|
+
}
|
|
2384
|
+
.drawio-sheetbar button[aria-pressed="true"] { background: #dbeafe; }
|
|
2385
|
+
` : ''} .content img {
|
|
1955
2386
|
max-width: 100%;
|
|
1956
2387
|
height: auto;
|
|
1957
2388
|
}
|
|
@@ -1968,6 +2399,7 @@ ${itemsHtml}
|
|
|
1968
2399
|
.content img,
|
|
1969
2400
|
.content .mermaid,
|
|
1970
2401
|
.content .graphviz,
|
|
2402
|
+
.content .drawio,
|
|
1971
2403
|
.content [id^="WaveDrom_Display_"],
|
|
1972
2404
|
.content .wavedrom-diagram { cursor: zoom-in; }
|
|
1973
2405
|
.content a img { cursor: pointer; }
|
|
@@ -2092,6 +2524,17 @@ ${itemsHtml}
|
|
|
2092
2524
|
background: #ffffff;
|
|
2093
2525
|
}
|
|
2094
2526
|
body[data-lightbox-open] { overflow: hidden; }
|
|
2527
|
+
/* The 'html, body { overflow-x: clip }' rule above keeps documentElement's
|
|
2528
|
+
own overflow-x at clip, not visible, so overflow does not propagate from
|
|
2529
|
+
body to the viewport per the CSS spec's root-propagation rule; the
|
|
2530
|
+
scrolling element stays documentElement, and its overflow-y sits at the
|
|
2531
|
+
visible default. That is the gap the body rule above does not close --
|
|
2532
|
+
measured in test/lightbox.test.js and test/editor-journey.test.js: a
|
|
2533
|
+
wheel over .lightbox-bar, PageDown and End all reach documentElement
|
|
2534
|
+
while it is open. Locking documentElement's own overflow here closes
|
|
2535
|
+
it; JS mirrors the data-lightbox-open attribute onto the html element
|
|
2536
|
+
in openLightbox/closeLightbox below. */
|
|
2537
|
+
html[data-lightbox-open] { overflow: hidden; }
|
|
2095
2538
|
@media print { .lightbox { display: none !important; } }
|
|
2096
2539
|
@media (max-width: 1080px) {
|
|
2097
2540
|
.sidebar-toggle { display: inline-flex; align-items: center; }
|
|
@@ -2122,6 +2565,21 @@ ${itemsHtml}
|
|
|
2122
2565
|
flex: initial;
|
|
2123
2566
|
}
|
|
2124
2567
|
body[data-sidebar-open] .reader-sidebar { transform: translateX(0); }
|
|
2568
|
+
/* Scroll lock for the drawer, same two-rule shape the lightbox uses a
|
|
2569
|
+
few lines above (body[data-lightbox-open] / html[data-lightbox-open])
|
|
2570
|
+
and for the identical reason: 'html, body { overflow-x: clip }' keeps
|
|
2571
|
+
documentElement's own overflow-y at the visible default, so it stays
|
|
2572
|
+
the scrolling element regardless of what 'body' says — a body-only
|
|
2573
|
+
rule leaves the page scrollable behind the scrim. Was missing
|
|
2574
|
+
entirely before this task (a missing lock, not a defeated one). Scoped
|
|
2575
|
+
to THIS breakpoint on purpose — see the comment above
|
|
2576
|
+
'.sidebar-scrim { display: none; … }' near the top of this stylesheet
|
|
2577
|
+
for why base scope would leak an unscrollable desktop page past a
|
|
2578
|
+
resize, since nothing clears data-sidebar-open on one. JS mirrors
|
|
2579
|
+
data-sidebar-open onto documentElement in setSidebarOpen(), matching
|
|
2580
|
+
openLightbox/closeLightbox. */
|
|
2581
|
+
body[data-sidebar-open] { overflow: hidden; }
|
|
2582
|
+
html[data-sidebar-open] { overflow: hidden; }
|
|
2125
2583
|
.sidebar-splitter { display: none; }
|
|
2126
2584
|
.heading-anchor { opacity: 1; }
|
|
2127
2585
|
}
|
|
@@ -2318,7 +2776,21 @@ ${itemsHtml}
|
|
|
2318
2776
|
this element's containing block and left:100% lands it flush against
|
|
2319
2777
|
the menu's right edge. Source order matters: equal specificity with
|
|
2320
2778
|
.ed-handle-menu's own left:-4px, so this rule must stay AFTER it. */
|
|
2321
|
-
|
|
2779
|
+
/* Viewport-clamp backstop: openConvertSubmenu() (lib/editor/client.js)
|
|
2780
|
+
shifts the panel's top up so its bottom clears the viewport when there
|
|
2781
|
+
is room to do so — that shift is the clamp. This rule covers what the
|
|
2782
|
+
shift cannot: a viewport shorter than the panel, where the viewport's
|
|
2783
|
+
own top edge still leaves the panel's bottom edge off-screen. What
|
|
2784
|
+
happens under max-height, in the order it happens: CONVERT_TARGETS's
|
|
2785
|
+
buttons (lib/editor/convert-md.js), each carrying .ed-handle-menu-btn's
|
|
2786
|
+
own height (26px), first shrink via flex as the cap tightens (measured
|
|
2787
|
+
on this branch: 26px at a cap around 342px, stepping down to 18px
|
|
2788
|
+
around a 250px cap) — no scrolling yet, the list still fits end to
|
|
2789
|
+
end, just shorter per row. overflow-y: auto only starts moving content
|
|
2790
|
+
once the rows have already reached that floor and the cap is still
|
|
2791
|
+
tighter than the list at 18px per row. (No backticks in this comment:
|
|
2792
|
+
it lives inside a JS template literal.) */
|
|
2793
|
+
.ed-handle-submenu { left: 100%; margin-left: 4px; max-height: calc(100vh - 8px); overflow-y: auto; }
|
|
2322
2794
|
.ed-handle-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
|
|
2323
2795
|
.ed-handle-menu-btn[hidden] { display: none; }
|
|
2324
2796
|
/* §10-gap fix: block-level INSERT — the + button sits NEXT TO the ⠿
|
|
@@ -2529,7 +3001,23 @@ ${itemsHtml}
|
|
|
2529
3001
|
font: inherit; font-size: 13px; line-height: 1;
|
|
2530
3002
|
white-space: nowrap; cursor: pointer;
|
|
2531
3003
|
}
|
|
2532
|
-
.ed-seltb-btn:hover { background: rgba(255, 255, 255, 0.18); }
|
|
3004
|
+
.ed-seltb-btn:hover:not([aria-disabled="true"]) { background: rgba(255, 255, 255, 0.18); }
|
|
3005
|
+
/* v3.3.0 (F2): the four states these buttons report about the selection.
|
|
3006
|
+
'true' (the selection is entirely inside a mark of this type) is the
|
|
3007
|
+
filled treatment the fixed toolbar already uses. 'mixed' (the selection
|
|
3008
|
+
overlaps marks of this type without lying inside one) is deliberately NOT
|
|
3009
|
+
the filled one — pressing it removes marks rather than adding any — so it
|
|
3010
|
+
gets a dashed edge and a half-strength ground instead. 'false' keeps the
|
|
3011
|
+
rule above. INERT is aria-disabled, never the disabled DOM property; see
|
|
3012
|
+
paintSelToolbarMarks() in lib/editor/client.js for why. */
|
|
3013
|
+
.ed-seltb-btn[aria-pressed="true"] {
|
|
3014
|
+
background: #e6edf3; color: #101215; border-color: #e6edf3;
|
|
3015
|
+
}
|
|
3016
|
+
.ed-seltb-btn[aria-pressed="mixed"] {
|
|
3017
|
+
background: rgba(230, 237, 243, 0.3);
|
|
3018
|
+
border-style: dashed; border-color: rgba(230, 237, 243, 0.8);
|
|
3019
|
+
}
|
|
3020
|
+
.ed-seltb-btn[aria-disabled="true"] { opacity: 0.38; cursor: default; }
|
|
2533
3021
|
.ed-seltb-b { font-weight: bold; }
|
|
2534
3022
|
.ed-seltb-i { font-style: italic; }
|
|
2535
3023
|
.ed-seltb-s { text-decoration: line-through; }
|
|
@@ -2550,13 +3038,91 @@ ${itemsHtml}
|
|
|
2550
3038
|
.ed-toolbar {
|
|
2551
3039
|
position: fixed; top: 0; left: 0; right: 0; z-index: 101;
|
|
2552
3040
|
box-sizing: border-box; height: var(--ed-toolbar-h);
|
|
2553
|
-
display: flex; align-items: center; justify-content:
|
|
3041
|
+
display: flex; align-items: center; justify-content: flex-start;
|
|
2554
3042
|
flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
|
|
2555
|
-
|
|
3043
|
+
/* v3.4.0 §3: gap was 6px until the save button (id 'save', file group)
|
|
3044
|
+
landed at the FRONT of the row. Ruling T11-2 (below) requires the
|
|
3045
|
+
outline (☰) button's own centre point to hit-test successfully at
|
|
3046
|
+
800/1080px WITHOUT any scrolling — it is the only way to open the
|
|
3047
|
+
sidebar drawer in edit mode, and that test deliberately does not
|
|
3048
|
+
scroll first. A leading addition shifts every button after it right
|
|
3049
|
+
by its own width; MEASURED at 800px wide before this change: 22
|
|
3050
|
+
buttons put outline's centre at x=795 (row content 975px, overflow
|
|
3051
|
+
175px) — already only a 5px margin inside the 800px viewport. Adding
|
|
3052
|
+
save (23 buttons, row content 1026px, overflow 226px) pushed it to
|
|
3053
|
+
x=846, entirely off screen (elementFromPoint at that point returns
|
|
3054
|
+
null — off-screen, not occluded by another element; contrast the
|
|
3055
|
+
.ed-toolbar-status overlap a few lines down, which returns the
|
|
3056
|
+
covered BUTTON because that slot has pointer-events: none, an
|
|
3057
|
+
entirely different failure shape). This gap value was chosen
|
|
3058
|
+
empirically (a one-off puppeteer script, not a formula — flex gap
|
|
3059
|
+
plus intrinsic button/separator widths do not sum cleanly by hand):
|
|
3060
|
+
re-measured at 800px, outline's centre lands at x=765, a 35px margin
|
|
3061
|
+
— comfortably more headroom than the original 5px, not just barely
|
|
3062
|
+
restored. Also re-verified against the F12 "narrow viewport,
|
|
3063
|
+
scrolling allowed" sweep just below (420/640/820px): every button,
|
|
3064
|
+
save included, still reaches an empty unreachable list and an empty
|
|
3065
|
+
neverClear list at all three — this change does not trade the T11-2
|
|
3066
|
+
no-scroll guarantee for the F12 with-scroll one. */
|
|
3067
|
+
gap: 3px; padding: 8px 12px;
|
|
3068
|
+
/* F12, both halves measured on this build at 900px viewport height.
|
|
3069
|
+
justify-content was center. Centering an overflowing flex row splits
|
|
3070
|
+
the overflow across BOTH ends, and the leading end of a scroll
|
|
3071
|
+
container's content is not reachable by scrolling: driven at 820 /
|
|
3072
|
+
640 / 420 px wide, sweeping scrollLeft from 0 to its maximum in 8px
|
|
3073
|
+
steps and reading every button's rect, the buttons that never once sat
|
|
3074
|
+
inside the viewport were [undo] / [undo, redo, headings] /
|
|
3075
|
+
[undo, redo, headings, quote, code, list]. On this build — leading-edge
|
|
3076
|
+
layout plus the padding-right below, driven together, not one at a time
|
|
3077
|
+
— the same sweep leaves that set empty at all three widths.
|
|
3078
|
+
|
|
3079
|
+
padding-right buys the buttons room to clear .ed-toolbar-status, whose
|
|
3080
|
+
fixed box occupies the viewport's right 124px (right: 12px plus
|
|
3081
|
+
min-width: 7rem at this font size). The claim it supports is narrow:
|
|
3082
|
+
SOME scroll position exists at which a given button is clear of the
|
|
3083
|
+
slot. It is NOT "a button can never sit under the slot" — driven at
|
|
3084
|
+
scrollLeft 0 after this change, [outline, preview] still overlap the
|
|
3085
|
+
slot at a 900px viewport and [check, bold, italic, strike] do at 420px.
|
|
3086
|
+
What makes that harmless is the slot's own pointer-events: none, in
|
|
3087
|
+
the rule further down — the load-bearing line, not this one. */
|
|
3088
|
+
padding-right: 124px;
|
|
2556
3089
|
background: rgba(16, 18, 21, 0.92); color: #e6edf3;
|
|
3090
|
+
/* F12, second half: before this the bar scrolled but nothing on screen
|
|
3091
|
+
said so, so a button parked outside the viewport looked absent rather
|
|
3092
|
+
than off to one side. Two edge gradients, painted only on the side
|
|
3093
|
+
that still has content hidden behind it; the client runtime writes
|
|
3094
|
+
data-ed-tb-overflow from scrollLeft against the scroll maximum.
|
|
3095
|
+
|
|
3096
|
+
Deliberately a background layer on the bar itself rather than a new
|
|
3097
|
+
overlay node. An overlay would have to be taken out of flow to avoid
|
|
3098
|
+
becoming a flex item in this row, and an out-of-flow child of this bar
|
|
3099
|
+
anchors to the scrolled content box and drifts with it (the
|
|
3100
|
+
.ed-toolbar-status note below records that measurement) — while
|
|
3101
|
+
anchoring it to the viewport instead would add a floating layer to the
|
|
3102
|
+
set test/editor-journey.test.js enumerates and demands a scroll answer
|
|
3103
|
+
for. A background layer needs neither: background-attachment defaults
|
|
3104
|
+
to scroll, which pins the gradient to this element's padding box while
|
|
3105
|
+
its contents move underneath.
|
|
3106
|
+
|
|
3107
|
+
These gradients sit behind the buttons, because that is where the
|
|
3108
|
+
painting order puts a background layer: above the background colour,
|
|
3109
|
+
below the element's content. That is the reason they are dark rather
|
|
3110
|
+
than light — darkening the ground a translucent button sits on is what
|
|
3111
|
+
reads as content passing under an edge, where a light wash behind the
|
|
3112
|
+
same button would not. */
|
|
3113
|
+
--ed-tb-edge-l: rgba(0, 0, 0, 0);
|
|
3114
|
+
--ed-tb-edge-r: rgba(0, 0, 0, 0);
|
|
3115
|
+
background-image:
|
|
3116
|
+
linear-gradient(to right, var(--ed-tb-edge-l), rgba(0, 0, 0, 0)),
|
|
3117
|
+
linear-gradient(to left, var(--ed-tb-edge-r), rgba(0, 0, 0, 0));
|
|
3118
|
+
background-repeat: no-repeat;
|
|
3119
|
+
background-position: left center, right center;
|
|
3120
|
+
background-size: 26px 100%, 26px 100%;
|
|
2557
3121
|
font-size: 13px;
|
|
2558
3122
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
2559
3123
|
}
|
|
3124
|
+
.ed-toolbar[data-ed-tb-overflow~="left"] { --ed-tb-edge-l: rgba(0, 0, 0, 0.55); }
|
|
3125
|
+
.ed-toolbar[data-ed-tb-overflow~="right"] { --ed-tb-edge-r: rgba(0, 0, 0, 0.55); }
|
|
2560
3126
|
.ed-toolbar-sep {
|
|
2561
3127
|
flex: 0 0 auto; width: 1px; height: 18px; margin: 0 2px;
|
|
2562
3128
|
background: rgba(255, 255, 255, 0.25);
|
|
@@ -2570,18 +3136,45 @@ ${itemsHtml}
|
|
|
2570
3136
|
white-space: nowrap; cursor: pointer;
|
|
2571
3137
|
}
|
|
2572
3138
|
.ed-toolbar-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.18); }
|
|
3139
|
+
/* F12: where the toolbar's keyboard cursor is. An outline rather than a
|
|
3140
|
+
background so it stays legible on a button that is already pressed-in
|
|
3141
|
+
(aria-pressed paints that one nearly white). Claim no more for it than a
|
|
3142
|
+
keyboard route: the buttons keep their tabindex="-1" and the caret stays
|
|
3143
|
+
in the document, so no focus move happens for assistive technology to
|
|
3144
|
+
follow, and what this cue does for a screen reader has not been measured
|
|
3145
|
+
here. What it does do is let the row be walked without a mouse. */
|
|
3146
|
+
.ed-toolbar-btn[data-ed-tb-cursor] {
|
|
3147
|
+
outline: 2px solid #6ea8fe; outline-offset: 1px;
|
|
3148
|
+
}
|
|
2573
3149
|
.ed-toolbar-btn:disabled { opacity: 0.38; cursor: default; }
|
|
2574
3150
|
.ed-toolbar-btn[aria-pressed="true"] {
|
|
2575
3151
|
background: #e6edf3; color: #101215; border-color: #e6edf3;
|
|
2576
3152
|
}
|
|
3153
|
+
/* v3.3.0 (F2): the middle state, matching .ed-seltb-btn[aria-pressed="mixed"]
|
|
3154
|
+
above so the two bars answer the same question the same way. */
|
|
3155
|
+
.ed-toolbar-btn[aria-pressed="mixed"] {
|
|
3156
|
+
background: rgba(230, 237, 243, 0.3);
|
|
3157
|
+
border-style: dashed; border-color: rgba(230, 237, 243, 0.8);
|
|
3158
|
+
}
|
|
2577
3159
|
.ed-toolbar-b { font-weight: bold; }
|
|
2578
3160
|
.ed-toolbar-i { font-style: italic; }
|
|
2579
3161
|
.ed-toolbar-s { text-decoration: line-through; }
|
|
2580
|
-
/* H1 fix
|
|
2581
|
-
|
|
2582
|
-
margin
|
|
2583
|
-
justify-content
|
|
2584
|
-
|
|
3162
|
+
/* H1 fix — and its reason has since moved, so both readings are kept here
|
|
3163
|
+
rather than one silently standing in for the other. The brief's literal
|
|
3164
|
+
margin-left: auto (a flex-flow item) was measured on v3.2.0 to break
|
|
3165
|
+
.ed-toolbar's justify-content: center — an auto margin on any flex item
|
|
3166
|
+
absorbs ALL positive free space before justify-content sees any, so the
|
|
3167
|
+
~22-button group snapped flush left on every viewport wide enough not to
|
|
3168
|
+
overflow. F12 replaced that declaration with justify-content: flex-start
|
|
3169
|
+
(the .ed-toolbar rule above), so flush left IS the shipped layout now and
|
|
3170
|
+
that particular failure mode no longer exists. What the rule guards today
|
|
3171
|
+
is the slot's out-of-flow-ness, and the assertion beside it in
|
|
3172
|
+
test/editmode-render.test.js carries the re-driven numbers: against the
|
|
3173
|
+
slot as it ships the margin changes nothing at all, while putting the slot
|
|
3174
|
+
back in flow with it turns the slot into a scrolling flex item
|
|
3175
|
+
(scrollWidth 975 -> 1093 at 420px) and moves its rect off the strip
|
|
3176
|
+
.ed-toolbar's padding-right reserves for it ([1276, 1388] -> [1164, 1276]
|
|
3177
|
+
at 1400px). position: fixed removes
|
|
2585
3178
|
the slot from the flex layout entirely (buttons measured byte-identical
|
|
2586
3179
|
to a build with the slot absent, at both a 1400px and a 420px viewport),
|
|
2587
3180
|
and — unlike position: absolute against .ed-toolbar's own
|
|
@@ -2593,14 +3186,17 @@ ${itemsHtml}
|
|
|
2593
3186
|
H2 fix: pointer-events: none below is LOAD-BEARING, not decorative.
|
|
2594
3187
|
v3.2.1 filled this slot with the current mode's name ('編輯' / '原始碼'),
|
|
2595
3188
|
so it no longer ships empty — but that content is inert TEXT, never a
|
|
2596
|
-
control, which is exactly what keeps the rule below sufficient.
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
3189
|
+
control, which is exactly what keeps the rule below sufficient. The
|
|
3190
|
+
overlap it keeps harmless is real and still present after F12 moved this
|
|
3191
|
+
bar's scroll geometry: at scrollLeft 0, this box's footprint covers
|
|
3192
|
+
outline and preview at a 900px-wide viewport, and check / bold / italic /
|
|
3193
|
+
strike at 420px. Re-driven on this build, hit-testing the middle of each
|
|
3194
|
+
of those overlaps: with the rule as it ships,
|
|
3195
|
+
document.elementFromPoint() answers the BUTTON at every one of them; flip
|
|
3196
|
+
this span's pointer-events to auto and leave everything else alone, and
|
|
3197
|
+
every one of them answers this span instead. That reading is the same hit
|
|
3198
|
+
test a press resolves through, and the rule below is what stands between
|
|
3199
|
+
the two readings — not the span's emptiness. WHOEVER PUTS A CONTROL HERE (or
|
|
2604
3200
|
wires up two-way sync, §3) MUST remove or scope this
|
|
2605
3201
|
rule AND independently solve the overlap (e.g. reflow the slot in-flow
|
|
2606
3202
|
at narrow widths, or shrink/hide it below some width) — simply
|
|
@@ -2613,13 +3209,19 @@ ${itemsHtml}
|
|
|
2613
3209
|
pointer-events: none; }
|
|
2614
3210
|
/* The H▾ dropdown: same singleton-panel idiom as .ed-insert-menu, but
|
|
2615
3211
|
anchored to a fixed bar rather than moved into a block, so it is
|
|
2616
|
-
positioned in viewport coordinates by the client runtime.
|
|
3212
|
+
positioned in viewport coordinates by the client runtime.
|
|
3213
|
+
max-height/overflow-y: same backstop role as .ed-handle-submenu above
|
|
3214
|
+
(Task 9 fix round 1, Ruling T9-5) — toggleToolbarHeadingMenu()
|
|
3215
|
+
(lib/editor/client.js) does the shift-to-fit; this covers a viewport
|
|
3216
|
+
shorter than the panel, where the shift alone cannot bring the whole
|
|
3217
|
+
list on-screen. */
|
|
2617
3218
|
.ed-toolbar-menu {
|
|
2618
3219
|
position: fixed; z-index: 102;
|
|
2619
3220
|
display: flex; flex-direction: column; gap: 2px;
|
|
2620
3221
|
padding: 4px; border-radius: 8px;
|
|
2621
3222
|
background: rgba(16, 18, 21, 0.96); color: #e6edf3;
|
|
2622
3223
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
|
3224
|
+
max-height: calc(100vh - 8px); overflow-y: auto;
|
|
2623
3225
|
}
|
|
2624
3226
|
.ed-toolbar-menu[hidden] { display: none; }
|
|
2625
3227
|
.ed-toolbar-menu-btn {
|
|
@@ -2628,6 +3230,18 @@ ${itemsHtml}
|
|
|
2628
3230
|
font: inherit; font-size: 13px; line-height: 1.4; cursor: pointer;
|
|
2629
3231
|
}
|
|
2630
3232
|
.ed-toolbar-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
|
|
3233
|
+
/* backlog #7 (Task 9 review, M1): the panel's OWN keyboard cursor
|
|
3234
|
+
(lib/editor/client.js's toolbarMenuIndex / data-ed-tb-menu-cursor) had
|
|
3235
|
+
no matching rule at all — the fix made the six items operable by
|
|
3236
|
+
keyboard but left the cursor invisible, the exact backlog #10 class of
|
|
3237
|
+
defect ("can act, but cannot see where"). Same outline idiom as
|
|
3238
|
+
.ed-toolbar-btn[data-ed-tb-cursor] above, for the same reason (legible
|
|
3239
|
+
over :hover's own background, and over nothing at all when neither
|
|
3240
|
+
applies). */
|
|
3241
|
+
.ed-toolbar-menu-btn[data-ed-tb-menu-cursor] {
|
|
3242
|
+
outline: 2px solid #6ea8fe; outline-offset: -2px;
|
|
3243
|
+
background: rgba(255, 255, 255, 0.18);
|
|
3244
|
+
}
|
|
2631
3245
|
/* 追加 4: the whole-document source escape hatch. Replaces the rendered
|
|
2632
3246
|
content column for the duration of source mode; the document's own
|
|
2633
3247
|
lines[] is untouched until the mode is left (see docsource.js). */
|
|
@@ -2651,8 +3265,45 @@ ${itemsHtml}
|
|
|
2651
3265
|
}
|
|
2652
3266
|
.ed-commit { color: #0a7a0a; }
|
|
2653
3267
|
.ed-cancel { color: #b00020; }
|
|
3268
|
+
/* backlog #8: 'top: 0' used to put this banner directly OVER .ed-toolbar
|
|
3269
|
+
(same top-of-viewport band, and z-index 999 against the bar's 101 wins
|
|
3270
|
+
the paint) — MEASURED (task-10-toolbar-probe.js): all 23 toolbar
|
|
3271
|
+
buttons' own centre points hit-tested to the banner, not the button,
|
|
3272
|
+
for every .ed-conflict banner showBanner() raises (conflict /
|
|
3273
|
+
render-failed / save-failed / dropped-gesture / structural-refusal /
|
|
3274
|
+
swallow all share this one class — there is exactly one producer, see
|
|
3275
|
+
showBanner() in lib/editor/client.js). Floors at the SAME
|
|
3276
|
+
--ed-toolbar-h token placeTeMenuAboveCell()'s .ed-te-menu already
|
|
3277
|
+
clamps to (if top < topInset, top = r.bottom + …, both in
|
|
3278
|
+
lib/editor/client.js) — rather than inventing a new offset.
|
|
3279
|
+
Final-review I1 correction: an earlier draft of this comment also named
|
|
3280
|
+
positionSelToolbar()'s .ed-seltb here, as if it clamped to this same
|
|
3281
|
+
token. Retracted — MEASURED: positionSelToolbar() reads --ed-toolbar-h
|
|
3282
|
+
only to decide whether a selection scrolled fully out of view should
|
|
3283
|
+
hide the bar entirely (rect.bottom <= topInset); the vertical floor it
|
|
3284
|
+
actually clamps 'top' to is a bare margin = 4 local, independent
|
|
3285
|
+
of the toolbar's height. Swept 66 scroll positions: at 17 of them
|
|
3286
|
+
.ed-seltb's own top measures as low as 14.75px — inside this banner's
|
|
3287
|
+
band — with no --ed-toolbar-h involved (task10-seltb-check-probe.js).
|
|
3288
|
+
Not reproduced with a LIVE banner open at the same time (two attempts:
|
|
3289
|
+
a burst's bold toggle never hits /api/render, and Ctrl+S's
|
|
3290
|
+
switchAwayFrom() tears the bar down before a banner could show) — left
|
|
3291
|
+
as a geometry note in CHANGELOG.md's Known issues rather than a
|
|
3292
|
+
behaviour change.
|
|
3293
|
+
Deliberately NOT a z-index change: the toolbar comment above
|
|
3294
|
+
("z-index sits ... below .ed-conflict (999): a save conflict must
|
|
3295
|
+
always be reachable") still holds — the banner stays the topmost
|
|
3296
|
+
thing, it just no longer shares the toolbar's own band to be on top
|
|
3297
|
+
OF. showBanner() additionally closes .ed-te-grip / .ed-tb-insert /
|
|
3298
|
+
.ed-te-menu / .ed-toolbar-menu — four dynamic fixed overlays that can
|
|
3299
|
+
each land in that band, though (per the correction above) only
|
|
3300
|
+
.ed-te-menu's own code floors there via --ed-toolbar-h; the others
|
|
3301
|
+
land nearby as a side effect of their own trigger's position, not a
|
|
3302
|
+
shared clamp — see showBanner()'s parallel comment in
|
|
3303
|
+
lib/editor/client.js. Closed regardless, so a banner can never land on
|
|
3304
|
+
top of one of those instead. */
|
|
2654
3305
|
.ed-conflict {
|
|
2655
|
-
position: fixed; top:
|
|
3306
|
+
position: fixed; top: var(--ed-toolbar-h); left: 0; right: 0; padding: 10px;
|
|
2656
3307
|
background: #b00020; color: #fff; z-index: 999; text-align: center;
|
|
2657
3308
|
display: flex; align-items: center; justify-content: center; gap: 12px;
|
|
2658
3309
|
}
|
|
@@ -3246,8 +3897,10 @@ ${mermaidInitTag}`)
|
|
|
3246
3897
|
function setSidebarOpen(open) {
|
|
3247
3898
|
if (open) {
|
|
3248
3899
|
document.body.setAttribute('data-sidebar-open', '');
|
|
3900
|
+
document.documentElement.setAttribute('data-sidebar-open', '');
|
|
3249
3901
|
} else {
|
|
3250
3902
|
document.body.removeAttribute('data-sidebar-open');
|
|
3903
|
+
document.documentElement.removeAttribute('data-sidebar-open');
|
|
3251
3904
|
}
|
|
3252
3905
|
if (sidebarToggle) sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
3253
3906
|
}
|
|
@@ -3414,11 +4067,62 @@ ${mermaidInitTag}`)
|
|
|
3414
4067
|
|
|
3415
4068
|
captureScrollAnchor();
|
|
3416
4069
|
|
|
4070
|
+
// ── drawio sheet bar ─────────────────────────────────────────────────────
|
|
4071
|
+
// v3.4.0 §4: switching pages is a pure hidden-attribute toggle. Every page was
|
|
4072
|
+
// rendered to inert SVG at build time (lib/drawio.js's bake pass), so there
|
|
4073
|
+
// is no engine, no diagrams.net script, and no network request here — this
|
|
4074
|
+
// works exactly the same with the network unplugged. Only injected when
|
|
4075
|
+
// usesDrawioMultiPage: a single-page .drawio file must not pay for this UI
|
|
4076
|
+
// at all (matching CSS gate is in the <style> block above).
|
|
4077
|
+
${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusable hook instead of a
|
|
4078
|
+
// bare one-shot forEach, so the edit-mode client runtime can re-invoke it
|
|
4079
|
+
// — scoped to just one freshly swapped-in block — after a background
|
|
4080
|
+
// refresh re-bakes a \`.drawio\`/\`.xml\` source that changed on disk (see
|
|
4081
|
+
// lib/editor/client.js's stale-drawio handler). Still gated on
|
|
4082
|
+
// usesDrawioMultiPage exactly as before: a single-page document must never
|
|
4083
|
+
// pay for (or even mention) this UI at all — a guard test asserts the
|
|
4084
|
+
// whole feature's class name is absent byte-for-byte from that output, so
|
|
4085
|
+
// nothing below this line may ever run, or exist as source text, outside
|
|
4086
|
+
// this branch.
|
|
4087
|
+
window.__md2docInitDrawioSheetBar = function (rootEl) {
|
|
4088
|
+
var scope = rootEl || document;
|
|
4089
|
+
scope.querySelectorAll('.drawio[data-drawio-pages]').forEach(function (box) {
|
|
4090
|
+
if (box.querySelector('.drawio-sheetbar')) return; // already wired
|
|
4091
|
+
var names;
|
|
4092
|
+
try { names = JSON.parse(atob(box.getAttribute('data-drawio-pages') || '')); }
|
|
4093
|
+
catch (e) { return; }
|
|
4094
|
+
if (!Array.isArray(names)) return;
|
|
4095
|
+
if (names.length < 2) return;
|
|
4096
|
+
var pages = box.querySelectorAll('.drawio-page');
|
|
4097
|
+
var bar = document.createElement('div');
|
|
4098
|
+
bar.className = 'drawio-sheetbar';
|
|
4099
|
+
names.forEach(function (name, i) {
|
|
4100
|
+
var b = document.createElement('button');
|
|
4101
|
+
b.type = 'button';
|
|
4102
|
+
b.textContent = name || String(i + 1);
|
|
4103
|
+
b.setAttribute('aria-pressed', pages[i] && !pages[i].hidden ? 'true' : 'false');
|
|
4104
|
+
b.addEventListener('click', function (event) {
|
|
4105
|
+
// The bar lives inside .drawio, which is also a lightbox click
|
|
4106
|
+
// target below (LIGHTBOX_TARGETS) — without stopPropagation a page
|
|
4107
|
+
// switch would also bubble up and pop the lightbox open.
|
|
4108
|
+
event.stopPropagation();
|
|
4109
|
+
for (var j = 0; j < pages.length; j++) pages[j].hidden = j !== i;
|
|
4110
|
+
bar.querySelectorAll('button').forEach(function (x, j) {
|
|
4111
|
+
x.setAttribute('aria-pressed', j === i ? 'true' : 'false');
|
|
4112
|
+
});
|
|
4113
|
+
});
|
|
4114
|
+
bar.appendChild(b);
|
|
4115
|
+
});
|
|
4116
|
+
box.appendChild(bar);
|
|
4117
|
+
});
|
|
4118
|
+
};
|
|
4119
|
+
window.__md2docInitDrawioSheetBar(document);
|
|
4120
|
+
` : ''}
|
|
3417
4121
|
// ── Diagram / image lightbox ──────────────────────────────────────────────
|
|
3418
4122
|
// Specs are read at 100% but their block diagrams and waveforms are drawn far
|
|
3419
4123
|
// wider than the column, so the inline copy is unreadably small. Clicking one
|
|
3420
4124
|
// pops it into a modal stage that zooms and scrolls.
|
|
3421
|
-
var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"], .wavedrom-diagram';
|
|
4125
|
+
var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, .drawio, [id^="WaveDrom_Display_"], .wavedrom-diagram';
|
|
3422
4126
|
var LIGHTBOX_MIN_ZOOM = 0.05;
|
|
3423
4127
|
var LIGHTBOX_MAX_ZOOM = 8;
|
|
3424
4128
|
var LIGHTBOX_STEP = 1.25;
|
|
@@ -4156,6 +4860,15 @@ ${mermaidInitTag}`)
|
|
|
4156
4860
|
|
|
4157
4861
|
function lightboxSourceOf(node) {
|
|
4158
4862
|
if (node.tagName === 'IMG') return node;
|
|
4863
|
+
// A multi-page .drawio bakes every page's <svg> into the DOM (hidden
|
|
4864
|
+
// ones included, so page switching needs no engine) — a plain
|
|
4865
|
+
// querySelector('svg') returns the FIRST one in document order, which is
|
|
4866
|
+
// wrong whenever page 1 isn't the one currently shown. Pick the visible
|
|
4867
|
+
// page's svg instead.
|
|
4868
|
+
if (node.classList && node.classList.contains('drawio')) {
|
|
4869
|
+
var visiblePage = node.querySelector('.drawio-page:not([hidden])');
|
|
4870
|
+
if (visiblePage) return visiblePage.querySelector('svg') || node;
|
|
4871
|
+
}
|
|
4159
4872
|
return node.querySelector('svg') || node;
|
|
4160
4873
|
}
|
|
4161
4874
|
|
|
@@ -4206,6 +4919,7 @@ ${mermaidInitTag}`)
|
|
|
4206
4919
|
lightboxReturnScroll = window.scrollY;
|
|
4207
4920
|
suppressAnchorCapture = true;
|
|
4208
4921
|
document.body.setAttribute('data-lightbox-open', '');
|
|
4922
|
+
document.documentElement.setAttribute('data-lightbox-open', '');
|
|
4209
4923
|
lightboxEl.hidden = false;
|
|
4210
4924
|
lightboxStage.scrollTop = 0;
|
|
4211
4925
|
lightboxStage.scrollLeft = 0;
|
|
@@ -4218,6 +4932,7 @@ ${mermaidInitTag}`)
|
|
|
4218
4932
|
if (!lightboxIsOpen()) return;
|
|
4219
4933
|
lightboxEl.hidden = true;
|
|
4220
4934
|
document.body.removeAttribute('data-lightbox-open');
|
|
4935
|
+
document.documentElement.removeAttribute('data-lightbox-open');
|
|
4221
4936
|
annoCancelDraft();
|
|
4222
4937
|
annoSyncInline();
|
|
4223
4938
|
annoSourceNode = null;
|
|
@@ -4289,12 +5004,34 @@ ${mermaidInitTag}`)
|
|
|
4289
5004
|
</body>
|
|
4290
5005
|
</html>`;
|
|
4291
5006
|
|
|
4292
|
-
const
|
|
5007
|
+
const gvHtml = await bakeGraphviz(html);
|
|
4293
5008
|
// editMode's bodyHtml is already baked per-part above; re-baking here is a
|
|
4294
5009
|
// short-circuit no-op (bakeGraphviz bails out when there's no
|
|
4295
5010
|
// data-graphviz-src left). Non-editMode bodyHtml is baked here as before.
|
|
4296
|
-
const
|
|
4297
|
-
|
|
5011
|
+
const gvBody = await bakeGraphviz(bodyHtml);
|
|
5012
|
+
// v3.4.0 §4: same short-circuit story for drawio. editMode already baked
|
|
5013
|
+
// every part above, so both calls below are no-ops there (no browser is
|
|
5014
|
+
// opened) — this is only load-bearing for the non-editMode
|
|
5015
|
+
// `bodyHtml = marked.parse(mdPre)` path, which never went through the
|
|
5016
|
+
// per-part loop. `html` and `bodyHtml` share the same placeholders (html
|
|
5017
|
+
// embeds bodyHtml), so both bakes share one lazily-opened browser instead
|
|
5018
|
+
// of paying for two.
|
|
5019
|
+
const drawioBrowser = makeLazyBrowserRef();
|
|
5020
|
+
let bakedHtml, bakedBody;
|
|
5021
|
+
try {
|
|
5022
|
+
bakedHtml = await bakeDrawio(gvHtml, drawioBrowser);
|
|
5023
|
+
bakedBody = await bakeDrawio(gvBody, drawioBrowser);
|
|
5024
|
+
} finally {
|
|
5025
|
+
await drawioBrowser.closeIfOpen();
|
|
5026
|
+
}
|
|
5027
|
+
return {
|
|
5028
|
+
html: bakedHtml, bodyHtml: bakedBody, parts, blocks,
|
|
5029
|
+
// v3.4.0 batch2 Task 6: an added field, not a shape change to the four
|
|
5030
|
+
// existing ones — every call site destructures the specific keys it
|
|
5031
|
+
// wants (checked across lib/editor/server.js and test/*.js), none reads
|
|
5032
|
+
// the object as a whole, so an addition here is backward compatible.
|
|
5033
|
+
drawioRefs: Array.from(drawioRefs, ([p, stamp]) => ({ path: p, stamp })),
|
|
5034
|
+
};
|
|
4298
5035
|
}
|
|
4299
5036
|
module.exports = { renderMarkdown };
|
|
4300
5037
|
|
|
@@ -4302,7 +5039,7 @@ module.exports = { renderMarkdown };
|
|
|
4302
5039
|
// in-process WASM engine. Loads the WASM module only when at least one dot
|
|
4303
5040
|
// block exists, so text-only docs pay nothing.
|
|
4304
5041
|
async function bakeGraphviz(htmlStr) {
|
|
4305
|
-
if (
|
|
5042
|
+
if (!GRAPHVIZ_PLACEHOLDER.probe.test(htmlStr)) return htmlStr;
|
|
4306
5043
|
let Graphviz;
|
|
4307
5044
|
try {
|
|
4308
5045
|
({ Graphviz } = require('@hpcc-js/wasm-graphviz'));
|
|
@@ -4311,7 +5048,7 @@ async function bakeGraphviz(htmlStr) {
|
|
|
4311
5048
|
return htmlStr;
|
|
4312
5049
|
}
|
|
4313
5050
|
const gv = await Graphviz.load();
|
|
4314
|
-
return htmlStr.replace(
|
|
5051
|
+
return htmlStr.replace(GRAPHVIZ_PLACEHOLDER.re, (m, b64) => {
|
|
4315
5052
|
const dotSrc = Buffer.from(b64, 'base64').toString('utf8');
|
|
4316
5053
|
try {
|
|
4317
5054
|
const svg = gv.dot(dotSrc)
|
|
@@ -4336,6 +5073,111 @@ function launchBrowser(puppeteer) {
|
|
|
4336
5073
|
});
|
|
4337
5074
|
}
|
|
4338
5075
|
|
|
5076
|
+
// v3.4.0 §4. A document with no drawio reference must not pay for opening
|
|
5077
|
+
// Chromium. `get()` launches on its first call only (and every later caller
|
|
5078
|
+
// awaits the same promise, so a document with several drawio placeholders
|
|
5079
|
+
// still opens exactly one browser); `closeIfOpen()` closes it only if `get()`
|
|
5080
|
+
// was ever actually called.
|
|
5081
|
+
function makeLazyBrowserRef() {
|
|
5082
|
+
let browserPromise = null;
|
|
5083
|
+
return {
|
|
5084
|
+
get() {
|
|
5085
|
+
if (!browserPromise) {
|
|
5086
|
+
const puppeteer = require('puppeteer');
|
|
5087
|
+
browserPromise = launchBrowser(puppeteer);
|
|
5088
|
+
}
|
|
5089
|
+
return browserPromise;
|
|
5090
|
+
},
|
|
5091
|
+
async closeIfOpen() {
|
|
5092
|
+
if (!browserPromise) return;
|
|
5093
|
+
const browser = await browserPromise;
|
|
5094
|
+
await browser.close();
|
|
5095
|
+
},
|
|
5096
|
+
};
|
|
5097
|
+
}
|
|
5098
|
+
|
|
5099
|
+
// Render every deferred drawio placeholder to inline SVG using a headless
|
|
5100
|
+
// Chromium running the vendored drawio viewer (lib/drawio.js). Every page is
|
|
5101
|
+
// baked, not just the one selected by the markdown fragment (`#PageName`),
|
|
5102
|
+
// so switching pages client-side is a pure `hidden`-attribute toggle with no
|
|
5103
|
+
// engine involved — see lib/drawio.js's own comment on why a Node-side XML
|
|
5104
|
+
// parser can't substitute for the real layout engine.
|
|
5105
|
+
async function bakeDrawio(htmlStr, browserRef) {
|
|
5106
|
+
if (!DRAWIO_PLACEHOLDER.probe.test(htmlStr)) return htmlStr;
|
|
5107
|
+
|
|
5108
|
+
// replace()'s callback can't be async, so collect the jobs first and apply
|
|
5109
|
+
// them (each with its own possible browser-page await) afterwards.
|
|
5110
|
+
const jobs = [];
|
|
5111
|
+
htmlStr.replace(DRAWIO_PLACEHOLDER.re, (m, b64, pageIdx, altB64) => {
|
|
5112
|
+
jobs.push({
|
|
5113
|
+
m,
|
|
5114
|
+
b64,
|
|
5115
|
+
pageIdx: Number(pageIdx),
|
|
5116
|
+
alt: Buffer.from(altB64, 'base64').toString('utf8'),
|
|
5117
|
+
});
|
|
5118
|
+
return m;
|
|
5119
|
+
});
|
|
5120
|
+
|
|
5121
|
+
for (const job of jobs) {
|
|
5122
|
+
const xml = Buffer.from(job.b64, 'base64').toString('utf8');
|
|
5123
|
+
const names = drawio.pageNamesOf(xml);
|
|
5124
|
+
let svgs;
|
|
5125
|
+
try {
|
|
5126
|
+
// One document shares one browser: browserRef.get() only really opens
|
|
5127
|
+
// Chromium on its first call.
|
|
5128
|
+
svgs = await drawio.bakeDrawioSvg(xml, { browser: await browserRef.get() });
|
|
5129
|
+
} catch (e) {
|
|
5130
|
+
// page.evaluate() can reject with a plain string rather than an Error
|
|
5131
|
+
// (observed: puppeteer surfacing a serialization failure as "buffer
|
|
5132
|
+
// error") — e.message would silently read as "undefined" then.
|
|
5133
|
+
const reason = (e && e.message) ? e.message : String(e);
|
|
5134
|
+
console.error('[WARN] drawio render failed:', reason);
|
|
5135
|
+
// A silent blank is the most expensive failure this project has learned
|
|
5136
|
+
// from (v3.3.0) — leave a visible placeholder naming the error instead.
|
|
5137
|
+
const msg = ('drawio 圖無法渲染:' + reason)
|
|
5138
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
5139
|
+
// The callback form, NOT a replacement string: `msg` is generated
|
|
5140
|
+
// content (a renderer error message), and String.replace() interprets
|
|
5141
|
+
// $$ / $& / $` / $' inside a replacement STRING even when the pattern
|
|
5142
|
+
// is a plain string. A callback's return value is never re-scanned.
|
|
5143
|
+
// See the same note on the success path below.
|
|
5144
|
+
htmlStr = htmlStr.replace(job.m,
|
|
5145
|
+
() => `<div class="drawio drawio-failed"><p>${msg}</p></div>`);
|
|
5146
|
+
continue;
|
|
5147
|
+
}
|
|
5148
|
+
const inner = svgs.map((svg, i) =>
|
|
5149
|
+
`<div class="drawio-page"${i === job.pageIdx ? '' : ' hidden'}>${svg}</div>`).join('');
|
|
5150
|
+
// Page names may contain any character (commas and quotes included), so
|
|
5151
|
+
// they travel as base64-encoded JSON rather than a delimited string — a
|
|
5152
|
+
// delimiter breaks on both "two names glued together" and "a name that
|
|
5153
|
+
// happens to contain the delimiter", and a literal control character
|
|
5154
|
+
// living in the document is invisible to boot.
|
|
5155
|
+
const attr = Buffer.from(JSON.stringify(names), 'utf8').toString('base64');
|
|
5156
|
+
// v3.4.0 batch2 final review R5 — the author already wrote a name in
|
|
5157
|
+
// ``; give the baked box that name.
|
|
5158
|
+
// The role differs by page count on purpose: a multi-page box has the
|
|
5159
|
+
// sheet bar's <button>s appended INSIDE it at runtime, and role="img"
|
|
5160
|
+
// would take them off the accessibility tree entirely, so those boxes get
|
|
5161
|
+
// role="group" — also nameable, children still exposed. No alt text means
|
|
5162
|
+
// no attributes at all: an empty accessible name is worse than none.
|
|
5163
|
+
const named = job.alt
|
|
5164
|
+
? ` role="${names.length >= 2 ? 'group' : 'img'}" aria-label="${job.alt}"`
|
|
5165
|
+
: '';
|
|
5166
|
+
// v3.4.0 batch2 final review B2 — the callback form is load-bearing.
|
|
5167
|
+
// `inner` is the baked SVG, i.e. arbitrary diagram text, and a
|
|
5168
|
+
// replacement STRING has $$ / $& / $` / $' interpreted in it even when
|
|
5169
|
+
// the pattern is a plain string: a `$$` label silently collapsed to a
|
|
5170
|
+
// single `$`, and a `$&` label (the byte sequence any label with `$`
|
|
5171
|
+
// before `&` produces once the SVG escapes the `&`) spliced this very
|
|
5172
|
+
// placeholder — the whole .drawio file's base64 — into the middle of a
|
|
5173
|
+
// <text> element. bakeGraphviz() above was always a callback; this is the
|
|
5174
|
+
// same rule. Keep it a callback.
|
|
5175
|
+
htmlStr = htmlStr.replace(job.m,
|
|
5176
|
+
() => `<div class="drawio" data-drawio-pages="${attr}"${named}>${inner}</div>`);
|
|
5177
|
+
}
|
|
5178
|
+
return htmlStr;
|
|
5179
|
+
}
|
|
5180
|
+
|
|
4339
5181
|
// Pre-render mermaid/wavedrom to inert SVG using headless Chromium, then strip
|
|
4340
5182
|
// the diagram-engine runtime scripts so the output HTML carries no diagram JS.
|
|
4341
5183
|
async function bakeDiagrams(htmlStr, dstPath) {
|