@mdzip/editor 1.3.18 → 1.3.20
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/AGENTS.md +4 -0
- package/README.md +40 -0
- package/dist/archive-utils.d.ts +11 -0
- package/dist/archive-utils.d.ts.map +1 -1
- package/dist/archive-utils.js +29 -0
- package/dist/archive-utils.js.map +1 -1
- package/dist/library-info.d.ts +1 -1
- package/dist/library-info.js +1 -1
- package/dist/view-css.d.ts.map +1 -1
- package/dist/view-css.js +54 -0
- package/dist/view-css.js.map +1 -1
- package/dist/view.d.ts +79 -0
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +319 -7
- package/dist/view.js.map +1 -1
- package/package.json +1 -1
package/dist/view.js
CHANGED
|
@@ -5,8 +5,9 @@ import { closeSearchPanel, openSearchPanel, search, searchKeymap } from '@codemi
|
|
|
5
5
|
import { Compartment, EditorState } from '@codemirror/state';
|
|
6
6
|
import { Decoration, EditorView, MatchDecorator, ViewPlugin, dropCursor, keymap, lineNumbers } from '@codemirror/view';
|
|
7
7
|
import { tags } from '@lezer/highlight';
|
|
8
|
-
import { Bold, Check, ChevronDown, ChevronRight, ClipboardPaste, ClipboardType, Code, Columns2, Copy, Eraser, Eye, File, FileBraces, FileImage, Folder, FolderOpen, Hash, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Highlighter, ImagePlus, Info, Italic, Link2Off, Link, List, ListOrdered, CornerDownLeft, Moon, PackagePlus, PanelLeft, Pilcrow, Quote, Save, Scissors, Search, SquareCode, SquarePen, Strikethrough, Sun, TextSelect, ZoomIn } from 'lucide';
|
|
8
|
+
import { Bold, Check, ChevronDown, ChevronRight, ClipboardPaste, ClipboardType, Code, Columns2, Copy, Eraser, Eye, File, FileBraces, FileImage, Folder, FolderOpen, Hash, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Highlighter, ImagePlus, Info, Italic, Link2Off, Link, List, ListOrdered, CornerDownLeft, Moon, PackagePlus, PanelLeft, Pilcrow, Quote, Save, Scissors, Search, SpellCheck, SquareCode, SquarePen, Strikethrough, Sun, TextSelect, ZoomIn } from 'lucide';
|
|
9
9
|
import { browserClipboardHasImage, readBrowserClipboardImage } from './browser.js';
|
|
10
|
+
import { buildPackedArchiveBytes, isMarkdownArchivePath, resolveMarkdownEntryPoint } from './archive-utils.js';
|
|
10
11
|
import { MdzipAssetSession, mdzipArchiveSourceId, sniffImageSize } from './asset-cache.js';
|
|
11
12
|
import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
|
|
12
13
|
import { MDZIP_RUNTIME_LIBRARIES } from './library-info.js';
|
|
@@ -71,6 +72,7 @@ const MENU_COPY_ICON_HTML = lucideIcon(Copy, MENU_ICON_CLASS);
|
|
|
71
72
|
const MENU_PASTE_ICON_HTML = lucideIcon(ClipboardPaste, MENU_ICON_CLASS);
|
|
72
73
|
const MENU_PASTE_PLAIN_ICON_HTML = lucideIcon(ClipboardType, MENU_ICON_CLASS);
|
|
73
74
|
const MENU_SELECT_ALL_ICON_HTML = lucideIcon(TextSelect, MENU_ICON_CLASS);
|
|
75
|
+
const MENU_SPELLCHECK_ICON_HTML = lucideIcon(SpellCheck, MENU_ICON_CLASS);
|
|
74
76
|
const MENU_BOLD_ICON_HTML = lucideIcon(Bold, MENU_ICON_CLASS);
|
|
75
77
|
const MENU_ITALIC_ICON_HTML = lucideIcon(Italic, MENU_ICON_CLASS);
|
|
76
78
|
const MENU_STRIKE_ICON_HTML = lucideIcon(Strikethrough, MENU_ICON_CLASS);
|
|
@@ -137,7 +139,8 @@ function renderContextMenuItem(item) {
|
|
|
137
139
|
const shortcut = item.shortcut
|
|
138
140
|
? `<span class="nav-menu-shortcut">${escapeHtml(item.shortcut)}</span>`
|
|
139
141
|
: '';
|
|
140
|
-
|
|
142
|
+
const disabledAttrs = item.disabled ? ' disabled aria-disabled="true"' : '';
|
|
143
|
+
return `<button type="button" role="menuitem" data-menu-action="${escapeHtml(item.action)}"${disabledAttrs}>${icon}${label}${shortcut}</button>`;
|
|
141
144
|
}
|
|
142
145
|
function renderContextMenuItems(items) {
|
|
143
146
|
return items
|
|
@@ -150,6 +153,10 @@ const ALL_LAYOUT_CONTROLS = {
|
|
|
150
153
|
split: true,
|
|
151
154
|
preview: true
|
|
152
155
|
};
|
|
156
|
+
const ALL_CONTEXT_MENU_CONTROLS = {
|
|
157
|
+
editor: true,
|
|
158
|
+
preview: true
|
|
159
|
+
};
|
|
153
160
|
const ALL_FORMATTING_CONTROLS = {
|
|
154
161
|
bold: true,
|
|
155
162
|
italic: true,
|
|
@@ -186,6 +193,7 @@ const CONTROL_PRESETS = {
|
|
|
186
193
|
title: { visible: false, editable: false },
|
|
187
194
|
layout: { source: false, split: false, preview: false },
|
|
188
195
|
formatting: { ...NO_FORMATTING_CONTROLS },
|
|
196
|
+
contextMenu: { ...ALL_CONTEXT_MENU_CONTROLS },
|
|
189
197
|
lineNumbers: false,
|
|
190
198
|
save: false,
|
|
191
199
|
zoom: false,
|
|
@@ -202,6 +210,7 @@ const CONTROL_PRESETS = {
|
|
|
202
210
|
title: { visible: true, editable: false },
|
|
203
211
|
layout: { ...ALL_LAYOUT_CONTROLS },
|
|
204
212
|
formatting: { ...NO_FORMATTING_CONTROLS },
|
|
213
|
+
contextMenu: { ...ALL_CONTEXT_MENU_CONTROLS },
|
|
205
214
|
lineNumbers: true,
|
|
206
215
|
save: false,
|
|
207
216
|
zoom: true,
|
|
@@ -218,6 +227,7 @@ const CONTROL_PRESETS = {
|
|
|
218
227
|
title: { visible: true, editable: true },
|
|
219
228
|
layout: { ...ALL_LAYOUT_CONTROLS },
|
|
220
229
|
formatting: { ...ALL_FORMATTING_CONTROLS },
|
|
230
|
+
contextMenu: { ...ALL_CONTEXT_MENU_CONTROLS },
|
|
221
231
|
lineNumbers: true,
|
|
222
232
|
save: true,
|
|
223
233
|
zoom: true,
|
|
@@ -234,6 +244,7 @@ const CONTROL_PRESETS = {
|
|
|
234
244
|
title: { visible: true, editable: true },
|
|
235
245
|
layout: { ...ALL_LAYOUT_CONTROLS },
|
|
236
246
|
formatting: { ...ALL_FORMATTING_CONTROLS },
|
|
247
|
+
contextMenu: { ...ALL_CONTEXT_MENU_CONTROLS },
|
|
237
248
|
lineNumbers: true,
|
|
238
249
|
save: false,
|
|
239
250
|
zoom: true,
|
|
@@ -267,7 +278,8 @@ export function resolveMdzipControlPolicy(controls) {
|
|
|
267
278
|
preset,
|
|
268
279
|
title: resolveTitleControls(base.title, controls.title),
|
|
269
280
|
layout: resolveLayoutControls(base.layout, controls.layout),
|
|
270
|
-
formatting: resolveFormattingControls(base.formatting, controls.formatting)
|
|
281
|
+
formatting: resolveFormattingControls(base.formatting, controls.formatting),
|
|
282
|
+
contextMenu: resolveContextMenuControls(base.contextMenu, controls.contextMenu)
|
|
271
283
|
};
|
|
272
284
|
}
|
|
273
285
|
function defaultLayoutForPolicy(policy) {
|
|
@@ -284,7 +296,8 @@ function cloneResolvedControlPolicy(policy) {
|
|
|
284
296
|
formatting: {
|
|
285
297
|
...policy.formatting,
|
|
286
298
|
headings: [...policy.formatting.headings]
|
|
287
|
-
}
|
|
299
|
+
},
|
|
300
|
+
contextMenu: { ...policy.contextMenu }
|
|
288
301
|
};
|
|
289
302
|
}
|
|
290
303
|
function resolveTitleControls(base, override) {
|
|
@@ -308,6 +321,21 @@ function resolveLayoutControls(base, override) {
|
|
|
308
321
|
: base;
|
|
309
322
|
return { ...resolvedBase, ...controls };
|
|
310
323
|
}
|
|
324
|
+
function resolveContextMenuControls(base, override) {
|
|
325
|
+
if (typeof override === 'boolean') {
|
|
326
|
+
return { editor: override, preview: override };
|
|
327
|
+
}
|
|
328
|
+
if (!override) {
|
|
329
|
+
return { ...base };
|
|
330
|
+
}
|
|
331
|
+
const { enabled, ...controls } = override;
|
|
332
|
+
const resolvedBase = enabled === false
|
|
333
|
+
? { editor: false, preview: false }
|
|
334
|
+
: enabled === true
|
|
335
|
+
? { ...ALL_CONTEXT_MENU_CONTROLS }
|
|
336
|
+
: base;
|
|
337
|
+
return { ...resolvedBase, ...controls };
|
|
338
|
+
}
|
|
311
339
|
function resolveFormattingControls(base, override) {
|
|
312
340
|
if (typeof override === 'boolean') {
|
|
313
341
|
return override
|
|
@@ -492,7 +520,10 @@ const hardBreakMarkerHighlight = ViewPlugin.fromClass(class {
|
|
|
492
520
|
// the name to be '>' directly, which a URL's ':' never satisfies.
|
|
493
521
|
const htmlTagMarkerMatcher = new MatchDecorator({
|
|
494
522
|
regexp: /<\/?(?!br\b)[a-zA-Z][a-zA-Z0-9-]*(?:\s[^<>]*)?\/?>/gi,
|
|
495
|
-
|
|
523
|
+
// spellcheck="false" on the wrapping span opts this range out of the
|
|
524
|
+
// container-wide spellcheck attribute — tag/attribute names (img, src,
|
|
525
|
+
// align, citation, ...) aren't prose and shouldn't get underlined.
|
|
526
|
+
decoration: Decoration.mark({ class: 'mdzip-hard-break-marker', attributes: { spellcheck: 'false' } })
|
|
496
527
|
});
|
|
497
528
|
const htmlTagMarkerHighlight = ViewPlugin.fromClass(class {
|
|
498
529
|
constructor(view) {
|
|
@@ -683,6 +714,12 @@ export class MdzipWorkspaceView {
|
|
|
683
714
|
this.titleDraft = '';
|
|
684
715
|
this.conversionAction = null;
|
|
685
716
|
this.imageInsertDialogState = null;
|
|
717
|
+
// Promise-based, like imageInsertDialogState — packFilesAsWorkspace is an
|
|
718
|
+
// explicit awaited call building a brand-new archive from an
|
|
719
|
+
// already-captured file list, so (unlike conversionAction's fire-and-forget
|
|
720
|
+
// trigger) there's no "currently open document" to race against and no
|
|
721
|
+
// de-dupe/staleness guard is needed.
|
|
722
|
+
this.packFilesDialogState = null;
|
|
686
723
|
this.navPaneWidth = 280;
|
|
687
724
|
this.splitRatio = 0.5;
|
|
688
725
|
this.resizing = false;
|
|
@@ -811,6 +848,11 @@ export class MdzipWorkspaceView {
|
|
|
811
848
|
this.elImageInsertSizeValueInput = q('[data-ref="image-insert-size-value"]');
|
|
812
849
|
this.elImageInsertPositionSelect = q('[data-ref="image-insert-position"]');
|
|
813
850
|
this.elImageInsertConfirmBtn = q('[data-ref="image-insert-confirm-btn"]');
|
|
851
|
+
this.elPackFilesDialog = q('[data-ref="pack-files-dialog"]');
|
|
852
|
+
this.elPackFilesModeDocument = q('[data-ref="pack-files-mode-document"]');
|
|
853
|
+
this.elPackFilesModeProject = q('[data-ref="pack-files-mode-project"]');
|
|
854
|
+
this.elPackFilesEntrySelect = q('[data-ref="pack-files-entry"]');
|
|
855
|
+
this.elPackFilesConfirmBtn = q('[data-ref="pack-files-confirm-btn"]');
|
|
814
856
|
this.elNavMenu = q('[data-ref="nav-menu"]');
|
|
815
857
|
this.elNameDialog = q('[data-ref="name-dialog"]');
|
|
816
858
|
this.elNameDialogHeading = q('[data-ref="name-dialog-heading"]');
|
|
@@ -1105,6 +1147,7 @@ export class MdzipWorkspaceView {
|
|
|
1105
1147
|
destroy() {
|
|
1106
1148
|
this.conversionDocumentGeneration += 1;
|
|
1107
1149
|
this.resolveImageInsertDialog(null);
|
|
1150
|
+
this.resolvePackFilesDialog(null);
|
|
1108
1151
|
try {
|
|
1109
1152
|
this.unsub?.();
|
|
1110
1153
|
}
|
|
@@ -1161,6 +1204,14 @@ export class MdzipWorkspaceView {
|
|
|
1161
1204
|
const next = resolveMdzipControlPolicy(controls);
|
|
1162
1205
|
const lineNumbersChanged = next.lineNumbers !== this.controlPolicy.lineNumbers;
|
|
1163
1206
|
const searchChanged = next.search !== this.controlPolicy.search;
|
|
1207
|
+
// codeBlockTools chrome is only attached when the preview HTML actually
|
|
1208
|
+
// re-mounts (mountPreviewHtml/mountProgressivePreview), which updatePreview()
|
|
1209
|
+
// skips via its memo when nothing document-facing changed. Unlike
|
|
1210
|
+
// lineNumbers/search (live CodeMirror compartment reconfigures), there's
|
|
1211
|
+
// no live DOM toggle for already-rendered code blocks, so the memo has to
|
|
1212
|
+
// be busted to pick up the new policy — same reason
|
|
1213
|
+
// setImageHydrationAnimation calls resetPreviewState() below.
|
|
1214
|
+
const codeBlockToolsChanged = next.codeBlockTools !== this.controlPolicy.codeBlockTools;
|
|
1164
1215
|
this.controlPolicy = next;
|
|
1165
1216
|
if (lineNumbersChanged && this.cmEditor) {
|
|
1166
1217
|
this.cmEditor.dispatch({
|
|
@@ -1175,6 +1226,9 @@ export class MdzipWorkspaceView {
|
|
|
1175
1226
|
effects: this.searchCompartment.reconfigure(next.search ? [search({ top: true }), keymap.of(searchKeymap)] : [])
|
|
1176
1227
|
});
|
|
1177
1228
|
}
|
|
1229
|
+
if (codeBlockToolsChanged) {
|
|
1230
|
+
this.resetPreviewState();
|
|
1231
|
+
}
|
|
1178
1232
|
const snapshot = this.workspace?.snapshot();
|
|
1179
1233
|
if (snapshot) {
|
|
1180
1234
|
this.layout = this.validLayoutForSnapshot(this.layout, snapshot);
|
|
@@ -1917,6 +1971,9 @@ export class MdzipWorkspaceView {
|
|
|
1917
1971
|
htmlTagMarkerHighlight,
|
|
1918
1972
|
EditorView.lineWrapping,
|
|
1919
1973
|
dropCursor(),
|
|
1974
|
+
// Content is contenteditable, but browsers don't agree on a default
|
|
1975
|
+
// for unconfigured spellcheck there — set it explicitly (#33).
|
|
1976
|
+
EditorView.contentAttributes.of({ spellcheck: 'true' }),
|
|
1920
1977
|
mdzipEditorTheme,
|
|
1921
1978
|
this.readOnlyCompartment.of(EditorState.readOnly.of(mode === 'read-only')),
|
|
1922
1979
|
EditorView.updateListener.of((update) => {
|
|
@@ -2169,11 +2226,23 @@ export class MdzipWorkspaceView {
|
|
|
2169
2226
|
this.elImageInsertPositionSelect.value = 'inline';
|
|
2170
2227
|
this.updateImageInsertOptionControls();
|
|
2171
2228
|
}
|
|
2229
|
+
this.elPackFilesDialog.hidden = this.packFilesDialogState === null;
|
|
2230
|
+
if (this.packFilesDialogState) {
|
|
2231
|
+
const { request } = this.packFilesDialogState;
|
|
2232
|
+
this.elPackFilesEntrySelect.innerHTML = request.markdownFiles
|
|
2233
|
+
.map((p) => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`)
|
|
2234
|
+
.join('');
|
|
2235
|
+
this.elPackFilesEntrySelect.value = request.suggestedEntryPoint;
|
|
2236
|
+
this.elPackFilesModeDocument.checked = true;
|
|
2237
|
+
this.elPackFilesModeProject.checked = false;
|
|
2238
|
+
}
|
|
2172
2239
|
this.elMetadataDialog.hidden = !this.metadataDialogOpen;
|
|
2173
2240
|
if (this.contextMenuState) {
|
|
2174
2241
|
const items = this.contextMenuState.kind === 'nav'
|
|
2175
2242
|
? this.navMenuItems(this.contextMenuState.target, snapshot)
|
|
2176
|
-
: this.
|
|
2243
|
+
: this.contextMenuState.kind === 'editor'
|
|
2244
|
+
? this.editorMenuItems(snapshot)
|
|
2245
|
+
: this.previewMenuItems();
|
|
2177
2246
|
if (items.length === 0) {
|
|
2178
2247
|
this.contextMenuState = null;
|
|
2179
2248
|
}
|
|
@@ -2258,6 +2327,21 @@ export class MdzipWorkspaceView {
|
|
|
2258
2327
|
this.render();
|
|
2259
2328
|
}
|
|
2260
2329
|
});
|
|
2330
|
+
// Ctrl/Cmd+A normally selects the whole page, which in split layout grabs
|
|
2331
|
+
// both panes' text at once. Scope it to the rendered content instead when
|
|
2332
|
+
// focus is inside the preview pane (see the mousedown handler below for
|
|
2333
|
+
// how it gets there); the source editor keeps its own defaultKeymap
|
|
2334
|
+
// binding since CodeMirror's content root sits outside this element.
|
|
2335
|
+
doc.addEventListener('keydown', (e) => {
|
|
2336
|
+
if (e.key.toLowerCase() !== 'a' || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) {
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
if (!doc.activeElement || !this.elPreviewPane.contains(doc.activeElement)) {
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
e.preventDefault();
|
|
2343
|
+
this.selectAllPreviewContent();
|
|
2344
|
+
});
|
|
2261
2345
|
this.elNavBtn.addEventListener('click', () => {
|
|
2262
2346
|
if (!this.controlPolicy.navigation) {
|
|
2263
2347
|
return;
|
|
@@ -2520,6 +2604,13 @@ export class MdzipWorkspaceView {
|
|
|
2520
2604
|
this.resetImageInsertSizeValue();
|
|
2521
2605
|
this.updateImageInsertOptionControls();
|
|
2522
2606
|
});
|
|
2607
|
+
this.elPackFilesDialog.querySelector('[data-action="cancel-pack-files"]')
|
|
2608
|
+
.addEventListener('click', () => {
|
|
2609
|
+
this.resolvePackFilesDialog(null);
|
|
2610
|
+
});
|
|
2611
|
+
this.elPackFilesConfirmBtn.addEventListener('click', () => {
|
|
2612
|
+
this.resolvePackFilesDialog(this.readPackFilesDialogDecision());
|
|
2613
|
+
});
|
|
2523
2614
|
this.elNavMenu.addEventListener('click', (e) => {
|
|
2524
2615
|
e.stopPropagation();
|
|
2525
2616
|
const item = e.target.closest('[data-menu-action]');
|
|
@@ -2530,6 +2621,9 @@ export class MdzipWorkspaceView {
|
|
|
2530
2621
|
if (this.contextMenuState?.kind === 'editor') {
|
|
2531
2622
|
void this.handleEditorMenuAction(action);
|
|
2532
2623
|
}
|
|
2624
|
+
else if (this.contextMenuState?.kind === 'preview') {
|
|
2625
|
+
this.handlePreviewMenuAction(action);
|
|
2626
|
+
}
|
|
2533
2627
|
else {
|
|
2534
2628
|
void this.handleNavMenuAction(action);
|
|
2535
2629
|
}
|
|
@@ -2560,6 +2654,9 @@ export class MdzipWorkspaceView {
|
|
|
2560
2654
|
submenu.style.top = `${clampedTop - rect.top}px`;
|
|
2561
2655
|
});
|
|
2562
2656
|
this.elEditorHost.addEventListener('contextmenu', (e) => {
|
|
2657
|
+
if (!this.controlPolicy.contextMenu.editor) {
|
|
2658
|
+
return;
|
|
2659
|
+
}
|
|
2563
2660
|
const snapshot = this.workspace?.snapshot();
|
|
2564
2661
|
if (!snapshot || !this.cmEditor) {
|
|
2565
2662
|
return;
|
|
@@ -2580,6 +2677,27 @@ export class MdzipWorkspaceView {
|
|
|
2580
2677
|
e.stopPropagation();
|
|
2581
2678
|
this.render();
|
|
2582
2679
|
});
|
|
2680
|
+
this.elPreviewPane.addEventListener('contextmenu', (e) => {
|
|
2681
|
+
if (!this.controlPolicy.contextMenu.preview) {
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
// Captured now, not read live in the click handler: clicking the menu
|
|
2685
|
+
// button collapses the browser selection before the action runs (same
|
|
2686
|
+
// reason the editor menu captures its CodeMirror range up front).
|
|
2687
|
+
const domSelection = this.elPreviewPane.ownerDocument.defaultView?.getSelection();
|
|
2688
|
+
const text = domSelection && !domSelection.isCollapsed
|
|
2689
|
+
&& this.elPreviewContent.contains(domSelection.anchorNode)
|
|
2690
|
+
? domSelection.toString()
|
|
2691
|
+
: '';
|
|
2692
|
+
this.contextMenuState = { kind: 'preview', text, x: e.clientX, y: e.clientY };
|
|
2693
|
+
if (this.previewMenuItems().length === 0) {
|
|
2694
|
+
this.contextMenuState = null;
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
e.preventDefault();
|
|
2698
|
+
e.stopPropagation();
|
|
2699
|
+
this.render();
|
|
2700
|
+
});
|
|
2583
2701
|
this.elNameInput.addEventListener('input', () => {
|
|
2584
2702
|
if (!this.nameDialogState) {
|
|
2585
2703
|
return;
|
|
@@ -2626,6 +2744,12 @@ export class MdzipWorkspaceView {
|
|
|
2626
2744
|
}
|
|
2627
2745
|
});
|
|
2628
2746
|
this.elPreviewPane.addEventListener('scroll', () => this.syncScrollFromPreview());
|
|
2747
|
+
// Gives the pane logical focus on click so a subsequent Ctrl/Cmd+A (below)
|
|
2748
|
+
// can be scoped to it. tabindex="-1" keeps it out of Tab order; this is
|
|
2749
|
+
// the only way it becomes focused.
|
|
2750
|
+
this.elPreviewPane.addEventListener('mousedown', () => {
|
|
2751
|
+
this.elPreviewPane.focus({ preventScroll: true });
|
|
2752
|
+
});
|
|
2629
2753
|
this.elPreviewPane.addEventListener('click', (event) => {
|
|
2630
2754
|
const link = event.target.closest('a[href]');
|
|
2631
2755
|
const snapshot = this.workspace?.snapshot();
|
|
@@ -2989,6 +3113,86 @@ export class MdzipWorkspaceView {
|
|
|
2989
3113
|
}
|
|
2990
3114
|
this.elImageInput.click();
|
|
2991
3115
|
}
|
|
3116
|
+
/**
|
|
3117
|
+
* Packs an already-collected file list (e.g. from a host folder picker)
|
|
3118
|
+
* into a new .mdz workspace. With 0 or 1 Markdown files among `files`,
|
|
3119
|
+
* packs Document mode immediately with no prompt. With more than one,
|
|
3120
|
+
* asks `onPackRequested` (if set) to decide Document vs. Project mode and
|
|
3121
|
+
* the entry point; falls back to a built-in dialog if the hook is absent,
|
|
3122
|
+
* declines, or throws. Document mode opens the result in memory; Project
|
|
3123
|
+
* mode returns the archive bytes without opening them, since only the
|
|
3124
|
+
* host knows where a project archive should be saved.
|
|
3125
|
+
*/
|
|
3126
|
+
async packFilesAsWorkspace(files, options = {}) {
|
|
3127
|
+
const markdownFiles = files.filter((f) => isMarkdownArchivePath(f.path)).map((f) => f.path);
|
|
3128
|
+
if (markdownFiles.length <= 1) {
|
|
3129
|
+
const entryPoint = markdownFiles[0] ?? 'index.md';
|
|
3130
|
+
return this.applyPackDecision(files, { mode: 'document', entryPoint }, options);
|
|
3131
|
+
}
|
|
3132
|
+
const suggestedEntryPoint = resolveMarkdownEntryPoint(markdownFiles);
|
|
3133
|
+
const request = { files, markdownFiles, suggestedEntryPoint };
|
|
3134
|
+
const hook = this.options.onPackRequested;
|
|
3135
|
+
if (hook) {
|
|
3136
|
+
const context = this.createPackFilesContext(files, options);
|
|
3137
|
+
let applied = null;
|
|
3138
|
+
const wrappedContext = {
|
|
3139
|
+
applyDecision: async (decision) => {
|
|
3140
|
+
applied = await context.applyDecision(decision);
|
|
3141
|
+
return applied;
|
|
3142
|
+
}
|
|
3143
|
+
};
|
|
3144
|
+
let handled = false;
|
|
3145
|
+
try {
|
|
3146
|
+
handled = await hook(request, wrappedContext);
|
|
3147
|
+
}
|
|
3148
|
+
catch (error) {
|
|
3149
|
+
this.options.onFailed?.(error);
|
|
3150
|
+
}
|
|
3151
|
+
if (handled) {
|
|
3152
|
+
return applied;
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
const decision = await this.openPackFilesDialog(request);
|
|
3156
|
+
return decision ? this.applyPackDecision(files, decision, options) : null;
|
|
3157
|
+
}
|
|
3158
|
+
createPackFilesContext(files, options) {
|
|
3159
|
+
return { applyDecision: (decision) => this.applyPackDecision(files, decision, options) };
|
|
3160
|
+
}
|
|
3161
|
+
async applyPackDecision(files, decision, options) {
|
|
3162
|
+
const archiveBytes = await buildPackedArchiveBytes(files, {
|
|
3163
|
+
mode: decision.mode,
|
|
3164
|
+
entryPoint: decision.entryPoint,
|
|
3165
|
+
title: options.title ?? 'document'
|
|
3166
|
+
});
|
|
3167
|
+
if (decision.mode === 'document') {
|
|
3168
|
+
await this.open(archiveBytes, { mode: 'editable', fileName: options.fileName ?? 'document.mdz' });
|
|
3169
|
+
return { mode: 'document', entryPoint: decision.entryPoint, archiveBytes, opened: true };
|
|
3170
|
+
}
|
|
3171
|
+
return { mode: 'project', entryPoint: decision.entryPoint, archiveBytes, opened: false };
|
|
3172
|
+
}
|
|
3173
|
+
openPackFilesDialog(request) {
|
|
3174
|
+
return new Promise((resolve) => {
|
|
3175
|
+
this.resolvePackFilesDialog(null); // cancel any prior pending dialog, same as image-insert
|
|
3176
|
+
this.packFilesDialogState = { request, files: request.files, resolve };
|
|
3177
|
+
this.render();
|
|
3178
|
+
requestAnimationFrame(() => this.elPackFilesConfirmBtn.focus());
|
|
3179
|
+
});
|
|
3180
|
+
}
|
|
3181
|
+
resolvePackFilesDialog(decision) {
|
|
3182
|
+
const state = this.packFilesDialogState;
|
|
3183
|
+
if (!state) {
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
this.packFilesDialogState = null;
|
|
3187
|
+
this.render();
|
|
3188
|
+
state.resolve(decision);
|
|
3189
|
+
}
|
|
3190
|
+
readPackFilesDialogDecision() {
|
|
3191
|
+
return {
|
|
3192
|
+
mode: this.elPackFilesModeProject.checked ? 'project' : 'document',
|
|
3193
|
+
entryPoint: this.elPackFilesEntrySelect.value
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
2992
3196
|
async handlePaste(event) {
|
|
2993
3197
|
const snapshot = this.workspace?.snapshot();
|
|
2994
3198
|
if (!snapshot || snapshot.mode === 'read-only' || snapshot.currentPathType !== 'markdown') {
|
|
@@ -3267,6 +3471,19 @@ export class MdzipWorkspaceView {
|
|
|
3267
3471
|
groups.push([{ action: 'editor-clear-format', label: 'Clear Formatting', icon: MENU_CLEAR_FORMAT_ICON_HTML }]);
|
|
3268
3472
|
}
|
|
3269
3473
|
groups.push([{ action: 'editor-select-all', label: 'Select All', icon: MENU_SELECT_ALL_ICON_HTML, shortcut: this.editorShortcut('A') }]);
|
|
3474
|
+
// This menu replaces the browser's native one, which is the only place
|
|
3475
|
+
// spelling suggestions live — there's no API to read the browser's
|
|
3476
|
+
// dictionary suggestions into a custom menu. Point at the escape hatch
|
|
3477
|
+
// instead of silently dropping the feature.
|
|
3478
|
+
if (editable) {
|
|
3479
|
+
groups.push([{
|
|
3480
|
+
action: 'editor-spelling-suggestions-hint',
|
|
3481
|
+
label: 'Spelling Suggestions',
|
|
3482
|
+
icon: MENU_SPELLCHECK_ICON_HTML,
|
|
3483
|
+
shortcut: 'Shift+Right-Click',
|
|
3484
|
+
disabled: true
|
|
3485
|
+
}]);
|
|
3486
|
+
}
|
|
3270
3487
|
return groups.flatMap((group, index) => (index === 0 ? group : [null, ...group]));
|
|
3271
3488
|
}
|
|
3272
3489
|
async handleEditorMenuAction(action) {
|
|
@@ -3332,6 +3549,72 @@ export class MdzipWorkspaceView {
|
|
|
3332
3549
|
break;
|
|
3333
3550
|
}
|
|
3334
3551
|
}
|
|
3552
|
+
// Items for the rendered-preview selection menu. Taking over `contextmenu`
|
|
3553
|
+
// to offer Select All also suppresses the browser's native menu — which is
|
|
3554
|
+
// the only thing that was otherwise offering Copy on a right-click — so
|
|
3555
|
+
// Copy has to be reinstated explicitly whenever there's a selection.
|
|
3556
|
+
previewMenuItems() {
|
|
3557
|
+
const state = this.contextMenuState;
|
|
3558
|
+
if (state?.kind !== 'preview' || !this.elPreviewContent.textContent?.trim()) {
|
|
3559
|
+
return [];
|
|
3560
|
+
}
|
|
3561
|
+
const items = [];
|
|
3562
|
+
if (state.text) {
|
|
3563
|
+
items.push({ action: 'preview-copy', label: 'Copy', icon: MENU_COPY_ICON_HTML, shortcut: this.editorShortcut('C') });
|
|
3564
|
+
items.push(null);
|
|
3565
|
+
}
|
|
3566
|
+
items.push({
|
|
3567
|
+
action: 'preview-select-all',
|
|
3568
|
+
label: 'Select All',
|
|
3569
|
+
icon: MENU_SELECT_ALL_ICON_HTML,
|
|
3570
|
+
shortcut: this.editorShortcut('A')
|
|
3571
|
+
});
|
|
3572
|
+
return items;
|
|
3573
|
+
}
|
|
3574
|
+
handlePreviewMenuAction(action) {
|
|
3575
|
+
const state = this.contextMenuState;
|
|
3576
|
+
this.contextMenuState = null;
|
|
3577
|
+
this.render();
|
|
3578
|
+
if (state?.kind !== 'preview') {
|
|
3579
|
+
return;
|
|
3580
|
+
}
|
|
3581
|
+
switch (action) {
|
|
3582
|
+
case 'preview-select-all':
|
|
3583
|
+
this.selectAllPreviewContent();
|
|
3584
|
+
break;
|
|
3585
|
+
case 'preview-copy':
|
|
3586
|
+
void this.copyPreviewSelection(state.text);
|
|
3587
|
+
break;
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
async copyPreviewSelection(text) {
|
|
3591
|
+
if (!text) {
|
|
3592
|
+
return;
|
|
3593
|
+
}
|
|
3594
|
+
try {
|
|
3595
|
+
await this.editorClipboard()?.writeText(text);
|
|
3596
|
+
}
|
|
3597
|
+
catch (error) {
|
|
3598
|
+
this.options.onFailed?.(error);
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
// Selects the full rendered content of the preview pane, scoped to that
|
|
3602
|
+
// pane rather than the whole page (native Ctrl+A / right-click Select All
|
|
3603
|
+
// would otherwise grab the source editor's text too in split layout).
|
|
3604
|
+
selectAllPreviewContent() {
|
|
3605
|
+
const doc = this.elPreviewContent.ownerDocument;
|
|
3606
|
+
const selection = doc.defaultView?.getSelection();
|
|
3607
|
+
if (!selection) {
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3610
|
+
// Focus before selecting: focusing the pane after building the range
|
|
3611
|
+
// collapses the selection right back out.
|
|
3612
|
+
this.elPreviewPane.focus({ preventScroll: true });
|
|
3613
|
+
const range = doc.createRange();
|
|
3614
|
+
range.selectNodeContents(this.elPreviewContent);
|
|
3615
|
+
selection.removeAllRanges();
|
|
3616
|
+
selection.addRange(range);
|
|
3617
|
+
}
|
|
3335
3618
|
// Inserts a fenced code block, carrying the chosen language as the fence info
|
|
3336
3619
|
// string. An empty language produces a plain ```` ``` ```` block.
|
|
3337
3620
|
insertCodeBlock(language) {
|
|
@@ -4599,7 +4882,7 @@ const SHELL_HTML = `
|
|
|
4599
4882
|
</section>
|
|
4600
4883
|
<div class="split-resizer" data-ref="split-resizer"
|
|
4601
4884
|
role="separator" aria-orientation="vertical" aria-label="Resize split panes"></div>
|
|
4602
|
-
<section class="pane preview-pane" data-ref="preview-pane">
|
|
4885
|
+
<section class="pane preview-pane" data-ref="preview-pane" tabindex="-1">
|
|
4603
4886
|
<article class="preview-content" data-ref="preview-content"></article>
|
|
4604
4887
|
</section>
|
|
4605
4888
|
<section class="pane entry-pane" data-ref="entry-pane"></section>
|
|
@@ -4693,6 +4976,35 @@ const SHELL_HTML = `
|
|
|
4693
4976
|
</div>
|
|
4694
4977
|
</div>
|
|
4695
4978
|
|
|
4979
|
+
<div class="title-dialog-backdrop" data-ref="pack-files-dialog" hidden
|
|
4980
|
+
role="dialog" aria-modal="true" aria-labelledby="mdzip-pack-files-dialog-heading">
|
|
4981
|
+
<div class="title-dialog pack-files-dialog">
|
|
4982
|
+
<h3 id="mdzip-pack-files-dialog-heading">Package Markdown Files</h3>
|
|
4983
|
+
<p>These files contain more than one Markdown document. Choose how to package them.</p>
|
|
4984
|
+
<fieldset class="pack-files-options">
|
|
4985
|
+
<legend>Mode</legend>
|
|
4986
|
+
<label>
|
|
4987
|
+
<input type="radio" name="mdzip-pack-files-mode" value="document"
|
|
4988
|
+
data-ref="pack-files-mode-document" checked />
|
|
4989
|
+
Document — one primary page; the rest are attached
|
|
4990
|
+
</label>
|
|
4991
|
+
<label>
|
|
4992
|
+
<input type="radio" name="mdzip-pack-files-mode" value="project"
|
|
4993
|
+
data-ref="pack-files-mode-project" />
|
|
4994
|
+
Project — a set of related documents
|
|
4995
|
+
</label>
|
|
4996
|
+
</fieldset>
|
|
4997
|
+
<label class="pack-files-field">
|
|
4998
|
+
<span>Entry point</span>
|
|
4999
|
+
<select data-ref="pack-files-entry"></select>
|
|
5000
|
+
</label>
|
|
5001
|
+
<div class="title-dialog-actions">
|
|
5002
|
+
<button type="button" data-action="cancel-pack-files">Cancel</button>
|
|
5003
|
+
<button type="button" class="save-title" data-ref="pack-files-confirm-btn">Package</button>
|
|
5004
|
+
</div>
|
|
5005
|
+
</div>
|
|
5006
|
+
</div>
|
|
5007
|
+
|
|
4696
5008
|
<div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
|
|
4697
5009
|
role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
|
|
4698
5010
|
<div class="title-dialog metadata-dialog">
|