@mdzip/editor 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/view.js CHANGED
@@ -19,6 +19,7 @@ import { escapeMarkdownImageAlt, findImageReferenceAtOffset, formatImageEditMark
19
19
  import { MdzipRenderingService, defaultSafeMarkdownRenderer, groupTokensIntoChunks, chunkSourceKey, collectMdzipHeadingIds, MDZIP_HEADING_ID_PREFIX, tokenEmbedsImage, tokenIsHeading } from './rendering.js';
20
20
  import { mdzipFrontMatterExtension } from './front-matter-extension.js';
21
21
  import { WORKSPACE_CSS } from './view-css.js';
22
+ import { INLINE_SVG_SHADOW_CSS } from './svg-inline.js';
22
23
  const STYLE_ATTR = 'data-mdzip-ws-styles';
23
24
  const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff?)$/i;
24
25
  const isImageFile = (path) => IMAGE_EXTENSIONS.test(path);
@@ -193,6 +194,29 @@ const PREVIEW_WIDTH_ICONS_HTML = {
193
194
  wide: PAGE_WIDTH_WIDE_ICON_HTML,
194
195
  full: PAGE_WIDTH_FULL_ICON_HTML
195
196
  };
197
+ const HOST_FILE_ACTION_PREFIX = 'host:';
198
+ /** The data type carrying dropped file addresses, if the drag has one. */
199
+ function droppedUriListType(dataTransfer) {
200
+ const types = dataTransfer?.types ?? [];
201
+ for (const type of ['application/vnd.code.uri-list', 'text/uri-list']) {
202
+ if (types.includes(type))
203
+ return type;
204
+ }
205
+ return null;
206
+ }
207
+ /** URIs from a uri-list: one per line, `#` lines are comments (RFC 2483). */
208
+ function parseUriList(text) {
209
+ return text.split('\n').map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
210
+ }
211
+ function fileActionTarget(target) {
212
+ return {
213
+ path: target.path,
214
+ isMarkdown: target.isMarkdown,
215
+ isImage: target.isImage,
216
+ isManifest: target.isManifest,
217
+ isEntryPoint: target.isEntryPoint
218
+ };
219
+ }
196
220
  const ALL_HEADINGS = [1, 2, 3, 4, 5, 6];
197
221
  const ALL_LAYOUT_CONTROLS = {
198
222
  source: true,
@@ -662,6 +686,12 @@ const noSpellcheckHighlight = ViewPlugin.fromClass(class {
662
686
  const IMAGE_EDIT_AFFORDANCE_CLICK_EVENT = 'mdzip-image-edit-affordance-click';
663
687
  /** Holds an archive image's original `src` in the preview, whose `src` becomes a blob: URL. */
664
688
  const PREVIEW_IMAGE_SOURCE_ATTRIBUTE = 'data-mdzip-source';
689
+ // Holds an archive-relative image source between parsing the preview HTML and
690
+ // the image pass (collectPendingImages), so the browser never sees it as a
691
+ // `src` and never requests it from the host page's server (#61).
692
+ const PREVIEW_IMAGE_PENDING_ATTRIBUTE = 'data-mdzip-pending-src';
693
+ // External, protocol-relative, data, blob and fragment URLs: left as they are.
694
+ const NON_ARCHIVE_IMAGE_SOURCE_RE = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i;
665
695
  // Preview chrome the editor adds around rendered content: removed, or
666
696
  // unwrapped (children kept), when the selection is copied as HTML.
667
697
  const PREVIEW_CHROME_REMOVE_SELECTOR = [
@@ -940,9 +970,13 @@ export class MdzipWorkspaceView {
940
970
  /** Choice whose icon the theme menu button shows, so render() only swaps it on change. */
941
971
  this.themeMenuIconPreference = null;
942
972
  this.colorSchemePreference = 'system';
973
+ /** The host theme System follows instead of the OS setting (see setSystemColorScheme). */
974
+ this.systemColorSource = null;
975
+ /** The System item's label and icon last rendered, so render() only rewrites them on change. */
976
+ this.systemItemKey = null;
943
977
  /** Re-resolves the scheme when the OS setting changes while 'system' is chosen. */
944
978
  this.onSystemColorSchemeChange = () => {
945
- if (this.colorSchemePreference !== 'system' || this.destroyed) {
979
+ if (this.colorSchemePreference !== 'system' || this.systemColorSource || this.destroyed) {
946
980
  return;
947
981
  }
948
982
  const next = this.resolveColorScheme('system');
@@ -1159,6 +1193,7 @@ export class MdzipWorkspaceView {
1159
1193
  // matches() does not re-run on every snapshot render of the same entry.
1160
1194
  this.entryMatchMissKey = null;
1161
1195
  this.entryGeneration = 0;
1196
+ this.pendingAddFilesDir = null;
1162
1197
  this.options = options;
1163
1198
  this.controlPolicy = resolveMdzipControlPolicy(options.controls);
1164
1199
  this.navigationMode = options.navigationMode ?? 'editor';
@@ -1170,6 +1205,7 @@ export class MdzipWorkspaceView {
1170
1205
  this.layout = options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy);
1171
1206
  this.systemColorSchemeQuery = container.ownerDocument.defaultView?.matchMedia?.('(prefers-color-scheme: dark)') ?? null;
1172
1207
  this.systemColorSchemeQuery?.addEventListener?.('change', this.onSystemColorSchemeChange);
1208
+ this.systemColorSource = options.systemColorScheme ?? null;
1173
1209
  this.colorSchemePreference = options.initialColorScheme ?? 'system';
1174
1210
  this.colorScheme = this.resolveColorScheme(this.colorSchemePreference);
1175
1211
  this.navVisible = options.navigationButtonActive ?? this.navVisible;
@@ -1269,6 +1305,7 @@ export class MdzipWorkspaceView {
1269
1305
  this.elDeleteDialogText = q('[data-ref="delete-dialog-text"]');
1270
1306
  this.elDeleteConfirmBtn = q('[data-ref="delete-confirm-btn"]');
1271
1307
  this.elReplaceInput = q('[data-ref="replace-input"]');
1308
+ this.elAddFilesInput = q('[data-ref="add-files-input"]');
1272
1309
  this.elTooltip = q('[data-ref="tooltip"]');
1273
1310
  this.elCopyToast = q('[data-ref="copy-toast"]');
1274
1311
  this.elEmptyState = q('[data-ref="empty-state"]');
@@ -1740,6 +1777,10 @@ export class MdzipWorkspaceView {
1740
1777
  this.applyExternalLinkPolicy(this.elPreviewContent);
1741
1778
  }
1742
1779
  applyExternalLinkPolicy(root) {
1780
+ for (const host of Array.from(root.querySelectorAll('.mdzip-inline-svg'))) {
1781
+ if (host.shadowRoot)
1782
+ this.applyExternalLinkPolicy(host.shadowRoot);
1783
+ }
1743
1784
  const policy = this.options.externalLinks;
1744
1785
  for (const link of Array.from(root.querySelectorAll('a[href]'))) {
1745
1786
  const original = this.externalLinkOriginals.get(link);
@@ -2081,7 +2122,7 @@ export class MdzipWorkspaceView {
2081
2122
  * has resolved and had its final `src` assigned (or there are none).
2082
2123
  */
2083
2124
  mountProgressivePreview(html, snapshot, context, generation, animateImageHydration) {
2084
- this.elPreviewContent.innerHTML = html;
2125
+ this.elPreviewContent.replaceChildren(this.inertPreviewFragment(html));
2085
2126
  // Cheap pass over every <img> happens before extensions/code-block
2086
2127
  // controls mount, same relative order as always — see
2087
2128
  // collectPendingImages for why it's split from the (expensive) slot
@@ -2100,9 +2141,9 @@ export class MdzipWorkspaceView {
2100
2141
  }
2101
2142
  /**
2102
2143
  * Cheap, attribute-only pass over every `<img>` under `roots`: no DOM tree
2103
- * mutation. Archive-relative sources have their `src` stripped here
2104
- * (immediately, so the browser never fires a bad network request for the
2105
- * archive-relative path) and are returned for {@link hydrateImages};
2144
+ * mutation. Archive-relative sources (already moved off `src` by
2145
+ * {@link inertPreviewFragment}, so the browser never requested them) are
2146
+ * returned for {@link hydrateImages};
2106
2147
  * external/data/fragment sources are left untouched (just get their align
2107
2148
  * class, if any — they never get a slot). Returns `[]` without touching
2108
2149
  * anything when there's no asset session to resolve archive images
@@ -2119,6 +2160,69 @@ export class MdzipWorkspaceView {
2119
2160
  * the image, since a fresh `<img>` element never carries over its
2120
2161
  * predecessor's already-resolved `src`.
2121
2162
  */
2163
+ /**
2164
+ * Parses preview HTML without loading anything: a `<template>`'s content is
2165
+ * inert, whereas `innerHTML` on any element of a live document (attached or
2166
+ * not) starts fetching every `<img src>` at once, resolved against the host
2167
+ * page's URL. Archive-relative sources move to a holding attribute for
2168
+ * {@link collectPendingImages}; with no asset session to resolve them
2169
+ * against, they're left as they are (that pass then skips them too).
2170
+ */
2171
+ inertPreviewFragment(html) {
2172
+ const template = this.elPreviewContent.ownerDocument.createElement('template');
2173
+ template.innerHTML = html;
2174
+ if (this.assetSession) {
2175
+ for (const image of Array.from(template.content.querySelectorAll('img[src]'))) {
2176
+ const source = image.getAttribute('src');
2177
+ if (!source || NON_ARCHIVE_IMAGE_SOURCE_RE.test(source))
2178
+ continue;
2179
+ image.setAttribute(PREVIEW_IMAGE_PENDING_ATTRIBUTE, source);
2180
+ image.removeAttribute('src');
2181
+ }
2182
+ }
2183
+ return template.content;
2184
+ }
2185
+ inlineSvgLinksEnabled() {
2186
+ return this.options.inlineSvgLinks !== false;
2187
+ }
2188
+ /**
2189
+ * Replaces `image` with a shadow-root host holding a copy of the cleaned
2190
+ * `svg` (see `svg-inline.ts`). The host carries the image's alt text,
2191
+ * size, alignment and source path, so the layout matches the `<img>`.
2192
+ */
2193
+ mountInlineSvg(image, svg) {
2194
+ const doc = this.elPreviewContent.ownerDocument;
2195
+ const host = doc.createElement('span');
2196
+ host.className = 'mdzip-inline-svg';
2197
+ for (const name of [PREVIEW_IMAGE_SOURCE_ATTRIBUTE, 'title']) {
2198
+ const value = image.getAttribute(name);
2199
+ if (value !== null)
2200
+ host.setAttribute(name, value);
2201
+ }
2202
+ for (const className of Array.from(image.classList)) {
2203
+ if (className !== 'mdzip-image-loading')
2204
+ host.classList.add(className);
2205
+ }
2206
+ const alignClass = rawHtmlImageAlignClass(image);
2207
+ if (alignClass)
2208
+ host.classList.add(alignClass);
2209
+ const alt = image.getAttribute('alt');
2210
+ host.setAttribute('role', 'group');
2211
+ if (alt)
2212
+ host.setAttribute('aria-label', alt);
2213
+ const width = image.getAttribute('width');
2214
+ if (width && /^\d+(\.\d+)?$/.test(width))
2215
+ host.style.width = `${width}px`;
2216
+ else if (width)
2217
+ host.style.width = width;
2218
+ const shadow = host.attachShadow({ mode: 'open' });
2219
+ const style = doc.createElement('style');
2220
+ style.textContent = INLINE_SVG_SHADOW_CSS + (host.style.width ? 'svg{width:100%}' : '');
2221
+ shadow.append(style, svg.cloneNode(true));
2222
+ this.applyExternalLinkPolicy(shadow);
2223
+ image.replaceWith(host);
2224
+ return host;
2225
+ }
2122
2226
  collectPendingImages(roots, context, generation, animateImageHydration) {
2123
2227
  if (!this.assetSession) {
2124
2228
  return [];
@@ -2127,9 +2231,12 @@ export class MdzipWorkspaceView {
2127
2231
  for (const root of roots) {
2128
2232
  for (const image of Array.from(root.querySelectorAll('img'))) {
2129
2233
  applyRawHtmlImageSizeAttributes(image);
2130
- const source = image.getAttribute('src');
2234
+ const held = image.getAttribute(PREVIEW_IMAGE_PENDING_ATTRIBUTE);
2235
+ if (held !== null)
2236
+ image.removeAttribute(PREVIEW_IMAGE_PENDING_ATTRIBUTE);
2237
+ const source = held ?? image.getAttribute('src');
2131
2238
  // Leave external, protocol-relative, data, and fragment URLs untouched.
2132
- if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
2239
+ if (!source || NON_ARCHIVE_IMAGE_SOURCE_RE.test(source)) {
2133
2240
  const alignClass = rawHtmlImageAlignClass(image);
2134
2241
  if (alignClass) {
2135
2242
  image.classList.add(alignClass);
@@ -2138,7 +2245,14 @@ export class MdzipWorkspaceView {
2138
2245
  }
2139
2246
  // The src is replaced by a blob: URL; Copy as HTML restores it from here.
2140
2247
  image.setAttribute(PREVIEW_IMAGE_SOURCE_ATTRIBUTE, source);
2141
- const known = this.assetSession.resolveKnownImage(source, context.currentPath);
2248
+ const inlineSvg = this.inlineSvgLinksEnabled()
2249
+ ? this.assetSession.knownInlineSvg(source, context.currentPath)
2250
+ : null;
2251
+ if (inlineSvg) {
2252
+ this.mountInlineSvg(image, inlineSvg);
2253
+ continue;
2254
+ }
2255
+ const known = inlineSvg === undefined ? undefined : this.assetSession.resolveKnownImage(source, context.currentPath);
2142
2256
  if (known) {
2143
2257
  if (known.width && known.height && !image.hasAttribute('width') && !image.hasAttribute('height')) {
2144
2258
  image.setAttribute('width', String(known.width));
@@ -2208,6 +2322,26 @@ export class MdzipWorkspaceView {
2208
2322
  return slot;
2209
2323
  };
2210
2324
  const hydrate = (image, slot, source) => {
2325
+ if (this.inlineSvgLinksEnabled() && session.knownInlineSvg(source, context.currentPath) !== null) {
2326
+ void session.resolveInlineSvg(source, context.currentPath).then((svg) => {
2327
+ if (generation !== this.previewGeneration || context.signal.aborted) {
2328
+ settle();
2329
+ return;
2330
+ }
2331
+ if (!svg) {
2332
+ hydrateImage(image, slot, source);
2333
+ return;
2334
+ }
2335
+ image.classList.remove('mdzip-image-loading');
2336
+ this.mountInlineSvg(image, svg);
2337
+ this.openImageSlot(slot);
2338
+ settle();
2339
+ });
2340
+ return;
2341
+ }
2342
+ hydrateImage(image, slot, source);
2343
+ };
2344
+ const hydrateImage = (image, slot, source) => {
2211
2345
  void session.resolveImage(source, context.currentPath).then((resolved) => {
2212
2346
  if (generation !== this.previewGeneration || context.signal.aborted) {
2213
2347
  settle();
@@ -2754,7 +2888,7 @@ export class MdzipWorkspaceView {
2754
2888
  const doc = this.elPreviewContent.ownerDocument;
2755
2889
  const wrapper = doc.createElement('div');
2756
2890
  wrapper.className = 'mdzip-chunk';
2757
- wrapper.innerHTML = html;
2891
+ wrapper.appendChild(this.inertPreviewFragment(html));
2758
2892
  if (beforeNode && beforeNode.parentNode === this.elPreviewContent) {
2759
2893
  this.elPreviewContent.insertBefore(wrapper, beforeNode);
2760
2894
  }
@@ -3590,14 +3724,26 @@ export class MdzipWorkspaceView {
3590
3724
  button.classList.toggle('active', this.colorSchemePreference === preference);
3591
3725
  button.setAttribute('aria-checked', String(this.colorSchemePreference === preference));
3592
3726
  }
3727
+ const systemIcon = this.systemColorSource?.icon
3728
+ ? `<span class="${TOOLBAR_ICON_CLASS} mdzip-host-icon" aria-hidden="true">${this.systemColorSource.icon}</span>`
3729
+ : SYSTEM_THEME_ICON_HTML;
3730
+ const systemItemLabel = this.systemColorSource?.label ?? 'System setting';
3731
+ const systemItemKey = `${systemItemLabel}\n${systemIcon}`;
3732
+ if (this.systemItemKey !== systemItemKey) {
3733
+ this.systemItemKey = systemItemKey;
3734
+ this.themeMenuIconPreference = null;
3735
+ this.elSystemThemeBtn.innerHTML = systemIcon;
3736
+ setTooltip(this.elSystemThemeBtn, systemItemLabel);
3737
+ this.elSystemThemeBtn.setAttribute('aria-label', systemItemLabel);
3738
+ }
3593
3739
  if (this.themeMenuIconPreference !== this.colorSchemePreference) {
3594
3740
  this.themeMenuIconPreference = this.colorSchemePreference;
3595
3741
  this.elThemeMenuIcon.innerHTML = this.colorSchemePreference === 'system'
3596
- ? SYSTEM_THEME_ICON_HTML
3742
+ ? systemIcon
3597
3743
  : this.colorSchemePreference === 'dark' ? DARK_THEME_ICON_HTML : LIGHT_THEME_ICON_HTML;
3598
3744
  }
3599
3745
  const schemeLabel = this.colorSchemePreference === 'system'
3600
- ? `System (${this.colorScheme})`
3746
+ ? `${this.systemColorSource?.label ?? 'System'} (${this.colorScheme})`
3601
3747
  : this.colorSchemePreference === 'dark' ? 'Dark' : 'Light';
3602
3748
  setTooltip(this.elThemeMenuBtn, `Color scheme: ${schemeLabel}`);
3603
3749
  this.elThemeMenuBtn.setAttribute('aria-label', `Color scheme: ${schemeLabel}`);
@@ -3829,6 +3975,7 @@ export class MdzipWorkspaceView {
3829
3975
  }
3830
3976
  e.preventDefault();
3831
3977
  this.selectAllPreview();
3978
+ this.keepPreviewSelectAll();
3832
3979
  });
3833
3980
  this.elNavBtn.addEventListener('click', () => {
3834
3981
  if (!this.controlPolicy.navigation) {
@@ -4341,6 +4488,15 @@ export class MdzipWorkspaceView {
4341
4488
  void this.deleteFile(path);
4342
4489
  }
4343
4490
  });
4491
+ this.elAddFilesInput.addEventListener('change', () => {
4492
+ const files = Array.from(this.elAddFilesInput.files ?? []);
4493
+ const dir = this.pendingAddFilesDir;
4494
+ this.elAddFilesInput.value = '';
4495
+ this.pendingAddFilesDir = null;
4496
+ if (files.length > 0 && dir !== null) {
4497
+ void this.addDroppedFiles(dir, files);
4498
+ }
4499
+ });
4344
4500
  this.elReplaceInput.addEventListener('change', () => {
4345
4501
  const file = this.elReplaceInput.files?.[0];
4346
4502
  const path = this.pendingReplacePath;
@@ -4366,7 +4522,9 @@ export class MdzipWorkspaceView {
4366
4522
  this.elPreviewPane.focus({ preventScroll: true });
4367
4523
  });
4368
4524
  this.elPreviewPane.addEventListener('click', (event) => {
4369
- const link = event.target.closest('a[href]');
4525
+ // The event's path, not its target: clicks inside an inline SVG's shadow root are
4526
+ // retargeted to the host, and SVG links are SVGAElements, not HTMLAnchorElements.
4527
+ const link = event.composedPath().find((node) => node.nodeType === 1 && node.localName === 'a' && node.hasAttribute('href')) ?? null;
4370
4528
  const snapshot = this.workspace?.snapshot();
4371
4529
  if (!link || !snapshot) {
4372
4530
  return;
@@ -4764,8 +4922,9 @@ export class MdzipWorkspaceView {
4764
4922
  }
4765
4923
  showTooltipForElementNow(element) {
4766
4924
  const rect = element.getBoundingClientRect();
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';
4925
+ // A menu item's tip goes beside it, so it doesn't cover the items above:
4926
+ // context menus and the toolbar's dropdowns (color scheme, page width).
4927
+ const placement = element.closest('.nav-context-menu, .theme-popover, .toolbar-popover') ? 'side' : 'above';
4769
4928
  this.showTooltip(element.dataset['tooltip'] ?? '', rect.left + rect.width / 2, rect.top, placement, rect);
4770
4929
  }
4771
4930
  /**
@@ -5827,6 +5986,31 @@ export class MdzipWorkspaceView {
5827
5986
  end: range.endContainer, endOffset: range.endOffset
5828
5987
  };
5829
5988
  }
5989
+ /**
5990
+ * Some hosts run their own Select All after the key event, even when the
5991
+ * editor handled it: a VS Code webview selects the whole page, source pane
5992
+ * included. For a moment after Ctrl+A, a selection that reaches outside
5993
+ * the preview is put back to the preview's own.
5994
+ */
5995
+ keepPreviewSelectAll() {
5996
+ const doc = this.elPreviewContent.ownerDocument;
5997
+ const stop = () => doc.removeEventListener('selectionchange', onChange);
5998
+ const onChange = () => {
5999
+ const selection = doc.defaultView?.getSelection();
6000
+ if (this.destroyed || !this.previewSelectAll || !selection || selection.rangeCount === 0) {
6001
+ stop();
6002
+ return;
6003
+ }
6004
+ const range = selection.getRangeAt(0);
6005
+ if (range.intersectsNode(this.elPreviewContent) && !this.elPreviewContent.contains(range.commonAncestorContainer)
6006
+ && range.commonAncestorContainer !== this.elPreviewContent) {
6007
+ stop();
6008
+ this.selectAllPreview();
6009
+ }
6010
+ };
6011
+ doc.addEventListener('selectionchange', onChange);
6012
+ setTimeout(stop, 500);
6013
+ }
5830
6014
  /**
5831
6015
  * Re-copies a Ctrl+C selection with its images embedded. Best effort: the
5832
6016
  * synchronous copy already on the clipboard stays if this fails, and it's
@@ -6739,11 +6923,27 @@ export class MdzipWorkspaceView {
6739
6923
  this.colorScheme = next;
6740
6924
  this.render();
6741
6925
  }
6742
- /** The light/dark scheme a choice means right now; `'system'` reads the OS setting. */
6926
+ /**
6927
+ * Sets what the menu's System choice follows: the host theme in `source`
6928
+ * (its current scheme, and optionally the choice's name and icon), or the
6929
+ * operating system's setting again with `null`. Call it whenever the host
6930
+ * theme changes; while System is chosen the preview switches with it,
6931
+ * keeping the rendered preview in place. Like `setColorScheme`, it fires
6932
+ * no change callbacks.
6933
+ */
6934
+ setSystemColorScheme(source) {
6935
+ this.systemColorSource = source;
6936
+ this.colorScheme = this.resolveColorScheme(this.colorSchemePreference);
6937
+ this.render();
6938
+ }
6939
+ /** The light/dark scheme a choice means right now; `'system'` reads the host theme or the OS setting. */
6743
6940
  resolveColorScheme(preference) {
6744
6941
  if (preference !== 'system') {
6745
6942
  return preference;
6746
6943
  }
6944
+ if (this.systemColorSource) {
6945
+ return this.systemColorSource.colorScheme;
6946
+ }
6747
6947
  return this.systemColorSchemeQuery?.matches ? 'dark' : 'light';
6748
6948
  }
6749
6949
  themeMenuItems() {
@@ -6906,11 +7106,14 @@ export class MdzipWorkspaceView {
6906
7106
  }
6907
7107
  return [
6908
7108
  { action: 'new-file', label: 'New .md File' },
6909
- { action: 'new-folder', label: 'New Folder' }
7109
+ { action: 'new-folder', label: 'New Folder' },
7110
+ null,
7111
+ { action: 'add-files', label: 'Add Files…' }
6910
7112
  ];
6911
7113
  }
7114
+ const hostItems = this.fileActionItems(target);
6912
7115
  if (target.isManifest) {
6913
- return [{ action: 'download', label: 'Download' }];
7116
+ return [...hostItems, ...(hostItems.length ? [null] : []), { action: 'download', label: 'Download' }];
6914
7117
  }
6915
7118
  const groups = [];
6916
7119
  if (canMutate) {
@@ -6942,6 +7145,9 @@ export class MdzipWorkspaceView {
6942
7145
  }
6943
7146
  groups.push(editGroup);
6944
7147
  }
7148
+ if (hostItems.length > 0) {
7149
+ groups.push(hostItems);
7150
+ }
6945
7151
  groups.push([{ action: 'download', label: 'Download' }]);
6946
7152
  if (canMutate && !target.isEntryPoint) {
6947
7153
  groups.push([{
@@ -6951,6 +7157,13 @@ export class MdzipWorkspaceView {
6951
7157
  }
6952
7158
  return groups.flatMap((group, index) => index === 0 ? group : [null, ...group]);
6953
7159
  }
7160
+ /** Menu items for the host's {@link MdzipFileAction}s that apply to this file. */
7161
+ fileActionItems(target) {
7162
+ const file = fileActionTarget(target);
7163
+ return (this.options.hostFileActions ?? [])
7164
+ .filter((item) => item.appliesTo?.(file) ?? true)
7165
+ .map((item) => ({ action: `${HOST_FILE_ACTION_PREFIX}${item.id}`, label: item.label }));
7166
+ }
6954
7167
  async handleNavMenuAction(action) {
6955
7168
  const state = this.contextMenuState;
6956
7169
  this.contextMenuState = null;
@@ -6971,6 +7184,12 @@ export class MdzipWorkspaceView {
6971
7184
  this.openNameDialog({ mode: 'new-folder', dir: target.path, value: 'new-folder' });
6972
7185
  }
6973
7186
  break;
7187
+ case 'add-files':
7188
+ if (target.kind === 'directory') {
7189
+ this.pendingAddFilesDir = target.path;
7190
+ this.elAddFilesInput.click();
7191
+ }
7192
+ break;
6974
7193
  case 'rename':
6975
7194
  if (target.kind === 'file') {
6976
7195
  this.openNameDialog({ mode: 'rename', oldPath: target.path, value: target.path });
@@ -7025,6 +7244,11 @@ export class MdzipWorkspaceView {
7025
7244
  }
7026
7245
  break;
7027
7246
  default:
7247
+ if (action.startsWith(HOST_FILE_ACTION_PREFIX) && target.kind === 'file') {
7248
+ const id = action.slice(HOST_FILE_ACTION_PREFIX.length);
7249
+ const item = (this.options.hostFileActions ?? []).find((candidate) => candidate.id === id);
7250
+ await item?.run(fileActionTarget(target));
7251
+ }
7028
7252
  break;
7029
7253
  }
7030
7254
  }
@@ -7277,7 +7501,8 @@ export class MdzipWorkspaceView {
7277
7501
  }
7278
7502
  const internal = this.dragSourcePath !== null
7279
7503
  || (e.dataTransfer?.types.includes('application/x-mdzip-path') ?? false);
7280
- const external = e.dataTransfer?.types.includes('Files') ?? false;
7504
+ const external = (e.dataTransfer?.types.includes('Files') ?? false)
7505
+ || (this.options.resolveDroppedUris !== undefined && droppedUriListType(e.dataTransfer) !== null);
7281
7506
  if (!internal && !external) {
7282
7507
  return;
7283
7508
  }
@@ -7312,6 +7537,15 @@ export class MdzipWorkspaceView {
7312
7537
  if (files && files.length > 0) {
7313
7538
  e.preventDefault();
7314
7539
  void this.addDroppedFiles(targetDir, Array.from(files));
7540
+ return;
7541
+ }
7542
+ const uriListType = droppedUriListType(e.dataTransfer);
7543
+ const resolveUris = this.options.resolveDroppedUris;
7544
+ if (uriListType && resolveUris) {
7545
+ e.preventDefault();
7546
+ const uris = parseUriList(e.dataTransfer?.getData(uriListType) ?? '');
7547
+ if (uris.length > 0)
7548
+ void this.addDroppedUris(targetDir, uris, resolveUris);
7315
7549
  }
7316
7550
  });
7317
7551
  }
@@ -7337,6 +7571,19 @@ export class MdzipWorkspaceView {
7337
7571
  this.options.onFailed?.(error);
7338
7572
  }
7339
7573
  }
7574
+ async addDroppedUris(targetDir, uris, resolveUris) {
7575
+ try {
7576
+ const resolved = await resolveUris(uris);
7577
+ // `File` here is an imported icon; take the browser's class from the page's window.
7578
+ const FileClass = this.elPreviewContent.ownerDocument.defaultView?.File ?? globalThis.File;
7579
+ const files = resolved.map(({ name, bytes }) => new FileClass([bytes], name));
7580
+ if (files.length > 0)
7581
+ await this.addDroppedFiles(targetDir, files);
7582
+ }
7583
+ catch (error) {
7584
+ this.options.onFailed?.(error);
7585
+ }
7586
+ }
7340
7587
  async addDroppedFiles(targetDir, files) {
7341
7588
  const workspace = this.workspace;
7342
7589
  if (!workspace) {
@@ -8038,6 +8285,7 @@ const SHELL_HTML = `
8038
8285
  </div>
8039
8286
 
8040
8287
  <input type="file" data-ref="replace-input" hidden />
8288
+ <input type="file" data-ref="add-files-input" multiple hidden />
8041
8289
 
8042
8290
  <div class="nav-context-menu" data-ref="nav-menu" hidden role="menu"></div>
8043
8291