@mdzip/editor 1.4.8 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/view.js CHANGED
@@ -12,7 +12,7 @@ import { MdzipAssetSession, mdzipArchiveSourceId, sniffImageSize } from './asset
12
12
  import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
13
13
  import { MDZIP_RUNTIME_LIBRARIES } from './library-info.js';
14
14
  import { MdzipWorkspaceService, extensionForMime, normalizeArchivePath, relativeArchivePath } from './workspace.js';
15
- import { buildMdzipNavTree, canEditMdzipPath, escapeHtml, isOrphanedMdzipAsset, mdzipEntryIconKind, isMdzipManifestPath, isMdzipWorkspaceRelativeLink, resolveMdzipArchiveLinkTarget, renderMdzipPreviewHtml } from './workspace-view.js';
15
+ import { buildMdzipNavTree, canEditMdzipPath, escapeHtml, isOrphanedMdzipAsset, mdzipEntryIconKind, isMdzipManifestPath, isMdzipDefaultPolicyExternalLink, isMdzipExternalLink, isMdzipWorkspaceRelativeLink, parseMdzipLink, renderMdzipPreviewHtml } from './workspace-view.js';
16
16
  import { escapeMarkdownImageAlt, findImageReferenceAtOffset, formatImageEditMarkdown } from './image-edit.js';
17
17
  import { MdzipRenderingService, defaultSafeMarkdownRenderer, groupTokensIntoChunks, chunkSourceKey, collectMdzipHeadingIds, MDZIP_HEADING_ID_PREFIX, tokenEmbedsImage, tokenIsHeading } from './rendering.js';
18
18
  import { mdzipFrontMatterExtension } from './front-matter-extension.js';
@@ -23,6 +23,14 @@ const isImageFile = (path) => IMAGE_EXTENSIONS.test(path);
23
23
  function isThenable(value) {
24
24
  return typeof value?.then === 'function';
25
25
  }
26
+ function restoreAttribute(element, name, value) {
27
+ if (value === null) {
28
+ element.removeAttribute(name);
29
+ }
30
+ else {
31
+ element.setAttribute(name, value);
32
+ }
33
+ }
26
34
  const NAV_ICON_CLASS = 'nav-lucide-icon';
27
35
  const TOOLBAR_ICON_CLASS = 'toggle-icon';
28
36
  const MANIFEST_ICON_HTML = lucideIcon(FileBraces, NAV_ICON_CLASS);
@@ -1006,6 +1014,21 @@ export class MdzipWorkspaceView {
1006
1014
  // until that document's preview has mounted so there is a heading to scroll
1007
1015
  // to. Consumed by firePreviewRendered.
1008
1016
  this.pendingAnchorFragment = null;
1017
+ // The previewGeneration whose preview last mounted (or was claimed by an
1018
+ // entry renderer); scrollToAnchor() waits until it catches up.
1019
+ this.previewMountedGeneration = -1;
1020
+ this.previewMountWaiters = [];
1021
+ // True while onPreviewRendered runs — see scrollToAnchor().
1022
+ this.firingPreviewRendered = false;
1023
+ this.destroyed = false;
1024
+ // After an anchor scroll, content above the target can still change height
1025
+ // (images loading or easing open, a diagram rendering) and push it out of
1026
+ // view. This holds the target at the pane's top edge until the user
1027
+ // scrolls either pane or the preview re-renders.
1028
+ this.anchorPin = null;
1029
+ // Each link's own target/rel from before the external-link policy touched
1030
+ // it, so a policy change can restore them before re-applying.
1031
+ this.externalLinkOriginals = new WeakMap();
1009
1032
  // Guards syncScrollToPreviewBottom against overlapping drains: set to the
1010
1033
  // chunkedRenderState generation currently being force-drained, null when
1011
1034
  // none is in flight. A second bottom-edge sync that arrives mid-drain
@@ -1442,6 +1465,9 @@ export class MdzipWorkspaceView {
1442
1465
  this.teardownEntryRenderer();
1443
1466
  // Release any whenRendered() waiters so their promises do not hang.
1444
1467
  this.flushRenderedWaiters();
1468
+ this.destroyed = true;
1469
+ this.markPreviewMounted();
1470
+ this.releaseAnchorPin();
1445
1471
  try {
1446
1472
  this.workspace?.dispose();
1447
1473
  }
@@ -1594,6 +1620,54 @@ export class MdzipWorkspaceView {
1594
1620
  setImageEditOptions(options) {
1595
1621
  this.options.imageEditHandler = options.imageEditHandler;
1596
1622
  }
1623
+ /**
1624
+ * Replaces the external-link rendering policy and re-applies it to the
1625
+ * links already in the preview, without re-rendering. `undefined` restores
1626
+ * every link's own `target`/`rel`.
1627
+ */
1628
+ setExternalLinks(policy) {
1629
+ this.options.externalLinks = policy;
1630
+ this.applyExternalLinkPolicy(this.elPreviewContent);
1631
+ }
1632
+ applyExternalLinkPolicy(root) {
1633
+ const policy = this.options.externalLinks;
1634
+ for (const link of Array.from(root.querySelectorAll('a[href]'))) {
1635
+ const original = this.externalLinkOriginals.get(link);
1636
+ if (original) {
1637
+ this.externalLinkOriginals.delete(link);
1638
+ restoreAttribute(link, 'target', original.target);
1639
+ restoreAttribute(link, 'rel', original.rel);
1640
+ }
1641
+ if (!policy) {
1642
+ continue;
1643
+ }
1644
+ const rel = policy.rel ?? (policy.target === '_blank' ? 'noopener noreferrer' : undefined);
1645
+ if (policy.target === undefined && rel === undefined) {
1646
+ continue;
1647
+ }
1648
+ const href = link.getAttribute('href') ?? '';
1649
+ if (!isMdzipExternalLink(href)) {
1650
+ continue;
1651
+ }
1652
+ let external = false;
1653
+ try {
1654
+ external = policy.predicate ? policy.predicate(href) : isMdzipDefaultPolicyExternalLink(href);
1655
+ }
1656
+ catch (error) {
1657
+ this.options.onFailed?.(error);
1658
+ }
1659
+ if (!external) {
1660
+ continue;
1661
+ }
1662
+ this.externalLinkOriginals.set(link, { target: link.getAttribute('target'), rel: link.getAttribute('rel') });
1663
+ if (policy.target !== undefined) {
1664
+ link.setAttribute('target', policy.target);
1665
+ }
1666
+ if (rel !== undefined) {
1667
+ link.setAttribute('rel', rel);
1668
+ }
1669
+ }
1670
+ }
1597
1671
  applyDensityClasses() {
1598
1672
  this.elRoot.classList.remove('toolbar-density-comfortable', 'toolbar-density-compact', 'toolbar-density-dense', 'content-density-comfortable', 'content-density-compact');
1599
1673
  this.elRoot.classList.add(`toolbar-density-${this.toolbarDensity}`);
@@ -1681,6 +1755,7 @@ export class MdzipWorkspaceView {
1681
1755
  // There is no built-in preview to wait for; release any waiters.
1682
1756
  this.previewHydrated = true;
1683
1757
  this.flushRenderedWaiters();
1758
+ this.markPreviewMounted();
1684
1759
  return;
1685
1760
  }
1686
1761
  const memo = this.previewMemo;
@@ -1816,6 +1891,7 @@ export class MdzipWorkspaceView {
1816
1891
  if (codeBlockHandle) {
1817
1892
  this.previewHandles.push(codeBlockHandle);
1818
1893
  }
1894
+ this.applyExternalLinkPolicy(this.elPreviewContent);
1819
1895
  // No archive images to resolve on this path: the preview is ready now.
1820
1896
  this.firePreviewRendered(snapshot, generation);
1821
1897
  this.fireAssetsHydrated(snapshot, generation);
@@ -1838,6 +1914,7 @@ export class MdzipWorkspaceView {
1838
1914
  if (codeBlockHandle) {
1839
1915
  this.previewHandles.push(codeBlockHandle);
1840
1916
  }
1917
+ this.applyExternalLinkPolicy(this.elPreviewContent);
1841
1918
  this.firePreviewRendered(snapshot, generation);
1842
1919
  this.hydrateImages(pending, context, generation, animateImageHydration, () => {
1843
1920
  this.fireAssetsHydrated(snapshot, generation);
@@ -2466,6 +2543,7 @@ export class MdzipWorkspaceView {
2466
2543
  if (codeBlockHandle) {
2467
2544
  record.handles.push(codeBlockHandle);
2468
2545
  }
2546
+ this.applyExternalLinkPolicy(record.root);
2469
2547
  }
2470
2548
  return { cursor, mountedRoots };
2471
2549
  }
@@ -2813,12 +2891,26 @@ export class MdzipWorkspaceView {
2813
2891
  }
2814
2892
  this.restorePendingPreviewScroll(generation);
2815
2893
  this.flushPendingAnchorSoon();
2894
+ this.markPreviewMounted();
2895
+ this.firingPreviewRendered = true;
2816
2896
  try {
2817
2897
  this.options.onPreviewRendered?.(snapshot);
2818
2898
  }
2819
2899
  catch (error) {
2820
2900
  this.options.onFailed?.(error);
2821
2901
  }
2902
+ finally {
2903
+ this.firingPreviewRendered = false;
2904
+ }
2905
+ }
2906
+ /** Releases {@link scrollToAnchor} calls waiting for the current preview to mount. */
2907
+ markPreviewMounted() {
2908
+ this.previewMountedGeneration = this.previewGeneration;
2909
+ const waiters = this.previewMountWaiters;
2910
+ this.previewMountWaiters = [];
2911
+ for (const resolve of waiters) {
2912
+ resolve();
2913
+ }
2822
2914
  }
2823
2915
  /** See `pendingPreviewScrollRestore`'s doc comment. */
2824
2916
  restorePendingPreviewScroll(generation) {
@@ -3147,7 +3239,10 @@ export class MdzipWorkspaceView {
3147
3239
  // scroll, on every keystroke. Wheel/touch/mousedown (which also covers
3148
3240
  // a scrollbar-thumb drag, since that lands on this same element) are
3149
3241
  // unambiguous and cover the vast majority of real scrolling.
3150
- const markEditorGesture = () => { self.lastEditorGestureTime = performance.now(); };
3242
+ const markEditorGesture = () => {
3243
+ self.lastEditorGestureTime = performance.now();
3244
+ self.releaseAnchorPin();
3245
+ };
3151
3246
  scroller.addEventListener('wheel', markEditorGesture, { passive: true });
3152
3247
  scroller.addEventListener('touchstart', markEditorGesture, { passive: true });
3153
3248
  scroller.addEventListener('mousedown', markEditorGesture);
@@ -3944,7 +4039,10 @@ export class MdzipWorkspaceView {
3944
4039
  void this.replaceFileFromPicker(path, file);
3945
4040
  }
3946
4041
  });
3947
- const markPreviewGesture = () => { this.lastPreviewGestureTime = performance.now(); };
4042
+ const markPreviewGesture = () => {
4043
+ this.lastPreviewGestureTime = performance.now();
4044
+ this.releaseAnchorPin();
4045
+ };
3948
4046
  this.elPreviewPane.addEventListener('wheel', markPreviewGesture, { passive: true });
3949
4047
  this.elPreviewPane.addEventListener('touchstart', markPreviewGesture, { passive: true });
3950
4048
  this.elPreviewPane.addEventListener('mousedown', markPreviewGesture);
@@ -3963,17 +4061,20 @@ export class MdzipWorkspaceView {
3963
4061
  return;
3964
4062
  }
3965
4063
  const href = link.getAttribute('href') ?? '';
4064
+ const parsed = parseMdzipLink(href, snapshot.currentPath, snapshot.content.paths);
4065
+ if (this.options.onLinkActivated && this.dispatchLinkActivated(event, link, href, parsed, snapshot)) {
4066
+ return;
4067
+ }
3966
4068
  if (href.startsWith('#')) {
3967
4069
  event.preventDefault();
3968
4070
  void this.scrollPreviewToAnchor(href.slice(1));
3969
4071
  return;
3970
4072
  }
3971
- const targetPath = resolveMdzipArchiveLinkTarget(href, snapshot.currentPath, snapshot.content.paths);
3972
- if (targetPath) {
4073
+ if (parsed.targetPath) {
3973
4074
  event.preventDefault();
3974
4075
  const hashIndex = href.indexOf('#');
3975
4076
  const fragment = hashIndex >= 0 ? href.slice(hashIndex + 1) : '';
3976
- void this.openPathAndScrollToAnchor(targetPath, fragment);
4077
+ void this.openPathAndScrollToAnchor(parsed.targetPath, fragment);
3977
4078
  return;
3978
4079
  }
3979
4080
  if (this.options.onUnresolvedLinkClick && isMdzipWorkspaceRelativeLink(href)) {
@@ -4025,6 +4126,132 @@ export class MdzipWorkspaceView {
4025
4126
  await this.scrollPreviewToAnchor(pending);
4026
4127
  }
4027
4128
  }
4129
+ /** Returns true when the host prevented the click, ending all further handling. */
4130
+ dispatchLinkActivated(domEvent, link, href, parsed, snapshot) {
4131
+ let prevented = false;
4132
+ const event = {
4133
+ href,
4134
+ text: (link.textContent ?? '').trim(),
4135
+ kind: parsed.kind,
4136
+ isExternal: parsed.kind === 'external',
4137
+ isAnchor: parsed.kind === 'anchor',
4138
+ path: parsed.path,
4139
+ anchor: parsed.anchor,
4140
+ sourcePath: snapshot.currentPath,
4141
+ targetPath: parsed.targetPath,
4142
+ ctrlKey: domEvent.ctrlKey,
4143
+ metaKey: domEvent.metaKey,
4144
+ shiftKey: domEvent.shiftKey,
4145
+ altKey: domEvent.altKey,
4146
+ domEvent,
4147
+ snapshot,
4148
+ preventDefault: () => {
4149
+ prevented = true;
4150
+ },
4151
+ get defaultPrevented() {
4152
+ return prevented;
4153
+ }
4154
+ };
4155
+ try {
4156
+ this.options.onLinkActivated?.(event);
4157
+ }
4158
+ catch (error) {
4159
+ this.options.onFailed?.(error);
4160
+ }
4161
+ if (prevented) {
4162
+ domEvent.preventDefault();
4163
+ }
4164
+ return prevented;
4165
+ }
4166
+ /**
4167
+ * Scrolls the preview to a heading or an explicit `id`/`name` anchor.
4168
+ * Accepts the fragment with or without its leading `#` and matches it the
4169
+ * way a clicked `#fragment` link does: case-insensitive, percent-decoded,
4170
+ * against both bare and `user-content-`-prefixed heading ids.
4171
+ *
4172
+ * Safe to call at any point — straight after `open()`/`openWorkspace()`,
4173
+ * or from `onPreviewRendered`: it waits for the current document's
4174
+ * preview to mount, and under progressive rendering mounts the chunks up
4175
+ * to the target first. Resolves `true` when it scrolled to a matching
4176
+ * anchor (`''` and `'top'` scroll to the top and resolve `true`), and
4177
+ * `false` when nothing matches, an entry renderer owns the pane, or the
4178
+ * view is destroyed first.
4179
+ */
4180
+ async scrollToAnchor(anchor) {
4181
+ const fragment = anchor.startsWith('#') ? anchor.slice(1) : anchor;
4182
+ // onPreviewRendered runs inside the first chunk batch's completion,
4183
+ // before the lazy-continuation sentinel is armed — same race as
4184
+ // flushPendingAnchorSoon, so a call from it (or one that had to wait for
4185
+ // the mount) is deferred a frame.
4186
+ let deferFrame = this.firingPreviewRendered;
4187
+ while (!this.destroyed && this.previewMountedGeneration !== this.previewGeneration) {
4188
+ deferFrame = true;
4189
+ await new Promise((resolve) => this.previewMountWaiters.push(resolve));
4190
+ }
4191
+ if (this.destroyed) {
4192
+ return false;
4193
+ }
4194
+ if (deferFrame) {
4195
+ const generation = this.previewGeneration;
4196
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
4197
+ if (this.destroyed) {
4198
+ return false;
4199
+ }
4200
+ if (generation !== this.previewGeneration) {
4201
+ return this.scrollToAnchor(anchor);
4202
+ }
4203
+ }
4204
+ if (this.entryState) {
4205
+ return false;
4206
+ }
4207
+ const found = await this.scrollPreviewToAnchor(fragment);
4208
+ return found || !fragment || fragment.toLowerCase() === 'top';
4209
+ }
4210
+ /**
4211
+ * The anchors {@link scrollToAnchor} can reach in the current preview, in
4212
+ * document order: every heading id (without the `user-content-` prefix),
4213
+ * including headings in chunks progressive rendering hasn't mounted yet,
4214
+ * plus explicit `id`/`name` anchors on links in the mounted preview.
4215
+ * Empty before the preview first mounts.
4216
+ */
4217
+ getAvailableAnchors() {
4218
+ if (this.entryState) {
4219
+ return [];
4220
+ }
4221
+ const anchors = [];
4222
+ const seen = new Set();
4223
+ const add = (value) => {
4224
+ if (!value)
4225
+ return;
4226
+ const bare = value.startsWith(MDZIP_HEADING_ID_PREFIX) ? value.slice(MDZIP_HEADING_ID_PREFIX.length) : value;
4227
+ if (bare && !seen.has(bare)) {
4228
+ seen.add(bare);
4229
+ anchors.push(bare);
4230
+ }
4231
+ };
4232
+ const fromDom = (root) => {
4233
+ for (const element of Array.from(root.querySelectorAll('h1[id],h2[id],h3[id],h4[id],h5[id],h6[id],a[id],a[name]'))) {
4234
+ add(element.getAttribute('id'));
4235
+ add(element.getAttribute('name'));
4236
+ }
4237
+ };
4238
+ const state = this.chunkedRenderState;
4239
+ if (state && state.generation === this.previewGeneration) {
4240
+ // Walk records in order so unmounted chunks' headings land in place.
4241
+ state.records.forEach((record) => {
4242
+ if (record.root) {
4243
+ fromDom(record.root);
4244
+ }
4245
+ else {
4246
+ collectMdzipHeadingIds(record.tokens).forEach(add);
4247
+ }
4248
+ });
4249
+ }
4250
+ else {
4251
+ fromDom(this.elPreviewContent);
4252
+ }
4253
+ return anchors;
4254
+ }
4028
4255
  // Deferred a frame: firePreviewRendered runs inside the first chunk batch's
4029
4256
  // completion callback, before the lazy-continuation sentinel for the rest is
4030
4257
  // armed. Scrolling then (which may need to drain those chunks) would race it.
@@ -4103,9 +4330,35 @@ export class MdzipWorkspaceView {
4103
4330
  }
4104
4331
  return false;
4105
4332
  }
4106
- pane.scrollTop += target.getBoundingClientRect().top - pane.getBoundingClientRect().top;
4333
+ this.scrollPaneToElement(target);
4334
+ this.pinPreviewAnchor(target);
4107
4335
  return true;
4108
4336
  }
4337
+ scrollPaneToElement(target) {
4338
+ const pane = this.elPreviewPane;
4339
+ pane.scrollTop += target.getBoundingClientRect().top - pane.getBoundingClientRect().top;
4340
+ }
4341
+ /** See `anchorPin`. */
4342
+ pinPreviewAnchor(target) {
4343
+ this.releaseAnchorPin();
4344
+ if (typeof ResizeObserver === 'undefined') {
4345
+ return;
4346
+ }
4347
+ const generation = this.previewGeneration;
4348
+ const observer = new ResizeObserver(() => {
4349
+ if (generation !== this.previewGeneration || !target.isConnected) {
4350
+ this.releaseAnchorPin();
4351
+ return;
4352
+ }
4353
+ this.scrollPaneToElement(target);
4354
+ });
4355
+ observer.observe(this.elPreviewContent);
4356
+ this.anchorPin = observer;
4357
+ }
4358
+ releaseAnchorPin() {
4359
+ this.anchorPin?.disconnect();
4360
+ this.anchorPin = null;
4361
+ }
4109
4362
  async openPath(path) {
4110
4363
  if (!this.workspace) {
4111
4364
  return;