@mdzip/editor 1.3.19 → 1.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/view.js CHANGED
@@ -7,6 +7,7 @@ import { Decoration, EditorView, MatchDecorator, ViewPlugin, dropCursor, keymap,
7
7
  import { tags } from '@lezer/highlight';
8
8
  import { Bold, Check, ChevronDown, ChevronRight, ClipboardPaste, ClipboardType, Code, Columns2, Copy, Eraser, Eye, File, FileBraces, FileImage, Folder, FolderOpen, Hash, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Highlighter, ImagePlus, Info, Italic, Link2Off, Link, List, ListOrdered, CornerDownLeft, Moon, PackagePlus, PanelLeft, Pilcrow, Quote, Save, Scissors, Search, SpellCheck, SquareCode, SquarePen, Strikethrough, Sun, TextSelect, ZoomIn } from 'lucide';
9
9
  import { browserClipboardHasImage, readBrowserClipboardImage } from './browser.js';
10
+ import { buildPackedArchiveBytes, isMarkdownArchivePath, resolveMarkdownEntryPoint } from './archive-utils.js';
10
11
  import { MdzipAssetSession, mdzipArchiveSourceId, sniffImageSize } from './asset-cache.js';
11
12
  import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
12
13
  import { MDZIP_RUNTIME_LIBRARIES } from './library-info.js';
@@ -146,6 +147,16 @@ function renderContextMenuItems(items) {
146
147
  .map((item) => (item === null ? MENU_SEPARATOR_HTML : renderContextMenuItem(item)))
147
148
  .join('');
148
149
  }
150
+ const PREVIEW_MAX_WIDTH_ALIASES = {
151
+ narrow: 650,
152
+ default: 900,
153
+ wide: 1200,
154
+ };
155
+ function resolvePreviewMaxWidthPx(value) {
156
+ if (value === undefined)
157
+ return undefined;
158
+ return typeof value === 'number' ? value : PREVIEW_MAX_WIDTH_ALIASES[value];
159
+ }
149
160
  const ALL_HEADINGS = [1, 2, 3, 4, 5, 6];
150
161
  const ALL_LAYOUT_CONTROLS = {
151
162
  source: true,
@@ -379,21 +390,29 @@ const mdzipEditorTheme = EditorView.theme({
379
390
  overflow: 'auto',
380
391
  },
381
392
  '.cm-content': {
382
- padding: 'var(--mdzip-editor-content-padding, var(--mdzip-density-editor-content-padding, 36px 48px))',
393
+ padding: 'var(--mdzip-editor-content-padding, var(--mdzip-density-editor-content-padding, 36px 48px 36px 16px))',
383
394
  caretColor: 'var(--mdzip-editor-cursor-color)',
384
395
  overflowWrap: 'anywhere',
385
396
  wordBreak: 'normal',
386
397
  },
387
398
  '.cm-gutters': {
388
- background: 'var(--mdzip-widget-background-color)',
389
- borderRight: '1px solid var(--mdzip-border-color)',
399
+ background: 'transparent',
400
+ border: 'none',
390
401
  color: 'var(--mdzip-line-number-foreground-color)',
391
402
  fontFamily: '"Cascadia Code", Consolas, monospace',
392
- minWidth: '52px',
403
+ fontSize: '0.85em',
404
+ // CodeMirror force-sets each gutter element's height to match its
405
+ // content line's rendered height, but a unitless/inherited line-height
406
+ // resolves against the gutter's own (smaller) font-size — leaving the
407
+ // number shorter than its box and rendering top-aligned instead of
408
+ // matching the content line's baseline. Pin it to the same absolute
409
+ // line-height as the content (fontSize '&' * the '.cm-scroller' 1.5
410
+ // multiplier) so both sit centered in equal-height boxes.
411
+ lineHeight: 'calc(16px * var(--mdz-zoom, 1) * 1.5)',
412
+ opacity: '0.65',
393
413
  },
394
414
  '.cm-lineNumbers .cm-gutterElement': {
395
415
  padding: '0 8px 0 4px',
396
- minWidth: '44px',
397
416
  },
398
417
  '&.cm-focused .cm-cursor': {
399
418
  borderLeftColor: 'var(--mdzip-editor-cursor-color)',
@@ -679,11 +698,11 @@ function navNodeOrder(a, b) {
679
698
  function applyRawHtmlImageSizeAttributes(image) {
680
699
  const width = image.getAttribute('width');
681
700
  if (width && /^\d+$/.test(width) && !image.style.width) {
682
- image.style.width = `${width}px`;
701
+ image.style.width = `calc(${width}px * var(--mdz-zoom, 1))`;
683
702
  }
684
703
  const height = image.getAttribute('height');
685
704
  if (height && /^\d+$/.test(height) && !image.style.height) {
686
- image.style.height = `${height}px`;
705
+ image.style.height = `calc(${height}px * var(--mdz-zoom, 1))`;
687
706
  }
688
707
  }
689
708
  // Maps the legacy raw HTML `align="left"|"right"` attribute (or the editor's
@@ -713,6 +732,12 @@ export class MdzipWorkspaceView {
713
732
  this.titleDraft = '';
714
733
  this.conversionAction = null;
715
734
  this.imageInsertDialogState = null;
735
+ // Promise-based, like imageInsertDialogState — packFilesAsWorkspace is an
736
+ // explicit awaited call building a brand-new archive from an
737
+ // already-captured file list, so (unlike conversionAction's fire-and-forget
738
+ // trigger) there's no "currently open document" to race against and no
739
+ // de-dupe/staleness guard is needed.
740
+ this.packFilesDialogState = null;
716
741
  this.navPaneWidth = 280;
717
742
  this.splitRatio = 0.5;
718
743
  this.resizing = false;
@@ -774,6 +799,7 @@ export class MdzipWorkspaceView {
774
799
  this.imageHydrationAnimation = options.imageHydrationAnimation ?? 'auto';
775
800
  this.toolbarDensity = options.toolbarDensity ?? 'comfortable';
776
801
  this.contentDensity = options.contentDensity ?? 'comfortable';
802
+ this.previewMaxWidth = options.previewMaxWidth;
777
803
  this.layout = options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy);
778
804
  this.colorScheme = options.initialColorScheme
779
805
  ?? (container.ownerDocument.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches
@@ -841,6 +867,11 @@ export class MdzipWorkspaceView {
841
867
  this.elImageInsertSizeValueInput = q('[data-ref="image-insert-size-value"]');
842
868
  this.elImageInsertPositionSelect = q('[data-ref="image-insert-position"]');
843
869
  this.elImageInsertConfirmBtn = q('[data-ref="image-insert-confirm-btn"]');
870
+ this.elPackFilesDialog = q('[data-ref="pack-files-dialog"]');
871
+ this.elPackFilesModeDocument = q('[data-ref="pack-files-mode-document"]');
872
+ this.elPackFilesModeProject = q('[data-ref="pack-files-mode-project"]');
873
+ this.elPackFilesEntrySelect = q('[data-ref="pack-files-entry"]');
874
+ this.elPackFilesConfirmBtn = q('[data-ref="pack-files-confirm-btn"]');
844
875
  this.elNavMenu = q('[data-ref="nav-menu"]');
845
876
  this.elNameDialog = q('[data-ref="name-dialog"]');
846
877
  this.elNameDialogHeading = q('[data-ref="name-dialog-heading"]');
@@ -1135,6 +1166,7 @@ export class MdzipWorkspaceView {
1135
1166
  destroy() {
1136
1167
  this.conversionDocumentGeneration += 1;
1137
1168
  this.resolveImageInsertDialog(null);
1169
+ this.resolvePackFilesDialog(null);
1138
1170
  try {
1139
1171
  this.unsub?.();
1140
1172
  }
@@ -1191,6 +1223,14 @@ export class MdzipWorkspaceView {
1191
1223
  const next = resolveMdzipControlPolicy(controls);
1192
1224
  const lineNumbersChanged = next.lineNumbers !== this.controlPolicy.lineNumbers;
1193
1225
  const searchChanged = next.search !== this.controlPolicy.search;
1226
+ // codeBlockTools chrome is only attached when the preview HTML actually
1227
+ // re-mounts (mountPreviewHtml/mountProgressivePreview), which updatePreview()
1228
+ // skips via its memo when nothing document-facing changed. Unlike
1229
+ // lineNumbers/search (live CodeMirror compartment reconfigures), there's
1230
+ // no live DOM toggle for already-rendered code blocks, so the memo has to
1231
+ // be busted to pick up the new policy — same reason
1232
+ // setImageHydrationAnimation calls resetPreviewState() below.
1233
+ const codeBlockToolsChanged = next.codeBlockTools !== this.controlPolicy.codeBlockTools;
1194
1234
  this.controlPolicy = next;
1195
1235
  if (lineNumbersChanged && this.cmEditor) {
1196
1236
  this.cmEditor.dispatch({
@@ -1205,6 +1245,9 @@ export class MdzipWorkspaceView {
1205
1245
  effects: this.searchCompartment.reconfigure(next.search ? [search({ top: true }), keymap.of(searchKeymap)] : [])
1206
1246
  });
1207
1247
  }
1248
+ if (codeBlockToolsChanged) {
1249
+ this.resetPreviewState();
1250
+ }
1208
1251
  const snapshot = this.workspace?.snapshot();
1209
1252
  if (snapshot) {
1210
1253
  this.layout = this.validLayoutForSnapshot(this.layout, snapshot);
@@ -1230,6 +1273,19 @@ export class MdzipWorkspaceView {
1230
1273
  this.contentDensity = nextContentDensity;
1231
1274
  this.applyDensityClasses();
1232
1275
  }
1276
+ /**
1277
+ * Sets the preview reading-column width. Developer-facing only — there is
1278
+ * no toolbar UI for this. Pass `undefined` to return to the built-in
1279
+ * default (which scales with zoom); any other value is used exactly as
1280
+ * given and does not scale with zoom (see {@link MdzipPreviewMaxWidth}).
1281
+ */
1282
+ setPreviewMaxWidth(value) {
1283
+ if (value === this.previewMaxWidth) {
1284
+ return;
1285
+ }
1286
+ this.previewMaxWidth = value;
1287
+ this.render();
1288
+ }
1233
1289
  setImageInsertOptions(options) {
1234
1290
  this.options.imageInsertMode = options.imageInsertMode;
1235
1291
  this.options.imageInsertHandler = options.imageInsertHandler;
@@ -2068,6 +2124,13 @@ export class MdzipWorkspaceView {
2068
2124
  this.elRoot.style.setProperty('--mdz-zoom', String(this.zoom));
2069
2125
  this.elRoot.style.setProperty('--nav-pane-width', `${this.navPaneWidth}px`);
2070
2126
  this.elRoot.style.setProperty('--split-edit-ratio', String(this.splitRatio));
2127
+ const previewMaxWidthPx = resolvePreviewMaxWidthPx(this.previewMaxWidth);
2128
+ if (previewMaxWidthPx === undefined) {
2129
+ this.elRoot.style.removeProperty('--mdzip-preview-content-max-width');
2130
+ }
2131
+ else {
2132
+ this.elRoot.style.setProperty('--mdzip-preview-content-max-width', `${previewMaxWidthPx}px`);
2133
+ }
2071
2134
  this.elRoot.classList.toggle('resizing', this.resizing);
2072
2135
  this.elRoot.classList.toggle('mdzip-theme-dark', this.colorScheme === 'dark');
2073
2136
  this.elRoot.classList.toggle('mdzip-theme-light', this.colorScheme === 'light');
@@ -2202,6 +2265,16 @@ export class MdzipWorkspaceView {
2202
2265
  this.elImageInsertPositionSelect.value = 'inline';
2203
2266
  this.updateImageInsertOptionControls();
2204
2267
  }
2268
+ this.elPackFilesDialog.hidden = this.packFilesDialogState === null;
2269
+ if (this.packFilesDialogState) {
2270
+ const { request } = this.packFilesDialogState;
2271
+ this.elPackFilesEntrySelect.innerHTML = request.markdownFiles
2272
+ .map((p) => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`)
2273
+ .join('');
2274
+ this.elPackFilesEntrySelect.value = request.suggestedEntryPoint;
2275
+ this.elPackFilesModeDocument.checked = true;
2276
+ this.elPackFilesModeProject.checked = false;
2277
+ }
2205
2278
  this.elMetadataDialog.hidden = !this.metadataDialogOpen;
2206
2279
  if (this.contextMenuState) {
2207
2280
  const items = this.contextMenuState.kind === 'nav'
@@ -2570,6 +2643,13 @@ export class MdzipWorkspaceView {
2570
2643
  this.resetImageInsertSizeValue();
2571
2644
  this.updateImageInsertOptionControls();
2572
2645
  });
2646
+ this.elPackFilesDialog.querySelector('[data-action="cancel-pack-files"]')
2647
+ .addEventListener('click', () => {
2648
+ this.resolvePackFilesDialog(null);
2649
+ });
2650
+ this.elPackFilesConfirmBtn.addEventListener('click', () => {
2651
+ this.resolvePackFilesDialog(this.readPackFilesDialogDecision());
2652
+ });
2573
2653
  this.elNavMenu.addEventListener('click', (e) => {
2574
2654
  e.stopPropagation();
2575
2655
  const item = e.target.closest('[data-menu-action]');
@@ -3072,6 +3152,86 @@ export class MdzipWorkspaceView {
3072
3152
  }
3073
3153
  this.elImageInput.click();
3074
3154
  }
3155
+ /**
3156
+ * Packs an already-collected file list (e.g. from a host folder picker)
3157
+ * into a new .mdz workspace. With 0 or 1 Markdown files among `files`,
3158
+ * packs Document mode immediately with no prompt. With more than one,
3159
+ * asks `onPackRequested` (if set) to decide Document vs. Project mode and
3160
+ * the entry point; falls back to a built-in dialog if the hook is absent,
3161
+ * declines, or throws. Document mode opens the result in memory; Project
3162
+ * mode returns the archive bytes without opening them, since only the
3163
+ * host knows where a project archive should be saved.
3164
+ */
3165
+ async packFilesAsWorkspace(files, options = {}) {
3166
+ const markdownFiles = files.filter((f) => isMarkdownArchivePath(f.path)).map((f) => f.path);
3167
+ if (markdownFiles.length <= 1) {
3168
+ const entryPoint = markdownFiles[0] ?? 'index.md';
3169
+ return this.applyPackDecision(files, { mode: 'document', entryPoint }, options);
3170
+ }
3171
+ const suggestedEntryPoint = resolveMarkdownEntryPoint(markdownFiles);
3172
+ const request = { files, markdownFiles, suggestedEntryPoint };
3173
+ const hook = this.options.onPackRequested;
3174
+ if (hook) {
3175
+ const context = this.createPackFilesContext(files, options);
3176
+ let applied = null;
3177
+ const wrappedContext = {
3178
+ applyDecision: async (decision) => {
3179
+ applied = await context.applyDecision(decision);
3180
+ return applied;
3181
+ }
3182
+ };
3183
+ let handled = false;
3184
+ try {
3185
+ handled = await hook(request, wrappedContext);
3186
+ }
3187
+ catch (error) {
3188
+ this.options.onFailed?.(error);
3189
+ }
3190
+ if (handled) {
3191
+ return applied;
3192
+ }
3193
+ }
3194
+ const decision = await this.openPackFilesDialog(request);
3195
+ return decision ? this.applyPackDecision(files, decision, options) : null;
3196
+ }
3197
+ createPackFilesContext(files, options) {
3198
+ return { applyDecision: (decision) => this.applyPackDecision(files, decision, options) };
3199
+ }
3200
+ async applyPackDecision(files, decision, options) {
3201
+ const archiveBytes = await buildPackedArchiveBytes(files, {
3202
+ mode: decision.mode,
3203
+ entryPoint: decision.entryPoint,
3204
+ title: options.title ?? 'document'
3205
+ });
3206
+ if (decision.mode === 'document') {
3207
+ await this.open(archiveBytes, { mode: 'editable', fileName: options.fileName ?? 'document.mdz' });
3208
+ return { mode: 'document', entryPoint: decision.entryPoint, archiveBytes, opened: true };
3209
+ }
3210
+ return { mode: 'project', entryPoint: decision.entryPoint, archiveBytes, opened: false };
3211
+ }
3212
+ openPackFilesDialog(request) {
3213
+ return new Promise((resolve) => {
3214
+ this.resolvePackFilesDialog(null); // cancel any prior pending dialog, same as image-insert
3215
+ this.packFilesDialogState = { request, files: request.files, resolve };
3216
+ this.render();
3217
+ requestAnimationFrame(() => this.elPackFilesConfirmBtn.focus());
3218
+ });
3219
+ }
3220
+ resolvePackFilesDialog(decision) {
3221
+ const state = this.packFilesDialogState;
3222
+ if (!state) {
3223
+ return;
3224
+ }
3225
+ this.packFilesDialogState = null;
3226
+ this.render();
3227
+ state.resolve(decision);
3228
+ }
3229
+ readPackFilesDialogDecision() {
3230
+ return {
3231
+ mode: this.elPackFilesModeProject.checked ? 'project' : 'document',
3232
+ entryPoint: this.elPackFilesEntrySelect.value
3233
+ };
3234
+ }
3075
3235
  async handlePaste(event) {
3076
3236
  const snapshot = this.workspace?.snapshot();
3077
3237
  if (!snapshot || snapshot.mode === 'read-only' || snapshot.currentPathType !== 'markdown') {
@@ -3891,6 +4051,14 @@ export class MdzipWorkspaceView {
3891
4051
  setZoom(value) {
3892
4052
  this.zoom = Math.max(0.5, Math.min(2.5, Math.round(value * 100) / 100));
3893
4053
  this.render();
4054
+ // render() only updates the --mdz-zoom CSS variable; CodeMirror caches
4055
+ // gutter/line-block heights from its own measure pass and has no way to
4056
+ // observe a custom-property change (font-size grows but the scroller's
4057
+ // own outer box doesn't, so no ResizeObserver fires either). Without
4058
+ // this, gutter row heights stay pinned to their pre-zoom pixel values
4059
+ // while .cm-line rows reflow normally, drifting the two further apart
4060
+ // with every line.
4061
+ this.cmEditor?.requestMeasure();
3894
4062
  }
3895
4063
  /**
3896
4064
  * Sets the active color scheme after construction.
@@ -4855,6 +5023,35 @@ const SHELL_HTML = `
4855
5023
  </div>
4856
5024
  </div>
4857
5025
 
5026
+ <div class="title-dialog-backdrop" data-ref="pack-files-dialog" hidden
5027
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-pack-files-dialog-heading">
5028
+ <div class="title-dialog pack-files-dialog">
5029
+ <h3 id="mdzip-pack-files-dialog-heading">Package Markdown Files</h3>
5030
+ <p>These files contain more than one Markdown document. Choose how to package them.</p>
5031
+ <fieldset class="pack-files-options">
5032
+ <legend>Mode</legend>
5033
+ <label>
5034
+ <input type="radio" name="mdzip-pack-files-mode" value="document"
5035
+ data-ref="pack-files-mode-document" checked />
5036
+ Document — one primary page; the rest are attached
5037
+ </label>
5038
+ <label>
5039
+ <input type="radio" name="mdzip-pack-files-mode" value="project"
5040
+ data-ref="pack-files-mode-project" />
5041
+ Project — a set of related documents
5042
+ </label>
5043
+ </fieldset>
5044
+ <label class="pack-files-field">
5045
+ <span>Entry point</span>
5046
+ <select data-ref="pack-files-entry"></select>
5047
+ </label>
5048
+ <div class="title-dialog-actions">
5049
+ <button type="button" data-action="cancel-pack-files">Cancel</button>
5050
+ <button type="button" class="save-title" data-ref="pack-files-confirm-btn">Package</button>
5051
+ </div>
5052
+ </div>
5053
+ </div>
5054
+
4858
5055
  <div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
4859
5056
  role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
4860
5057
  <div class="title-dialog metadata-dialog">