@mdzip/editor 1.4.4 → 1.4.6

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/README.md CHANGED
@@ -1,547 +1,554 @@
1
- [![MDZip logo][mdzip-logo]][mdzip-url]
2
-
3
- [mdzip-logo]: https://raw.githubusercontent.com/mdzip-project/mdzip-editor/main/resources/mdzip-mark.svg
4
- [mdzip-url]: https://mdzip.org
5
-
6
- # @mdzip/editor
7
-
8
- [![npm](https://img.shields.io/npm/v/@mdzip/editor?logo=npm)](https://www.npmjs.com/package/@mdzip/editor)
9
- [![license](https://img.shields.io/npm/l/@mdzip/editor)](https://github.com/mdzip-project/mdzip-editor/blob/main/LICENSE)
10
-
11
- Framework-independent MDZip workspace engine and browser view.
12
-
13
- `@mdzip/editor` provides reusable helpers for opening `.mdz` archives, rendering Markdown previews, editing archive contents, comparing archive inventories, and embedding a configurable MDZip workspace UI.
14
-
15
- ## Install
16
-
17
- ```sh
18
- npm install @mdzip/editor
19
- ```
20
-
21
- ## Basic Usage
22
-
23
- ```ts
24
- import { MdzipWorkspaceView } from '@mdzip/editor';
25
-
26
- const view = new MdzipWorkspaceView(container, {
27
- controls: 'viewer',
28
- initialLayout: 'preview',
29
- onFailed(error) {
30
- console.error(error);
31
- }
32
- });
33
-
34
- await view.open(bytes, {
35
- mode: 'read-only',
36
- fileName: 'document.mdz'
37
- });
38
- ```
39
-
40
- Regular Markdown is also supported:
41
-
42
- ```ts
43
- await view.open(markdownBytes, {
44
- mode: 'editable',
45
- fileName: 'notes.md'
46
- });
47
- ```
48
-
49
- CodeMirror is not initialized until a source or split layout is used. Consumers
50
- that only need archive, rendering, and preview helpers can import from
51
- `@mdzip/editor/preview`; that entry point excludes `MdzipWorkspaceView` and its
52
- CodeMirror dependencies from the module graph.
53
-
54
- The filename normally selects the source format. Pass `sourceFormat:
55
- 'markdown'` or `sourceFormat: 'mdz'` to override detection.
56
-
57
- Normalized `@mdzip/core-js` workspaces can be opened without an initial archive
58
- rebuild:
59
-
60
- ```ts
61
- await view.openWorkspace(workspace, {
62
- mode: 'editable',
63
- fileName: 'document.mdz',
64
- assetSourceId: 'document-etag-or-content-id'
65
- });
66
- ```
67
-
68
- Images are resolved only when referenced by the active document or selected in
69
- the navigator. To reuse resolved bytes across sessions, inject the optional
70
- bounded IndexedDB cache:
71
-
72
- ```ts
73
- import { MdzipIndexedDbAssetCache, MdzipWorkspaceView } from '@mdzip/editor';
74
-
75
- const view = new MdzipWorkspaceView(container, {
76
- assetCache: new MdzipIndexedDbAssetCache({ maxBytes: 64 * 1024 * 1024 })
77
- });
78
- ```
79
-
80
- For `openWorkspace()`, provide a stable `assetSourceId` (or `archiveBytes`) so
81
- cache hits can bypass lazy ZIP readers. Cache and storage failures automatically
82
- fall back to archive reads.
83
-
84
- ## Editor Mode
85
-
86
- ```ts
87
- const view = new MdzipWorkspaceView(container, {
88
- controls: 'standalone-editor',
89
- async onSaved(bytes) {
90
- await persist(bytes);
91
- view.markPersisted();
92
- }
93
- });
94
-
95
- await view.open(bytes, {
96
- mode: 'editable',
97
- fileName: 'document.mdz'
98
- });
99
- ```
100
-
101
- When `onSaved` is omitted, the built-in Save button downloads the current file
102
- in the browser. When `onSaved` is provided, the host owns persistence and must
103
- call `markPersisted()` after a successful write. Failed writes should leave the
104
- workspace dirty.
105
-
106
- ## Control Presets
107
-
108
- - `preview`: clean document preview with no toolbar or package navigation.
109
- - `viewer`: read-only viewer controls, including navigation, layout switching, and zoom.
110
- - `standalone-editor`: full editor controls, including save.
111
- - `hosted-editor`: editor controls without an embedded save button, for hosts such as VS Code that own persistence.
112
-
113
- Call `setControls(nextControls)` to update the policy after construction
114
- without rebuilding the workspace view. `lineNumbers` changes are applied to the
115
- existing CodeMirror editor, preserving the current document and selection.
116
-
117
- ## Density and Spacing
118
-
119
- Hosts can opt into smaller built-in UI without targeting private classes:
120
-
121
- ```ts
122
- const view = new MdzipWorkspaceView(container, {
123
- controls: 'hosted-editor',
124
- toolbarDensity: 'compact', // 'comfortable' | 'compact' | 'dense'
125
- contentDensity: 'compact' // 'comfortable' | 'compact'
126
- });
127
-
128
- view.setDensityOptions({
129
- toolbarDensity: 'dense',
130
- contentDensity: 'compact'
131
- });
132
- ```
133
-
134
- For exact sizing, set stable CSS custom properties on an ancestor of the
135
- workspace:
136
-
137
- ```css
138
- .studio-editor {
139
- --mdzip-toolbar-button-size: 28px;
140
- --mdzip-toolbar-compact-button-size: 26px;
141
- --mdzip-toolbar-icon-size: 14px;
142
- --mdzip-format-button-size: 26px;
143
- --mdzip-format-icon-size: 14px;
144
- --mdzip-toolbar-padding: 2px 8px;
145
- --mdzip-toolbar-gap: 4px;
146
- --mdzip-editor-content-padding: 16px 20px;
147
- --mdzip-preview-content-padding: 16px 20px 24px;
148
- --mdzip-preview-content-max-width: 720px;
149
- }
150
- ```
151
-
152
- ## Preview Width
153
-
154
- `previewMaxWidth` is a developer-facing option, not toolbar UI — it sets the
155
- preview's reading-column width (`--mdzip-preview-content-max-width` under the
156
- hood). A number is an exact pixel value; `'narrow'` / `'default'` / `'wide'`
157
- are convenience aliases for 650 / 900 / 1200px:
158
-
159
- ```ts
160
- const view = new MdzipWorkspaceView(container, {
161
- controls: 'hosted-editor',
162
- previewMaxWidth: 'wide' // or an exact pixel number, e.g. 760
163
- });
164
-
165
- view.setPreviewMaxWidth(720);
166
- ```
167
-
168
- Leaving it unset (the default) keeps the built-in CSS default in effect,
169
- which scales with the zoom control; an explicit value set here (or via the
170
- `--mdzip-preview-content-max-width` CSS variable directly) does not scale
171
- with zoom — it's used exactly as given.
172
-
173
- ## Host Persistence
174
-
175
- Desktop hosts can flush pending editor content, persist the returned bytes, and
176
- only then acknowledge a successful write:
177
-
178
- ```ts
179
- const snapshot = await view.flush();
180
- if (snapshot) {
181
- await nativeSave(snapshot.bytes);
182
- view.markPersisted();
183
- }
184
- ```
185
-
186
- `flush()` deliberately leaves `dirty` set until `markPersisted()` is called.
187
- `serialize()` and `getCurrentSnapshot()` provide non-acknowledging alternatives.
188
-
189
- Structured callbacks are available for workspace, document, asset, manifest,
190
- selection, dirty, validation, and snapshot changes. Asset hosts can also call
191
- `addAsset()`, `replaceAsset()`, `removeAsset()`, and `listAssets()`.
192
-
193
- ## File Management (navigation pane)
194
-
195
- With `controls: 'standalone-editor'` or `'hosted-editor'` (or `fileActions: true`
196
- in a custom policy), the navigation pane offers a right-click context menu: new
197
- `.md` file, new folder, rename/move (edit the full archive path), duplicate,
198
- replace, download, copy markdown link / image embed, set entry point (shown bold
199
- in the tree), set/remove cover image, and delete with a confirmation prompt
200
- (orphaned assets delete immediately). The entry-point document and
201
- `manifest.json` cannot be deleted. Copy and Download remain available in
202
- read-only contexts.
203
-
204
- Drag and drop is supported in all directions: move files between folders, drop
205
- OS files onto the pane to add them as assets, drag a tree file onto the editor
206
- to insert a markdown link or image embed at the pointer, and drop an OS image
207
- onto the editor to embed it like a paste.
208
-
209
- The same operations are available programmatically: `removeFile()`,
210
- `renameFile()` (rewrites markdown references, including a moved document's own
211
- relative links), `setEntryPoint()`, and `setCoverImage()`.
212
-
213
- ## Conversion Hook
214
-
215
- For plain-markdown sources, hosts can take over the markdown→MDZ conversion
216
- flow (triggered by the nav button, Insert Image, or an image paste/drop):
217
-
218
- ```ts
219
- const view = new MdzipWorkspaceView(container, {
220
- async onConversionRequested(action, context) {
221
- // action.kind: 'navigation' | 'image-picker' | 'image-file' (with action.file)
222
- const relativePath = await hostHandlesImage(action);
223
- return relativePath
224
- ? context.insertMarkdown(`![](${relativePath})`)
225
- : false;
226
- }
227
- });
228
- ```
229
-
230
- Returning or resolving `false` (or omitting the callback) keeps the built-in
231
- conversion dialog. Errors thrown by the hook are reported via `onFailed` and
232
- fall back to the built-in dialog. The context preserves the triggering
233
- selection while a host dialog is open; it returns `false` if that document has
234
- changed. `context.convertToMdz()` runs the built-in conversion and image action
235
- against the same captured selection.
236
-
237
- ## Image Insert Hook
238
-
239
- Set `imageInsertMode` to choose the built-in image markup flow:
240
- `'markdown'` keeps the default `![alt](path)` insertion, `'html'` inserts a
241
- default `<img>` element, and `'ask'` opens a small dialog for Markdown vs HTML,
242
- alt text, proportional size, and alignment.
243
-
244
- For host-owned UI, provide `imageInsertHandler`. It receives file metadata,
245
- intrinsic image size when detected, and the source (`'paste'`, `'drop'`, or
246
- `'picker'`). Return `{ mode: 'markdown', altText }` or
247
- `{ mode: 'html', altText, width, height, position }`; return `null` to cancel.
248
- The built-in dialog asks for width, height, or percent scaling and preserves
249
- aspect ratio by emitting one dimension, or proportional dimensions for percent
250
- scaling. Returning `undefined` falls back to `imageInsertMode`.
251
- The built-in HTML path uses portable `align` attributes for positioning because
252
- the default preview sanitizer strips inline `style` attributes.
253
-
254
- `MdzipRenderingService` uses `defaultSafeMarkdownRenderer` when no renderer is
255
- injected. The default renderer sanitizes generated HTML and unsafe URL schemes.
256
-
257
- ## Image Edit Hook
258
-
259
- Editing an *existing* image (Markdown `![]()` or raw HTML `<img>`, including
260
- one wrapped in `<p align="...">`) is fully opt-in — unlike image insertion,
261
- there is no built-in fallback dialog and no `imageEditMode`. Set
262
- `imageEditHandler` to enable it:
263
-
264
- ```ts
265
- const view = new MdzipWorkspaceView(container, {
266
- async imageEditHandler(request) {
267
- // request: { src, altText, width?, height?, position?, mode: 'markdown' | 'html' }
268
- const decision = await hostEditDialog(request);
269
- if (!decision) return null; // cancel — leaves the image untouched
270
- return decision; // same shape as an image-insert decision
271
- }
272
- });
273
- ```
274
-
275
- With no `imageEditHandler` set, clicking an existing image does nothing —
276
- no edit affordance ever appears. With one set, clicking an image reference
277
- in the source editor shows a small edit icon next to it; clicking that icon
278
- calls the handler with the image's current alt/width/height/position parsed
279
- from its existing Markdown or HTML, and rewrites that exact reference in
280
- place with whatever decision is returned (same `{ mode, altText, width,
281
- height, position }` shape `imageInsertHandler` returns). Reference-style
282
- Markdown images (`![alt][label]`) aren't supported.
283
-
284
- ## Pack Files Hook
285
-
286
- Hosts that let a user pick a folder (Electron dialog, VS Code workspace API,
287
- etc.) can hand the collected files straight to the view instead of building
288
- the `.mdz` themselves:
289
-
290
- ```ts
291
- const result = await view.packFilesAsWorkspace(
292
- files, // Array<{ path: string; bytes: Uint8Array }>
293
- { title: 'My Project', fileName: 'project.mdz' }
294
- );
295
- ```
296
-
297
- With zero or one Markdown file among `files`, it packs Document mode
298
- immediately and opens the result in memory — no prompt. With more than one,
299
- it needs a Document-vs-Project mode and entry-point decision. Provide
300
- `onPackRequested` to own that decision:
301
-
302
- ```ts
303
- const view = new MdzipWorkspaceView(container, {
304
- async onPackRequested(request, context) {
305
- // request.markdownFiles, request.suggestedEntryPoint
306
- const decision = await hostPicksModeAndEntryPoint(request);
307
- if (!decision) return false; // fall back to the built-in dialog
308
- await context.applyDecision(decision);
309
- return true;
310
- }
311
- });
312
- ```
313
-
314
- Returning or resolving `false` (or omitting the callback) keeps the built-in
315
- dialog — a mode toggle plus an entry-point picker listing the discovered
316
- Markdown files. Errors thrown by the hook are reported via `onFailed` and
317
- fall back to the built-in dialog. `context.applyDecision({ mode, entryPoint })`
318
- performs the actual pack either way.
319
-
320
- Document mode opens the packed archive in the view (unsaved); Project mode
321
- returns the archive bytes without opening them, since only the host knows
322
- where a project archive should be saved — save it, then `view.open(bytes, ...)`.
323
-
324
- ## Rendering Extensibility
325
-
326
- The view accepts a custom markdown renderer, composable markdown pipeline
327
- extensions, and entry renderers that replace the content area for selected
328
- archive entries — all optional, with built-in behavior as the fallback:
329
-
330
- ```ts
331
- import { mdzipPathMatcher, type MdzipEntryRenderer } from '@mdzip/editor';
332
-
333
- const manifestRenderer: MdzipEntryRenderer = {
334
- id: 'host-manifest-editor',
335
- priority: 100,
336
- matches: mdzipPathMatcher('manifest.json'),
337
- mount: (container, context) => mountManifestEditor(container, {
338
- manifest: context.manifest,
339
- editable: context.mode === 'editable',
340
- onChange: (manifest) => context.updateManifest(manifest)
341
- })
342
- };
343
-
344
- const view = new MdzipWorkspaceView(container, {
345
- markdownRenderer, // optional full renderer replacement
346
- markdownExtensions: [mermaidExt], // transformMarkdown/transformHtml/mount
347
- entryRenderers: [manifestRenderer]
348
- });
349
- ```
350
-
351
- Renderers may be asynchronous; stale results are dropped when the selection
352
- moves on. Sanitization stays in the pipeline: string output from custom
353
- renderers and transform hooks passes through DOMPurify before insertion. Entry
354
- renderer handles are destroyed on selection change and `destroy()`. The same
355
- options are available as inputs/props on the Angular, React, and Vue wrappers.
356
- See the Developer Guide's Rendering Extensibility section for the contracts
357
- and lifecycle rules.
358
-
359
- An extension whose `transformHtml` emits markup the default policy would strip
360
- (inline SVG, for example) declares the narrow relaxations it needs via a
361
- `sanitize` contribution (`MdzipSanitizeContribution`), merged into the single
362
- DOMPurify pass. Keep contributions minimal — they widen the policy for the
363
- whole preview, since the extension output and surrounding markdown HTML are
364
- sanitized together.
365
-
366
- ### Mermaid diagrams
367
-
368
- Render fenced ` ```mermaid ` blocks to inline SVG with the optional extension
369
- from the `@mdzip/editor/mermaid` entrypoint. It is shipped separately so the
370
- ~1MB mermaid library stays out of the core bundle — it is dynamically imported
371
- the first time a document actually contains a mermaid block. Install `mermaid`
372
- (an optional peer dependency) alongside `@mdzip/editor`:
373
-
374
- ```ts
375
- import { mdzipMermaidExtension } from '@mdzip/editor/mermaid';
376
-
377
- const view = new MdzipWorkspaceView(container, {
378
- markdownExtensions: [mdzipMermaidExtension({ theme: 'auto' })]
379
- });
380
- ```
381
-
382
- Diagrams render with mermaid's `strict` security level, each SVG is
383
- re-sanitized before insertion, and an invalid diagram renders as an inline
384
- error block instead of breaking the preview. `theme` defaults to `'auto'`,
385
- following the preview color scheme. Enabling the extension widens the preview's
386
- sanitize policy to allow SVG and inline styles for the whole render. For a
387
- CSP-restricted host, mermaid bundles into the consumer's webview script (no new
388
- `img-src` needs); if you lazy-load its chunk, serve it under the existing
389
- `script-src` nonce.
390
-
391
- ### Front matter
392
-
393
- A leading `---`-delimited YAML block is recognized automatically — no
394
- `markdownExtensions` wiring needed — and no longer falls through to `marked`
395
- as a stray `<hr>` plus paragraph/setext heading. Control how it renders with
396
- `frontMatter`:
397
-
398
- ```ts
399
- const view = new MdzipWorkspaceView(container, {
400
- frontMatter: {
401
- enabled: true, // default; false strips it with nothing rendered in its place
402
- display: 'table', // default; 'raw' shows the YAML source as a syntax-highlighted code block
403
- collapsible: true, // default; false renders a static, always-visible block (no collapse toggle)
404
- label: 'Front matter' // default; a custom string, or the sentinel 'first-line' — see below
405
- }
406
- });
407
- ```
408
-
409
- - `display: 'table'` (default) — a key/value table. `'raw'` — the block's YAML
410
- source as a fenced, syntax-highlighted `yaml` code block. Either way the
411
- panel is wrapped the same: same header, same expand/collapse behavior.
412
- - `collapsible: false` turns off the collapsible `<details>` wrapper,
413
- rendering a static block with the same header and body instead.
414
- - `label` sets the header text — a fixed string (`'Front Matter'`, `'Metadata'`,
415
- etc.), or the sentinel `'first-line'` to use the block's own first raw YAML
416
- line instead (e.g. `title: My Document`), falling back to the default label
417
- when that line is blank.
418
- - `enabled: false` strips the block with nothing rendered in its place.
419
-
420
- `setRenderingOptions({ frontMatter })` updates it live. A manifest-less
421
- `.md` file's front matter `title:` also feeds the title-fallback chain
422
- (`suggestedTitleFromMarkdown`), ahead of the first heading and filename. The
423
- parser itself (`parseFrontMatter`, DOM-free) is exported from the package
424
- root for hosts that want the raw data without the render extension.
425
-
426
- ## Developer Guide
427
-
428
- See the [Developer Guide](https://github.com/mdzip-project/mdzip-editor/blob/main/docs/developer-guide.md)
429
- for granular host controls, height requirements, lifecycle events, persistence,
430
- theming, and Raw, Angular, React, and Vue examples.
431
-
432
- See the [Theming Guide](https://github.com/mdzip-project/mdzip-editor/blob/main/docs/theming.md)
433
- for custom theme examples and the complete CSS variable reference.
434
-
435
- ## Archive Helpers
436
-
437
- ```ts
438
- import {
439
- openMdzArchive,
440
- readCanonicalMarkdown,
441
- createArchiveInventory,
442
- diffArchiveInventories
443
- } from '@mdzip/editor';
444
- ```
445
-
446
- These helpers are built on `@mdzip/core-js` and are suitable for framework wrappers, desktop hosts, browser apps, and extension integrations.
447
-
448
- ## Archive Diff View
449
-
450
- The optional diff entry point keeps merge dependencies out of normal editor
451
- and preview bundles:
452
-
453
- ```ts
454
- import { MdzipDiffView } from '@mdzip/editor/diff-view';
455
-
456
- const diff = new MdzipDiffView(container, {
457
- before: { bytes: baseBytes, label: 'Git base' },
458
- after: { bytes: workingBytes, label: 'Working tree' },
459
- initialPath: 'index.md',
460
- toolbarActions: [{
461
- id: 'refresh',
462
- label: 'Refresh comparison',
463
- icon: 'refresh',
464
- run: refreshComparison
465
- }]
466
- });
467
- ```
468
-
469
- Construction opens the initial comparison. Use `await diff.open(options)` to
470
- replace both sides, `openPath(path)` to select an entry, and
471
- `setShowUnchanged(false)` to focus the tree on changes. Toolbar actions may be
472
- updated with `setToolbarActions()` without reopening either archive.
473
-
474
- The library-owned toolbar provides a navigation toggle, Previous/Next change
475
- buttons, and a Show-unchanged toggle. Drive change traversal in code with
476
- `openPreviousChange()` / `openNextChange()` (they walk non-unchanged entries
477
- and resolve `false` at the ends). Opt individual built-in controls out with
478
- the `controls` option (`navigation`, `changeTraversal`, `showUnchanged`; all
479
- default on). The navigation pane animates open/closed like the workspace nav
480
- pane.
481
-
482
- The read-only view shows the union of archive paths in a directory tree,
483
- added/removed/changed status, side-by-side text diffs, explicit missing-side
484
- states, image previews with metadata, and binary metadata. Entry content is
485
- loaded only when selected. Call `destroy()` to release editors, listeners, and
486
- image object URLs.
487
-
488
- ## Preview Lifecycle Signals
489
-
490
- To reveal or animate read-only preview content without scraping internal DOM,
491
- use the preview lifecycle signals on `MdzipWorkspaceView`:
492
-
493
- ```ts
494
- const view = new MdzipWorkspaceView(container, {
495
- controls: 'preview',
496
- onPreviewRendered: (snapshot) => { /* preview HTML is mounted */ },
497
- onAssetsHydrated: (snapshot) => { /* its images have finished loading */ }
498
- });
499
-
500
- // Or await the fullest "ready" point (mounted + images hydrated):
501
- await view.whenRendered();
502
- revealContent();
503
- ```
504
-
505
- `whenRendered()` resolves immediately when the latest preview is already
506
- hydrated, and resolves (rather than hanging) if the view is destroyed.
507
-
508
- The preview hydrates images progressively: the text mounts right away with each
509
- archive image in a collapsed slot, so the reader gets a compact, readable text
510
- block first. As each image resolves, its slot eases open to the height reserved
511
- from dimensions sniffed out of the image header — a single slide to the exact
512
- box, so the pixels arrive with no further reflow (and it snaps open instead
513
- under `prefers-reduced-motion`). `onPreviewRendered` fires when the text is
514
- mounted; `onAssetsHydrated` fires once every referenced image has resolved and
515
- its final `src` is assigned.
516
-
517
- In live-editing hosts where the preview re-renders frequently, pass
518
- `imageHydrationAnimation: 'initial'` to keep the first-load reveal but snap
519
- images open on same-document edits. Use `'off'` to disable the loading pulse
520
- and slide-open animation entirely.
521
-
522
- ## Content Security Policy (restricted hosts)
523
-
524
- Archive images resolve to `URL.createObjectURL()` **`blob:` object URLs** (the
525
- view falls back to `data:` URLs only where `createObjectURL` is unavailable,
526
- such as Node). When embedding the editor in a host with a restrictive
527
- Content-Security-Policy — a VS Code webview, a sandboxed iframe, etc. — the
528
- policy's `img-src` **must include `blob:`**, or every archive image renders as a
529
- broken placeholder:
530
-
531
- ```
532
- img-src 'self' <cspSource> blob: data:;
533
- ```
534
-
535
- Including `data:` as well lets the view's built-in recovery path work: if a
536
- `blob:` URL fails to load (for example because `img-src` omits `blob:`), the
537
- view retries that image once with a `data:` URL and, if it still fails, reports
538
- the error through the `onFailed` option instead of failing silently. Listen on
539
- `onFailed` to surface image-load problems during development:
540
-
541
- ```ts
542
- const view = new MdzipWorkspaceView(container, {
543
- onFailed: (error) => console.warn('[mdzip]', error)
544
- });
545
- ```
546
-
547
- Call `destroy()` to revoke the object URLs the view created.
1
+ [![MDZip logo][mdzip-logo]][mdzip-url]
2
+
3
+ [mdzip-logo]: https://raw.githubusercontent.com/mdzip-project/mdzip-editor/main/resources/mdzip-mark.svg
4
+ [mdzip-url]: https://mdzip.org
5
+
6
+ # @mdzip/editor
7
+
8
+ [![npm](https://img.shields.io/npm/v/@mdzip/editor?logo=npm)](https://www.npmjs.com/package/@mdzip/editor)
9
+ [![license](https://img.shields.io/npm/l/@mdzip/editor)](https://github.com/mdzip-project/mdzip-editor/blob/main/LICENSE)
10
+
11
+ Framework-independent MDZip workspace engine and browser view.
12
+
13
+ `@mdzip/editor` provides reusable helpers for opening `.mdz` archives, rendering Markdown previews, editing archive contents, comparing archive inventories, and embedding a configurable MDZip workspace UI.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ npm install @mdzip/editor
19
+ ```
20
+
21
+ ## Basic Usage
22
+
23
+ ```ts
24
+ import { MdzipWorkspaceView } from '@mdzip/editor';
25
+
26
+ const view = new MdzipWorkspaceView(container, {
27
+ controls: 'viewer',
28
+ initialLayout: 'preview',
29
+ onFailed(error) {
30
+ console.error(error);
31
+ }
32
+ });
33
+
34
+ await view.open(bytes, {
35
+ mode: 'read-only',
36
+ fileName: 'document.mdz'
37
+ });
38
+ ```
39
+
40
+ Regular Markdown is also supported:
41
+
42
+ ```ts
43
+ await view.open(markdownBytes, {
44
+ mode: 'editable',
45
+ fileName: 'notes.md'
46
+ });
47
+ ```
48
+
49
+ CodeMirror is not initialized until a source or split layout is used. Consumers
50
+ that only need archive, rendering, and preview helpers can import from
51
+ `@mdzip/editor/preview`; that entry point excludes `MdzipWorkspaceView` and its
52
+ CodeMirror dependencies from the module graph.
53
+
54
+ The filename normally selects the source format. Pass `sourceFormat:
55
+ 'markdown'` or `sourceFormat: 'mdz'` to override detection.
56
+
57
+ Normalized `@mdzip/core-js` workspaces can be opened without an initial archive
58
+ rebuild:
59
+
60
+ ```ts
61
+ await view.openWorkspace(workspace, {
62
+ mode: 'editable',
63
+ fileName: 'document.mdz',
64
+ assetSourceId: 'document-etag-or-content-id'
65
+ });
66
+ ```
67
+
68
+ Images are resolved only when referenced by the active document or selected in
69
+ the navigator. To reuse resolved bytes across sessions, inject the optional
70
+ bounded IndexedDB cache:
71
+
72
+ ```ts
73
+ import { MdzipIndexedDbAssetCache, MdzipWorkspaceView } from '@mdzip/editor';
74
+
75
+ const view = new MdzipWorkspaceView(container, {
76
+ assetCache: new MdzipIndexedDbAssetCache({ maxBytes: 64 * 1024 * 1024 })
77
+ });
78
+ ```
79
+
80
+ For `openWorkspace()`, provide a stable `assetSourceId` (or `archiveBytes`) so
81
+ cache hits can bypass lazy ZIP readers. Cache and storage failures automatically
82
+ fall back to archive reads.
83
+
84
+ ## Editor Mode
85
+
86
+ ```ts
87
+ const view = new MdzipWorkspaceView(container, {
88
+ controls: 'standalone-editor',
89
+ async onSaved(bytes) {
90
+ await persist(bytes);
91
+ view.markPersisted();
92
+ }
93
+ });
94
+
95
+ await view.open(bytes, {
96
+ mode: 'editable',
97
+ fileName: 'document.mdz'
98
+ });
99
+ ```
100
+
101
+ When `onSaved` is omitted, the built-in Save button downloads the current file
102
+ in the browser. When `onSaved` is provided, the host owns persistence and must
103
+ call `markPersisted()` after a successful write. Failed writes should leave the
104
+ workspace dirty.
105
+
106
+ ## Control Presets
107
+
108
+ - `preview`: clean document preview with no toolbar or package navigation.
109
+ - `viewer`: read-only viewer controls, including navigation, layout switching, and zoom.
110
+ - `standalone-editor`: full editor controls, including save.
111
+ - `hosted-editor`: editor controls without an embedded save button, for hosts such as VS Code that own persistence.
112
+
113
+ Call `setControls(nextControls)` to update the policy after construction
114
+ without rebuilding the workspace view. `lineNumbers` changes are applied to the
115
+ existing CodeMirror editor, preserving the current document and selection.
116
+
117
+ ## Density and Spacing
118
+
119
+ Hosts can opt into smaller built-in UI without targeting private classes:
120
+
121
+ ```ts
122
+ const view = new MdzipWorkspaceView(container, {
123
+ controls: 'hosted-editor',
124
+ toolbarDensity: 'compact', // 'comfortable' | 'compact' | 'dense'
125
+ contentDensity: 'compact' // 'comfortable' | 'compact'
126
+ });
127
+
128
+ view.setDensityOptions({
129
+ toolbarDensity: 'dense',
130
+ contentDensity: 'compact'
131
+ });
132
+ ```
133
+
134
+ For exact sizing, set stable CSS custom properties on an ancestor of the
135
+ workspace:
136
+
137
+ ```css
138
+ .studio-editor {
139
+ --mdzip-toolbar-button-size: 28px;
140
+ --mdzip-toolbar-compact-button-size: 26px;
141
+ --mdzip-toolbar-icon-size: 14px;
142
+ --mdzip-format-button-size: 26px;
143
+ --mdzip-format-icon-size: 14px;
144
+ --mdzip-toolbar-padding: 2px 8px;
145
+ --mdzip-toolbar-gap: 4px;
146
+ --mdzip-editor-content-padding: 16px 20px;
147
+ --mdzip-preview-content-padding: 16px 20px 24px;
148
+ --mdzip-preview-content-max-width: 720px;
149
+ }
150
+ ```
151
+
152
+ ## Preview Width
153
+
154
+ `previewMaxWidth` is a developer-facing option, not toolbar UI — it sets the
155
+ preview's reading-column width (`--mdzip-preview-content-max-width` under the
156
+ hood). A number is an exact pixel value; `'narrow'` / `'default'` / `'wide'`
157
+ are convenience aliases for 650 / 900 / 1200px:
158
+
159
+ ```ts
160
+ const view = new MdzipWorkspaceView(container, {
161
+ controls: 'hosted-editor',
162
+ previewMaxWidth: 'wide' // or an exact pixel number, e.g. 760
163
+ });
164
+
165
+ view.setPreviewMaxWidth(720);
166
+ ```
167
+
168
+ Leaving it unset (the default) keeps the built-in CSS default in effect,
169
+ which scales with the zoom control; an explicit value set here (or via the
170
+ `--mdzip-preview-content-max-width` CSS variable directly) does not scale
171
+ with zoom — it's used exactly as given.
172
+
173
+ ## Host Persistence
174
+
175
+ Desktop hosts can flush pending editor content, persist the returned bytes, and
176
+ only then acknowledge a successful write:
177
+
178
+ ```ts
179
+ const snapshot = await view.flush();
180
+ if (snapshot) {
181
+ await nativeSave(snapshot.bytes);
182
+ view.markPersisted();
183
+ }
184
+ ```
185
+
186
+ `flush()` deliberately leaves `dirty` set until `markPersisted()` is called.
187
+ `serialize()` and `getCurrentSnapshot()` provide non-acknowledging alternatives.
188
+
189
+ Structured callbacks are available for workspace, document, asset, manifest,
190
+ selection, dirty, validation, and snapshot changes. Asset hosts can also call
191
+ `addAsset()`, `replaceAsset()`, `removeAsset()`, and `listAssets()`.
192
+
193
+ ## File Management (navigation pane)
194
+
195
+ With `controls: 'standalone-editor'` or `'hosted-editor'` (or `fileActions: true`
196
+ in a custom policy), the navigation pane offers a right-click context menu: new
197
+ `.md` file, new folder, rename/move (edit the full archive path), duplicate,
198
+ replace, download, copy markdown link / image embed, set entry point (shown bold
199
+ in the tree), set/remove cover image, and delete with a confirmation prompt
200
+ (orphaned assets delete immediately). The entry-point document and
201
+ `manifest.json` cannot be deleted. Copy and Download remain available in
202
+ read-only contexts.
203
+
204
+ Drag and drop is supported in all directions: move files between folders, drop
205
+ OS files onto the pane to add them as assets, drag a tree file onto the editor
206
+ to insert a markdown link or image embed at the pointer, and drop an OS image
207
+ onto the editor to embed it like a paste.
208
+
209
+ The same operations are available programmatically: `removeFile()`,
210
+ `renameFile()` (rewrites markdown references, including a moved document's own
211
+ relative links), `setEntryPoint()`, and `setCoverImage()`.
212
+
213
+ ## Conversion Hook
214
+
215
+ For plain-markdown sources, hosts can take over the markdown→MDZ conversion
216
+ flow (triggered by the nav button, Insert Image, or an image paste/drop):
217
+
218
+ ```ts
219
+ const view = new MdzipWorkspaceView(container, {
220
+ async onConversionRequested(action, context) {
221
+ // action.kind: 'navigation' | 'image-picker' | 'image-file' (with action.file)
222
+ const relativePath = await hostHandlesImage(action);
223
+ return relativePath
224
+ ? context.insertMarkdown(`![](${relativePath})`)
225
+ : false;
226
+ }
227
+ });
228
+ ```
229
+
230
+ Returning or resolving `false` (or omitting the callback) keeps the built-in
231
+ conversion dialog. Errors thrown by the hook are reported via `onFailed` and
232
+ fall back to the built-in dialog. The context preserves the triggering
233
+ selection while a host dialog is open; it returns `false` if that document has
234
+ changed. `context.convertToMdz()` runs the built-in conversion and image action
235
+ against the same captured selection.
236
+
237
+ A host that writes the image file itself (a linked image next to the `.md`) can
238
+ still give the user the same Markdown/HTML, alt text, size and alignment choices
239
+ a `.mdz` paste gets: `await context.promptImageInsert({ bytes, fileName, altText })`
240
+ runs `imageInsertHandler` or the `'ask'` dialog and resolves `null` on cancel
241
+ (write nothing), then `context.formatImageInsert(src, decision)` returns the text
242
+ to pass to `context.insertMarkdown()`. `src` is used as given, so URL-encode it.
243
+
244
+ ## Image Insert Hook
245
+
246
+ Set `imageInsertMode` to choose the built-in image markup flow:
247
+ `'markdown'` keeps the default `![alt](path)` insertion, `'html'` inserts a
248
+ default `<img>` element, and `'ask'` opens a small dialog for Markdown vs HTML,
249
+ alt text, proportional size, and alignment.
250
+
251
+ For host-owned UI, provide `imageInsertHandler`. It receives file metadata,
252
+ intrinsic image size when detected, and the source (`'paste'`, `'drop'`, or
253
+ `'picker'`). Return `{ mode: 'markdown', altText }` or
254
+ `{ mode: 'html', altText, width, height, position }`; return `null` to cancel.
255
+ The built-in dialog asks for width, height, or percent scaling and preserves
256
+ aspect ratio by emitting one dimension, or proportional dimensions for percent
257
+ scaling. Returning `undefined` falls back to `imageInsertMode`.
258
+ The built-in HTML path uses portable `align` attributes for positioning because
259
+ the default preview sanitizer strips inline `style` attributes.
260
+
261
+ `MdzipRenderingService` uses `defaultSafeMarkdownRenderer` when no renderer is
262
+ injected. The default renderer sanitizes generated HTML and unsafe URL schemes.
263
+
264
+ ## Image Edit Hook
265
+
266
+ Editing an *existing* image (Markdown `![]()` or raw HTML `<img>`, including
267
+ one wrapped in `<p align="...">`) is fully opt-in — unlike image insertion,
268
+ there is no built-in fallback dialog and no `imageEditMode`. Set
269
+ `imageEditHandler` to enable it:
270
+
271
+ ```ts
272
+ const view = new MdzipWorkspaceView(container, {
273
+ async imageEditHandler(request) {
274
+ // request: { src, altText, width?, height?, position?, mode: 'markdown' | 'html' }
275
+ const decision = await hostEditDialog(request);
276
+ if (!decision) return null; // cancel — leaves the image untouched
277
+ return decision; // same shape as an image-insert decision
278
+ }
279
+ });
280
+ ```
281
+
282
+ With no `imageEditHandler` set, clicking an existing image does nothing —
283
+ no edit affordance ever appears. With one set, clicking an image reference
284
+ in the source editor shows a small edit icon next to it; clicking that icon
285
+ calls the handler with the image's current alt/width/height/position parsed
286
+ from its existing Markdown or HTML, and rewrites that exact reference in
287
+ place with whatever decision is returned (same `{ mode, altText, width,
288
+ height, position }` shape `imageInsertHandler` returns). Reference-style
289
+ Markdown images (`![alt][label]`) aren't supported.
290
+
291
+ ## Pack Files Hook
292
+
293
+ Hosts that let a user pick a folder (Electron dialog, VS Code workspace API,
294
+ etc.) can hand the collected files straight to the view instead of building
295
+ the `.mdz` themselves:
296
+
297
+ ```ts
298
+ const result = await view.packFilesAsWorkspace(
299
+ files, // Array<{ path: string; bytes: Uint8Array }>
300
+ { title: 'My Project', fileName: 'project.mdz' }
301
+ );
302
+ ```
303
+
304
+ With zero or one Markdown file among `files`, it packs Document mode
305
+ immediately and opens the result in memory — no prompt. With more than one,
306
+ it needs a Document-vs-Project mode and entry-point decision. Provide
307
+ `onPackRequested` to own that decision:
308
+
309
+ ```ts
310
+ const view = new MdzipWorkspaceView(container, {
311
+ async onPackRequested(request, context) {
312
+ // request.markdownFiles, request.suggestedEntryPoint
313
+ const decision = await hostPicksModeAndEntryPoint(request);
314
+ if (!decision) return false; // fall back to the built-in dialog
315
+ await context.applyDecision(decision);
316
+ return true;
317
+ }
318
+ });
319
+ ```
320
+
321
+ Returning or resolving `false` (or omitting the callback) keeps the built-in
322
+ dialog — a mode toggle plus an entry-point picker listing the discovered
323
+ Markdown files. Errors thrown by the hook are reported via `onFailed` and
324
+ fall back to the built-in dialog. `context.applyDecision({ mode, entryPoint })`
325
+ performs the actual pack either way.
326
+
327
+ Document mode opens the packed archive in the view (unsaved); Project mode
328
+ returns the archive bytes without opening them, since only the host knows
329
+ where a project archive should be saved — save it, then `view.open(bytes, ...)`.
330
+
331
+ ## Rendering Extensibility
332
+
333
+ The view accepts a custom markdown renderer, composable markdown pipeline
334
+ extensions, and entry renderers that replace the content area for selected
335
+ archive entries — all optional, with built-in behavior as the fallback:
336
+
337
+ ```ts
338
+ import { mdzipPathMatcher, type MdzipEntryRenderer } from '@mdzip/editor';
339
+
340
+ const manifestRenderer: MdzipEntryRenderer = {
341
+ id: 'host-manifest-editor',
342
+ priority: 100,
343
+ matches: mdzipPathMatcher('manifest.json'),
344
+ mount: (container, context) => mountManifestEditor(container, {
345
+ manifest: context.manifest,
346
+ editable: context.mode === 'editable',
347
+ onChange: (manifest) => context.updateManifest(manifest)
348
+ })
349
+ };
350
+
351
+ const view = new MdzipWorkspaceView(container, {
352
+ markdownRenderer, // optional full renderer replacement
353
+ markdownExtensions: [mermaidExt], // transformMarkdown/transformHtml/mount
354
+ entryRenderers: [manifestRenderer]
355
+ });
356
+ ```
357
+
358
+ Renderers may be asynchronous; stale results are dropped when the selection
359
+ moves on. Sanitization stays in the pipeline: string output from custom
360
+ renderers and transform hooks passes through DOMPurify before insertion. Entry
361
+ renderer handles are destroyed on selection change and `destroy()`. The same
362
+ options are available as inputs/props on the Angular, React, and Vue wrappers.
363
+ See the Developer Guide's Rendering Extensibility section for the contracts
364
+ and lifecycle rules.
365
+
366
+ An extension whose `transformHtml` emits markup the default policy would strip
367
+ (inline SVG, for example) declares the narrow relaxations it needs via a
368
+ `sanitize` contribution (`MdzipSanitizeContribution`), merged into the single
369
+ DOMPurify pass. Keep contributions minimal — they widen the policy for the
370
+ whole preview, since the extension output and surrounding markdown HTML are
371
+ sanitized together.
372
+
373
+ ### Mermaid diagrams
374
+
375
+ Render fenced ` ```mermaid ` blocks to inline SVG with the optional extension
376
+ from the `@mdzip/editor/mermaid` entrypoint. It is shipped separately so the
377
+ ~1MB mermaid library stays out of the core bundle — it is dynamically imported
378
+ the first time a document actually contains a mermaid block. Install `mermaid`
379
+ (an optional peer dependency) alongside `@mdzip/editor`:
380
+
381
+ ```ts
382
+ import { mdzipMermaidExtension } from '@mdzip/editor/mermaid';
383
+
384
+ const view = new MdzipWorkspaceView(container, {
385
+ markdownExtensions: [mdzipMermaidExtension({ theme: 'auto' })]
386
+ });
387
+ ```
388
+
389
+ Diagrams render with mermaid's `strict` security level, each SVG is
390
+ re-sanitized before insertion, and an invalid diagram renders as an inline
391
+ error block instead of breaking the preview. `theme` defaults to `'auto'`,
392
+ following the preview color scheme. Enabling the extension widens the preview's
393
+ sanitize policy to allow SVG and inline styles for the whole render. For a
394
+ CSP-restricted host, mermaid bundles into the consumer's webview script (no new
395
+ `img-src` needs); if you lazy-load its chunk, serve it under the existing
396
+ `script-src` nonce.
397
+
398
+ ### Front matter
399
+
400
+ A leading `---`-delimited YAML block is recognized automatically — no
401
+ `markdownExtensions` wiring needed — and no longer falls through to `marked`
402
+ as a stray `<hr>` plus paragraph/setext heading. Control how it renders with
403
+ `frontMatter`:
404
+
405
+ ```ts
406
+ const view = new MdzipWorkspaceView(container, {
407
+ frontMatter: {
408
+ enabled: true, // default; false strips it with nothing rendered in its place
409
+ display: 'table', // default; 'raw' shows the YAML source as a syntax-highlighted code block
410
+ collapsible: true, // default; false renders a static, always-visible block (no collapse toggle)
411
+ label: 'Front matter' // default; a custom string, or the sentinel 'first-line' — see below
412
+ }
413
+ });
414
+ ```
415
+
416
+ - `display: 'table'` (default) — a key/value table. `'raw'` — the block's YAML
417
+ source as a fenced, syntax-highlighted `yaml` code block. Either way the
418
+ panel is wrapped the same: same header, same expand/collapse behavior.
419
+ - `collapsible: false` turns off the collapsible `<details>` wrapper,
420
+ rendering a static block with the same header and body instead.
421
+ - `label` sets the header text — a fixed string (`'Front Matter'`, `'Metadata'`,
422
+ etc.), or the sentinel `'first-line'` to use the block's own first raw YAML
423
+ line instead (e.g. `title: My Document`), falling back to the default label
424
+ when that line is blank.
425
+ - `enabled: false` strips the block with nothing rendered in its place.
426
+
427
+ `setRenderingOptions({ frontMatter })` updates it live. A manifest-less
428
+ `.md` file's front matter `title:` also feeds the title-fallback chain
429
+ (`suggestedTitleFromMarkdown`), ahead of the first heading and filename. The
430
+ parser itself (`parseFrontMatter`, DOM-free) is exported from the package
431
+ root for hosts that want the raw data without the render extension.
432
+
433
+ ## Developer Guide
434
+
435
+ See the [Developer Guide](https://github.com/mdzip-project/mdzip-editor/blob/main/docs/developer-guide.md)
436
+ for granular host controls, height requirements, lifecycle events, persistence,
437
+ theming, and Raw, Angular, React, and Vue examples.
438
+
439
+ See the [Theming Guide](https://github.com/mdzip-project/mdzip-editor/blob/main/docs/theming.md)
440
+ for custom theme examples and the complete CSS variable reference.
441
+
442
+ ## Archive Helpers
443
+
444
+ ```ts
445
+ import {
446
+ openMdzArchive,
447
+ readCanonicalMarkdown,
448
+ createArchiveInventory,
449
+ diffArchiveInventories
450
+ } from '@mdzip/editor';
451
+ ```
452
+
453
+ These helpers are built on `@mdzip/core-js` and are suitable for framework wrappers, desktop hosts, browser apps, and extension integrations.
454
+
455
+ ## Archive Diff View
456
+
457
+ The optional diff entry point keeps merge dependencies out of normal editor
458
+ and preview bundles:
459
+
460
+ ```ts
461
+ import { MdzipDiffView } from '@mdzip/editor/diff-view';
462
+
463
+ const diff = new MdzipDiffView(container, {
464
+ before: { bytes: baseBytes, label: 'Git base' },
465
+ after: { bytes: workingBytes, label: 'Working tree' },
466
+ initialPath: 'index.md',
467
+ toolbarActions: [{
468
+ id: 'refresh',
469
+ label: 'Refresh comparison',
470
+ icon: 'refresh',
471
+ run: refreshComparison
472
+ }]
473
+ });
474
+ ```
475
+
476
+ Construction opens the initial comparison. Use `await diff.open(options)` to
477
+ replace both sides, `openPath(path)` to select an entry, and
478
+ `setShowUnchanged(false)` to focus the tree on changes. Toolbar actions may be
479
+ updated with `setToolbarActions()` without reopening either archive.
480
+
481
+ The library-owned toolbar provides a navigation toggle, Previous/Next change
482
+ buttons, and a Show-unchanged toggle. Drive change traversal in code with
483
+ `openPreviousChange()` / `openNextChange()` (they walk non-unchanged entries
484
+ and resolve `false` at the ends). Opt individual built-in controls out with
485
+ the `controls` option (`navigation`, `changeTraversal`, `showUnchanged`; all
486
+ default on). The navigation pane animates open/closed like the workspace nav
487
+ pane.
488
+
489
+ The read-only view shows the union of archive paths in a directory tree,
490
+ added/removed/changed status, side-by-side text diffs, explicit missing-side
491
+ states, image previews with metadata, and binary metadata. Entry content is
492
+ loaded only when selected. Call `destroy()` to release editors, listeners, and
493
+ image object URLs.
494
+
495
+ ## Preview Lifecycle Signals
496
+
497
+ To reveal or animate read-only preview content without scraping internal DOM,
498
+ use the preview lifecycle signals on `MdzipWorkspaceView`:
499
+
500
+ ```ts
501
+ const view = new MdzipWorkspaceView(container, {
502
+ controls: 'preview',
503
+ onPreviewRendered: (snapshot) => { /* preview HTML is mounted */ },
504
+ onAssetsHydrated: (snapshot) => { /* its images have finished loading */ }
505
+ });
506
+
507
+ // Or await the fullest "ready" point (mounted + images hydrated):
508
+ await view.whenRendered();
509
+ revealContent();
510
+ ```
511
+
512
+ `whenRendered()` resolves immediately when the latest preview is already
513
+ hydrated, and resolves (rather than hanging) if the view is destroyed.
514
+
515
+ The preview hydrates images progressively: the text mounts right away with each
516
+ archive image in a collapsed slot, so the reader gets a compact, readable text
517
+ block first. As each image resolves, its slot eases open to the height reserved
518
+ from dimensions sniffed out of the image header — a single slide to the exact
519
+ box, so the pixels arrive with no further reflow (and it snaps open instead
520
+ under `prefers-reduced-motion`). `onPreviewRendered` fires when the text is
521
+ mounted; `onAssetsHydrated` fires once every referenced image has resolved and
522
+ its final `src` is assigned.
523
+
524
+ In live-editing hosts where the preview re-renders frequently, pass
525
+ `imageHydrationAnimation: 'initial'` to keep the first-load reveal but snap
526
+ images open on same-document edits. Use `'off'` to disable the loading pulse
527
+ and slide-open animation entirely.
528
+
529
+ ## Content Security Policy (restricted hosts)
530
+
531
+ Archive images resolve to `URL.createObjectURL()` **`blob:` object URLs** (the
532
+ view falls back to `data:` URLs only where `createObjectURL` is unavailable,
533
+ such as Node). When embedding the editor in a host with a restrictive
534
+ Content-Security-Policy — a VS Code webview, a sandboxed iframe, etc. — the
535
+ policy's `img-src` **must include `blob:`**, or every archive image renders as a
536
+ broken placeholder:
537
+
538
+ ```
539
+ img-src 'self' <cspSource> blob: data:;
540
+ ```
541
+
542
+ Including `data:` as well lets the view's built-in recovery path work: if a
543
+ `blob:` URL fails to load (for example because `img-src` omits `blob:`), the
544
+ view retries that image once with a `data:` URL and, if it still fails, reports
545
+ the error through the `onFailed` option instead of failing silently. Listen on
546
+ `onFailed` to surface image-load problems during development:
547
+
548
+ ```ts
549
+ const view = new MdzipWorkspaceView(container, {
550
+ onFailed: (error) => console.warn('[mdzip]', error)
551
+ });
552
+ ```
553
+
554
+ Call `destroy()` to revoke the object URLs the view created.