@helping-ai-workflow/md2doc 2.11.0 → 2.12.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/client.js +1750 -280
- package/lib/editor/indent-clamp.js +6 -1
- package/lib/editor/list-md.js +68 -1
- package/lib/editor/selection.js +204 -0
- package/lib/editor/server.js +7 -0
- package/lib/md2doc.js +130 -8
- package/package.json +2 -2
|
@@ -30,7 +30,12 @@
|
|
|
30
30
|
// §3.4's global convention). For a multi-block operation
|
|
31
31
|
// this is the SMALLEST old indent in the set — anchoring on
|
|
32
32
|
// the first member instead drives later members negative on
|
|
33
|
-
// a delete
|
|
33
|
+
// a delete (§3.4 rule 3's first worked failure). It is the
|
|
34
|
+
// bound for the blocks BELOW the set only; how far the set
|
|
35
|
+
// ITSELF may move is a separate question the caller answers
|
|
36
|
+
// (client.js batchIndentDelta(), which since the 2026-08-31
|
|
37
|
+
// D1 review takes the minimum head-room across ALL members,
|
|
38
|
+
// not the head-room of this shallowest one).
|
|
34
39
|
// opts.removed — the operated block(s) no longer exist.
|
|
35
40
|
// opts.operatedBecomes — the operated block is still there but is no
|
|
36
41
|
// longer a list item (§3.3 conversion); the value
|
package/lib/editor/list-md.js
CHANGED
|
@@ -377,6 +377,61 @@
|
|
|
377
377
|
return n > 0 ? new Array(n + 1).join(' ') : '';
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
// ── The setext-underline hazard on an EMPTY bulleted item ───────────────
|
|
381
|
+
// An empty item is emitted as a BARE marker ('-', no trailing space): '- '
|
|
382
|
+
// lexes as a PARAGRAPH, which is why lib/editor/client.js's
|
|
383
|
+
// BLOCK_SKELETONS.list is a bare marker too, and that reasoning is not being
|
|
384
|
+
// undone here. But CommonMark gives that same bare '-' a SECOND reading, and
|
|
385
|
+
// which one wins is decided by the line ABOVE it: an EMPTY list item may not
|
|
386
|
+
// interrupt a paragraph, and a line consisting of nothing but '-' standing
|
|
387
|
+
// at an open paragraph's own content column is a SETEXT H2 UNDERLINE.
|
|
388
|
+
//
|
|
389
|
+
// That is exactly the line an indent produces. `- beta` + Enter + Tab wrote
|
|
390
|
+
//
|
|
391
|
+
// - alpha
|
|
392
|
+
// - beta
|
|
393
|
+
// -
|
|
394
|
+
//
|
|
395
|
+
// and marked (14.1.4) reads it back as `<li><h2>beta</h2></li>`: the new
|
|
396
|
+
// item is gone and the parent's text has been re-typed as a heading. Two
|
|
397
|
+
// ordinary keystrokes, silent content destruction, measured on 2.11.0.
|
|
398
|
+
//
|
|
399
|
+
// The hazard is POSITIONAL, not a property of the marker. It exists only
|
|
400
|
+
// where the previously emitted line is a paragraph at this line's own
|
|
401
|
+
// column, which inside a run means precisely "this item is the FIRST item of
|
|
402
|
+
// a deeper nesting" (`prev.indent < indent`) — the parent's own text (or its
|
|
403
|
+
// lazy continuation) is then the line immediately above, and a child's
|
|
404
|
+
// marker column IS the parent's content column by construction. An empty
|
|
405
|
+
// item that follows a SAME-level sibling is safe (the line above is a marker
|
|
406
|
+
// line, so '-' can only be another marker there), and so is one whose
|
|
407
|
+
// predecessor is deeper.
|
|
408
|
+
//
|
|
409
|
+
// Every other shape is left byte-identical:
|
|
410
|
+
// * ordered — '1.' is not a run of dashes, so it is not a setext underline;
|
|
411
|
+
// * task — a content-free task item never emits a marker line at all
|
|
412
|
+
// (it becomes `pending`, a same-line prefix), which is what the
|
|
413
|
+
// `head === indentPrefix + marker` test below detects;
|
|
414
|
+
// * non-empty items, and every empty item at top level.
|
|
415
|
+
//
|
|
416
|
+
// The escape is a U+200B ZERO WIDTH SPACE: real, non-whitespace content to
|
|
417
|
+
// the block lexer (so the line is a list item, not an underline) and nothing
|
|
418
|
+
// at all to a reader. It is the same trade-off client.js already documents
|
|
419
|
+
// for BLOCK_SKELETONS.paragraph. It never becomes part of the user's text:
|
|
420
|
+
// lib/md2doc.js's edit-mode list renderer renders a U+200B-only item as an
|
|
421
|
+
// EMPTY surface, so the next keystroke lands in an empty item, and the
|
|
422
|
+
// itemMd normalisation above takes the character back off on the way out.
|
|
423
|
+
const SETEXT_ESCAPE = '\u200b';
|
|
424
|
+
const SETEXT_ESCAPE_RE = /^\u200b+$/;
|
|
425
|
+
function escapeSetextHazard(firstOwnLine, ctx) {
|
|
426
|
+
if (firstOwnLine !== '') return firstOwnLine;
|
|
427
|
+
if (ctx.listType !== 'ul' || ctx.isTask) return firstOwnLine;
|
|
428
|
+
// A `pending` task prefix has already been joined onto `head`, so the line
|
|
429
|
+
// is not a bare run of dashes and needs nothing.
|
|
430
|
+
if (ctx.head !== ctx.indentPrefix + ctx.marker) return firstOwnLine;
|
|
431
|
+
if (!ctx.prev || ctx.indent <= ctx.prev.indent) return firstOwnLine;
|
|
432
|
+
return SETEXT_ESCAPE;
|
|
433
|
+
}
|
|
434
|
+
|
|
380
435
|
// `opts.carryOver` (spec §3.4, 多行 li 的旁觀者規則): a map of block id →
|
|
381
436
|
// that block's ORIGINAL source lines. A hard-wrapped item named there is NOT
|
|
382
437
|
// re-serialized; its own bytes are replayed with the column difference of its
|
|
@@ -536,6 +591,15 @@
|
|
|
536
591
|
const res = inlineMd.serializeInline({ childNodes: inlineKids });
|
|
537
592
|
itemMd = res.md;
|
|
538
593
|
res.unsupported.forEach((u) => innerUnsupported.push(u));
|
|
594
|
+
// The other half of the SETEXT ESCAPE applied at the emission site
|
|
595
|
+
// below: a U+200B is this serializer's own way of saying "empty item
|
|
596
|
+
// in a position where a bare marker would re-lex as a heading
|
|
597
|
+
// underline", so this serializer is also the one that takes it back
|
|
598
|
+
// off. Without it, an escaped item that later moves somewhere the
|
|
599
|
+
// escape is not needed (Shift+Tab back to the top level) would keep a
|
|
600
|
+
// zero-width character it never asked for, and the item would stop
|
|
601
|
+
// reading as empty to every `itemMd === ''` test in this function.
|
|
602
|
+
if (SETEXT_ESCAPE_RE.test(itemMd)) itemMd = '';
|
|
539
603
|
}
|
|
540
604
|
|
|
541
605
|
// Anything in the block that is neither chrome nor the text surface is
|
|
@@ -717,7 +781,10 @@
|
|
|
717
781
|
prev = { indent: indent, listType: listType };
|
|
718
782
|
return;
|
|
719
783
|
}
|
|
720
|
-
lines.push((head + ownLines[0]
|
|
784
|
+
lines.push((head + escapeSetextHazard(ownLines[0], {
|
|
785
|
+
head: head, indentPrefix: indentPrefix, marker: marker,
|
|
786
|
+
listType: listType, isTask: isTask, indent: indent, prev: prev,
|
|
787
|
+
})).replace(/[ \t]+$/, ''));
|
|
721
788
|
lineMeta.push({ blockId: blockId, indentPrefix: metaPrefix, marker: marker });
|
|
722
789
|
for (let k = 1; k < ownLines.length; k++) {
|
|
723
790
|
const body = ownLines[k].replace(/^[ \t]+/, '').replace(/[ \t]+$/, '');
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* Spec §3.3 / §3.6 / §4.4 — block 多選的純模型.
|
|
3
|
+
UMD, same shape as indent-clamp.js: `require`-able in node for the unit
|
|
4
|
+
tests, and injected into the editor page as `window.md2docSelection`. */
|
|
5
|
+
(function (root, factory) {
|
|
6
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
7
|
+
else root.md2docSelection = factory();
|
|
8
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
9
|
+
|
|
10
|
+
// A selection's identity is a LINE RANGE — never block ids, never DOM nodes.
|
|
11
|
+
// buildBlockMap() renumbers every id from 0 on every render (blockmap.js:170)
|
|
12
|
+
// and every batch operation triggers a full rerenderAll(), so an id or an
|
|
13
|
+
// element held across a commit is a dangling reference into a document that
|
|
14
|
+
// no longer exists. Every post-commit recovery path already in client.js
|
|
15
|
+
// (blockElAtLine, reresolveBlockEl, focusBlockAtLine) goes through startLine
|
|
16
|
+
// for exactly this reason. Nothing in this file reads the DOM, and nothing
|
|
17
|
+
// outside it may hand this file an id.
|
|
18
|
+
//
|
|
19
|
+
// a selection — { anchorLine, focusLine }, or null for "nothing selected"
|
|
20
|
+
// a block — a record from buildBlockMap(): { id, type, startLine,
|
|
21
|
+
// endLine, ... }. Read here for startLine/endLine only;
|
|
22
|
+
// `id` is passed through untouched for the caller's own use.
|
|
23
|
+
// `blocks` — the whole live block list, in document order.
|
|
24
|
+
|
|
25
|
+
// ── the no-line exclusion ────────────────────────────────────────────
|
|
26
|
+
// buildBlockMap emits a block for the OUTER item of a same-line nest
|
|
27
|
+
// ("- - a" -> outer {startLine:3, endLine:2}, inner {startLine:3,
|
|
28
|
+
// endLine:3}), and its range is INVERTED because it owns no source line of
|
|
29
|
+
// its own. Such a block has no line for a range to touch, so it can never be
|
|
30
|
+
// a member of a line-range selection. This is the same predicate as
|
|
31
|
+
// blockOwnsNoLine() in client.js:928, which is the guard every structural
|
|
32
|
+
// path in the editor already checks before it writes bytes.
|
|
33
|
+
function ownsALine(b) {
|
|
34
|
+
return !!b && typeof b.startLine === 'number' && typeof b.endLine === 'number'
|
|
35
|
+
&& b.endLine >= b.startLine;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function lineOf(v) {
|
|
39
|
+
const n = Number(v);
|
|
40
|
+
return Number.isFinite(n) ? n : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── normalize ────────────────────────────────────────────────────────
|
|
44
|
+
// Orders anchor/focus into the {startLine, endLine} shape the block records
|
|
45
|
+
// and the UndoStack op shape already use, so the two never have to be
|
|
46
|
+
// mentally converted at a call site. Returns null rather than a NaN range
|
|
47
|
+
// for "no selection" — a NaN range compares false against everything and
|
|
48
|
+
// would silently produce an empty member set that looks like a real answer.
|
|
49
|
+
function normalize(sel) {
|
|
50
|
+
if (!sel) return null;
|
|
51
|
+
const a = lineOf(sel.anchorLine);
|
|
52
|
+
const f = lineOf(sel.focusLine);
|
|
53
|
+
if (a === null || f === null) return null;
|
|
54
|
+
return { startLine: Math.min(a, f), endLine: Math.max(a, f) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── membership ───────────────────────────────────────────────────────
|
|
58
|
+
// Membership is INTERSECTION, not containment: a multi-line block touched
|
|
59
|
+
// anywhere is wholly selected. Block selection has no partial state (§3.6 —
|
|
60
|
+
// the visual is a tint over whole blocks), so a code fence whose middle line
|
|
61
|
+
// is in range is in the set exactly as much as a one-line paragraph is.
|
|
62
|
+
// Returns block RECORDS in document order, never ids and never elements.
|
|
63
|
+
function membersOf(sel, blocks) {
|
|
64
|
+
const r = normalize(sel);
|
|
65
|
+
if (!r) return [];
|
|
66
|
+
return (blocks || []).filter(
|
|
67
|
+
(b) => ownsALine(b) && b.startLine <= r.endLine && b.endLine >= r.startLine);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// There is deliberately NO per-block `isSelected()` here. One existed until
|
|
71
|
+
// 2026-08-31 and never had a production caller in `lib/`: every paint and
|
|
72
|
+
// every operation asks membersOf() for the whole set at once, because a batch
|
|
73
|
+
// needs the set's contiguity and its index range, not N independent yes/no
|
|
74
|
+
// answers. Its assertions were all over SINGLE-line blocks, so the
|
|
75
|
+
// intersection-not-containment rule was pinned by membersOf()'s multi-line
|
|
76
|
+
// fixture alone and nothing was lost by removing it. A future caller that
|
|
77
|
+
// genuinely wants one block's answer should re-add it WITH that caller and
|
|
78
|
+
// with a multi-line fixture, rather than inherit an untested export.
|
|
79
|
+
|
|
80
|
+
// ── extendTo ─────────────────────────────────────────────────────────
|
|
81
|
+
// Shift+Click and a drag both move the FOCUS and keep the anchor where the
|
|
82
|
+
// gesture started; that is what makes a selection reversible by dragging
|
|
83
|
+
// back. With no prior selection there is no anchor to keep, so the gesture
|
|
84
|
+
// starts one collapsed at that line.
|
|
85
|
+
function extendTo(sel, line) {
|
|
86
|
+
const l = lineOf(line);
|
|
87
|
+
if (l === null) return sel || null;
|
|
88
|
+
const a = sel ? lineOf(sel.anchorLine) : null;
|
|
89
|
+
return { anchorLine: a === null ? l : a, focusLine: l };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── stepFocus ────────────────────────────────────────────────────────
|
|
93
|
+
// §4.4's Shift+↑↓. Moves by BLOCK, not by line: blocks do not tile the
|
|
94
|
+
// document (blank separator lines belong to nothing), so a per-line step
|
|
95
|
+
// would park the focus on a line that owns no block and collapse the set to
|
|
96
|
+
// nothing on the way past. Blocks that own no source line are skipped for
|
|
97
|
+
// the same reason membersOf() excludes them — landing on one would leave a
|
|
98
|
+
// selection whose member set is empty.
|
|
99
|
+
//
|
|
100
|
+
// Clamped at both ends: stepping past either end is a no-op, never an error
|
|
101
|
+
// and never a wrap. `dir` is any negative number for up, anything else down.
|
|
102
|
+
function stepFocus(sel, blocks, dir) {
|
|
103
|
+
if (!sel) return sel || null;
|
|
104
|
+
const nav = (blocks || []).filter(ownsALine);
|
|
105
|
+
if (!nav.length) return sel;
|
|
106
|
+
const focus = lineOf(sel.focusLine);
|
|
107
|
+
if (focus === null) return sel;
|
|
108
|
+
const step = dir < 0 ? -1 : 1;
|
|
109
|
+
|
|
110
|
+
// The focus normally sits exactly on a block's startLine, because that is
|
|
111
|
+
// what this function and the gesture handlers write. If it does not (a
|
|
112
|
+
// caller resolved a raw click y-coordinate, say), take the nearest block
|
|
113
|
+
// at or after it, and the last block when the line is past the end.
|
|
114
|
+
let i = nav.findIndex((b) => b.startLine === focus);
|
|
115
|
+
if (i === -1) i = nav.findIndex((b) => b.startLine > focus);
|
|
116
|
+
if (i === -1) i = nav.length - 1;
|
|
117
|
+
|
|
118
|
+
const j = Math.max(0, Math.min(nav.length - 1, i + step));
|
|
119
|
+
return { anchorLine: sel.anchorLine, focusLine: nav[j].startLine };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── §3.3's membership rule ───────────────────────────────────────────
|
|
123
|
+
// "grip 在選取集合內 → 作用整個集合;grip 在集合外 → 先把集合換成該單一
|
|
124
|
+
// block 再作用." One helper so every operation asks the question the same
|
|
125
|
+
// way instead of each menu handler re-deriving it.
|
|
126
|
+
//
|
|
127
|
+
// `opBlock` MUST be a record out of `blocks` — identity is by reference, not
|
|
128
|
+
// by id (this module never touches ids) and not by line tuple (a nest three
|
|
129
|
+
// deep like "- - - a" produces two structurally identical phantoms, so a
|
|
130
|
+
// tuple compare is genuinely ambiguous). A record from anywhere else answers
|
|
131
|
+
// 'single', which is the conservative direction: it operates on one block
|
|
132
|
+
// rather than silently on N.
|
|
133
|
+
//
|
|
134
|
+
// "By reference" is a TESTED contract, not a description of the current line
|
|
135
|
+
// of code. `id` is passed through untouched and never compared, so a shallow
|
|
136
|
+
// COPY of a member — same id, same lines, different object — is a different
|
|
137
|
+
// block here and answers 'single'. test/selection.test.js pins exactly that
|
|
138
|
+
// case, because it is the only fixture that separates this from
|
|
139
|
+
// `members.some((m) => m.id === opBlock.id)`: every other fixture in the
|
|
140
|
+
// suite hands in a record straight out of `blocks`, where the two agree.
|
|
141
|
+
//
|
|
142
|
+
// A selection of exactly the grip block is 'batch' with one member, not a
|
|
143
|
+
// fallthrough to 'single'. The two answers are byte-identical for a single
|
|
144
|
+
// block today, but a caller that special-cased size 1 would drift the moment
|
|
145
|
+
// batch and single paths diverge.
|
|
146
|
+
function resolveMembership(sel, blocks, opBlock) {
|
|
147
|
+
const members = membersOf(sel, blocks);
|
|
148
|
+
if (opBlock && members.indexOf(opBlock) !== -1) return { mode: 'batch', members };
|
|
149
|
+
return { mode: 'single', members: opBlock ? [opBlock] : [] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── collapseTo ───────────────────────────────────────────────────────
|
|
153
|
+
// §3.3: "操作後集合塌縮為「操作結果所涵蓋的行區間」". Every structural
|
|
154
|
+
// operation declares the line range it produced; rerenderAll() re-derives
|
|
155
|
+
// the member set from that range against the freshly built block list. §4.4:
|
|
156
|
+
// if the range does not resolve, the selection is CLEARED rather than left
|
|
157
|
+
// dangling — an operation that removed everything it touched declares an
|
|
158
|
+
// empty (inverted) range, and that is not a selection of one line.
|
|
159
|
+
function collapseTo(range) {
|
|
160
|
+
if (!range) return null;
|
|
161
|
+
const s = lineOf(range.startLine);
|
|
162
|
+
const e = lineOf(range.endLine);
|
|
163
|
+
if (s === null || e === null || e < s) return null;
|
|
164
|
+
return { anchorLine: s, focusLine: e };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── spanIsContiguous ─────────────────────────────────────────────────
|
|
168
|
+
// Batch convert/delete/duplicate rewrite their single-item entry points to
|
|
169
|
+
// take ONE contiguous index range — a loop would re-render between items and
|
|
170
|
+
// invalidate every id in between, which is the defect class recorded at
|
|
171
|
+
// client.js:3298 and :4806. This is the gate that says a set can be
|
|
172
|
+
// expressed that way.
|
|
173
|
+
//
|
|
174
|
+
// Indices are taken over the WHOLE `blocks` list, phantoms included. A
|
|
175
|
+
// no-line phantom sitting between two members ('- a\n- - b\n- c\n' ->
|
|
176
|
+
// li{1,1} | phantom{2,1} | li{2,2} | li{3,3}) is never a member, but it IS
|
|
177
|
+
// in the DOM run a batch operation would slice, and every structural path
|
|
178
|
+
// already refuses a phantom (blockOwnsNoLine). Reporting false there hands
|
|
179
|
+
// the caller a refusal instead of a range it cannot honour.
|
|
180
|
+
function spanIsContiguous(members, blocks) {
|
|
181
|
+
const list = members || [];
|
|
182
|
+
if (list.length <= 1) {
|
|
183
|
+
// 0 members has no gap to find; the caller checks emptiness itself,
|
|
184
|
+
// because "nothing selected" and "a set with a hole in it" deserve
|
|
185
|
+
// different messages.
|
|
186
|
+
return list.every((m) => (blocks || []).indexOf(m) !== -1);
|
|
187
|
+
}
|
|
188
|
+
const idx = list.map((m) => (blocks || []).indexOf(m));
|
|
189
|
+
if (idx.some((i) => i < 0)) return false;
|
|
190
|
+
idx.sort((a, b) => a - b);
|
|
191
|
+
for (let k = 1; k < idx.length; k++) if (idx[k] !== idx[k - 1] + 1) return false;
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
normalize,
|
|
197
|
+
membersOf,
|
|
198
|
+
extendTo,
|
|
199
|
+
stepFocus,
|
|
200
|
+
resolveMembership,
|
|
201
|
+
collapseTo,
|
|
202
|
+
spanIsContiguous,
|
|
203
|
+
};
|
|
204
|
+
});
|
package/lib/editor/server.js
CHANGED
|
@@ -23,6 +23,12 @@ const INDENT_CLAMP_SRC = fs.readFileSync(path.join(__dirname, 'indent-clamp.js')
|
|
|
23
23
|
// indent-clamp.js above — it only has to land before client.js reads
|
|
24
24
|
// window.md2docConvertMd.
|
|
25
25
|
const CONVERT_MD_SRC = fs.readFileSync(path.join(__dirname, 'convert-md.js'), 'utf8');
|
|
26
|
+
// S3 spec §3.3/§3.6/§4.4: the pure block multi-select model — line-range
|
|
27
|
+
// normalization, membership, Shift+arrow stepping, the §3.3 grip rule and the
|
|
28
|
+
// post-operation collapse. Same "no dependency on any other editor module"
|
|
29
|
+
// property as indent-clamp.js and convert-md.js above; it only has to land
|
|
30
|
+
// before client.js reads window.md2docSelection.
|
|
31
|
+
const SELECTION_SRC = fs.readFileSync(path.join(__dirname, 'selection.js'), 'utf8');
|
|
26
32
|
|
|
27
33
|
function readJson(req, limitBytes = 50 * 1024 * 1024) {
|
|
28
34
|
return new Promise((resolve, reject) => {
|
|
@@ -159,6 +165,7 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
|
|
|
159
165
|
`<script>${HISTORY_SRC}</script>\n` +
|
|
160
166
|
`<script>${INDENT_CLAMP_SRC}</script>\n` +
|
|
161
167
|
`<script>${CONVERT_MD_SRC}</script>\n` +
|
|
168
|
+
`<script>${SELECTION_SRC}</script>\n` +
|
|
162
169
|
`<script>${clientJs}</script>\n`;
|
|
163
170
|
// Splice at the LAST "</body>" — the document's real closing tag.
|
|
164
171
|
// The first occurrence can sit inside an inlined diagram bundle's JS
|
package/lib/md2doc.js
CHANGED
|
@@ -284,6 +284,15 @@ function renderEditModeList(listToken, blocks, biRef, out) {
|
|
|
284
284
|
// but would render as a phantom blank line inside the item.
|
|
285
285
|
inner = marked.parser(ownTokens).trim();
|
|
286
286
|
}
|
|
287
|
+
// The receiving half of list-md.js's SETEXT ESCAPE (see
|
|
288
|
+
// escapeSetextHazard() there): an empty list item that is the first child
|
|
289
|
+
// of a deeper nesting is written to disk as `- <U+200B>`, because a bare
|
|
290
|
+
// `-` on that line is a setext H2 underline for the parent's own text and
|
|
291
|
+
// destroys both blocks. The zero-width space is the serializer's, not the
|
|
292
|
+
// user's, so it is taken back off here — the surface renders EMPTY, which
|
|
293
|
+
// is what keeps the next keystroke from landing next to an invisible
|
|
294
|
+
// character and writing it back out inside the user's own text.
|
|
295
|
+
if (/^\u200b+$/.test(inner)) inner = '';
|
|
287
296
|
const check = b.task
|
|
288
297
|
? `<span class="ed-li-check" data-checked="${b.checked ? 1 : 0}" role="checkbox" aria-checked="${!!b.checked}"></span>`
|
|
289
298
|
: '';
|
|
@@ -1078,8 +1087,9 @@ ${itemsHtml}
|
|
|
1078
1087
|
// (now reverted) "inset the row grip into the table" hack that ended up
|
|
1079
1088
|
// covering the first cell's text. 56px of content padding plus moving the
|
|
1080
1089
|
// gutter buttons out (⠿ to left:-36px, + to left:-54px) separates them
|
|
1081
|
-
// properly: the gutter pair occupies [contentLeft-
|
|
1082
|
-
//
|
|
1090
|
+
// properly: the gutter pair occupies [contentLeft-40, contentLeft-4]
|
|
1091
|
+
// (v2.11.1; it was [contentLeft-54, contentLeft-18] until spec §4.2's own
|
|
1092
|
+
// numbers were restored) and the 20px-wide row grip straddles contentLeft at
|
|
1083
1093
|
// [contentLeft-10, contentLeft+10] — an 8px gap, and the grip's inner half
|
|
1084
1094
|
// stays within the cell's own 14px padding, so it never touches cell text.
|
|
1085
1095
|
// (The padding was 48px until §4.2's hit-test conflict 1 was fixed; see the
|
|
@@ -1133,7 +1143,11 @@ ${itemsHtml}
|
|
|
1133
1143
|
|
|
1134
1144
|
8 more pixels of padding move the pair to [contentLeft-54,
|
|
1135
1145
|
contentLeft-18] = [splitterRight+2, splitterRight+20], i.e. entirely
|
|
1136
|
-
inside the content column with 2px of clearance.
|
|
1146
|
+
inside the content column with 2px of clearance. (v2.11.1 pulled the pair
|
|
1147
|
+
further in, to spec §4.2's own [contentLeft-40, contentLeft-4] — the
|
|
1148
|
+
clearance is 16px at this padding rather than 2px. The padding stays at
|
|
1149
|
+
56px: nothing asks for the text column to move, and this measurement is
|
|
1150
|
+
the one the §4.2 conflict-1 guard is written against.) The 20px table row grip
|
|
1137
1151
|
still straddles contentLeft at [contentLeft-10, contentLeft+10], so its
|
|
1138
1152
|
8px gap to ⠿ is unchanged. Guarded by the elementFromPoint(splitter.right
|
|
1139
1153
|
- 2, y) assertion §4.2 asks for, in
|
|
@@ -1210,8 +1224,66 @@ ${itemsHtml}
|
|
|
1210
1224
|
reaches a negative viewport x and never covers the splitter. The 20px
|
|
1211
1225
|
table row grip still straddles contentLeft at
|
|
1212
1226
|
[contentLeft-10, contentLeft+10] - an 8px gap, unchanged. */
|
|
1213
|
-
.
|
|
1214
|
-
|
|
1227
|
+
/* Stated ONCE, as tokens, because §4.2's three numbers are not independent:
|
|
1228
|
+
the gutter is exactly two buttons wide plus the deliberate right-hand
|
|
1229
|
+
breathing gap, and the hover zone below is exactly the gutter. Writing any
|
|
1230
|
+
of them as a second literal is how the corridor comes back. */
|
|
1231
|
+
:root {
|
|
1232
|
+
--ed-gutter-btn: 18px;
|
|
1233
|
+
--ed-gutter-gap: 4px;
|
|
1234
|
+
--ed-gutter-w: calc(var(--ed-gutter-btn) * 2 + var(--ed-gutter-gap));
|
|
1235
|
+
}
|
|
1236
|
+
.ed-handle {
|
|
1237
|
+
left: calc(-1 * (var(--ed-gutter-btn) + var(--ed-gutter-gap))); top: 0;
|
|
1238
|
+
width: var(--ed-gutter-btn);
|
|
1239
|
+
}
|
|
1240
|
+
.ed-insert {
|
|
1241
|
+
left: calc(-1 * var(--ed-gutter-w)); top: 0;
|
|
1242
|
+
width: var(--ed-gutter-btn);
|
|
1243
|
+
}
|
|
1244
|
+
/* v2.11.1: the gutter's HOVER ZONE, and it is the reason the pair could move
|
|
1245
|
+
back to spec §4.2's own numbers at all.
|
|
1246
|
+
|
|
1247
|
+
Both buttons are revealed by .ed-block:hover, and :hover is true only
|
|
1248
|
+
over the block's border box or over one of the buttons themselves. At
|
|
1249
|
+
-36/-54 that left the band [blockLeft-18, blockLeft) belonging to neither:
|
|
1250
|
+
measured at 1400x900, elementFromPoint() returned main.content for
|
|
1251
|
+
x 394..411, and a real pointer walking out of the text at 100 px/s held
|
|
1252
|
+
the ⠿ at opacity 0 for 16 consecutive frames (~270 ms) — the cursor is
|
|
1253
|
+
between the text and the ⠿ and the ⠿ is not there. Conforming to §4.2
|
|
1254
|
+
([contentLeft-40, contentLeft-4], the + and ⠿ flush, 4px of breathing
|
|
1255
|
+
room on the right) narrows that band to the 4px gap but does not close it:
|
|
1256
|
+
with the pair moved and this rule taken back out, a 2px-per-frame walk
|
|
1257
|
+
across the same row measured 0.06 / 0.49 / 0.53 at three of its 23 stops —
|
|
1258
|
+
a flicker instead of a disappearance, but still the ⠿ dimming under the
|
|
1259
|
+
cursor that is travelling to it. Geometry alone cannot close it, because §4.2's 4px gap is
|
|
1260
|
+
deliberate (5.3 item 3a's elementFromPoint must land ON the ⠿).
|
|
1261
|
+
|
|
1262
|
+
Two more holes have the same shape and the same cure: the buttons are 20px
|
|
1263
|
+
tall at top:0 while an li row is 24.75px, so the bottom ~4.75px of EVERY
|
|
1264
|
+
row is an empty gutter; and a 61.5px heading's vertical centre is 20px
|
|
1265
|
+
below the bottom of its own ⠿, so moving left from the middle of a heading
|
|
1266
|
+
never reached anything at all.
|
|
1267
|
+
|
|
1268
|
+
One absolutely-positioned pseudo-element spanning the whole gutter for the
|
|
1269
|
+
block's whole height makes :hover continuously true from the text out
|
|
1270
|
+
past the +, at every Y. Its width is the --ed-gutter-w token the pair's
|
|
1271
|
+
own offsets are built from, so the two cannot drift apart — if they do,
|
|
1272
|
+
the corridor comes straight back.
|
|
1273
|
+
.ed-block is already position:relative, and position: absolute keeps
|
|
1274
|
+
this out of the li row's flex flow. It is deliberately NOT
|
|
1275
|
+
pointer-events: none: that would stop it being hit-tested, which is the
|
|
1276
|
+
entire mechanism. It therefore also changes what a click in the band
|
|
1277
|
+
hits (main.content -> the block), which wireBlockSelection() in
|
|
1278
|
+
lib/editor/client.js compensates for explicitly — see the
|
|
1279
|
+
clientX-vs-block-rect guard just above its "clicked outside any block"
|
|
1280
|
+
branch. (No backticks in this comment: it lives inside a JS template
|
|
1281
|
+
literal.) */
|
|
1282
|
+
.ed-block::before {
|
|
1283
|
+
content: ""; position: absolute;
|
|
1284
|
+
left: calc(-1 * var(--ed-gutter-w)); top: 0;
|
|
1285
|
+
width: var(--ed-gutter-w); height: 100%;
|
|
1286
|
+
}`;
|
|
1215
1287
|
|
|
1216
1288
|
const html = `<!DOCTYPE html>
|
|
1217
1289
|
<html lang="en">
|
|
@@ -1978,6 +2050,36 @@ ${itemsHtml}
|
|
|
1978
2050
|
output, same precedent as the lightbox selectors above. */
|
|
1979
2051
|
.ed-block { position: relative; cursor: pointer; }
|
|
1980
2052
|
.ed-block:hover { outline: 1px dashed #b0b0b0; }
|
|
2053
|
+
/* S3: the block-level multi-select tint (spec §3.6). rgba, never opaque —
|
|
2054
|
+
§3.6 says the text underneath stays readable. #3b82f6 is the editor's
|
|
2055
|
+
established blue (.ed-wys-armed:focus, .ed-li-check[data-checked="1"],
|
|
2056
|
+
.ed-te-hl).
|
|
2057
|
+
|
|
2058
|
+
The descendant rules and their !important are not decoration, they are
|
|
2059
|
+
the whole problem: <pre> (#f6f8fa), <th> (#f6f8fa), the zebra stripe
|
|
2060
|
+
tr:nth-child(even) (#fafbfc) and the sticky first column (#ffffff) all
|
|
2061
|
+
carry OPAQUE backgrounds, and the sticky column additionally paints at
|
|
2062
|
+
z-index: 1 over anything drawn beneath it. Two of those beat a plain
|
|
2063
|
+
descendant selector on specificity alone — counted:
|
|
2064
|
+
.content table tbody tr:nth-child(even) td:first-child is (0,3,4)
|
|
2065
|
+
against this rule's (0,3,2), and .content table tbody td:first-child is
|
|
2066
|
+
(0,2,3) against the same (0,3,2) — so without !important the tint is
|
|
2067
|
+
invisible on exactly the blocks a user is most likely to select. .ed-te-hl
|
|
2068
|
+
needed the identical treatment for the identical reason. This ADDS rules
|
|
2069
|
+
rather than reordering the sticky/zebra/header set, which CLAUDE.md says
|
|
2070
|
+
wins by source order and must not be moved.
|
|
2071
|
+
(No backticks in this comment: it lives inside a JS template literal.)
|
|
2072
|
+
|
|
2073
|
+
:focus outline is suppressed because the roving tabindex below focuses a
|
|
2074
|
+
.ed-block for real (spec §4.4 wants a real focus holder, not <body>) and
|
|
2075
|
+
the tint — not a second ring — is the selection's affordance. */
|
|
2076
|
+
.ed-block.ed-selected { background: rgba(59, 130, 246, 0.15); }
|
|
2077
|
+
.ed-block.ed-selected pre,
|
|
2078
|
+
.ed-block.ed-selected th,
|
|
2079
|
+
.ed-block.ed-selected tr:nth-child(even),
|
|
2080
|
+
.ed-block.ed-selected tbody td:first-child,
|
|
2081
|
+
.ed-block.ed-selected thead th:first-child { background: rgba(59, 130, 246, 0.15) !important; }
|
|
2082
|
+
.ed-block.ed-selected:focus { outline: none; }
|
|
1981
2083
|
/* white-space: pre-line is LOAD-BEARING, not styling — do not relax it.
|
|
1982
2084
|
A hard-wrapped ("lazy continuation") list item's own content contains a
|
|
1983
2085
|
real newline, and its .ed-li-text is an editing host. Under the default
|
|
@@ -2039,7 +2141,9 @@ ${itemsHtml}
|
|
|
2039
2141
|
.ed-block:hover .ed-handle,
|
|
2040
2142
|
.ed-handle:focus { opacity: 1; }
|
|
2041
2143
|
.ed-handle:hover { background: rgba(0, 0, 0, 0.08); }
|
|
2042
|
-
/* The ⠿ handle's menu: 轉換成 › /
|
|
2144
|
+
/* The ⠿ handle's menu: 轉換成 › / 建立副本 / 刪除 / MD 原始碼 (spec §3.7),
|
|
2145
|
+
each led by a 16px currentColor icon (v2.12.0; drawn in
|
|
2146
|
+
lib/editor/client.js's MENU_ICON_PATHS).
|
|
2043
2147
|
Dark translucent panel, bordered rows — same visual language as
|
|
2044
2148
|
.ed-seltb below.
|
|
2045
2149
|
|
|
@@ -2063,7 +2167,23 @@ ${itemsHtml}
|
|
|
2063
2167
|
background: rgba(255, 255, 255, 0.08); color: inherit;
|
|
2064
2168
|
font: inherit; font-size: 12px; line-height: 1;
|
|
2065
2169
|
text-align: left; white-space: nowrap; cursor: pointer;
|
|
2066
|
-
|
|
2170
|
+
/* v2.12.0: a flex ROW so the icon and the label share a baseline-free
|
|
2171
|
+
centre line and a fixed gap. The row is still one flex item of the
|
|
2172
|
+
column above, and align-items: stretch there keeps every row the same
|
|
2173
|
+
width, so S2's one-left-edge / monotonic-top invariants are untouched.
|
|
2174
|
+
The submenu's own buttons carry this class and no icon; a lone text node
|
|
2175
|
+
is an anonymous flex item at flex-start, i.e. exactly where text-align
|
|
2176
|
+
put it. */
|
|
2177
|
+
display: flex; align-items: center; gap: 8px;
|
|
2178
|
+
}
|
|
2179
|
+
/* Notion's own sizing. The stroke colour is NOT set here on purpose: the
|
|
2180
|
+
markup carries stroke="currentColor" (lib/editor/client.js), so the icon
|
|
2181
|
+
is whatever colour .ed-handle-menu's own color property is, and follows a
|
|
2182
|
+
retheme for free. A hex here would break that silently. (No backticks in
|
|
2183
|
+
this comment: it lives inside a JS template literal.)
|
|
2184
|
+
flex: 0 0 auto because a 16px-wide flex item next to a nowrap label
|
|
2185
|
+
would otherwise be the thing that shrinks. */
|
|
2186
|
+
.ed-menu-icon { flex: 0 0 auto; width: 16px; height: 16px; }
|
|
2067
2187
|
/* The 轉換成 submenu carries BOTH classes, so it inherits every rule above
|
|
2068
2188
|
and only overrides the horizontal offset. It is a CHILD of the menu, and
|
|
2069
2189
|
the menu is itself position:absolute, so the menu's own padding box is
|
|
@@ -2086,7 +2206,9 @@ ${itemsHtml}
|
|
|
2086
2206
|
wording allows this ("left gutter, above or beside it").
|
|
2087
2207
|
|
|
2088
2208
|
S1 Task 5 (D6): edit mode now overrides BOTH buttons to sit side by side
|
|
2089
|
-
(+ at left:-
|
|
2209
|
+
(v2.11.1, spec §4.2's own numbers: + at left:-40px, ⠿ at left:-22px,
|
|
2210
|
+
both top:0, plus a .ed-block::before hover zone spanning the pair) — see
|
|
2211
|
+
editModeLayoutCss.
|
|
2090
2212
|
That became possible only once .content gained 48px of edit-mode padding;
|
|
2091
2213
|
the stacked geometry declared here is what any NON-edit render would use,
|
|
2092
2214
|
and those never emit .ed-block at all, so it is inert there. Kept rather
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helping-ai-workflow/md2doc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, and Graphviz support",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"preinstall": "node scripts/preinstall.js",
|
|
39
|
-
"test": "node test/md2doc.test.js && node test/blockmap.test.js && node test/images.test.js && node test/scroll-anchor.test.js && node test/lightbox.test.js && node test/lightbox-anno.test.js && node test/lightbox-anno-style.test.js && node test/reader-panels.test.js && node test/cli.test.js && node test/code-operator.test.js && node test/convert-md.test.js && node test/render-api.test.js && node test/lineops.test.js && node test/indent-clamp.test.js && node test/editmode-render.test.js && node test/editmode-wavedrom-reinit.test.js && node test/editor-server.test.js && node test/cli-edit.test.js && node test/open-viewer.test.js && node test/editor-client.test.js && node test/editor-client-runtime.test.js && node test/roundtrip.test.js && node test/byte-stability.test.js && node test/editor-server-throw.test.js && node test/editor-reader-rebind.test.js && node test/inline-md.test.js && node test/table-md.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js"
|
|
39
|
+
"test": "node test/md2doc.test.js && node test/blockmap.test.js && node test/images.test.js && node test/scroll-anchor.test.js && node test/lightbox.test.js && node test/lightbox-anno.test.js && node test/lightbox-anno-style.test.js && node test/reader-panels.test.js && node test/cli.test.js && node test/code-operator.test.js && node test/convert-md.test.js && node test/render-api.test.js && node test/lineops.test.js && node test/indent-clamp.test.js && node test/selection.test.js && node test/editmode-render.test.js && node test/editmode-wavedrom-reinit.test.js && node test/editor-server.test.js && node test/cli-edit.test.js && node test/open-viewer.test.js && node test/editor-client.test.js && node test/editor-client-runtime.test.js && node test/roundtrip.test.js && node test/byte-stability.test.js && node test/editor-server-throw.test.js && node test/editor-reader-rebind.test.js && node test/inline-md.test.js && node test/table-md.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|