@helping-ai-workflow/md2doc 3.12.1 → 3.13.1

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/lib/md2doc.js CHANGED
@@ -23,6 +23,8 @@ const path = require('path');
23
23
  const drawio = require('./drawio.js');
24
24
  const { applyReaderTheme } = require('./theme/tokens');
25
25
  const { highlightCode } = require('./highlight');
26
+ const waveCodec = require('./editor/wave-codec.js');
27
+ const waveOverflow = require('./wave-overflow.js');
26
28
 
27
29
  // How long to let client-side WaveDrom / Mermaid scripts render before we
28
30
  // snapshot the DOM (PDF print, or --bake-svg inert-SVG bake).
@@ -183,6 +185,9 @@ function buildKatexStyleTag() {
183
185
  // it per-call would grow the registry unboundedly in a long-lived process
184
186
  // (the editor server calls renderMarkdown() once per edit).
185
187
  let marked, Renderer;
188
+ // v3.13.1 (T9b): set by renderMarkdown() around its synchronous parse — see the
189
+ // blockKatex renderer below. Null at every other time.
190
+ let blockMathText = null;
186
191
  try {
187
192
  ({ marked, Renderer } = require('marked'));
188
193
 
@@ -230,6 +235,14 @@ try {
230
235
  // default no-space-adjacency rules keep prose currency ($5 to $10) unrendered.
231
236
  const markedKatex = require('marked-katex-extension');
232
237
  marked.use(markedKatex({ throwOnError: false }));
238
+ // v3.13.1 (T9b): a $$ block renders through the extension's own renderer,
239
+ // which the per-call renderer overrides never reach, so the search index
240
+ // would miss it. This one hands its TeX to the render in progress, then
241
+ // returns false so the extension's renderer still draws the formula.
242
+ marked.use({ extensions: [{ name: 'blockKatex', renderer(token) {
243
+ if (blockMathText) blockMathText(token.text);
244
+ return false;
245
+ } }] });
233
246
  } catch (e) {
234
247
  console.error('[ERROR] marked not found — install with: npm install marked');
235
248
  console.error(e.message);
@@ -333,7 +346,10 @@ function renderEditModeList(listToken, blocks, biRef, out) {
333
346
  let inner;
334
347
  if (!item.loose && ownTokens.length === 1 && ownTokens[0].type === 'text') {
335
348
  // Tight item with a single text token: use static parseInline to get
336
- // clean inline HTML without a <p> wrapper.
349
+ // clean inline HTML without a <p> wrapper. parseInline never reaches the
350
+ // block renderers, so the item's text is indexed for search here
351
+ // (v3.13.1 T9b; biRef.indexInline is set by renderMarkdown).
352
+ if (biRef.indexInline) biRef.indexInline(ownTokens[0].tokens);
337
353
  inner = marked.Parser.parseInline(ownTokens[0].tokens);
338
354
  } else {
339
355
  // Loose item or non-standard own-token shape: use marked.parser, which
@@ -343,7 +359,10 @@ function renderEditModeList(listToken, blocks, biRef, out) {
343
359
  // marked pretty-prints a trailing "\n" after the closing tag, which is
344
360
  // insignificant to every serializer (list-md.js drops it via isBlankText)
345
361
  // but would render as a phantom blank line inside the item.
346
- inner = marked.parser(ownTokens).trim();
362
+ // biRef.parseItem (renderMarkdown) is marked.parser with the search
363
+ // index told that a text run here is wrapped in a made-up paragraph,
364
+ // as in a loose item, so each item is indexed once (v3.13.1 T9b).
365
+ inner = (biRef.parseItem ? biRef.parseItem(ownTokens) : marked.parser(ownTokens)).trim();
347
366
  }
348
367
  // The receiving half of list-md.js's SETEXT ESCAPE (see
349
368
  // escapeSetextHazard() there): an empty list item that is the first child
@@ -730,21 +749,27 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
730
749
  const sections = [];
731
750
  let currentSection = null;
732
751
 
733
- function startSection({ id, depth, text }) {
752
+ // The search index: one string per section, built from the text the
753
+ // reader sees, the way the in-text marks read it (highlightFirstOccurrence
754
+ // in the reader runtime). Both sides must agree, or the results list finds
755
+ // a phrase the reader cannot mark.
756
+ function startSection({ id, depth, text, search }) {
734
757
  currentSection = {
735
758
  id,
736
759
  depth,
737
760
  title: text,
738
- searchTextParts: [text],
761
+ searchTextParts: [search === undefined ? text : search.replace(/\s+/g, ' ').trim()],
739
762
  };
740
763
  sections.push(currentSection);
741
764
  }
742
765
 
766
+ // Takes plain text (sectionInlineText, code, TeX): no tag stripping here,
767
+ // so code such as "if (a<b && c>d)" is indexed as written.
743
768
  function appendSectionText(value) {
744
769
  if (!currentSection || !value) {
745
770
  return;
746
771
  }
747
- const clean = stripHtmlTags(value).replace(/\s+/g, ' ').trim();
772
+ const clean = String(value).replace(/\s+/g, ' ').trim();
748
773
  if (clean) {
749
774
  currentSection.searchTextParts.push(clean);
750
775
  }
@@ -756,13 +781,37 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
756
781
  }
757
782
  for (const cell of cells) {
758
783
  if (cell && Array.isArray(cell.tokens)) {
759
- appendSectionText(flattenTokenText(cell.tokens));
784
+ appendSectionText(sectionInlineText(cell.tokens));
760
785
  } else if (cell && typeof cell.text === 'string') {
761
- appendSectionText(cell.text);
786
+ appendSectionText(stripHtmlTags(cell.text));
762
787
  }
763
788
  }
764
789
  }
765
790
 
791
+ // v3.13.1 (T9b): inline tokens as the reader sees them, for the search
792
+ // index. Inline math is its TeX source (the reader runtime reads a formula
793
+ // from its TeX annotation); a hard break or a literal <br> is a space (the
794
+ // runtime reads a br element as one); other inline html carries no text;
795
+ // an image's alt text is not on the page. A text token's text is escaped
796
+ // by marked, so a tag in it is real markup (the checkbox a loose task item
797
+ // gets) and is stripped before the entities are decoded.
798
+ function sectionInlineText(tokens) {
799
+ if (!Array.isArray(tokens)) {
800
+ return '';
801
+ }
802
+ return tokens
803
+ .map((item) => {
804
+ if (item.type === 'br') return ' ';
805
+ if (item.type === 'html') return /^<br\b/i.test(item.text || '') ? ' ' : '';
806
+ if (item.type === 'image') return '';
807
+ if (item.type === 'inlineKatex') return item.text || '';
808
+ if (item.type === 'codespan') return unescapeHtml(item.text || '');
809
+ if (item.tokens) return sectionInlineText(item.tokens);
810
+ return unescapeHtml(stripHtmlTags(String(item.text || '').replace(/<br\b[^>]*>/gi, ' ')));
811
+ })
812
+ .join('');
813
+ }
814
+
766
815
  function buildTocTree(items) {
767
816
  const root = [];
768
817
  const stack = [{ depth: 0, children: root }];
@@ -976,9 +1025,64 @@ ${itemsHtml}
976
1025
  return out + '>';
977
1026
  };
978
1027
 
1028
+ // R7 甲 (v3.13.1): a block that is nothing but one or more native <svg>
1029
+ // elements gets the class inline-svg, so dark mode can put it on a white
1030
+ // plate like images and draw.io. Decided on the block's own source, not
1031
+ // its tokens: marked lexes '<svg><text>x</text></svg>' on one line as a
1032
+ // paragraph of html,html,text,html,html tokens, and '<svg ...>' alone on
1033
+ // its first line as one block html token. Outside the svg elements only
1034
+ // whitespace may appear, so an svg inside prose is never wrapped. Reader
1035
+ // output only: edit mode keeps its block DOM exactly as it was.
1036
+ // Not covered: an svg in a tight list item or a table cell reaches the
1037
+ // renderer as inline tokens with no block of its own, so it gets no plate.
1038
+ // HTML comments are dropped before any <svg> is looked at: '<!-- <svg> -->'
1039
+ // opens nothing.
1040
+ function noComments(src) { return String(src || '').replace(/<!--[\s\S]*?-->/g, ''); }
1041
+ function isSvgOnly(src) {
1042
+ const s = noComments(src).trim();
1043
+ if (!/^<svg[\s>]/i.test(s) || !/<\/svg>$/i.test(s)) return false;
1044
+ const tag = /<(\/?)svg\b[^>]*?(\/?)>/gi;
1045
+ let depth = 0, last = 0, m;
1046
+ while ((m = tag.exec(s)) !== null) {
1047
+ if (depth === 0 && s.slice(last, m.index).trim()) return false;
1048
+ if (m[1]) depth--; else if (!m[2]) depth++;
1049
+ if (depth < 0) return false;
1050
+ last = tag.lastIndex;
1051
+ }
1052
+ return depth === 0 && !s.slice(last).trim();
1053
+ }
1054
+ // An outer <svg> that a blank line split into several html blocks is still
1055
+ // open while its later blocks render; a <div> there would close it early, so
1056
+ // no html block is wrapped until it closes. Only raw html blocks can keep an
1057
+ // svg open: a paragraph's <p> and </p> end any open svg in the browser's
1058
+ // parser (measured in Chromium and WebKit), so a paragraph always resets the
1059
+ // count, and text in a code span or an svg left unclosed inside a paragraph
1060
+ // never holds later blocks hostage. Counted per block, in document order.
1061
+ let openSvg = 0;
1062
+ function svgNet(src) {
1063
+ const tag = /<(\/?)svg\b[^>]*?(\/?)>/gi;
1064
+ let n = 0, m;
1065
+ const s = noComments(src);
1066
+ while ((m = tag.exec(s)) !== null) { if (m[1]) n--; else if (!m[2]) n++; }
1067
+ return n;
1068
+ }
1069
+ function wrapsSvg(src) {
1070
+ const wrap = openSvg === 0 && isSvgOnly(src);
1071
+ openSvg = Math.max(0, openSvg + svgNet(src));
1072
+ return wrap;
1073
+ }
1074
+ function wrapsSvgParagraph(src) {
1075
+ openSvg = 0;
1076
+ return isSvgOnly(src);
1077
+ }
1078
+
979
1079
  const baseHtml = renderer.html.bind(renderer);
980
1080
  renderer.html = function(token) {
981
- return inlineImagesInHtmlChunk(baseHtml.apply(this, arguments));
1081
+ const out = inlineImagesInHtmlChunk(baseHtml.apply(this, arguments));
1082
+ if (!opts.editMode && token && token.block && wrapsSvg(token.text)) {
1083
+ return '<div class="inline-svg">' + out.trim() + '</div>\n';
1084
+ }
1085
+ return out;
982
1086
  };
983
1087
 
984
1088
  // Spec §3.12 — a markdown HARD BREAK and a literal '<br>' the source spelled
@@ -1004,6 +1108,7 @@ ${itemsHtml}
1004
1108
  renderer.br = function() { return '<br data-hard-break="1">'; };
1005
1109
  }
1006
1110
 
1111
+ let waveBlockCount = 0;
1007
1112
  renderer.code = function(token) {
1008
1113
  // token is either a string (old API) or {text, lang} object (new API)
1009
1114
  const lang = (typeof token === 'object') ? (token.lang || '') : (arguments[1] || '');
@@ -1011,6 +1116,29 @@ ${itemsHtml}
1011
1116
 
1012
1117
  if (lang === 'wavedrom') {
1013
1118
  usesWaveDrom = true;
1119
+ waveBlockCount++;
1120
+ // CLI-only: the CLI passes its source path as opts.warnWaveOverflow;
1121
+ // library and --edit renders leave it unset and stay silent.
1122
+ // One line per diagram, never one per label (final review M1): the
1123
+ // per-label lines of one diagram each named their own scale, and
1124
+ // following the first ("set to 2") left the next one ("to 4") still
1125
+ // overflowing. The line names the count, the label that needs the
1126
+ // most, and the one scale that fits them all (suggestScale).
1127
+ if (opts.warnWaveOverflow) {
1128
+ try {
1129
+ const parsed = waveCodec.parseSource(code);
1130
+ const list = parsed.ok ? waveOverflow.overflowOf(parsed.doc) : [];
1131
+ if (list.length > 0) {
1132
+ let worst = list[0];
1133
+ for (const o of list) if (o.needScale > worst.needScale) worst = o;
1134
+ const named = '"' + worst.label + '" in ' + worst.name;
1135
+ const what = list.length === 1
1136
+ ? '1 data label is wider than its segment (' + named + ')'
1137
+ : list.length + ' data labels are wider than their segments (worst: ' + named + ')';
1138
+ console.error(`md2doc: ${opts.warnWaveOverflow}: wavedrom #${waveBlockCount}: ${what}; set config.hscale to ${waveOverflow.suggestScale(list)} (estimated)`);
1139
+ }
1140
+ } catch (e) { /* a warning must never break a render */ }
1141
+ }
1014
1142
  return `\n<script type="WaveDrom">\n${code}\n</script>\n`;
1015
1143
  }
1016
1144
  if (lang === 'mermaid') {
@@ -1022,6 +1150,8 @@ ${itemsHtml}
1022
1150
  return `\n<div class="mermaid">\n${escapeHtml(code)}\n</div>\n`;
1023
1151
  }
1024
1152
  if (lang === 'math') {
1153
+ // Indexed as its TeX, like inline and $$ math (T9b).
1154
+ appendSectionText(code);
1025
1155
  // KaTeX renders synchronously to static HTML (class="katex"); no client
1026
1156
  // runtime, no async post-pass. throwOnError:false degrades a bad formula
1027
1157
  // to red error text instead of crashing the whole render.
@@ -1042,7 +1172,9 @@ ${itemsHtml}
1042
1172
  }
1043
1173
  // Default: fenced code. v3.10.0 colours registered languages here, at
1044
1174
  // render time (lib/highlight.js); an unknown or missing language stays
1045
- // plain escaped text.
1175
+ // plain escaped text. v3.13.1 (T9b): indexed for search as written, so a
1176
+ // name that only a code block holds is found and marked.
1177
+ appendSectionText(code);
1046
1178
  const coloured = highlightCode(code, lang);
1047
1179
  // The info string goes into an attribute: escape it (an info string such
1048
1180
  // as x" onmouseover=… must stay text). Ordinary language names are unchanged.
@@ -1077,21 +1209,54 @@ ${itemsHtml}
1077
1209
  id: headingId,
1078
1210
  text: headingText,
1079
1211
  });
1080
- startSection({ depth, id: headingId, text: headingText });
1212
+ startSection({ depth, id: headingId, text: headingText, search: sectionInlineText(token.tokens) });
1081
1213
 
1082
1214
  return `<h${depth} id="${headingId}" class="heading-with-anchor" data-reader-heading data-reader-depth="${depth}">${headingHtml}<a class="heading-anchor" href="#${headingId}" aria-label="Link to this section">#</a></h${depth}>\n`;
1083
1215
  };
1084
1216
 
1217
+ // A list item's text is indexed as it renders, in reading order with its
1218
+ // block children: marked's parser hands each of the item's block-level
1219
+ // text tokens (the only text tokens that carry inline .tokens) to
1220
+ // renderer.text. In a loose item the parser then wraps that run of text in
1221
+ // a paragraph it makes up from the HTML it just rendered (a formula as
1222
+ // KaTeX markup); renderer.text arms a one-shot skip for that paragraph.
1223
+ // Real paragraph tokens (in a loose item, those after a nested list) are
1224
+ // indexed by renderer.paragraph as usual.
1225
+ let looseItemText = false;
1226
+ let skipMadeUpParagraph = false;
1227
+ const baseText = renderer.text.bind(renderer);
1228
+ renderer.text = function(token) {
1229
+ if (token && Array.isArray(token.tokens)) {
1230
+ appendSectionText(sectionInlineText(token.tokens));
1231
+ if (looseItemText) skipMadeUpParagraph = true;
1232
+ }
1233
+ return baseText(token);
1234
+ };
1085
1235
  const baseParagraph = renderer.paragraph.bind(renderer);
1086
1236
  renderer.paragraph = function(token) {
1087
- appendSectionText(flattenTokenText(token.tokens));
1237
+ if (skipMadeUpParagraph) {
1238
+ skipMadeUpParagraph = false;
1239
+ } else {
1240
+ appendSectionText(sectionInlineText(token.tokens));
1241
+ }
1242
+ // R7 甲: see isSvgOnly above renderer.html.
1243
+ if (!opts.editMode && wrapsSvgParagraph(token.raw)) {
1244
+ return baseParagraph(token).replace(/^<p>/, '<p class="inline-svg">');
1245
+ }
1088
1246
  return baseParagraph(token);
1089
1247
  };
1090
1248
 
1091
1249
  const baseListitem = renderer.listitem.bind(renderer);
1092
1250
  renderer.listitem = function(token) {
1093
- appendSectionText(flattenTokenText(token.tokens));
1094
- const html = baseListitem(token);
1251
+ // Indexes nothing itself: see renderer.text above.
1252
+ const outerLoose = looseItemText;
1253
+ looseItemText = !!token.loose;
1254
+ let html;
1255
+ try {
1256
+ html = baseListitem(token);
1257
+ } finally {
1258
+ looseItemText = outerLoose;
1259
+ }
1095
1260
  if (!token.task) return html;
1096
1261
  // v3.10.0: a task item drops its bullet and keeps its own text in a span,
1097
1262
  // so a checked item's strike-through stops at its own words — CSS
@@ -1107,8 +1272,14 @@ ${itemsHtml}
1107
1272
 
1108
1273
  const baseBlockquote = renderer.blockquote.bind(renderer);
1109
1274
  renderer.blockquote = function(token) {
1110
- appendSectionText(flattenTokenText(token.tokens));
1111
- return baseBlockquote(token);
1275
+ // Its paragraphs index themselves (indexing them here too doubled them).
1276
+ const outerLoose = looseItemText;
1277
+ looseItemText = false;
1278
+ try {
1279
+ return baseBlockquote(token);
1280
+ } finally {
1281
+ looseItemText = outerLoose;
1282
+ }
1112
1283
  };
1113
1284
 
1114
1285
  renderer.table = function(token) {
@@ -1179,6 +1350,8 @@ ${itemsHtml}
1179
1350
  return `<a href="#${escAttr(slug)}">[${escAttr(body)}]</a>`;
1180
1351
  });
1181
1352
 
1353
+ // A $$ block's TeX reaches the index through the blockKatex hook at the
1354
+ // top of the file, set only while this render's synchronous parse runs.
1182
1355
  if (opts.editMode) {
1183
1356
  // Ranges are computed on mdPre. The [[...]] preprocessing above only
1184
1357
  // replaces text within single lines and never adds/removes newlines,
@@ -1198,19 +1371,37 @@ ${itemsHtml}
1198
1371
  // flat list renderer above threads through its own recursion (see
1199
1372
  // liRunStartsHere()).
1200
1373
  const biRef = { v: 0, prevLi: null, types: [] };
1201
- for (const t of tokens) {
1202
- if (t.type === 'space') continue;
1203
- if (t.type === 'list') {
1204
- parts.push(...renderEditModeList(t, blocks, biRef));
1205
- continue;
1374
+ // The edit page searches this index too: renderEditModeList indexes each
1375
+ // item through these two, once, in the edit DOM's order (an item's own
1376
+ // block, then its nested lists).
1377
+ biRef.indexInline = (inlineTokens) => appendSectionText(sectionInlineText(inlineTokens));
1378
+ biRef.parseItem = (ownTokens) => {
1379
+ const outerLoose = looseItemText;
1380
+ looseItemText = true;
1381
+ try {
1382
+ return marked.parser(ownTokens);
1383
+ } finally {
1384
+ looseItemText = outerLoose;
1206
1385
  }
1207
- resetLiRunState(biRef); // §3.8 rule (c): a non-li block ends every run
1208
- const b = blocks[biRef.v++];
1209
- const inner = marked.parser([t]);
1210
- parts.push(
1211
- `<div class="ed-block" data-block-id="${b.id}" data-block-type="${b.type}">` +
1212
- inner + '</div>'
1213
- );
1386
+ };
1387
+ try {
1388
+ blockMathText = appendSectionText;
1389
+ for (const t of tokens) {
1390
+ if (t.type === 'space') continue;
1391
+ if (t.type === 'list') {
1392
+ parts.push(...renderEditModeList(t, blocks, biRef));
1393
+ continue;
1394
+ }
1395
+ resetLiRunState(biRef); // §3.8 rule (c): a non-li block ends every run
1396
+ const b = blocks[biRef.v++];
1397
+ const inner = marked.parser([t]);
1398
+ parts.push(
1399
+ `<div class="ed-block" data-block-id="${b.id}" data-block-type="${b.type}">` +
1400
+ inner + '</div>'
1401
+ );
1402
+ }
1403
+ } finally {
1404
+ blockMathText = null;
1214
1405
  }
1215
1406
  // Development-time alignment guard: the render walk and blockmap.js's
1216
1407
  // pushListItemBlocks must consume blocks[] in lockstep. A mismatch means
@@ -1244,7 +1435,12 @@ ${itemsHtml}
1244
1435
  }
1245
1436
  bodyHtml = parts.join('\n');
1246
1437
  } else {
1247
- bodyHtml = marked.parse(mdPre);
1438
+ try {
1439
+ blockMathText = appendSectionText;
1440
+ bodyHtml = marked.parse(mdPre);
1441
+ } finally {
1442
+ blockMathText = null;
1443
+ }
1248
1444
  }
1249
1445
  // Both the ```math fence and the $/$$ extension emit class="katex" — a single
1250
1446
  // post-parse scan is the source of truth for conditional CSS injection.
@@ -1260,6 +1456,11 @@ ${itemsHtml}
1260
1456
  ).replace(/</g, '\\u003c');
1261
1457
  if (tocItems.length > 0) {
1262
1458
  const tocTree = buildTocTree(tocItems);
1459
+ // v3.13.1 (R3): two or more H1s kept as the TOC's top level are
1460
+ // chapters; the marker lets CSS set each chapter apart with a rule and
1461
+ // a gap. A flattened single-H1 TOC (its H2s on top) never carries it.
1462
+ const tocRoots = flattenSingleRoot(tocTree);
1463
+ const tocChapters = tocRoots === tocTree && tocTree.filter((n) => n.depth === 1).length >= 2;
1263
1464
  tocHtml = `<aside class="reader-sidebar" data-reader-sidebar>
1264
1465
  <section class="reader-tools">
1265
1466
  <label class="reader-search-label" for="doc-search-input">Search</label>
@@ -1278,7 +1479,7 @@ ${itemsHtml}
1278
1479
  </div>
1279
1480
  <div id="search-results-list"></div>
1280
1481
  </section>
1281
- <nav class="toc" aria-label="Table of contents" data-reader-toc>
1482
+ <nav class="toc" aria-label="Table of contents" data-reader-toc${tocChapters ? ' data-toc-chapters' : ''}>
1282
1483
  <div class="toc-header">
1283
1484
  <span class="toc-title">Contents</span>
1284
1485
  <div class="toc-header-actions">
@@ -1288,7 +1489,7 @@ ${itemsHtml}
1288
1489
  </div>
1289
1490
  <div class="toc-breadcrumb" data-toc-breadcrumb aria-label="Current location"></div>
1290
1491
  </div>
1291
- ${renderTocNodes(flattenSingleRoot(tocTree))}
1492
+ ${renderTocNodes(tocRoots)}
1292
1493
  </nav>
1293
1494
  </aside>
1294
1495
  <div class="sidebar-splitter" id="sidebar-splitter" role="separator" aria-orientation="vertical" aria-label="Resize sidebar"></div>`;
@@ -1787,488 +1988,341 @@ ${edUlMarkerCss}${edOlCounterCss}
1787
1988
  position: fixed; inset: 0; z-index: 998; background: rgba(0,0,0,.45);
1788
1989
  display: flex; align-items: center; justify-content: center;
1789
1990
  }
1790
- /* v3.6.0 Task 13: width cap raised from 1200px to 1920px — MEASURED
1791
- (createEditorServer, four real viewports): at 1200px the cap itself
1792
- was the binding constraint at 1280/1600/1920 alike (94vw there is
1793
- 1203/1504/1805px, all above the old 1200px cap), so every screen from a
1794
- 1280 laptop up to a 1920 monitor got the IDENTICAL canvas width — the
1795
- exact complaint that opened this version ("a bigger monitor buys the
1796
- user nothing"). 1920px is chosen so 94vw is the actual governor at
1797
- every one of those four measured sizes (94% of 1920 is 1804.8, still
1798
- under the new cap) — this number is a ceiling for a screen wider than
1799
- that, not a value that binds within the measured range. At 800x600,
1800
- 94vw (752px) was already narrower than either cap, so that viewport is
1801
- unchanged by this. See task-13-report.md for the re-measured
1802
- toolbar/canvas table. */
1991
+ /* Wave redesign Task 6b (spec section 4.1): one column, top to bottom:
1992
+ title bar, toolbar, the notice card, the stage, the hint bar. The side
1993
+ rail is gone and the stage takes the whole width. The width cap of
1994
+ 1920px is v3.6.0 Task 13's: below it, 94vw governs at every measured
1995
+ screen size. position: relative because the toolbar's popovers are
1996
+ placed against the panel. */
1803
1997
  .ed-wave-panel {
1804
- background: #fff; border-radius: 6px; width: min(1920px, 94vw);
1998
+ position: relative; background: #fff; border-radius: 6px; width: min(1920px, 94vw);
1805
1999
  height: min(760px, 90vh); display: flex; flex-direction: column;
1806
2000
  box-shadow: 0 6px 28px rgba(0,0,0,.35); overflow: hidden;
1807
2001
  }
1808
2002
  .ed-wave-head {
1809
- display: flex; align-items: center; justify-content: space-between;
1810
- padding: 8px 12px; border-bottom: 1px solid #e2e2e2; font-weight: bold;
1811
- }
2003
+ display: flex; align-items: center; gap: 12px;
2004
+ padding: 8px 12px; border-bottom: 1px solid #e2e2e2;
2005
+ }
2006
+ .ed-wave-title { font-weight: bold; }
2007
+ /* The FILE's save state (spec section 4.8), the same two states and
2008
+ colours as the edit toolbar's own status slot. */
2009
+ .ed-wave-save { margin-left: auto; font-size: 12px; color: var(--md-ed-ok); }
2010
+ .ed-wave-save[data-dirty="1"] { color: var(--md-ed-dirty); }
1812
2011
  .ed-wave-close {
1813
- border: 1px solid #bbb; border-radius: 4px; background: #f6f6f6;
1814
- padding: 3px 12px; cursor: pointer;
1815
- }
1816
- /* Which way out keeps the work and which throws it away. Escape is a modal's
1817
- universal close key and here it discards a whole session, so the header
1818
- says so next to the button that does not. Normal weight against the bar's
1819
- bold heading: it is a caption, not a warning the user has to clear. */
1820
- .ed-wave-escape-hint {
1821
- margin-left: auto; margin-right: 12px; font-weight: normal;
1822
- font-size: 11px; color: #7a4b00;
2012
+ border: 0; border-radius: 4px; background: transparent; color: #555;
2013
+ width: 28px; height: 28px; font-size: 15px; line-height: 1; cursor: pointer;
1823
2014
  }
2015
+ .ed-wave-close:hover { background: var(--md-ed-hover); }
2016
+ /* Spec section 4.2: one row; groups are divided by a thin rule and 完成
2017
+ sits on the right. Buttons are quiet until hovered or switched on. */
1824
2018
  .ed-wave-toolbar {
1825
- display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
1826
- padding: 8px 12px; border-bottom: 1px solid #e2e2e2; background: #fafafa;
1827
- }
1828
- /* v3.6.0 Task 12: the eight captioned sections themselves. .ed-wave-tools
1829
- is the row that holds them (the SAME element as .ed-wave-toolbar
1830
- above -- that class still owns the bar's padding/border/background,
1831
- this one owns only the group row's own gap). Each .ed-wave-tool-group
1832
- is a bordered chip so eight adjacent sections read as eight things, not
1833
- one longer wall of buttons; the caption sits to its buttons' LEFT (see
1834
- wave-panels.js's toolGroup() for why not above). */
1835
- .ed-wave-tools { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
1836
- .ed-wave-tool-group {
1837
- display: flex; align-items: center; gap: 4px;
1838
- padding: 2px 6px; border: 1px solid #ececec; border-radius: 6px;
1839
- }
1840
- .ed-wave-tool-caption {
1841
- font-size: 10px; color: #8a8a8a; letter-spacing: .5px;
1842
- white-space: nowrap; margin-right: 2px;
1843
- }
1844
- .ed-wave-brushes, .ed-wave-meta {
1845
2019
  display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
2020
+ padding: 6px 12px; border-bottom: 1px solid #e2e2e2; background: #fafafa;
1846
2021
  }
1847
- /* v3.6.0 Task 10: widened from a 28x28 bare-character square to a small
1848
- column (glyph on top, the character kept as a caption underneath) so
1849
- the 22-brush palette says what each brush paints instead of relying on
1850
- the letter alone -- nine of those 22 (P, N, u, d, h, l, 2, 6-9) have no
1851
- obvious on-canvas picture from the character by itself. */
1852
- /* '.ed-wave-brushes .ed-wave-brush', not the bare class: '.ed-wave-toolbar
1853
- button' below outranks a single class on specificity (class+type beats
1854
- class alone) and would otherwise win the padding fight and squeeze the
1855
- 24px-wide icon into a content box narrower than it is. */
1856
- .ed-wave-brushes .ed-wave-brush {
1857
- width: 34px; height: 40px; border: 1px solid #c4c4c4; border-radius: 4px;
1858
- background: #fff; font-family: monospace; cursor: pointer;
1859
- display: flex; flex-direction: column; align-items: center;
1860
- justify-content: center; gap: 1px; padding: 2px 0; box-sizing: border-box;
1861
- }
1862
- .ed-wave-brush.is-on { background: #1a73e8; color: #fff; border-color: #1a73e8; }
1863
- .ed-wave-brush-char { font-size: 11px; line-height: 1; }
1864
- /* The glyph itself. Its shapes are drawn by wave-draw.js's levelIcon,
1865
- which reuses renderCanvas's own drawing functions (bandOf, brickPath,
1866
- the bus/clock/gap helpers) -- never a second hand-drawn set, so a
1867
- change to how a brush paints on the real canvas cannot leave the
1868
- toolbar showing the old picture. */
1869
- .ed-wave-level-icon { display: block; overflow: visible; }
1870
2022
  .ed-wave-toolbar button {
1871
- border: 1px solid #c4c4c4; border-radius: 4px; background: #fff;
1872
- padding: 3px 8px; font-size: 12px; cursor: pointer;
1873
- }
1874
- .ed-wave-toolbar button:disabled { opacity: .45; cursor: default; }
1875
- /* v3.5.0 Task 7: the 關聯線 arm button, armed or mid-drag. Same on/off
1876
- treatment as .ed-wave-brush.is-on above — a picked tool reads the same
1877
- way whichever toolbar it lives in. */
1878
- .ed-wave-edge-arm.is-on { background: #1a73e8; color: #fff; border-color: #1a73e8; }
1879
- /* v3.6.0 Task 13: the fixed 110px input width that used to live here moved
1880
- into .ed-wave-section-body input below — every .ed-wave-metafield
1881
- now lives inside a right-hand panel (關聯線/文件/Lane), not the toolbar,
1882
- and a panel field should fill the ~340px column rather than sit at a
1883
- width chosen for the old horizontal toolbar row. */
1884
- .ed-wave-metafield { display: flex; align-items: center; gap: 4px; font-size: 12px; }
1885
- /* v3.6.0 Task 13: the 關聯線 panel's own content, stacked vertically now
1886
- that it lives in a narrow side column rather than a wide flex-wrap
1887
- toolbar row (the old 'flex: 1 1 100%' here was what made it claim a
1888
- full row of THAT row; a panel body has no siblings to share a row
1889
- with). */
1890
- .ed-wave-edgebar { display: flex; flex-direction: column; gap: 4px; }
1891
- /* The "this drawing is not showing you everything" notice. period / phase /
1892
- config.hscale all change what the ENGINE paints and none of them is
1893
- modelled by the hand drawing, so a document carrying one says so instead
1894
- of drawing a confident wrong picture. Full row, so it cannot be mistaken
1895
- for another toolbar control. */
1896
- .ed-wave-unmodelled {
1897
- flex: 1 1 100%; font-size: 12px; color: #7a4b00; background: #fff6e0;
1898
- border: 1px solid #e8c987; border-radius: 4px; padding: 4px 8px;
1899
- }
1900
- /* The stronger of the two notices: not "this drawing is incomplete" but
1901
- "this block has nothing this editor can edit" (a reg: / assign: block).
1902
- Same shape as its neighbour so the two read as one family, one step
1903
- louder because the information gap is bigger. */
1904
- .ed-wave-nolanes { color: #7a1f00; background: #ffe9e0; border-color: #e8a987; }
1905
- /* v3.6.0 Task 13 fix round 1 (Critical 2): position: relative added.
1906
- .ed-wave-side[data-expanded] below is position: absolute, and neither
1907
- .ed-wave-panel nor (until this line) this element had a position of
1908
- their own — so its containing block was .ed-wave-overlay
1909
- (position: fixed; inset: 0), the WHOLE viewport, not the dialog.
1910
- .ed-wave-panel's own overflow: hidden did not clip it either: clipping
1911
- follows the containing-block chain, not DOM nesting, so the rail
1912
- rendered against viewport corners at a narrow window and covered
1913
- .ed-wave-head's 關閉 button — the only discoverable way to close the
1914
- dialog (a hit-test at 關閉's own rectangle returned the edge panel's
1915
- delete button instead; Escape still worked but was not discoverable).
1916
- Anchoring the rail to THIS element rather than to .ed-wave-panel itself
1917
- is deliberate, not incidental: .ed-wave-body sits below .ed-wave-head
1918
- and above .ed-wave-status, so top: 0 / bottom: 0 on the rail now lands
1919
- at body's own top/bottom — never over the header (關閉) or the status
1920
- line — without this rule having to know either one's height. */
1921
- .ed-wave-body { display: flex; flex: 1 1 auto; min-height: 0; position: relative; }
1922
- /* v3.6.0 Task 13: the old comment here hand-computed the canvas's leftover
1923
- width from the (now gone) .ed-wave-preview column's own flex-basis;
1924
- that arithmetic no longer applies — the third column is .ed-wave-side
1925
- below, which collapses to a 28px rail under 1100px specifically so the
1926
- canvas is not squeezed the way a fixed 160px minimum used to squeeze it
1927
- at 800x600 (MEASURED before this task: canvas width there was only
1928
- 282px). Re-measured after this task at four real viewports; see
1929
- task-13-report.md rather than a formula here — a number derived by hand
1930
- drifts the moment any sibling column's own width changes, which is
1931
- exactly what this task did. */
1932
- .ed-wave-lanes {
1933
- flex: 0 0 200px; border-right: 1px solid #e2e2e2; overflow: auto;
1934
- padding: 8px 0 8px 0;
1935
- }
1936
- /* The rows are absolutely positioned at index * laneHeight so they line up
1937
- with the drawing beside them cell for cell. A group title is NOT a row —
1938
- measured against the pinned engine in Task 2 — so it is drawn as a label in
1939
- the rail spanning its lanes' rows instead, which is the shape the engine
1940
- itself gives it (horizontal space in the name column, no vertical space). */
1941
- .ed-wave-lane-rows { position: relative; }
1942
- /* v3.6.0 Task 14: a row is a drag handle, a name field and (on a {}
1943
- spacer) a badge. It used to be six buttons plus the field, and the six
1944
- did not fit: flexed into what was left of 200px the field measured
1945
- 45px on the most crowded row, and a browser given a 24px box and a
1946
- three-character Chinese caption lays that caption out VERTICALLY — the
1947
- editor shipped columns of stacked glyphs where labels were meant.
1948
- Those six are toolbar buttons now (the 訊號 / 群組 sections), acting
1949
- on the one selection. */
1950
- .ed-wave-lane-row {
1951
- position: absolute; left: 0; right: 0; display: flex; align-items: center;
1952
- gap: 6px; padding-right: 6px; box-sizing: border-box;
1953
- }
1954
- .ed-wave-lane-row.is-selected { background: #eaf2fe; }
1955
- .ed-wave-lane-handle {
1956
- flex: 0 0 auto; cursor: move; color: #aaa; user-select: none; font-size: 13px;
1957
- }
1958
- .ed-wave-lane-handle:active { cursor: move; }
1959
- /* min-width, not just flex-grow: min-width is what a flex item will not
1960
- shrink past, and shrinking past it is the vertical-text failure above.
1961
- MEASURED after this change (real page, 800x600, the journey suite's own
1962
- wave fixture): rail 201px, handle 9.5px, badge 36px, and the field comes
1963
- out 178.5px ungrouped, 162.5px one group deep, 136.5px on the {} spacer
1964
- row that also carries the badge — writing-mode horizontal-tb on all six.
1965
- So 120 is slack at every depth this fixture reaches, NOT a computed
1966
- exact fit. Deeper nesting spends 16px a level and would eventually hit
1967
- the floor; there the field stops shrinking and .ed-wave-lanes scrolls,
1968
- which is the trade this rule chooses on purpose. */
1969
- .ed-wave-lane-name {
1970
- flex: 1 1 auto; min-width: 120px; font-size: 12px; padding: 2px 4px;
1971
- }
1972
- .ed-wave-lane-badge { flex: 0 0 auto; font-size: 10px; color: #888; }
1973
- .ed-wave-group, .ed-wave-group-input {
1974
- position: absolute; width: 14px; padding: 0; font-size: 11px;
1975
- writing-mode: vertical-rl; text-orientation: mixed; overflow: hidden;
1976
- border: 1px solid #d8d8d8; border-radius: 3px; background: #f2f2f2;
1977
- color: #555; cursor: pointer; box-sizing: border-box;
1978
- }
1979
- .ed-wave-group-input { width: 16px; height: 90px; cursor: text; }
1980
- /* position: relative so the data-label field below (v3.5.0 Task 11) has
1981
- an offsetParent to measure from: wave-ui.js reads canvas.offsetLeft /
1982
- offsetTop against THIS element, not a hard-coded copy of the padding
1983
- number just below, so the two cannot drift apart if that padding ever
1984
- changes. */
1985
- .ed-wave-canvas-wrap { flex: 1 1 auto; overflow: auto; padding: 8px; position: relative; }
1986
- .ed-wave-canvas { display: block; }
1987
- /* v3.5.0 Task 11: the bus data-label field, positioned by wave-ui.js's
1988
- openDataEdit() from geometry.cellRect() — left/top/width/height are all
1989
- set inline per cell, so only the visual skin lives here. Sized to sit
1990
- exactly over the box it labels, text centred to match .ed-wave-buslabel
1991
- beside it in the drawing underneath, box-sizing so the border does not
1992
- push the field past the cell it is meant to cover. */
1993
- .ed-wave-data-input {
1994
- position: absolute; z-index: 1; box-sizing: border-box; text-align: center;
1995
- font-size: 11px; padding: 0 2px; border: 1px solid #1a73e8; border-radius: 2px;
1996
- background: #fff; color: #222;
1997
- }
1998
- .ed-wave-grid { stroke: #ececec; stroke-width: 1; }
1999
- .ed-wave-line, .ed-wave-edge { stroke: #111; stroke-width: 2; fill: none; }
2000
- /* v3.6.0 Task 10: the toolbar glyph's plain-level backbone (0/1/h/l/z/u/d
2001
- only -- bus/clock/x/gap glyphs draw their own shape with their own
2002
- fixed canvas colour instead, further down). Declared after the rule
2003
- above on purpose: both are single-class selectors, so source order is
2004
- what lets currentColor win over the fixed #111 for this one case,
2005
- turning the icon white together with the rest of the button's text
2006
- under .ed-wave-brush.is-on. */
2023
+ border: 1px solid transparent; border-radius: 4px; background: transparent;
2024
+ color: #333; padding: 4px 8px; font-size: 13px; min-height: 32px; cursor: pointer;
2025
+ }
2026
+ .ed-wave-toolbar button:hover { background: var(--md-ed-hover); }
2027
+ .ed-wave-toolbar button:disabled { opacity: .45; cursor: default; background: transparent; }
2028
+ .ed-wave-sep { width: 1px; align-self: stretch; margin: 4px 4px; background: #e2e2e2; }
2029
+ .ed-wave-toolbar .ed-wave-done {
2030
+ margin-left: auto; background: #1a73e8; color: #fff; border-color: #1a73e8; padding: 4px 14px;
2031
+ }
2032
+ .ed-wave-toolbar .ed-wave-done:hover { background: #1a73e8; }
2033
+ /* A brush: the level's glyph with its keyboard character in the lower
2034
+ right corner (spec section 4.2). The glyph is wave-draw.js levelIcon,
2035
+ built from the same brick helpers that model the engine's bricks. The
2036
+ selector names the button type so it outranks the generic toolbar
2037
+ button rule above. */
2038
+ button.ed-wave-brush {
2039
+ position: relative; width: 38px; height: 34px; padding: 2px; box-sizing: border-box;
2040
+ border: 1px solid #c4c4c4; border-radius: 4px; background: #fff; font-family: monospace;
2041
+ display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
2042
+ }
2043
+ .ed-wave-brush-char {
2044
+ position: absolute; right: 2px; bottom: 1px; font-size: 9px; line-height: 1; color: #555;
2045
+ }
2046
+ .ed-wave-toolbar button.ed-wave-brush.is-on, .ed-wave-pop button.ed-wave-brush.is-on,
2047
+ .ed-wave-toolbar .ed-wave-brush-more.is-on, .ed-wave-toolbar .ed-wave-edge-arm.is-on {
2048
+ background: #1a73e8; color: #fff; border-color: #1a73e8;
2049
+ }
2050
+ .ed-wave-brush.is-on .ed-wave-brush-char { color: #fff; }
2051
+ .ed-wave-level-icon { display: block; overflow: visible; }
2052
+ /* The glyph's shapes: the plain-level backbone follows the button's text
2053
+ colour (so a picked brush turns white with its caption); bus, x, clock
2054
+ and gap glyphs carry their own canvas colours. */
2055
+ .ed-wave-line { stroke: #111; stroke-width: 2; fill: none; }
2007
2056
  .ed-wave-level-icon-path { stroke: currentColor; }
2008
2057
  .ed-wave-weak { stroke-dasharray: 4 3; }
2009
- /* v3.5.0 Task 10: the P/N explicit-edge arrowhead — see wave-ui.js's
2010
- drawLane isClock branch for the measurement behind its shape and
2011
- placement. Filled, no stroke, matching the line colour above. */
2012
2058
  .ed-wave-clock-arrow { fill: #111; stroke: none; }
2013
2059
  .ed-wave-x { fill: #d9d9d9; stroke: #111; stroke-width: 2; }
2014
2060
  .ed-wave-bus { fill: #f5e9c8; stroke: #111; stroke-width: 2; }
2015
2061
  .ed-wave-bus-3 { fill: #d8ead3; }
2016
2062
  .ed-wave-bus-4 { fill: #d6e4f7; }
2017
2063
  .ed-wave-bus-5 { fill: #f7d6e4; }
2018
- /* v3.5.0 Task 11 fix round 2: this text, not the box under it, is the
2019
- click target that opens the data-label field (wave-ui.js's onCanvasDown
2020
- gates on ev.target.classList.contains('ed-wave-buslabel') — pressing
2021
- anywhere else in the bus box still paints). pointer-events:
2022
- visiblePainted is the SVG initial value already, so this changes
2023
- nothing on its own; it is written out explicitly because that default
2024
- is now load-bearing for a real gesture, not incidental — a future
2025
- blanket pointer-events: none added upstream of this rule would
2026
- silently make the label unclickable again, and this line is what a
2027
- reviewer would have to notice and remove. */
2028
- .ed-wave-buslabel { font-size: 11px; fill: #222; pointer-events: visiblePainted; cursor: text; }
2029
- /* v3.5.0 Task 11 fix round 3: the hit target for an UNLABELLED bus run —
2030
- drawLane draws this circle in place of the text when the run has no
2031
- label, sharing the ed-wave-buslabel class above (same pointer-events,
2032
- same cursor: text) so onCanvasDown's hit test needed no change. Kept
2033
- visually quiet on purpose: dim and small, so a diagram full of
2034
- unlabelled buses reads as buses first and marks second, not as a field
2035
- of dots. Geometry (position, radius) is inline per cell, sized against
2036
- that cell's own box in drawLane so it always stays a small fraction of
2037
- it — this rule is skin only. */
2038
- .ed-wave-buslabel-empty { fill: #666; opacity: .35; }
2039
- /* v3.5.0 Task 11 fix round 4: this rect's job is to SHOW what cycle range
2040
- is selected, never to be pressed. pointer-events: none, so it can never
2041
- swallow a press meant for whatever it happens to be sitting over —
2042
- reviewer-measured, a bus label whose run is one cycle wide can sit
2043
- right up against this rect's own right edge (stroke included), and
2044
- without this a second press on that same label landed on the
2045
- selection's stroke instead of the label text underneath it, and the
2046
- data-label field never opened. Same principle as .ed-wave-cursor two
2047
- rules below, applied to the OTHER highlight this drawing draws. */
2048
- .ed-wave-selection {
2049
- fill: rgba(26,115,232,.18); stroke: #1a73e8; stroke-width: 1;
2050
- pointer-events: none;
2051
- }
2052
- /* The KEYBOARD's own cell, and deliberately not the same mark as the
2053
- selection: the selection is the RANGE a cycle operation acts on, the
2054
- cursor is the ONE cell the next keystroke paints, and after Shift+left /
2055
- right both are on screen at once. pointer-events: none so the rect can
2056
- never swallow the press the drag underneath it is made of. */
2057
- .ed-wave-cursor {
2058
- fill: none; stroke: #d93025; stroke-width: 2; pointer-events: none;
2059
- }
2060
- /* The drawing is in the Tab cycle, so it has to SAY when it holds the
2061
- keyboard: a cell cursor alone leaves a user who just Tabbed onto it with
2062
- nothing that distinguishes "the drawing has the keyboard" from "the
2063
- drawing is where I left a cursor before I walked away". */
2064
+ .ed-wave-gap { stroke: #111; stroke-width: 2; fill: none; }
2065
+ /* The one popover the toolbar has open at a time (wave-ui.js places it
2066
+ under its button): 更多's grouped brush grid, the 訊號 and 匯出 menus. */
2067
+ .ed-wave-pop {
2068
+ position: absolute; z-index: 4; background: #fff; border: 1px solid #e2e2e2;
2069
+ border-radius: 6px; box-shadow: 0 4px 16px var(--md-ed-shadow); padding: 6px;
2070
+ }
2071
+ .ed-wave-menu { display: flex; flex-direction: column; min-width: 140px; }
2072
+ .ed-wave-menu-item {
2073
+ border: 0; background: transparent; color: #333; text-align: left;
2074
+ padding: 6px 10px; font-size: 13px; border-radius: 4px; cursor: pointer;
2075
+ }
2076
+ .ed-wave-menu-item:hover, .ed-wave-menu-item:focus { background: var(--md-ed-hover); }
2077
+ /* Task 8: a menu item says the key its action also has (the grip menu's
2078
+ F2, Ctrl+D, Alt+arrows, Del) on its right; 刪除 is red. */
2079
+ .ed-wave-menu-item { display: flex; align-items: center; gap: 16px; }
2080
+ .ed-wave-menu-label { flex: 1 1 auto; }
2081
+ .ed-wave-menu-key { flex: 0 0 auto; font-size: 11px; color: #8a8a8a; }
2082
+ .ed-wave-menu-item.is-danger { color: var(--md-ed-err); }
2083
+ /* Task 8: the grip menu's 週期與相位 panel, period and phase of one lane. */
2084
+ .ed-wave-period-pop { display: flex; flex-direction: column; gap: 6px; }
2085
+ .ed-wave-period-row { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #333; }
2086
+ .ed-wave-period-cap { flex: 0 0 84px; }
2087
+ .ed-wave-period-input {
2088
+ width: 72px; box-sizing: border-box; padding: 3px 6px; font-size: 12px;
2089
+ border: 1px solid #e2e2e2; border-radius: 4px; background: #fff; color: #222;
2090
+ }
2091
+ /* Task 10 (spec section 4.6): the settings popover, the whole diagram.
2092
+ Title and cycle width on top, the overflow card under them, the rulers
2093
+ and the foot title folded under the advanced toggle. */
2094
+ .ed-wave-settings-pop { display: flex; flex-direction: column; gap: 6px; width: 260px; }
2095
+ .ed-wave-set-cap { font-size: 11px; color: #8a8a8a; margin-top: 2px; }
2096
+ .ed-wave-set-row { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #333; }
2097
+ .ed-wave-set-label { flex: 0 0 44px; }
2098
+ .ed-wave-set-input {
2099
+ flex: 1 1 auto; min-width: 0; box-sizing: border-box; padding: 3px 6px; font-size: 12px;
2100
+ font-family: inherit; border: 1px solid #e2e2e2; border-radius: 4px; background: #fff; color: #222;
2101
+ }
2102
+ textarea.ed-wave-set-input { resize: vertical; }
2103
+ .ed-wave-set-scales { display: flex; gap: 4px; }
2104
+ .ed-wave-set-scales button, .ed-wave-set-advanced, .ed-wave-set-fix {
2105
+ border: 1px solid #e2e2e2; border-radius: 4px; background: #fff; color: #333;
2106
+ padding: 3px 10px; font-size: 12px; cursor: pointer;
2107
+ }
2108
+ .ed-wave-set-scales button:hover, .ed-wave-set-advanced:hover, .ed-wave-set-fix:hover { background: var(--md-ed-hover); }
2109
+ .ed-wave-set-scales button[aria-pressed="true"] { background: #1a73e8; color: #fff; border-color: #1a73e8; }
2110
+ .ed-wave-set-advanced { align-self: flex-start; }
2111
+ .ed-wave-set-advanced::after { content: " ⌄"; }
2112
+ .ed-wave-set-advanced[aria-expanded="true"]::after { content: " ⌃"; }
2113
+ .ed-wave-set-adv { display: flex; flex-direction: column; gap: 6px; }
2114
+ .ed-wave-set-adv[hidden] { display: none; }
2115
+ .ed-wave-set-overflow {
2116
+ display: flex; flex-direction: column; align-items: flex-start; gap: 6px; padding: 6px 8px;
2117
+ border: 1px dashed var(--md-ed-dirty); border-radius: 4px; font-size: 12px; color: #333;
2118
+ }
2119
+ .ed-wave-set-overflow[hidden] { display: none; }
2120
+ .ed-wave-set-fix { color: var(--md-ed-dirty); border-color: var(--md-ed-dirty); }
2121
+ /* The export menu's last line: what every export looks like. */
2122
+ .ed-wave-export-note {
2123
+ border-top: 1px solid #e2e2e2; margin-top: 4px; padding: 6px 10px 2px; font-size: 11px; color: #8a8a8a;
2124
+ }
2125
+ /* The chip beside a label wider than its segment; one press widens the
2126
+ cycles. Above the layer, so a press on it never paints. */
2127
+ .ed-wave-overflow-chip {
2128
+ position: absolute; z-index: 2; white-space: nowrap; padding: 1px 6px; font-size: 11px;
2129
+ line-height: 14px; border: 1px solid var(--md-ed-dirty); border-radius: 8px;
2130
+ background: var(--md-ed-surface); color: var(--md-ed-dirty); cursor: pointer;
2131
+ }
2132
+ .ed-wave-overflow-chip[hidden] { display: none; }
2133
+ .ed-wave-brush-grid { display: flex; flex-direction: column; gap: 6px; }
2134
+ .ed-wave-brush-group { display: flex; align-items: center; gap: 8px; }
2135
+ .ed-wave-brush-group-title { flex: 0 0 32px; font-size: 11px; color: #8a8a8a; }
2136
+ .ed-wave-brushes { display: flex; flex-wrap: wrap; gap: 4px; }
2137
+ /* Spec section 4.9: what the editor cannot place, right under the
2138
+ toolbar, in neutral colours, and it can be folded away. */
2139
+ .ed-wave-notice {
2140
+ display: flex; align-items: flex-start; gap: 8px; padding: 6px 12px;
2141
+ border-bottom: 1px solid #e2e2e2; background: #fafafa; color: #555; font-size: 12px;
2142
+ }
2143
+ .ed-wave-notice[hidden] { display: none; }
2144
+ .ed-wave-notice-text { flex: 1 1 auto; }
2145
+ .ed-wave-notice[data-folded] .ed-wave-notice-text {
2146
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
2147
+ }
2148
+ .ed-wave-notice-fold {
2149
+ flex: 0 0 auto; border: 0; background: transparent; color: #1a73e8;
2150
+ font-size: 12px; cursor: pointer; padding: 0 4px;
2151
+ }
2152
+ /* The stage: the canvas's scroll container, the dialog's whole width.
2153
+ position: relative so the data-label field (wave-ui.js openDataEdit)
2154
+ is placed in its scrolled content box. */
2155
+ .ed-wave-stage {
2156
+ flex: 1 1 auto; min-height: 0; overflow: auto; padding: 8px; position: relative;
2157
+ }
2158
+ /* The engine's svg and the interaction layer share this box. The top
2159
+ padding is the room for the layer's cycle ruler, which sits above the
2160
+ engine's drawing (wave-ui.js RULER_GAP is the baseline's distance). */
2161
+ .ed-wave-canvas-host { position: relative; display: inline-block; padding-top: 20px; }
2162
+ .ed-wave-canvas-error { color: #b00020; padding: 8px; }
2163
+ .ed-wave-canvas { display: block; }
2164
+ /* The drawing is in the Tab cycle, so it has to say when it holds the
2165
+ keyboard. */
2064
2166
  .ed-wave-canvas:focus { outline: 2px solid #1a73e8; outline-offset: 2px; }
2065
- /* The discontinuity marker. The engine draws one gap <use> per pipe
2066
- character; drawing none left the two pictures disagreeing on a lane whose
2067
- levels agreed exactly. v3.5.0 Task 11 fix round 4 audit: drawLane's own
2068
- comment already calls this "last so it sits over the trace it
2069
- interrupts" — purely decorative, drawn on top purely to be seen, the
2070
- exact shape of thing that swallowed a press meant for the bus label
2071
- underneath it (see .ed-wave-selection above). A gap cycle inside a bus
2072
- run can land at that run's own label position, so pointer-events: none
2073
- here too, on the same principle, not because a concrete collision was
2074
- reproduced. */
2075
- .ed-wave-gap { stroke: #111; stroke-width: 2; fill: none; pointer-events: none; }
2076
- /* v3.5.0 Task 6: edge: relationship lines, drawn by wave-ui.js's
2077
- renderEdges() after every lane. Named .ed-wave-edge-* (with a hyphen)
2078
- rather than .ed-wave-edge, which is already the brick-to-brick
2079
- transition tick inside ONE lane (two rules above) — a different mark
2080
- with a different meaning, and CSS class matching is exact-token so the
2081
- two never collide, but the names are kept visually distinct too.
2082
- Colours match the engine's own arc-shape.js style string
2083
- (stroke:#0041c4) so a line drawn here and the same line in the
2084
- WaveDrom preview beside it read as the same colour. */
2085
- .ed-wave-edge-path { stroke: #0041c4; stroke-width: 1; fill: none; }
2086
- .ed-wave-edge-path.is-on { stroke: #1a73e8; stroke-width: 2; }
2087
- /* v3.5.0 Task 8: the edge an in-flight endpoint drag is dragging, drawn at
2088
- its own ACTUAL (still-committed) position — the document does not
2089
- change until the drop — but faded so it reads as "not where this will
2090
- end up" once wave-ui.js's own dashed .ed-wave-edge-pending preview line
2091
- (same mechanism Task 7's not-yet-written edge already uses) is standing
2092
- next to it. Combines with .is-on: the dragged edge is always the
2093
- selected one (renderEdges()'s own comment), so both classes are present
2094
- together and this only lowers opacity on top of that colour. */
2095
- .ed-wave-edge-path.is-drag-source { opacity: .35; }
2096
- /* The invisible, wide-stroke click target layered over each visible path
2097
- (see renderEdges()'s own comment) — pointer-events: stroke is what makes
2098
- a transparent stroke hit-testable at all, since a stroke that paints
2099
- nothing is not visiblePainted by the SVG default. */
2100
- .ed-wave-edge-hit { stroke: transparent; stroke-width: 10; fill: none; pointer-events: stroke; }
2101
- /* v3.5.0 Task 11 fix round 4 audit, closed in round 5: this text (drawn
2102
- by renderEdges, AFTER .ed-wave-edge-hit) is the same shape of hazard as
2103
- .ed-wave-selection and .ed-wave-gap above — purely decorative, sits on
2104
- top of the fat invisible click target a press aimed at SELECTING the
2105
- edge is meant to land on. Round 4 found this but could not apply
2106
- pointer-events: none to the bare class: ed-wave-edge-label is not this
2107
- text's own class alone — wave-ui.js's sidebar edgeLabelInput (the real,
2108
- keyboard-and-mouse text field for editing an edge's label) uses the
2109
- IDENTICAL class name, and a blanket rule would have also landed on
2110
- that input field and blocked clicking into it with the mouse.
2111
- Round 5's fix: scope the SELECTOR by element instead of renaming
2112
- anything. text.ed-wave-edge-label only ever matches the SVG <text>
2113
- wave-ui.js draws on the canvas — the sidebar control is an <input>, a
2114
- different element entirely, and keeps its own default pointer-events
2115
- untouched no matter what class it shares. */
2116
- text.ed-wave-edge-label { pointer-events: none; }
2117
- .ed-wave-edge-label { font-size: 11px; fill: #333; }
2118
- /* v3.6.0 Task 8: the canvas's own name column — a mirror of the rail's
2119
- .ed-wave-lane-name / .ed-wave-group (see the comment above
2120
- .ed-wave-lane-rows). Unlike .ed-wave-edge-label these two class names
2121
- are not shared with any non-SVG control, so pointer-events: none goes
2122
- straight on the class rather than needing the element-scoped selector
2123
- that rule uses. MEASURED (task-8-report.md): the longest lane name in
2124
- this repo's fixtures is 12 chars ("he said \"hi\""), which at 11px in
2125
- both the app's default sans-serif and the worst case (monospace)
2126
- measures well under the 110px a top-level name has to fit in
2127
- (nameColWidth 120 - NAME_GAP 10), so SIZES.nameColWidth stays 120. */
2128
- .ed-wave-lane-label, .ed-wave-group-label { pointer-events: none; }
2129
- .ed-wave-lane-label { font-size: 11px; fill: #333; }
2130
- .ed-wave-group-label {
2131
- font-size: 11px; fill: #555; writing-mode: vertical-rl; text-orientation: mixed;
2132
- }
2133
- /* v3.6.0 Task 9: the cycle ruler and the head/foot bands — see
2134
- wave-draw.js's drawRuler()/drawBand() comment for the geometry (tick on
2135
- boundaries, tock at centres, both filtered by every) and why this is
2136
- read-only with respect to the document. Purely decorative like the name
2137
- column above it, so pointer-events: none on the same principle. The
2138
- ruler's own fill matches the pinned engine's own muted class (fill
2139
- #aaa, node_modules/wavedrom's waveSkin CSS) so a tick/tock painted here
2140
- reads the same shade as the identical mark in the WaveDrom preview
2141
- beside it; the head/foot titles use the darker ed-wave-lane-label shade
2142
- instead, matching the engine's own plain black fill for head.text /
2143
- foot.text closely enough at this scale without introducing a third
2144
- grey. */
2145
- .ed-wave-ruler-tick, .ed-wave-ruler-tock, .ed-wave-head-text, .ed-wave-foot-text {
2146
- pointer-events: none;
2167
+ /* The interaction layer's marks (wave-draw.js createLayer). None of them
2168
+ takes a press: the layer svg itself does, and the pointer is resolved
2169
+ to a cell by geometry. The exceptions are a transition dot, which takes
2170
+ it through its wider transparent ring, and the edges' hit twins below. */
2171
+ .ed-wave-ruler-num, .ed-wave-ruler-plus, .ed-wave-row-hover, .ed-wave-hover-col,
2172
+ .ed-wave-selection, .ed-wave-cursor, .ed-wave-dot-mark, .ed-wave-pending,
2173
+ .ed-wave-edge-sel, .ed-wave-edge-end, .ed-wave-overflow, .ed-wave-drop-line { pointer-events: none; }
2174
+ /* Task 8: where a lane dragged by its grip will land. */
2175
+ .ed-wave-drop-line { stroke: #1a73e8; stroke-width: 2; }
2176
+ .ed-wave-ruler-num { font-size: 11px; fill: #aaa; }
2177
+ .ed-wave-ruler-num.is-on { fill: #1a73e8; font-weight: bold; }
2178
+ .ed-wave-ruler-plus-glyph { font-size: 14px; font-weight: bold; fill: #1a73e8; }
2179
+ /* Task 7: the ruler's plus inserts a cycle where it stands, so its ring
2180
+ (wave-draw.js createLayer) is the one ruler mark that takes a press. */
2181
+ .ed-wave-ruler-plus-hit { pointer-events: all; cursor: pointer; }
2182
+ .ed-wave-row-hover, .ed-wave-hover-col { fill: var(--md-ed-sel); }
2183
+ .ed-wave-selection { fill: rgba(26,115,232,.18); stroke: #1a73e8; stroke-width: 1; }
2184
+ /* The keyboard's own cell, deliberately not the selection's mark: the
2185
+ selection is the range an operation acts on, the cursor the one cell
2186
+ the next keystroke paints, and both can be on screen at once. */
2187
+ .ed-wave-cursor { fill: none; stroke: #d93025; stroke-width: 2; }
2188
+ .ed-wave-dot { cursor: crosshair; }
2189
+ .ed-wave-dot-hit { pointer-events: all; }
2190
+ .ed-wave-dot-mark { fill: #fff; stroke: #1a73e8; stroke-width: 1.5; }
2191
+ .ed-wave-dot.is-hot .ed-wave-dot-mark { fill: #1a73e8; stroke: #0041c4; }
2192
+ .ed-wave-pending { stroke: #1a73e8; stroke-width: 2; stroke-dasharray: 5 4; fill: none; }
2193
+ .ed-wave-edge-sel { stroke: #1a73e8; stroke-width: 2; fill: none; }
2194
+ .ed-wave-edge-end { fill: #1a73e8; stroke: #fff; stroke-width: 1.5; }
2195
+ .ed-wave-overflow { fill: none; stroke: var(--md-ed-dirty); stroke-width: 1.5; stroke-dasharray: 3 2; }
2196
+ /* The bus data-label field, placed over its cell by openDataEdit; only
2197
+ the skin lives here. */
2198
+ .ed-wave-data-input {
2199
+ position: absolute; z-index: 1; box-sizing: border-box; text-align: center;
2200
+ font-size: 11px; padding: 0 2px; border: 1px solid #1a73e8; border-radius: 2px;
2201
+ background: #fff; color: #222;
2147
2202
  }
2148
- .ed-wave-ruler-tick, .ed-wave-ruler-tock { font-size: 11px; fill: #aaa; }
2149
- .ed-wave-head-text, .ed-wave-foot-text { font-size: 11px; fill: #333; }
2150
- .ed-wave-edge-handle { fill: #1a73e8; stroke: #ffffff; }
2151
- .ed-wave-edge-arrow { fill: #0041c4; }
2152
- .ed-wave-edge-tee { stroke: #0041c4; }
2153
- /* v3.5.0 Task 7: the pending line an armed drag or keyboard two-step is
2154
- standing on, from wave-ui.js's renderCanvas() — UI state only, never a
2155
- document read, so it is dashed rather than the solid .ed-wave-edge-path
2156
- colour to read as "not written yet". pointer-events: none so it can
2157
- never itself become the thing a press lands on. */
2158
- .ed-wave-edge-pending {
2159
- stroke: #1a73e8; stroke-width: 2; stroke-dasharray: 5 4; fill: none;
2160
- pointer-events: none;
2203
+ /* Task 8 (spec section 4.4): the name column. Each row and each group
2204
+ title has a grip, laid over the column by wave-ui.js (the box sits on
2205
+ the canvas host padding box, above the layer). A grip shows for the row
2206
+ the pointer is over, while it has the keyboard and while its menu is
2207
+ open; otherwise it is invisible and takes no press, so the column under
2208
+ it stays the name the pointer means. */
2209
+ .ed-wave-grips { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 2; }
2210
+ .ed-wave-grip {
2211
+ position: absolute; width: 16px; height: 22px; padding: 0; margin: 0;
2212
+ display: flex; align-items: center; justify-content: center;
2213
+ border: 0; border-radius: 4px; background: var(--md-ed-surface); color: var(--md-ed-glyph);
2214
+ font-size: 13px; line-height: 1; cursor: pointer; opacity: 0; pointer-events: none;
2215
+ }
2216
+ .ed-wave-grip.is-on, .ed-wave-grip:focus, .ed-wave-grip[aria-expanded="true"] {
2217
+ opacity: 1; pointer-events: auto;
2218
+ }
2219
+ .ed-wave-grip:hover, .ed-wave-grip[aria-expanded="true"] { background: var(--md-ed-hover); color: #333; }
2220
+ .ed-wave-grip:focus { outline: 2px solid #1a73e8; outline-offset: 0; }
2221
+ /* The in-place name field over a signal name (right-aligned, like the
2222
+ name the engine draws) or a group title. */
2223
+ .ed-wave-rename {
2224
+ position: absolute; z-index: 3; box-sizing: border-box; text-align: right;
2225
+ font-size: 14px; padding: 0 6px; border: 1px solid #1a73e8; border-radius: 3px;
2226
+ background: #fff; color: #222;
2161
2227
  }
2162
- /* v3.6.0 Task 11: the transition dots wave-draw.js's drawTransitions()
2163
- paints while edge-creation is armed -- the visual answer to "where can
2164
- I aim". pointer-events: none for the same reason ed-wave-selection and
2165
- the other UI-state marks above are: the actual hit-testing runs through
2166
- geometry.boundaryAt against the pointer's raw coordinates
2167
- (wave-ui.js's cellFromEvent/onCanvasHover), never through this circle
2168
- as a DOM target, so it must never itself swallow the press it exists
2169
- to help aim. The [data-hot] variant is the same dot, larger and
2170
- filled solid, for the one nearest the pointer right now
2171
- (state.hoverBoundary in drawTransitions). */
2172
- .ed-wave-transition {
2173
- fill: #ffffff; stroke: #1a73e8; stroke-width: 1.5; pointer-events: none;
2174
- }
2175
- .ed-wave-transition[data-hot] { fill: #1a73e8; stroke: #0041c4; }
2176
- /* v3.6.0 Task 13: the old flex-basis/border/padding here were for
2177
- .ed-wave-preview as a direct flex child of .ed-wave-body; it now
2178
- lives inside .ed-wave-section-body (the 預覽 panel), which already
2179
- supplies the column width and padding, so only its own scroll behaviour
2180
- is left to set — a wide diagram's preview SVG must scroll within the
2181
- panel rather than stretching the whole 340px column. */
2182
- .ed-wave-preview { overflow: auto; max-height: 260px; }
2183
- .ed-wave-preview-label { font-size: 11px; color: #666; margin-bottom: 6px; }
2184
- /* v3.6.0 Task 13: the right-hand column — five collapsible panels
2185
- (關聯線/文件/Lane/預覽/原始碼) replacing the old direct
2186
- .ed-wave-preview column. 'flex: 0 0 340px' (not '0 1'): unlike the
2187
- old preview column, this one does not shrink when the canvas wants
2188
- more room — width-budget wins go through .ed-wave-panel's own cap
2189
- above, not by starving this column, because starving it re-introduces
2190
- the exact 160px-minimum squeeze this task's own .ed-wave-lanes
2191
- comment measured as the 800x600 defect. Below 1100px it collapses to a
2192
- 28px rail instead (media query below) for the identical reason. */
2193
- /* v3.6.0 Task 13 fix round 4: box-sizing: border-box added. wave-ui.js's
2194
- clearSelectedEdgeOfSide() sets an inline width (in border-box, i.e.
2195
- rendered, pixels — the same unit getBoundingClientRect() reports
2196
- everywhere else it measures this element) to keep the selected edge's
2197
- own handle clear of this panel when expanded. Under the default
2198
- content-box sizing, width excludes padding (16px) and the border
2199
- (1px), so a JS-computed width landed 17px narrower on screen than
2200
- intended — MEASURED, not assumed: an inline width of 310px rendered at
2201
- 327px. border-box makes width mean the same thing in CSS and in
2202
- getBoundingClientRect(), so the two agree without wave-ui.js having to
2203
- hardcode this rule's own padding/border numbers into its arithmetic. */
2204
- .ed-wave-side {
2205
- box-sizing: border-box; flex: 0 0 340px; border-left: 1px solid #e2e2e2;
2206
- overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 8px;
2207
- }
2208
- .ed-wave-section { border: 1px solid #ececec; border-radius: 6px; }
2209
- .ed-wave-section-title {
2210
- display: block; width: 100%; text-align: left; border: 0; background: #fafafa;
2211
- font: inherit; font-size: 11px; letter-spacing: .5px; color: #555;
2212
- padding: 5px 8px; cursor: pointer; border-radius: 6px 6px 0 0;
2213
- }
2214
- .ed-wave-section-body { display: none; padding: 8px; }
2215
- /* v3.6.0 Task 13 fix round 3: data-open is this SECTION's own toggle
2216
- state (wave-panels.js's section()) and says NOTHING about whether the
2217
- RAIL is currently showing it — those are two independent attributes on
2218
- two different elements. A section can report data-open with a
2219
- zero-size, unhittable body the whole time .ed-wave-side itself is
2220
- collapsed to its <1100px 28px rail (below): 關聯線/文件/Lane all default
2221
- data-open, and none of that changes just because the rail hides them.
2222
- Left deliberately this way rather than making data-open track actual
2223
- visibility — that would conflate "this section is open" with "the rail
2224
- happens to be showing right now", and toggling ONE section would then
2225
- have to silently relabel every OTHER already-open section's own state
2226
- too. What actually closes the gap this could otherwise leave (a control
2227
- that LOOKS reachable in the DOM but is not) is wave-ui.js's render()
2228
- expanding the rail on the one transition that matters (selecting an
2229
- edge) rather than this attribute pretending to mean something it does
2230
- not. */
2231
- .ed-wave-section[data-open] .ed-wave-section-body { display: block; }
2232
- .ed-wave-section-body label { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
2233
- .ed-wave-section-body label span { flex: 0 0 96px; font-size: 12px; color: #555; }
2234
- .ed-wave-section-body input {
2235
- flex: 1 1 auto; min-width: 0; font-size: 12px; padding: 2px 4px;
2236
- }
2237
- .ed-wave-source {
2238
- margin: 0; font-size: 11px; line-height: 1.45; white-space: pre;
2239
- overflow: auto; max-height: 260px;
2240
- }
2241
- /* 視窗窄於這條線時右欄收成一條直立標籤,展開時覆蓋在畫布上 —— 否則
2242
- 800x600 的測試視窗下畫布會被 340px 固定寬的側欄吃掉大半,拖曳手勢會
2243
- 不可用(v3.6.0 Task 13 自己的 .ed-wave-lanes 註解量到的舊值:只剩
2244
- 282px)。收起時仍是普通文件流寬度(28px),展開時疊在畫布上方而不擠
2245
- 壓它。data-expanded 有三個來源:wave-panels.js 的 section() 點擊 title、
2246
- 同檔案的 collapseBtn 反過來移除它、以及 fix round 3 新增的
2247
- wave-panels.js 的 expandSide()(由 wave-ui.js 的 render() 在
2248
- selectedEdge 從 null 變成有值的那一拍呼叫——選取一條 edge 之後,
2249
- 整個回饋只長在這個側欄的關聯線面板裡,窄視窗下不展開就等於使用者
2250
- 什麼都看不到、也按不到)。 */
2251
- /* v3.6.0 Task 13 fix round 1: the collapse-back button. Hidden everywhere
2252
- except the one state it exists for — the <1100px rail, expanded — so it
2253
- never shows, and never sits in the tab order, at a wide viewport or in
2254
- the rail's own default collapsed state. */
2255
- .ed-wave-side-collapse { display: none; }
2256
- @media (max-width: 1100px) {
2257
- .ed-wave-side { flex: 0 0 28px; padding: 4px 2px; }
2258
- .ed-wave-side:not([data-expanded]) .ed-wave-section-body { display: none; }
2259
- .ed-wave-side[data-expanded] {
2260
- position: absolute; right: 0; top: 0; bottom: 0; width: 340px;
2261
- background: #ffffff; z-index: 3; flex: 0 0 340px; padding: 8px;
2262
- }
2263
- .ed-wave-side[data-expanded] .ed-wave-side-collapse {
2264
- display: block; width: 100%; text-align: right; border: 0;
2265
- background: transparent; font-size: 12px; color: #555; cursor: pointer;
2266
- padding: 2px 4px 6px;
2267
- }
2228
+ .ed-wave-rename.is-group { text-align: left; }
2229
+ /* The add-a-signal row under the last lane. */
2230
+ .ed-wave-add-lane {
2231
+ display: block; margin: 4px 0 0; border: 0; border-radius: 4px; background: transparent;
2232
+ color: #1a73e8; font-size: 12px; padding: 4px 8px; cursor: pointer;
2233
+ }
2234
+ .ed-wave-add-lane:hover, .ed-wave-add-lane:focus { background: var(--md-ed-hover); }
2235
+ /* Spec section 4.3: the floating toolbar over a selected run of cycles,
2236
+ placed against the panel by wave-ui.js (above the run, or below it when
2237
+ there is no room). Under the popovers (z-index 4), so 電位's grid opens
2238
+ over it. */
2239
+ .ed-wave-range {
2240
+ position: absolute; z-index: 3; display: flex; align-items: center; gap: 2px;
2241
+ padding: 3px; background: #fff; border: 1px solid #e2e2e2; border-radius: 6px;
2242
+ box-shadow: 0 4px 16px var(--md-ed-shadow); white-space: nowrap;
2243
+ }
2244
+ .ed-wave-range button {
2245
+ border: 0; border-radius: 4px; background: transparent; color: #333;
2246
+ padding: 4px 8px; font-size: 12px; cursor: pointer;
2247
+ }
2248
+ .ed-wave-range button:hover, .ed-wave-range button:focus { background: var(--md-ed-hover); }
2249
+ /* Task 9 (spec section 4.5): the edges. The engine draws them under the
2250
+ layer, so the layer carries a transparent twin of each for a press to
2251
+ land on: the line (hit on its wide stroke only), the label box and an
2252
+ unlabelled self-loop ring (hit on their area). A selected edge has a
2253
+ ring at each end that takes a press, under its visible mark. The
2254
+ selection is drawn with the engine path and its matrix, so its width
2255
+ must not scale with it. */
2256
+ .ed-wave-edge-hit { pointer-events: stroke; cursor: pointer; }
2257
+ .ed-wave-edge-label-hit, .ed-wave-edge-loop-hit { pointer-events: all; }
2258
+ .ed-wave-edge-end-hit { pointer-events: all; cursor: move; }
2259
+ .ed-wave-edge-sel { vector-effect: non-scaling-stroke; }
2260
+ .ed-wave-note-sel { fill: none; stroke: #1a73e8; stroke-width: 1.5; pointer-events: none; }
2261
+ /* The toolbar beside a selected edge or note (right of its label),
2262
+ placed against the panel like the range toolbar; 更多 lists its shapes
2263
+ on a second row. */
2264
+ .ed-wave-edge-bar {
2265
+ position: absolute; z-index: 3; display: flex; flex-wrap: wrap; align-items: center; gap: 2px;
2266
+ max-width: 360px; padding: 3px; background: #fff; border: 1px solid #e2e2e2; border-radius: 6px;
2267
+ box-shadow: 0 4px 16px var(--md-ed-shadow); white-space: nowrap;
2268
+ }
2269
+ .ed-wave-edge-group { display: flex; gap: 1px; padding-right: 3px; margin-right: 1px; border-right: 1px solid #e2e2e2; }
2270
+ .ed-wave-edge-bar button {
2271
+ border: 0; border-radius: 4px; background: transparent; color: #333;
2272
+ padding: 4px 8px; font-size: 12px; cursor: pointer;
2273
+ }
2274
+ .ed-wave-edge-bar button:hover, .ed-wave-edge-bar button:focus { background: var(--md-ed-hover); }
2275
+ .ed-wave-edge-bar button[aria-pressed="true"] { background: #1a73e8; color: #fff; }
2276
+ .ed-wave-edge-bar button.is-danger { color: var(--md-ed-err); }
2277
+ .ed-wave-edge-more-list { display: flex; flex-wrap: wrap; gap: 1px; flex-basis: 100%; }
2278
+ .ed-wave-edge-more-list[hidden] { display: none; }
2279
+ .ed-wave-edge-more-list button { font-family: monospace; }
2280
+ /* The one-line field over an edge's label or a point note. */
2281
+ .ed-wave-edge-input {
2282
+ position: absolute; z-index: 3; box-sizing: border-box; text-align: center;
2283
+ font-size: 12px; padding: 0 4px; border: 1px solid #1a73e8; border-radius: 3px;
2284
+ background: #fff; color: #222;
2268
2285
  }
2269
- .ed-wave-status {
2270
- border-top: 1px solid #e2e2e2; padding: 6px 12px; font-size: 12px;
2271
- color: #444; min-height: 18px;
2286
+ /* Spec section 4.8: the bottom line says what can be done next, never
2287
+ what just happened (results go to the toast). Task 11: the sentence
2288
+ follows the state, and 「? 全部快捷鍵」 sits fixed at its right end. */
2289
+ .ed-wave-hint {
2290
+ display: flex; align-items: center; gap: 12px;
2291
+ border-top: 1px solid #e2e2e2; padding: 3px 8px 3px 12px; font-size: 12px; color: #444; min-height: 24px;
2292
+ }
2293
+ .ed-wave-hint-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2294
+ .ed-wave-hint-help {
2295
+ flex: 0 0 auto; border: 0; border-radius: 4px; background: transparent; color: #1a73e8;
2296
+ font-size: 12px; padding: 3px 8px; cursor: pointer;
2297
+ }
2298
+ .ed-wave-hint-help:hover, .ed-wave-hint-help[aria-expanded="true"] { background: var(--md-ed-hover); }
2299
+ /* Task 11: the shortcut card, in the middle of the editor (wave-ui.js
2300
+ centres it), above every other popover and floating toolbar. */
2301
+ .ed-wave-shortcuts {
2302
+ z-index: 5; box-sizing: border-box; width: min(600px, calc(100% - 16px));
2303
+ max-height: calc(100% - 16px); overflow: auto; padding: 10px 14px 14px;
2304
+ }
2305
+ .ed-wave-shortcuts:focus { outline: none; }
2306
+ .ed-wave-shortcuts-head { display: flex; align-items: center; margin-bottom: 4px; }
2307
+ .ed-wave-shortcuts-title { flex: 1 1 auto; font-weight: bold; font-size: 14px; color: #222; }
2308
+ .ed-wave-shortcuts-close {
2309
+ border: 0; border-radius: 4px; background: transparent; color: #555;
2310
+ width: 26px; height: 26px; font-size: 13px; cursor: pointer;
2311
+ }
2312
+ .ed-wave-shortcuts-close:hover { background: var(--md-ed-hover); }
2313
+ .ed-wave-shortcuts-body { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; }
2314
+ .ed-wave-shortcut-title { font-weight: bold; font-size: 12px; color: #444; margin: 6px 0 4px; }
2315
+ .ed-wave-shortcut-row { display: flex; align-items: baseline; gap: 10px; font-size: 12px; color: #333; padding: 2px 0; }
2316
+ .ed-wave-shortcut-keys { flex: 0 0 92px; display: flex; flex-wrap: wrap; gap: 3px; }
2317
+ .ed-wave-shortcut-keys kbd {
2318
+ font-family: inherit; font-size: 11px; line-height: 16px; padding: 0 4px;
2319
+ border: 1px solid #e2e2e2; border-radius: 3px; background: #fff; color: #222; white-space: nowrap;
2320
+ }
2321
+ /* The cursor narration: for a screen reader only, so visually hidden but
2322
+ never display: none (a live region that is not rendered is not read). */
2323
+ .ed-wave-live {
2324
+ position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
2325
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
2272
2326
  }
2273
2327
  .ed-wave-parse-error { padding: 16px; }
2274
2328
  .ed-wave-parse-message { color: #b00020; font-weight: bold; }
@@ -2660,6 +2714,7 @@ const html = `<!DOCTYPE html>
2660
2714
  padding: 0 2px;
2661
2715
  border-radius: 3px;
2662
2716
  }
2717
+ mark.search-hit-block { display: block; }
2663
2718
  .toc {
2664
2719
  display: flex;
2665
2720
  flex-direction: column;
@@ -2795,6 +2850,12 @@ const html = `<!DOCTYPE html>
2795
2850
  .toc-list-level-1 > .toc-item > details > summary > a {
2796
2851
  font-size: 0.95em;
2797
2852
  }
2853
+ /* v3.13.1 (R3): chapters of a multi-H1 TOC, set apart by a rule and a gap; weight unchanged. */
2854
+ .toc[data-toc-chapters] > .toc-list-level-1 > .toc-item + .toc-item {
2855
+ margin-top: 10px;
2856
+ padding-top: 6px;
2857
+ border-top: 1px solid #d8dee4;
2858
+ }
2798
2859
  .toc-list-level-2 > .toc-item > a,
2799
2860
  .toc-list-level-2 > .toc-item > details > summary > a {
2800
2861
  font-size: 0.9em;
@@ -4116,6 +4177,7 @@ ${mermaidInitTag}`)
4116
4177
  results: [],
4117
4178
  selectedResultIndex: -1,
4118
4179
  activeHighlight: null,
4180
+ activeHighlights: [],
4119
4181
  };
4120
4182
  window.__readerState = readerState;
4121
4183
 
@@ -4258,14 +4320,18 @@ ${mermaidInitTag}`)
4258
4320
  // already above the line and would steal the highlight from the row that was
4259
4321
  // just clicked. The clicked heading stays the answer for as long as it is
4260
4322
  // still between the page top and the line, and the first scroll that moves
4261
- // it out of that band hands tracking back to the line.
4323
+ // it out of that band hands tracking back to the line. The last sections of
4324
+ // a document can be too short to ever reach the line: at the bottom of the
4325
+ // page a clicked heading anywhere on screen keeps its row lit. Only a click
4326
+ // pins, so a plain scroll to the bottom still follows the line.
4262
4327
  let pinnedHeadingId = null;
4263
4328
  function readingLineHeading() {
4264
4329
  const line = window.innerHeight * 0.35;
4265
4330
  if (pinnedHeadingId) {
4266
4331
  const pinned = document.getElementById(pinnedHeadingId);
4267
4332
  const top = pinned ? pinned.getBoundingClientRect().top : Infinity;
4268
- if (pinned && tocLinks.has(pinned.id) && top >= -40 && top <= line) return pinned;
4333
+ const atBottom = window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
4334
+ if (pinned && tocLinks.has(pinned.id) && top >= -40 && (top <= line || (atBottom && top < window.innerHeight))) return pinned;
4269
4335
  pinnedHeadingId = null;
4270
4336
  }
4271
4337
  let first = null;
@@ -4304,15 +4370,54 @@ ${mermaidInitTag}`)
4304
4370
  syncActiveHeading(node.id);
4305
4371
  syncReadingTitle(node);
4306
4372
  }
4307
- window.addEventListener('scroll', () => {
4373
+ // Scrolls are not the only thing that moves a heading across the line: a
4374
+ // height-only window resize moves the line itself (and reflows nothing, so
4375
+ // no scroll follows), and a column resize reflows the text under it.
4376
+ function scheduleReadingLinePass() {
4308
4377
  if (readingLinePending) return;
4309
4378
  readingLinePending = true;
4310
4379
  requestAnimationFrame(readingLinePass);
4311
- }, { passive: true });
4380
+ }
4381
+ window.addEventListener('scroll', scheduleReadingLinePass, { passive: true });
4382
+ window.addEventListener('resize', scheduleReadingLinePass);
4383
+ // Ends the freeze a selected search result put on the highlight. The freeze
4384
+ // also kept the TOC from opening the hit's path, and the next pass may find
4385
+ // the same section (the reader is still inside the hit's section), which
4386
+ // syncActiveHeading skips as no change: so the next frame re-syncs the row
4387
+ // itself, opening its path and centring it.
4388
+ function endSearchFreeze() {
4389
+ observerFrozen = false;
4390
+ requestAnimationFrame(resyncTocToScroll);
4391
+ }
4392
+ // A TOC click is the reader leaving a search hit: it also ends the freeze,
4393
+ // so the clicked row lights.
4312
4394
  document.addEventListener('click', (event) => {
4313
4395
  const link = event.target && event.target.closest && event.target.closest('.toc a[href^="#"]');
4314
- if (link) pinnedHeadingId = link.getAttribute('href').slice(1);
4396
+ if (!link) return;
4397
+ pinnedHeadingId = link.getAttribute('href').slice(1);
4398
+ endSearchFreeze();
4315
4399
  });
4400
+ // So does a scroll of the page by hand (R6b, R17): a wheel, a touch drag or
4401
+ // a scroll key. Inside the sidebar (the results list, the TOC) or the
4402
+ // lightbox the same gestures work that control, and keep the freeze; so do
4403
+ // a sideways wheel (a wide table's swipe), ctrl+wheel (zoom) and a key typed
4404
+ // into a field. A programmatic scroll (the result jump itself) fires none of
4405
+ // these events.
4406
+ const SCROLL_KEYS = new Set(['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar']);
4407
+ const unfreezeOnUserScroll = (event) => {
4408
+ if (!observerFrozen) return;
4409
+ const target = event.target;
4410
+ if (event.type === 'wheel' && (event.deltaY === 0 || event.ctrlKey)) return;
4411
+ if (event.type === 'keydown') {
4412
+ if (!SCROLL_KEYS.has(event.key) || lightboxIsOpen()) return;
4413
+ if (target instanceof Element && (target.closest('input, textarea, select') || target.isContentEditable)) return;
4414
+ }
4415
+ if (target instanceof Element && target.closest('.reader-sidebar, .lightbox')) return;
4416
+ endSearchFreeze();
4417
+ };
4418
+ window.addEventListener('wheel', unfreezeOnUserScroll, { passive: true });
4419
+ window.addEventListener('touchmove', unfreezeOnUserScroll, { passive: true });
4420
+ window.addEventListener('keydown', unfreezeOnUserScroll);
4316
4421
  function bindHeadingObserver() {
4317
4422
  readingLinePass();
4318
4423
  }
@@ -4417,13 +4522,22 @@ ${mermaidInitTag}`)
4417
4522
  });
4418
4523
  }
4419
4524
 
4525
+ // A hit that crosses inline markup is several marks (see
4526
+ // highlightFirstOccurrence); each is unwrapped and its own parent normalised,
4527
+ // which rejoins the text it was cut from, so main.content is byte-identical.
4528
+ // A math mark wraps a whole formula and cut nothing, so it is only unwrapped.
4420
4529
  function clearSelectedHighlight() {
4421
- const mark = readerState.activeHighlight;
4422
- if (mark && mark.parentNode) {
4423
- const text = document.createTextNode(mark.textContent || '');
4424
- mark.parentNode.replaceChild(text, mark);
4425
- text.parentNode.normalize();
4426
- }
4530
+ const marks = readerState.activeHighlights.length
4531
+ ? readerState.activeHighlights
4532
+ : (readerState.activeHighlight ? [readerState.activeHighlight] : []);
4533
+ marks.forEach((mark) => {
4534
+ const parent = mark.parentNode;
4535
+ if (!parent) return;
4536
+ while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
4537
+ parent.removeChild(mark);
4538
+ if (!mark.classList.contains('search-hit-math')) parent.normalize();
4539
+ });
4540
+ readerState.activeHighlights = [];
4427
4541
  readerState.activeHighlight = null;
4428
4542
  }
4429
4543
 
@@ -4489,61 +4603,153 @@ ${mermaidInitTag}`)
4489
4603
  return { start, end };
4490
4604
  }
4491
4605
 
4606
+ // A whitespace-only text node is the real space between words when it sits in
4607
+ // an inline element or between two inline siblings ("**bold** *italic*").
4608
+ // Between blocks (and inside list / table structure) it is markup layout:
4609
+ // block boundaries get a virtual space instead, and a mark must never land
4610
+ // there (a mark as a direct child of a ul or tr).
4611
+ const INLINE_SELECTOR = 'a, abbr, b, bdi, bdo, cite, code, data, del, dfn, em, i, img, ins, kbd, mark, q, s, samp, small, span, strong, sub, sup, time, u, var';
4612
+ function isInlineSibling(node) {
4613
+ if (!node) return false;
4614
+ if (node.nodeType === Node.TEXT_NODE) return !!node.nodeValue.trim();
4615
+ return node.nodeType === Node.ELEMENT_NODE && node.matches(INLINE_SELECTOR);
4616
+ }
4617
+ function isInlineSpace(node) {
4618
+ const parent = node.parentElement;
4619
+ if (!parent) return false;
4620
+ if (parent.matches(INLINE_SELECTOR)) return true;
4621
+ return isInlineSibling(node.previousSibling) && isInlineSibling(node.nextSibling);
4622
+ }
4623
+
4624
+ // The section's text as one string, read the way the search index reads it
4625
+ // (renderer side, sectionInlineText): whitespace runs fold to one space and
4626
+ // letters to lower case, as normalizeText folds the query; a br element is a
4627
+ // space; a formula (.katex) is its TeX source, taken from its annotation, and
4628
+ // its MathML and glyph markup are never walked; two blocks are joined by a
4629
+ // space. Every flat position maps back to its (node, offset), so a phrase can
4630
+ // cross a heading's .sec number, inline code, bold, a soft or hard line break
4631
+ // or a formula. Spaces that stand for a br or a block boundary map to no node
4632
+ // and are never wrapped. The walk stops at the first node that completes a
4633
+ // hit, so a long section is not read past its first occurrence.
4492
4634
  function highlightFirstOccurrence(sectionId, query) {
4493
4635
  const bounds = sectionBoundary(sectionId);
4494
4636
  if (!bounds) return null;
4495
4637
  const { start, end } = bounds;
4496
4638
  const container = start.parentNode;
4497
4639
  if (!container) return null;
4498
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
4640
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
4499
4641
  acceptNode(node) {
4500
- if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
4501
- if (node.parentElement && node.parentElement.closest(SKIP_SELECTOR)) return NodeFilter.FILTER_REJECT;
4502
- if (node.parentElement && node.parentElement.closest('.reader-sidebar')) return NodeFilter.FILTER_REJECT;
4642
+ if (node.nodeType === Node.ELEMENT_NODE) {
4643
+ if (node.matches(SKIP_SELECTOR) || node.matches('.reader-sidebar, .heading-anchor')) return NodeFilter.FILTER_REJECT;
4644
+ return node.matches('br, .katex') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
4645
+ }
4646
+ if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
4647
+ if (!node.nodeValue.trim() && !isInlineSpace(node)) return NodeFilter.FILTER_REJECT;
4503
4648
  return NodeFilter.FILTER_ACCEPT;
4504
4649
  },
4505
4650
  });
4506
- let inRange = false;
4507
- const queryLower = query.toLowerCase();
4651
+ // Nothing before the heading is in range, so the walk starts at it.
4652
+ walker.currentNode = start;
4653
+ const BLOCK = 'p, li, td, th, h1, h2, h3, h4, h5, h6, pre, blockquote, dt, dd, figcaption, .katex-display';
4654
+ const nodes = [];
4655
+ let flat = '';
4656
+ const at = [];
4657
+ let space = true;
4658
+ let lastBlock = null;
4659
+ let idx = -1;
4508
4660
  while (walker.nextNode()) {
4509
4661
  const node = walker.currentNode;
4510
- if (!inRange) {
4511
- if (start.contains(node)) {
4512
- inRange = true;
4513
- } else if (start.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) {
4514
- inRange = true;
4515
- } else {
4516
- continue;
4517
- }
4518
- }
4519
- if (node.parentElement && node.parentElement.closest('.heading-anchor')) continue;
4520
4662
  if (end) {
4521
4663
  const pos = end.compareDocumentPosition(node);
4522
4664
  if (end === node || end.contains(node) || (pos & Node.DOCUMENT_POSITION_FOLLOWING)) {
4523
4665
  break;
4524
4666
  }
4525
4667
  }
4526
- const text = node.nodeValue;
4527
- const idx = text.toLowerCase().indexOf(queryLower);
4528
- if (idx === -1) continue;
4529
- const before = text.slice(0, idx);
4530
- const match = text.slice(idx, idx + query.length);
4531
- const after = text.slice(idx + query.length);
4668
+ if (node.nodeName === 'BR') {
4669
+ if (!space) {
4670
+ flat += ' ';
4671
+ at.push(null);
4672
+ space = true;
4673
+ }
4674
+ continue;
4675
+ }
4676
+ const isMath = node.nodeType === Node.ELEMENT_NODE;
4677
+ let text = node.nodeValue;
4678
+ if (isMath) {
4679
+ const tex = node.querySelector('annotation[encoding="application/x-tex"]');
4680
+ text = tex ? tex.textContent : '';
4681
+ // Resume the walk after the formula's last descendant.
4682
+ let last = node;
4683
+ while (last.lastChild) last = last.lastChild;
4684
+ walker.currentNode = last;
4685
+ }
4686
+ const ni = nodes.length;
4687
+ nodes.push(node);
4688
+ const prevLen = flat.length;
4689
+ const block = (node.parentElement && node.parentElement.closest(BLOCK)) || (isMath ? node : null);
4690
+ if (ni && block !== lastBlock && !space) {
4691
+ flat += ' ';
4692
+ at.push(null);
4693
+ space = true;
4694
+ }
4695
+ lastBlock = block;
4696
+ for (let i = 0; i < text.length; i++) {
4697
+ if (/\\s/.test(text[i])) {
4698
+ if (space) continue;
4699
+ flat += ' ';
4700
+ at.push([ni, i]);
4701
+ space = true;
4702
+ } else {
4703
+ // A few characters lower-case to two code units; map each to i.
4704
+ const lower = text[i].toLowerCase();
4705
+ flat += lower;
4706
+ for (let u = 0; u < lower.length; u++) at.push([ni, i]);
4707
+ space = false;
4708
+ }
4709
+ }
4710
+ // Only a hit that ends in this node's text can be new.
4711
+ idx = flat.indexOf(query, Math.max(0, prevLen - query.length + 1));
4712
+ if (idx !== -1) break;
4713
+ }
4714
+ if (idx === -1) return null;
4715
+ // node index -> [first offset, last offset] of the hit inside that node
4716
+ const spans = new Map();
4717
+ for (let k = idx; k < idx + query.length; k++) {
4718
+ const p = at[k];
4719
+ if (!p) continue;
4720
+ const span = spans.get(p[0]) || [p[1], p[1]];
4721
+ span[1] = p[1];
4722
+ spans.set(p[0], span);
4723
+ }
4724
+ // One mark per node, wrapping only that node's slice, so the .sec / code /
4725
+ // bold wrappers stay whole siblings and clearing restores the markup
4726
+ // exactly. Last node first; each node is split only once anyway. A formula
4727
+ // is marked whole from outside, so KaTeX's own markup is never cut; a
4728
+ // display formula's mark goes around its .katex-display (KaTeX styles
4729
+ // .katex-display > .katex) and is a block, so its background shows.
4730
+ const marks = [];
4731
+ Array.from(spans.keys()).sort((a, b) => b - a).forEach((ni) => {
4732
+ const node = nodes[ni];
4532
4733
  const mark = document.createElement('mark');
4533
4734
  mark.className = 'search-hit is-selected';
4534
- mark.textContent = match;
4535
- const parent = node.parentNode;
4536
- if (!parent) return null;
4537
- if (before) parent.insertBefore(document.createTextNode(before), node);
4538
- parent.insertBefore(mark, node);
4539
- if (after) {
4540
- node.nodeValue = after;
4735
+ if (node.nodeType === Node.ELEMENT_NODE) {
4736
+ const display = node.parentElement && node.parentElement.classList.contains('katex-display') ? node.parentElement : null;
4737
+ const target = display || node;
4738
+ mark.classList.add('search-hit-math');
4739
+ if (display) mark.classList.add('search-hit-block');
4740
+ target.parentNode.insertBefore(mark, target);
4741
+ mark.appendChild(target);
4541
4742
  } else {
4542
- parent.removeChild(node);
4743
+ const span = spans.get(ni);
4744
+ const range = document.createRange();
4745
+ range.setStart(node, span[0]);
4746
+ range.setEnd(node, span[1] + 1);
4747
+ range.surroundContents(mark);
4543
4748
  }
4544
- return mark;
4545
- }
4546
- return null;
4749
+ marks.unshift(mark);
4750
+ });
4751
+ readerState.activeHighlights = marks;
4752
+ return marks[0] || null;
4547
4753
  }
4548
4754
 
4549
4755
  function jumpToAndHighlight(result) {
@@ -4791,6 +4997,11 @@ ${mermaidInitTag}`)
4791
4997
  var anchorTop = 0;
4792
4998
  var anchorFrame = 0;
4793
4999
  var suppressAnchorCapture = false;
5000
+ // Set by any scroll that is not our own restore, cleared by a capture: the
5001
+ // anchor then describes a position the page has already left (R1, R9).
5002
+ var anchorStale = false;
5003
+ // scrollY when the anchor was captured or last restored by us.
5004
+ var anchorScrollY = 0;
4794
5005
  // Column width the anchor was last captured/held at; -1 until the column
4795
5006
  // ResizeObserver below is installed. See captureScrollAnchor's first guard.
4796
5007
  var lastAnchorColW = -1;
@@ -4836,8 +5047,13 @@ ${mermaidInitTag}`)
4836
5047
  }
4837
5048
  }
4838
5049
  if (!node) return;
5050
+ // A display formula's search mark is itself a child of the column, and
5051
+ // clearing the search removes it: pin the formula inside it instead.
5052
+ if (node.matches('mark.search-hit') && node.firstElementChild) node = node.firstElementChild;
4839
5053
  anchorNode = node;
4840
5054
  anchorTop = node.getBoundingClientRect().top;
5055
+ anchorScrollY = window.scrollY;
5056
+ anchorStale = false;
4841
5057
  }
4842
5058
 
4843
5059
  function restoreScrollAnchor() {
@@ -4845,9 +5061,13 @@ ${mermaidInitTag}`)
4845
5061
  var delta = anchorNode.getBoundingClientRect().top - anchorTop;
4846
5062
  if (!delta) return;
4847
5063
  window.scrollTo(window.scrollX, window.scrollY + delta);
5064
+ // Our own correction is not a newer scroll: without this, the next frame
5065
+ // of a splitter drag reads it as one and drops the anchor.
5066
+ anchorScrollY = window.scrollY;
4848
5067
  }
4849
5068
 
4850
5069
  window.addEventListener('scroll', function () {
5070
+ if (!suppressAnchorCapture) anchorStale = true;
4851
5071
  if (anchorFrame) return;
4852
5072
  anchorFrame = window.requestAnimationFrame(function () {
4853
5073
  anchorFrame = 0;
@@ -4856,6 +5076,13 @@ ${mermaidInitTag}`)
4856
5076
  }, { passive: true });
4857
5077
 
4858
5078
  function holdScrollAnchor() {
5079
+ // A scroll issued before this reflow's frame may not have dispatched its
5080
+ // event yet (resize runs before scroll in a frame): compare offsets too.
5081
+ // A shorter document clamping the offset is not somebody else's scroll.
5082
+ var maxY = document.documentElement.scrollHeight - window.innerHeight;
5083
+ var moved = Math.abs(window.scrollY - anchorScrollY) > 1;
5084
+ var clamped = window.scrollY < anchorScrollY && window.scrollY >= maxY - 1;
5085
+ if (anchorStale || (moved && !clamped)) anchorNode = null;
4859
5086
  // Hold the anchor across the whole reflow: the scrollTo below fires scroll
4860
5087
  // events that would otherwise re-capture a mid-reflow position.
4861
5088
  suppressAnchorCapture = true;
@@ -4895,6 +5122,7 @@ ${mermaidInitTag}`)
4895
5122
  if (w === lastAnchorColW) return;
4896
5123
  lastAnchorColW = w;
4897
5124
  holdScrollAnchor();
5125
+ scheduleReadingLinePass();
4898
5126
  }).observe(contentEl);
4899
5127
  }
4900
5128
 
@@ -6377,6 +6605,9 @@ if (require.main === module) {
6377
6605
  const [,, src, dst] = process.argv;
6378
6606
  const BAKE_SVG = process.argv.slice(4).includes('--bake-svg');
6379
6607
  const LANG_ARG = (process.argv.slice(4).find((a) => a.startsWith('--lang=')) || '').slice('--lang='.length) || undefined;
6608
+ // bin/md2doc.js renders each format of one input in its own process and
6609
+ // passes this to every one after the first, so --html --pdf warns once.
6610
+ const WAVE_WARN = !process.argv.slice(4).includes('--no-wave-warn');
6380
6611
  if (!src || !dst) {
6381
6612
  console.error('Usage: node md2doc.js <input.md> <output.html|pdf>');
6382
6613
  process.exit(1);
@@ -6386,7 +6617,7 @@ if (require.main === module) {
6386
6617
 
6387
6618
  (async () => {
6388
6619
  const mdText = fs.readFileSync(src, 'utf8');
6389
- const { html } = await renderMarkdown(mdText, path.resolve(src), { lang: LANG_ARG });
6620
+ const { html } = await renderMarkdown(mdText, path.resolve(src), { lang: LANG_ARG, warnWaveOverflow: WAVE_WARN ? src : undefined });
6390
6621
  let finalHtml = html;
6391
6622
 
6392
6623
  if (ext === '.html') {