@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/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
- return `<button type="button" role="menuitem" data-menu-action="${escapeHtml(item.action)}">${icon}${label}${shortcut}</button>`;
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
- decoration: Decoration.mark({ class: 'mdzip-hard-break-marker' })
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.editorMenuItems(snapshot);
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">