@mdzip/editor 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +126 -0
  2. package/dist/archive-utils.d.ts +35 -0
  3. package/dist/archive-utils.d.ts.map +1 -0
  4. package/dist/archive-utils.js +148 -0
  5. package/dist/archive-utils.js.map +1 -0
  6. package/dist/browser.d.ts +7 -0
  7. package/dist/browser.d.ts.map +1 -0
  8. package/dist/browser.js +108 -0
  9. package/dist/browser.js.map +1 -0
  10. package/dist/diff.d.ts +36 -0
  11. package/dist/diff.d.ts.map +1 -0
  12. package/dist/diff.js +100 -0
  13. package/dist/diff.js.map +1 -0
  14. package/dist/icons/md-markdown.d.ts +3 -0
  15. package/dist/icons/md-markdown.d.ts.map +1 -0
  16. package/dist/icons/md-markdown.js +35 -0
  17. package/dist/icons/md-markdown.js.map +1 -0
  18. package/dist/index.d.ts +11 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +10 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/metadata.d.ts +9 -0
  23. package/dist/metadata.d.ts.map +1 -0
  24. package/dist/metadata.js +21 -0
  25. package/dist/metadata.js.map +1 -0
  26. package/dist/rendering.d.ts +20 -0
  27. package/dist/rendering.d.ts.map +1 -0
  28. package/dist/rendering.js +83 -0
  29. package/dist/rendering.js.map +1 -0
  30. package/dist/theme.d.ts +14 -0
  31. package/dist/theme.d.ts.map +1 -0
  32. package/dist/theme.js +96 -0
  33. package/dist/theme.js.map +1 -0
  34. package/dist/view-css.d.ts +2 -0
  35. package/dist/view-css.d.ts.map +1 -0
  36. package/dist/view-css.js +1281 -0
  37. package/dist/view-css.js.map +1 -0
  38. package/dist/view.d.ts +237 -0
  39. package/dist/view.d.ts.map +1 -0
  40. package/dist/view.js +1857 -0
  41. package/dist/view.js.map +1 -0
  42. package/dist/workspace-view.d.ts +20 -0
  43. package/dist/workspace-view.d.ts.map +1 -0
  44. package/dist/workspace-view.js +200 -0
  45. package/dist/workspace-view.js.map +1 -0
  46. package/dist/workspace.d.ts +131 -0
  47. package/dist/workspace.d.ts.map +1 -0
  48. package/dist/workspace.js +555 -0
  49. package/dist/workspace.js.map +1 -0
  50. package/package.json +40 -0
package/dist/view.js ADDED
@@ -0,0 +1,1857 @@
1
+ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
2
+ import { markdown } from '@codemirror/lang-markdown';
3
+ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
4
+ import { Compartment, EditorState } from '@codemirror/state';
5
+ import { EditorView, keymap, lineNumbers } from '@codemirror/view';
6
+ import { tags } from '@lezer/highlight';
7
+ import { Bold, ChevronDown, Code, Columns2, Eye, File, FileBraces, FileImage, Folder, FolderOpen, Hash, Heading1, ImagePlus, Info, Italic, Link2Off, Link, List, ListOrdered, Moon, PanelLeft, Quote, Save, SquarePen, Strikethrough, Sun, ZoomIn } from 'lucide';
8
+ import { browserClipboardHasImage, readBrowserClipboardImage } from './browser.js';
9
+ import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
10
+ import { MdzipWorkspaceService } from './workspace.js';
11
+ import { buildMdzipNavTree, canEditMdzipPath, escapeHtml, isOrphanedMdzipAsset, mdzipEntryIconKind, isMdzipManifestPath, resolveMdzipArchiveLinkTarget, renderMdzipPreviewHtml } from './workspace-view.js';
12
+ import { WORKSPACE_CSS } from './view-css.js';
13
+ const STYLE_ATTR = 'data-mdzip-ws-styles';
14
+ const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff?)$/i;
15
+ const isImageFile = (path) => IMAGE_EXTENSIONS.test(path);
16
+ const NAV_ICON_CLASS = 'nav-lucide-icon';
17
+ const TOOLBAR_ICON_CLASS = 'toggle-icon';
18
+ const MANIFEST_ICON_HTML = lucideIcon(FileBraces, NAV_ICON_CLASS);
19
+ const MARKDOWN_ICON_HTML = lucideIcon(MD_MARKDOWN_ICON, NAV_ICON_CLASS);
20
+ const FOLDER_CLOSED_ICON_HTML = lucideIcon(Folder, NAV_ICON_CLASS);
21
+ const FOLDER_OPEN_ICON_HTML = lucideIcon(FolderOpen, NAV_ICON_CLASS);
22
+ const IMAGE_ICON_HTML = lucideIcon(FileImage, NAV_ICON_CLASS);
23
+ const FILE_ICON_HTML = lucideIcon(File, NAV_ICON_CLASS);
24
+ const ORPHAN_ICON_HTML = lucideIcon(Link2Off, '');
25
+ const SOURCE_EDIT_ICON_HTML = lucideIcon(SquarePen, TOOLBAR_ICON_CLASS);
26
+ const SOURCE_MARKDOWN_ICON_HTML = lucideIcon(Hash, TOOLBAR_ICON_CLASS);
27
+ const NAV_TOGGLE_ICON_HTML = lucideIcon(PanelLeft, `${TOOLBAR_ICON_CLASS} nav-toggle-icon`);
28
+ const PREVIEW_ICON_HTML = lucideIcon(Eye, TOOLBAR_ICON_CLASS);
29
+ const SPLIT_ICON_HTML = lucideIcon(Columns2, TOOLBAR_ICON_CLASS);
30
+ const SAVE_ICON_HTML = lucideIcon(Save, TOOLBAR_ICON_CLASS);
31
+ const ZOOM_ICON_HTML = lucideIcon(ZoomIn, TOOLBAR_ICON_CLASS);
32
+ const DARK_THEME_ICON_HTML = lucideIcon(Moon, TOOLBAR_ICON_CLASS);
33
+ const LIGHT_THEME_ICON_HTML = lucideIcon(Sun, TOOLBAR_ICON_CLASS);
34
+ const FORMAT_ICON_CLASS = 'format-icon';
35
+ const BOLD_ICON_HTML = lucideIcon(Bold, FORMAT_ICON_CLASS);
36
+ const ITALIC_ICON_HTML = lucideIcon(Italic, FORMAT_ICON_CLASS);
37
+ const STRIKE_ICON_HTML = lucideIcon(Strikethrough, FORMAT_ICON_CLASS);
38
+ const HEADING_ICON_HTML = lucideIcon(Heading1, FORMAT_ICON_CLASS);
39
+ const BULLET_LIST_ICON_HTML = lucideIcon(List, FORMAT_ICON_CLASS);
40
+ const ORDERED_LIST_ICON_HTML = lucideIcon(ListOrdered, FORMAT_ICON_CLASS);
41
+ const CODE_ICON_HTML = lucideIcon(Code, FORMAT_ICON_CLASS);
42
+ const QUOTE_ICON_HTML = lucideIcon(Quote, FORMAT_ICON_CLASS);
43
+ const LINK_ICON_HTML = lucideIcon(Link, FORMAT_ICON_CLASS);
44
+ const IMAGE_FORMAT_ICON_HTML = lucideIcon(ImagePlus, FORMAT_ICON_CLASS);
45
+ const CHEVRON_ICON_HTML = lucideIcon(ChevronDown, 'format-chevron');
46
+ const INFO_ICON_HTML = lucideIcon(Info, 'document-info-icon');
47
+ const ALL_HEADINGS = [1, 2, 3, 4, 5, 6];
48
+ const ALL_LAYOUT_CONTROLS = {
49
+ source: true,
50
+ split: true,
51
+ preview: true
52
+ };
53
+ const ALL_FORMATTING_CONTROLS = {
54
+ bold: true,
55
+ italic: true,
56
+ strikethrough: true,
57
+ headings: ALL_HEADINGS,
58
+ bulletList: true,
59
+ orderedList: true,
60
+ inlineCode: true,
61
+ codeBlock: true,
62
+ blockquote: true,
63
+ link: true,
64
+ image: true
65
+ };
66
+ const NO_FORMATTING_CONTROLS = {
67
+ bold: false,
68
+ italic: false,
69
+ strikethrough: false,
70
+ headings: [],
71
+ bulletList: false,
72
+ orderedList: false,
73
+ inlineCode: false,
74
+ codeBlock: false,
75
+ blockquote: false,
76
+ link: false,
77
+ image: false
78
+ };
79
+ const CONTROL_PRESETS = {
80
+ preview: {
81
+ preset: 'preview',
82
+ toolbar: false,
83
+ navigation: false,
84
+ title: { visible: false, editable: false },
85
+ layout: { source: false, split: false, preview: false },
86
+ formatting: { ...NO_FORMATTING_CONTROLS },
87
+ lineNumbers: false,
88
+ save: false,
89
+ zoom: false,
90
+ colorScheme: false,
91
+ orphanActions: false
92
+ },
93
+ viewer: {
94
+ preset: 'viewer',
95
+ toolbar: true,
96
+ navigation: true,
97
+ title: { visible: true, editable: false },
98
+ layout: { ...ALL_LAYOUT_CONTROLS },
99
+ formatting: { ...NO_FORMATTING_CONTROLS },
100
+ lineNumbers: true,
101
+ save: false,
102
+ zoom: true,
103
+ colorScheme: true,
104
+ orphanActions: false
105
+ },
106
+ 'standalone-editor': {
107
+ preset: 'standalone-editor',
108
+ toolbar: true,
109
+ navigation: true,
110
+ title: { visible: true, editable: true },
111
+ layout: { ...ALL_LAYOUT_CONTROLS },
112
+ formatting: { ...ALL_FORMATTING_CONTROLS },
113
+ lineNumbers: true,
114
+ save: true,
115
+ zoom: true,
116
+ colorScheme: true,
117
+ orphanActions: true
118
+ },
119
+ 'hosted-editor': {
120
+ preset: 'hosted-editor',
121
+ toolbar: true,
122
+ navigation: true,
123
+ title: { visible: true, editable: true },
124
+ layout: { ...ALL_LAYOUT_CONTROLS },
125
+ formatting: { ...ALL_FORMATTING_CONTROLS },
126
+ lineNumbers: true,
127
+ save: false,
128
+ zoom: true,
129
+ colorScheme: true,
130
+ orphanActions: true
131
+ }
132
+ };
133
+ export function resolveMdzipControlPolicy(controls) {
134
+ if (!controls) {
135
+ return cloneResolvedControlPolicy(CONTROL_PRESETS['standalone-editor']);
136
+ }
137
+ if (typeof controls === 'string') {
138
+ if (controls === 'custom') {
139
+ return {
140
+ ...cloneResolvedControlPolicy(CONTROL_PRESETS['standalone-editor']),
141
+ preset: 'custom'
142
+ };
143
+ }
144
+ return cloneResolvedControlPolicy(CONTROL_PRESETS[controls]);
145
+ }
146
+ const preset = controls.preset ?? 'custom';
147
+ const base = preset === 'custom'
148
+ ? CONTROL_PRESETS['standalone-editor']
149
+ : CONTROL_PRESETS[preset];
150
+ return {
151
+ ...cloneResolvedControlPolicy(base),
152
+ ...controls,
153
+ preset,
154
+ title: resolveTitleControls(base.title, controls.title),
155
+ layout: resolveLayoutControls(base.layout, controls.layout),
156
+ formatting: resolveFormattingControls(base.formatting, controls.formatting)
157
+ };
158
+ }
159
+ function defaultLayoutForPolicy(policy) {
160
+ const preferred = policy.preset === 'viewer'
161
+ ? ['preview', 'split', 'source']
162
+ : ['split', 'source', 'preview'];
163
+ return preferred.find(layout => policy.layout[layout]) ?? 'preview';
164
+ }
165
+ function cloneResolvedControlPolicy(policy) {
166
+ return {
167
+ ...policy,
168
+ title: { ...policy.title },
169
+ layout: { ...policy.layout },
170
+ formatting: {
171
+ ...policy.formatting,
172
+ headings: [...policy.formatting.headings]
173
+ }
174
+ };
175
+ }
176
+ function resolveTitleControls(base, override) {
177
+ if (typeof override === 'boolean') {
178
+ return { visible: override, editable: override && base.editable };
179
+ }
180
+ return { ...base, ...override };
181
+ }
182
+ function resolveLayoutControls(base, override) {
183
+ if (typeof override === 'boolean') {
184
+ return { source: override, split: override, preview: override };
185
+ }
186
+ if (!override) {
187
+ return { ...base };
188
+ }
189
+ const { enabled, ...controls } = override;
190
+ const resolvedBase = enabled === false
191
+ ? { source: false, split: false, preview: false }
192
+ : enabled === true
193
+ ? { ...ALL_LAYOUT_CONTROLS }
194
+ : base;
195
+ return { ...resolvedBase, ...controls };
196
+ }
197
+ function resolveFormattingControls(base, override) {
198
+ if (typeof override === 'boolean') {
199
+ return override
200
+ ? { ...ALL_FORMATTING_CONTROLS, headings: [...ALL_HEADINGS] }
201
+ : { ...NO_FORMATTING_CONTROLS, headings: [] };
202
+ }
203
+ const { enabled, headings, ...controls } = override ?? {};
204
+ const resolvedBase = enabled === false
205
+ ? NO_FORMATTING_CONTROLS
206
+ : enabled === true
207
+ ? ALL_FORMATTING_CONTROLS
208
+ : base;
209
+ return {
210
+ ...resolvedBase,
211
+ ...controls,
212
+ headings: headings === undefined
213
+ ? [...resolvedBase.headings]
214
+ : headings === true
215
+ ? [...ALL_HEADINGS]
216
+ : headings === false
217
+ ? []
218
+ : [...new Set(headings)].filter((level) => ALL_HEADINGS.includes(level)).sort()
219
+ };
220
+ }
221
+ const mdzipEditorTheme = EditorView.theme({
222
+ '&': {
223
+ height: '100%',
224
+ fontSize: 'calc(16px * var(--mdz-zoom, 1))',
225
+ fontFamily: '"Cascadia Code", Consolas, monospace',
226
+ fontVariantLigatures: 'none',
227
+ fontFeatureSettings: '"liga" 0, "calt" 0',
228
+ background: 'var(--mdzip-editor-background-color)',
229
+ },
230
+ '.cm-scroller': {
231
+ lineHeight: '1.5',
232
+ overflow: 'auto',
233
+ },
234
+ '.cm-content': {
235
+ padding: '36px 48px',
236
+ caretColor: 'var(--mdzip-editor-cursor-color)',
237
+ overflowWrap: 'anywhere',
238
+ wordBreak: 'normal',
239
+ },
240
+ '.cm-gutters': {
241
+ background: 'var(--mdzip-widget-background-color)',
242
+ borderRight: '1px solid var(--mdzip-border-color)',
243
+ color: 'var(--mdzip-line-number-foreground-color)',
244
+ fontFamily: '"Cascadia Code", Consolas, monospace',
245
+ minWidth: '52px',
246
+ },
247
+ '.cm-lineNumbers .cm-gutterElement': {
248
+ padding: '0 8px 0 4px',
249
+ minWidth: '44px',
250
+ },
251
+ '&.cm-focused .cm-cursor': {
252
+ borderLeftColor: 'var(--mdzip-editor-cursor-color)',
253
+ },
254
+ '&.cm-focused .cm-selectionBackground': {
255
+ background: 'var(--mdzip-hover-background-color)',
256
+ },
257
+ '.cm-selectionBackground': {
258
+ background: 'var(--mdzip-selection-background-color)',
259
+ },
260
+ '.cm-activeLine': {
261
+ background: 'transparent',
262
+ },
263
+ '.cm-activeLineGutter': {
264
+ background: 'transparent',
265
+ },
266
+ });
267
+ const mdzipMarkdownHighlight = HighlightStyle.define([
268
+ { tag: [tags.heading1, tags.heading2, tags.heading3, tags.heading4, tags.heading5, tags.heading6],
269
+ color: '#c36f00', fontWeight: 'bold' },
270
+ { tag: tags.processingInstruction, color: '#7a5c00', fontWeight: 'bold' },
271
+ { tag: tags.strong, color: '#008b8b', fontWeight: 'bold' },
272
+ { tag: tags.emphasis, color: '#008b8b', fontStyle: 'italic' },
273
+ { tag: tags.strikethrough, color: '#57606a', textDecoration: 'line-through' },
274
+ { tag: tags.link, color: '#0969da' },
275
+ { tag: tags.url, color: '#0969da' },
276
+ { tag: tags.monospace, color: '#8a8f00' },
277
+ { tag: tags.quote, color: '#7a5c00' },
278
+ { tag: tags.contentSeparator, color: '#6a9955' },
279
+ { tag: tags.atom, color: '#d100d1' },
280
+ ]);
281
+ function injectStyles(doc) {
282
+ const existing = doc.querySelector(`style[${STYLE_ATTR}]`);
283
+ if (existing) {
284
+ existing.textContent = WORKSPACE_CSS;
285
+ return;
286
+ }
287
+ const style = doc.createElement('style');
288
+ style.setAttribute(STYLE_ATTR, '');
289
+ style.textContent = WORKSPACE_CSS;
290
+ (doc.head ?? doc.documentElement).appendChild(style);
291
+ }
292
+ function lucideIcon(icon, className) {
293
+ const classAttr = className ? ` class="${escapeHtml(className)}"` : '';
294
+ const children = icon
295
+ .map(([tag, attrs]) => `<${tag}${attributesToHtml(attrs)} />`)
296
+ .join('');
297
+ return `<svg${classAttr} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${children}</svg>`;
298
+ }
299
+ function attributesToHtml(attrs) {
300
+ return Object.entries(attrs)
301
+ .filter((entry) => entry[1] !== undefined)
302
+ .map(([key, value]) => ` ${key}="${escapeHtml(String(value))}"`)
303
+ .join('');
304
+ }
305
+ function renderNavNode(node, state, allowOrphanActions) {
306
+ if (node.entry) {
307
+ const isCurrent = node.entry.path === state.currentPath;
308
+ const isOrphaned = isOrphanedMdzipAsset(node.entry, state);
309
+ const iconKind = mdzipEntryIconKind(node.entry);
310
+ const safePath = escapeHtml(node.entry.path);
311
+ const safeName = escapeHtml(node.name);
312
+ const title = isOrphaned
313
+ ? `${safePath} - not referenced by the entry markdown`
314
+ : safePath;
315
+ const classes = ['nav-file', isCurrent ? 'current-entry' : '', isOrphaned ? 'orphaned-asset' : '']
316
+ .filter(Boolean).join(' ');
317
+ const iconHtml = node.entry.isMarkdown
318
+ ? MARKDOWN_ICON_HTML
319
+ : isMdzipManifestPath(node.entry.path)
320
+ ? MANIFEST_ICON_HTML
321
+ : isImageFile(node.entry.path)
322
+ ? IMAGE_ICON_HTML
323
+ : FILE_ICON_HTML;
324
+ const orphanBtnHtml = isOrphaned && allowOrphanActions ? `
325
+ <span class="nav-orphan-button" role="button" tabindex="0"
326
+ title="Orphaned asset" aria-label="Orphaned asset actions"
327
+ data-orphan-path="${safePath}">
328
+ ${ORPHAN_ICON_HTML}
329
+ </span>` : '';
330
+ return `<button type="button" class="${classes}" title="${title}"
331
+ data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}">
332
+ <span class="nav-caret"></span>
333
+ <span class="nav-file-icon ${iconKind}">${iconHtml}</span>
334
+ ${orphanBtnHtml}
335
+ <span class="nav-label">${safeName}</span>
336
+ </button>`;
337
+ }
338
+ const children = node.children.map(c => renderNavNode(c, state, allowOrphanActions)).join('');
339
+ return `<details class="nav-directory" open>
340
+ <summary>
341
+ <span class="nav-caret" aria-hidden="true"></span>
342
+ <span class="nav-folder-icon closed">${FOLDER_CLOSED_ICON_HTML}</span>
343
+ <span class="nav-folder-icon open">${FOLDER_OPEN_ICON_HTML}</span>
344
+ <span class="nav-label">${escapeHtml(node.name)}</span>
345
+ </summary>
346
+ <div class="nav-directory-children">${children}</div>
347
+ </details>`;
348
+ }
349
+ export class MdzipWorkspaceView {
350
+ constructor(container, options = {}) {
351
+ this.workspace = null;
352
+ this.unsub = null;
353
+ this.pendingOrphanPath = null;
354
+ this.layout = 'split';
355
+ this.navVisible = true;
356
+ this.zoom = 1;
357
+ this.zoomOpen = false;
358
+ this.titleDialogOpen = false;
359
+ this.metadataDialogOpen = false;
360
+ this.titleDraft = '';
361
+ this.conversionAction = null;
362
+ this.navPaneWidth = 280;
363
+ this.splitRatio = 0.5;
364
+ this.resizing = false;
365
+ this.orphanMenuState = null;
366
+ this.tooltipState = null;
367
+ this.tooltipShowTimer = null;
368
+ this.tooltipHideTimer = null;
369
+ this.cmEditor = null;
370
+ this.readOnlyCompartment = new Compartment();
371
+ this.updatingCm = false;
372
+ this.syncing = false;
373
+ this.options = options;
374
+ this.controlPolicy = resolveMdzipControlPolicy(options.controls);
375
+ this.navigationMode = options.navigationMode ?? 'editor';
376
+ this.layout = options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy);
377
+ this.colorScheme = options.initialColorScheme
378
+ ?? (container.ownerDocument.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches
379
+ ? 'dark'
380
+ : 'light');
381
+ this.navVisible = options.navigationButtonActive ?? this.navVisible;
382
+ injectStyles(container.ownerDocument);
383
+ container.replaceChildren();
384
+ container.innerHTML = SHELL_HTML;
385
+ const q = (sel) => container.querySelector(sel);
386
+ this.elRoot = q('.mdzip-root');
387
+ this.elDocumentStrip = q('[data-ref="document-strip"]');
388
+ this.elToolbar = q('[data-ref="toolbar"]');
389
+ this.elToolbarLeft = q('.toolbar-left');
390
+ this.elLayoutControls = q('[data-ref="layout-controls"]');
391
+ this.elToolbarControls = q('[data-ref="toolbar-controls"]');
392
+ this.elNavBtn = q('[data-ref="nav-btn"]');
393
+ this.elTitleBtn = q('[data-ref="title-btn"]');
394
+ this.elDocumentInfoBtn = q('[data-ref="document-info-btn"]');
395
+ this.elPreviewBtn = q('[data-ref="preview-btn"]');
396
+ this.elSplitBtn = q('[data-ref="split-btn"]');
397
+ this.elSourceBtn = q('[data-ref="source-btn"]');
398
+ this.elSourceIcon = q('[data-ref="source-icon"]');
399
+ this.elSaveBtn = q('[data-ref="save-btn"]');
400
+ this.elZoomBtn = q('[data-ref="zoom-btn"]');
401
+ this.elThemeControls = q('[data-ref="theme-controls"]');
402
+ this.elDarkThemeBtn = q('[data-ref="dark-theme-btn"]');
403
+ this.elLightThemeBtn = q('[data-ref="light-theme-btn"]');
404
+ this.elZoomPopover = q('[data-ref="zoom-popover"]');
405
+ this.elZoomLevel = q('[data-ref="zoom-level"]');
406
+ this.elWorkspaceShell = q('[data-ref="workspace-shell"]');
407
+ this.elNavPane = q('[data-ref="nav-pane"]');
408
+ this.elNavResizer = q('[data-ref="nav-resizer"]');
409
+ this.elNavTree = q('[data-ref="nav-tree"]');
410
+ this.elPaneStack = q('[data-ref="pane-stack"]');
411
+ this.elEditPane = q('[data-ref="edit-pane"]');
412
+ this.elEditToolbar = q('[data-ref="edit-toolbar"]');
413
+ this.elImageInput = q('[data-ref="image-input"]');
414
+ this.elEditorHost = q('[data-ref="editor-host"]');
415
+ this.elSplitResizer = q('[data-ref="split-resizer"]');
416
+ this.elPreviewPane = q('[data-ref="preview-pane"]');
417
+ this.elPreviewContent = q('[data-ref="preview-content"]');
418
+ this.elTitleDialog = q('[data-ref="title-dialog"]');
419
+ this.elTitleInput = q('[data-ref="title-input"]');
420
+ this.elTitleValidation = q('[data-ref="title-validation"]');
421
+ this.elTitleSaveBtn = q('[data-ref="title-save-btn"]');
422
+ this.elTitleResetBtn = q('[data-ref="title-reset-btn"]');
423
+ this.elMetadataDialog = q('[data-ref="metadata-dialog"]');
424
+ this.elMetadataList = q('[data-ref="metadata-list"]');
425
+ this.elConversionDialog = q('[data-ref="conversion-dialog"]');
426
+ this.elConversionConfirmBtn = q('[data-ref="conversion-confirm-btn"]');
427
+ this.elOrphanMenu = q('[data-ref="orphan-menu"]');
428
+ this.elTooltip = q('[data-ref="tooltip"]');
429
+ this.elEmptyState = q('[data-ref="empty-state"]');
430
+ this.prepareTooltips();
431
+ this.attachEvents();
432
+ this.render();
433
+ }
434
+ async open(bytes, options = {}) {
435
+ await this.openArchive(bytes, options);
436
+ }
437
+ async openArchive(bytes, options = {}) {
438
+ this.unsub?.();
439
+ this.cmEditor?.destroy();
440
+ this.cmEditor = null;
441
+ this.workspace = null;
442
+ try {
443
+ const ws = await MdzipWorkspaceService.open(bytes, options);
444
+ this.workspace = ws;
445
+ const snap = ws.snapshot();
446
+ if (snap.sourceFormat === 'markdown') {
447
+ this.navVisible = false;
448
+ }
449
+ this.layout = this.validLayoutForSnapshot(this.options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy), snap);
450
+ this.cmEditor = this.createCmEditor(this.elEditorHost, snap.currentText, snap.mode);
451
+ this.unsub = ws.subscribe((event) => {
452
+ this.render();
453
+ void this.notifyChanged(event);
454
+ });
455
+ this.render();
456
+ void this.notifyChanged(this.initialWorkspaceEvent(ws.snapshot()));
457
+ }
458
+ catch (error) {
459
+ this.options.onFailed?.(error);
460
+ }
461
+ }
462
+ async openWorkspace(workspace, options = {}) {
463
+ this.unsub?.();
464
+ this.cmEditor?.destroy();
465
+ this.cmEditor = null;
466
+ this.workspace = null;
467
+ try {
468
+ const ws = await MdzipWorkspaceService.openWorkspace(workspace, options);
469
+ this.workspace = ws;
470
+ const snap = ws.snapshot();
471
+ this.layout = this.validLayoutForSnapshot(this.options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy), snap);
472
+ this.cmEditor = this.createCmEditor(this.elEditorHost, snap.currentText, snap.mode);
473
+ this.unsub = ws.subscribe((event) => {
474
+ this.render();
475
+ void this.notifyChanged(event);
476
+ });
477
+ this.render();
478
+ void this.notifyChanged(this.initialWorkspaceEvent(ws.snapshot()));
479
+ }
480
+ catch (error) {
481
+ this.options.onFailed?.(error);
482
+ }
483
+ }
484
+ async flush() {
485
+ return this.workspace?.flush() ?? null;
486
+ }
487
+ async serialize() {
488
+ return this.workspace?.serialize() ?? null;
489
+ }
490
+ async getCurrentSnapshot() {
491
+ return this.workspace?.getCurrentSnapshot() ?? null;
492
+ }
493
+ markPersisted() {
494
+ this.workspace?.markPersisted();
495
+ }
496
+ async addAsset(archivePath, fileBytes) {
497
+ await this.workspace?.addAsset(archivePath, fileBytes);
498
+ }
499
+ async replaceAsset(archivePath, fileBytes) {
500
+ return this.workspace?.replaceAsset(archivePath, fileBytes) ?? false;
501
+ }
502
+ async removeAsset(archivePath, options) {
503
+ return this.workspace?.removeAsset(archivePath, options) ?? false;
504
+ }
505
+ listAssets() {
506
+ return this.workspace?.listAssets() ?? [];
507
+ }
508
+ canExecuteCommand(command) {
509
+ const snapshot = this.workspace?.snapshot();
510
+ if (!snapshot || !this.cmEditor || snapshot.mode === 'read-only'
511
+ || snapshot.currentPathType !== 'markdown') {
512
+ return false;
513
+ }
514
+ return true;
515
+ }
516
+ async executeCommand(command, file) {
517
+ if (!this.canExecuteCommand(command)) {
518
+ return false;
519
+ }
520
+ if (command === 'insert-image') {
521
+ if (this.workspace?.sourceFormat === 'markdown') {
522
+ this.requestMdzConversion(file
523
+ ? { kind: 'image-file', file }
524
+ : { kind: 'image-picker' });
525
+ return true;
526
+ }
527
+ if (file) {
528
+ await this.insertImageFile(file);
529
+ }
530
+ else {
531
+ this.elImageInput.click();
532
+ }
533
+ return true;
534
+ }
535
+ this.applyMarkdownFormat(command);
536
+ return true;
537
+ }
538
+ async convertToMdz() {
539
+ if (!this.workspace || this.workspace.mode === 'read-only') {
540
+ return false;
541
+ }
542
+ try {
543
+ const converted = await this.workspace.convertToMdz();
544
+ this.render();
545
+ return converted;
546
+ }
547
+ catch (error) {
548
+ this.options.onFailed?.(error);
549
+ return false;
550
+ }
551
+ }
552
+ focus() {
553
+ this.cmEditor?.focus();
554
+ }
555
+ destroy() {
556
+ try {
557
+ this.unsub?.();
558
+ }
559
+ catch {
560
+ // Ignore subscription cleanup errors
561
+ }
562
+ try {
563
+ this.cmEditor?.destroy();
564
+ }
565
+ catch {
566
+ // Ignore CodeMirror cleanup errors
567
+ }
568
+ try {
569
+ this.elRoot?.remove();
570
+ }
571
+ catch {
572
+ // Ignore DOM cleanup errors
573
+ }
574
+ }
575
+ createCmEditor(parent, initialText, mode) {
576
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
577
+ const self = this;
578
+ const state = EditorState.create({
579
+ doc: initialText,
580
+ extensions: [
581
+ ...(this.controlPolicy.lineNumbers ? [lineNumbers()] : []),
582
+ history(),
583
+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
584
+ markdown(),
585
+ syntaxHighlighting(mdzipMarkdownHighlight),
586
+ EditorView.lineWrapping,
587
+ mdzipEditorTheme,
588
+ this.readOnlyCompartment.of(EditorState.readOnly.of(mode === 'read-only')),
589
+ EditorView.updateListener.of((update) => {
590
+ if (update.docChanged && !self.updatingCm) {
591
+ try {
592
+ self.workspace?.editText(update.state.doc.toString());
593
+ }
594
+ catch {
595
+ // read-only rejection; workspace state unchanged
596
+ }
597
+ }
598
+ }),
599
+ EditorView.domEventHandlers({
600
+ paste(event) {
601
+ const clipEvent = event;
602
+ if (browserClipboardHasImage(clipEvent.clipboardData)) {
603
+ event.preventDefault();
604
+ void self.handlePaste(clipEvent);
605
+ return true;
606
+ }
607
+ }
608
+ }),
609
+ ],
610
+ });
611
+ const editor = new EditorView({ state, parent });
612
+ // Attach scroll listener to sync with preview
613
+ const scroller = editor.dom.querySelector('.cm-scroller');
614
+ if (scroller) {
615
+ scroller.addEventListener('scroll', () => self.syncScrollToPreview());
616
+ }
617
+ return editor;
618
+ }
619
+ render() {
620
+ const snapshot = this.workspace?.snapshot() ?? null;
621
+ this.elEmptyState.hidden = snapshot !== null;
622
+ this.elWorkspaceShell.hidden = snapshot === null;
623
+ if (!snapshot) {
624
+ this.elDocumentStrip.hidden = true;
625
+ this.elToolbar.hidden = true;
626
+ return;
627
+ }
628
+ this.layout = this.validLayoutForSnapshot(this.layout, snapshot);
629
+ const canEdit = canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, snapshot.mode);
630
+ const canShowSource = canShowSourceLayout(snapshot);
631
+ const showNavigationControl = this.controlPolicy.navigation;
632
+ const showTitleControl = this.controlPolicy.title.visible;
633
+ const showLayoutControls = Object.values(this.controlPolicy.layout).some(Boolean);
634
+ const showSaveControl = this.controlPolicy.save && snapshot.mode !== 'read-only';
635
+ const showZoomControl = this.controlPolicy.zoom;
636
+ const showColorSchemeControl = this.controlPolicy.colorScheme;
637
+ const showEditControls = canEdit
638
+ && snapshot.currentPathType === 'markdown'
639
+ && this.layout !== 'preview'
640
+ && hasFormattingControls(this.controlPolicy.formatting);
641
+ const showToolbar = this.controlPolicy.toolbar
642
+ && (showNavigationControl || showLayoutControls
643
+ || showSaveControl || showZoomControl || showColorSchemeControl || showEditControls);
644
+ this.elDocumentStrip.hidden = !showTitleControl;
645
+ this.elToolbar.hidden = !showToolbar;
646
+ this.elToolbarLeft.hidden = !showNavigationControl;
647
+ this.elEditToolbar.hidden = !showEditControls;
648
+ this.elLayoutControls.hidden = !showLayoutControls;
649
+ this.elToolbarControls.hidden = !showSaveControl && !showZoomControl && !showColorSchemeControl;
650
+ this.elNavBtn.hidden = !showNavigationControl;
651
+ this.elPreviewBtn.hidden = !this.controlPolicy.layout.preview;
652
+ this.elSplitBtn.hidden = !this.controlPolicy.layout.split;
653
+ this.elSourceBtn.hidden = !this.controlPolicy.layout.source;
654
+ this.elSaveBtn.hidden = !showSaveControl;
655
+ this.elZoomBtn.hidden = !showZoomControl;
656
+ this.elThemeControls.hidden = !showColorSchemeControl;
657
+ this.elRoot.style.setProperty('--mdz-zoom', String(this.zoom));
658
+ this.elRoot.style.setProperty('--nav-pane-width', `${this.navPaneWidth}px`);
659
+ this.elRoot.style.setProperty('--split-edit-ratio', String(this.splitRatio));
660
+ this.elRoot.classList.toggle('resizing', this.resizing);
661
+ this.elRoot.classList.toggle('mdzip-theme-dark', this.colorScheme === 'dark');
662
+ this.elRoot.classList.toggle('mdzip-theme-light', this.colorScheme === 'light');
663
+ let titleContent;
664
+ if (snapshot.sourceFormat === 'markdown') {
665
+ titleContent = escapeHtml(snapshot.fileName);
666
+ }
667
+ else if (snapshot.displayTitle === snapshot.fileName) {
668
+ titleContent = escapeHtml(snapshot.fileName);
669
+ }
670
+ else {
671
+ titleContent = `${escapeHtml(snapshot.displayTitle)}<span class="title-filename"> (${escapeHtml(snapshot.fileName)})</span>`;
672
+ }
673
+ this.elTitleBtn.innerHTML = titleContent;
674
+ this.elTitleBtn.disabled = snapshot.mode === 'read-only'
675
+ || snapshot.sourceFormat === 'markdown'
676
+ || !this.controlPolicy.title.editable;
677
+ this.renderMetadata(snapshot);
678
+ this.elSaveBtn.disabled = snapshot.mode === 'read-only' || !snapshot.dirty;
679
+ this.elSplitBtn.disabled = !this.controlPolicy.layout.split || !canShowSource;
680
+ this.elSourceBtn.disabled = !this.controlPolicy.layout.source || !canShowSource;
681
+ this.renderFormattingControls();
682
+ // Update button labels based on mode
683
+ if (snapshot.mode === 'read-only') {
684
+ this.elSourceBtn.setAttribute('aria-label', 'Raw markdown');
685
+ this.elSourceBtn.dataset['tooltip'] = 'Raw markdown';
686
+ this.elSourceIcon.innerHTML = SOURCE_MARKDOWN_ICON_HTML;
687
+ }
688
+ else {
689
+ this.elSourceBtn.setAttribute('aria-label', 'Edit');
690
+ this.elSourceBtn.dataset['tooltip'] = 'Edit';
691
+ this.elSourceIcon.innerHTML = SOURCE_EDIT_ICON_HTML;
692
+ }
693
+ this.elPreviewBtn.classList.toggle('active', this.layout === 'preview');
694
+ this.elSplitBtn.classList.toggle('active', this.layout === 'split');
695
+ this.elSourceBtn.classList.toggle('active', this.layout === 'source');
696
+ this.elPreviewBtn.setAttribute('aria-pressed', String(this.layout === 'preview'));
697
+ this.elSplitBtn.setAttribute('aria-pressed', String(this.layout === 'split'));
698
+ this.elSourceBtn.setAttribute('aria-pressed', String(this.layout === 'source'));
699
+ this.elNavBtn.classList.toggle('active', this.navVisible);
700
+ this.elNavBtn.setAttribute('aria-pressed', String(this.navVisible));
701
+ this.elZoomBtn.classList.toggle('active', this.zoomOpen);
702
+ this.elDarkThemeBtn.classList.toggle('active', this.colorScheme === 'dark');
703
+ this.elLightThemeBtn.classList.toggle('active', this.colorScheme === 'light');
704
+ this.elDarkThemeBtn.setAttribute('aria-pressed', String(this.colorScheme === 'dark'));
705
+ this.elLightThemeBtn.setAttribute('aria-pressed', String(this.colorScheme === 'light'));
706
+ this.elZoomPopover.hidden = !this.zoomOpen;
707
+ this.elZoomLevel.textContent = `${Math.round(this.zoom * 100)}%`;
708
+ const showNavigationPane = this.controlPolicy.navigation
709
+ && snapshot.sourceFormat === 'mdz'
710
+ && this.navVisible
711
+ && this.navigationMode === 'editor';
712
+ this.elRoot.classList.toggle('navigation-pane-visible', showNavigationPane);
713
+ this.elNavPane.classList.toggle('hidden', !showNavigationPane);
714
+ this.elNavResizer.classList.toggle('hidden', !showNavigationPane);
715
+ const navTree = snapshot.sourceFormat === 'mdz'
716
+ ? buildMdzipNavTree(snapshot.content.paths)
717
+ : [];
718
+ const allowOrphanActions = this.controlPolicy.orphanActions && snapshot.mode !== 'read-only';
719
+ this.elNavTree.innerHTML = navTree.map(n => renderNavNode(n, snapshot, allowOrphanActions)).join('');
720
+ this.prepareTooltips();
721
+ if (this.cmEditor) {
722
+ this.updatingCm = true;
723
+ const current = this.cmEditor.state.doc.toString();
724
+ if (current !== snapshot.currentText) {
725
+ this.cmEditor.dispatch({
726
+ changes: { from: 0, to: current.length, insert: snapshot.currentText }
727
+ });
728
+ }
729
+ this.updatingCm = false;
730
+ this.cmEditor.dispatch({
731
+ effects: this.readOnlyCompartment.reconfigure(EditorState.readOnly.of(snapshot.mode === 'read-only'))
732
+ });
733
+ }
734
+ this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
735
+ const pt = snapshot.currentPathType;
736
+ const showEdit = (pt === 'markdown' || pt === 'text') && this.layout !== 'preview';
737
+ const showPreview = pt === 'image' || pt === 'binary' || pt === 'text'
738
+ || (pt === 'markdown' && this.layout !== 'source');
739
+ this.elEditPane.classList.toggle('active', showEdit);
740
+ this.elPreviewPane.classList.toggle('active', showPreview);
741
+ this.elPaneStack.classList.toggle('split-mode', this.layout === 'split');
742
+ if (!showTitleControl) {
743
+ this.titleDialogOpen = false;
744
+ this.metadataDialogOpen = false;
745
+ }
746
+ this.elTitleDialog.hidden = !this.titleDialogOpen;
747
+ if (this.titleDialogOpen) {
748
+ this.elTitleInput.value = this.titleDraft;
749
+ const valid = this.titleDraft.trim().length > 0;
750
+ this.elTitleValidation.hidden = valid;
751
+ this.elTitleSaveBtn.disabled = !valid;
752
+ }
753
+ this.elConversionDialog.hidden = this.conversionAction === null;
754
+ this.elMetadataDialog.hidden = !this.metadataDialogOpen;
755
+ if (!allowOrphanActions) {
756
+ this.orphanMenuState = null;
757
+ this.pendingOrphanPath = null;
758
+ }
759
+ if (this.orphanMenuState) {
760
+ this.elOrphanMenu.hidden = false;
761
+ this.elOrphanMenu.style.left = `${this.orphanMenuState.x}px`;
762
+ this.elOrphanMenu.style.top = `${this.orphanMenuState.y}px`;
763
+ }
764
+ else {
765
+ this.elOrphanMenu.hidden = true;
766
+ }
767
+ }
768
+ attachEvents() {
769
+ const doc = this.elRoot.ownerDocument;
770
+ doc.addEventListener('click', () => {
771
+ this.closeFormatMenus();
772
+ if (this.zoomOpen || this.orphanMenuState) {
773
+ this.zoomOpen = false;
774
+ this.orphanMenuState = null;
775
+ this.pendingOrphanPath = null;
776
+ this.render();
777
+ }
778
+ });
779
+ this.elNavBtn.addEventListener('click', () => {
780
+ if (!this.controlPolicy.navigation) {
781
+ return;
782
+ }
783
+ const snapshot = this.workspace?.snapshot();
784
+ if (snapshot?.sourceFormat === 'markdown') {
785
+ if (snapshot.mode !== 'read-only') {
786
+ this.requestMdzConversion({ kind: 'navigation' });
787
+ }
788
+ return;
789
+ }
790
+ this.navVisible = !this.navVisible;
791
+ this.render();
792
+ });
793
+ this.elTitleBtn.addEventListener('click', () => {
794
+ const snapshot = this.workspace?.snapshot();
795
+ if (!this.controlPolicy.title.visible || !this.controlPolicy.title.editable
796
+ || !snapshot || snapshot.mode === 'read-only') {
797
+ return;
798
+ }
799
+ this.titleDraft = snapshot.displayTitle;
800
+ this.titleDialogOpen = true;
801
+ this.render();
802
+ requestAnimationFrame(() => this.elTitleInput.select());
803
+ });
804
+ this.elDocumentInfoBtn.addEventListener('click', () => {
805
+ this.metadataDialogOpen = true;
806
+ this.render();
807
+ });
808
+ this.elPreviewBtn.addEventListener('click', () => {
809
+ if (this.controlPolicy.layout.preview) {
810
+ this.setLayout('preview');
811
+ }
812
+ });
813
+ this.elSplitBtn.addEventListener('click', () => {
814
+ if (this.controlPolicy.layout.split) {
815
+ this.setLayout('split');
816
+ }
817
+ });
818
+ this.elSourceBtn.addEventListener('click', () => {
819
+ if (this.controlPolicy.layout.source) {
820
+ this.setLayout('source');
821
+ }
822
+ });
823
+ this.elSaveBtn.addEventListener('click', () => {
824
+ if (this.controlPolicy.save) {
825
+ void this.save();
826
+ }
827
+ });
828
+ this.elZoomBtn.addEventListener('click', (e) => {
829
+ if (!this.controlPolicy.zoom) {
830
+ return;
831
+ }
832
+ e.stopPropagation();
833
+ this.zoomOpen = !this.zoomOpen;
834
+ this.render();
835
+ });
836
+ this.elZoomPopover.addEventListener('click', (e) => e.stopPropagation());
837
+ this.elZoomPopover.querySelector('[data-action="zoom-out"]')
838
+ .addEventListener('click', () => this.setZoom(this.zoom - 0.1));
839
+ this.elZoomPopover.querySelector('[data-action="zoom-in"]')
840
+ .addEventListener('click', () => this.setZoom(this.zoom + 0.1));
841
+ this.elZoomPopover.querySelector('[data-action="zoom-reset"]')
842
+ .addEventListener('click', () => this.setZoom(1));
843
+ this.elDarkThemeBtn.addEventListener('click', () => this.setColorScheme('dark'));
844
+ this.elLightThemeBtn.addEventListener('click', () => this.setColorScheme('light'));
845
+ this.elEditToolbar.addEventListener('click', (event) => {
846
+ const menuToggle = event.target
847
+ .closest('[data-format-menu-toggle]');
848
+ if (menuToggle) {
849
+ event.preventDefault();
850
+ event.stopPropagation();
851
+ this.toggleFormatMenu(menuToggle);
852
+ return;
853
+ }
854
+ const button = event.target.closest('[data-format]');
855
+ if (!button) {
856
+ return;
857
+ }
858
+ event.preventDefault();
859
+ event.stopPropagation();
860
+ this.closeFormatMenus();
861
+ const format = button.dataset['format'] ?? '';
862
+ if (format === 'image') {
863
+ void this.executeCommand('insert-image');
864
+ return;
865
+ }
866
+ const command = markdownCommandFromToolbarFormat(format);
867
+ if (command) {
868
+ void this.executeCommand(command);
869
+ }
870
+ });
871
+ this.elImageInput.addEventListener('change', () => {
872
+ const file = this.elImageInput.files?.[0];
873
+ this.elImageInput.value = '';
874
+ if (file) {
875
+ void this.insertImageFile(file);
876
+ }
877
+ });
878
+ this.elEditToolbar.addEventListener('keydown', (event) => {
879
+ if (event.key === 'Escape') {
880
+ const openToggle = this.elEditToolbar
881
+ .querySelector('[data-format-menu-toggle][aria-expanded="true"]');
882
+ if (!openToggle) {
883
+ return;
884
+ }
885
+ event.preventDefault();
886
+ this.closeFormatMenus();
887
+ openToggle.focus();
888
+ return;
889
+ }
890
+ const item = event.target.closest('[role="menuitem"]');
891
+ if (!item || (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')) {
892
+ return;
893
+ }
894
+ event.preventDefault();
895
+ const items = Array.from(item.closest('[role="menu"]').querySelectorAll('[role="menuitem"]'));
896
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
897
+ items[(items.indexOf(item) + direction + items.length) % items.length]?.focus();
898
+ });
899
+ this.elNavTree.addEventListener('click', (e) => {
900
+ const target = e.target;
901
+ const orphanBtn = target.closest('[data-orphan-path]');
902
+ if (orphanBtn) {
903
+ if (!this.controlPolicy.orphanActions) {
904
+ return;
905
+ }
906
+ e.preventDefault();
907
+ e.stopPropagation();
908
+ this.showOrphanMenu(orphanBtn.getAttribute('data-orphan-path'), e);
909
+ return;
910
+ }
911
+ const navFile = target.closest('[data-nav-path]');
912
+ if (navFile) {
913
+ void this.openPath(navFile.getAttribute('data-nav-path'));
914
+ }
915
+ });
916
+ this.elNavTree.addEventListener('contextmenu', (e) => {
917
+ const navFile = e.target.closest('[data-nav-path]');
918
+ if (this.controlPolicy.orphanActions && navFile?.getAttribute('data-orphan') === 'true') {
919
+ e.preventDefault();
920
+ this.showOrphanMenu(navFile.getAttribute('data-nav-path'), e);
921
+ }
922
+ });
923
+ this.elNavTree.addEventListener('keydown', (e) => {
924
+ if (e.key === 'Enter') {
925
+ const orphanBtn = e.target.closest('[data-orphan-path]');
926
+ if (this.controlPolicy.orphanActions && orphanBtn) {
927
+ this.showOrphanMenu(orphanBtn.getAttribute('data-orphan-path'), e);
928
+ }
929
+ }
930
+ });
931
+ this.elNavResizer.addEventListener('pointerdown', (e) => {
932
+ if (e.button !== 0) {
933
+ return;
934
+ }
935
+ e.preventDefault();
936
+ this.resizing = true;
937
+ this.elRoot.classList.add('resizing');
938
+ const onMove = (me) => {
939
+ const bounds = this.elWorkspaceShell.getBoundingClientRect();
940
+ const max = Math.max(300, Math.floor(bounds.width * 0.6));
941
+ this.navPaneWidth = Math.max(180, Math.min(max, Math.round(me.clientX - bounds.left)));
942
+ this.elRoot.style.setProperty('--nav-pane-width', `${this.navPaneWidth}px`);
943
+ };
944
+ const onUp = () => {
945
+ this.resizing = false;
946
+ this.elRoot.classList.remove('resizing');
947
+ window.removeEventListener('pointermove', onMove);
948
+ };
949
+ window.addEventListener('pointermove', onMove);
950
+ window.addEventListener('pointerup', onUp, { once: true });
951
+ });
952
+ this.elSplitResizer.addEventListener('pointerdown', (e) => {
953
+ if (e.button !== 0 || this.layout !== 'split') {
954
+ return;
955
+ }
956
+ e.preventDefault();
957
+ this.resizing = true;
958
+ this.elRoot.classList.add('resizing');
959
+ const onMove = (me) => {
960
+ const bounds = this.elPaneStack.getBoundingClientRect();
961
+ const ratio = (me.clientX - bounds.left) / Math.max(1, bounds.width);
962
+ this.splitRatio = Math.max(0.2, Math.min(0.8, Math.round(ratio * 10000) / 10000));
963
+ this.elRoot.style.setProperty('--split-edit-ratio', String(this.splitRatio));
964
+ };
965
+ const onUp = () => {
966
+ this.resizing = false;
967
+ this.elRoot.classList.remove('resizing');
968
+ window.removeEventListener('pointermove', onMove);
969
+ };
970
+ window.addEventListener('pointermove', onMove);
971
+ window.addEventListener('pointerup', onUp, { once: true });
972
+ });
973
+ this.elTitleInput.addEventListener('input', () => {
974
+ this.titleDraft = this.elTitleInput.value;
975
+ const valid = this.titleDraft.trim().length > 0;
976
+ this.elTitleValidation.hidden = valid;
977
+ this.elTitleSaveBtn.disabled = !valid;
978
+ });
979
+ this.elTitleInput.addEventListener('keydown', (e) => {
980
+ if (e.key === 'Enter') {
981
+ void this.saveTitle();
982
+ }
983
+ if (e.key === 'Escape') {
984
+ this.titleDialogOpen = false;
985
+ this.render();
986
+ }
987
+ });
988
+ this.elTitleResetBtn.addEventListener('click', () => {
989
+ const snapshot = this.workspace?.snapshot();
990
+ if (snapshot) {
991
+ this.titleDraft = snapshot.suggestedTitle;
992
+ this.elTitleInput.value = this.titleDraft;
993
+ const valid = this.titleDraft.trim().length > 0;
994
+ this.elTitleValidation.hidden = valid;
995
+ this.elTitleSaveBtn.disabled = !valid;
996
+ }
997
+ });
998
+ this.elTitleDialog.querySelector('[data-action="cancel-title"]')
999
+ .addEventListener('click', () => { this.titleDialogOpen = false; this.render(); });
1000
+ this.elTitleSaveBtn.addEventListener('click', () => { void this.saveTitle(); });
1001
+ this.elMetadataDialog.querySelector('[data-action="close-metadata"]')
1002
+ .addEventListener('click', () => {
1003
+ this.metadataDialogOpen = false;
1004
+ this.render();
1005
+ });
1006
+ this.elConversionDialog.querySelector('[data-action="cancel-conversion"]')
1007
+ .addEventListener('click', () => {
1008
+ this.conversionAction = null;
1009
+ this.render();
1010
+ });
1011
+ this.elConversionConfirmBtn.addEventListener('click', () => {
1012
+ void this.confirmMdzConversion();
1013
+ });
1014
+ this.elOrphanMenu.addEventListener('click', (e) => e.stopPropagation());
1015
+ this.elOrphanMenu.querySelector('[data-action="remove-orphan"]')
1016
+ .addEventListener('click', () => {
1017
+ if (this.controlPolicy.orphanActions) {
1018
+ void this.removeOrphan();
1019
+ }
1020
+ });
1021
+ this.elPreviewPane.addEventListener('scroll', () => this.syncScrollFromPreview());
1022
+ this.elPreviewPane.addEventListener('click', (event) => {
1023
+ const link = event.target.closest('a[href]');
1024
+ const snapshot = this.workspace?.snapshot();
1025
+ if (!link || !snapshot) {
1026
+ return;
1027
+ }
1028
+ const targetPath = resolveMdzipArchiveLinkTarget(link.getAttribute('href') ?? '', snapshot.currentPath, snapshot.content.paths);
1029
+ if (!targetPath) {
1030
+ return;
1031
+ }
1032
+ event.preventDefault();
1033
+ void this.openPath(targetPath);
1034
+ });
1035
+ this.elRoot.addEventListener('pointerover', (event) => this.handleTooltipPointer(event));
1036
+ this.elRoot.addEventListener('pointerout', (event) => {
1037
+ const from = event.target?.closest('[data-tooltip]');
1038
+ const to = event.relatedTarget?.closest('[data-tooltip]');
1039
+ if (from && from !== to) {
1040
+ this.hideTooltip();
1041
+ }
1042
+ });
1043
+ this.elRoot.addEventListener('pointerleave', () => this.hideTooltip());
1044
+ this.elRoot.addEventListener('focusin', (event) => {
1045
+ const target = event.target?.closest('[data-tooltip]');
1046
+ if (target) {
1047
+ this.showTooltipForElement(target);
1048
+ }
1049
+ });
1050
+ this.elRoot.addEventListener('focusout', () => this.hideTooltip());
1051
+ this.elRoot.addEventListener('pointerdown', () => this.hideTooltip());
1052
+ this.elRoot.addEventListener('scroll', () => this.hideTooltip(), true);
1053
+ window.addEventListener('blur', () => this.hideTooltip());
1054
+ }
1055
+ prepareTooltips() {
1056
+ this.elRoot.querySelectorAll('[title]').forEach((element) => {
1057
+ const title = element.getAttribute('title');
1058
+ if (!title) {
1059
+ return;
1060
+ }
1061
+ if (!element.dataset['tooltip']) {
1062
+ element.dataset['tooltip'] = title;
1063
+ }
1064
+ element.removeAttribute('title');
1065
+ });
1066
+ }
1067
+ async openPath(path) {
1068
+ if (!this.workspace) {
1069
+ return;
1070
+ }
1071
+ try {
1072
+ const opened = await this.workspace.openPath(path);
1073
+ if (opened) {
1074
+ this.layout = this.validLayoutForSnapshot(this.layout, this.workspace.snapshot());
1075
+ this.render();
1076
+ }
1077
+ }
1078
+ catch (error) {
1079
+ this.options.onFailed?.(error);
1080
+ }
1081
+ }
1082
+ handleTooltipPointer(event) {
1083
+ const target = event.target?.closest('[data-tooltip]');
1084
+ if (!target || target.closest('[hidden]')) {
1085
+ this.hideTooltip();
1086
+ return;
1087
+ }
1088
+ this.scheduleTooltipForElement(target);
1089
+ }
1090
+ showTooltipForElement(element) {
1091
+ this.scheduleTooltipForElement(element, 0);
1092
+ }
1093
+ scheduleTooltipForElement(element, delay = 350) {
1094
+ if (this.tooltipShowTimer) {
1095
+ clearTimeout(this.tooltipShowTimer);
1096
+ }
1097
+ if (this.tooltipHideTimer) {
1098
+ clearTimeout(this.tooltipHideTimer);
1099
+ this.tooltipHideTimer = null;
1100
+ }
1101
+ this.tooltipShowTimer = setTimeout(() => {
1102
+ this.tooltipShowTimer = null;
1103
+ if (!element.isConnected || element.closest('[hidden]')) {
1104
+ return;
1105
+ }
1106
+ this.showTooltipForElementNow(element);
1107
+ }, delay);
1108
+ }
1109
+ showTooltipForElementNow(element) {
1110
+ const rect = element.getBoundingClientRect();
1111
+ this.showTooltip(element.dataset['tooltip'] ?? '', rect.left + rect.width / 2, rect.top);
1112
+ }
1113
+ showTooltip(text, anchorX, anchorY) {
1114
+ if (!text) {
1115
+ this.hideTooltip();
1116
+ return;
1117
+ }
1118
+ if (this.tooltipHideTimer) {
1119
+ clearTimeout(this.tooltipHideTimer);
1120
+ this.tooltipHideTimer = null;
1121
+ }
1122
+ const viewportPadding = 8;
1123
+ const offset = 8;
1124
+ this.elTooltip.textContent = text;
1125
+ this.elTooltip.hidden = false;
1126
+ const rect = this.elTooltip.getBoundingClientRect();
1127
+ const x = Math.max(viewportPadding, Math.min(anchorX - rect.width / 2, window.innerWidth - rect.width - viewportPadding));
1128
+ const y = Math.max(viewportPadding, anchorY - rect.height - offset);
1129
+ this.tooltipState = { text, x, y };
1130
+ this.elTooltip.style.left = `${Math.round(x)}px`;
1131
+ this.elTooltip.style.top = `${Math.round(y)}px`;
1132
+ }
1133
+ hideTooltip() {
1134
+ if (this.tooltipShowTimer) {
1135
+ clearTimeout(this.tooltipShowTimer);
1136
+ this.tooltipShowTimer = null;
1137
+ }
1138
+ if (this.tooltipHideTimer) {
1139
+ clearTimeout(this.tooltipHideTimer);
1140
+ }
1141
+ this.tooltipHideTimer = setTimeout(() => {
1142
+ this.tooltipState = null;
1143
+ this.elTooltip.hidden = true;
1144
+ this.elTooltip.textContent = '';
1145
+ this.tooltipHideTimer = null;
1146
+ }, 40);
1147
+ }
1148
+ async save() {
1149
+ try {
1150
+ const workspace = this.workspace;
1151
+ if (!workspace) {
1152
+ return;
1153
+ }
1154
+ const saved = await workspace.flush();
1155
+ const bytes = new Uint8Array(await saved.bytes.arrayBuffer());
1156
+ const snapshot = workspace.snapshot();
1157
+ if (this.options.onSaved) {
1158
+ this.options.onSaved(bytes, snapshot);
1159
+ }
1160
+ else {
1161
+ this.downloadSavedBlob(saved.bytes, snapshot.fileName);
1162
+ workspace.markPersisted();
1163
+ }
1164
+ this.render();
1165
+ }
1166
+ catch (error) {
1167
+ this.options.onFailed?.(error);
1168
+ }
1169
+ }
1170
+ downloadSavedBlob(blob, fileName) {
1171
+ const doc = this.elRoot.ownerDocument;
1172
+ const urlApi = doc.defaultView?.URL;
1173
+ if (!urlApi?.createObjectURL) {
1174
+ throw new Error('Browser download is unavailable. Provide an onSaved handler to persist the file.');
1175
+ }
1176
+ const url = urlApi.createObjectURL(blob);
1177
+ const anchor = doc.createElement('a');
1178
+ anchor.href = url;
1179
+ anchor.download = fileName;
1180
+ anchor.hidden = true;
1181
+ doc.body.appendChild(anchor);
1182
+ anchor.click();
1183
+ anchor.remove();
1184
+ urlApi.revokeObjectURL(url);
1185
+ }
1186
+ async saveTitle() {
1187
+ const title = this.titleDraft.trim();
1188
+ if (!title) {
1189
+ return;
1190
+ }
1191
+ try {
1192
+ await this.workspace?.setManifestTitle(title);
1193
+ this.titleDialogOpen = false;
1194
+ this.render();
1195
+ }
1196
+ catch (error) {
1197
+ this.options.onFailed?.(error);
1198
+ }
1199
+ }
1200
+ renderMetadata(snapshot) {
1201
+ const manifest = snapshot.content.manifest;
1202
+ const fields = [
1203
+ ['Filename', snapshot.fileName],
1204
+ ['Format', snapshot.sourceFormat === 'mdz' ? 'MDZ package' : 'Markdown'],
1205
+ ['Document title', snapshot.displayTitle],
1206
+ ['First heading', snapshot.headingFallback ?? 'Not found'],
1207
+ ['Created', formatMetadataValue(manifest?.created)],
1208
+ ['Modified', formatMetadataValue(manifest?.modified)],
1209
+ ['Entry point', snapshot.sourceFormat === 'mdz' ? snapshot.content.entryPoint : 'Not applicable']
1210
+ ];
1211
+ this.elMetadataList.replaceChildren(...fields.map(([label, value]) => {
1212
+ const row = this.elRoot.ownerDocument.createElement('div');
1213
+ row.className = 'metadata-row';
1214
+ const term = this.elRoot.ownerDocument.createElement('dt');
1215
+ term.textContent = label;
1216
+ const detail = this.elRoot.ownerDocument.createElement('dd');
1217
+ detail.textContent = value;
1218
+ row.append(term, detail);
1219
+ return row;
1220
+ }));
1221
+ }
1222
+ requestMdzConversion(action) {
1223
+ const snapshot = this.workspace?.snapshot();
1224
+ if (!snapshot || snapshot.mode === 'read-only' || snapshot.sourceFormat !== 'markdown') {
1225
+ return;
1226
+ }
1227
+ this.conversionAction = action;
1228
+ this.render();
1229
+ requestAnimationFrame(() => this.elConversionConfirmBtn.focus());
1230
+ }
1231
+ async confirmMdzConversion() {
1232
+ const action = this.conversionAction;
1233
+ this.conversionAction = null;
1234
+ if (!action || !await this.convertToMdz()) {
1235
+ this.render();
1236
+ return;
1237
+ }
1238
+ if (action.kind === 'navigation') {
1239
+ this.navVisible = true;
1240
+ this.render();
1241
+ return;
1242
+ }
1243
+ if (action.kind === 'image-file') {
1244
+ await this.insertImageFile(action.file);
1245
+ return;
1246
+ }
1247
+ this.elImageInput.click();
1248
+ }
1249
+ async handlePaste(event) {
1250
+ const snapshot = this.workspace?.snapshot();
1251
+ if (!snapshot || snapshot.mode === 'read-only' || snapshot.currentPathType !== 'markdown') {
1252
+ return;
1253
+ }
1254
+ try {
1255
+ const image = await readBrowserClipboardImage(event.clipboardData);
1256
+ if (!image || !this.cmEditor) {
1257
+ return;
1258
+ }
1259
+ await this.insertImageBytes(image.bytes, image.mimeType);
1260
+ }
1261
+ catch (error) {
1262
+ this.options.onFailed?.(error);
1263
+ }
1264
+ }
1265
+ async insertImageFile(file) {
1266
+ if (!file.type.startsWith('image/') && !/\.(png|jpe?g|gif|webp|svg)$/i.test(file.name)) {
1267
+ return;
1268
+ }
1269
+ try {
1270
+ await this.insertImageBytes(new Uint8Array(await file.arrayBuffer()), file.type || imageMimeTypeFromFileName(file.name));
1271
+ }
1272
+ catch (error) {
1273
+ this.options.onFailed?.(error);
1274
+ }
1275
+ }
1276
+ async insertImageBytes(bytes, mimeType) {
1277
+ const editor = this.cmEditor;
1278
+ if (!editor) {
1279
+ return;
1280
+ }
1281
+ const selection = editor.state.selection.main;
1282
+ const result = await this.workspace?.pasteImage({
1283
+ bytes,
1284
+ mimeType,
1285
+ selectionStart: selection.from,
1286
+ selectionEnd: selection.to
1287
+ });
1288
+ if (result && this.cmEditor) {
1289
+ this.render();
1290
+ this.cmEditor.dispatch({ selection: { anchor: result.cursor } });
1291
+ this.cmEditor.focus();
1292
+ }
1293
+ }
1294
+ applyMarkdownFormat(format) {
1295
+ const editor = this.cmEditor;
1296
+ const snapshot = this.workspace?.snapshot();
1297
+ if (!editor || !snapshot || snapshot.mode === 'read-only' || snapshot.currentPathType !== 'markdown') {
1298
+ return;
1299
+ }
1300
+ switch (format) {
1301
+ case 'bold':
1302
+ this.wrapSelection('**', '**', 'bold text');
1303
+ break;
1304
+ case 'italic':
1305
+ this.wrapSelection('_', '_', 'italic text');
1306
+ break;
1307
+ case 'strikethrough':
1308
+ this.wrapSelection('~~', '~~', 'strikethrough text');
1309
+ break;
1310
+ case 'paragraph':
1311
+ this.setSelectedLinePrefix('', /^(#{1,6})\s+/);
1312
+ break;
1313
+ case 'heading-1':
1314
+ case 'heading-2':
1315
+ case 'heading-3':
1316
+ case 'heading-4':
1317
+ case 'heading-5':
1318
+ case 'heading-6':
1319
+ this.setSelectedLinePrefix(`${'#'.repeat(Number(format.at(-1)))} `, /^(#{1,6})\s+/);
1320
+ break;
1321
+ case 'bullet-list':
1322
+ this.prefixSelectedLines('- ', /^(\s*)[-*+]\s+/);
1323
+ break;
1324
+ case 'ordered-list':
1325
+ this.numberSelectedLines();
1326
+ break;
1327
+ case 'inline-code':
1328
+ this.wrapSelection('`', '`', 'code');
1329
+ break;
1330
+ case 'code-block':
1331
+ this.wrapSelection('```\n', '\n```', 'code');
1332
+ break;
1333
+ case 'blockquote':
1334
+ this.prefixSelectedLines('> ', /^(\s*)>\s?/);
1335
+ break;
1336
+ case 'link':
1337
+ this.wrapSelection('[', '](url)', 'link text');
1338
+ break;
1339
+ default:
1340
+ return;
1341
+ }
1342
+ }
1343
+ renderFormattingControls() {
1344
+ const formatting = this.controlPolicy.formatting;
1345
+ const visibility = {
1346
+ bold: formatting.bold,
1347
+ italic: formatting.italic,
1348
+ strikethrough: formatting.strikethrough,
1349
+ headings: formatting.headings.length > 0,
1350
+ bulletList: formatting.bulletList,
1351
+ orderedList: formatting.orderedList,
1352
+ code: formatting.inlineCode || formatting.codeBlock,
1353
+ blockquote: formatting.blockquote,
1354
+ link: formatting.link,
1355
+ image: formatting.image
1356
+ };
1357
+ this.elEditToolbar.querySelectorAll('[data-format-control]').forEach((element) => {
1358
+ element.hidden = !visibility[element.dataset['formatControl'] ?? ''];
1359
+ });
1360
+ this.elEditToolbar.querySelectorAll('[data-heading-level]').forEach((element) => {
1361
+ const level = Number(element.dataset['headingLevel']);
1362
+ element.hidden = !formatting.headings.includes(level);
1363
+ });
1364
+ this.elEditToolbar.querySelector('[data-code-kind="inline"]').hidden =
1365
+ !formatting.inlineCode;
1366
+ this.elEditToolbar.querySelector('[data-code-kind="block"]').hidden =
1367
+ !formatting.codeBlock;
1368
+ const groups = Array.from(this.elEditToolbar.querySelectorAll('.edit-toolbar-group'));
1369
+ groups.forEach((group) => {
1370
+ group.hidden = !Array.from(group.children).some(child => child instanceof HTMLElement && !child.hidden && child.matches('[data-format-control]'));
1371
+ });
1372
+ this.elEditToolbar.querySelectorAll('.edit-toolbar-divider').forEach((divider, index) => {
1373
+ const hasVisibleBefore = groups.slice(0, index + 1).some(group => !group.hidden);
1374
+ const hasVisibleAfter = groups.slice(index + 1).some(group => !group.hidden);
1375
+ divider.hidden = !hasVisibleBefore || !hasVisibleAfter;
1376
+ });
1377
+ }
1378
+ toggleFormatMenu(toggle) {
1379
+ const menu = toggle.nextElementSibling;
1380
+ const willOpen = toggle.getAttribute('aria-expanded') !== 'true';
1381
+ this.closeFormatMenus();
1382
+ if (!menu || !willOpen) {
1383
+ return;
1384
+ }
1385
+ toggle.setAttribute('aria-expanded', 'true');
1386
+ menu.hidden = false;
1387
+ menu.querySelector('[role="menuitem"]')?.focus();
1388
+ }
1389
+ closeFormatMenus() {
1390
+ this.elEditToolbar
1391
+ .querySelectorAll('[data-format-menu-toggle][aria-expanded="true"]')
1392
+ .forEach(toggle => toggle.setAttribute('aria-expanded', 'false'));
1393
+ this.elEditToolbar
1394
+ .querySelectorAll('[data-format-menu]')
1395
+ .forEach(menu => { menu.hidden = true; });
1396
+ }
1397
+ wrapSelection(prefix, suffix, placeholder) {
1398
+ const editor = this.cmEditor;
1399
+ if (!editor) {
1400
+ return;
1401
+ }
1402
+ const selection = editor.state.selection.main;
1403
+ const selectedText = editor.state.sliceDoc(selection.from, selection.to);
1404
+ const content = selectedText || placeholder;
1405
+ const insert = `${prefix}${content}${suffix}`;
1406
+ const anchor = selection.from + prefix.length;
1407
+ editor.dispatch({
1408
+ changes: { from: selection.from, to: selection.to, insert },
1409
+ selection: { anchor, head: anchor + content.length },
1410
+ scrollIntoView: true
1411
+ });
1412
+ editor.focus();
1413
+ }
1414
+ prefixSelectedLines(prefix, existingPrefix) {
1415
+ const editor = this.cmEditor;
1416
+ if (!editor) {
1417
+ return;
1418
+ }
1419
+ const selection = editor.state.selection.main;
1420
+ const firstLine = editor.state.doc.lineAt(selection.from);
1421
+ const lastLine = editor.state.doc.lineAt(selection.to);
1422
+ const text = editor.state.sliceDoc(firstLine.from, lastLine.to);
1423
+ const lines = text.split('\n');
1424
+ const allPrefixed = lines.every(line => existingPrefix.test(line) && line.match(existingPrefix)?.[0]);
1425
+ const insert = lines.map((line) => {
1426
+ const withoutExisting = line.replace(existingPrefix, '$1');
1427
+ return allPrefixed ? withoutExisting : `${prefix}${withoutExisting}`;
1428
+ }).join('\n');
1429
+ editor.dispatch({
1430
+ changes: { from: firstLine.from, to: lastLine.to, insert },
1431
+ selection: { anchor: firstLine.from, head: firstLine.from + insert.length },
1432
+ scrollIntoView: true
1433
+ });
1434
+ editor.focus();
1435
+ }
1436
+ numberSelectedLines() {
1437
+ const editor = this.cmEditor;
1438
+ if (!editor) {
1439
+ return;
1440
+ }
1441
+ const selection = editor.state.selection.main;
1442
+ const firstLine = editor.state.doc.lineAt(selection.from);
1443
+ const lastLine = editor.state.doc.lineAt(selection.to);
1444
+ const text = editor.state.sliceDoc(firstLine.from, lastLine.to);
1445
+ const lines = text.split('\n');
1446
+ const orderedPrefix = /^(\s*)\d+[.)]\s+/;
1447
+ const nonEmptyLines = lines.filter(line => line.trim().length > 0);
1448
+ const allNumbered = nonEmptyLines.length > 0 && nonEmptyLines.every(line => orderedPrefix.test(line));
1449
+ let number = 1;
1450
+ const insert = lines.map((line) => {
1451
+ if (!line.trim()) {
1452
+ return line;
1453
+ }
1454
+ const match = line.match(orderedPrefix);
1455
+ const indent = match?.[1] ?? line.match(/^\s*/)?.[0] ?? '';
1456
+ const content = match ? line.slice(match[0].length) : line.slice(indent.length);
1457
+ if (allNumbered) {
1458
+ return `${indent}${content}`;
1459
+ }
1460
+ return `${indent}${number++}. ${content}`;
1461
+ }).join('\n');
1462
+ editor.dispatch({
1463
+ changes: { from: firstLine.from, to: lastLine.to, insert },
1464
+ selection: { anchor: firstLine.from, head: firstLine.from + insert.length },
1465
+ scrollIntoView: true
1466
+ });
1467
+ editor.focus();
1468
+ }
1469
+ setSelectedLinePrefix(prefix, existingPrefix) {
1470
+ const editor = this.cmEditor;
1471
+ if (!editor) {
1472
+ return;
1473
+ }
1474
+ const selection = editor.state.selection.main;
1475
+ const firstLine = editor.state.doc.lineAt(selection.from);
1476
+ const lastLine = editor.state.doc.lineAt(selection.to);
1477
+ const text = editor.state.sliceDoc(firstLine.from, lastLine.to);
1478
+ const insert = text.split('\n')
1479
+ .map(line => `${prefix}${line.replace(existingPrefix, '')}`)
1480
+ .join('\n');
1481
+ editor.dispatch({
1482
+ changes: { from: firstLine.from, to: lastLine.to, insert },
1483
+ selection: { anchor: firstLine.from, head: firstLine.from + insert.length },
1484
+ scrollIntoView: true
1485
+ });
1486
+ editor.focus();
1487
+ }
1488
+ async notifyChanged(event) {
1489
+ if (!this.workspace) {
1490
+ return;
1491
+ }
1492
+ try {
1493
+ const snapshot = event.snapshot;
1494
+ if (this.options.onChanged) {
1495
+ const bytes = await this.workspace.exportBytes();
1496
+ this.options.onChanged(bytes, snapshot);
1497
+ }
1498
+ this.options.onWorkspaceChanged?.(event);
1499
+ if (event.changes.includes('document')) {
1500
+ this.options.onDocumentChanged?.(event);
1501
+ }
1502
+ if (event.changes.includes('asset')) {
1503
+ this.options.onAssetChanged?.(event);
1504
+ }
1505
+ if (event.changes.includes('manifest')) {
1506
+ this.options.onManifestChanged?.(event);
1507
+ }
1508
+ this.options.onSnapshotChanged?.(snapshot);
1509
+ this.options.onDirtyChanged?.(snapshot);
1510
+ this.options.onValidationChanged?.(snapshot);
1511
+ if (event.changes.includes('selection')) {
1512
+ this.options.onSelectionChanged?.(snapshot);
1513
+ }
1514
+ }
1515
+ catch (error) {
1516
+ this.options.onFailed?.(error);
1517
+ }
1518
+ }
1519
+ initialWorkspaceEvent(snapshot) {
1520
+ return {
1521
+ reason: 'reload',
1522
+ changes: ['workspace'],
1523
+ snapshot
1524
+ };
1525
+ }
1526
+ setZoom(value) {
1527
+ this.zoom = Math.max(0.5, Math.min(2.5, Math.round(value * 100) / 100));
1528
+ this.render();
1529
+ }
1530
+ setColorScheme(colorScheme) {
1531
+ if (this.colorScheme === colorScheme) {
1532
+ return;
1533
+ }
1534
+ this.colorScheme = colorScheme;
1535
+ this.render();
1536
+ this.options.onColorSchemeChanged?.(colorScheme);
1537
+ }
1538
+ setLayout(requested) {
1539
+ const snapshot = this.workspace?.snapshot();
1540
+ this.layout = snapshot ? this.validLayoutForSnapshot(requested, snapshot) : requested;
1541
+ this.render();
1542
+ }
1543
+ showOrphanMenu(path, event) {
1544
+ if (!this.controlPolicy.orphanActions) {
1545
+ return;
1546
+ }
1547
+ const bounds = event.target?.getBoundingClientRect();
1548
+ const clientX = event instanceof MouseEvent ? event.clientX : (bounds?.left ?? 0);
1549
+ const clientY = event instanceof MouseEvent ? event.clientY : (bounds?.bottom ?? 0);
1550
+ this.pendingOrphanPath = path;
1551
+ this.orphanMenuState = {
1552
+ path,
1553
+ x: Math.max(4, Math.min(clientX, window.innerWidth - 210)),
1554
+ y: Math.max(4, Math.min(clientY, window.innerHeight - 44))
1555
+ };
1556
+ this.render();
1557
+ }
1558
+ validLayoutForSnapshot(requested, snapshot) {
1559
+ if (requested === 'source' || requested === 'split') {
1560
+ return canShowSourceLayout(snapshot) ? requested : 'preview';
1561
+ }
1562
+ return requested;
1563
+ }
1564
+ async removeOrphan() {
1565
+ const path = this.pendingOrphanPath;
1566
+ this.orphanMenuState = null;
1567
+ this.pendingOrphanPath = null;
1568
+ this.render();
1569
+ if (!path) {
1570
+ return;
1571
+ }
1572
+ try {
1573
+ await this.workspace?.removeAsset(path, { requireOrphaned: true });
1574
+ this.render();
1575
+ }
1576
+ catch (error) {
1577
+ this.options.onFailed?.(error);
1578
+ }
1579
+ }
1580
+ syncScrollFromPreview() {
1581
+ if (this.syncing || !this.cmEditor || this.layout !== 'split') {
1582
+ return;
1583
+ }
1584
+ this.syncing = true;
1585
+ const previewHeight = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
1586
+ const scrollRatio = previewHeight > 0 ? this.elPreviewPane.scrollTop / previewHeight : 0;
1587
+ const cmScroller = this.cmEditor.dom.querySelector('.cm-scroller');
1588
+ if (cmScroller) {
1589
+ const editorHeight = cmScroller.scrollHeight - cmScroller.clientHeight;
1590
+ cmScroller.scrollTop = scrollRatio * editorHeight;
1591
+ }
1592
+ this.syncing = false;
1593
+ }
1594
+ syncScrollToPreview() {
1595
+ if (this.syncing || !this.cmEditor || this.layout !== 'split') {
1596
+ return;
1597
+ }
1598
+ this.syncing = true;
1599
+ const cmScroller = this.cmEditor.dom.querySelector('.cm-scroller');
1600
+ if (cmScroller) {
1601
+ const editorHeight = cmScroller.scrollHeight - cmScroller.clientHeight;
1602
+ const scrollRatio = editorHeight > 0 ? cmScroller.scrollTop / editorHeight : 0;
1603
+ const previewHeight = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
1604
+ this.elPreviewPane.scrollTop = scrollRatio * previewHeight;
1605
+ }
1606
+ this.syncing = false;
1607
+ }
1608
+ }
1609
+ function canShowSourceLayout(snapshot) {
1610
+ return canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, 'editable');
1611
+ }
1612
+ function hasFormattingControls(policy) {
1613
+ return policy.bold
1614
+ || policy.italic
1615
+ || policy.strikethrough
1616
+ || policy.headings.length > 0
1617
+ || policy.bulletList
1618
+ || policy.orderedList
1619
+ || policy.inlineCode
1620
+ || policy.codeBlock
1621
+ || policy.blockquote
1622
+ || policy.link
1623
+ || policy.image;
1624
+ }
1625
+ function markdownCommandFromToolbarFormat(format) {
1626
+ switch (format) {
1627
+ case 'bold':
1628
+ case 'italic':
1629
+ case 'paragraph':
1630
+ case 'heading-1':
1631
+ case 'heading-2':
1632
+ case 'heading-3':
1633
+ case 'heading-4':
1634
+ case 'heading-5':
1635
+ case 'heading-6':
1636
+ case 'bullet-list':
1637
+ case 'ordered-list':
1638
+ case 'code-block':
1639
+ case 'link':
1640
+ return format;
1641
+ case 'strike':
1642
+ return 'strikethrough';
1643
+ case 'code':
1644
+ return 'inline-code';
1645
+ case 'quote':
1646
+ return 'blockquote';
1647
+ default:
1648
+ return null;
1649
+ }
1650
+ }
1651
+ function imageMimeTypeFromFileName(fileName) {
1652
+ const extension = fileName.toLowerCase().split('.').pop();
1653
+ switch (extension) {
1654
+ case 'jpg':
1655
+ case 'jpeg':
1656
+ return 'image/jpeg';
1657
+ case 'gif':
1658
+ return 'image/gif';
1659
+ case 'webp':
1660
+ return 'image/webp';
1661
+ case 'svg':
1662
+ return 'image/svg+xml';
1663
+ case 'png':
1664
+ default:
1665
+ return 'image/png';
1666
+ }
1667
+ }
1668
+ function formatMetadataValue(value) {
1669
+ if (typeof value === 'string' && value.trim()) {
1670
+ return value;
1671
+ }
1672
+ if (value && typeof value === 'object' && 'when' in value) {
1673
+ const when = value.when;
1674
+ return typeof when === 'string' && when.trim() ? when : 'Not available';
1675
+ }
1676
+ return 'Not available';
1677
+ }
1678
+ const SHELL_HTML = `
1679
+ <section class="mdzip-root">
1680
+ <div class="document-strip" data-ref="document-strip" hidden>
1681
+ <button type="button" class="title-button" data-ref="title-btn"></button>
1682
+ <button type="button" class="document-info-button" data-ref="document-info-btn"
1683
+ title="Document information" aria-label="Document information">
1684
+ ${INFO_ICON_HTML}
1685
+ </button>
1686
+ </div>
1687
+
1688
+ <header class="toolbar" data-ref="toolbar" hidden>
1689
+ <div class="toolbar-start">
1690
+ <div class="toolbar-left">
1691
+ <button type="button" class="icon-toggle nav-toggle" data-ref="nav-btn" title="Toggle contents" aria-label="Toggle contents">
1692
+ ${NAV_TOGGLE_ICON_HTML}
1693
+ </button>
1694
+ </div>
1695
+
1696
+ <div class="edit-toolbar" data-ref="edit-toolbar" role="toolbar" aria-label="Markdown formatting">
1697
+ <div class="edit-toolbar-group">
1698
+ <button type="button" data-format="bold" data-format-control="bold" title="Bold" aria-label="Bold">${BOLD_ICON_HTML}</button>
1699
+ <button type="button" data-format="italic" data-format-control="italic" title="Italic" aria-label="Italic">${ITALIC_ICON_HTML}</button>
1700
+ <button type="button" data-format="strike" data-format-control="strikethrough" title="Strikethrough" aria-label="Strikethrough">${STRIKE_ICON_HTML}</button>
1701
+ <div class="format-menu" data-format-control="headings">
1702
+ <button type="button" class="format-menu-toggle" data-format-menu-toggle
1703
+ aria-label="Heading" aria-haspopup="menu" aria-expanded="false">
1704
+ ${HEADING_ICON_HTML}${CHEVRON_ICON_HTML}
1705
+ </button>
1706
+ <div class="format-menu-popover" data-format-menu role="menu" aria-label="Heading level" hidden>
1707
+ <button type="button" role="menuitem" data-format="paragraph">Paragraph</button>
1708
+ <button type="button" role="menuitem" data-format="heading-1" data-heading-level="1"><strong>H1</strong> Heading 1</button>
1709
+ <button type="button" role="menuitem" data-format="heading-2" data-heading-level="2"><strong>H2</strong> Heading 2</button>
1710
+ <button type="button" role="menuitem" data-format="heading-3" data-heading-level="3"><strong>H3</strong> Heading 3</button>
1711
+ <button type="button" role="menuitem" data-format="heading-4" data-heading-level="4"><strong>H4</strong> Heading 4</button>
1712
+ <button type="button" role="menuitem" data-format="heading-5" data-heading-level="5"><strong>H5</strong> Heading 5</button>
1713
+ <button type="button" role="menuitem" data-format="heading-6" data-heading-level="6"><strong>H6</strong> Heading 6</button>
1714
+ </div>
1715
+ </div>
1716
+ </div>
1717
+ <span class="edit-toolbar-divider" aria-hidden="true"></span>
1718
+ <div class="edit-toolbar-group">
1719
+ <button type="button" data-format="bullet-list" data-format-control="bulletList" title="Bulleted list" aria-label="Bulleted list">${BULLET_LIST_ICON_HTML}</button>
1720
+ <button type="button" data-format="ordered-list" data-format-control="orderedList" title="Numbered list" aria-label="Numbered list">${ORDERED_LIST_ICON_HTML}</button>
1721
+ <div class="format-menu" data-format-control="code">
1722
+ <button type="button" class="format-menu-toggle" data-format-menu-toggle
1723
+ aria-label="Code" aria-haspopup="menu" aria-expanded="false">
1724
+ ${CODE_ICON_HTML}${CHEVRON_ICON_HTML}
1725
+ </button>
1726
+ <div class="format-menu-popover" data-format-menu role="menu" aria-label="Code format" hidden>
1727
+ <button type="button" role="menuitem" data-format="code" data-code-kind="inline">Inline code</button>
1728
+ <button type="button" role="menuitem" data-format="code-block" data-code-kind="block">Code block</button>
1729
+ </div>
1730
+ </div>
1731
+ <button type="button" data-format="quote" data-format-control="blockquote" title="Blockquote" aria-label="Blockquote">${QUOTE_ICON_HTML}</button>
1732
+ </div>
1733
+ <span class="edit-toolbar-divider" aria-hidden="true"></span>
1734
+ <div class="edit-toolbar-group">
1735
+ <button type="button" data-format="link" data-format-control="link" title="Link" aria-label="Link">${LINK_ICON_HTML}</button>
1736
+ <button type="button" data-format="image" data-format-control="image" title="Image" aria-label="Image">${IMAGE_FORMAT_ICON_HTML}</button>
1737
+ <input type="file" data-ref="image-input" accept="image/*" hidden />
1738
+ </div>
1739
+ </div>
1740
+ </div>
1741
+
1742
+ <div class="toolbar-buttons view-mode-toggle-group" data-ref="layout-controls" role="group" aria-label="Editor layout">
1743
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="source-btn" title="Edit" aria-label="Edit" aria-pressed="false">
1744
+ <span class="commandbar-flex-container" data-ref="source-icon">${SOURCE_EDIT_ICON_HTML}</span>
1745
+ </button>
1746
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="split-btn" title="Split" aria-label="Split" aria-pressed="false">
1747
+ <span class="commandbar-flex-container">
1748
+ ${SPLIT_ICON_HTML}
1749
+ </span>
1750
+ </button>
1751
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="preview-btn" title="View" aria-label="View" aria-pressed="false">
1752
+ <span class="commandbar-flex-container">
1753
+ ${PREVIEW_ICON_HTML}
1754
+ </span>
1755
+ </button>
1756
+ </div>
1757
+
1758
+ <div class="toolbar-controls" data-ref="toolbar-controls">
1759
+ <button type="button" class="icon-toggle" data-ref="save-btn" title="Save" aria-label="Save">
1760
+ ${SAVE_ICON_HTML}
1761
+ </button>
1762
+ <button type="button" class="icon-toggle zoom-toggle" data-ref="zoom-btn" title="Zoom controls" aria-label="Zoom controls">
1763
+ ${ZOOM_ICON_HTML}
1764
+ </button>
1765
+ <div class="theme-toggle-group" data-ref="theme-controls" role="group" aria-label="Color scheme">
1766
+ <button type="button" class="icon-toggle theme-toggle" data-ref="dark-theme-btn"
1767
+ title="Dark mode" aria-label="Dark mode" aria-pressed="false">
1768
+ ${DARK_THEME_ICON_HTML}
1769
+ </button>
1770
+ <button type="button" class="icon-toggle theme-toggle" data-ref="light-theme-btn"
1771
+ title="Light mode" aria-label="Light mode" aria-pressed="false">
1772
+ ${LIGHT_THEME_ICON_HTML}
1773
+ </button>
1774
+ </div>
1775
+ <div class="zoom-popover" data-ref="zoom-popover" hidden role="group" aria-label="Zoom">
1776
+ <span class="zoom-level" data-ref="zoom-level">100%</span>
1777
+ <span class="zoom-stepper">
1778
+ <button type="button" data-action="zoom-out" title="Zoom out" aria-label="Zoom out">-</button>
1779
+ <button type="button" data-action="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
1780
+ </span>
1781
+ <button type="button" class="zoom-reset" data-action="zoom-reset" title="Reset zoom">Reset</button>
1782
+ </div>
1783
+ </div>
1784
+ </header>
1785
+
1786
+ <div class="workspace-shell" data-ref="workspace-shell" hidden>
1787
+ <aside class="nav-pane" data-ref="nav-pane" aria-label="Package contents">
1788
+ <div class="nav-tree" data-ref="nav-tree"></div>
1789
+ </aside>
1790
+ <div class="nav-resizer" data-ref="nav-resizer"
1791
+ role="separator" aria-orientation="vertical" aria-label="Resize contents pane"></div>
1792
+
1793
+ <div class="pane-stack" data-ref="pane-stack">
1794
+ <section class="pane edit-pane" data-ref="edit-pane">
1795
+ <div class="editor-host" data-ref="editor-host"></div>
1796
+ </section>
1797
+ <div class="split-resizer" data-ref="split-resizer"
1798
+ role="separator" aria-orientation="vertical" aria-label="Resize split panes"></div>
1799
+ <section class="pane preview-pane" data-ref="preview-pane">
1800
+ <article class="preview-content" data-ref="preview-content"></article>
1801
+ </section>
1802
+ </div>
1803
+ </div>
1804
+
1805
+ <div class="title-dialog-backdrop" data-ref="title-dialog" hidden
1806
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-title-dialog-heading">
1807
+ <div class="title-dialog">
1808
+ <h3 id="mdzip-title-dialog-heading">Set Document Title</h3>
1809
+ <p>This is the package-level title stored in manifest.json.</p>
1810
+ <input type="text" maxlength="120" data-ref="title-input" aria-label="Document title" />
1811
+ <p class="title-dialog-validation" data-ref="title-validation" hidden>Title cannot be empty.</p>
1812
+ <p>If unset, consumers may fall back to entry point, filename, or first heading.</p>
1813
+ <div class="title-dialog-actions">
1814
+ <button type="button" class="reset-title" data-ref="title-reset-btn">Reset</button>
1815
+ <button type="button" data-action="cancel-title">Cancel</button>
1816
+ <button type="button" class="save-title" data-ref="title-save-btn">Save</button>
1817
+ </div>
1818
+ </div>
1819
+ </div>
1820
+
1821
+ <div class="title-dialog-backdrop" data-ref="conversion-dialog" hidden
1822
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-conversion-dialog-heading">
1823
+ <div class="title-dialog">
1824
+ <h3 id="mdzip-conversion-dialog-heading">Convert to MDZ?</h3>
1825
+ <p>
1826
+ Regular Markdown files contain only text. Convert this document to an
1827
+ MDZ package to add images, package navigation, and document metadata.
1828
+ </p>
1829
+ <p>The next save will produce an .mdz file.</p>
1830
+ <div class="title-dialog-actions">
1831
+ <button type="button" data-action="cancel-conversion">Cancel</button>
1832
+ <button type="button" class="save-title" data-ref="conversion-confirm-btn">Convert</button>
1833
+ </div>
1834
+ </div>
1835
+ </div>
1836
+
1837
+ <div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
1838
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
1839
+ <div class="title-dialog metadata-dialog">
1840
+ <h3 id="mdzip-metadata-dialog-heading">Document Information</h3>
1841
+ <dl data-ref="metadata-list"></dl>
1842
+ <div class="title-dialog-actions">
1843
+ <button type="button" class="save-title" data-action="close-metadata">Close</button>
1844
+ </div>
1845
+ </div>
1846
+ </div>
1847
+
1848
+ <div class="orphan-context-menu" data-ref="orphan-menu" hidden role="menu">
1849
+ <button type="button" role="menuitem" data-action="remove-orphan">Remove Orphaned Asset</button>
1850
+ </div>
1851
+
1852
+ <div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
1853
+
1854
+ <p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
1855
+ </section>
1856
+ `;
1857
+ //# sourceMappingURL=view.js.map