@helping-ai-workflow/md2doc 3.2.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/blockmap.js +146 -11
- package/lib/editor/cli.js +50 -1
- package/lib/editor/client.js +2236 -105
- package/lib/editor/server.js +75 -3
- package/lib/editor/toolbar-model.js +75 -14
- package/lib/md2doc.js +199 -20
- package/package.json +1 -1
package/lib/editor/server.js
CHANGED
|
@@ -145,15 +145,61 @@ function assertBlockRangesFit(blocks, lines) {
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
// F13: the never-opened grace deadline is deliberately its own constant,
|
|
149
|
+
// not idleTimeoutMs reused. idleTimeoutMs governs the gap since the last
|
|
150
|
+
// browser ping (client.js pings every 10s; the default 30,000ms tolerates
|
|
151
|
+
// a short run of missed pings once a real tab is confirmed present).
|
|
152
|
+
// Before the URL is opened there is no ping to measure against — the wait
|
|
153
|
+
// is instead for a human to alt-tab to a browser, or, over SSH, copy the
|
|
154
|
+
// printed URL across to a local machine and load it there, which routinely
|
|
155
|
+
// takes longer than idleTimeoutMs allows. Reusing idleTimeoutMs for that
|
|
156
|
+
// wait would close sessions on people still reading the terminal output.
|
|
157
|
+
// Five minutes is chosen as a margin generous for that human handoff while
|
|
158
|
+
// still finite; test/cli-edit.test.js drives it through a shortened
|
|
159
|
+
// override — an unopened session still exits on its own, and a session
|
|
160
|
+
// opened partway through the grace window keeps working afterward.
|
|
161
|
+
const DEFAULT_NEVER_OPENED_GRACE_MS = 5 * 60 * 1000;
|
|
162
|
+
|
|
163
|
+
// T21 item 3: closing this server has to mean "stop serving now", not "stop
|
|
164
|
+
// serving once every open socket happens to go away". `server.close()` on its
|
|
165
|
+
// own does the latter: it stops accepting, then waits for the connections it
|
|
166
|
+
// already has. A browser handed the edit URL opens a socket it may never send
|
|
167
|
+
// a request on (a speculative preconnect), and such a socket is not one Node
|
|
168
|
+
// retires by itself — driven against `md2doc --edit` on this box with a
|
|
169
|
+
// viewer stub that opened one TCP connection to the port and sent no bytes,
|
|
170
|
+
// SIGINT printed cli.js's line and the process was still alive 10 s later,
|
|
171
|
+
// 3 runs of 3 — while the same runs with a viewer stub that connected and
|
|
172
|
+
// sent a request, and with `--no-open`, exited in 7-10 ms. The same hang
|
|
173
|
+
// reaches the idle and never-opened deadlines below, which have no second
|
|
174
|
+
// keystroke to rescue them, so every exit this file owns goes through here.
|
|
175
|
+
//
|
|
176
|
+
// closeAllConnections() lands the sockets that close() would wait on. It is
|
|
177
|
+
// Node >= 18.2 and package.json's engines floor is >= 18, so the call is
|
|
178
|
+
// guarded rather than assumed; on an 18.0/18.1 runtime the behaviour is
|
|
179
|
+
// exactly what it was before this function existed.
|
|
180
|
+
function shutdownServer(server, reason) {
|
|
181
|
+
// F13: name the reason before closing, so cli.js's listener (the process's
|
|
182
|
+
// stdout writer) can print a line distinct from the other exits.
|
|
183
|
+
if (reason) server.emit('md2doc-auto-close', reason);
|
|
184
|
+
server.close();
|
|
185
|
+
if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function createEditorServer({
|
|
189
|
+
files, idleTimeoutMs = 30000, neverOpenedGraceMs = DEFAULT_NEVER_OPENED_GRACE_MS,
|
|
190
|
+
clientJs = '', listenPort = 0,
|
|
191
|
+
}) {
|
|
149
192
|
const absFiles = files.map((f) => path.resolve(f));
|
|
150
193
|
let idleTimer = null;
|
|
194
|
+
let neverOpenedTimer = null;
|
|
151
195
|
let started = false;
|
|
152
196
|
|
|
153
197
|
function bumpIdle(server) {
|
|
154
198
|
if (!started) return;
|
|
155
199
|
if (idleTimer) clearTimeout(idleTimer);
|
|
156
|
-
idleTimer = setTimeout(() =>
|
|
200
|
+
idleTimer = setTimeout(() => {
|
|
201
|
+
shutdownServer(server, 'idle');
|
|
202
|
+
}, idleTimeoutMs);
|
|
157
203
|
if (idleTimer.unref) idleTimer.unref();
|
|
158
204
|
}
|
|
159
205
|
|
|
@@ -442,6 +488,28 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
442
488
|
server.listen(listenPort, '127.0.0.1');
|
|
443
489
|
});
|
|
444
490
|
const port = server.address().port;
|
|
491
|
+
|
|
492
|
+
// F13: counts from listen (this point), not from process start — listen
|
|
493
|
+
// is the moment a client could first reach the server, and that is the
|
|
494
|
+
// deadline this timer is measuring against. Unref'd like idleTimer, and
|
|
495
|
+
// for the same reason: the listening socket is what should keep the
|
|
496
|
+
// process alive, not this timer. A timer that held a ref would keep the
|
|
497
|
+
// process up on its own even if the socket already went away, which is
|
|
498
|
+
// backwards for a clock whose only job is to give up and exit.
|
|
499
|
+
neverOpenedTimer = setTimeout(() => {
|
|
500
|
+
// The guard against closing a session that has already been opened —
|
|
501
|
+
// this timer is armed once, at listen time, and left to run; it is
|
|
502
|
+
// not cancelled by the GET /edit/:id handler. Whether the URL was
|
|
503
|
+
// opened by the time the deadline arrives is decided here, by reading
|
|
504
|
+
// `started` fresh at fire time. Proven load-bearing by mutation: with
|
|
505
|
+
// this line removed, an actively-pinged session still gets closed the
|
|
506
|
+
// moment this deadline passes (see test/cli-edit.test.js's "must
|
|
507
|
+
// survive the never-opened grace deadline" case).
|
|
508
|
+
if (started) return;
|
|
509
|
+
shutdownServer(server, 'never-opened');
|
|
510
|
+
}, neverOpenedGraceMs);
|
|
511
|
+
if (neverOpenedTimer.unref) neverOpenedTimer.unref();
|
|
512
|
+
|
|
445
513
|
return {
|
|
446
514
|
server,
|
|
447
515
|
port,
|
|
@@ -449,7 +517,11 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
449
517
|
const i = absFiles.indexOf(path.resolve(absPath));
|
|
450
518
|
return i === -1 ? null : `http://127.0.0.1:${port}/edit/${i}`;
|
|
451
519
|
},
|
|
452
|
-
close() {
|
|
520
|
+
close() {
|
|
521
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
522
|
+
if (neverOpenedTimer) clearTimeout(neverOpenedTimer);
|
|
523
|
+
shutdownServer(server, null);
|
|
524
|
+
},
|
|
453
525
|
};
|
|
454
526
|
}
|
|
455
527
|
|
|
@@ -29,11 +29,11 @@ const BUTTON_DEFS = [
|
|
|
29
29
|
{ id: 'check', group: 'block', label: '☑', icon: '☑', title: '待辦清單', toggle: false },
|
|
30
30
|
|
|
31
31
|
// inline
|
|
32
|
-
{ id: 'bold', group: 'inline', label: 'B', icon: 'B', title: '粗體', toggle:
|
|
33
|
-
{ id: 'italic', group: 'inline', label: 'I', icon: 'I', title: '斜體', toggle:
|
|
34
|
-
{ id: 'strike', group: 'inline', label: 'S', icon: 'S', title: '刪除線', toggle:
|
|
35
|
-
{ id: 'inline-code', group: 'inline', label: '`', icon: '`', title: '行內程式碼', toggle:
|
|
36
|
-
{ id: 'link', group: 'inline', label: '🔗', icon: '🔗', title: '連結', toggle:
|
|
32
|
+
{ id: 'bold', group: 'inline', label: 'B', icon: 'B', title: '粗體', toggle: true },
|
|
33
|
+
{ id: 'italic', group: 'inline', label: 'I', icon: 'I', title: '斜體', toggle: true },
|
|
34
|
+
{ id: 'strike', group: 'inline', label: 'S', icon: 'S', title: '刪除線', toggle: true },
|
|
35
|
+
{ id: 'inline-code', group: 'inline', label: '`', icon: '`', title: '行內程式碼', toggle: true },
|
|
36
|
+
{ id: 'link', group: 'inline', label: '🔗', icon: '🔗', title: '連結', toggle: true },
|
|
37
37
|
|
|
38
38
|
// indent
|
|
39
39
|
{ id: 'outdent', group: 'indent', label: '⇤', icon: '⇤', title: '減少縮排', toggle: false },
|
|
@@ -65,8 +65,36 @@ const GROUPS = Object.freeze(['history', 'block', 'inline', 'indent', 'insert',
|
|
|
65
65
|
// state right after rerenderAll, before any block gets focus/selection).
|
|
66
66
|
const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview']);
|
|
67
67
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
68
|
+
// The mark element each inline button toggles. This module cannot ask the
|
|
69
|
+
// DOM which marks a selection sits in, so the client measures that and hands
|
|
70
|
+
// the answer in as `ctx.marks` — keyed by TAG, not by button id, because
|
|
71
|
+
// .ed-seltb carries a U button with no counterpart in this roster and both
|
|
72
|
+
// toolbars are painted from one measurement. The mapping from button to tag
|
|
73
|
+
// has to live somewhere, and here is where the button ids are.
|
|
74
|
+
const MARK_TAG_BY_ID = Object.freeze({
|
|
75
|
+
bold: 'STRONG',
|
|
76
|
+
italic: 'EM',
|
|
77
|
+
strike: 'DEL',
|
|
78
|
+
'inline-code': 'CODE',
|
|
79
|
+
link: 'A',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// The inline-format ids, read off the map above rather than retyped. The
|
|
83
|
+
// `hasSelection` rule below used to carry a hand-written copy of this list;
|
|
84
|
+
// the marks rule under it would have been a second.
|
|
85
|
+
const MARK_IDS = Object.freeze(Object.keys(MARK_TAG_BY_ID));
|
|
86
|
+
|
|
87
|
+
// The per-tag values `ctx.marks` may carry, and what each one means for the
|
|
88
|
+
// button that toggles that tag. The client's selectionMarkStates() is what
|
|
89
|
+
// produces them; the names are shared vocabulary, not a re-derivation.
|
|
90
|
+
// 'whole' the selection lies inside one mark of that tag
|
|
91
|
+
// 'partial' the selection overlaps marks of that tag without lying in one
|
|
92
|
+
// 'none' no mark of that tag is involved
|
|
93
|
+
// 'inert' pressing the button would do nothing at all
|
|
94
|
+
|
|
95
|
+
// ctx: {blockType, indent, headingDepth, inList, listOrdered, hasSelection,
|
|
96
|
+
// mode, marks}
|
|
97
|
+
// -> { [id]: {active: bool, mixed: bool, disabled: bool, label?: string} }
|
|
70
98
|
function deriveState(ctx) {
|
|
71
99
|
const c = ctx || {};
|
|
72
100
|
const blockType = c.blockType;
|
|
@@ -79,7 +107,7 @@ function deriveState(ctx) {
|
|
|
79
107
|
|
|
80
108
|
const state = {};
|
|
81
109
|
for (const b of BUTTONS) {
|
|
82
|
-
state[b.id] = { active: false, disabled: false };
|
|
110
|
+
state[b.id] = { active: false, mixed: false, disabled: false };
|
|
83
111
|
}
|
|
84
112
|
|
|
85
113
|
// v3.2.1: the mode button names the NEXT state, never the current one.
|
|
@@ -152,24 +180,57 @@ function deriveState(ctx) {
|
|
|
152
180
|
// refuse for reasons this ctx does not carry (a structural list gate, a
|
|
153
181
|
// block that owns no source line).
|
|
154
182
|
if (!c.hasSelection) {
|
|
155
|
-
for (const id of
|
|
183
|
+
for (const id of MARK_IDS) {
|
|
156
184
|
state[id].disabled = true;
|
|
157
185
|
}
|
|
158
186
|
}
|
|
159
187
|
|
|
188
|
+
// v3.3.0 (F2): what the selection is already marked as. Before this the ctx
|
|
189
|
+
// carried one boolean about the selection and nothing about its identity.
|
|
190
|
+
// MEASURED on the build before this change, reading the fixed toolbar's B
|
|
191
|
+
// over a selection wholly inside a <strong>, one straddling its edge, and one
|
|
192
|
+
// in plain text: aria-pressed came back null on all three — the buttons were
|
|
193
|
+
// `toggle: false`, so the client's aria-pressed write skipped them.
|
|
194
|
+
//
|
|
195
|
+
// `active` and `mixed` are separate booleans rather than one tri-state
|
|
196
|
+
// because `active` is what the toggle-roster test sweeps for and what the
|
|
197
|
+
// rest of this function already speaks. The client turns the pair into
|
|
198
|
+
// aria-pressed true / mixed / false.
|
|
199
|
+
//
|
|
200
|
+
// 'inert' disables. It is NOT the same question as `hasSelection`: that one
|
|
201
|
+
// asks whether the action would refuse the selection outright, this one asks
|
|
202
|
+
// whether the branch the action would actually take is a no-op. A press on
|
|
203
|
+
// an inert button leaves the document exactly as it was.
|
|
204
|
+
const marks = c.marks;
|
|
205
|
+
if (marks) {
|
|
206
|
+
for (const id of MARK_IDS) {
|
|
207
|
+
const m = marks[MARK_TAG_BY_ID[id]];
|
|
208
|
+
state[id].active = m === 'whole';
|
|
209
|
+
state[id].mixed = m === 'partial';
|
|
210
|
+
if (m === 'inert') state[id].disabled = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
160
214
|
// mode 'source' is the full-document raw-source escape hatch: everything
|
|
161
215
|
// is disabled except the mode button that got you there (id 'preview',
|
|
162
|
-
// which in v3.2.1 is the edit <-> source toggle)
|
|
163
|
-
//
|
|
164
|
-
//
|
|
216
|
+
// which in v3.2.1 is the edit <-> source toggle) and, since T11-2,
|
|
217
|
+
// 'outline' (☰). The mode button is still the ONLY way back to 'edit' —
|
|
218
|
+
// that has not changed. 'outline' stays reachable because it is what
|
|
219
|
+
// opens the sidebar drawer (toggleOutlineSidebar() in
|
|
220
|
+
// lib/editor/client.js), and the fixed `.sidebar-toggle` button that used
|
|
221
|
+
// to reach the drawer another way is hidden in edit mode entirely (see the
|
|
222
|
+
// T11-2 comment in lib/md2doc.js) — a disabled ☰ here would leave source
|
|
223
|
+
// mode with no way to open that drawer at all. This override still runs
|
|
224
|
+
// last and still overrides every disabled flag computed above for every
|
|
225
|
+
// other button; do not narrow it for anything but these two ids.
|
|
165
226
|
if (mode === 'source') {
|
|
166
227
|
for (const b of BUTTONS) {
|
|
167
|
-
state[b.id].disabled = b.id !== 'preview';
|
|
228
|
+
state[b.id].disabled = b.id !== 'preview' && b.id !== 'outline';
|
|
168
229
|
}
|
|
169
230
|
}
|
|
170
231
|
|
|
171
232
|
return state;
|
|
172
233
|
}
|
|
173
234
|
|
|
174
|
-
return { BUTTONS, GROUPS, deriveState };
|
|
235
|
+
return { BUTTONS, GROUPS, MARK_TAG_BY_ID, deriveState };
|
|
175
236
|
});
|
package/lib/md2doc.js
CHANGED
|
@@ -1349,7 +1349,19 @@ ${itemsHtml}
|
|
|
1349
1349
|
it. Fixed chrome takes no layout space, so the page has to make its
|
|
1350
1350
|
own: this is the only place that offset is written. The mobile rule at
|
|
1351
1351
|
max-width:1080px above already reserves 60px for .sidebar-toggle; the
|
|
1352
|
-
bar sits above it
|
|
1352
|
+
bar sits above it. That 60px is NOT re-reserved here: T11-2 hides
|
|
1353
|
+
.sidebar-toggle in edit mode (the rule further down this stylesheet), so
|
|
1354
|
+
in edit mode there is no button under the bar to clear, and adding the
|
|
1355
|
+
two offsets left a band of empty page nothing was ever painted into.
|
|
1356
|
+
Measured on the build that did add them, at 1080/1000/800 wide:
|
|
1357
|
+
.page-layout's computed padding-top was 104px against a 44px toolbar —
|
|
1358
|
+
60px of dead space, and a 105px gap between the toolbar's bottom edge
|
|
1359
|
+
and the first block against 45px at 1200 wide, where the desktop rule
|
|
1360
|
+
applies. The @media block below therefore still has to carry an explicit
|
|
1361
|
+
value, identical to the desktop one: the READER stylesheet's own
|
|
1362
|
+
'max-width: 1080px' rule sets padding-top: 60px for the drawer toggle it
|
|
1363
|
+
still shows, and deleting this override would hand the edit page that
|
|
1364
|
+
60px straight back.
|
|
1353
1365
|
.reader-sidebar is sticky at top: 24px in the base stylesheet — pushed
|
|
1354
1366
|
down by the same amount here so the outline does not slide under the
|
|
1355
1367
|
bar, with its height reduced to match so it still ends at the viewport
|
|
@@ -1360,7 +1372,7 @@ ${itemsHtml}
|
|
|
1360
1372
|
height: calc(100vh - 48px - var(--ed-toolbar-h));
|
|
1361
1373
|
}
|
|
1362
1374
|
@media (max-width: 1080px) {
|
|
1363
|
-
.page-layout { padding-top:
|
|
1375
|
+
.page-layout { padding-top: var(--ed-toolbar-h); }
|
|
1364
1376
|
.reader-sidebar { top: 0; height: 100vh; padding-top: calc(16px + var(--ed-toolbar-h)); }
|
|
1365
1377
|
}
|
|
1366
1378
|
/* The 大綱 button's DESKTOP half, on an attribute of its OWN.
|
|
@@ -1382,7 +1394,41 @@ ${itemsHtml}
|
|
|
1382
1394
|
@media (min-width: 1081px) {
|
|
1383
1395
|
body[data-ed-outline-hidden] .reader-sidebar,
|
|
1384
1396
|
body[data-ed-outline-hidden] .sidebar-splitter { display: none; }
|
|
1385
|
-
}
|
|
1397
|
+
}
|
|
1398
|
+
/* T11-2 step 3: this button sits at z-index 100 (base stylesheet, above)
|
|
1399
|
+
directly under .ed-toolbar's z-index 101. Measured at 1080x900 and
|
|
1400
|
+
800x900: document.elementFromPoint() at the button's own rect centre
|
|
1401
|
+
returns the toolbar, not the button, at both widths — a real pointer
|
|
1402
|
+
click never lands on it there, only a synthetic .click() does. The
|
|
1403
|
+
toolbar's own left/right are both 0, spanning the full viewport width,
|
|
1404
|
+
so nothing about that occlusion is a function of width; the two
|
|
1405
|
+
measured points are a sample of a band the toolbar covers edge to
|
|
1406
|
+
edge, not two lucky ones. It is safe to remove in edit mode because
|
|
1407
|
+
lib/editor/client.js's toolbar carries its own outline button
|
|
1408
|
+
(runToolbarAction()'s 'outline' case, which calls toggleOutlineSidebar(),
|
|
1409
|
+
the same function this button calls) which opens the identical drawer,
|
|
1410
|
+
and Task 8 gave that toolbar a keyboard cursor (Alt+F10) — the outline
|
|
1411
|
+
button is not among the ids Task 8 measured as stranding the caret on
|
|
1412
|
+
BODY after activation. This rule is edit-mode-only (editModeLayoutCss);
|
|
1413
|
+
reader-mode output never includes it, so the mobile drawer toggle there
|
|
1414
|
+
is unaffected. */
|
|
1415
|
+
.sidebar-toggle { display: none; }
|
|
1416
|
+
/* T11-2 step 5: enterSourceMode() (lib/editor/client.js) sets
|
|
1417
|
+
contentEl.hidden = true and mounts a plain textarea in its place, and
|
|
1418
|
+
setDocMode() writes docMode onto body as data-ed-mode. Measured on that
|
|
1419
|
+
build: with ten TOC links listed, clicking the last one moved neither
|
|
1420
|
+
the (hidden) reading position nor the textarea's own scrollTop
|
|
1421
|
+
(3924 before and after) — the click target was a normal anchor jump /
|
|
1422
|
+
scrollIntoView() reaching into a subtree rooted at .content, and
|
|
1423
|
+
.content's own display:none is what absorbed it; the same subtree
|
|
1424
|
+
relationship holds for a search result's jump target. Hiding the TOC
|
|
1425
|
+
nav and the search-results panel while data-ed-mode="source" removes
|
|
1426
|
+
the two controls whose only reachable target sits in that hidden
|
|
1427
|
+
subtree; the search input row (.reader-tools) is left as-is — driving
|
|
1428
|
+
it against the textarea's own line numbers instead is a separate
|
|
1429
|
+
feasibility question, not answered here. */
|
|
1430
|
+
body[data-ed-mode="source"] .toc,
|
|
1431
|
+
body[data-ed-mode="source"] .search-results { display: none !important; }`;
|
|
1386
1432
|
|
|
1387
1433
|
const html = `<!DOCTYPE html>
|
|
1388
1434
|
<html lang="en">
|
|
@@ -2092,6 +2138,17 @@ ${itemsHtml}
|
|
|
2092
2138
|
background: #ffffff;
|
|
2093
2139
|
}
|
|
2094
2140
|
body[data-lightbox-open] { overflow: hidden; }
|
|
2141
|
+
/* The 'html, body { overflow-x: clip }' rule above keeps documentElement's
|
|
2142
|
+
own overflow-x at clip, not visible, so overflow does not propagate from
|
|
2143
|
+
body to the viewport per the CSS spec's root-propagation rule; the
|
|
2144
|
+
scrolling element stays documentElement, and its overflow-y sits at the
|
|
2145
|
+
visible default. That is the gap the body rule above does not close --
|
|
2146
|
+
measured in test/lightbox.test.js and test/editor-journey.test.js: a
|
|
2147
|
+
wheel over .lightbox-bar, PageDown and End all reach documentElement
|
|
2148
|
+
while it is open. Locking documentElement's own overflow here closes
|
|
2149
|
+
it; JS mirrors the data-lightbox-open attribute onto the html element
|
|
2150
|
+
in openLightbox/closeLightbox below. */
|
|
2151
|
+
html[data-lightbox-open] { overflow: hidden; }
|
|
2095
2152
|
@media print { .lightbox { display: none !important; } }
|
|
2096
2153
|
@media (max-width: 1080px) {
|
|
2097
2154
|
.sidebar-toggle { display: inline-flex; align-items: center; }
|
|
@@ -2318,7 +2375,21 @@ ${itemsHtml}
|
|
|
2318
2375
|
this element's containing block and left:100% lands it flush against
|
|
2319
2376
|
the menu's right edge. Source order matters: equal specificity with
|
|
2320
2377
|
.ed-handle-menu's own left:-4px, so this rule must stay AFTER it. */
|
|
2321
|
-
|
|
2378
|
+
/* Viewport-clamp backstop: openConvertSubmenu() (lib/editor/client.js)
|
|
2379
|
+
shifts the panel's top up so its bottom clears the viewport when there
|
|
2380
|
+
is room to do so — that shift is the clamp. This rule covers what the
|
|
2381
|
+
shift cannot: a viewport shorter than the panel, where the viewport's
|
|
2382
|
+
own top edge still leaves the panel's bottom edge off-screen. What
|
|
2383
|
+
happens under max-height, in the order it happens: CONVERT_TARGETS's
|
|
2384
|
+
buttons (lib/editor/convert-md.js), each carrying .ed-handle-menu-btn's
|
|
2385
|
+
own height (26px), first shrink via flex as the cap tightens (measured
|
|
2386
|
+
on this branch: 26px at a cap around 342px, stepping down to 18px
|
|
2387
|
+
around a 250px cap) — no scrolling yet, the list still fits end to
|
|
2388
|
+
end, just shorter per row. overflow-y: auto only starts moving content
|
|
2389
|
+
once the rows have already reached that floor and the cap is still
|
|
2390
|
+
tighter than the list at 18px per row. (No backticks in this comment:
|
|
2391
|
+
it lives inside a JS template literal.) */
|
|
2392
|
+
.ed-handle-submenu { left: 100%; margin-left: 4px; max-height: calc(100vh - 8px); overflow-y: auto; }
|
|
2322
2393
|
.ed-handle-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
|
|
2323
2394
|
.ed-handle-menu-btn[hidden] { display: none; }
|
|
2324
2395
|
/* §10-gap fix: block-level INSERT — the + button sits NEXT TO the ⠿
|
|
@@ -2529,7 +2600,23 @@ ${itemsHtml}
|
|
|
2529
2600
|
font: inherit; font-size: 13px; line-height: 1;
|
|
2530
2601
|
white-space: nowrap; cursor: pointer;
|
|
2531
2602
|
}
|
|
2532
|
-
.ed-seltb-btn:hover { background: rgba(255, 255, 255, 0.18); }
|
|
2603
|
+
.ed-seltb-btn:hover:not([aria-disabled="true"]) { background: rgba(255, 255, 255, 0.18); }
|
|
2604
|
+
/* v3.3.0 (F2): the four states these buttons report about the selection.
|
|
2605
|
+
'true' (the selection is entirely inside a mark of this type) is the
|
|
2606
|
+
filled treatment the fixed toolbar already uses. 'mixed' (the selection
|
|
2607
|
+
overlaps marks of this type without lying inside one) is deliberately NOT
|
|
2608
|
+
the filled one — pressing it removes marks rather than adding any — so it
|
|
2609
|
+
gets a dashed edge and a half-strength ground instead. 'false' keeps the
|
|
2610
|
+
rule above. INERT is aria-disabled, never the disabled DOM property; see
|
|
2611
|
+
paintSelToolbarMarks() in lib/editor/client.js for why. */
|
|
2612
|
+
.ed-seltb-btn[aria-pressed="true"] {
|
|
2613
|
+
background: #e6edf3; color: #101215; border-color: #e6edf3;
|
|
2614
|
+
}
|
|
2615
|
+
.ed-seltb-btn[aria-pressed="mixed"] {
|
|
2616
|
+
background: rgba(230, 237, 243, 0.3);
|
|
2617
|
+
border-style: dashed; border-color: rgba(230, 237, 243, 0.8);
|
|
2618
|
+
}
|
|
2619
|
+
.ed-seltb-btn[aria-disabled="true"] { opacity: 0.38; cursor: default; }
|
|
2533
2620
|
.ed-seltb-b { font-weight: bold; }
|
|
2534
2621
|
.ed-seltb-i { font-style: italic; }
|
|
2535
2622
|
.ed-seltb-s { text-decoration: line-through; }
|
|
@@ -2550,13 +2637,67 @@ ${itemsHtml}
|
|
|
2550
2637
|
.ed-toolbar {
|
|
2551
2638
|
position: fixed; top: 0; left: 0; right: 0; z-index: 101;
|
|
2552
2639
|
box-sizing: border-box; height: var(--ed-toolbar-h);
|
|
2553
|
-
display: flex; align-items: center; justify-content:
|
|
2640
|
+
display: flex; align-items: center; justify-content: flex-start;
|
|
2554
2641
|
flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
|
|
2555
2642
|
gap: 6px; padding: 8px 12px;
|
|
2643
|
+
/* F12, both halves measured on this build at 900px viewport height.
|
|
2644
|
+
justify-content was center. Centering an overflowing flex row splits
|
|
2645
|
+
the overflow across BOTH ends, and the leading end of a scroll
|
|
2646
|
+
container's content is not reachable by scrolling: driven at 820 /
|
|
2647
|
+
640 / 420 px wide, sweeping scrollLeft from 0 to its maximum in 8px
|
|
2648
|
+
steps and reading every button's rect, the buttons that never once sat
|
|
2649
|
+
inside the viewport were [undo] / [undo, redo, headings] /
|
|
2650
|
+
[undo, redo, headings, quote, code, list]. On this build — leading-edge
|
|
2651
|
+
layout plus the padding-right below, driven together, not one at a time
|
|
2652
|
+
— the same sweep leaves that set empty at all three widths.
|
|
2653
|
+
|
|
2654
|
+
padding-right buys the buttons room to clear .ed-toolbar-status, whose
|
|
2655
|
+
fixed box occupies the viewport's right 124px (right: 12px plus
|
|
2656
|
+
min-width: 7rem at this font size). The claim it supports is narrow:
|
|
2657
|
+
SOME scroll position exists at which a given button is clear of the
|
|
2658
|
+
slot. It is NOT "a button can never sit under the slot" — driven at
|
|
2659
|
+
scrollLeft 0 after this change, [outline, preview] still overlap the
|
|
2660
|
+
slot at a 900px viewport and [check, bold, italic, strike] do at 420px.
|
|
2661
|
+
What makes that harmless is the slot's own pointer-events: none, in
|
|
2662
|
+
the rule further down — the load-bearing line, not this one. */
|
|
2663
|
+
padding-right: 124px;
|
|
2556
2664
|
background: rgba(16, 18, 21, 0.92); color: #e6edf3;
|
|
2665
|
+
/* F12, second half: before this the bar scrolled but nothing on screen
|
|
2666
|
+
said so, so a button parked outside the viewport looked absent rather
|
|
2667
|
+
than off to one side. Two edge gradients, painted only on the side
|
|
2668
|
+
that still has content hidden behind it; the client runtime writes
|
|
2669
|
+
data-ed-tb-overflow from scrollLeft against the scroll maximum.
|
|
2670
|
+
|
|
2671
|
+
Deliberately a background layer on the bar itself rather than a new
|
|
2672
|
+
overlay node. An overlay would have to be taken out of flow to avoid
|
|
2673
|
+
becoming a flex item in this row, and an out-of-flow child of this bar
|
|
2674
|
+
anchors to the scrolled content box and drifts with it (the
|
|
2675
|
+
.ed-toolbar-status note below records that measurement) — while
|
|
2676
|
+
anchoring it to the viewport instead would add a floating layer to the
|
|
2677
|
+
set test/editor-journey.test.js enumerates and demands a scroll answer
|
|
2678
|
+
for. A background layer needs neither: background-attachment defaults
|
|
2679
|
+
to scroll, which pins the gradient to this element's padding box while
|
|
2680
|
+
its contents move underneath.
|
|
2681
|
+
|
|
2682
|
+
These gradients sit behind the buttons, because that is where the
|
|
2683
|
+
painting order puts a background layer: above the background colour,
|
|
2684
|
+
below the element's content. That is the reason they are dark rather
|
|
2685
|
+
than light — darkening the ground a translucent button sits on is what
|
|
2686
|
+
reads as content passing under an edge, where a light wash behind the
|
|
2687
|
+
same button would not. */
|
|
2688
|
+
--ed-tb-edge-l: rgba(0, 0, 0, 0);
|
|
2689
|
+
--ed-tb-edge-r: rgba(0, 0, 0, 0);
|
|
2690
|
+
background-image:
|
|
2691
|
+
linear-gradient(to right, var(--ed-tb-edge-l), rgba(0, 0, 0, 0)),
|
|
2692
|
+
linear-gradient(to left, var(--ed-tb-edge-r), rgba(0, 0, 0, 0));
|
|
2693
|
+
background-repeat: no-repeat;
|
|
2694
|
+
background-position: left center, right center;
|
|
2695
|
+
background-size: 26px 100%, 26px 100%;
|
|
2557
2696
|
font-size: 13px;
|
|
2558
2697
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
2559
2698
|
}
|
|
2699
|
+
.ed-toolbar[data-ed-tb-overflow~="left"] { --ed-tb-edge-l: rgba(0, 0, 0, 0.55); }
|
|
2700
|
+
.ed-toolbar[data-ed-tb-overflow~="right"] { --ed-tb-edge-r: rgba(0, 0, 0, 0.55); }
|
|
2560
2701
|
.ed-toolbar-sep {
|
|
2561
2702
|
flex: 0 0 auto; width: 1px; height: 18px; margin: 0 2px;
|
|
2562
2703
|
background: rgba(255, 255, 255, 0.25);
|
|
@@ -2570,18 +2711,45 @@ ${itemsHtml}
|
|
|
2570
2711
|
white-space: nowrap; cursor: pointer;
|
|
2571
2712
|
}
|
|
2572
2713
|
.ed-toolbar-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.18); }
|
|
2714
|
+
/* F12: where the toolbar's keyboard cursor is. An outline rather than a
|
|
2715
|
+
background so it stays legible on a button that is already pressed-in
|
|
2716
|
+
(aria-pressed paints that one nearly white). Claim no more for it than a
|
|
2717
|
+
keyboard route: the buttons keep their tabindex="-1" and the caret stays
|
|
2718
|
+
in the document, so no focus move happens for assistive technology to
|
|
2719
|
+
follow, and what this cue does for a screen reader has not been measured
|
|
2720
|
+
here. What it does do is let the row be walked without a mouse. */
|
|
2721
|
+
.ed-toolbar-btn[data-ed-tb-cursor] {
|
|
2722
|
+
outline: 2px solid #6ea8fe; outline-offset: 1px;
|
|
2723
|
+
}
|
|
2573
2724
|
.ed-toolbar-btn:disabled { opacity: 0.38; cursor: default; }
|
|
2574
2725
|
.ed-toolbar-btn[aria-pressed="true"] {
|
|
2575
2726
|
background: #e6edf3; color: #101215; border-color: #e6edf3;
|
|
2576
2727
|
}
|
|
2728
|
+
/* v3.3.0 (F2): the middle state, matching .ed-seltb-btn[aria-pressed="mixed"]
|
|
2729
|
+
above so the two bars answer the same question the same way. */
|
|
2730
|
+
.ed-toolbar-btn[aria-pressed="mixed"] {
|
|
2731
|
+
background: rgba(230, 237, 243, 0.3);
|
|
2732
|
+
border-style: dashed; border-color: rgba(230, 237, 243, 0.8);
|
|
2733
|
+
}
|
|
2577
2734
|
.ed-toolbar-b { font-weight: bold; }
|
|
2578
2735
|
.ed-toolbar-i { font-style: italic; }
|
|
2579
2736
|
.ed-toolbar-s { text-decoration: line-through; }
|
|
2580
|
-
/* H1 fix
|
|
2581
|
-
|
|
2582
|
-
margin
|
|
2583
|
-
justify-content
|
|
2584
|
-
|
|
2737
|
+
/* H1 fix — and its reason has since moved, so both readings are kept here
|
|
2738
|
+
rather than one silently standing in for the other. The brief's literal
|
|
2739
|
+
margin-left: auto (a flex-flow item) was measured on v3.2.0 to break
|
|
2740
|
+
.ed-toolbar's justify-content: center — an auto margin on any flex item
|
|
2741
|
+
absorbs ALL positive free space before justify-content sees any, so the
|
|
2742
|
+
~22-button group snapped flush left on every viewport wide enough not to
|
|
2743
|
+
overflow. F12 replaced that declaration with justify-content: flex-start
|
|
2744
|
+
(the .ed-toolbar rule above), so flush left IS the shipped layout now and
|
|
2745
|
+
that particular failure mode no longer exists. What the rule guards today
|
|
2746
|
+
is the slot's out-of-flow-ness, and the assertion beside it in
|
|
2747
|
+
test/editmode-render.test.js carries the re-driven numbers: against the
|
|
2748
|
+
slot as it ships the margin changes nothing at all, while putting the slot
|
|
2749
|
+
back in flow with it turns the slot into a scrolling flex item
|
|
2750
|
+
(scrollWidth 975 -> 1093 at 420px) and moves its rect off the strip
|
|
2751
|
+
.ed-toolbar's padding-right reserves for it ([1276, 1388] -> [1164, 1276]
|
|
2752
|
+
at 1400px). position: fixed removes
|
|
2585
2753
|
the slot from the flex layout entirely (buttons measured byte-identical
|
|
2586
2754
|
to a build with the slot absent, at both a 1400px and a 420px viewport),
|
|
2587
2755
|
and — unlike position: absolute against .ed-toolbar's own
|
|
@@ -2593,14 +2761,17 @@ ${itemsHtml}
|
|
|
2593
2761
|
H2 fix: pointer-events: none below is LOAD-BEARING, not decorative.
|
|
2594
2762
|
v3.2.1 filled this slot with the current mode's name ('編輯' / '原始碼'),
|
|
2595
2763
|
so it no longer ships empty — but that content is inert TEXT, never a
|
|
2596
|
-
control, which is exactly what keeps the rule below sufficient.
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2764
|
+
control, which is exactly what keeps the rule below sufficient. The
|
|
2765
|
+
overlap it keeps harmless is real and still present after F12 moved this
|
|
2766
|
+
bar's scroll geometry: at scrollLeft 0, this box's footprint covers
|
|
2767
|
+
outline and preview at a 900px-wide viewport, and check / bold / italic /
|
|
2768
|
+
strike at 420px. Re-driven on this build, hit-testing the middle of each
|
|
2769
|
+
of those overlaps: with the rule as it ships,
|
|
2770
|
+
document.elementFromPoint() answers the BUTTON at every one of them; flip
|
|
2771
|
+
this span's pointer-events to auto and leave everything else alone, and
|
|
2772
|
+
every one of them answers this span instead. That reading is the same hit
|
|
2773
|
+
test a press resolves through, and the rule below is what stands between
|
|
2774
|
+
the two readings — not the span's emptiness. WHOEVER PUTS A CONTROL HERE (or
|
|
2604
2775
|
wires up two-way sync, §3) MUST remove or scope this
|
|
2605
2776
|
rule AND independently solve the overlap (e.g. reflow the slot in-flow
|
|
2606
2777
|
at narrow widths, or shrink/hide it below some width) — simply
|
|
@@ -2613,13 +2784,19 @@ ${itemsHtml}
|
|
|
2613
2784
|
pointer-events: none; }
|
|
2614
2785
|
/* The H▾ dropdown: same singleton-panel idiom as .ed-insert-menu, but
|
|
2615
2786
|
anchored to a fixed bar rather than moved into a block, so it is
|
|
2616
|
-
positioned in viewport coordinates by the client runtime.
|
|
2787
|
+
positioned in viewport coordinates by the client runtime.
|
|
2788
|
+
max-height/overflow-y: same backstop role as .ed-handle-submenu above
|
|
2789
|
+
(Task 9 fix round 1, Ruling T9-5) — toggleToolbarHeadingMenu()
|
|
2790
|
+
(lib/editor/client.js) does the shift-to-fit; this covers a viewport
|
|
2791
|
+
shorter than the panel, where the shift alone cannot bring the whole
|
|
2792
|
+
list on-screen. */
|
|
2617
2793
|
.ed-toolbar-menu {
|
|
2618
2794
|
position: fixed; z-index: 102;
|
|
2619
2795
|
display: flex; flex-direction: column; gap: 2px;
|
|
2620
2796
|
padding: 4px; border-radius: 8px;
|
|
2621
2797
|
background: rgba(16, 18, 21, 0.96); color: #e6edf3;
|
|
2622
2798
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
|
2799
|
+
max-height: calc(100vh - 8px); overflow-y: auto;
|
|
2623
2800
|
}
|
|
2624
2801
|
.ed-toolbar-menu[hidden] { display: none; }
|
|
2625
2802
|
.ed-toolbar-menu-btn {
|
|
@@ -4206,6 +4383,7 @@ ${mermaidInitTag}`)
|
|
|
4206
4383
|
lightboxReturnScroll = window.scrollY;
|
|
4207
4384
|
suppressAnchorCapture = true;
|
|
4208
4385
|
document.body.setAttribute('data-lightbox-open', '');
|
|
4386
|
+
document.documentElement.setAttribute('data-lightbox-open', '');
|
|
4209
4387
|
lightboxEl.hidden = false;
|
|
4210
4388
|
lightboxStage.scrollTop = 0;
|
|
4211
4389
|
lightboxStage.scrollLeft = 0;
|
|
@@ -4218,6 +4396,7 @@ ${mermaidInitTag}`)
|
|
|
4218
4396
|
if (!lightboxIsOpen()) return;
|
|
4219
4397
|
lightboxEl.hidden = true;
|
|
4220
4398
|
document.body.removeAttribute('data-lightbox-open');
|
|
4399
|
+
document.documentElement.removeAttribute('data-lightbox-open');
|
|
4221
4400
|
annoCancelDraft();
|
|
4222
4401
|
annoSyncInline();
|
|
4223
4402
|
annoSourceNode = null;
|