@mdzip/editor 2.0.1 → 2.1.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.
Files changed (43) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +73 -9
  3. package/dist/asset-cache.d.ts.map +1 -1
  4. package/dist/asset-cache.js +3 -7
  5. package/dist/asset-cache.js.map +1 -1
  6. package/dist/diff.d.ts.map +1 -1
  7. package/dist/diff.js +2 -3
  8. package/dist/diff.js.map +1 -1
  9. package/dist/front-matter-extension.d.ts.map +1 -1
  10. package/dist/front-matter-extension.js +3 -1
  11. package/dist/front-matter-extension.js.map +1 -1
  12. package/dist/icons/page-width.d.ts +6 -0
  13. package/dist/icons/page-width.d.ts.map +1 -0
  14. package/dist/icons/page-width.js +15 -0
  15. package/dist/icons/page-width.js.map +1 -0
  16. package/dist/library-info.d.ts +7 -2
  17. package/dist/library-info.d.ts.map +1 -1
  18. package/dist/library-info.js +8 -2
  19. package/dist/library-info.js.map +1 -1
  20. package/dist/mermaid.d.ts +32 -0
  21. package/dist/mermaid.d.ts.map +1 -1
  22. package/dist/mermaid.js +127 -27
  23. package/dist/mermaid.js.map +1 -1
  24. package/dist/rendering.d.ts +12 -0
  25. package/dist/rendering.d.ts.map +1 -1
  26. package/dist/rendering.js +83 -4
  27. package/dist/rendering.js.map +1 -1
  28. package/dist/sha256.d.ts +10 -0
  29. package/dist/sha256.d.ts.map +1 -0
  30. package/dist/sha256.js +83 -0
  31. package/dist/sha256.js.map +1 -0
  32. package/dist/view-css.d.ts.map +1 -1
  33. package/dist/view-css.js +2216 -2114
  34. package/dist/view-css.js.map +1 -1
  35. package/dist/view.d.ts +183 -20
  36. package/dist/view.d.ts.map +1 -1
  37. package/dist/view.js +1140 -415
  38. package/dist/view.js.map +1 -1
  39. package/dist/workspace.d.ts +12 -0
  40. package/dist/workspace.d.ts.map +1 -1
  41. package/dist/workspace.js +22 -2
  42. package/dist/workspace.js.map +1 -1
  43. package/package.json +5 -4
package/dist/view.js CHANGED
@@ -5,12 +5,13 @@ import { closeSearchPanel, openSearchPanel, search, searchKeymap } from '@codemi
5
5
  import { Compartment, EditorState, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
6
6
  import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType, 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, SpellCheck, 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, Monitor, Moon, PackagePlus, PanelLeft, Pilcrow, Quote, Save, Scissors, Search, SpellCheck, SquareCode, SquarePen, Strikethrough, Sun, TextSelect, ZoomIn } from 'lucide';
9
9
  import { samePath } from '@mdzip/core-js';
10
10
  import { browserClipboardHasImage, readBrowserClipboardImage } from './browser.js';
11
11
  import { buildPackedArchiveBytes, isMarkdownArchivePath, resolveMarkdownEntryPoint } from './archive-utils.js';
12
12
  import { MdzipAssetSession, mdzipArchiveSourceId, sniffImageSize } from './asset-cache.js';
13
13
  import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
14
+ import { PAGE_WIDTH_DEFAULT_ICON, PAGE_WIDTH_FULL_ICON, PAGE_WIDTH_NARROW_ICON, PAGE_WIDTH_WIDE_ICON } from './icons/page-width.js';
14
15
  import { MDZIP_RUNTIME_LIBRARIES } from './library-info.js';
15
16
  import { MdzipWorkspaceService, extensionForMime, normalizeArchivePath, relativeArchivePath } from './workspace.js';
16
17
  import { buildMdzipNavTree, canEditMdzipPath, escapeHtml, isOrphanedMdzipAsset, mdzipEntryIconKind, isMdzipManifestPath, isMdzipDefaultPolicyExternalLink, isMdzipExternalLink, isMdzipWorkspaceRelativeLink, parseMdzipLink, renderMdzipPreviewHtml } from './workspace-view.js';
@@ -53,6 +54,11 @@ const SEARCH_ICON_HTML = lucideIcon(Search, TOOLBAR_ICON_CLASS);
53
54
  const ZOOM_ICON_HTML = lucideIcon(ZoomIn, TOOLBAR_ICON_CLASS);
54
55
  const DARK_THEME_ICON_HTML = lucideIcon(Moon, TOOLBAR_ICON_CLASS);
55
56
  const LIGHT_THEME_ICON_HTML = lucideIcon(Sun, TOOLBAR_ICON_CLASS);
57
+ const SYSTEM_THEME_ICON_HTML = lucideIcon(Monitor, TOOLBAR_ICON_CLASS);
58
+ const PAGE_WIDTH_NARROW_ICON_HTML = lucideIcon(PAGE_WIDTH_NARROW_ICON, TOOLBAR_ICON_CLASS);
59
+ const PAGE_WIDTH_DEFAULT_ICON_HTML = lucideIcon(PAGE_WIDTH_DEFAULT_ICON, TOOLBAR_ICON_CLASS);
60
+ const PAGE_WIDTH_WIDE_ICON_HTML = lucideIcon(PAGE_WIDTH_WIDE_ICON, TOOLBAR_ICON_CLASS);
61
+ const PAGE_WIDTH_FULL_ICON_HTML = lucideIcon(PAGE_WIDTH_FULL_ICON, TOOLBAR_ICON_CLASS);
56
62
  const FORMAT_ICON_CLASS = 'format-icon';
57
63
  const BOLD_ICON_HTML = lucideIcon(Bold, FORMAT_ICON_CLASS);
58
64
  const ITALIC_ICON_HTML = lucideIcon(Italic, FORMAT_ICON_CLASS);
@@ -152,7 +158,8 @@ function renderContextMenuItem(item) {
152
158
  ? `<span class="nav-menu-shortcut">${escapeHtml(item.shortcut)}</span>`
153
159
  : '';
154
160
  const disabledAttrs = item.disabled ? ' disabled aria-disabled="true"' : '';
155
- return `<button type="button" role="menuitem" data-menu-action="${escapeHtml(item.action)}"${disabledAttrs}>${icon}${label}${shortcut}</button>`;
161
+ const tooltipAttr = item.tooltip ? ` data-tooltip="${escapeHtml(item.tooltip)}"` : '';
162
+ return `<button type="button" role="menuitem" data-menu-action="${escapeHtml(item.action)}"${disabledAttrs}${tooltipAttr}>${icon}${label}${shortcut}</button>`;
156
163
  }
157
164
  function renderContextMenuItems(items) {
158
165
  return items
@@ -164,11 +171,28 @@ const PREVIEW_MAX_WIDTH_ALIASES = {
164
171
  default: 900,
165
172
  wide: 1200,
166
173
  };
167
- function resolvePreviewMaxWidthPx(value) {
168
- if (value === undefined)
174
+ /** The CSS value for --mdzip-preview-content-max-width, or undefined for the built-in default. */
175
+ function previewMaxWidthCss(value) {
176
+ if (value === undefined || value === 'default')
169
177
  return undefined;
170
- return typeof value === 'number' ? value : PREVIEW_MAX_WIDTH_ALIASES[value];
178
+ if (typeof value === 'number')
179
+ return `${value}px`;
180
+ if (value === 'full')
181
+ return 'none';
182
+ return `calc(${PREVIEW_MAX_WIDTH_ALIASES[value]}px * var(--mdz-zoom, 1))`;
171
183
  }
184
+ const PREVIEW_WIDTH_LABELS = {
185
+ narrow: 'Narrow',
186
+ default: 'Default',
187
+ wide: 'Wide',
188
+ full: 'Full Width'
189
+ };
190
+ const PREVIEW_WIDTH_ICONS_HTML = {
191
+ narrow: PAGE_WIDTH_NARROW_ICON_HTML,
192
+ default: PAGE_WIDTH_DEFAULT_ICON_HTML,
193
+ wide: PAGE_WIDTH_WIDE_ICON_HTML,
194
+ full: PAGE_WIDTH_FULL_ICON_HTML
195
+ };
172
196
  const ALL_HEADINGS = [1, 2, 3, 4, 5, 6];
173
197
  const ALL_LAYOUT_CONTROLS = {
174
198
  source: true,
@@ -219,6 +243,7 @@ const CONTROL_PRESETS = {
219
243
  lineNumbers: false,
220
244
  save: false,
221
245
  zoom: false,
246
+ previewWidth: false,
222
247
  colorScheme: false,
223
248
  orphanActions: false,
224
249
  fileActions: false,
@@ -236,6 +261,7 @@ const CONTROL_PRESETS = {
236
261
  lineNumbers: true,
237
262
  save: false,
238
263
  zoom: true,
264
+ previewWidth: true,
239
265
  colorScheme: true,
240
266
  orphanActions: false,
241
267
  fileActions: false,
@@ -253,6 +279,7 @@ const CONTROL_PRESETS = {
253
279
  lineNumbers: true,
254
280
  save: true,
255
281
  zoom: true,
282
+ previewWidth: true,
256
283
  colorScheme: true,
257
284
  orphanActions: true,
258
285
  fileActions: true,
@@ -270,6 +297,7 @@ const CONTROL_PRESETS = {
270
297
  lineNumbers: true,
271
298
  save: false,
272
299
  zoom: true,
300
+ previewWidth: true,
273
301
  colorScheme: true,
274
302
  orphanActions: true,
275
303
  fileActions: true,
@@ -632,6 +660,44 @@ const noSpellcheckHighlight = ViewPlugin.fromClass(class {
632
660
  // createCmEditor. Fully inert unless a host imageEditHandler is set — see
633
661
  // that click handler's own gate.
634
662
  const IMAGE_EDIT_AFFORDANCE_CLICK_EVENT = 'mdzip-image-edit-affordance-click';
663
+ /** Holds an archive image's original `src` in the preview, whose `src` becomes a blob: URL. */
664
+ const PREVIEW_IMAGE_SOURCE_ATTRIBUTE = 'data-mdzip-source';
665
+ // Preview chrome the editor adds around rendered content: removed, or
666
+ // unwrapped (children kept), when the selection is copied as HTML.
667
+ const PREVIEW_CHROME_REMOVE_SELECTOR = [
668
+ '.mdzip-code-block-header',
669
+ '.mdzip-chunk-sentinel',
670
+ '[data-mdzip-image-edit-affordance]'
671
+ ].join(',');
672
+ // Subtrees left out of a plain-text copy: the chrome above, plus markup whose
673
+ // text isn't document text (a drawn diagram's SVG carries its stylesheet).
674
+ const PLAIN_TEXT_SKIP_SELECTOR = `${PREVIEW_CHROME_REMOVE_SELECTOR},style,script,template,svg,button`;
675
+ /**
676
+ * The text of rendered preview markup for a plain-text copy: the document's
677
+ * own text, without style or script contents, drawn diagrams or the editor's
678
+ * chrome. Walks the DOM (no clone), so it's cheap on a large live preview.
679
+ */
680
+ function previewPlainText(root) {
681
+ const parts = [];
682
+ // NodeFilter: SHOW_ELEMENT | SHOW_TEXT; FILTER_ACCEPT 1, REJECT 2 (skips the subtree), SKIP 3.
683
+ const walker = root.ownerDocument.createTreeWalker(root, 0x1 | 0x4, {
684
+ acceptNode: (node) => node.nodeType === 1
685
+ ? (node.matches(PLAIN_TEXT_SKIP_SELECTOR) ? 2 : 3)
686
+ : 1
687
+ });
688
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
689
+ parts.push(node.data);
690
+ }
691
+ return parts.join('');
692
+ }
693
+ const PLAIN_TEXT_TOOLTIP = 'Text only, for Notepad or a code editor';
694
+ const FORMATTED_TEXT_TOOLTIP = 'Headings, styles and images, for Word, Google Docs or email';
695
+ const PREVIEW_CHROME_UNWRAP_SELECTOR = [
696
+ '.mdzip-chunk',
697
+ '.mdzip-code-block',
698
+ '.mdzip-code-block-body',
699
+ '.mdzip-image-slot'
700
+ ].join(',');
635
701
  const imageEditAffordanceEffect = StateEffect.define();
636
702
  class ImageEditAffordanceWidget extends WidgetType {
637
703
  constructor(from, to) {
@@ -695,6 +761,14 @@ function injectStyles(doc) {
695
761
  style.textContent = WORKSPACE_CSS;
696
762
  (doc.head ?? doc.documentElement).appendChild(style);
697
763
  }
764
+ /**
765
+ * Sets a control's tooltip after startup. prepareTooltips() turns `title` into
766
+ * `data-tooltip` only once, so a later `title` would show a stale tooltip.
767
+ */
768
+ function setTooltip(element, text) {
769
+ element.dataset['tooltip'] = text;
770
+ element.removeAttribute('title');
771
+ }
698
772
  function lucideIcon(icon, className) {
699
773
  const classAttr = className ? ` class="${escapeHtml(className)}"` : '';
700
774
  const children = icon
@@ -755,19 +829,19 @@ depth = 0) {
755
829
  : isImageFile(node.entry.path)
756
830
  ? IMAGE_ICON_HTML
757
831
  : FILE_ICON_HTML;
758
- const orphanBtnHtml = isOrphaned && options.allowOrphanActions ? `
759
- <span class="nav-orphan-button" role="button" tabindex="0"
760
- title="Orphaned asset" aria-label="Orphaned asset actions"
761
- data-orphan-path="${safePath}">
762
- ${ORPHAN_ICON_HTML}
832
+ const orphanBtnHtml = isOrphaned && options.allowOrphanActions ? `
833
+ <span class="nav-orphan-button" role="button" tabindex="0"
834
+ title="Orphaned asset" aria-label="Orphaned asset actions"
835
+ data-orphan-path="${safePath}">
836
+ ${ORPHAN_ICON_HTML}
763
837
  </span>` : '';
764
838
  const draggable = options.allowDrag && !isManifest;
765
- return `<button type="button" class="${classes}" title="${title}"
766
- data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}"${draggable ? ' draggable="true"' : ''}>
767
- ${guides}<span class="nav-caret"></span>
768
- <span class="nav-file-icon ${iconKind}">${iconHtml}</span>
769
- ${orphanBtnHtml}
770
- <span class="nav-label">${safeName}</span>
839
+ return `<button type="button" class="${classes}" title="${title}"
840
+ data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}"${draggable ? ' draggable="true"' : ''}>
841
+ ${guides}<span class="nav-caret"></span>
842
+ <span class="nav-file-icon ${iconKind}">${iconHtml}</span>
843
+ ${orphanBtnHtml}
844
+ <span class="nav-label">${safeName}</span>
771
845
  </button>`;
772
846
  }
773
847
  const childTrail = depth === 0 ? [] : [...trail, !isLast];
@@ -775,14 +849,14 @@ depth = 0) {
775
849
  .map((child, index) => renderNavNode(child, state, options, childTrail, index === node.children.length - 1, depth + 1))
776
850
  .join('');
777
851
  const pending = options.pendingFolders.has(node.path);
778
- return `<details class="nav-directory${pending ? ' pending-folder' : ''}" open data-nav-dir="${escapeHtml(node.path)}">
779
- <summary${pending ? ` title="${escapeHtml(node.path)} — not saved until it contains a file"` : ''}>
780
- ${guides}<span class="nav-caret" aria-hidden="true"></span>
781
- <span class="nav-folder-icon closed">${FOLDER_CLOSED_ICON_HTML}</span>
782
- <span class="nav-folder-icon open">${FOLDER_OPEN_ICON_HTML}</span>
783
- <span class="nav-label">${escapeHtml(node.name)}</span>
784
- </summary>
785
- <div class="nav-directory-children">${children}</div>
852
+ return `<details class="nav-directory${pending ? ' pending-folder' : ''}" open data-nav-dir="${escapeHtml(node.path)}">
853
+ <summary${pending ? ` title="${escapeHtml(node.path)} — not saved until it contains a file"` : ''}>
854
+ ${guides}<span class="nav-caret" aria-hidden="true"></span>
855
+ <span class="nav-folder-icon closed">${FOLDER_CLOSED_ICON_HTML}</span>
856
+ <span class="nav-folder-icon open">${FOLDER_OPEN_ICON_HTML}</span>
857
+ <span class="nav-label">${escapeHtml(node.name)}</span>
858
+ </summary>
859
+ <div class="nav-directory-children">${children}</div>
786
860
  </details>`;
787
861
  }
788
862
  // Inserts view-local pending (not yet saved) folders into the nav tree so they
@@ -859,6 +933,24 @@ export class MdzipWorkspaceView {
859
933
  this.navVisible = true;
860
934
  this.zoom = 1;
861
935
  this.zoomOpen = false;
936
+ this.themeMenuOpen = false;
937
+ this.widthMenuOpen = false;
938
+ /** Choice whose icon the page-width button shows, so render() only swaps it on change. */
939
+ this.widthMenuIconChoice = null;
940
+ /** Choice whose icon the theme menu button shows, so render() only swaps it on change. */
941
+ this.themeMenuIconPreference = null;
942
+ this.colorSchemePreference = 'system';
943
+ /** Re-resolves the scheme when the OS setting changes while 'system' is chosen. */
944
+ this.onSystemColorSchemeChange = () => {
945
+ if (this.colorSchemePreference !== 'system' || this.destroyed) {
946
+ return;
947
+ }
948
+ const next = this.resolveColorScheme('system');
949
+ if (next !== this.colorScheme) {
950
+ this.colorScheme = next;
951
+ this.render();
952
+ }
953
+ };
862
954
  this.titleDialogOpen = false;
863
955
  this.metadataDialogOpen = false;
864
956
  this.titleDraft = '';
@@ -1023,6 +1115,10 @@ export class MdzipWorkspaceView {
1023
1115
  // True while onPreviewRendered runs — see scrollToAnchor().
1024
1116
  this.firingPreviewRendered = false;
1025
1117
  this.destroyed = false;
1118
+ /** Counts preview Ctrl+C copies, so a late image upgrade never overwrites a newer copy. */
1119
+ this.previewCopyId = 0;
1120
+ /** The preview Ctrl+A selection's boundaries; Ctrl+C copies the whole document while it's unchanged. */
1121
+ this.previewSelectAll = null;
1026
1122
  // After an anchor scroll, content above the target can still change height
1027
1123
  // (images loading or easing open, a diagram rendering) and push it out of
1028
1124
  // view. This holds the target at the pane's top edge until the user
@@ -1051,6 +1147,8 @@ export class MdzipWorkspaceView {
1051
1147
  this.previewMemo = null;
1052
1148
  this.previewGeneration = 0;
1053
1149
  this.previewAbort = null;
1150
+ /** Aborts an in-flight in-place re-theme (see rethemePreview). */
1151
+ this.rethemeAbort = null;
1054
1152
  this.previewHandles = [];
1055
1153
  // Whether the latest preview generation has finished mounting and hydrating
1056
1154
  // its images; drives `whenRendered()` and gates the hydration callback.
@@ -1070,10 +1168,10 @@ export class MdzipWorkspaceView {
1070
1168
  this.contentDensity = options.contentDensity ?? 'comfortable';
1071
1169
  this.previewMaxWidth = options.previewMaxWidth;
1072
1170
  this.layout = options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy);
1073
- this.colorScheme = options.initialColorScheme
1074
- ?? (container.ownerDocument.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches
1075
- ? 'dark'
1076
- : 'light');
1171
+ this.systemColorSchemeQuery = container.ownerDocument.defaultView?.matchMedia?.('(prefers-color-scheme: dark)') ?? null;
1172
+ this.systemColorSchemeQuery?.addEventListener?.('change', this.onSystemColorSchemeChange);
1173
+ this.colorSchemePreference = options.initialColorScheme ?? 'system';
1174
+ this.colorScheme = this.resolveColorScheme(this.colorSchemePreference);
1077
1175
  this.navVisible = options.navigationButtonActive ?? this.navVisible;
1078
1176
  this.markdownRenderer = options.markdownRenderer;
1079
1177
  this.markdownExtensions = options.markdownExtensions ?? [];
@@ -1101,8 +1199,16 @@ export class MdzipWorkspaceView {
1101
1199
  this.elSaveBtn = q('[data-ref="save-btn"]');
1102
1200
  this.elSearchBtn = q('[data-ref="search-btn"]');
1103
1201
  this.elZoomBtn = q('[data-ref="zoom-btn"]');
1202
+ this.elWidthControls = q('[data-ref="width-controls"]');
1203
+ this.elWidthMenuBtn = q('[data-ref="width-menu-btn"]');
1204
+ this.elWidthMenuIcon = q('[data-ref="width-menu-icon"]');
1205
+ this.elWidthPopover = q('[data-ref="width-popover"]');
1104
1206
  this.elThemeControls = q('[data-ref="theme-controls"]');
1105
1207
  this.elDarkThemeBtn = q('[data-ref="dark-theme-btn"]');
1208
+ this.elThemeMenuBtn = q('[data-ref="theme-menu-btn"]');
1209
+ this.elThemeMenuIcon = q('[data-ref="theme-menu-icon"]');
1210
+ this.elThemePopover = q('[data-ref="theme-popover"]');
1211
+ this.elSystemThemeBtn = q('[data-ref="system-theme-btn"]');
1106
1212
  this.elLightThemeBtn = q('[data-ref="light-theme-btn"]');
1107
1213
  this.elZoomPopover = q('[data-ref="zoom-popover"]');
1108
1214
  this.elZoomLevel = q('[data-ref="zoom-level"]');
@@ -1453,6 +1559,7 @@ export class MdzipWorkspaceView {
1453
1559
  this.cmEditor?.focus();
1454
1560
  }
1455
1561
  destroy() {
1562
+ this.systemColorSchemeQuery?.removeEventListener?.('change', this.onSystemColorSchemeChange);
1456
1563
  this.conversionDocumentGeneration += 1;
1457
1564
  this.resolveImageInsertDialog(null);
1458
1565
  this.resolvePackFilesDialog(null);
@@ -1603,10 +1710,11 @@ export class MdzipWorkspaceView {
1603
1710
  this.applyDensityClasses();
1604
1711
  }
1605
1712
  /**
1606
- * Sets the preview reading-column width. Developer-facing only — there is
1607
- * no toolbar UI for this. Pass `undefined` to return to the built-in
1608
- * default (which scales with zoom); any other value is used exactly as
1609
- * given and does not scale with zoom (see {@link MdzipPreviewMaxWidth}).
1713
+ * Sets the preview reading-column width, as the toolbar's page-width menu
1714
+ * does: `'narrow'`, `'default'`, `'wide'` (all scale with zoom) or `'full'`.
1715
+ * A number is an exact pixel width that doesn't scale with zoom, and leaves
1716
+ * no menu item checked. `undefined` is `'default'` (see
1717
+ * {@link MdzipPreviewMaxWidth}).
1610
1718
  */
1611
1719
  setPreviewMaxWidth(value) {
1612
1720
  if (value === this.previewMaxWidth) {
@@ -1693,6 +1801,8 @@ export class MdzipWorkspaceView {
1693
1801
  resetPreviewState() {
1694
1802
  this.previewAbort?.abort();
1695
1803
  this.previewAbort = null;
1804
+ this.rethemeAbort?.abort();
1805
+ this.rethemeAbort = null;
1696
1806
  this.previewGeneration += 1;
1697
1807
  this.destroyPreviewHandles();
1698
1808
  this.previewMemo = null;
@@ -1742,6 +1852,64 @@ export class MdzipWorkspaceView {
1742
1852
  && memo.text === snapshot.currentText
1743
1853
  && memo.colorScheme === this.colorScheme;
1744
1854
  }
1855
+ /**
1856
+ * True when only the color scheme changed since `memo`, and the preview can
1857
+ * be kept: the default renderer (whose HTML doesn't depend on the scheme),
1858
+ * every extension that transforms or mounts content can re-theme in place,
1859
+ * and the current render has finished mounting (otherwise its chunks were
1860
+ * rendered, or are still rendering, with the old scheme).
1861
+ */
1862
+ canRethemePreviewInPlace(memo, snapshot) {
1863
+ return memo.colorScheme !== this.colorScheme
1864
+ && memo.path === snapshot.currentPath
1865
+ && memo.pathType === snapshot.currentPathType
1866
+ && memo.text === snapshot.currentText
1867
+ && this.renderingService.supportsChunking
1868
+ && this.previewMountedGeneration === this.previewGeneration
1869
+ && !this.chunkedRenderState?.mounting
1870
+ && this.markdownExtensions.every((extension) => !!extension.updateColorScheme
1871
+ || !(extension.transformMarkdown || extension.transformHtml || extension.mount));
1872
+ }
1873
+ /**
1874
+ * Applies a scheme-only change without re-rendering: the root's theme class
1875
+ * (set in render()) re-colors the DOM; chunks not mounted yet will render
1876
+ * with the new scheme; cached chunk HTML (which may hold old-scheme diagram
1877
+ * SVG) is dropped; and each extension re-themes its mounted output.
1878
+ */
1879
+ rethemePreview(snapshot, memo) {
1880
+ memo.colorScheme = this.colorScheme;
1881
+ if (this.chunkedRenderState) {
1882
+ this.chunkedRenderState.context = { ...this.chunkedRenderState.context, colorScheme: this.colorScheme };
1883
+ }
1884
+ this.chunkHtmlCache = null;
1885
+ if (snapshot.currentPathType !== 'markdown') {
1886
+ return;
1887
+ }
1888
+ this.rethemeAbort?.abort();
1889
+ const abort = new AbortController();
1890
+ this.rethemeAbort = abort;
1891
+ const context = this.createMarkdownContext(snapshot, abort.signal);
1892
+ const generation = this.previewGeneration;
1893
+ const report = (error) => {
1894
+ if (generation === this.previewGeneration && !abort.signal.aborted) {
1895
+ this.options.onFailed?.(error);
1896
+ }
1897
+ };
1898
+ for (const extension of this.markdownExtensions) {
1899
+ if (!extension.updateColorScheme) {
1900
+ continue;
1901
+ }
1902
+ try {
1903
+ const result = extension.updateColorScheme(this.elPreviewContent, context);
1904
+ if (isThenable(result)) {
1905
+ void Promise.resolve(result).catch(report);
1906
+ }
1907
+ }
1908
+ catch (error) {
1909
+ report(error);
1910
+ }
1911
+ }
1912
+ }
1745
1913
  updatePreview(snapshot, entryClaimed) {
1746
1914
  this.lastContentChangeTime = performance.now();
1747
1915
  // Captured before anything below can move it — see
@@ -1766,6 +1934,12 @@ export class MdzipWorkspaceView {
1766
1934
  // mounted extension handles.
1767
1935
  return;
1768
1936
  }
1937
+ if (memo && this.canRethemePreviewInPlace(memo, snapshot)) {
1938
+ // Only the color scheme changed: CSS re-colors the existing DOM, and
1939
+ // extensions re-theme their own output, so nothing moves.
1940
+ this.rethemePreview(snapshot, memo);
1941
+ return;
1942
+ }
1769
1943
  const animateImageHydration = this.shouldAnimateImageHydration(memo, snapshot);
1770
1944
  // A same-document, text-only edit with an existing chunked structure can
1771
1945
  // be reconciled against (only the chunks whose source actually changed
@@ -1789,6 +1963,8 @@ export class MdzipWorkspaceView {
1789
1963
  const priorChunkedState = reconcileEligible ? this.chunkedRenderState : null;
1790
1964
  this.previewAbort?.abort();
1791
1965
  this.previewAbort = null;
1966
+ this.rethemeAbort?.abort();
1967
+ this.rethemeAbort = null;
1792
1968
  if (!priorChunkedState) {
1793
1969
  this.destroyPreviewHandles();
1794
1970
  }
@@ -1960,6 +2136,8 @@ export class MdzipWorkspaceView {
1960
2136
  }
1961
2137
  continue;
1962
2138
  }
2139
+ // The src is replaced by a blob: URL; Copy as HTML restores it from here.
2140
+ image.setAttribute(PREVIEW_IMAGE_SOURCE_ATTRIBUTE, source);
1963
2141
  const known = this.assetSession.resolveKnownImage(source, context.currentPath);
1964
2142
  if (known) {
1965
2143
  if (known.width && known.height && !image.hasAttribute('width') && !image.hasAttribute('height')) {
@@ -2600,10 +2778,15 @@ export class MdzipWorkspaceView {
2600
2778
  // Same race this field guards against everywhere else: an edit landing
2601
2779
  // while this scroll-triggered continuation is mid-flight must not
2602
2780
  // reconcile against a cursor that doesn't yet reflect it.
2781
+ // The live context, not the one captured when this sentinel was armed:
2782
+ // a color scheme change since then (rethemePreview) replaces it, and
2783
+ // chunks mounted from here on must render with the new scheme.
2784
+ let liveContext = context;
2603
2785
  if (this.chunkedRenderState?.generation === generation) {
2604
2786
  this.chunkedRenderState.mounting = true;
2787
+ liveContext = this.chunkedRenderState.context;
2605
2788
  }
2606
- void this.mountChunkBatch(records, nextCursor, context, generation, animateImageHydration, () => { })
2789
+ void this.mountChunkBatch(records, nextCursor, liveContext, generation, animateImageHydration, () => { })
2607
2790
  .then((newCursor) => {
2608
2791
  // Record whatever this batch actually mounted regardless of
2609
2792
  // whether a newer edit has since landed — recordChunkProgress's
@@ -2622,7 +2805,7 @@ export class MdzipWorkspaceView {
2622
2805
  this.chunkedRenderState.mounting = false;
2623
2806
  }
2624
2807
  if (newCursor < records.length) {
2625
- this.armChunkSentinel(records, newCursor, context, generation, animateImageHydration);
2808
+ this.armChunkSentinel(records, newCursor, liveContext, generation, animateImageHydration);
2626
2809
  }
2627
2810
  });
2628
2811
  };
@@ -3302,6 +3485,13 @@ export class MdzipWorkspaceView {
3302
3485
  const showSaveControl = this.controlPolicy.save && snapshot.mode !== 'read-only';
3303
3486
  const showZoomControl = this.controlPolicy.zoom;
3304
3487
  const showColorSchemeControl = this.controlPolicy.colorScheme;
3488
+ // Only the rendered Markdown preview has a reading column to size, so the
3489
+ // menu is hidden in edit (source) layout and for images and other files.
3490
+ const showWidthControl = this.controlPolicy.previewWidth && !entryClaimed
3491
+ && snapshot.currentPathType === 'markdown' && this.layout !== 'source';
3492
+ if (!showWidthControl) {
3493
+ this.widthMenuOpen = false;
3494
+ }
3305
3495
  const showSearchControl = this.controlPolicy.search
3306
3496
  && canShowSource && snapshot.currentPathType === 'markdown';
3307
3497
  const showEditControls = canEdit
@@ -3310,14 +3500,15 @@ export class MdzipWorkspaceView {
3310
3500
  && hasFormattingControls(this.controlPolicy.formatting);
3311
3501
  const showToolbar = this.controlPolicy.toolbar
3312
3502
  && (showNavigationControl || showLayoutControls || showSaveControl
3313
- || showZoomControl || showColorSchemeControl || showSearchControl || showEditControls);
3503
+ || showZoomControl || showWidthControl || showColorSchemeControl || showSearchControl
3504
+ || showEditControls);
3314
3505
  this.elDocumentStrip.hidden = !showTitleControl;
3315
3506
  this.elToolbar.hidden = !showToolbar;
3316
3507
  this.elToolbarLeft.hidden = !showNavigationControl;
3317
3508
  this.elEditToolbar.hidden = !showEditControls;
3318
3509
  this.elLayoutControls.hidden = !showLayoutControls;
3319
3510
  this.elToolbarControls.hidden = !showSaveControl && !showZoomControl
3320
- && !showColorSchemeControl && !showSearchControl;
3511
+ && !showWidthControl && !showColorSchemeControl && !showSearchControl;
3321
3512
  this.elNavBtn.hidden = !showNavigationControl;
3322
3513
  this.elPreviewBtn.hidden = !this.controlPolicy.layout.preview;
3323
3514
  this.elSplitBtn.hidden = !this.controlPolicy.layout.split;
@@ -3325,16 +3516,17 @@ export class MdzipWorkspaceView {
3325
3516
  this.elSaveBtn.hidden = !showSaveControl;
3326
3517
  this.elSearchBtn.hidden = !showSearchControl;
3327
3518
  this.elZoomBtn.hidden = !showZoomControl;
3519
+ this.elWidthControls.hidden = !showWidthControl;
3328
3520
  this.elThemeControls.hidden = !showColorSchemeControl;
3329
3521
  this.elRoot.style.setProperty('--mdz-zoom', String(this.zoom));
3330
3522
  this.elRoot.style.setProperty('--nav-pane-width', `${this.navPaneWidth}px`);
3331
3523
  this.elRoot.style.setProperty('--split-edit-ratio', String(this.splitRatio));
3332
- const previewMaxWidthPx = resolvePreviewMaxWidthPx(this.previewMaxWidth);
3333
- if (previewMaxWidthPx === undefined) {
3524
+ const previewMaxWidth = previewMaxWidthCss(this.previewMaxWidth);
3525
+ if (previewMaxWidth === undefined) {
3334
3526
  this.elRoot.style.removeProperty('--mdzip-preview-content-max-width');
3335
3527
  }
3336
3528
  else {
3337
- this.elRoot.style.setProperty('--mdzip-preview-content-max-width', `${previewMaxWidthPx}px`);
3529
+ this.elRoot.style.setProperty('--mdzip-preview-content-max-width', previewMaxWidth);
3338
3530
  }
3339
3531
  this.elRoot.classList.toggle('resizing', this.resizing);
3340
3532
  this.elRoot.classList.toggle('mdzip-theme-dark', this.colorScheme === 'dark');
@@ -3394,10 +3586,41 @@ export class MdzipWorkspaceView {
3394
3586
  this.elNavBtn.setAttribute('aria-pressed', String(this.navVisible));
3395
3587
  }
3396
3588
  this.elZoomBtn.classList.toggle('active', this.zoomOpen);
3397
- this.elDarkThemeBtn.classList.toggle('active', this.colorScheme === 'dark');
3398
- this.elLightThemeBtn.classList.toggle('active', this.colorScheme === 'light');
3399
- this.elDarkThemeBtn.setAttribute('aria-pressed', String(this.colorScheme === 'dark'));
3400
- this.elLightThemeBtn.setAttribute('aria-pressed', String(this.colorScheme === 'light'));
3589
+ for (const [button, preference] of this.themeMenuItems()) {
3590
+ button.classList.toggle('active', this.colorSchemePreference === preference);
3591
+ button.setAttribute('aria-checked', String(this.colorSchemePreference === preference));
3592
+ }
3593
+ if (this.themeMenuIconPreference !== this.colorSchemePreference) {
3594
+ this.themeMenuIconPreference = this.colorSchemePreference;
3595
+ this.elThemeMenuIcon.innerHTML = this.colorSchemePreference === 'system'
3596
+ ? SYSTEM_THEME_ICON_HTML
3597
+ : this.colorSchemePreference === 'dark' ? DARK_THEME_ICON_HTML : LIGHT_THEME_ICON_HTML;
3598
+ }
3599
+ const schemeLabel = this.colorSchemePreference === 'system'
3600
+ ? `System (${this.colorScheme})`
3601
+ : this.colorSchemePreference === 'dark' ? 'Dark' : 'Light';
3602
+ setTooltip(this.elThemeMenuBtn, `Color scheme: ${schemeLabel}`);
3603
+ this.elThemeMenuBtn.setAttribute('aria-label', `Color scheme: ${schemeLabel}`);
3604
+ this.elThemeMenuBtn.classList.toggle('active', this.themeMenuOpen);
3605
+ this.elThemeMenuBtn.setAttribute('aria-expanded', String(this.themeMenuOpen));
3606
+ this.elThemePopover.hidden = !this.themeMenuOpen;
3607
+ const widthChoice = this.previewWidthChoice();
3608
+ for (const [button, choice] of this.widthMenuItems()) {
3609
+ button.classList.toggle('active', widthChoice === choice);
3610
+ button.setAttribute('aria-checked', String(widthChoice === choice));
3611
+ }
3612
+ // A custom pixel width (set by the host) shows the default icon, with no item checked.
3613
+ const widthIconChoice = widthChoice ?? 'default';
3614
+ if (this.widthMenuIconChoice !== widthIconChoice) {
3615
+ this.widthMenuIconChoice = widthIconChoice;
3616
+ this.elWidthMenuIcon.innerHTML = PREVIEW_WIDTH_ICONS_HTML[widthIconChoice];
3617
+ }
3618
+ // Measuring the pane forces layout, so only while the menu is open; hover
3619
+ // and focus on the button refresh the labels too.
3620
+ this.updateWidthLabels(this.widthMenuOpen);
3621
+ this.elWidthMenuBtn.classList.toggle('active', this.widthMenuOpen);
3622
+ this.elWidthMenuBtn.setAttribute('aria-expanded', String(this.widthMenuOpen));
3623
+ this.elWidthPopover.hidden = !this.widthMenuOpen;
3401
3624
  this.elZoomPopover.hidden = !this.zoomOpen;
3402
3625
  this.elZoomLevel.textContent = `${Math.round(this.zoom * 100)}%`;
3403
3626
  const showNavigationPane = this.controlPolicy.navigation
@@ -3565,8 +3788,10 @@ export class MdzipWorkspaceView {
3565
3788
  const doc = this.elRoot.ownerDocument;
3566
3789
  doc.addEventListener('click', () => {
3567
3790
  this.closeFormatMenus();
3568
- if (this.zoomOpen || this.contextMenuState) {
3791
+ if (this.zoomOpen || this.themeMenuOpen || this.widthMenuOpen || this.contextMenuState) {
3569
3792
  this.zoomOpen = false;
3793
+ this.themeMenuOpen = false;
3794
+ this.widthMenuOpen = false;
3570
3795
  this.contextMenuState = null;
3571
3796
  this.render();
3572
3797
  }
@@ -3575,6 +3800,12 @@ export class MdzipWorkspaceView {
3575
3800
  if (e.key !== 'Escape') {
3576
3801
  return;
3577
3802
  }
3803
+ if (this.themeMenuOpen) {
3804
+ this.closeThemeMenu(true);
3805
+ }
3806
+ if (this.widthMenuOpen) {
3807
+ this.closeWidthMenu(true);
3808
+ }
3578
3809
  if (this.contextMenuState || this.deleteDialogState || this.nameDialogState) {
3579
3810
  this.contextMenuState = null;
3580
3811
  this.deleteDialogState = null;
@@ -3583,11 +3814,12 @@ export class MdzipWorkspaceView {
3583
3814
  }
3584
3815
  });
3585
3816
  // Ctrl/Cmd+A normally selects the whole page, which in split layout grabs
3586
- // both panes' text at once. Scope it to a Copy All of the rendered
3587
- // content instead when focus is inside the preview pane (see the
3588
- // mousedown handler below for how it gets there); the source editor
3589
- // keeps its own defaultKeymap binding since CodeMirror's content root
3590
- // sits outside this element.
3817
+ // both panes' text at once. With focus inside the preview pane (see the
3818
+ // mousedown handler below for how it gets there) it selects just the
3819
+ // rendered content; the source editor keeps its own defaultKeymap binding
3820
+ // since CodeMirror's content root sits outside this element. The
3821
+ // selection is remembered so the copy handler can copy the whole
3822
+ // document, including chunks not mounted yet, while it's unchanged.
3591
3823
  doc.addEventListener('keydown', (e) => {
3592
3824
  if (e.key.toLowerCase() !== 'a' || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) {
3593
3825
  return;
@@ -3596,7 +3828,7 @@ export class MdzipWorkspaceView {
3596
3828
  return;
3597
3829
  }
3598
3830
  e.preventDefault();
3599
- void this.copyAllPreviewContent();
3831
+ this.selectAllPreview();
3600
3832
  });
3601
3833
  this.elNavBtn.addEventListener('click', () => {
3602
3834
  if (!this.controlPolicy.navigation) {
@@ -3663,6 +3895,8 @@ export class MdzipWorkspaceView {
3663
3895
  }
3664
3896
  e.stopPropagation();
3665
3897
  this.zoomOpen = !this.zoomOpen;
3898
+ this.themeMenuOpen = false;
3899
+ this.widthMenuOpen = false;
3666
3900
  this.render();
3667
3901
  });
3668
3902
  this.elZoomPopover.addEventListener('click', (e) => e.stopPropagation());
@@ -3672,8 +3906,47 @@ export class MdzipWorkspaceView {
3672
3906
  .addEventListener('click', () => this.setZoom(this.zoom + 0.1));
3673
3907
  this.elZoomPopover.querySelector('[data-action="zoom-reset"]')
3674
3908
  .addEventListener('click', () => this.setZoom(1));
3675
- this.elDarkThemeBtn.addEventListener('click', () => this.setColorSchemeFromToolbar('dark'));
3676
- this.elLightThemeBtn.addEventListener('click', () => this.setColorSchemeFromToolbar('light'));
3909
+ this.elThemeMenuBtn.addEventListener('click', (e) => {
3910
+ e.stopPropagation();
3911
+ this.themeMenuOpen = !this.themeMenuOpen;
3912
+ this.zoomOpen = false;
3913
+ this.widthMenuOpen = false;
3914
+ this.render();
3915
+ // Keyboard activation (Enter/Space reports detail 0) moves focus into the
3916
+ // menu; a mouse click doesn't, so no focus tooltip covers the icons.
3917
+ if (this.themeMenuOpen && e.detail === 0) {
3918
+ this.themeMenuItems().find(([, preference]) => preference === this.colorSchemePreference)?.[0].focus();
3919
+ }
3920
+ });
3921
+ this.elThemePopover.addEventListener('click', (e) => e.stopPropagation());
3922
+ this.elThemePopover.addEventListener('keydown', (e) => {
3923
+ this.moveMenuFocus(e, this.themeMenuItems().map(([button]) => button));
3924
+ });
3925
+ for (const [button, preference] of this.themeMenuItems()) {
3926
+ button.addEventListener('click', () => this.chooseColorSchemeFromMenu(preference));
3927
+ }
3928
+ this.elWidthMenuBtn.addEventListener('click', (e) => {
3929
+ e.stopPropagation();
3930
+ this.widthMenuOpen = !this.widthMenuOpen;
3931
+ this.zoomOpen = false;
3932
+ this.themeMenuOpen = false;
3933
+ this.render();
3934
+ // As with the theme menu, only keyboard activation moves focus inside.
3935
+ if (this.widthMenuOpen && e.detail === 0) {
3936
+ const items = this.widthMenuItems();
3937
+ const choice = this.previewWidthChoice() ?? 'default';
3938
+ (items.find(([, item]) => item === choice) ?? items[0])[0].focus();
3939
+ }
3940
+ });
3941
+ this.elWidthMenuBtn.addEventListener('pointerenter', () => this.updateWidthLabels(true));
3942
+ this.elWidthMenuBtn.addEventListener('focus', () => this.updateWidthLabels(true));
3943
+ this.elWidthPopover.addEventListener('click', (e) => e.stopPropagation());
3944
+ this.elWidthPopover.addEventListener('keydown', (e) => {
3945
+ this.moveMenuFocus(e, this.widthMenuItems().map(([button]) => button));
3946
+ });
3947
+ for (const [button, choice] of this.widthMenuItems()) {
3948
+ button.addEventListener('click', () => this.choosePreviewWidthFromMenu(choice));
3949
+ }
3677
3950
  this.elEditToolbar.addEventListener('click', (event) => {
3678
3951
  const menuToggle = event.target
3679
3952
  .closest('[data-format-menu-toggle]');
@@ -3975,6 +4248,41 @@ export class MdzipWorkspaceView {
3975
4248
  e.stopPropagation();
3976
4249
  this.render();
3977
4250
  });
4251
+ // Ctrl+C on a preview selection copies Formatted Text, like the menu item:
4252
+ // clean markup and plain text now (the copy event is synchronous), then,
4253
+ // when the selection has images, the same copy again with them embedded,
4254
+ // since the preview's own blob: URLs only resolve inside this view.
4255
+ doc.addEventListener('copy', (e) => {
4256
+ const selection = doc.defaultView?.getSelection();
4257
+ if (this.destroyed || !e.clipboardData || !selection || selection.isCollapsed || selection.rangeCount === 0
4258
+ || !this.elPreviewContent.contains(selection.anchorNode)
4259
+ || this.workspace?.snapshot().currentPathType !== 'markdown') {
4260
+ return;
4261
+ }
4262
+ const range = selection.getRangeAt(0);
4263
+ const all = this.previewSelectAll;
4264
+ if (all && range.startContainer === all.start && range.startOffset === all.startOffset
4265
+ && range.endContainer === all.end && range.endOffset === all.endOffset) {
4266
+ // Ctrl+A, Ctrl+C: the whole document as Formatted Text, like Copy All >
4267
+ // Formatted Text, so chunks not mounted yet are copied too. Nothing
4268
+ // goes on the clipboard synchronously: the mounted part alone would
4269
+ // be a silently partial copy.
4270
+ e.preventDefault();
4271
+ void this.copyAllWithImagesPreviewContent();
4272
+ return;
4273
+ }
4274
+ const { text, html } = this.previewSelectionContent(range);
4275
+ if (!html) {
4276
+ return;
4277
+ }
4278
+ e.preventDefault();
4279
+ e.clipboardData.setData('text/plain', text);
4280
+ e.clipboardData.setData('text/html', html);
4281
+ const copyId = (this.previewCopyId += 1);
4282
+ if (this.assetSession && /<img\b/i.test(html)) {
4283
+ void this.upgradePreviewCopyImages(copyId, html, text);
4284
+ }
4285
+ });
3978
4286
  this.elPreviewPane.addEventListener('contextmenu', (e) => {
3979
4287
  if (!this.controlPolicy.contextMenu.preview) {
3980
4288
  return;
@@ -3983,11 +4291,12 @@ export class MdzipWorkspaceView {
3983
4291
  // button collapses the browser selection before the action runs (same
3984
4292
  // reason the editor menu captures its CodeMirror range up front).
3985
4293
  const domSelection = this.elPreviewPane.ownerDocument.defaultView?.getSelection();
3986
- const text = domSelection && !domSelection.isCollapsed
3987
- && this.elPreviewContent.contains(domSelection.anchorNode)
3988
- ? domSelection.toString()
3989
- : '';
3990
- this.contextMenuState = { kind: 'preview', text, x: e.clientX, y: e.clientY };
4294
+ const selected = domSelection && !domSelection.isCollapsed
4295
+ && this.elPreviewContent.contains(domSelection.anchorNode);
4296
+ const { text, html } = selected && domSelection.rangeCount > 0
4297
+ ? this.previewSelectionContent(domSelection.getRangeAt(0))
4298
+ : { text: '', html: '' };
4299
+ this.contextMenuState = { kind: 'preview', text, html, x: e.clientX, y: e.clientY };
3991
4300
  if (this.previewMenuItems().length === 0) {
3992
4301
  this.contextMenuState = null;
3993
4302
  return;
@@ -4119,7 +4428,7 @@ export class MdzipWorkspaceView {
4119
4428
  async openPathAndScrollToAnchor(path, fragment) {
4120
4429
  const generationBefore = this.previewGeneration;
4121
4430
  this.pendingAnchorFragment = fragment || null;
4122
- await this.openPath(path);
4431
+ await this.openPathOnly(path);
4123
4432
  // No new render started (already on that document, or the open failed):
4124
4433
  // nothing will call firePreviewRendered, so apply the anchor now.
4125
4434
  if (this.pendingAnchorFragment !== null && this.previewGeneration === generationBefore) {
@@ -4292,6 +4601,10 @@ export class MdzipWorkspaceView {
4292
4601
  if (collectMdzipHeadingIds(record.tokens).some((id) => id.toLowerCase() === lower)) {
4293
4602
  return true;
4294
4603
  }
4604
+ // The footnotes section (#58) renders footnote-<label> targets.
4605
+ if (lower.startsWith('footnote-') && record.tokens.some((token) => token.type === 'footnotes')) {
4606
+ return true;
4607
+ }
4295
4608
  // An explicit <a id="x"> / <a name="x"> anchor written in the source.
4296
4609
  const escaped = fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4297
4610
  return new RegExp(`(?:id|name)\\s*=\\s*["']${escaped}["']`, 'i').test(record.key);
@@ -4361,9 +4674,53 @@ export class MdzipWorkspaceView {
4361
4674
  this.anchorPin?.disconnect();
4362
4675
  this.anchorPin = null;
4363
4676
  }
4364
- async openPath(path) {
4677
+ /**
4678
+ * Opens another file of the current workspace, as clicking it in the file
4679
+ * tree does, optionally landing on a heading or anchor in it (#53). Paths
4680
+ * match the archive exactly (case-sensitive after NFC normalization).
4681
+ *
4682
+ * Resolves `true` once the file is shown and, when `anchor` is given, the
4683
+ * preview has scrolled to it — with the same pinning as {@link scrollToAnchor}
4684
+ * while images above it load. Resolves `false` for a path that isn't in the
4685
+ * workspace (the current file stays open) or an anchor that isn't found
4686
+ * (the file stays open at its top).
4687
+ */
4688
+ async openPath(path, options = {}) {
4689
+ if (!(await this.openPathOnly(path))) {
4690
+ return false;
4691
+ }
4692
+ return options.anchor === undefined ? true : this.scrollToAnchor(options.anchor);
4693
+ }
4694
+ /**
4695
+ * Changes the file name shown in the title bar and used to save, without
4696
+ * reopening the workspace or changing the selected file, e.g. after Save As
4697
+ * (#51). Returns false, changing nothing, when no workspace is open or the
4698
+ * new name implies a different format (`.md` vs `.mdz`): that needs a reopen.
4699
+ */
4700
+ setFileName(fileName) {
4701
+ const snapshot = this.workspace?.snapshot();
4702
+ if (!this.workspace || !snapshot) {
4703
+ return false;
4704
+ }
4705
+ const lower = fileName.toLowerCase();
4706
+ const impliedFormat = /\.(?:md|markdown|mdown|mkd)$/.test(lower)
4707
+ ? 'markdown'
4708
+ : lower.endsWith('.mdz') ? 'mdz' : snapshot.sourceFormat;
4709
+ if (impliedFormat !== snapshot.sourceFormat) {
4710
+ return false;
4711
+ }
4712
+ if (this.workspace.setFileName(fileName)) {
4713
+ this.render();
4714
+ }
4715
+ return true;
4716
+ }
4717
+ /** The archive path of the file currently shown, or null before a workspace opens. */
4718
+ getCurrentPath() {
4719
+ return this.workspace?.snapshot().currentPath ?? null;
4720
+ }
4721
+ async openPathOnly(path) {
4365
4722
  if (!this.workspace) {
4366
- return;
4723
+ return false;
4367
4724
  }
4368
4725
  try {
4369
4726
  const opened = await this.workspace.openPath(path);
@@ -4371,9 +4728,11 @@ export class MdzipWorkspaceView {
4371
4728
  this.layout = this.validLayoutForSnapshot(this.layout, this.workspace.snapshot());
4372
4729
  this.render();
4373
4730
  }
4731
+ return opened;
4374
4732
  }
4375
4733
  catch (error) {
4376
4734
  this.options.onFailed?.(error);
4735
+ return false;
4377
4736
  }
4378
4737
  }
4379
4738
  handleTooltipPointer(event) {
@@ -4405,9 +4764,16 @@ export class MdzipWorkspaceView {
4405
4764
  }
4406
4765
  showTooltipForElementNow(element) {
4407
4766
  const rect = element.getBoundingClientRect();
4408
- this.showTooltip(element.dataset['tooltip'] ?? '', rect.left + rect.width / 2, rect.top);
4767
+ // A menu item's tip goes beside it, so it doesn't cover the items above.
4768
+ const placement = element.closest('.nav-context-menu') ? 'side' : 'above';
4769
+ this.showTooltip(element.dataset['tooltip'] ?? '', rect.left + rect.width / 2, rect.top, placement, rect);
4409
4770
  }
4410
- showTooltip(text, anchorX, anchorY) {
4771
+ /**
4772
+ * Shows the tooltip centered above (anchorX, anchorY), or, with `side`
4773
+ * placement, beside `anchorRect` (to its right, or its left when there's no
4774
+ * room), vertically centered on it.
4775
+ */
4776
+ showTooltip(text, anchorX, anchorY, placement = 'above', anchorRect) {
4411
4777
  if (!text) {
4412
4778
  this.hideTooltip();
4413
4779
  return;
@@ -4420,9 +4786,23 @@ export class MdzipWorkspaceView {
4420
4786
  const offset = 8;
4421
4787
  this.elTooltip.textContent = text;
4422
4788
  this.elTooltip.hidden = false;
4789
+ // Measured at the origin, so its previous position doesn't make it wrap early.
4790
+ this.elTooltip.style.left = '0px';
4791
+ this.elTooltip.style.top = '0px';
4423
4792
  const rect = this.elTooltip.getBoundingClientRect();
4424
- const x = Math.max(viewportPadding, Math.min(anchorX - rect.width / 2, window.innerWidth - rect.width - viewportPadding));
4425
- const y = Math.max(viewportPadding, anchorY - rect.height - offset);
4793
+ let x;
4794
+ let y;
4795
+ if (placement === 'side' && anchorRect) {
4796
+ const right = anchorRect.right + offset;
4797
+ x = right + rect.width <= window.innerWidth - viewportPadding
4798
+ ? right
4799
+ : Math.max(viewportPadding, anchorRect.left - offset - rect.width);
4800
+ y = Math.max(viewportPadding, Math.min(anchorRect.top + (anchorRect.height - rect.height) / 2, window.innerHeight - rect.height - viewportPadding));
4801
+ }
4802
+ else {
4803
+ x = Math.max(viewportPadding, Math.min(anchorX - rect.width / 2, window.innerWidth - rect.width - viewportPadding));
4804
+ y = Math.max(viewportPadding, anchorY - rect.height - offset);
4805
+ }
4426
4806
  this.tooltipState = { text, x, y };
4427
4807
  this.elTooltip.style.left = `${Math.round(x)}px`;
4428
4808
  this.elTooltip.style.top = `${Math.round(y)}px`;
@@ -5224,21 +5604,43 @@ export class MdzipWorkspaceView {
5224
5604
  return [];
5225
5605
  }
5226
5606
  const items = [];
5227
- if (state.text) {
5228
- items.push({ action: 'preview-copy', label: 'Copy', icon: MENU_COPY_ICON_HTML, shortcut: this.editorShortcut('C') });
5229
- items.push(null);
5230
- }
5231
- items.push({
5232
- action: 'preview-copy-all',
5233
- label: 'Copy All',
5234
- icon: MENU_COPY_ICON_HTML,
5235
- shortcut: this.editorShortcut('A')
5236
- });
5237
- items.push({
5238
- action: 'preview-copy-all-images',
5239
- label: 'Copy All with Images',
5240
- icon: MENU_COPY_ICON_HTML
5241
- });
5607
+ const markdown = this.workspace?.snapshot().currentPathType === 'markdown';
5608
+ // Copy: the selection, in the same formats as Copy All below (no
5609
+ // Markdown: see there). A selection of only an image has no text, but
5610
+ // still has markup.
5611
+ const copy = [];
5612
+ if (state.text.trim()) {
5613
+ copy.push({ action: 'preview-copy', label: 'Plain Text', tooltip: PLAIN_TEXT_TOOLTIP });
5614
+ }
5615
+ if (markdown && state.html) {
5616
+ copy.push({
5617
+ action: 'preview-copy-selection-formatted',
5618
+ label: 'Formatted Text',
5619
+ shortcut: this.editorShortcut('C'),
5620
+ tooltip: FORMATTED_TEXT_TOOLTIP
5621
+ }, { action: 'preview-copy-selection-html', label: 'HTML', tooltip: "The selection's HTML markup, as text" });
5622
+ }
5623
+ if (copy.length > 0) {
5624
+ items.push({ action: 'preview-copy-menu', label: 'Copy', icon: MENU_COPY_ICON_HTML, submenu: copy }, null);
5625
+ }
5626
+ // Copy All: the whole document, in the format each item names. Markdown
5627
+ // and HTML are source copies (#56); the preview can't map a selection
5628
+ // back to exact Markdown (the source pane's own Copy does that).
5629
+ const copyAll = [
5630
+ { action: 'preview-copy-all', label: 'Plain Text', tooltip: PLAIN_TEXT_TOOLTIP },
5631
+ {
5632
+ action: 'preview-copy-all-images',
5633
+ label: 'Formatted Text',
5634
+ shortcut: `${this.editorShortcut('A')}, ${this.editorShortcut('C')}`,
5635
+ tooltip: FORMATTED_TEXT_TOOLTIP
5636
+ }
5637
+ ];
5638
+ if (markdown) {
5639
+ copyAll.push({ action: 'preview-copy-markdown', label: 'Markdown', tooltip: "The document's Markdown source" }, { action: 'preview-copy-html', label: 'HTML', tooltip: "The document's HTML markup, as text" });
5640
+ }
5641
+ items.push({ action: 'preview-copy-all-menu', label: 'Copy All', icon: MENU_COPY_ICON_HTML, submenu: copyAll });
5642
+ // Same item and group as the source editor's menu.
5643
+ items.push(null, { action: 'preview-select-all', label: 'Select All', icon: MENU_SELECT_ALL_ICON_HTML, shortcut: this.editorShortcut('A') });
5242
5644
  return items;
5243
5645
  }
5244
5646
  handlePreviewMenuAction(action) {
@@ -5255,6 +5657,27 @@ export class MdzipWorkspaceView {
5255
5657
  case 'preview-copy-all-images':
5256
5658
  void this.copyAllWithImagesPreviewContent();
5257
5659
  break;
5660
+ case 'preview-copy-html':
5661
+ void this.copyHtmlPreviewContent();
5662
+ break;
5663
+ case 'preview-copy-markdown': {
5664
+ const markdown = this.workspace?.snapshot().currentText ?? '';
5665
+ void this.writeClipboardText(markdown, 'Markdown copied to clipboard')
5666
+ .then((outcome) => this.finishCopyNotification(false, outcome));
5667
+ break;
5668
+ }
5669
+ case 'preview-select-all':
5670
+ this.elPreviewPane.focus({ preventScroll: true });
5671
+ this.selectAllPreview();
5672
+ break;
5673
+ case 'preview-copy-selection-formatted':
5674
+ void this.copyPreviewSelectionFormatted(state.html, state.text)
5675
+ .then((outcome) => this.finishCopyNotification(false, outcome));
5676
+ break;
5677
+ case 'preview-copy-selection-html':
5678
+ void this.writeClipboardText(state.html, 'HTML copied to clipboard as text')
5679
+ .then((outcome) => this.finishCopyNotification(false, outcome));
5680
+ break;
5258
5681
  case 'preview-copy':
5259
5682
  // Partial Copy never shows the render dialog (there's nothing to
5260
5683
  // drain), so its confirmation is always the brief toast.
@@ -5333,19 +5756,198 @@ export class MdzipWorkspaceView {
5333
5756
  }
5334
5757
  }
5335
5758
  /** Writes `text` to the clipboard. Returns null if there was nothing to copy (a true no-op, not a failure); otherwise a success message or the failure text (also reported via `onFailed`, but that's a host callback with no guaranteed visible surface — the caller needs the text itself to show the user). */
5336
- async copyPreviewSelection(text) {
5759
+ copyPreviewSelection(text) {
5760
+ return this.writeClipboardText(text, 'Plain text copied to clipboard');
5761
+ }
5762
+ /**
5763
+ * A preview selection as the Copy items copy it. `html`: the selected
5764
+ * markup inside its enclosing elements (so part of a list item is still
5765
+ * `<ul><li>…`), without the editor's own chrome (code-block headers, image
5766
+ * slots and edit buttons, chunk wrappers) and with archive images' `src`
5767
+ * back to their archive paths instead of blob: URLs. `text`: its plain
5768
+ * text, cleaned the same way as Copy All's (see previewPlainText).
5769
+ */
5770
+ previewSelectionContent(range) {
5771
+ const doc = this.elPreviewContent.ownerDocument;
5772
+ let content = range.cloneContents();
5773
+ let ancestor = range.commonAncestorContainer;
5774
+ if (ancestor.nodeType !== 1) {
5775
+ ancestor = ancestor.parentNode;
5776
+ }
5777
+ while (ancestor && ancestor.nodeType === 1 && ancestor !== this.elPreviewContent
5778
+ && this.elPreviewContent.contains(ancestor)) {
5779
+ const wrapper = ancestor.cloneNode(false);
5780
+ wrapper.appendChild(content);
5781
+ content = wrapper;
5782
+ ancestor = ancestor.parentNode;
5783
+ }
5784
+ const container = doc.createElement('div');
5785
+ container.appendChild(content);
5786
+ const text = previewPlainText(container);
5787
+ for (const element of Array.from(container.querySelectorAll(PREVIEW_CHROME_REMOVE_SELECTOR))) {
5788
+ element.remove();
5789
+ }
5790
+ for (const element of Array.from(container.querySelectorAll(PREVIEW_CHROME_UNWRAP_SELECTOR))) {
5791
+ element.replaceWith(...Array.from(element.childNodes));
5792
+ }
5793
+ for (const image of Array.from(container.querySelectorAll('img'))) {
5794
+ const source = image.getAttribute(PREVIEW_IMAGE_SOURCE_ATTRIBUTE);
5795
+ if (source !== null) {
5796
+ image.setAttribute('src', source);
5797
+ image.removeAttribute(PREVIEW_IMAGE_SOURCE_ATTRIBUTE);
5798
+ }
5799
+ for (const name of Array.from(image.classList)) {
5800
+ if (name === 'mdzip-image-loading') {
5801
+ image.classList.remove(name);
5802
+ }
5803
+ }
5804
+ if (!image.classList.length) {
5805
+ image.removeAttribute('class');
5806
+ }
5807
+ }
5808
+ return { text, html: container.innerHTML.trim() };
5809
+ }
5810
+ /**
5811
+ * Selects all of the rendered document (Ctrl+A and the menu's Select All),
5812
+ * remembering the selection so Ctrl+C copies the whole document while it's
5813
+ * unchanged (see the copy handler).
5814
+ */
5815
+ selectAllPreview() {
5816
+ const doc = this.elPreviewContent.ownerDocument;
5817
+ const selection = doc.defaultView?.getSelection();
5818
+ if (!selection) {
5819
+ return;
5820
+ }
5821
+ const range = doc.createRange();
5822
+ range.selectNodeContents(this.elPreviewContent);
5823
+ selection.removeAllRanges();
5824
+ selection.addRange(range);
5825
+ this.previewSelectAll = {
5826
+ start: range.startContainer, startOffset: range.startOffset,
5827
+ end: range.endContainer, endOffset: range.endOffset
5828
+ };
5829
+ }
5830
+ /**
5831
+ * Re-copies a Ctrl+C selection with its images embedded. Best effort: the
5832
+ * synchronous copy already on the clipboard stays if this fails, and it's
5833
+ * skipped if another copy happened meanwhile.
5834
+ */
5835
+ async upgradePreviewCopyImages(copyId, html, text) {
5836
+ const snapshot = this.workspace?.snapshot();
5837
+ const clipboard = this.editorClipboard();
5838
+ const ClipboardItemCtor = this.elPreviewContent.ownerDocument.defaultView?.ClipboardItem;
5839
+ if (!this.assetSession || !snapshot || !clipboard || !ClipboardItemCtor || !('write' in clipboard)) {
5840
+ return;
5841
+ }
5842
+ try {
5843
+ const embedded = await this.assetSession.rewriteHtmlEmbeddingImages(html, snapshot.currentPath, new AbortController().signal);
5844
+ if (copyId !== this.previewCopyId || this.destroyed) {
5845
+ return;
5846
+ }
5847
+ await clipboard.write([new ClipboardItemCtor({
5848
+ 'text/html': new Blob([embedded], { type: 'text/html' }),
5849
+ 'text/plain': new Blob([text], { type: 'text/plain' })
5850
+ })]);
5851
+ }
5852
+ catch {
5853
+ // The plain copy is already on the clipboard.
5854
+ }
5855
+ }
5856
+ /**
5857
+ * Copies a preview selection as formatted text (Copy > Formatted Text, and
5858
+ * Ctrl+C's image upgrade): its HTML with archive images embedded as data:
5859
+ * URLs, so they survive pasting into Word or email, plus its plain text.
5860
+ */
5861
+ async copyPreviewSelectionFormatted(html, text) {
5862
+ const snapshot = this.workspace?.snapshot();
5863
+ let imageCount = 0;
5864
+ let embedded = html;
5865
+ if (this.assetSession && snapshot && /<img\b/i.test(html)) {
5866
+ try {
5867
+ embedded = await this.assetSession.rewriteHtmlEmbeddingImages(html, snapshot.currentPath, new AbortController().signal, (_done, total) => { imageCount = total; });
5868
+ }
5869
+ catch (error) {
5870
+ this.options.onFailed?.(error);
5871
+ }
5872
+ }
5873
+ return this.writeRichClipboard(embedded, text, imageCount);
5874
+ }
5875
+ /** `writeText` with the same timeout and outcome reporting as {@link copyPreviewSelection}; `message` is the success confirmation. */
5876
+ async writeClipboardText(text, message) {
5337
5877
  if (!text) {
5338
5878
  return null;
5339
5879
  }
5340
5880
  try {
5341
5881
  await this.withTimeout(Promise.resolve(this.editorClipboard()?.writeText(text)), this.clipboardFallbackWriteTimeoutMs, 'Clipboard write timed out.');
5342
- return { message: 'Plain text copied to clipboard' };
5882
+ return { message };
5343
5883
  }
5344
5884
  catch (error) {
5345
5885
  this.options.onFailed?.(error);
5346
5886
  return { error: error instanceof Error ? error.message : String(error) };
5347
5887
  }
5348
5888
  }
5889
+ /**
5890
+ * Copies the whole document's rendered HTML markup as plain text (#56), so
5891
+ * any paste target, Notepad included, gets the raw `<h1>…</h1><p>…</p>`.
5892
+ * Rendered fresh like Copy All with Images, not read from the live preview
5893
+ * (which holds hydrated `blob:` image URLs, lazy-mount sentinels and
5894
+ * code-block chrome): images keep their archive-relative `src`, and
5895
+ * extension `mount()` output (e.g. mermaid diagrams) copies as its
5896
+ * pre-render markup. Large documents get the same cancelable progress
5897
+ * dialog and "ready to copy" step. A host's custom (non-chunking) renderer
5898
+ * renders the document in one pass instead.
5899
+ */
5900
+ async copyHtmlPreviewContent() {
5901
+ const snapshot = this.workspace?.snapshot();
5902
+ if (!snapshot || snapshot.currentPathType !== 'markdown') {
5903
+ return;
5904
+ }
5905
+ const message = 'HTML copied to clipboard as text';
5906
+ const abort = new AbortController();
5907
+ let dialogShown = false;
5908
+ const showDialogTimer = setTimeout(() => {
5909
+ dialogShown = true;
5910
+ this.copyRenderDialogState = { done: 0, total: 0, label: 'Rendering the document', abort };
5911
+ this.render();
5912
+ }, 200);
5913
+ let html;
5914
+ try {
5915
+ const context = this.createMarkdownContext(snapshot, abort.signal);
5916
+ if (this.renderingService.supportsChunking) {
5917
+ const cacheGeneration = this.previewMemoMatchesSnapshot(snapshot) ? this.previewGeneration : null;
5918
+ html = await this.renderFullDocumentHtml(snapshot, context, abort.signal, cacheGeneration, (done, total) => {
5919
+ if (!dialogShown)
5920
+ return;
5921
+ this.copyRenderDialogState = { done, total, label: 'Rendering the document', abort };
5922
+ this.updateCopyRenderDialogProgress();
5923
+ });
5924
+ }
5925
+ else {
5926
+ html = await this.renderingService.renderMarkdown(snapshot.currentText, context);
5927
+ }
5928
+ }
5929
+ catch (error) {
5930
+ clearTimeout(showDialogTimer);
5931
+ if (error?.name === 'AbortError') {
5932
+ if (dialogShown) {
5933
+ this.copyRenderDialogState = null;
5934
+ this.render();
5935
+ }
5936
+ }
5937
+ else {
5938
+ this.finishCopyNotification(dialogShown, { error: error instanceof Error ? error.message : String(error) });
5939
+ this.options.onFailed?.(error);
5940
+ }
5941
+ return;
5942
+ }
5943
+ clearTimeout(showDialogTimer);
5944
+ if (dialogShown) {
5945
+ this.armCopyReady(() => this.writeClipboardText(html, message));
5946
+ return;
5947
+ }
5948
+ const outcome = await this.writeClipboardText(html, message);
5949
+ this.finishCopyNotification(false, outcome);
5950
+ }
5349
5951
  /** Syncs just the copy-render dialog's progress bar/text from `copyRenderDialogState` — see `copyAllPreviewContent` for why this bypasses `render()`. No-op while the dialog isn't showing. */
5350
5952
  updateCopyRenderDialogProgress() {
5351
5953
  if (!this.copyRenderDialogState) {
@@ -5371,7 +5973,7 @@ export class MdzipWorkspaceView {
5371
5973
  async copyAllPreviewContent() {
5372
5974
  const pending = this.chunkedRenderState;
5373
5975
  if (!pending || pending.cursor >= pending.records.length) {
5374
- const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
5976
+ const outcome = await this.copyPreviewSelection(previewPlainText(this.elPreviewContent));
5375
5977
  this.finishCopyNotification(false, outcome);
5376
5978
  return;
5377
5979
  }
@@ -5417,10 +6019,10 @@ export class MdzipWorkspaceView {
5417
6019
  // activation by now, so the actual write waits for a fresh one
5418
6020
  // (the dialog's own Copy button) instead of firing automatically.
5419
6021
  // See copyReadyState's doc comment.
5420
- this.armCopyReady(() => this.copyPreviewSelection(this.elPreviewContent.textContent ?? ''));
6022
+ this.armCopyReady(() => this.copyPreviewSelection(previewPlainText(this.elPreviewContent)));
5421
6023
  return;
5422
6024
  }
5423
- const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
6025
+ const outcome = await this.copyPreviewSelection(previewPlainText(this.elPreviewContent));
5424
6026
  this.finishCopyNotification(false, outcome);
5425
6027
  }
5426
6028
  /**
@@ -5579,7 +6181,7 @@ export class MdzipWorkspaceView {
5579
6181
  clearTimeout(showDialogTimer);
5580
6182
  const scratch = this.elPreviewContent.ownerDocument.createElement('div');
5581
6183
  scratch.innerHTML = finalHtml;
5582
- const plainText = scratch.textContent ?? '';
6184
+ const plainText = previewPlainText(scratch);
5583
6185
  if (dialogShown) {
5584
6186
  // Same reasoning as copyAllPreviewContent: the prepare phase (often
5585
6187
  // the slower of the two here, once image embedding is involved) has
@@ -5623,8 +6225,8 @@ export class MdzipWorkspaceView {
5623
6225
  await this.withTimeout(clipboard.write([item]), this.clipboardWriteTimeoutMs, 'Clipboard write timed out.');
5624
6226
  return {
5625
6227
  message: imageCount > 0
5626
- ? `HTML with ${imageCount} image${imageCount === 1 ? '' : 's'} copied to clipboard`
5627
- : 'HTML copied to clipboard'
6228
+ ? `Formatted text with ${imageCount} image${imageCount === 1 ? '' : 's'} copied to clipboard`
6229
+ : 'Formatted text copied to clipboard'
5628
6230
  };
5629
6231
  }
5630
6232
  catch (error) {
@@ -6123,22 +6725,122 @@ export class MdzipWorkspaceView {
6123
6725
  * reacting to `data-vscode-theme-kind` changes via MutationObserver — so the
6124
6726
  * editor follows the host theme without being recreated (recreation would
6125
6727
  * destroy the CodeMirror instance and lose unsaved edits). No-op when the
6126
- * scheme is unchanged. Does not fire `onColorSchemeChanged`, which only
6127
- * reports user-initiated toggles from the built-in toolbar buttons.
6728
+ * scheme is unchanged. `'system'` follows the operating system's setting
6729
+ * from then on. Does not fire `onColorSchemeChanged` or
6730
+ * `onColorSchemePreferenceChanged`, which only report choices made in the
6731
+ * built-in toolbar menu.
6128
6732
  */
6129
6733
  setColorScheme(colorScheme) {
6130
- if (this.colorScheme === colorScheme) {
6734
+ const next = this.resolveColorScheme(colorScheme);
6735
+ if (this.colorSchemePreference === colorScheme && this.colorScheme === next) {
6736
+ return;
6737
+ }
6738
+ this.colorSchemePreference = colorScheme;
6739
+ this.colorScheme = next;
6740
+ this.render();
6741
+ }
6742
+ /** The light/dark scheme a choice means right now; `'system'` reads the OS setting. */
6743
+ resolveColorScheme(preference) {
6744
+ if (preference !== 'system') {
6745
+ return preference;
6746
+ }
6747
+ return this.systemColorSchemeQuery?.matches ? 'dark' : 'light';
6748
+ }
6749
+ themeMenuItems() {
6750
+ return [
6751
+ [this.elLightThemeBtn, 'light'],
6752
+ [this.elDarkThemeBtn, 'dark'],
6753
+ [this.elSystemThemeBtn, 'system']
6754
+ ];
6755
+ }
6756
+ /** Arrow keys cycle focus through a toolbar menu's items. */
6757
+ moveMenuFocus(e, buttons) {
6758
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
6759
+ return;
6760
+ }
6761
+ e.preventDefault();
6762
+ const current = buttons.indexOf(this.elRoot.ownerDocument.activeElement);
6763
+ const step = e.key === 'ArrowDown' ? 1 : -1;
6764
+ buttons[(current + step + buttons.length) % buttons.length].focus();
6765
+ }
6766
+ widthMenuItems() {
6767
+ return Array.from(this.elWidthPopover.querySelectorAll('[data-width]'))
6768
+ .map((button) => [button, button.dataset['width']]);
6769
+ }
6770
+ /** The menu choice matching the current width, or null for a custom pixel width. */
6771
+ previewWidthChoice() {
6772
+ if (this.previewMaxWidth === undefined) {
6773
+ return 'default';
6774
+ }
6775
+ return typeof this.previewMaxWidth === 'number' ? null : this.previewMaxWidth;
6776
+ }
6777
+ /**
6778
+ * A choice's label: its own width (base width times zoom) and, when `measure`
6779
+ * is set and the preview pane is narrower than that, the width it fits to.
6780
+ * Full Width has no number: it is always the pane's width.
6781
+ */
6782
+ previewWidthLabel(choice, measure) {
6783
+ if (choice === 'full') {
6784
+ return PREVIEW_WIDTH_LABELS[choice];
6785
+ }
6786
+ const width = Math.round(PREVIEW_MAX_WIDTH_ALIASES[choice] * this.zoom);
6787
+ const pane = measure ? this.elPreviewPane.clientWidth : 0;
6788
+ const fit = pane > 0 && pane < width ? `, fits to ${pane} px` : '';
6789
+ return `${PREVIEW_WIDTH_LABELS[choice]} (${width} px${fit})`;
6790
+ }
6791
+ /** Labels each width menu item, and the button, with the pixel width it gives. */
6792
+ updateWidthLabels(measure) {
6793
+ for (const [button, choice] of this.widthMenuItems()) {
6794
+ const label = this.previewWidthLabel(choice, measure);
6795
+ setTooltip(button, label);
6796
+ button.setAttribute('aria-label', label);
6797
+ }
6798
+ const choice = this.previewWidthChoice();
6799
+ const label = `Page width: ${choice ? this.previewWidthLabel(choice, measure) : `${this.previewMaxWidth} px`}`;
6800
+ setTooltip(this.elWidthMenuBtn, label);
6801
+ this.elWidthMenuBtn.setAttribute('aria-label', label);
6802
+ }
6803
+ choosePreviewWidthFromMenu(choice) {
6804
+ const changed = this.previewWidthChoice() !== choice;
6805
+ this.previewMaxWidth = choice;
6806
+ this.closeWidthMenu(true);
6807
+ if (changed) {
6808
+ this.options.onPreviewWidthChanged?.(choice);
6809
+ }
6810
+ }
6811
+ /** Closes the page-width menu; `refocus` returns focus to its button (keyboard use). */
6812
+ closeWidthMenu(refocus) {
6813
+ this.widthMenuOpen = false;
6814
+ this.render();
6815
+ if (refocus) {
6816
+ this.elWidthMenuBtn.focus();
6817
+ }
6818
+ }
6819
+ chooseColorSchemeFromMenu(preference) {
6820
+ this.setColorSchemeFromToolbar(preference);
6821
+ this.closeThemeMenu(true);
6822
+ }
6823
+ /** Closes the color scheme menu; `refocus` returns focus to its button (keyboard use). */
6824
+ closeThemeMenu(refocus) {
6825
+ if (!this.themeMenuOpen) {
6131
6826
  return;
6132
6827
  }
6133
- this.colorScheme = colorScheme;
6828
+ this.themeMenuOpen = false;
6134
6829
  this.render();
6830
+ if (refocus) {
6831
+ this.elThemeMenuBtn.focus();
6832
+ }
6135
6833
  }
6136
- setColorSchemeFromToolbar(colorScheme) {
6137
- if (this.colorScheme === colorScheme) {
6834
+ setColorSchemeFromToolbar(preference) {
6835
+ if (this.colorSchemePreference === preference) {
6138
6836
  return;
6139
6837
  }
6140
- this.setColorScheme(colorScheme);
6141
- this.options.onColorSchemeChanged?.(colorScheme);
6838
+ const before = this.colorScheme;
6839
+ this.setColorScheme(preference);
6840
+ this.options.onColorSchemePreferenceChanged?.(preference);
6841
+ if (this.colorScheme !== before) {
6842
+ this.options.onColorSchemeChanged?.(this.colorScheme);
6843
+ }
6142
6844
  }
6143
6845
  async setLayout(requested) {
6144
6846
  const snapshot = this.workspace?.snapshot();
@@ -7002,325 +7704,348 @@ function formatMetadataValue(value) {
7002
7704
  }
7003
7705
  return 'Not available';
7004
7706
  }
7005
- const SHELL_HTML = `
7006
- <section class="mdzip-root">
7007
- <div class="document-strip" data-ref="document-strip" hidden>
7008
- <button type="button" class="title-button" data-ref="title-btn"></button>
7009
- <button type="button" class="document-info-button" data-ref="document-info-btn"
7010
- title="Document information" aria-label="Document information">
7011
- ${INFO_ICON_HTML}
7012
- </button>
7013
- </div>
7014
-
7015
- <header class="toolbar" data-ref="toolbar" hidden>
7016
- <div class="toolbar-start">
7017
- <div class="toolbar-left">
7018
- <button type="button" class="icon-toggle nav-toggle" data-ref="nav-btn" title="Toggle contents" aria-label="Toggle contents">
7019
- ${NAV_TOGGLE_ICON_HTML}
7020
- </button>
7021
- </div>
7022
-
7023
- <div class="edit-toolbar" data-ref="edit-toolbar" role="toolbar" aria-label="Markdown formatting">
7024
- <div class="edit-toolbar-group">
7025
- <button type="button" data-format="bold" data-format-control="bold" title="Bold" aria-label="Bold">${BOLD_ICON_HTML}</button>
7026
- <button type="button" data-format="italic" data-format-control="italic" title="Italic" aria-label="Italic">${ITALIC_ICON_HTML}</button>
7027
- <button type="button" data-format="strike" data-format-control="strikethrough" title="Strikethrough" aria-label="Strikethrough">${STRIKE_ICON_HTML}</button>
7028
- <div class="format-menu" data-format-control="headings">
7029
- <button type="button" class="format-menu-toggle" data-format-menu-toggle
7030
- aria-label="Heading" aria-haspopup="menu" aria-expanded="false">
7031
- ${HEADING_ICON_HTML}${CHEVRON_ICON_HTML}
7032
- </button>
7033
- <div class="format-menu-popover" data-format-menu role="menu" aria-label="Heading level" hidden>
7034
- <button type="button" role="menuitem" data-format="paragraph">Paragraph</button>
7035
- <button type="button" role="menuitem" data-format="heading-1" data-heading-level="1"><strong>H1</strong> Heading 1</button>
7036
- <button type="button" role="menuitem" data-format="heading-2" data-heading-level="2"><strong>H2</strong> Heading 2</button>
7037
- <button type="button" role="menuitem" data-format="heading-3" data-heading-level="3"><strong>H3</strong> Heading 3</button>
7038
- <button type="button" role="menuitem" data-format="heading-4" data-heading-level="4"><strong>H4</strong> Heading 4</button>
7039
- <button type="button" role="menuitem" data-format="heading-5" data-heading-level="5"><strong>H5</strong> Heading 5</button>
7040
- <button type="button" role="menuitem" data-format="heading-6" data-heading-level="6"><strong>H6</strong> Heading 6</button>
7041
- </div>
7042
- </div>
7043
- </div>
7044
- <span class="edit-toolbar-divider" aria-hidden="true"></span>
7045
- <div class="edit-toolbar-group">
7046
- <button type="button" data-format="bullet-list" data-format-control="bulletList" title="Bulleted list" aria-label="Bulleted list">${BULLET_LIST_ICON_HTML}</button>
7047
- <button type="button" data-format="ordered-list" data-format-control="orderedList" title="Numbered list" aria-label="Numbered list">${ORDERED_LIST_ICON_HTML}</button>
7048
- <div class="format-menu" data-format-control="code">
7049
- <button type="button" class="format-menu-toggle" data-format-menu-toggle
7050
- aria-label="Code" aria-haspopup="menu" aria-expanded="false">
7051
- ${CODE_ICON_HTML}${CHEVRON_ICON_HTML}
7052
- </button>
7053
- <div class="format-menu-popover" data-format-menu role="menu" aria-label="Code format" hidden>
7054
- <button type="button" role="menuitem" data-format="code" data-code-kind="inline">Inline code</button>
7055
- <button type="button" role="menuitem" data-format="code-block" data-code-kind="block">Code block</button>
7056
- </div>
7057
- </div>
7058
- <button type="button" data-format="quote" data-format-control="blockquote" title="Blockquote" aria-label="Blockquote">${QUOTE_ICON_HTML}</button>
7059
- <button type="button" data-format="line-break" data-format-control="lineBreak" title="Line break" aria-label="Line break">${LINE_BREAK_ICON_HTML}</button>
7060
- </div>
7061
- <span class="edit-toolbar-divider" aria-hidden="true"></span>
7062
- <div class="edit-toolbar-group">
7063
- <button type="button" data-format="link" data-format-control="link" title="Link" aria-label="Link">${LINK_ICON_HTML}</button>
7064
- <button type="button" data-format="image" data-format-control="image" title="Image" aria-label="Image">${IMAGE_FORMAT_ICON_HTML}</button>
7065
- <input type="file" data-ref="image-input" accept="image/*" hidden />
7066
- </div>
7067
- </div>
7068
- </div>
7069
-
7070
- <div class="toolbar-buttons view-mode-toggle-group" data-ref="layout-controls" role="group" aria-label="Editor layout">
7071
- <button type="button" class="icon-toggle view-mode-toggle" data-ref="source-btn" title="Edit" aria-label="Edit" aria-pressed="false">
7072
- <span class="commandbar-flex-container" data-ref="source-icon">${SOURCE_EDIT_ICON_HTML}</span>
7073
- </button>
7074
- <button type="button" class="icon-toggle view-mode-toggle" data-ref="split-btn" title="Split" aria-label="Split" aria-pressed="false">
7075
- <span class="commandbar-flex-container">
7076
- ${SPLIT_ICON_HTML}
7077
- </span>
7078
- </button>
7079
- <button type="button" class="icon-toggle view-mode-toggle" data-ref="preview-btn" title="View" aria-label="View" aria-pressed="false">
7080
- <span class="commandbar-flex-container">
7081
- ${PREVIEW_ICON_HTML}
7082
- </span>
7083
- </button>
7084
- </div>
7085
-
7086
- <div class="toolbar-controls" data-ref="toolbar-controls">
7087
- <button type="button" class="icon-toggle" data-ref="search-btn" title="Find/replace (Mod-F)" aria-label="Find/replace">
7088
- ${SEARCH_ICON_HTML}
7089
- </button>
7090
- <button type="button" class="icon-toggle" data-ref="save-btn" title="Save" aria-label="Save">
7091
- ${SAVE_ICON_HTML}
7092
- </button>
7093
- <button type="button" class="icon-toggle zoom-toggle" data-ref="zoom-btn" title="Zoom controls" aria-label="Zoom controls">
7094
- ${ZOOM_ICON_HTML}
7095
- </button>
7096
- <div class="theme-toggle-group" data-ref="theme-controls" role="group" aria-label="Color scheme">
7097
- <button type="button" class="icon-toggle theme-toggle" data-ref="dark-theme-btn"
7098
- title="Dark mode" aria-label="Dark mode" aria-pressed="false">
7099
- ${DARK_THEME_ICON_HTML}
7100
- </button>
7101
- <button type="button" class="icon-toggle theme-toggle" data-ref="light-theme-btn"
7102
- title="Light mode" aria-label="Light mode" aria-pressed="false">
7103
- ${LIGHT_THEME_ICON_HTML}
7104
- </button>
7105
- </div>
7106
- <div class="zoom-popover" data-ref="zoom-popover" hidden role="group" aria-label="Zoom">
7107
- <span class="zoom-level" data-ref="zoom-level">100%</span>
7108
- <span class="zoom-stepper">
7109
- <button type="button" data-action="zoom-out" title="Zoom out" aria-label="Zoom out">-</button>
7110
- <button type="button" data-action="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
7111
- </span>
7112
- <button type="button" class="zoom-reset" data-action="zoom-reset" title="Reset zoom">Reset</button>
7113
- </div>
7114
- </div>
7115
- </header>
7116
-
7117
- <div class="workspace-shell" data-ref="workspace-shell" hidden>
7118
- <aside class="nav-pane" data-ref="nav-pane" aria-label="Package contents">
7119
- <div class="nav-tree" data-ref="nav-tree"></div>
7120
- </aside>
7121
- <div class="nav-resizer" data-ref="nav-resizer"
7122
- role="separator" aria-orientation="vertical" aria-label="Resize contents pane"></div>
7123
-
7124
- <div class="pane-stack" data-ref="pane-stack">
7125
- <section class="pane edit-pane" data-ref="edit-pane">
7126
- <div class="editor-host" data-ref="editor-host"></div>
7127
- </section>
7128
- <div class="split-resizer" data-ref="split-resizer"
7129
- role="separator" aria-orientation="vertical" aria-label="Resize split panes"></div>
7130
- <section class="pane preview-pane" data-ref="preview-pane" tabindex="-1">
7131
- <article class="preview-content" data-ref="preview-content"></article>
7132
- </section>
7133
- <section class="pane entry-pane" data-ref="entry-pane"></section>
7134
- </div>
7135
- </div>
7136
-
7137
- <div class="title-dialog-backdrop" data-ref="title-dialog" hidden
7138
- role="dialog" aria-modal="true" aria-labelledby="mdzip-title-dialog-heading">
7139
- <div class="title-dialog">
7140
- <h3 id="mdzip-title-dialog-heading">Set Document Title</h3>
7141
- <p>This is the package-level title stored in manifest.json.</p>
7142
- <input type="text" maxlength="120" data-ref="title-input" aria-label="Document title" />
7143
- <p class="title-dialog-validation" data-ref="title-validation" hidden>Title cannot be empty.</p>
7144
- <p>If unset, consumers may fall back to entry point, filename, or first heading.</p>
7145
- <div class="title-dialog-actions">
7146
- <button type="button" class="reset-title" data-ref="title-reset-btn">Reset</button>
7147
- <button type="button" data-action="cancel-title">Cancel</button>
7148
- <button type="button" class="save-title" data-ref="title-save-btn">Save</button>
7149
- </div>
7150
- </div>
7151
- </div>
7152
-
7153
- <div class="title-dialog-backdrop" data-ref="conversion-dialog" hidden
7154
- role="dialog" aria-modal="true" aria-labelledby="mdzip-conversion-dialog-heading">
7155
- <div class="title-dialog">
7156
- <h3 id="mdzip-conversion-dialog-heading">Convert to MDZ?</h3>
7157
- <p>
7158
- Regular Markdown files contain only text. Convert this document to an
7159
- MDZ package to add images, package navigation, and document metadata.
7160
- </p>
7161
- <p>The next save will produce an .mdz file.</p>
7162
- <div class="title-dialog-actions">
7163
- <button type="button" data-action="cancel-conversion">Cancel</button>
7164
- <button type="button" class="save-title" data-ref="conversion-confirm-btn">Convert</button>
7165
- </div>
7166
- </div>
7167
- </div>
7168
-
7169
- <div class="title-dialog-backdrop" data-ref="image-insert-dialog" hidden
7170
- role="dialog" aria-modal="true" aria-labelledby="mdzip-image-insert-dialog-heading">
7171
- <div class="title-dialog image-insert-dialog">
7172
- <h3 id="mdzip-image-insert-dialog-heading">Insert Image</h3>
7173
- <p>Choose the Markdown inserted for this image.</p>
7174
- <fieldset class="image-insert-options">
7175
- <legend>Output</legend>
7176
- <label>
7177
- <input type="radio" name="mdzip-image-insert-mode" value="markdown"
7178
- data-ref="image-insert-mode-markdown" checked />
7179
- Markdown image
7180
- </label>
7181
- <label>
7182
- <input type="radio" name="mdzip-image-insert-mode" value="html"
7183
- data-ref="image-insert-mode-html" />
7184
- HTML &lt;img&gt; with sizing
7185
- </label>
7186
- </fieldset>
7187
- <label class="image-insert-field">
7188
- <span>Alt text</span>
7189
- <input type="text" data-ref="image-insert-alt" />
7190
- </label>
7191
- <div class="image-insert-grid">
7192
- <label class="image-insert-field">
7193
- <span>Size by</span>
7194
- <select data-ref="image-insert-size-mode">
7195
- <option value="original">Original size</option>
7196
- <option value="width" selected>Width</option>
7197
- <option value="height">Height</option>
7198
- <option value="percent">Percent</option>
7199
- </select>
7200
- </label>
7201
- <label class="image-insert-field">
7202
- <span>Value</span>
7203
- <input type="number" min="1" step="1" data-ref="image-insert-size-value" />
7204
- </label>
7205
- </div>
7206
- <label class="image-insert-field">
7207
- <span>Position</span>
7208
- <select data-ref="image-insert-position">
7209
- <option value="inline">Inline/default</option>
7210
- <option value="left">Left</option>
7211
- <option value="center">Center</option>
7212
- <option value="right">Right</option>
7213
- <option value="wrap-left">Wrap left</option>
7214
- <option value="wrap-right">Wrap right</option>
7215
- </select>
7216
- </label>
7217
- <div class="title-dialog-actions">
7218
- <button type="button" data-action="cancel-image-insert">Cancel</button>
7219
- <button type="button" class="save-title" data-ref="image-insert-confirm-btn">Insert</button>
7220
- </div>
7221
- </div>
7222
- </div>
7223
-
7224
- <div class="title-dialog-backdrop" data-ref="pack-files-dialog" hidden
7225
- role="dialog" aria-modal="true" aria-labelledby="mdzip-pack-files-dialog-heading">
7226
- <div class="title-dialog pack-files-dialog">
7227
- <h3 id="mdzip-pack-files-dialog-heading">Package Markdown Files</h3>
7228
- <p>These files contain more than one Markdown document. Choose how to package them.</p>
7229
- <fieldset class="pack-files-options">
7230
- <legend>Mode</legend>
7231
- <label>
7232
- <input type="radio" name="mdzip-pack-files-mode" value="document"
7233
- data-ref="pack-files-mode-document" checked />
7234
- Document — one primary page; the rest are attached
7235
- </label>
7236
- <label>
7237
- <input type="radio" name="mdzip-pack-files-mode" value="project"
7238
- data-ref="pack-files-mode-project" />
7239
- Project — a set of related documents
7240
- </label>
7241
- </fieldset>
7242
- <label class="pack-files-field">
7243
- <span>Entry point</span>
7244
- <select data-ref="pack-files-entry"></select>
7245
- </label>
7246
- <div class="title-dialog-actions">
7247
- <button type="button" data-action="cancel-pack-files">Cancel</button>
7248
- <button type="button" class="save-title" data-ref="pack-files-confirm-btn">Package</button>
7249
- </div>
7250
- </div>
7251
- </div>
7252
-
7253
- <div class="title-dialog-backdrop" data-ref="copy-render-dialog" hidden
7254
- role="dialog" aria-modal="true" aria-labelledby="mdzip-copy-render-dialog-heading">
7255
- <div class="title-dialog copy-render-dialog">
7256
- <h3 id="mdzip-copy-render-dialog-heading" data-ref="copy-render-heading">Copying document...</h3>
7257
- <div data-ref="copy-render-progress-section">
7258
- <p data-ref="copy-render-progress-text">Rendering the full document...</p>
7259
- <div class="copy-render-progress-track">
7260
- <div class="copy-render-progress-bar" data-ref="copy-render-progress-bar"></div>
7261
- </div>
7262
- </div>
7263
- <p data-ref="copy-render-ready-text" hidden>
7264
- The document is ready. Browsers only allow a clipboard write right after a click, so this needs one more —
7265
- click Copy to finish.
7266
- </p>
7267
- <p data-ref="copy-render-done-text" hidden></p>
7268
- <div class="title-dialog-actions">
7269
- <button type="button" data-action="cancel-copy-render" data-ref="copy-render-cancel-btn">Cancel</button>
7270
- <button type="button" data-action="cancel-copy-ready" data-ref="copy-render-ready-cancel-btn" hidden>Cancel</button>
7271
- <button type="button" class="save-title" data-action="confirm-copy-ready" data-ref="copy-render-confirm-btn" hidden>Copy</button>
7272
- <button type="button" class="save-title" data-action="dismiss-copy-render" data-ref="copy-render-dismiss-btn" hidden>Dismiss</button>
7273
- </div>
7274
- </div>
7275
- </div>
7276
-
7277
- <div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
7278
- role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
7279
- <div class="title-dialog metadata-dialog">
7280
- <h3 id="mdzip-metadata-dialog-heading">Document Information</h3>
7281
- <dl data-ref="metadata-list"></dl>
7282
- <h4>Libraries</h4>
7283
- <dl data-ref="library-list"></dl>
7284
- <div class="title-dialog-actions">
7285
- <button type="button" class="save-title" data-action="close-metadata">Close</button>
7286
- </div>
7287
- </div>
7288
- </div>
7289
-
7290
- <div class="title-dialog-backdrop" data-ref="name-dialog" hidden
7291
- role="dialog" aria-modal="true" aria-labelledby="mdzip-name-dialog-heading">
7292
- <div class="title-dialog">
7293
- <h3 id="mdzip-name-dialog-heading" data-ref="name-dialog-heading">New Markdown File</h3>
7294
- <input type="text" maxlength="260" data-ref="name-input" aria-label="File name" />
7295
- <p class="title-dialog-validation" data-ref="name-validation" hidden></p>
7296
- <div class="title-dialog-actions">
7297
- <button type="button" data-action="cancel-name">Cancel</button>
7298
- <button type="button" class="save-title" data-ref="name-confirm-btn">Create</button>
7299
- </div>
7300
- </div>
7301
- </div>
7302
-
7303
- <div class="title-dialog-backdrop" data-ref="delete-dialog" hidden
7304
- role="dialog" aria-modal="true" aria-labelledby="mdzip-delete-dialog-heading">
7305
- <div class="title-dialog">
7306
- <h3 id="mdzip-delete-dialog-heading">Delete File?</h3>
7307
- <p data-ref="delete-dialog-text"></p>
7308
- <div class="title-dialog-actions">
7309
- <button type="button" data-action="cancel-delete">Cancel</button>
7310
- <button type="button" class="danger-action" data-ref="delete-confirm-btn">Delete</button>
7311
- </div>
7312
- </div>
7313
- </div>
7314
-
7315
- <input type="file" data-ref="replace-input" hidden />
7316
-
7317
- <div class="nav-context-menu" data-ref="nav-menu" hidden role="menu"></div>
7318
-
7319
- <div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
7320
-
7321
- <div class="mdzip-copy-toast" data-ref="copy-toast" role="status" aria-live="polite" hidden></div>
7322
-
7323
- <p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
7324
- </section>
7707
+ const SHELL_HTML = `
7708
+ <section class="mdzip-root">
7709
+ <div class="document-strip" data-ref="document-strip" hidden>
7710
+ <button type="button" class="title-button" data-ref="title-btn"></button>
7711
+ <button type="button" class="document-info-button" data-ref="document-info-btn"
7712
+ title="Document information" aria-label="Document information">
7713
+ ${INFO_ICON_HTML}
7714
+ </button>
7715
+ </div>
7716
+
7717
+ <header class="toolbar" data-ref="toolbar" hidden>
7718
+ <div class="toolbar-start">
7719
+ <div class="toolbar-left">
7720
+ <button type="button" class="icon-toggle nav-toggle" data-ref="nav-btn" title="Toggle contents" aria-label="Toggle contents">
7721
+ ${NAV_TOGGLE_ICON_HTML}
7722
+ </button>
7723
+ </div>
7724
+
7725
+ <div class="edit-toolbar" data-ref="edit-toolbar" role="toolbar" aria-label="Markdown formatting">
7726
+ <div class="edit-toolbar-group">
7727
+ <button type="button" data-format="bold" data-format-control="bold" title="Bold" aria-label="Bold">${BOLD_ICON_HTML}</button>
7728
+ <button type="button" data-format="italic" data-format-control="italic" title="Italic" aria-label="Italic">${ITALIC_ICON_HTML}</button>
7729
+ <button type="button" data-format="strike" data-format-control="strikethrough" title="Strikethrough" aria-label="Strikethrough">${STRIKE_ICON_HTML}</button>
7730
+ <div class="format-menu" data-format-control="headings">
7731
+ <button type="button" class="format-menu-toggle" data-format-menu-toggle
7732
+ aria-label="Heading" aria-haspopup="menu" aria-expanded="false">
7733
+ ${HEADING_ICON_HTML}${CHEVRON_ICON_HTML}
7734
+ </button>
7735
+ <div class="format-menu-popover" data-format-menu role="menu" aria-label="Heading level" hidden>
7736
+ <button type="button" role="menuitem" data-format="paragraph">Paragraph</button>
7737
+ <button type="button" role="menuitem" data-format="heading-1" data-heading-level="1"><strong>H1</strong> Heading 1</button>
7738
+ <button type="button" role="menuitem" data-format="heading-2" data-heading-level="2"><strong>H2</strong> Heading 2</button>
7739
+ <button type="button" role="menuitem" data-format="heading-3" data-heading-level="3"><strong>H3</strong> Heading 3</button>
7740
+ <button type="button" role="menuitem" data-format="heading-4" data-heading-level="4"><strong>H4</strong> Heading 4</button>
7741
+ <button type="button" role="menuitem" data-format="heading-5" data-heading-level="5"><strong>H5</strong> Heading 5</button>
7742
+ <button type="button" role="menuitem" data-format="heading-6" data-heading-level="6"><strong>H6</strong> Heading 6</button>
7743
+ </div>
7744
+ </div>
7745
+ </div>
7746
+ <span class="edit-toolbar-divider" aria-hidden="true"></span>
7747
+ <div class="edit-toolbar-group">
7748
+ <button type="button" data-format="bullet-list" data-format-control="bulletList" title="Bulleted list" aria-label="Bulleted list">${BULLET_LIST_ICON_HTML}</button>
7749
+ <button type="button" data-format="ordered-list" data-format-control="orderedList" title="Numbered list" aria-label="Numbered list">${ORDERED_LIST_ICON_HTML}</button>
7750
+ <div class="format-menu" data-format-control="code">
7751
+ <button type="button" class="format-menu-toggle" data-format-menu-toggle
7752
+ aria-label="Code" aria-haspopup="menu" aria-expanded="false">
7753
+ ${CODE_ICON_HTML}${CHEVRON_ICON_HTML}
7754
+ </button>
7755
+ <div class="format-menu-popover" data-format-menu role="menu" aria-label="Code format" hidden>
7756
+ <button type="button" role="menuitem" data-format="code" data-code-kind="inline">Inline code</button>
7757
+ <button type="button" role="menuitem" data-format="code-block" data-code-kind="block">Code block</button>
7758
+ </div>
7759
+ </div>
7760
+ <button type="button" data-format="quote" data-format-control="blockquote" title="Blockquote" aria-label="Blockquote">${QUOTE_ICON_HTML}</button>
7761
+ <button type="button" data-format="line-break" data-format-control="lineBreak" title="Line break" aria-label="Line break">${LINE_BREAK_ICON_HTML}</button>
7762
+ </div>
7763
+ <span class="edit-toolbar-divider" aria-hidden="true"></span>
7764
+ <div class="edit-toolbar-group">
7765
+ <button type="button" data-format="link" data-format-control="link" title="Link" aria-label="Link">${LINK_ICON_HTML}</button>
7766
+ <button type="button" data-format="image" data-format-control="image" title="Image" aria-label="Image">${IMAGE_FORMAT_ICON_HTML}</button>
7767
+ <input type="file" data-ref="image-input" accept="image/*" hidden />
7768
+ </div>
7769
+ </div>
7770
+ </div>
7771
+
7772
+ <div class="toolbar-buttons view-mode-toggle-group" data-ref="layout-controls" role="group" aria-label="Editor layout">
7773
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="source-btn" title="Edit" aria-label="Edit" aria-pressed="false">
7774
+ <span class="commandbar-flex-container" data-ref="source-icon">${SOURCE_EDIT_ICON_HTML}</span>
7775
+ </button>
7776
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="split-btn" title="Split" aria-label="Split" aria-pressed="false">
7777
+ <span class="commandbar-flex-container">
7778
+ ${SPLIT_ICON_HTML}
7779
+ </span>
7780
+ </button>
7781
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="preview-btn" title="View" aria-label="View" aria-pressed="false">
7782
+ <span class="commandbar-flex-container">
7783
+ ${PREVIEW_ICON_HTML}
7784
+ </span>
7785
+ </button>
7786
+ </div>
7787
+
7788
+ <div class="toolbar-controls" data-ref="toolbar-controls">
7789
+ <button type="button" class="icon-toggle" data-ref="search-btn" title="Find/replace (Mod-F)" aria-label="Find/replace">
7790
+ ${SEARCH_ICON_HTML}
7791
+ </button>
7792
+ <button type="button" class="icon-toggle" data-ref="save-btn" title="Save" aria-label="Save">
7793
+ ${SAVE_ICON_HTML}
7794
+ </button>
7795
+ <button type="button" class="icon-toggle zoom-toggle" data-ref="zoom-btn" title="Zoom controls" aria-label="Zoom controls">
7796
+ ${ZOOM_ICON_HTML}
7797
+ </button>
7798
+ <div class="toolbar-menu width-menu" data-ref="width-controls">
7799
+ <button type="button" class="icon-toggle toolbar-menu-toggle" data-ref="width-menu-btn"
7800
+ title="Page width" aria-label="Page width" aria-haspopup="menu" aria-expanded="false">
7801
+ <span class="toolbar-menu-icon" data-ref="width-menu-icon">${PAGE_WIDTH_DEFAULT_ICON_HTML}</span>
7802
+ </button>
7803
+ <div class="toolbar-popover" data-ref="width-popover" role="menu" aria-label="Page width" hidden>
7804
+ ${['narrow', 'default', 'wide', 'full'].map((choice) => `
7805
+ <button type="button" role="menuitemradio" class="icon-toggle toolbar-menu-item" data-width="${choice}"
7806
+ title="${PREVIEW_WIDTH_LABELS[choice]}" aria-label="${PREVIEW_WIDTH_LABELS[choice]}" aria-checked="false">
7807
+ ${PREVIEW_WIDTH_ICONS_HTML[choice]}
7808
+ </button>`).join('')}
7809
+ </div>
7810
+ </div>
7811
+ <div class="theme-menu" data-ref="theme-controls">
7812
+ <button type="button" class="icon-toggle theme-menu-toggle" data-ref="theme-menu-btn"
7813
+ title="Color scheme" aria-label="Color scheme" aria-haspopup="menu" aria-expanded="false">
7814
+ <span class="theme-menu-icon" data-ref="theme-menu-icon">${SYSTEM_THEME_ICON_HTML}</span>
7815
+ </button>
7816
+ <div class="theme-popover" data-ref="theme-popover" role="menu" aria-label="Color scheme" hidden>
7817
+ <button type="button" role="menuitemradio" class="icon-toggle theme-toggle" data-ref="light-theme-btn"
7818
+ title="Light mode" aria-label="Light mode" aria-checked="false">
7819
+ ${LIGHT_THEME_ICON_HTML}
7820
+ </button>
7821
+ <button type="button" role="menuitemradio" class="icon-toggle theme-toggle" data-ref="dark-theme-btn"
7822
+ title="Dark mode" aria-label="Dark mode" aria-checked="false">
7823
+ ${DARK_THEME_ICON_HTML}
7824
+ </button>
7825
+ <button type="button" role="menuitemradio" class="icon-toggle theme-toggle" data-ref="system-theme-btn"
7826
+ title="System setting" aria-label="System setting" aria-checked="false">
7827
+ ${SYSTEM_THEME_ICON_HTML}
7828
+ </button>
7829
+ </div>
7830
+ </div>
7831
+ <div class="zoom-popover" data-ref="zoom-popover" hidden role="group" aria-label="Zoom">
7832
+ <span class="zoom-level" data-ref="zoom-level">100%</span>
7833
+ <span class="zoom-stepper">
7834
+ <button type="button" data-action="zoom-out" title="Zoom out" aria-label="Zoom out">-</button>
7835
+ <button type="button" data-action="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
7836
+ </span>
7837
+ <button type="button" class="zoom-reset" data-action="zoom-reset" title="Reset zoom">Reset</button>
7838
+ </div>
7839
+ </div>
7840
+ </header>
7841
+
7842
+ <div class="workspace-shell" data-ref="workspace-shell" hidden>
7843
+ <aside class="nav-pane" data-ref="nav-pane" aria-label="Package contents">
7844
+ <div class="nav-tree" data-ref="nav-tree"></div>
7845
+ </aside>
7846
+ <div class="nav-resizer" data-ref="nav-resizer"
7847
+ role="separator" aria-orientation="vertical" aria-label="Resize contents pane"></div>
7848
+
7849
+ <div class="pane-stack" data-ref="pane-stack">
7850
+ <section class="pane edit-pane" data-ref="edit-pane">
7851
+ <div class="editor-host" data-ref="editor-host"></div>
7852
+ </section>
7853
+ <div class="split-resizer" data-ref="split-resizer"
7854
+ role="separator" aria-orientation="vertical" aria-label="Resize split panes"></div>
7855
+ <section class="pane preview-pane" data-ref="preview-pane" tabindex="-1">
7856
+ <article class="preview-content" data-ref="preview-content"></article>
7857
+ </section>
7858
+ <section class="pane entry-pane" data-ref="entry-pane"></section>
7859
+ </div>
7860
+ </div>
7861
+
7862
+ <div class="title-dialog-backdrop" data-ref="title-dialog" hidden
7863
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-title-dialog-heading">
7864
+ <div class="title-dialog">
7865
+ <h3 id="mdzip-title-dialog-heading">Set Document Title</h3>
7866
+ <p>This is the package-level title stored in manifest.json.</p>
7867
+ <input type="text" maxlength="120" data-ref="title-input" aria-label="Document title" />
7868
+ <p class="title-dialog-validation" data-ref="title-validation" hidden>Title cannot be empty.</p>
7869
+ <p>If unset, consumers may fall back to entry point, filename, or first heading.</p>
7870
+ <div class="title-dialog-actions">
7871
+ <button type="button" class="reset-title" data-ref="title-reset-btn">Reset</button>
7872
+ <button type="button" data-action="cancel-title">Cancel</button>
7873
+ <button type="button" class="save-title" data-ref="title-save-btn">Save</button>
7874
+ </div>
7875
+ </div>
7876
+ </div>
7877
+
7878
+ <div class="title-dialog-backdrop" data-ref="conversion-dialog" hidden
7879
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-conversion-dialog-heading">
7880
+ <div class="title-dialog">
7881
+ <h3 id="mdzip-conversion-dialog-heading">Convert to MDZ?</h3>
7882
+ <p>
7883
+ Regular Markdown files contain only text. Convert this document to an
7884
+ MDZ package to add images, package navigation, and document metadata.
7885
+ </p>
7886
+ <p>The next save will produce an .mdz file.</p>
7887
+ <div class="title-dialog-actions">
7888
+ <button type="button" data-action="cancel-conversion">Cancel</button>
7889
+ <button type="button" class="save-title" data-ref="conversion-confirm-btn">Convert</button>
7890
+ </div>
7891
+ </div>
7892
+ </div>
7893
+
7894
+ <div class="title-dialog-backdrop" data-ref="image-insert-dialog" hidden
7895
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-image-insert-dialog-heading">
7896
+ <div class="title-dialog image-insert-dialog">
7897
+ <h3 id="mdzip-image-insert-dialog-heading">Insert Image</h3>
7898
+ <p>Choose the Markdown inserted for this image.</p>
7899
+ <fieldset class="image-insert-options">
7900
+ <legend>Output</legend>
7901
+ <label>
7902
+ <input type="radio" name="mdzip-image-insert-mode" value="markdown"
7903
+ data-ref="image-insert-mode-markdown" checked />
7904
+ Markdown image
7905
+ </label>
7906
+ <label>
7907
+ <input type="radio" name="mdzip-image-insert-mode" value="html"
7908
+ data-ref="image-insert-mode-html" />
7909
+ HTML &lt;img&gt; with sizing
7910
+ </label>
7911
+ </fieldset>
7912
+ <label class="image-insert-field">
7913
+ <span>Alt text</span>
7914
+ <input type="text" data-ref="image-insert-alt" />
7915
+ </label>
7916
+ <div class="image-insert-grid">
7917
+ <label class="image-insert-field">
7918
+ <span>Size by</span>
7919
+ <select data-ref="image-insert-size-mode">
7920
+ <option value="original">Original size</option>
7921
+ <option value="width" selected>Width</option>
7922
+ <option value="height">Height</option>
7923
+ <option value="percent">Percent</option>
7924
+ </select>
7925
+ </label>
7926
+ <label class="image-insert-field">
7927
+ <span>Value</span>
7928
+ <input type="number" min="1" step="1" data-ref="image-insert-size-value" />
7929
+ </label>
7930
+ </div>
7931
+ <label class="image-insert-field">
7932
+ <span>Position</span>
7933
+ <select data-ref="image-insert-position">
7934
+ <option value="inline">Inline/default</option>
7935
+ <option value="left">Left</option>
7936
+ <option value="center">Center</option>
7937
+ <option value="right">Right</option>
7938
+ <option value="wrap-left">Wrap left</option>
7939
+ <option value="wrap-right">Wrap right</option>
7940
+ </select>
7941
+ </label>
7942
+ <div class="title-dialog-actions">
7943
+ <button type="button" data-action="cancel-image-insert">Cancel</button>
7944
+ <button type="button" class="save-title" data-ref="image-insert-confirm-btn">Insert</button>
7945
+ </div>
7946
+ </div>
7947
+ </div>
7948
+
7949
+ <div class="title-dialog-backdrop" data-ref="pack-files-dialog" hidden
7950
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-pack-files-dialog-heading">
7951
+ <div class="title-dialog pack-files-dialog">
7952
+ <h3 id="mdzip-pack-files-dialog-heading">Package Markdown Files</h3>
7953
+ <p>These files contain more than one Markdown document. Choose how to package them.</p>
7954
+ <fieldset class="pack-files-options">
7955
+ <legend>Mode</legend>
7956
+ <label>
7957
+ <input type="radio" name="mdzip-pack-files-mode" value="document"
7958
+ data-ref="pack-files-mode-document" checked />
7959
+ Document — one primary page; the rest are attached
7960
+ </label>
7961
+ <label>
7962
+ <input type="radio" name="mdzip-pack-files-mode" value="project"
7963
+ data-ref="pack-files-mode-project" />
7964
+ Project — a set of related documents
7965
+ </label>
7966
+ </fieldset>
7967
+ <label class="pack-files-field">
7968
+ <span>Entry point</span>
7969
+ <select data-ref="pack-files-entry"></select>
7970
+ </label>
7971
+ <div class="title-dialog-actions">
7972
+ <button type="button" data-action="cancel-pack-files">Cancel</button>
7973
+ <button type="button" class="save-title" data-ref="pack-files-confirm-btn">Package</button>
7974
+ </div>
7975
+ </div>
7976
+ </div>
7977
+
7978
+ <div class="title-dialog-backdrop" data-ref="copy-render-dialog" hidden
7979
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-copy-render-dialog-heading">
7980
+ <div class="title-dialog copy-render-dialog">
7981
+ <h3 id="mdzip-copy-render-dialog-heading" data-ref="copy-render-heading">Copying document...</h3>
7982
+ <div data-ref="copy-render-progress-section">
7983
+ <p data-ref="copy-render-progress-text">Rendering the full document...</p>
7984
+ <div class="copy-render-progress-track">
7985
+ <div class="copy-render-progress-bar" data-ref="copy-render-progress-bar"></div>
7986
+ </div>
7987
+ </div>
7988
+ <p data-ref="copy-render-ready-text" hidden>
7989
+ The document is ready. Browsers only allow a clipboard write right after a click, so this needs one more —
7990
+ click Copy to finish.
7991
+ </p>
7992
+ <p data-ref="copy-render-done-text" hidden></p>
7993
+ <div class="title-dialog-actions">
7994
+ <button type="button" data-action="cancel-copy-render" data-ref="copy-render-cancel-btn">Cancel</button>
7995
+ <button type="button" data-action="cancel-copy-ready" data-ref="copy-render-ready-cancel-btn" hidden>Cancel</button>
7996
+ <button type="button" class="save-title" data-action="confirm-copy-ready" data-ref="copy-render-confirm-btn" hidden>Copy</button>
7997
+ <button type="button" class="save-title" data-action="dismiss-copy-render" data-ref="copy-render-dismiss-btn" hidden>Dismiss</button>
7998
+ </div>
7999
+ </div>
8000
+ </div>
8001
+
8002
+ <div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
8003
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
8004
+ <div class="title-dialog metadata-dialog">
8005
+ <h3 id="mdzip-metadata-dialog-heading">Document Information</h3>
8006
+ <dl data-ref="metadata-list"></dl>
8007
+ <h4>Libraries</h4>
8008
+ <dl data-ref="library-list"></dl>
8009
+ <div class="title-dialog-actions">
8010
+ <button type="button" class="save-title" data-action="close-metadata">Close</button>
8011
+ </div>
8012
+ </div>
8013
+ </div>
8014
+
8015
+ <div class="title-dialog-backdrop" data-ref="name-dialog" hidden
8016
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-name-dialog-heading">
8017
+ <div class="title-dialog">
8018
+ <h3 id="mdzip-name-dialog-heading" data-ref="name-dialog-heading">New Markdown File</h3>
8019
+ <input type="text" maxlength="260" data-ref="name-input" aria-label="File name" />
8020
+ <p class="title-dialog-validation" data-ref="name-validation" hidden></p>
8021
+ <div class="title-dialog-actions">
8022
+ <button type="button" data-action="cancel-name">Cancel</button>
8023
+ <button type="button" class="save-title" data-ref="name-confirm-btn">Create</button>
8024
+ </div>
8025
+ </div>
8026
+ </div>
8027
+
8028
+ <div class="title-dialog-backdrop" data-ref="delete-dialog" hidden
8029
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-delete-dialog-heading">
8030
+ <div class="title-dialog">
8031
+ <h3 id="mdzip-delete-dialog-heading">Delete File?</h3>
8032
+ <p data-ref="delete-dialog-text"></p>
8033
+ <div class="title-dialog-actions">
8034
+ <button type="button" data-action="cancel-delete">Cancel</button>
8035
+ <button type="button" class="danger-action" data-ref="delete-confirm-btn">Delete</button>
8036
+ </div>
8037
+ </div>
8038
+ </div>
8039
+
8040
+ <input type="file" data-ref="replace-input" hidden />
8041
+
8042
+ <div class="nav-context-menu" data-ref="nav-menu" hidden role="menu"></div>
8043
+
8044
+ <div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
8045
+
8046
+ <div class="mdzip-copy-toast" data-ref="copy-toast" role="status" aria-live="polite" hidden></div>
8047
+
8048
+ <p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
8049
+ </section>
7325
8050
  `;
7326
8051
  //# sourceMappingURL=view.js.map