@helping-ai-workflow/md2doc 3.4.0 → 3.4.2
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 +142 -18
- package/lib/editor/paste-md.js +5 -1
- package/lib/editor/server.js +36 -2
- package/package.json +1 -1
package/lib/editor/client.js
CHANGED
|
@@ -145,7 +145,63 @@
|
|
|
145
145
|
// in the opposite direction (a zero-width "after" range) to let the
|
|
146
146
|
// existing UndoStack undo()/redo() pair (lib/editor/lineops.js) handle a
|
|
147
147
|
// pure insertion/pure removal without a third op shape.
|
|
148
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Is every line of this paste part of a list?
|
|
150
|
+
*
|
|
151
|
+
* "Part of a list" is a list line OR an indented continuation of one — a
|
|
152
|
+
* wrapped sentence, a nested block, a fenced snippet inside an item. Blank
|
|
153
|
+
* lines inside the paste are allowed (a list may be loose on purpose; what
|
|
154
|
+
* this decides is only whether to ADD one at the seam).
|
|
155
|
+
*
|
|
156
|
+
* A paste that is a list FOLLOWED BY a paragraph answers false, and should:
|
|
157
|
+
* the blank line before that paragraph is what keeps it out of the last
|
|
158
|
+
* item, and the seam's own blank is the lesser problem of the two.
|
|
159
|
+
*/
|
|
160
|
+
function pasteIsAllListLines(pastedLines) {
|
|
161
|
+
let sawListLine = false;
|
|
162
|
+
for (const raw of pastedLines) {
|
|
163
|
+
const line = String(raw == null ? '' : raw);
|
|
164
|
+
if (line.trim() === '') continue;
|
|
165
|
+
if (LIST_LINE_RE.test(line)) { sawListLine = true; continue; }
|
|
166
|
+
// A continuation has to be indented AND it has to belong to something.
|
|
167
|
+
if (sawListLine && /^[ \t]/.test(line)) continue;
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
return sawListLine;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Does this line open a list item? Deliberately the same shape as
|
|
174
|
+
// list-md.js's own SRC_MARKER_RE (`^([ \t]*)(?:[-*+]|\d{1,9}[.)])([ \t]+)…`),
|
|
175
|
+
// which is function-local there and answers a different question — it
|
|
176
|
+
// PARSES a marker, this only CLASSIFIES a line. The two are kept in
|
|
177
|
+
// agreement by a test that runs both over the same table of lines rather
|
|
178
|
+
// than by sharing one object: making that one exportable would drag a
|
|
179
|
+
// browser-side module into commitBlockInsertion(), whose whole value is
|
|
180
|
+
// that it is a pure function node can call directly.
|
|
181
|
+
const LIST_LINE_RE = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+/;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `opts.tight` — insert with NO leading blank line, and skip the trailing
|
|
185
|
+
* one when what follows is itself a list line.
|
|
186
|
+
*
|
|
187
|
+
* The always-on blank is right for a paragraph and wrong for a list item:
|
|
188
|
+
* a blank line between items makes the list LOOSE, every item then renders
|
|
189
|
+
* as <p>, serializeBlocks() answers 'P' per item, and the run degrades
|
|
190
|
+
* READ-ONLY with no banner. A user meets that as "I pasted an item beside
|
|
191
|
+
* my list and now Tab does nothing" — no error, no explanation, nothing to
|
|
192
|
+
* search for.
|
|
193
|
+
*
|
|
194
|
+
* It is why insertListItemAfter() and duplicateListItems() bypass this
|
|
195
|
+
* function. Paste could not: it carries arbitrary pasted lines, and
|
|
196
|
+
* re-serialising a run to absorb them would REWRITE the user's bytes —
|
|
197
|
+
* marker widths, ordinals, and the escaping trap duplicateListItems()
|
|
198
|
+
* records ('~5px' coming back as '\~5px'). So paste keeps the raw-line
|
|
199
|
+
* insert and turns the leading blank off instead.
|
|
200
|
+
*
|
|
201
|
+
* Opt-in, decided by the caller: every other call site wants the blank.
|
|
202
|
+
*/
|
|
203
|
+
function commitBlockInsertion(state, blockId, newBlockLines, opts) {
|
|
204
|
+
const tight = !!(opts && opts.tight);
|
|
149
205
|
const block = state.blocks.find((b) => b.id === blockId);
|
|
150
206
|
// T8 item 1, third helper in the same family: the anchor's range is read as
|
|
151
207
|
// an interval here too (`endLine + 1` is the insertion point, `lines[endLine]`
|
|
@@ -157,20 +213,26 @@
|
|
|
157
213
|
if (anchorRefusal) return anchorRefusal;
|
|
158
214
|
const endLine = block.endLine;
|
|
159
215
|
const nextLine = state.lines[endLine]; // 0-indexed: line right after the block, or undefined at EOF
|
|
160
|
-
|
|
216
|
+
// A list line following a tight insert needs no separator: emitting one
|
|
217
|
+
// would divorce THAT item from the run instead — the same defect, one
|
|
218
|
+
// item further down.
|
|
219
|
+
const needsTrailingBlank = nextLine !== undefined && nextLine.trim() !== '' &&
|
|
220
|
+
!(tight && LIST_LINE_RE.test(nextLine));
|
|
221
|
+
const lead = tight ? [] : [''];
|
|
161
222
|
const after = needsTrailingBlank
|
|
162
|
-
? [
|
|
163
|
-
: [
|
|
223
|
+
? [...lead, ...newBlockLines, '']
|
|
224
|
+
: [...lead, ...newBlockLines];
|
|
164
225
|
const op = { startLine: endLine + 1, endLine, before: [], after };
|
|
165
226
|
const r = ops.insertLines(state.lines, endLine, after);
|
|
166
227
|
const blocks = ops.shiftBlocks(state.blocks, blockId, r.delta);
|
|
167
228
|
state.stack.push(op);
|
|
168
229
|
// The new block's own content starts one line after the leading blank
|
|
169
|
-
// this function
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
230
|
+
// this function inserts by default (see `after` above) — callers use this
|
|
231
|
+
// to locate the freshly-inserted block in the blocks array a subsequent
|
|
232
|
+
// full rerenderAll() (which recomputes blocks server-side) hands back.
|
|
233
|
+
// A tight insert emits no leading blank, so its content begins on the
|
|
234
|
+
// first inserted line itself.
|
|
235
|
+
const newStartLine = tight ? op.startLine : op.startLine + 1;
|
|
174
236
|
return { lines: r.lines, blocks, op, newStartLine };
|
|
175
237
|
}
|
|
176
238
|
|
|
@@ -646,7 +708,7 @@
|
|
|
646
708
|
}
|
|
647
709
|
|
|
648
710
|
if (typeof module === 'object' && module.exports) {
|
|
649
|
-
module.exports = { extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe };
|
|
711
|
+
module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe };
|
|
650
712
|
return; // node: pure core only
|
|
651
713
|
}
|
|
652
714
|
|
|
@@ -4984,7 +5046,14 @@
|
|
|
4984
5046
|
const liveBlockId = Number(anchorEl.getAttribute('data-block-id'));
|
|
4985
5047
|
const block = blocks.find((b) => b.id === liveBlockId);
|
|
4986
5048
|
if (!block) return;
|
|
4987
|
-
|
|
5049
|
+
// `tight` is only ever honoured when the anchor really is a list item:
|
|
5050
|
+
// the caller decides the CONTENT is list-shaped, this decides the
|
|
5051
|
+
// DESTINATION is a list. Both halves are needed — tight-inserting a list
|
|
5052
|
+
// under a paragraph would glue it to that paragraph's last line.
|
|
5053
|
+
const tight = !!(opts && opts.tight) &&
|
|
5054
|
+
anchorEl.getAttribute('data-block-type') === 'li';
|
|
5055
|
+
const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines,
|
|
5056
|
+
{ tight });
|
|
4988
5057
|
const prevLines = lines;
|
|
4989
5058
|
lines = result.lines;
|
|
4990
5059
|
const editRange = result.op
|
|
@@ -14242,7 +14311,32 @@
|
|
|
14242
14311
|
// intent has to be captured on the keystroke that produced it.
|
|
14243
14312
|
let plainPasteArmed = false;
|
|
14244
14313
|
|
|
14245
|
-
const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE =
|
|
14314
|
+
const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE =
|
|
14315
|
+
'只接受 PNG / JPEG / GIF / WebP 圖片,或 draw.io 的 .drawio / .xml';
|
|
14316
|
+
// The one place the two accepted diagram extensions are written down on
|
|
14317
|
+
// this side. /api/asset owns the same pair plus the content check.
|
|
14318
|
+
const DRAWIO_NAME_RE = /\.(drawio|xml)$/i;
|
|
14319
|
+
|
|
14320
|
+
/**
|
|
14321
|
+
* Is this File something the insert path will even try to send?
|
|
14322
|
+
*
|
|
14323
|
+
* ONE predicate with three call sites -- the `drop` filter,
|
|
14324
|
+
* `clipboardItemsOf()` and `uploadImageBlob()` -- because three copies of
|
|
14325
|
+
* "is this insertable" is three chances to disagree. v3.4.1's first cut
|
|
14326
|
+
* widened only two of them and the `drop` filter kept dropping .drawio
|
|
14327
|
+
* files on the floor: the filter runs BEFORE insertImages(), so the file
|
|
14328
|
+
* never entered the pipeline and there was no banner, no request, no
|
|
14329
|
+
* failure -- nothing at all happened, which is indistinguishable from the
|
|
14330
|
+
* feature not existing.
|
|
14331
|
+
*
|
|
14332
|
+
* "Will try to send" is the whole claim. Whether anything is WRITTEN is
|
|
14333
|
+
* decided by /api/asset, which re-checks the extension against its own two
|
|
14334
|
+
* literals and then parses the bytes.
|
|
14335
|
+
*/
|
|
14336
|
+
function insertableFile(f) {
|
|
14337
|
+
if (!f || !assetLib) return false;
|
|
14338
|
+
return !!(assetLib.extFor(f.type) || DRAWIO_NAME_RE.test(f.name || ''));
|
|
14339
|
+
}
|
|
14246
14340
|
const TOOLBAR_NO_ANCHOR_MESSAGE = '找不到可以插入圖片的位置';
|
|
14247
14341
|
// backlog #10 — see the keydown handler's own comment for why every
|
|
14248
14342
|
// stray-key hand-back raises this rather than only some of them.
|
|
@@ -15208,7 +15302,13 @@
|
|
|
15208
15302
|
// authoritative.
|
|
15209
15303
|
async function uploadImageBlob(blob) {
|
|
15210
15304
|
if (!blob) return null;
|
|
15211
|
-
|
|
15305
|
+
// A .drawio arrives with `type === ''` -- browsers have no media type for
|
|
15306
|
+
// it -- so `extFor()` alone answered "unsupported" without ever asking the
|
|
15307
|
+
// server. The name is enough to get the request SENT; it is not enough to
|
|
15308
|
+
// get anything written: /api/asset re-checks the extension against its own
|
|
15309
|
+
// two literals and then parses the bytes. Keep the two rules the same
|
|
15310
|
+
// shape, and let the server's refusal be the one the user sees.
|
|
15311
|
+
if (!insertableFile(blob)) {
|
|
15212
15312
|
showBanner(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE, null, null);
|
|
15213
15313
|
return null;
|
|
15214
15314
|
}
|
|
@@ -15238,6 +15338,15 @@
|
|
|
15238
15338
|
}
|
|
15239
15339
|
let j;
|
|
15240
15340
|
try { j = await res.json(); } catch (e) { j = null; }
|
|
15341
|
+
// A refusal carries a reason; saying "伺服器回應格式不正確" instead is a
|
|
15342
|
+
// second wrong answer on top of the first. The content gate added in
|
|
15343
|
+
// v3.4.1 makes this reachable for an ordinary mistake -- picking an .xml
|
|
15344
|
+
// that is not a draw.io diagram -- so the reason has to arrive.
|
|
15345
|
+
if (!res.ok) {
|
|
15346
|
+
const why = (j && typeof j.error === 'string' && j.error) ? j.error : ('HTTP ' + res.status);
|
|
15347
|
+
showBanner('檔案上傳失敗 — ' + why, null, null);
|
|
15348
|
+
return null;
|
|
15349
|
+
}
|
|
15241
15350
|
if (!j || typeof j.path !== 'string') {
|
|
15242
15351
|
showBanner('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
|
|
15243
15352
|
return null;
|
|
@@ -15351,7 +15460,13 @@
|
|
|
15351
15460
|
const dialogIdentity = captureBlockIdentity(anchorEl);
|
|
15352
15461
|
const input = document.createElement('input');
|
|
15353
15462
|
input.type = 'file';
|
|
15354
|
-
|
|
15463
|
+
// v3.4.1: extension tokens sit beside the MIME tokens deliberately.
|
|
15464
|
+
// `.drawio` has no registered media type, so a MIME-only accept list
|
|
15465
|
+
// cannot name it and the dialog greys the file out -- which is how
|
|
15466
|
+
// v3.4.0 shipped a renderer that embeds .drawio/.xml next to an insert
|
|
15467
|
+
// button that could not pick one. Mixing the two token kinds is what
|
|
15468
|
+
// the attribute is specified for.
|
|
15469
|
+
input.accept = 'image/png,image/jpeg,image/gif,image/webp,.drawio,.xml';
|
|
15355
15470
|
input.multiple = true;
|
|
15356
15471
|
input.hidden = true;
|
|
15357
15472
|
// (a) above.
|
|
@@ -15436,7 +15551,7 @@
|
|
|
15436
15551
|
const files = e.dataTransfer
|
|
15437
15552
|
? Array.prototype.slice.call(e.dataTransfer.files || [])
|
|
15438
15553
|
: [];
|
|
15439
|
-
const images = files.filter(
|
|
15554
|
+
const images = files.filter(insertableFile);
|
|
15440
15555
|
if (!images.length) return; // not ours: let the page do whatever it would
|
|
15441
15556
|
e.preventDefault();
|
|
15442
15557
|
const anchorEl = (e.target && e.target.closest) ? e.target.closest('.ed-block') : null;
|
|
@@ -15456,8 +15571,15 @@
|
|
|
15456
15571
|
try { items['text/html'] = dt.getData('text/html') || ''; } catch (err) { items['text/html'] = ''; }
|
|
15457
15572
|
const files = dt.files ? Array.prototype.slice.call(dt.files) : [];
|
|
15458
15573
|
for (let i = 0; i < files.length; i += 1) {
|
|
15459
|
-
|
|
15460
|
-
|
|
15574
|
+
// This map is keyed by MIME, and a .drawio has no MIME at all, so a
|
|
15575
|
+
// diagram pasted as a FILE goes under the fixed 'file' key that
|
|
15576
|
+
// pickPayload() also looks at. The key is a protocol constant between
|
|
15577
|
+
// these two modules -- the RULE for which files are insertable stays
|
|
15578
|
+
// in insertableFile() alone, so paste-md.js never grows a second
|
|
15579
|
+
// opinion about extensions.
|
|
15580
|
+
if (files[i] && insertableFile(files[i])) {
|
|
15581
|
+
const mime = String(files[i].type || '');
|
|
15582
|
+
items[mime.indexOf('image/') === 0 ? mime : 'file'] = files[i];
|
|
15461
15583
|
break;
|
|
15462
15584
|
}
|
|
15463
15585
|
}
|
|
@@ -15524,7 +15646,9 @@
|
|
|
15524
15646
|
// commitBlockInsertion()/replaceLines() and therefore through
|
|
15525
15647
|
// serializeBlocks()'s existing gates, instead of arriving as a pile of
|
|
15526
15648
|
// foreign nodes the serializer would have to make sense of afterwards.
|
|
15527
|
-
|
|
15649
|
+
const pastedLines = md.split('\n');
|
|
15650
|
+
insertBlockBelow(editEl.closest('.ed-block'), 'paragraph',
|
|
15651
|
+
{ lines: pastedLines, tight: pasteIsAllListLines(pastedLines) });
|
|
15528
15652
|
return;
|
|
15529
15653
|
}
|
|
15530
15654
|
snapBurstIfActive(editEl, 'paste-pre');
|
package/lib/editor/paste-md.js
CHANGED
|
@@ -243,10 +243,14 @@ function htmlToMarkdown(html) {
|
|
|
243
243
|
function pickPayload(clipboardItems, plainOnly) {
|
|
244
244
|
var items = clipboardItems || {};
|
|
245
245
|
|
|
246
|
+
// 'file' sits beside the image/* keys: a .drawio carries no MIME, so the
|
|
247
|
+
// client keys it there rather than inventing one. The payload kind stays
|
|
248
|
+
// 'image' because its only consumer is the upload path, which asks nothing
|
|
249
|
+
// about the bytes -- /api/asset decides what may actually be written.
|
|
246
250
|
var imageKey = null;
|
|
247
251
|
for (var k in items) {
|
|
248
252
|
if (Object.prototype.hasOwnProperty.call(items, k) &&
|
|
249
|
-
k.indexOf('image/') === 0 && items[k] != null) {
|
|
253
|
+
(k.indexOf('image/') === 0 || k === 'file') && items[k] != null) {
|
|
250
254
|
imageKey = k;
|
|
251
255
|
break;
|
|
252
256
|
}
|
package/lib/editor/server.js
CHANGED
|
@@ -670,12 +670,46 @@ async function createEditorServer({
|
|
|
670
670
|
return send(res, 400, { error: 'fileId must be an index into the open file list' });
|
|
671
671
|
}
|
|
672
672
|
const file = absFiles[fileId];
|
|
673
|
-
const ext = assetLib.extFor(body.mime);
|
|
674
|
-
if (!ext) return send(res, 400, { error: 'unsupported image type' });
|
|
675
673
|
const b64 = typeof body.data === 'string' ? body.data : '';
|
|
676
674
|
const buf = Buffer.from(b64, 'base64');
|
|
677
675
|
if (buf.length === 0) return send(res, 400, { error: 'empty image payload' });
|
|
678
676
|
|
|
677
|
+
// v3.4.1: two gates, not one widened whitelist.
|
|
678
|
+
//
|
|
679
|
+
// The raster gate is `extFor(body.mime)` and it is unchanged --
|
|
680
|
+
// `image/svg+xml` stays out for the reason asset.js documents (an SVG
|
|
681
|
+
// can carry <script>, and a MIME string is attacker-controlled).
|
|
682
|
+
//
|
|
683
|
+
// The drawio gate below exists because that first gate CANNOT answer
|
|
684
|
+
// for .drawio at all: browsers report `file.type === ''` for it, so
|
|
685
|
+
// v3.4.0 shipped a renderer that embeds `.drawio`/`.xml` and an insert
|
|
686
|
+
// path that answered every such upload with 'unsupported image type'.
|
|
687
|
+
//
|
|
688
|
+
// Its rule is the half that makes it safe: the EXTENSION picks the
|
|
689
|
+
// candidate and the CONTENT decides whether anything is written. The
|
|
690
|
+
// extension is matched against two literals and the stored value is
|
|
691
|
+
// taken from this table rather than from the name, so no casing,
|
|
692
|
+
// Unicode lookalike or double-extension in an attacker-supplied
|
|
693
|
+
// filename can become the extension on disk. A payload renamed to
|
|
694
|
+
// .xml is refused unless drawio's own parser recognises a root it
|
|
695
|
+
// writes (<mxfile> or <mxGraphModel>).
|
|
696
|
+
const DRAWIO_EXT = { '.drawio': '.drawio', '.xml': '.xml' };
|
|
697
|
+
let ext = assetLib.extFor(body.mime);
|
|
698
|
+
if (!ext) {
|
|
699
|
+
const rawName = String(body.name === null || body.name === undefined ? '' : body.name);
|
|
700
|
+
const m = /\.(drawio|xml)$/i.exec(rawName);
|
|
701
|
+
const candidate = m ? DRAWIO_EXT['.' + m[1].toLowerCase()] : null;
|
|
702
|
+
if (!candidate) return send(res, 400, { error: 'unsupported image type' });
|
|
703
|
+
// `buf` is what lands on disk, so `buf` is what gets checked --
|
|
704
|
+
// never a re-decode of `body.data`, which could differ.
|
|
705
|
+
if (!drawioLib.isDrawioXml(buf.toString('utf8'))) {
|
|
706
|
+
return send(res, 400, {
|
|
707
|
+
error: 'refused: this file is not a draw.io diagram (no <mxfile> or <mxGraphModel> root)',
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
ext = candidate;
|
|
711
|
+
}
|
|
712
|
+
|
|
679
713
|
const baseDir = path.dirname(file);
|
|
680
714
|
const assetsDir = path.join(baseDir, 'assets');
|
|
681
715
|
fs.mkdirSync(assetsDir, { recursive: true });
|