@helping-ai-workflow/md2doc 2.11.1 → 3.0.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 +3010 -326
- package/lib/editor/indent-clamp.js +6 -1
- package/lib/editor/selection.js +204 -0
- package/lib/editor/server.js +7 -0
- package/lib/md2doc.js +149 -28
- 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
|
|
@@ -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
|
@@ -1080,18 +1080,27 @@ ${itemsHtml}
|
|
|
1080
1080
|
// `.content { min-width: 0; flex: 1 1 auto; }` block, which
|
|
1081
1081
|
// test/md2doc.test.js matches verbatim).
|
|
1082
1082
|
//
|
|
1083
|
-
// Why it exists: the gutter buttons hang OUTSIDE the content box
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1083
|
+
// Why it exists: the gutter buttons hang OUTSIDE the content box. 56px of
|
|
1084
|
+
// content padding plus moving the gutter buttons out puts the pair at
|
|
1085
|
+
// [contentLeft-40, contentLeft-4] (v2.11.1; it was
|
|
1086
|
+
// [contentLeft-54, contentLeft-18] until spec §4.2's own numbers were
|
|
1087
|
+
// restored), clear of .sidebar-splitter — see the .content rule below.
|
|
1088
|
+
//
|
|
1089
|
+
// GEOMETRY, AS MEASURED (S4 T1; the previous version of this comment
|
|
1090
|
+
// claimed "an 8px gap" between the gutter and the table row grip, a number
|
|
1091
|
+
// left over from the v2.11.0 pair position that v2.11.1 invalidated — the
|
|
1092
|
+
// real gap was MINUS 6px, i.e. an overlap):
|
|
1093
|
+
// ⠿ [contentLeft-22, contentLeft-4] (--ed-gutter-btn wide)
|
|
1094
|
+
// row grip [contentLeft, contentLeft+20] (--ed-te-grip-row-w wide)
|
|
1095
|
+
// The grip used to be centred on the table's left border at
|
|
1096
|
+
// [contentLeft-10, contentLeft+10], which overlapped the ⠿'s right 6px and
|
|
1097
|
+
// — being position: fixed, z-index 7, on document.body — won the hit test
|
|
1098
|
+
// there. spec §4.2 conflict 2's ruling (grip entirely inside the table)
|
|
1099
|
+
// is landed in lib/editor/client.js's updateTableEdgeGrips(); the
|
|
1100
|
+
// clearance that remains is exactly --ed-gutter-gap, because the ⠿'s right
|
|
1101
|
+
// edge and the grip's left edge are both derived from it. The grip is kept
|
|
1102
|
+
// off the first cell's TEXT by --ed-te-cell-pad-left below, not by the
|
|
1103
|
+
// 14px default padding, which is narrower than the grip.
|
|
1095
1104
|
// (The padding was 48px until §4.2's hit-test conflict 1 was fixed; see the
|
|
1096
1105
|
// .content rule in editModeLayoutCss below for why it is 56px now.)
|
|
1097
1106
|
// S1 Task 5: the ordered-list ordinal is drawn by a CSS counter, and the
|
|
@@ -1147,10 +1156,11 @@ ${itemsHtml}
|
|
|
1147
1156
|
further in, to spec §4.2's own [contentLeft-40, contentLeft-4] — the
|
|
1148
1157
|
clearance is 16px at this padding rather than 2px. The padding stays at
|
|
1149
1158
|
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
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1159
|
+
the one the §4.2 conflict-1 guard is written against.) The table row grip no
|
|
1160
|
+
longer reaches this far left at all: §4.2 conflict 2 moved it inside the
|
|
1161
|
+
table to [contentLeft, contentLeft+20] (S4 T1), so nothing but the gutter
|
|
1162
|
+
pair itself occupies [contentLeft-40, contentLeft-4]. Guarded by the
|
|
1163
|
+
elementFromPoint(splitter.right - 2, y) assertion §4.2 asks for, in
|
|
1154
1164
|
test/editor-client-runtime.test.js. */
|
|
1155
1165
|
.content { padding-left: 56px; }
|
|
1156
1166
|
|
|
@@ -1221,9 +1231,10 @@ ${itemsHtml}
|
|
|
1221
1231
|
[contentLeft-54, contentLeft-18], which with 56px of padding sits wholly
|
|
1222
1232
|
inside the content column (2px clear of .sidebar-splitter's right edge —
|
|
1223
1233
|
see the padding rule's own comment for the measurement), so + never
|
|
1224
|
-
reaches a negative viewport x and never covers the splitter. The
|
|
1225
|
-
|
|
1226
|
-
|
|
1234
|
+
reaches a negative viewport x and never covers the splitter. The table
|
|
1235
|
+
row grip is at [contentLeft, contentLeft+20] since §4.2 conflict 2
|
|
1236
|
+
landed (S4 T1), so the clearance from the ⠿'s right edge is exactly
|
|
1237
|
+
--ed-gutter-gap and cannot drift: both edges are derived from it. */
|
|
1227
1238
|
/* Stated ONCE, as tokens, because §4.2's three numbers are not independent:
|
|
1228
1239
|
the gutter is exactly two buttons wide plus the deliberate right-hand
|
|
1229
1240
|
breathing gap, and the hover zone below is exactly the gutter. Writing any
|
|
@@ -1232,6 +1243,19 @@ ${itemsHtml}
|
|
|
1232
1243
|
--ed-gutter-btn: 18px;
|
|
1233
1244
|
--ed-gutter-gap: 4px;
|
|
1234
1245
|
--ed-gutter-w: calc(var(--ed-gutter-btn) * 2 + var(--ed-gutter-gap));
|
|
1246
|
+
/* spec §4.2 hit-test conflict 2, landed S4 T1. The table row grip now
|
|
1247
|
+
sits ENTIRELY inside the table (its LEFT edge on the table's left
|
|
1248
|
+
border — see updateTableEdgeGrips() in lib/editor/client.js), which
|
|
1249
|
+
leaves the ⠿'s right edge and the grip's left edge exactly
|
|
1250
|
+
--ed-gutter-gap apart: the 4px breathing gap §4.2 already keeps on the
|
|
1251
|
+
right of the gutter pair IS the separation. Nothing new is written
|
|
1252
|
+
down, so nothing new can drift.
|
|
1253
|
+
The consequence is this token: the grip is 20px wide and the first
|
|
1254
|
+
column's default padding is 14px, so an inset grip would sit on that
|
|
1255
|
+
cell's TEXT — the v2.10.1 user-acceptance defect that got the first
|
|
1256
|
+
attempt at this ruling reverted. The first column is padded past the
|
|
1257
|
+
grip instead, by the grip's own width plus that same gap. */
|
|
1258
|
+
--ed-te-cell-pad-left: calc(var(--ed-te-grip-row-w) + var(--ed-gutter-gap));
|
|
1235
1259
|
}
|
|
1236
1260
|
.ed-handle {
|
|
1237
1261
|
left: calc(-1 * (var(--ed-gutter-btn) + var(--ed-gutter-gap))); top: 0;
|
|
@@ -1283,6 +1307,16 @@ ${itemsHtml}
|
|
|
1283
1307
|
content: ""; position: absolute;
|
|
1284
1308
|
left: calc(-1 * var(--ed-gutter-w)); top: 0;
|
|
1285
1309
|
width: var(--ed-gutter-w); height: 100%;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/* §4.2 conflict 2's other half: the row grip lives inside the first column
|
|
1313
|
+
now, so that column's cells must be padded past it. EDIT MODE ONLY —
|
|
1314
|
+
reader HTML and PDF output keep the shared 7px 14px and stay
|
|
1315
|
+
byte-identical. Only the LEFT padding changes, and only on the first
|
|
1316
|
+
cell of each row; every other cell is untouched. */
|
|
1317
|
+
.ed-block[data-block-type="table"] th:first-child,
|
|
1318
|
+
.ed-block[data-block-type="table"] td:first-child {
|
|
1319
|
+
padding-left: var(--ed-te-cell-pad-left);
|
|
1286
1320
|
}`;
|
|
1287
1321
|
|
|
1288
1322
|
const html = `<!DOCTYPE html>
|
|
@@ -2050,6 +2084,36 @@ ${itemsHtml}
|
|
|
2050
2084
|
output, same precedent as the lightbox selectors above. */
|
|
2051
2085
|
.ed-block { position: relative; cursor: pointer; }
|
|
2052
2086
|
.ed-block:hover { outline: 1px dashed #b0b0b0; }
|
|
2087
|
+
/* S3: the block-level multi-select tint (spec §3.6). rgba, never opaque —
|
|
2088
|
+
§3.6 says the text underneath stays readable. #3b82f6 is the editor's
|
|
2089
|
+
established blue (.ed-wys-armed:focus, .ed-li-check[data-checked="1"],
|
|
2090
|
+
.ed-te-hl).
|
|
2091
|
+
|
|
2092
|
+
The descendant rules and their !important are not decoration, they are
|
|
2093
|
+
the whole problem: <pre> (#f6f8fa), <th> (#f6f8fa), the zebra stripe
|
|
2094
|
+
tr:nth-child(even) (#fafbfc) and the sticky first column (#ffffff) all
|
|
2095
|
+
carry OPAQUE backgrounds, and the sticky column additionally paints at
|
|
2096
|
+
z-index: 1 over anything drawn beneath it. Two of those beat a plain
|
|
2097
|
+
descendant selector on specificity alone — counted:
|
|
2098
|
+
.content table tbody tr:nth-child(even) td:first-child is (0,3,4)
|
|
2099
|
+
against this rule's (0,3,2), and .content table tbody td:first-child is
|
|
2100
|
+
(0,2,3) against the same (0,3,2) — so without !important the tint is
|
|
2101
|
+
invisible on exactly the blocks a user is most likely to select. .ed-te-hl
|
|
2102
|
+
needed the identical treatment for the identical reason. This ADDS rules
|
|
2103
|
+
rather than reordering the sticky/zebra/header set, which CLAUDE.md says
|
|
2104
|
+
wins by source order and must not be moved.
|
|
2105
|
+
(No backticks in this comment: it lives inside a JS template literal.)
|
|
2106
|
+
|
|
2107
|
+
:focus outline is suppressed because the roving tabindex below focuses a
|
|
2108
|
+
.ed-block for real (spec §4.4 wants a real focus holder, not <body>) and
|
|
2109
|
+
the tint — not a second ring — is the selection's affordance. */
|
|
2110
|
+
.ed-block.ed-selected { background: rgba(59, 130, 246, 0.15); }
|
|
2111
|
+
.ed-block.ed-selected pre,
|
|
2112
|
+
.ed-block.ed-selected th,
|
|
2113
|
+
.ed-block.ed-selected tr:nth-child(even),
|
|
2114
|
+
.ed-block.ed-selected tbody td:first-child,
|
|
2115
|
+
.ed-block.ed-selected thead th:first-child { background: rgba(59, 130, 246, 0.15) !important; }
|
|
2116
|
+
.ed-block.ed-selected:focus { outline: none; }
|
|
2053
2117
|
/* white-space: pre-line is LOAD-BEARING, not styling — do not relax it.
|
|
2054
2118
|
A hard-wrapped ("lazy continuation") list item's own content contains a
|
|
2055
2119
|
real newline, and its .ed-li-text is an editing host. Under the default
|
|
@@ -2111,7 +2175,9 @@ ${itemsHtml}
|
|
|
2111
2175
|
.ed-block:hover .ed-handle,
|
|
2112
2176
|
.ed-handle:focus { opacity: 1; }
|
|
2113
2177
|
.ed-handle:hover { background: rgba(0, 0, 0, 0.08); }
|
|
2114
|
-
/* The ⠿ handle's menu: 轉換成 › / 建立副本 / 刪除 / MD 原始碼 (spec §3.7)
|
|
2178
|
+
/* The ⠿ handle's menu: 轉換成 › / 建立副本 / 刪除 / MD 原始碼 (spec §3.7),
|
|
2179
|
+
each led by a 16px currentColor icon (v2.12.0; drawn in
|
|
2180
|
+
lib/editor/client.js's MENU_ICON_PATHS).
|
|
2115
2181
|
Dark translucent panel, bordered rows — same visual language as
|
|
2116
2182
|
.ed-seltb below.
|
|
2117
2183
|
|
|
@@ -2135,7 +2201,23 @@ ${itemsHtml}
|
|
|
2135
2201
|
background: rgba(255, 255, 255, 0.08); color: inherit;
|
|
2136
2202
|
font: inherit; font-size: 12px; line-height: 1;
|
|
2137
2203
|
text-align: left; white-space: nowrap; cursor: pointer;
|
|
2138
|
-
|
|
2204
|
+
/* v2.12.0: a flex ROW so the icon and the label share a baseline-free
|
|
2205
|
+
centre line and a fixed gap. The row is still one flex item of the
|
|
2206
|
+
column above, and align-items: stretch there keeps every row the same
|
|
2207
|
+
width, so S2's one-left-edge / monotonic-top invariants are untouched.
|
|
2208
|
+
The submenu's own buttons carry this class and no icon; a lone text node
|
|
2209
|
+
is an anonymous flex item at flex-start, i.e. exactly where text-align
|
|
2210
|
+
put it. */
|
|
2211
|
+
display: flex; align-items: center; gap: 8px;
|
|
2212
|
+
}
|
|
2213
|
+
/* Notion's own sizing. The stroke colour is NOT set here on purpose: the
|
|
2214
|
+
markup carries stroke="currentColor" (lib/editor/client.js), so the icon
|
|
2215
|
+
is whatever colour .ed-handle-menu's own color property is, and follows a
|
|
2216
|
+
retheme for free. A hex here would break that silently. (No backticks in
|
|
2217
|
+
this comment: it lives inside a JS template literal.)
|
|
2218
|
+
flex: 0 0 auto because a 16px-wide flex item next to a nowrap label
|
|
2219
|
+
would otherwise be the thing that shrinks. */
|
|
2220
|
+
.ed-menu-icon { flex: 0 0 auto; width: 16px; height: 16px; }
|
|
2139
2221
|
/* The 轉換成 submenu carries BOTH classes, so it inherits every rule above
|
|
2140
2222
|
and only overrides the horizontal offset. It is a CHILD of the menu, and
|
|
2141
2223
|
the menu is itself position:absolute, so the menu's own padding box is
|
|
@@ -2248,17 +2330,49 @@ ${itemsHtml}
|
|
|
2248
2330
|
}
|
|
2249
2331
|
.ed-te-drop-indicator[hidden] { display: none; }
|
|
2250
2332
|
.ed-te-row-dragging { opacity: 0.4; }
|
|
2333
|
+
/* S4 Task 2 (spec §4.5): the block drag's own singleton drop indicator,
|
|
2334
|
+
shown while a block is being dragged by its ⠿. Same 3px blue rule as the
|
|
2335
|
+
table's, on a SEPARATE node rather than sharing '.ed-te-drop-indicator':
|
|
2336
|
+
that one is shared by the row and column axes and each of them has to
|
|
2337
|
+
rewrite the OTHER axis's dimension on every move or a stale value leaks
|
|
2338
|
+
in — a third writer multiplies that coupling. It is still ONE node for the
|
|
2339
|
+
whole document (the Global Constraint is "never one per boundary"), and
|
|
2340
|
+
the separate class is what lets a test assert that a block drag raised
|
|
2341
|
+
the BLOCK line rather than table chrome that happened to be on screen. */
|
|
2342
|
+
.ed-block-drop-indicator {
|
|
2343
|
+
position: fixed; z-index: 8; height: 3px; border-radius: 2px;
|
|
2344
|
+
background: #3b82f6; pointer-events: none;
|
|
2345
|
+
}
|
|
2346
|
+
.ed-block-drop-indicator[hidden] { display: none; }
|
|
2347
|
+
/* S4 Task 2b (user request, 2026-09-01): the drag's feedback on the SOURCE
|
|
2348
|
+
side. lib/editor/client.js puts '.ed-block-dragging' on <html> the moment
|
|
2349
|
+
a ⠿ drag engages and takes it off in teardownBlockDrag(), which every one
|
|
2350
|
+
of the five abort paths funnels through. On <html> rather than on the
|
|
2351
|
+
dragged block because the plan forbids mutating anything inside
|
|
2352
|
+
'.content' during a drag — that invariant is the only reason Escape /
|
|
2353
|
+
pointercancel / window blur can all clean up with nothing to revert.
|
|
2354
|
+
The universal selector plus !important is what the rule needs to be worth
|
|
2355
|
+
having: '.ed-handle' (the button the gesture starts on), '.ed-handle-menu-btn'
|
|
2356
|
+
and every table grip carry their own 'cursor: pointer', and a plain
|
|
2357
|
+
inherited cursor loses to all of them. */
|
|
2358
|
+
html.ed-block-dragging, html.ed-block-dragging * { cursor: grabbing !important; }
|
|
2251
2359
|
/* Notion-style grip handles: replace the original invisible TE_EDGE_PX=8
|
|
2252
2360
|
proximity zone (user-acceptance feedback: unusably small, no visible
|
|
2253
2361
|
affordance) with two real, adequately-sized (>=18x24px) click/drag
|
|
2254
2362
|
targets. '.ed-te-grip-row' is a vertical 6-dot handle shown at the LEFT
|
|
2255
2363
|
EDGE of the hovered row -- every row, the HEADER included (spec 3.10:
|
|
2256
|
-
the header is draggable too),
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
the
|
|
2261
|
-
|
|
2364
|
+
the header is draggable too), sitting ENTIRELY INSIDE the table with its
|
|
2365
|
+
LEFT edge on the table's left border (spec 4.2 hit-test conflict 2,
|
|
2366
|
+
landed in S4 T1). The two axes are therefore NOT mirror images any more:
|
|
2367
|
+
'.ed-te-grip-col' still straddles the table's TOP border, because nothing
|
|
2368
|
+
of the page's own chrome lives up there. The row axis cannot: MEASURED on
|
|
2369
|
+
v2.12.0 at 1400x900, the straddling grip occupied [tableLeft-10,
|
|
2370
|
+
tableLeft+10] while the block's own '.ed-handle' occupied [blockLeft-22,
|
|
2371
|
+
blockLeft-4] with tableLeft == blockLeft, so the grip (position: fixed,
|
|
2372
|
+
z-index 7, on document.body) won the hit test for the RIGHT 6px of every
|
|
2373
|
+
⠿ whose Y band it reached. The first column's own padding is widened in
|
|
2374
|
+
the edit-mode-only layout chunk near the end of this stylesheet so the
|
|
2375
|
+
inset grip still never reaches cell TEXT; '.ed-te-grip-col' is a horizontal 6-dot
|
|
2262
2376
|
handle shown just ABOVE the hovered column (every column). Dots are plain <span>s laid out via CSS grid with
|
|
2263
2377
|
place-content: center, so the dot cluster stays compact/centered
|
|
2264
2378
|
regardless of the button's own (larger, hit-target-sized) box — no
|
|
@@ -2277,8 +2391,15 @@ ${itemsHtml}
|
|
|
2277
2391
|
.ed-te-grip:hover { background: rgba(0, 0, 0, 0.06); }
|
|
2278
2392
|
.ed-te-grip-dot { width: 3px; height: 3px; border-radius: 50%; background: #9ca3af; }
|
|
2279
2393
|
.ed-te-grip:hover .ed-te-grip-dot { background: #6b7280; }
|
|
2394
|
+
/* spec §4.2 hit-test conflict 2 (S4 T1): the row grip's width is a TOKEN
|
|
2395
|
+
because two other numbers are derived from it — lib/editor/client.js pins
|
|
2396
|
+
the grip's LEFT edge to the table's left border (so the whole grip is
|
|
2397
|
+
inside the first column and shares no pixel with the block gutter's ⠿),
|
|
2398
|
+
and the edit-mode first-column padding must be wide enough to host it
|
|
2399
|
+
without covering cell text. Written once here, derived twice below. */
|
|
2400
|
+
:root { --ed-te-grip-row-w: 20px; }
|
|
2280
2401
|
.ed-te-grip-row {
|
|
2281
|
-
width:
|
|
2402
|
+
width: var(--ed-te-grip-row-w); height: 28px; cursor: grab; z-index: 7;
|
|
2282
2403
|
grid-template-columns: repeat(2, 3px); grid-template-rows: repeat(3, 3px);
|
|
2283
2404
|
gap: 3px 4px;
|
|
2284
2405
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helping-ai-workflow/md2doc",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.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",
|