@mdzip/editor 2.2.0 → 2.4.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,17 @@ 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;
695
+ // A raw draw.io diagram (.drawio, .dio). The preview can't draw these yet (#59),
696
+ // so an image pointing at one shows a placeholder card instead of a broken
697
+ // image. `.drawio.svg` and `.drawio.png` end in .svg/.png and stay images.
698
+ const DRAWIO_PATH_RE = /\.(?:drawio|dio)$/i;
699
+ const DRAWIO_PLACEHOLDER_CLASS = 'mdzip-drawio-placeholder';
665
700
  // Preview chrome the editor adds around rendered content: removed, or
666
701
  // unwrapped (children kept), when the selection is copied as HTML.
667
702
  const PREVIEW_CHROME_REMOVE_SELECTOR = [
@@ -670,8 +705,9 @@ const PREVIEW_CHROME_REMOVE_SELECTOR = [
670
705
  '[data-mdzip-image-edit-affordance]'
671
706
  ].join(',');
672
707
  // 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`;
708
+ // text isn't document text (a drawn diagram's SVG carries its stylesheet, the
709
+ // draw.io placeholder's explanation is the editor's, not the document's).
710
+ const PLAIN_TEXT_SKIP_SELECTOR = `${PREVIEW_CHROME_REMOVE_SELECTOR},style,script,template,svg,button,.${DRAWIO_PLACEHOLDER_CLASS}`;
675
711
  /**
676
712
  * The text of rendered preview markup for a plain-text copy: the document's
677
713
  * own text, without style or script contents, drawn diagrams or the editor's
@@ -1163,6 +1199,7 @@ export class MdzipWorkspaceView {
1163
1199
  // matches() does not re-run on every snapshot render of the same entry.
1164
1200
  this.entryMatchMissKey = null;
1165
1201
  this.entryGeneration = 0;
1202
+ this.pendingAddFilesDir = null;
1166
1203
  this.options = options;
1167
1204
  this.controlPolicy = resolveMdzipControlPolicy(options.controls);
1168
1205
  this.navigationMode = options.navigationMode ?? 'editor';
@@ -1274,6 +1311,7 @@ export class MdzipWorkspaceView {
1274
1311
  this.elDeleteDialogText = q('[data-ref="delete-dialog-text"]');
1275
1312
  this.elDeleteConfirmBtn = q('[data-ref="delete-confirm-btn"]');
1276
1313
  this.elReplaceInput = q('[data-ref="replace-input"]');
1314
+ this.elAddFilesInput = q('[data-ref="add-files-input"]');
1277
1315
  this.elTooltip = q('[data-ref="tooltip"]');
1278
1316
  this.elCopyToast = q('[data-ref="copy-toast"]');
1279
1317
  this.elEmptyState = q('[data-ref="empty-state"]');
@@ -1745,6 +1783,10 @@ export class MdzipWorkspaceView {
1745
1783
  this.applyExternalLinkPolicy(this.elPreviewContent);
1746
1784
  }
1747
1785
  applyExternalLinkPolicy(root) {
1786
+ for (const host of Array.from(root.querySelectorAll('.mdzip-inline-svg'))) {
1787
+ if (host.shadowRoot)
1788
+ this.applyExternalLinkPolicy(host.shadowRoot);
1789
+ }
1748
1790
  const policy = this.options.externalLinks;
1749
1791
  for (const link of Array.from(root.querySelectorAll('a[href]'))) {
1750
1792
  const original = this.externalLinkOriginals.get(link);
@@ -2086,7 +2128,7 @@ export class MdzipWorkspaceView {
2086
2128
  * has resolved and had its final `src` assigned (or there are none).
2087
2129
  */
2088
2130
  mountProgressivePreview(html, snapshot, context, generation, animateImageHydration) {
2089
- this.elPreviewContent.innerHTML = html;
2131
+ this.elPreviewContent.replaceChildren(this.inertPreviewFragment(html));
2090
2132
  // Cheap pass over every <img> happens before extensions/code-block
2091
2133
  // controls mount, same relative order as always — see
2092
2134
  // collectPendingImages for why it's split from the (expensive) slot
@@ -2105,9 +2147,9 @@ export class MdzipWorkspaceView {
2105
2147
  }
2106
2148
  /**
2107
2149
  * Cheap, attribute-only pass over every `<img>` under `roots`: no DOM tree
2108
- * mutation. Archive-relative sources have their `src` stripped here
2109
- * (immediately, so the browser never fires a bad network request for the
2110
- * archive-relative path) and are returned for {@link hydrateImages};
2150
+ * mutation. Archive-relative sources (already moved off `src` by
2151
+ * {@link inertPreviewFragment}, so the browser never requested them) are
2152
+ * returned for {@link hydrateImages};
2111
2153
  * external/data/fragment sources are left untouched (just get their align
2112
2154
  * class, if any — they never get a slot). Returns `[]` without touching
2113
2155
  * anything when there's no asset session to resolve archive images
@@ -2124,6 +2166,130 @@ export class MdzipWorkspaceView {
2124
2166
  * the image, since a fresh `<img>` element never carries over its
2125
2167
  * predecessor's already-resolved `src`.
2126
2168
  */
2169
+ /**
2170
+ * Parses preview HTML without loading anything: a `<template>`'s content is
2171
+ * inert, whereas `innerHTML` on any element of a live document (attached or
2172
+ * not) starts fetching every `<img src>` at once, resolved against the host
2173
+ * page's URL. Archive-relative sources move to a holding attribute for
2174
+ * {@link collectPendingImages}; with no asset session to resolve them
2175
+ * against, they're left as they are (that pass then skips them too).
2176
+ */
2177
+ inertPreviewFragment(html) {
2178
+ const template = this.elPreviewContent.ownerDocument.createElement('template');
2179
+ template.innerHTML = html;
2180
+ if (this.assetSession) {
2181
+ for (const image of Array.from(template.content.querySelectorAll('img[src]'))) {
2182
+ const source = image.getAttribute('src');
2183
+ if (!source || NON_ARCHIVE_IMAGE_SOURCE_RE.test(source))
2184
+ continue;
2185
+ image.setAttribute(PREVIEW_IMAGE_PENDING_ATTRIBUTE, source);
2186
+ image.removeAttribute('src');
2187
+ }
2188
+ }
2189
+ return template.content;
2190
+ }
2191
+ inlineSvgLinksEnabled() {
2192
+ return this.options.inlineSvgLinks !== false;
2193
+ }
2194
+ /**
2195
+ * Replaces `image` with a shadow-root host holding a copy of the cleaned
2196
+ * `svg` (see `svg-inline.ts`). The host carries the image's alt text,
2197
+ * size, alignment and source path, so the layout matches the `<img>`.
2198
+ */
2199
+ mountInlineSvg(image, svg) {
2200
+ const doc = this.elPreviewContent.ownerDocument;
2201
+ const host = doc.createElement('span');
2202
+ host.className = 'mdzip-inline-svg';
2203
+ for (const name of [PREVIEW_IMAGE_SOURCE_ATTRIBUTE, 'title']) {
2204
+ const value = image.getAttribute(name);
2205
+ if (value !== null)
2206
+ host.setAttribute(name, value);
2207
+ }
2208
+ for (const className of Array.from(image.classList)) {
2209
+ if (className !== 'mdzip-image-loading')
2210
+ host.classList.add(className);
2211
+ }
2212
+ const alignClass = rawHtmlImageAlignClass(image);
2213
+ if (alignClass)
2214
+ host.classList.add(alignClass);
2215
+ const alt = image.getAttribute('alt');
2216
+ host.setAttribute('role', 'group');
2217
+ if (alt)
2218
+ host.setAttribute('aria-label', alt);
2219
+ const width = image.getAttribute('width');
2220
+ if (width && /^\d+(\.\d+)?$/.test(width))
2221
+ host.style.width = `${width}px`;
2222
+ else if (width)
2223
+ host.style.width = width;
2224
+ const shadow = host.attachShadow({ mode: 'open' });
2225
+ const style = doc.createElement('style');
2226
+ style.textContent = INLINE_SVG_SHADOW_CSS + (host.style.width ? 'svg{width:100%}' : '');
2227
+ shadow.append(style, svg.cloneNode(true));
2228
+ this.applyExternalLinkPolicy(shadow);
2229
+ image.replaceWith(host);
2230
+ return host;
2231
+ }
2232
+ /**
2233
+ * Replaces an image of a raw draw.io diagram (`.drawio`, `.dio`) with a card
2234
+ * saying what it is, why the preview can't draw it yet (#59) and how to show
2235
+ * it anyway, plus a button for each host file action that applies to it
2236
+ * (VS Code's "Open in VS Code Editor" opens it in Draw.io Integration). The
2237
+ * card keeps the image's source and alt text, so copying restores the image.
2238
+ */
2239
+ mountDrawioPlaceholder(image, path) {
2240
+ const doc = this.elPreviewContent.ownerDocument;
2241
+ const el = (tag, className, text) => {
2242
+ const node = doc.createElement(tag);
2243
+ if (className)
2244
+ node.className = className;
2245
+ if (text !== undefined)
2246
+ node.textContent = text;
2247
+ return node;
2248
+ };
2249
+ const card = el('span', DRAWIO_PLACEHOLDER_CLASS);
2250
+ card.setAttribute('role', 'note');
2251
+ card.setAttribute(PREVIEW_IMAGE_SOURCE_ATTRIBUTE, image.getAttribute(PREVIEW_IMAGE_SOURCE_ATTRIBUTE) ?? path);
2252
+ const alt = image.getAttribute('alt')?.trim() ?? '';
2253
+ if (alt)
2254
+ card.setAttribute('data-mdzip-alt', alt);
2255
+ const fileName = path.split('/').pop() ?? path;
2256
+ const icon = el('span', `${DRAWIO_PLACEHOLDER_CLASS}-icon`);
2257
+ icon.setAttribute('aria-hidden', 'true');
2258
+ icon.innerHTML = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round">'
2259
+ + '<rect x="8.5" y="2.5" width="7" height="5" rx="1"/><rect x="2.5" y="16.5" width="7" height="5" rx="1"/>'
2260
+ + '<rect x="14.5" y="16.5" width="7" height="5" rx="1"/><path d="M12 7.5v4.5M6 16.5V12h12v4.5"/></svg>';
2261
+ const body = el('span', `${DRAWIO_PLACEHOLDER_CLASS}-body`);
2262
+ body.append(el('strong', `${DRAWIO_PLACEHOLDER_CLASS}-title`, alt || fileName), el('span', `${DRAWIO_PLACEHOLDER_CLASS}-file`, `draw.io diagram · ${path}`));
2263
+ const why = el('span', `${DRAWIO_PLACEHOLDER_CLASS}-text`);
2264
+ why.append(doc.createTextNode('This preview can\'t draw '), el('code', '', '.drawio'), doc.createTextNode(' files yet. To show the diagram here, save it from draw.io as an editable SVG ('), el('code', '', '.drawio.svg'), doc.createTextNode(') and use that file for the image instead. You can still open this file in draw.io to see and edit it.'));
2265
+ body.append(why);
2266
+ const target = { path, isMarkdown: false, isImage: false, isManifest: false, isEntryPoint: false };
2267
+ const actions = (this.options.hostFileActions ?? []).filter((item) => item.appliesTo?.(target) ?? true);
2268
+ if (actions.length) {
2269
+ const row = el('span', `${DRAWIO_PLACEHOLDER_CLASS}-actions`);
2270
+ for (const action of actions) {
2271
+ const button = el('button', `${DRAWIO_PLACEHOLDER_CLASS}-action`, action.label);
2272
+ button.type = 'button';
2273
+ button.addEventListener('click', () => {
2274
+ void Promise.resolve()
2275
+ .then(() => action.run(target))
2276
+ .catch((error) => this.options.onFailed?.(error));
2277
+ });
2278
+ row.append(button);
2279
+ }
2280
+ body.append(row);
2281
+ // The same actions are on the file's right-click menu in the contents
2282
+ // pane, when this host shows one.
2283
+ if (this.controlPolicy.navigation) {
2284
+ const hint = el('span', `${DRAWIO_PLACEHOLDER_CLASS}-hint`);
2285
+ hint.append(doc.createTextNode('Or right-click '), el('code', '', fileName), doc.createTextNode(' in the contents pane.'));
2286
+ body.append(hint);
2287
+ }
2288
+ }
2289
+ card.append(icon, body);
2290
+ image.replaceWith(card);
2291
+ return card;
2292
+ }
2127
2293
  collectPendingImages(roots, context, generation, animateImageHydration) {
2128
2294
  if (!this.assetSession) {
2129
2295
  return [];
@@ -2132,9 +2298,12 @@ export class MdzipWorkspaceView {
2132
2298
  for (const root of roots) {
2133
2299
  for (const image of Array.from(root.querySelectorAll('img'))) {
2134
2300
  applyRawHtmlImageSizeAttributes(image);
2135
- const source = image.getAttribute('src');
2301
+ const held = image.getAttribute(PREVIEW_IMAGE_PENDING_ATTRIBUTE);
2302
+ if (held !== null)
2303
+ image.removeAttribute(PREVIEW_IMAGE_PENDING_ATTRIBUTE);
2304
+ const source = held ?? image.getAttribute('src');
2136
2305
  // Leave external, protocol-relative, data, and fragment URLs untouched.
2137
- if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
2306
+ if (!source || NON_ARCHIVE_IMAGE_SOURCE_RE.test(source)) {
2138
2307
  const alignClass = rawHtmlImageAlignClass(image);
2139
2308
  if (alignClass) {
2140
2309
  image.classList.add(alignClass);
@@ -2143,7 +2312,19 @@ export class MdzipWorkspaceView {
2143
2312
  }
2144
2313
  // The src is replaced by a blob: URL; Copy as HTML restores it from here.
2145
2314
  image.setAttribute(PREVIEW_IMAGE_SOURCE_ATTRIBUTE, source);
2146
- const known = this.assetSession.resolveKnownImage(source, context.currentPath);
2315
+ const drawioPath = this.assetSession.assetPath(source, context.currentPath);
2316
+ if (drawioPath && DRAWIO_PATH_RE.test(drawioPath)) {
2317
+ this.mountDrawioPlaceholder(image, drawioPath);
2318
+ continue;
2319
+ }
2320
+ const inlineSvg = this.inlineSvgLinksEnabled()
2321
+ ? this.assetSession.knownInlineSvg(source, context.currentPath)
2322
+ : null;
2323
+ if (inlineSvg) {
2324
+ this.mountInlineSvg(image, inlineSvg);
2325
+ continue;
2326
+ }
2327
+ const known = inlineSvg === undefined ? undefined : this.assetSession.resolveKnownImage(source, context.currentPath);
2147
2328
  if (known) {
2148
2329
  if (known.width && known.height && !image.hasAttribute('width') && !image.hasAttribute('height')) {
2149
2330
  image.setAttribute('width', String(known.width));
@@ -2213,6 +2394,26 @@ export class MdzipWorkspaceView {
2213
2394
  return slot;
2214
2395
  };
2215
2396
  const hydrate = (image, slot, source) => {
2397
+ if (this.inlineSvgLinksEnabled() && session.knownInlineSvg(source, context.currentPath) !== null) {
2398
+ void session.resolveInlineSvg(source, context.currentPath).then((svg) => {
2399
+ if (generation !== this.previewGeneration || context.signal.aborted) {
2400
+ settle();
2401
+ return;
2402
+ }
2403
+ if (!svg) {
2404
+ hydrateImage(image, slot, source);
2405
+ return;
2406
+ }
2407
+ image.classList.remove('mdzip-image-loading');
2408
+ this.mountInlineSvg(image, svg);
2409
+ this.openImageSlot(slot);
2410
+ settle();
2411
+ });
2412
+ return;
2413
+ }
2414
+ hydrateImage(image, slot, source);
2415
+ };
2416
+ const hydrateImage = (image, slot, source) => {
2216
2417
  void session.resolveImage(source, context.currentPath).then((resolved) => {
2217
2418
  if (generation !== this.previewGeneration || context.signal.aborted) {
2218
2419
  settle();
@@ -2759,7 +2960,7 @@ export class MdzipWorkspaceView {
2759
2960
  const doc = this.elPreviewContent.ownerDocument;
2760
2961
  const wrapper = doc.createElement('div');
2761
2962
  wrapper.className = 'mdzip-chunk';
2762
- wrapper.innerHTML = html;
2963
+ wrapper.appendChild(this.inertPreviewFragment(html));
2763
2964
  if (beforeNode && beforeNode.parentNode === this.elPreviewContent) {
2764
2965
  this.elPreviewContent.insertBefore(wrapper, beforeNode);
2765
2966
  }
@@ -4359,6 +4560,15 @@ export class MdzipWorkspaceView {
4359
4560
  void this.deleteFile(path);
4360
4561
  }
4361
4562
  });
4563
+ this.elAddFilesInput.addEventListener('change', () => {
4564
+ const files = Array.from(this.elAddFilesInput.files ?? []);
4565
+ const dir = this.pendingAddFilesDir;
4566
+ this.elAddFilesInput.value = '';
4567
+ this.pendingAddFilesDir = null;
4568
+ if (files.length > 0 && dir !== null) {
4569
+ void this.addDroppedFiles(dir, files);
4570
+ }
4571
+ });
4362
4572
  this.elReplaceInput.addEventListener('change', () => {
4363
4573
  const file = this.elReplaceInput.files?.[0];
4364
4574
  const path = this.pendingReplacePath;
@@ -4384,7 +4594,9 @@ export class MdzipWorkspaceView {
4384
4594
  this.elPreviewPane.focus({ preventScroll: true });
4385
4595
  });
4386
4596
  this.elPreviewPane.addEventListener('click', (event) => {
4387
- const link = event.target.closest('a[href]');
4597
+ // The event's path, not its target: clicks inside an inline SVG's shadow root are
4598
+ // retargeted to the host, and SVG links are SVGAElements, not HTMLAnchorElements.
4599
+ const link = event.composedPath().find((node) => node.nodeType === 1 && node.localName === 'a' && node.hasAttribute('href')) ?? null;
4388
4600
  const snapshot = this.workspace?.snapshot();
4389
4601
  if (!link || !snapshot) {
4390
4602
  return;
@@ -5802,6 +6014,13 @@ export class MdzipWorkspaceView {
5802
6014
  }
5803
6015
  const container = doc.createElement('div');
5804
6016
  container.appendChild(content);
6017
+ // A draw.io placeholder copies as the image it stands for.
6018
+ for (const card of Array.from(container.querySelectorAll(`.${DRAWIO_PLACEHOLDER_CLASS}`))) {
6019
+ const image = doc.createElement('img');
6020
+ image.setAttribute(PREVIEW_IMAGE_SOURCE_ATTRIBUTE, card.getAttribute(PREVIEW_IMAGE_SOURCE_ATTRIBUTE) ?? '');
6021
+ image.setAttribute('alt', card.getAttribute('data-mdzip-alt') ?? '');
6022
+ card.replaceWith(image);
6023
+ }
5805
6024
  const text = previewPlainText(container);
5806
6025
  for (const element of Array.from(container.querySelectorAll(PREVIEW_CHROME_REMOVE_SELECTOR))) {
5807
6026
  element.remove();
@@ -6966,11 +7185,14 @@ export class MdzipWorkspaceView {
6966
7185
  }
6967
7186
  return [
6968
7187
  { action: 'new-file', label: 'New .md File' },
6969
- { action: 'new-folder', label: 'New Folder' }
7188
+ { action: 'new-folder', label: 'New Folder' },
7189
+ null,
7190
+ { action: 'add-files', label: 'Add Files…' }
6970
7191
  ];
6971
7192
  }
7193
+ const hostItems = this.fileActionItems(target);
6972
7194
  if (target.isManifest) {
6973
- return [{ action: 'download', label: 'Download' }];
7195
+ return [...hostItems, ...(hostItems.length ? [null] : []), { action: 'download', label: 'Download' }];
6974
7196
  }
6975
7197
  const groups = [];
6976
7198
  if (canMutate) {
@@ -7002,6 +7224,9 @@ export class MdzipWorkspaceView {
7002
7224
  }
7003
7225
  groups.push(editGroup);
7004
7226
  }
7227
+ if (hostItems.length > 0) {
7228
+ groups.push(hostItems);
7229
+ }
7005
7230
  groups.push([{ action: 'download', label: 'Download' }]);
7006
7231
  if (canMutate && !target.isEntryPoint) {
7007
7232
  groups.push([{
@@ -7011,6 +7236,13 @@ export class MdzipWorkspaceView {
7011
7236
  }
7012
7237
  return groups.flatMap((group, index) => index === 0 ? group : [null, ...group]);
7013
7238
  }
7239
+ /** Menu items for the host's {@link MdzipFileAction}s that apply to this file. */
7240
+ fileActionItems(target) {
7241
+ const file = fileActionTarget(target);
7242
+ return (this.options.hostFileActions ?? [])
7243
+ .filter((item) => item.appliesTo?.(file) ?? true)
7244
+ .map((item) => ({ action: `${HOST_FILE_ACTION_PREFIX}${item.id}`, label: item.label }));
7245
+ }
7014
7246
  async handleNavMenuAction(action) {
7015
7247
  const state = this.contextMenuState;
7016
7248
  this.contextMenuState = null;
@@ -7031,6 +7263,12 @@ export class MdzipWorkspaceView {
7031
7263
  this.openNameDialog({ mode: 'new-folder', dir: target.path, value: 'new-folder' });
7032
7264
  }
7033
7265
  break;
7266
+ case 'add-files':
7267
+ if (target.kind === 'directory') {
7268
+ this.pendingAddFilesDir = target.path;
7269
+ this.elAddFilesInput.click();
7270
+ }
7271
+ break;
7034
7272
  case 'rename':
7035
7273
  if (target.kind === 'file') {
7036
7274
  this.openNameDialog({ mode: 'rename', oldPath: target.path, value: target.path });
@@ -7085,6 +7323,11 @@ export class MdzipWorkspaceView {
7085
7323
  }
7086
7324
  break;
7087
7325
  default:
7326
+ if (action.startsWith(HOST_FILE_ACTION_PREFIX) && target.kind === 'file') {
7327
+ const id = action.slice(HOST_FILE_ACTION_PREFIX.length);
7328
+ const item = (this.options.hostFileActions ?? []).find((candidate) => candidate.id === id);
7329
+ await item?.run(fileActionTarget(target));
7330
+ }
7088
7331
  break;
7089
7332
  }
7090
7333
  }
@@ -7337,7 +7580,8 @@ export class MdzipWorkspaceView {
7337
7580
  }
7338
7581
  const internal = this.dragSourcePath !== null
7339
7582
  || (e.dataTransfer?.types.includes('application/x-mdzip-path') ?? false);
7340
- const external = e.dataTransfer?.types.includes('Files') ?? false;
7583
+ const external = (e.dataTransfer?.types.includes('Files') ?? false)
7584
+ || (this.options.resolveDroppedUris !== undefined && droppedUriListType(e.dataTransfer) !== null);
7341
7585
  if (!internal && !external) {
7342
7586
  return;
7343
7587
  }
@@ -7372,6 +7616,15 @@ export class MdzipWorkspaceView {
7372
7616
  if (files && files.length > 0) {
7373
7617
  e.preventDefault();
7374
7618
  void this.addDroppedFiles(targetDir, Array.from(files));
7619
+ return;
7620
+ }
7621
+ const uriListType = droppedUriListType(e.dataTransfer);
7622
+ const resolveUris = this.options.resolveDroppedUris;
7623
+ if (uriListType && resolveUris) {
7624
+ e.preventDefault();
7625
+ const uris = parseUriList(e.dataTransfer?.getData(uriListType) ?? '');
7626
+ if (uris.length > 0)
7627
+ void this.addDroppedUris(targetDir, uris, resolveUris);
7375
7628
  }
7376
7629
  });
7377
7630
  }
@@ -7397,6 +7650,19 @@ export class MdzipWorkspaceView {
7397
7650
  this.options.onFailed?.(error);
7398
7651
  }
7399
7652
  }
7653
+ async addDroppedUris(targetDir, uris, resolveUris) {
7654
+ try {
7655
+ const resolved = await resolveUris(uris);
7656
+ // `File` here is an imported icon; take the browser's class from the page's window.
7657
+ const FileClass = this.elPreviewContent.ownerDocument.defaultView?.File ?? globalThis.File;
7658
+ const files = resolved.map(({ name, bytes }) => new FileClass([bytes], name));
7659
+ if (files.length > 0)
7660
+ await this.addDroppedFiles(targetDir, files);
7661
+ }
7662
+ catch (error) {
7663
+ this.options.onFailed?.(error);
7664
+ }
7665
+ }
7400
7666
  async addDroppedFiles(targetDir, files) {
7401
7667
  const workspace = this.workspace;
7402
7668
  if (!workspace) {
@@ -8098,6 +8364,7 @@ const SHELL_HTML = `
8098
8364
  </div>
8099
8365
 
8100
8366
  <input type="file" data-ref="replace-input" hidden />
8367
+ <input type="file" data-ref="add-files-input" multiple hidden />
8101
8368
 
8102
8369
  <div class="nav-context-menu" data-ref="nav-menu" hidden role="menu"></div>
8103
8370