@helping-ai-workflow/md2doc 3.2.0 → 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 +3395 -174
- package/lib/editor/docsource.js +16 -6
- package/lib/editor/server.js +75 -3
- package/lib/editor/toolbar-model.js +95 -19
- package/lib/md2doc.js +203 -24
- package/package.json +2 -2
package/lib/editor/docsource.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
})(typeof self !== 'undefined' ? self : this, function () {
|
|
12
12
|
|
|
13
13
|
// Pure state machine + lines[]<->string conversion for the "追加 4 全文原始碼
|
|
14
|
-
// 逃生口" (whole-document source escape hatch): 編輯 ⇄
|
|
14
|
+
// 逃生口" (whole-document source escape hatch): 編輯 ⇄ 原始碼.
|
|
15
|
+
// v3.2.1: 預覽(preview)已移除,見下方 MODES 上的說明。
|
|
15
16
|
//
|
|
16
17
|
// EOL representation: `eol` is always a single literal separator string --
|
|
17
18
|
// '\n', '\r\n' or '\r' -- the same convention server.js already establishes
|
|
@@ -31,12 +32,21 @@
|
|
|
31
32
|
// 2. `\r\n` is listed before the two single-character alternatives, so a
|
|
32
33
|
// CRLF pair is always consumed as ONE separator, never split into a
|
|
33
34
|
// spurious blank line between a lone \r and a lone \n.
|
|
34
|
-
const MODES = ['edit', 'source'
|
|
35
|
+
const MODES = ['edit', 'source'];
|
|
35
36
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
37
|
+
// v3.2.1: 兩態循環 edit <-> source。preview 移除 —— 實測它相對 edit 只做
|
|
38
|
+
// 兩件事(applyPreviewEditability(false) 與兩行隱藏 ⠿/+ 的 CSS),卻擋在
|
|
39
|
+
// source 回 edit 的路上,而且 22 顆工具列按鈕中 13 顆在它底下仍然寫得進磁碟。
|
|
40
|
+
//
|
|
41
|
+
// 為什麼是移除而不是加閘門:一個「列舉可及面」推導出來的唯讀閘門必然漏掉
|
|
42
|
+
// 一整族 —— 切換前就已經開著的 ⠿ 選單在切換後仍是 140x118、display:flex,
|
|
43
|
+
// 其 16 個項目每一個都寫得進磁碟。閘門要約 8 個守衛點跨 2 個檔案才擋得住,
|
|
44
|
+
// 而 preview 本身沒有任何 edit 給不了的能力。
|
|
45
|
+
//
|
|
46
|
+
// An unrecognized mode (defensive: a caller passing something stale) falls
|
|
47
|
+
// back to the first state rather than throwing, matching how the rest of
|
|
48
|
+
// this module prefers a safe default over a thrown exception for malformed
|
|
49
|
+
// input.
|
|
40
50
|
function next(mode) {
|
|
41
51
|
const idx = MODES.indexOf(mode);
|
|
42
52
|
return MODES[(idx + 1) % MODES.length];
|
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 },
|
|
@@ -48,7 +48,13 @@ const BUTTON_DEFS = [
|
|
|
48
48
|
|
|
49
49
|
// view
|
|
50
50
|
{ id: 'outline', group: 'view', label: '☰', icon: '☰', title: '大綱', toggle: false },
|
|
51
|
-
|
|
51
|
+
// v3.2.1: 這顆是 edit <-> source 的兩態切換(preview 模式已移除)。id 保留
|
|
52
|
+
// 為 'preview' 是刻意的 —— 每一支選擇器、journey 案例與 runtime 測試都指名
|
|
53
|
+
// 它,改名只換得一個更好聽的字串。label / title 不是烘死值:deriveState()
|
|
54
|
+
// 每次都依當前 mode 覆寫成【下一個】狀態的名字,這裡的值只是 edit 模式的
|
|
55
|
+
// 起始值。toggle: false —— 兩態下 aria-pressed 講不出真話(按鈕講的是去處,
|
|
56
|
+
// 不是目前所在),真話由會變的 label / title 承擔。
|
|
57
|
+
{ id: 'preview', group: 'view', label: 'M↓', icon: 'M↓', title: '切換到原始碼模式', toggle: false },
|
|
52
58
|
];
|
|
53
59
|
|
|
54
60
|
const BUTTONS = Object.freeze(BUTTON_DEFS.map((b) => Object.freeze(Object.assign({}, b))));
|
|
@@ -59,8 +65,36 @@ const GROUPS = Object.freeze(['history', 'block', 'inline', 'indent', 'insert',
|
|
|
59
65
|
// state right after rerenderAll, before any block gets focus/selection).
|
|
60
66
|
const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview']);
|
|
61
67
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
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} }
|
|
64
98
|
function deriveState(ctx) {
|
|
65
99
|
const c = ctx || {};
|
|
66
100
|
const blockType = c.blockType;
|
|
@@ -73,14 +107,21 @@ function deriveState(ctx) {
|
|
|
73
107
|
|
|
74
108
|
const state = {};
|
|
75
109
|
for (const b of BUTTONS) {
|
|
76
|
-
state[b.id] = { active: false, disabled: false };
|
|
110
|
+
state[b.id] = { active: false, mixed: false, disabled: false };
|
|
77
111
|
}
|
|
78
112
|
|
|
79
|
-
//
|
|
80
|
-
// the
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
113
|
+
// v3.2.1: the mode button names the NEXT state, never the current one.
|
|
114
|
+
// Under the old tri-state cycle it was permanently labelled 「預覽」 and
|
|
115
|
+
// reported aria-pressed=true in BOTH non-edit modes, so pressing it twice
|
|
116
|
+
// landed in source with no grips and an interface that said nothing about
|
|
117
|
+
// where you were — the reported "stuck" defect. It carries no `active`
|
|
118
|
+
// now: with two states the honest signal is the label, and the current
|
|
119
|
+
// mode is spelled out in .ed-toolbar-status by the client.
|
|
120
|
+
// It stays enabled in source mode via the mode==='source' override below
|
|
121
|
+
// (do not gate this on inList/hasBlock — it is the escape hatch OUT of
|
|
122
|
+
// those states).
|
|
123
|
+
state.preview.label = mode === 'source' ? '✎' : 'M↓';
|
|
124
|
+
state.preview.title = mode === 'source' ? '切換到編輯模式' : '切換到原始碼模式';
|
|
84
125
|
|
|
85
126
|
// headings: dropdown whose label tracks the current heading depth.
|
|
86
127
|
state.headings.label = blockType === 'heading' ? 'H' + headingDepth : 'H';
|
|
@@ -139,22 +180,57 @@ function deriveState(ctx) {
|
|
|
139
180
|
// refuse for reasons this ctx does not carry (a structural list gate, a
|
|
140
181
|
// block that owns no source line).
|
|
141
182
|
if (!c.hasSelection) {
|
|
142
|
-
for (const id of
|
|
183
|
+
for (const id of MARK_IDS) {
|
|
143
184
|
state[id].disabled = true;
|
|
144
185
|
}
|
|
145
186
|
}
|
|
146
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
|
+
|
|
147
214
|
// mode 'source' is the full-document raw-source escape hatch: everything
|
|
148
|
-
// is disabled except the
|
|
149
|
-
//
|
|
215
|
+
// is disabled except the mode button that got you there (id 'preview',
|
|
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.
|
|
150
226
|
if (mode === 'source') {
|
|
151
227
|
for (const b of BUTTONS) {
|
|
152
|
-
state[b.id].disabled = b.id !== 'preview';
|
|
228
|
+
state[b.id].disabled = b.id !== 'preview' && b.id !== 'outline';
|
|
153
229
|
}
|
|
154
230
|
}
|
|
155
231
|
|
|
156
232
|
return state;
|
|
157
233
|
}
|
|
158
234
|
|
|
159
|
-
return { BUTTONS, GROUPS, deriveState };
|
|
235
|
+
return { BUTTONS, GROUPS, MARK_TAG_BY_ID, deriveState };
|
|
160
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
|
|
@@ -2590,16 +2758,21 @@ ${itemsHtml}
|
|
|
2590
2758
|
row's overflow-x: auto scrolls (measured identical rect at scrollLeft
|
|
2591
2759
|
0 / mid / max, where the absolute alternative moved by the exact scroll
|
|
2592
2760
|
delta). References no gutter-alignment custom property.
|
|
2593
|
-
H2 fix: pointer-events: none below is LOAD-BEARING
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
(
|
|
2761
|
+
H2 fix: pointer-events: none below is LOAD-BEARING, not decorative.
|
|
2762
|
+
v3.2.1 filled this slot with the current mode's name ('編輯' / '原始碼'),
|
|
2763
|
+
so it no longer ships empty — but that content is inert TEXT, never a
|
|
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
|
|
2775
|
+
wires up two-way sync, §3) MUST remove or scope this
|
|
2603
2776
|
rule AND independently solve the overlap (e.g. reflow the slot in-flow
|
|
2604
2777
|
at narrow widths, or shrink/hide it below some width) — simply
|
|
2605
2778
|
deleting this line without doing that reopens the swallowed-click
|
|
@@ -2611,13 +2784,19 @@ ${itemsHtml}
|
|
|
2611
2784
|
pointer-events: none; }
|
|
2612
2785
|
/* The H▾ dropdown: same singleton-panel idiom as .ed-insert-menu, but
|
|
2613
2786
|
anchored to a fixed bar rather than moved into a block, so it is
|
|
2614
|
-
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. */
|
|
2615
2793
|
.ed-toolbar-menu {
|
|
2616
2794
|
position: fixed; z-index: 102;
|
|
2617
2795
|
display: flex; flex-direction: column; gap: 2px;
|
|
2618
2796
|
padding: 4px; border-radius: 8px;
|
|
2619
2797
|
background: rgba(16, 18, 21, 0.96); color: #e6edf3;
|
|
2620
2798
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
|
2799
|
+
max-height: calc(100vh - 8px); overflow-y: auto;
|
|
2621
2800
|
}
|
|
2622
2801
|
.ed-toolbar-menu[hidden] { display: none; }
|
|
2623
2802
|
.ed-toolbar-menu-btn {
|
|
@@ -2636,8 +2815,6 @@ ${itemsHtml}
|
|
|
2636
2815
|
border: 1px solid #d0d7de; border-radius: 8px; resize: vertical;
|
|
2637
2816
|
white-space: pre; overflow-wrap: normal;
|
|
2638
2817
|
}
|
|
2639
|
-
body[data-ed-mode="preview"] .ed-handle,
|
|
2640
|
-
body[data-ed-mode="preview"] .ed-insert { display: none !important; }
|
|
2641
2818
|
.ed-editing { position: relative; }
|
|
2642
2819
|
.ed-raw {
|
|
2643
2820
|
display: block; width: 100%; font-family: monospace; font-size: 13px;
|
|
@@ -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;
|