@meowdown/core 0.74.1 → 0.75.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/index.d.ts CHANGED
@@ -808,14 +808,23 @@ type PostEmbedSnapshot = {
808
808
  export declare function parsePostEmbedSnapshot(value: unknown): PostEmbedSnapshot | undefined;
809
809
  //#endregion
810
810
  //#region src/extensions/image.d.ts
811
- type ImageUrlResolver = (src: string) => string | undefined;
811
+ /**
812
+ * Maps a markdown `src` to a displayable URL, or `undefined` to skip rendering
813
+ * that image. May return a `Promise` when the answer needs data that is not
814
+ * loaded yet: the image appears once it resolves, and a rejection or an
815
+ * `undefined` result leaves the source without a preview.
816
+ */
817
+ type ImageUrlResolver = (src: string) => string | undefined | Promise<string | undefined>;
812
818
  /**
813
819
  * Options for {@link defineImage}.
814
820
  */
815
821
  interface ImageOptions {
816
822
  /**
817
823
  * Map a markdown `src` to a displayable URL, or `undefined` to skip rendering
818
- * that image. Defaults to `defaultResolveImageUrl`.
824
+ * that image. May return a `Promise`; while it is pending, an image whose
825
+ * `width` and `height` are both persisted shows a sized placeholder so the
826
+ * resolved image does not shift the layout. Defaults to
827
+ * `defaultResolveImageUrl`.
819
828
  */
820
829
  resolveImageUrl?: ImageUrlResolver;
821
830
  /**
@@ -1482,6 +1491,12 @@ interface LinkHoverOptions {
1482
1491
  * software keyboard.
1483
1492
  */
1484
1493
  export declare function defineLinkHoverHandler(onHoverChange: LinkHoverHandler, { canLeave }?: LinkHoverOptions): PlainExtension;
1494
+ /**
1495
+ * Tell the link hover handler that the user dismissed the UI it opened. The
1496
+ * link under the pointer stays silent until the pointer leaves it, so a
1497
+ * pending hover cannot reopen the UI.
1498
+ */
1499
+ export declare function dismissLinkHover(state: EditorState): void;
1485
1500
  //#endregion
1486
1501
  //#region src/extensions/link-paste.d.ts
1487
1502
  /**
@@ -1674,4 +1689,4 @@ export declare function getSelectedText(state: EditorState): string;
1674
1689
  */
1675
1690
  export declare function getVirtualElementFromRange(view: EditorView, range: PositionRange): VirtualElement;
1676
1691
  //#endregion
1677
- export { type AcceptPendingReplacementOptions, type CheckRoundTripOptions, type CodeBlockAttrs, type CodeBlockFenceStyle, type CodeToken, type DocToMarkdownOptions, type EditorConfig, type EditorExtension, type EditorExtensionOptions, type ExitBoundaryHandler, type ExitBoundaryOptions, type FileClickHandler, type FileClickPayload, type FileInfo, type FileInfoResolver, type FileLinkOptions, type FileLinkPayload, type FileLinkResolver, type FilePasteHandler, type FilePasteOptions, type FileSaveErrorHandler, type FileViewOptions, type FollowLinkHandlers, type ImageClickHandler, type ImageClickPayload, type ImageOptions, type InlineMarkContext, type InlineMarkOptions, type KaTeXRender, type LanguageItem, type LinkAttrs, type LinkClickHandler, type LinkClickPayload, type LinkCopyHandler, type LinkCopyPayload, type LinkEditHandler, type LinkEditOptions, type LinkHoverHandler, type LinkHoverOptions, type LinkPreview, type LinkPreviewResolver, type LinkUnit, type ListMarker, type MarkChunk, type MarkMode, type MarkName, type MarkdownToDocOptions, type MdFileAttrs, type MdImageAttrs, type MdLinkTextAttrs, type MdMathAttrs, type MdWikilinkAttrs, type MeowdownCodeBlockAttrs, type MeowdownHTMLCommentAttrs, type MeowdownListAttrs, type MeowdownTableCellAttrs, type NodeName, type ParsedWikiEmbed, type PendingReplacement, type PendingReplacementEvent, type PendingReplacementHandler, type PendingReplacementMode, type PendingReplacementOutcome, type PlaceholderOptions, type PositionRange, type PostEmbedSnapshot, Priority, type ReferenceDefinition, type ReferenceDefinitionIndex, type ReferenceDefinitions, type RoundTripFidelity, type SearchStatus, type SearchStatusHandler, type StartPendingReplacementOptions, type TableColumnAlign, type TagClickHandler, type TagClickPayload, type TypedEditor, type TypedMarkBuilders, type VirtualElement, type WikiEmbedOptions, type WikiEmbedResolution, type WikiEmbedResolver, type WikilinkClickHandler, type WikilinkClickPayload, type WikilinkHoverHandler, type WikilinkHoverHit, type WikilinkOptions, type WikilinkPayload, type WikilinkResolution, type WikilinkResolver, type XPostMediaClickHandler, type XPostResolver, type YouTubeVideoClickHandler, type YouTubeVideoResolver, defineCodeBlockPreviewPlugin, definePlaceholder, defineSearchStatusHandler, getSearchStatus, isCodeBlockPreviewHiddenDecoration, withPriority };
1692
+ export { type AcceptPendingReplacementOptions, type CheckRoundTripOptions, type CodeBlockAttrs, type CodeBlockFenceStyle, type CodeToken, type DocToMarkdownOptions, type EditorConfig, type EditorExtension, type EditorExtensionOptions, type ExitBoundaryHandler, type ExitBoundaryOptions, type FileClickHandler, type FileClickPayload, type FileInfo, type FileInfoResolver, type FileLinkOptions, type FileLinkPayload, type FileLinkResolver, type FilePasteHandler, type FilePasteOptions, type FileSaveErrorHandler, type FileViewOptions, type FollowLinkHandlers, type ImageClickHandler, type ImageClickPayload, type ImageOptions, type ImageUrlResolver, type InlineMarkContext, type InlineMarkOptions, type KaTeXRender, type LanguageItem, type LinkAttrs, type LinkClickHandler, type LinkClickPayload, type LinkCopyHandler, type LinkCopyPayload, type LinkEditHandler, type LinkEditOptions, type LinkHoverHandler, type LinkHoverOptions, type LinkPreview, type LinkPreviewResolver, type LinkUnit, type ListMarker, type MarkChunk, type MarkMode, type MarkName, type MarkdownToDocOptions, type MdFileAttrs, type MdImageAttrs, type MdLinkTextAttrs, type MdMathAttrs, type MdWikilinkAttrs, type MeowdownCodeBlockAttrs, type MeowdownHTMLCommentAttrs, type MeowdownListAttrs, type MeowdownTableCellAttrs, type NodeName, type ParsedWikiEmbed, type PendingReplacement, type PendingReplacementEvent, type PendingReplacementHandler, type PendingReplacementMode, type PendingReplacementOutcome, type PlaceholderOptions, type PositionRange, type PostEmbedSnapshot, Priority, type ReferenceDefinition, type ReferenceDefinitionIndex, type ReferenceDefinitions, type RoundTripFidelity, type SearchStatus, type SearchStatusHandler, type StartPendingReplacementOptions, type TableColumnAlign, type TagClickHandler, type TagClickPayload, type TypedEditor, type TypedMarkBuilders, type VirtualElement, type WikiEmbedOptions, type WikiEmbedResolution, type WikiEmbedResolver, type WikilinkClickHandler, type WikilinkClickPayload, type WikilinkHoverHandler, type WikilinkHoverHit, type WikilinkOptions, type WikilinkPayload, type WikilinkResolution, type WikilinkResolver, type XPostMediaClickHandler, type XPostResolver, type YouTubeVideoClickHandler, type YouTubeVideoResolver, defineCodeBlockPreviewPlugin, definePlaceholder, defineSearchStatusHandler, getSearchStatus, isCodeBlockPreviewHiddenDecoration, withPriority };
package/dist/index.js CHANGED
@@ -84,20 +84,86 @@ function getIsComposing() {
84
84
  }
85
85
 
86
86
  //#endregion
87
- //#region src/utils/transaction.ts
87
+ //#region src/utils/input-modality.ts
88
+ const KEYBOARD_MODALITY_KEYS = /* @__PURE__ */ new Set([
89
+ "ArrowLeft",
90
+ "ArrowRight",
91
+ "ArrowUp",
92
+ "ArrowDown",
93
+ "Home",
94
+ "End",
95
+ "PageUp",
96
+ "PageDown"
97
+ ]);
98
+ let lastIsTouchInput = false;
99
+ let lastIsPointerSelection = false;
100
+ const listeners = /* @__PURE__ */ new Set();
101
+ function setIsTouchInput(isTouchInput) {
102
+ if (isTouchInput === lastIsTouchInput) return;
103
+ lastIsTouchInput = isTouchInput;
104
+ for (const listener of listeners) listener();
105
+ }
106
+ function handlePointerDown(event) {
107
+ const target = event.target;
108
+ lastIsPointerSelection = target instanceof Element && !!target.closest(".ProseMirror");
109
+ const pointerType = event.pointerType;
110
+ if (pointerType === "mouse") setIsTouchInput(false);
111
+ else if (pointerType === "touch" || pointerType === "pen") setIsTouchInput(true);
112
+ }
113
+ function handleKeyDown(event) {
114
+ if (getIsComposing() || event.isComposing) return;
115
+ lastIsPointerSelection = false;
116
+ if (KEYBOARD_MODALITY_KEYS.has(event.key) || event.metaKey || event.ctrlKey) setIsTouchInput(false);
117
+ }
118
+ if (typeof window !== "undefined") {
119
+ window.addEventListener("pointerdown", handlePointerDown, {
120
+ capture: true,
121
+ passive: true
122
+ });
123
+ window.addEventListener("keydown", handleKeyDown, {
124
+ capture: true,
125
+ passive: true
126
+ });
127
+ }
88
128
  /**
89
- * Whether the given transaction is selection transactions directly caused by mouse or touch input.
129
+ * Whether the user last drove the selection with a finger (or pen) on the
130
+ * screen, as opposed to precise input (hardware keyboard navigation).
90
131
  *
91
- * See https://code.haverbeke.berlin/prosemirror/prosemirror-view/src/tag/1.42.2/src/input.ts#L191
132
+ * Starts as `false` and follows the events; a touch device flips it on the
133
+ * first tap, which necessarily precedes any caret. Only navigation keys and
134
+ * modifier combos flip it back: letter keys are ignored because a software
135
+ * keyboard sends them too, and the software keyboard's own caret gestures (a
136
+ * spacebar long-press drag) reach the page as bare `selectionchange` events
137
+ * with no key or touch events at all, so staying on the previous value is the
138
+ * correct reading for them.
139
+ */
140
+ function getIsTouchInput() {
141
+ return lastIsTouchInput;
142
+ }
143
+ /**
144
+ * Whether the next selection change in an editor comes from a pointer: the
145
+ * last `pointerdown` landed inside an editor and no key was pressed since.
146
+ */
147
+ function getIsPointerSelection() {
148
+ return lastIsPointerSelection;
149
+ }
150
+ /**
151
+ * Calls `listener` whenever {@link getIsTouchInput} may report a new value.
92
152
  */
93
- function isPointerSelectionTransaction(tr) {
94
- return !!tr.getMeta("pointer");
153
+ function onIsTouchInputChange(listener) {
154
+ listeners.add(listener);
155
+ return () => {
156
+ listeners.delete(listener);
157
+ };
95
158
  }
159
+
160
+ //#endregion
161
+ //#region src/utils/transaction.ts
96
162
  /**
97
163
  * Whether any of the given transactions are selection transactions directly caused by mouse or touch input.
98
164
  */
99
165
  function hasPointerSelectionTransaction(transactions) {
100
- return transactions.some(isPointerSelectionTransaction);
166
+ return getIsPointerSelection() && transactions.some((tr) => tr.selectionSet && !tr.docChanged);
101
167
  }
102
168
 
103
169
  //#endregion
@@ -512,9 +578,8 @@ function createCaretSnapPlugin(marks) {
512
578
  if (markNames.length === 0) return null;
513
579
  const selection = newState.selection;
514
580
  if (!isTextSelection(selection)) return null;
515
- const isPointer = hasPointerSelectionTransaction(transactions);
516
581
  if (!selection.empty) {
517
- if (!isPointer) return null;
582
+ if (!hasPointerSelectionTransaction(transactions)) return null;
518
583
  const from = getMarkRangeStrictlyAround(newState, selection.from, markNames)?.from ?? selection.from;
519
584
  const to = getMarkRangeStrictlyAround(newState, selection.to, markNames)?.to ?? selection.to;
520
585
  if (from === selection.from && to === selection.to) return null;
@@ -524,6 +589,7 @@ function createCaretSnapPlugin(marks) {
524
589
  }
525
590
  const range = getMarkRangeStrictlyAround(newState, selection.head, markNames);
526
591
  if (!range) return null;
592
+ const isPointer = hasPointerSelectionTransaction(transactions);
527
593
  const head = getUnitEdge(range, oldState.selection.head, selection.head, isPointer);
528
594
  return newState.tr.setSelection(TextSelection.create(newState.doc, head));
529
595
  }
@@ -614,6 +680,9 @@ function handleMouseDown(view, event) {
614
680
  const { doc } = view.state;
615
681
  const lastBlock = doc.lastChild;
616
682
  if (!lastBlock || acceptsCaretAtEnd(lastBlock)) return false;
683
+ let bottom = lastBlock;
684
+ while (bottom && !bottom.isTextblock && !isNodeOfType(bottom, "table")) bottom = bottom.lastChild;
685
+ if (bottom && bottom.content.size === 0 && acceptsCaretAtEnd(bottom)) return false;
617
686
  const lastBlockDOM = view.nodeDOM(doc.content.size - lastBlock.nodeSize);
618
687
  if (!(lastBlockDOM instanceof HTMLElement)) return false;
619
688
  if (event.clientY <= lastBlockDOM.getBoundingClientRect().bottom) return false;
@@ -628,7 +697,8 @@ function handleMouseDown(view, event) {
628
697
  }
629
698
  /**
630
699
  * A press below the last block appends an empty paragraph and puts the caret
631
- * in it, when that block would otherwise keep the caret inside itself.
700
+ * in it, when that block would otherwise keep the caret inside itself and
701
+ * does not end in an empty line.
632
702
  */
633
703
  function defineClickBelow() {
634
704
  return definePlugin(new Plugin({
@@ -3469,8 +3539,8 @@ function createSnapPlugin() {
3469
3539
  if (getMarkMode(newState) !== "hide") return null;
3470
3540
  const selection = newState.selection;
3471
3541
  if (!isTextSelection(selection)) return null;
3472
- const isPointer = hasPointerSelectionTransaction(transactions);
3473
3542
  if (selection.empty) {
3543
+ const isPointer = hasPointerSelectionTransaction(transactions);
3474
3544
  const next = getRestPosition(newState, oldState.selection.head, selection.head, isPointer);
3475
3545
  if (next === selection.head) return null;
3476
3546
  return newState.tr.setSelection(TextSelection.create(newState.doc, next));
@@ -3922,12 +3992,8 @@ var ImageMarkView = class {
3922
3992
  this.#contentDOM.className = "md-image-view-content md-atom-view-content";
3923
3993
  this.#contentDOM.dataset.testid = "image-source";
3924
3994
  for (const [name, value] of Object.entries(NON_PROSE_ATTRS)) this.#contentDOM.setAttribute(name, value);
3925
- const preview = this.#renderPreview();
3926
- if (preview) {
3927
- preview.contentEditable = "false";
3928
- this.#dom.appendChild(preview);
3929
- }
3930
3995
  this.#dom.appendChild(this.#contentDOM);
3996
+ this.#mountPreview();
3931
3997
  }
3932
3998
  get dom() {
3933
3999
  return this.#dom;
@@ -3954,22 +4020,67 @@ var ImageMarkView = class {
3954
4020
  this.#destroyed = true;
3955
4021
  }
3956
4022
  /**
3957
- * Build the inline preview for the image `src`: a post-embed card or a
3958
- * resizable `<img>`.
4023
+ * Mount the inline preview for the image `src`, if any: a post-embed card, a
4024
+ * resizable `<img>`, or, while an asynchronous resolver is pending and the
4025
+ * size is persisted, an empty box of that size that the image later fills.
3959
4026
  */
3960
- #renderPreview() {
4027
+ #mountPreview() {
3961
4028
  const { src } = this.#attrs;
3962
- const wrapper = document.createElement("span");
3963
- wrapper.className = "md-image-view-preview md-atom-view-preview";
3964
4029
  const kind = matchEmbed(src);
3965
4030
  if (kind) {
4031
+ const wrapper = this.#createWrapper();
3966
4032
  wrapper.dataset.testid = `${kind}-embed`;
3967
4033
  wrapper.dataset.postEmbed = kind;
3968
4034
  wrapper.appendChild(this.#buildPostEmbed(kind, src));
3969
- return wrapper;
4035
+ this.#insertPreview(wrapper);
4036
+ return;
3970
4037
  }
3971
- const url = (this.#resolveImageUrl ?? defaultResolveImageUrl)(src);
3972
- if (!url) return void 0;
4038
+ const resolved = (this.#resolveImageUrl ?? defaultResolveImageUrl)(src);
4039
+ if (resolved instanceof Promise) {
4040
+ const placeholder = this.#hasPersistedSize() ? this.#renderImage(null) : void 0;
4041
+ if (placeholder) this.#insertPreview(placeholder);
4042
+ resolved.then((url) => this.#settle(placeholder, url), () => this.#settle(placeholder, void 0));
4043
+ return;
4044
+ }
4045
+ if (resolved) this.#insertPreview(this.#renderImage(resolved));
4046
+ }
4047
+ #hasPersistedSize() {
4048
+ return this.#attrs.width != null && this.#attrs.height != null;
4049
+ }
4050
+ /**
4051
+ * Apply a resolver's asynchronous answer: fill the placeholder with the
4052
+ * image, mount the image from scratch, or drop the placeholder when there is
4053
+ * nothing to show. A view destroyed in the meantime (its `src` changed, or it
4054
+ * left the document) ignores the answer.
4055
+ */
4056
+ #settle(placeholder, url) {
4057
+ if (this.#destroyed) return;
4058
+ if (!url) {
4059
+ placeholder?.remove();
4060
+ this.#resizableRoot = void 0;
4061
+ return;
4062
+ }
4063
+ if (placeholder && this.#resizableRoot) this.#attachImage(this.#resizableRoot, url);
4064
+ else this.#insertPreview(this.#renderImage(url));
4065
+ }
4066
+ #createWrapper() {
4067
+ const wrapper = document.createElement("span");
4068
+ wrapper.className = "md-image-view-preview md-atom-view-preview";
4069
+ return wrapper;
4070
+ }
4071
+ /**
4072
+ * Insert a preview in front of the source text.
4073
+ */
4074
+ #insertPreview(preview) {
4075
+ preview.contentEditable = "false";
4076
+ this.#dom.insertBefore(preview, this.#contentDOM);
4077
+ }
4078
+ /**
4079
+ * The resizable image preview. `null` builds the sized box without its
4080
+ * `<img>`, for an asynchronous resolver to fill through `#attachImage`.
4081
+ */
4082
+ #renderImage(url) {
4083
+ const wrapper = this.#createWrapper();
3973
4084
  wrapper.dataset.testid = "image-preview";
3974
4085
  wrapper.appendChild(this.#buildResizableImage(url));
3975
4086
  return wrapper;
@@ -4052,6 +4163,25 @@ var ImageMarkView = class {
4052
4163
  root.className = "md-image-resizable";
4053
4164
  root.dataset.testid = "image-resizable";
4054
4165
  root.setAttribute("data-loading", "");
4166
+ applySize(root, this.#attrs.width, this.#attrs.height);
4167
+ const handle = document.createElement("prosekit-resizable-handle");
4168
+ handle.className = "md-image-resize-handle";
4169
+ handle.setAttribute("position", "bottom-right");
4170
+ handle.addEventListener("click", (event) => event.stopPropagation());
4171
+ root.appendChild(handle);
4172
+ root.addEventListener("resizeEnd", (event) => {
4173
+ const { width: nextWidth, height: nextHeight } = event.detail;
4174
+ commitImageSize(this.#view, this.#contentDOM, nextWidth, nextHeight);
4175
+ });
4176
+ this.#resizableRoot = root;
4177
+ if (url != null) this.#attachImage(root, url);
4178
+ return root;
4179
+ }
4180
+ /**
4181
+ * Put the `<img>` for `url` into a resizable root built by
4182
+ * `#buildResizableImage`, in front of its handle.
4183
+ */
4184
+ #attachImage(root, url) {
4055
4185
  const image = document.createElement("img");
4056
4186
  image.src = url;
4057
4187
  image.alt = this.#attrs.alt;
@@ -4067,19 +4197,8 @@ var ImageMarkView = class {
4067
4197
  image.addEventListener("error", () => {
4068
4198
  root.removeAttribute("data-loading");
4069
4199
  });
4070
- root.appendChild(image);
4071
- const handle = document.createElement("prosekit-resizable-handle");
4072
- handle.className = "md-image-resize-handle";
4073
- handle.setAttribute("position", "bottom-right");
4074
- handle.addEventListener("click", (event) => event.stopPropagation());
4075
- root.appendChild(handle);
4076
- root.addEventListener("resizeEnd", (event) => {
4077
- const { width: nextWidth, height: nextHeight } = event.detail;
4078
- commitImageSize(this.#view, this.#contentDOM, nextWidth, nextHeight);
4079
- });
4080
- this.#resizableRoot = root;
4200
+ root.prepend(image);
4081
4201
  this.#image = image;
4082
- return root;
4083
4202
  }
4084
4203
  };
4085
4204
  /**
@@ -8478,32 +8597,29 @@ const EDITOR_KEY_BINDINGS = {
8478
8597
 
8479
8598
  //#endregion
8480
8599
  //#region src/extensions/mark-hover.ts
8600
+ const IDLE = { kind: "idle" };
8601
+ function getHit(phase) {
8602
+ return phase.kind === "active" || phase.kind === "leaving" ? phase.hit : void 0;
8603
+ }
8481
8604
  /**
8482
- * Delegate hover tracking for a rendered mark to the editor root.
8483
- *
8484
- * Movement within a mark is de-duplicated. The active hit is also revalidated
8485
- * after every editor update, so deleting, replacing, or rewriting a hovered
8486
- * mark emits leave even when the pointer itself never moves. Destroying the
8487
- * editor or removing the extension emits leave as well.
8488
- *
8489
- * With `tap`, a touch tap enters too. The browser is the tap recognizer:
8490
- * only a recognized stationary single-finger tap synthesizes the
8491
- * compatibility mouse events and the trailing `click`; scrolls, drags,
8492
- * long-presses, and multi-finger gestures never produce them.
8605
+ * The tracked element the pointer is on, if any.
8493
8606
  */
8494
- function defineMarkHoverHandler(config) {
8495
- const { openDelay, closeDelay, tap } = config;
8607
+ function getPointerElement(phase) {
8608
+ switch (phase.kind) {
8609
+ case "pending":
8610
+ case "dismissed": return phase.element;
8611
+ case "active": return phase.hit.element;
8612
+ default: return;
8613
+ }
8614
+ }
8615
+ function createHoverTracker(config) {
8616
+ const { openDelay, closeDelay } = config;
8617
+ let phase = IDLE;
8496
8618
  /**
8497
- * What `onHoverChange` last received, while that is a hit.
8619
+ * Runs exactly while the phase is `pending` or `leaving`.
8498
8620
  */
8499
- let emitted;
8500
8621
  let timer;
8501
8622
  /**
8502
- * The element of a scheduled enter, `null` for a scheduled leave,
8503
- * `undefined` when nothing is scheduled.
8504
- */
8505
- let scheduledElement;
8506
- /**
8507
8623
  * The type of the pointer that spawned the current event sequence:
8508
8624
  * compatibility mouse events carry no pointer type of their own.
8509
8625
  */
@@ -8514,127 +8630,201 @@ function defineMarkHoverHandler(config) {
8514
8630
  const findPayloadForElement = (view, element) => {
8515
8631
  return config.findPayloadForElement ? config.findPayloadForElement(view, element) : config.findPayloadAt(view.state, view.posAtDOM(element, 0));
8516
8632
  };
8517
- const cancel = () => {
8633
+ /**
8634
+ * The only transition. It restarts the timer the next phase needs and
8635
+ * reports a change of the hit element.
8636
+ */
8637
+ const go = (next) => {
8518
8638
  clearTimeout(timer);
8519
- scheduledElement = void 0;
8639
+ const previousHit = getHit(phase);
8640
+ phase = next;
8641
+ if (next.kind === "pending") timer = setTimeout(finishOpen, openDelay);
8642
+ if (next.kind === "leaving") timer = setTimeout(finishLeave, closeDelay);
8643
+ const hit = getHit(next);
8644
+ if (hit?.element !== previousHit?.element) config.onHoverChange(hit);
8520
8645
  };
8521
- const emit = (hit) => {
8522
- cancel();
8523
- if (!hit && !emitted) return;
8524
- emitted = hit;
8525
- config.onHoverChange(hit);
8646
+ const finishOpen = () => {
8647
+ if (phase.kind !== "pending") return;
8648
+ const { element, view } = phase;
8649
+ const payload = element.isConnected ? findPayloadForElement(view, element) : void 0;
8650
+ go(payload == null ? IDLE : {
8651
+ kind: "active",
8652
+ hit: {
8653
+ payload,
8654
+ element
8655
+ }
8656
+ });
8526
8657
  };
8527
- const scheduleLeave = () => {
8528
- cancel();
8529
- scheduledElement = null;
8530
- const fire = () => {
8531
- if (config.canLeave?.() === false) {
8532
- timer = setTimeout(fire, closeDelay);
8658
+ const finishLeave = () => {
8659
+ if (phase.kind !== "leaving") return;
8660
+ go(config.canLeave?.() === false ? phase : IDLE);
8661
+ };
8662
+ const startPending = (view, element) => {
8663
+ if (findPayloadForElement(view, element) == null) return;
8664
+ go({
8665
+ kind: "pending",
8666
+ element,
8667
+ view
8668
+ });
8669
+ };
8670
+ const handleOver = (view, event) => {
8671
+ const element = findClosestMark(event.target);
8672
+ if (!element || !view.dom.contains(element)) return;
8673
+ switch (phase.kind) {
8674
+ case "idle": return startPending(view, element);
8675
+ case "pending":
8676
+ case "dismissed":
8677
+ if (element !== phase.element) startPending(view, element);
8678
+ return;
8679
+ case "active":
8680
+ case "leaving": {
8681
+ if (element === phase.hit.element) return go({
8682
+ kind: "active",
8683
+ hit: phase.hit
8684
+ });
8685
+ const payload = findPayloadForElement(view, element);
8686
+ if (payload != null) go({
8687
+ kind: "active",
8688
+ hit: {
8689
+ payload,
8690
+ element
8691
+ }
8692
+ });
8533
8693
  return;
8534
8694
  }
8535
- emit(void 0);
8536
- };
8537
- timer = setTimeout(fire, closeDelay);
8695
+ }
8696
+ };
8697
+ const handleOut = (event) => {
8698
+ const element = getPointerElement(phase);
8699
+ if (!element) return;
8700
+ const related = event.relatedTarget;
8701
+ if (related instanceof Node && element.contains(related)) return;
8702
+ go(phase.kind === "active" ? {
8703
+ kind: "leaving",
8704
+ hit: phase.hit
8705
+ } : IDLE);
8706
+ };
8707
+ const handlePointerDown = (event) => {
8708
+ lastPointerType = event.pointerType;
8538
8709
  };
8539
- const enter = (view, element) => {
8710
+ const handleMouseDown = (event) => {
8711
+ if (lastPointerType !== "touch" || !findClosestMark(event.target)) return false;
8712
+ event.preventDefault();
8713
+ return true;
8714
+ };
8715
+ const handleClick = (view, event) => {
8716
+ if (lastPointerType !== "touch") return false;
8717
+ const element = findClosestMark(event.target);
8718
+ if (!element || !view.dom.contains(element)) {
8719
+ go(IDLE);
8720
+ return false;
8721
+ }
8722
+ event.preventDefault();
8540
8723
  const payload = findPayloadForElement(view, element);
8541
- if (payload == null) return;
8542
- if (emitted) {
8543
- emit({
8724
+ if (payload != null) go({
8725
+ kind: "active",
8726
+ hit: {
8544
8727
  payload,
8545
8728
  element
8546
- });
8729
+ }
8730
+ });
8731
+ return true;
8732
+ };
8733
+ const update = (view) => {
8734
+ if (phase.kind === "dismissed") {
8735
+ if (!view.dom.contains(phase.element)) go(IDLE);
8547
8736
  return;
8548
8737
  }
8549
- cancel();
8550
- scheduledElement = element;
8551
- timer = setTimeout(() => {
8552
- const fresh = element.isConnected ? findPayloadForElement(view, element) : void 0;
8553
- if (fresh == null) {
8554
- cancel();
8555
- return;
8556
- }
8557
- emit({
8558
- payload: fresh,
8738
+ if (phase.kind !== "active" && phase.kind !== "leaving") return;
8739
+ const { element } = phase.hit;
8740
+ if (!element.isConnected || !view.dom.contains(element)) return go(IDLE);
8741
+ const payload = findPayloadForElement(view, element);
8742
+ if (payload == null || !config.isSamePayload(phase.hit.payload, payload)) return go(IDLE);
8743
+ phase = {
8744
+ kind: phase.kind,
8745
+ hit: {
8746
+ payload,
8559
8747
  element
8560
- });
8561
- }, openDelay);
8748
+ }
8749
+ };
8562
8750
  };
8563
- const handleOver = (view, event) => {
8564
- const element = findClosestMark(event.target);
8565
- if (!element || !view.dom.contains(element)) return;
8566
- if (element === emitted?.element) {
8567
- if (scheduledElement === null) cancel();
8568
- return;
8751
+ const dismiss = () => {
8752
+ switch (phase.kind) {
8753
+ case "pending": return go({
8754
+ kind: "dismissed",
8755
+ element: phase.element
8756
+ });
8757
+ case "active": return go({
8758
+ kind: "dismissed",
8759
+ element: phase.hit.element
8760
+ });
8761
+ case "leaving": return go(IDLE);
8569
8762
  }
8570
- if (element === scheduledElement) return;
8571
- enter(view, element);
8572
8763
  };
8573
- const handleOut = (event) => {
8574
- const active = emitted?.element ?? scheduledElement;
8575
- if (!active) return;
8576
- const related = event.relatedTarget;
8577
- if (related instanceof Node && active.contains(related)) return;
8578
- if (emitted) scheduleLeave();
8579
- else cancel();
8764
+ return {
8765
+ handleOver,
8766
+ handleOut,
8767
+ handlePointerDown,
8768
+ handleMouseDown,
8769
+ handleClick,
8770
+ update,
8771
+ dismiss,
8772
+ destroy: () => go(IDLE)
8580
8773
  };
8774
+ }
8775
+ /**
8776
+ * Delegate hover tracking for a rendered mark to the editor root.
8777
+ *
8778
+ * Movement within a mark is de-duplicated. The active hit is also revalidated
8779
+ * after every editor update, so deleting, replacing, or rewriting a hovered
8780
+ * mark emits leave even when the pointer itself never moves. Destroying the
8781
+ * editor or removing the extension emits leave as well.
8782
+ *
8783
+ * With `tap`, a touch tap enters too. The browser is the tap recognizer:
8784
+ * only a recognized stationary single-finger tap synthesizes the
8785
+ * compatibility mouse events and the trailing `click`; scrolls, drags,
8786
+ * long-presses, and multi-finger gestures never produce them.
8787
+ *
8788
+ * The tracker lives in the plugin state, so `key.getState(state)?.dismiss()`
8789
+ * reaches the one tracking that editor.
8790
+ */
8791
+ function defineMarkHoverHandler(config) {
8792
+ const { key } = config;
8581
8793
  return definePlugin(new Plugin({
8582
- key: config.key,
8794
+ key,
8795
+ state: {
8796
+ init: () => createHoverTracker(config),
8797
+ apply: (_tr, tracker) => tracker
8798
+ },
8583
8799
  props: { handleDOMEvents: {
8584
8800
  mouseover: (view, event) => {
8585
- handleOver(view, event);
8801
+ key.getState(view.state)?.handleOver(view, event);
8586
8802
  return false;
8587
8803
  },
8588
- mouseout: (_view, event) => {
8589
- handleOut(event);
8804
+ mouseout: (view, event) => {
8805
+ key.getState(view.state)?.handleOut(event);
8590
8806
  return false;
8591
8807
  },
8592
- ...tap && {
8593
- pointerdown: (_view, event) => {
8594
- lastPointerType = event.pointerType;
8808
+ ...config.tap && {
8809
+ pointerdown: (view, event) => {
8810
+ key.getState(view.state)?.handlePointerDown(event);
8595
8811
  return false;
8596
8812
  },
8597
- mousedown: (_view, event) => {
8598
- if (lastPointerType !== "touch" || !findClosestMark(event.target)) return false;
8599
- event.preventDefault();
8600
- return true;
8813
+ mousedown: (view, event) => {
8814
+ return key.getState(view.state)?.handleMouseDown(event) ?? false;
8601
8815
  },
8602
8816
  click: (view, event) => {
8603
- if (lastPointerType !== "touch") return false;
8604
- const element = findClosestMark(event.target);
8605
- if (!element || !view.dom.contains(element)) {
8606
- emit(void 0);
8607
- return false;
8608
- }
8609
- event.preventDefault();
8610
- const payload = findPayloadForElement(view, element);
8611
- if (payload != null) emit({
8612
- payload,
8613
- element
8614
- });
8615
- return true;
8817
+ return key.getState(view.state)?.handleClick(view, event) ?? false;
8616
8818
  }
8617
8819
  }
8618
8820
  } },
8619
- view: () => ({
8620
- update: (view) => {
8621
- if (!emitted) return;
8622
- if (!emitted.element.isConnected || !view.dom.contains(emitted.element)) {
8623
- emit(void 0);
8624
- return;
8625
- }
8626
- const payload = findPayloadForElement(view, emitted.element);
8627
- if (payload == null || !config.isSamePayload(emitted.payload, payload)) {
8628
- emit(void 0);
8629
- return;
8630
- }
8631
- emitted = {
8632
- ...emitted,
8633
- payload
8634
- };
8635
- },
8636
- destroy: () => emit(void 0)
8637
- })
8821
+ view: (view) => {
8822
+ const tracker = key.getState(view.state);
8823
+ return {
8824
+ update: (view) => tracker?.update(view),
8825
+ destroy: () => tracker?.destroy()
8826
+ };
8827
+ }
8638
8828
  }));
8639
8829
  }
8640
8830
 
@@ -8674,6 +8864,14 @@ function defineLinkHoverHandler(onHoverChange, { canLeave } = {}) {
8674
8864
  onHoverChange
8675
8865
  });
8676
8866
  }
8867
+ /**
8868
+ * Tell the link hover handler that the user dismissed the UI it opened. The
8869
+ * link under the pointer stays silent until the pointer leaves it, so a
8870
+ * pending hover cannot reopen the UI.
8871
+ */
8872
+ function dismissLinkHover(state) {
8873
+ linkHoverKey.getState(state)?.dismiss();
8874
+ }
8677
8875
 
8678
8876
  //#endregion
8679
8877
  //#region src/utils/force-reflow.ts
@@ -8681,69 +8879,6 @@ function forceReflow(element) {
8681
8879
  element.offsetWidth;
8682
8880
  }
8683
8881
 
8684
- //#endregion
8685
- //#region src/utils/input-modality.ts
8686
- const KEYBOARD_MODALITY_KEYS = /* @__PURE__ */ new Set([
8687
- "ArrowLeft",
8688
- "ArrowRight",
8689
- "ArrowUp",
8690
- "ArrowDown",
8691
- "Home",
8692
- "End",
8693
- "PageUp",
8694
- "PageDown"
8695
- ]);
8696
- let lastIsTouchInput = false;
8697
- const listeners = /* @__PURE__ */ new Set();
8698
- function setIsTouchInput(isTouchInput) {
8699
- if (isTouchInput === lastIsTouchInput) return;
8700
- lastIsTouchInput = isTouchInput;
8701
- for (const listener of listeners) listener();
8702
- }
8703
- function handlePointerDown(event) {
8704
- const pointerType = event.pointerType;
8705
- if (pointerType === "mouse") setIsTouchInput(false);
8706
- else if (pointerType === "touch" || pointerType === "pen") setIsTouchInput(true);
8707
- }
8708
- function handleKeyDown(event) {
8709
- if (getIsComposing() || event.isComposing) return;
8710
- if (KEYBOARD_MODALITY_KEYS.has(event.key) || event.metaKey || event.ctrlKey) setIsTouchInput(false);
8711
- }
8712
- if (typeof window !== "undefined") {
8713
- window.addEventListener("pointerdown", handlePointerDown, {
8714
- capture: true,
8715
- passive: true
8716
- });
8717
- window.addEventListener("keydown", handleKeyDown, {
8718
- capture: true,
8719
- passive: true
8720
- });
8721
- }
8722
- /**
8723
- * Whether the user last drove the selection with a finger (or pen) on the
8724
- * screen, as opposed to precise input (hardware keyboard navigation).
8725
- *
8726
- * Starts as `false` and follows the events; a touch device flips it on the
8727
- * first tap, which necessarily precedes any caret. Only navigation keys and
8728
- * modifier combos flip it back: letter keys are ignored because a software
8729
- * keyboard sends them too, and the software keyboard's own caret gestures (a
8730
- * spacebar long-press drag) reach the page as bare `selectionchange` events
8731
- * with no key or touch events at all, so staying on the previous value is the
8732
- * correct reading for them.
8733
- */
8734
- function getIsTouchInput() {
8735
- return lastIsTouchInput;
8736
- }
8737
- /**
8738
- * Calls `listener` whenever {@link getIsTouchInput} may report a new value.
8739
- */
8740
- function onIsTouchInputChange(listener) {
8741
- listeners.add(listener);
8742
- return () => {
8743
- listeners.delete(listener);
8744
- };
8745
- }
8746
-
8747
8882
  //#endregion
8748
8883
  //#region src/extensions/virtual-caret.ts
8749
8884
  const key = new PluginKey("meowdown-virtual-caret");
@@ -9041,4 +9176,4 @@ function getVirtualElementFromRange(view, range) {
9041
9176
  }
9042
9177
 
9043
9178
  //#endregion
9044
- export { EDITOR_KEY_BINDINGS, Priority, buildFileMarkdown, checkRoundTrip, codeBlockLanguages, collectReferenceDefinitions, defaultResolveImageUrl, defaultResolveXPost, defaultResolveYouTubeVideo, defineBulletAfterHeading, defineCodeBlockPreviewPlugin, defineCodeBlockSyntaxHighlight, defineEditorExtension, defineEmbedPaste, defineExitBoundaryHandler, defineFileClickHandler, defineFilePaste, defineFileView, defineFollowLinkHandler, defineHTMLComment, defineImage, defineImageClickHandler, defineLinkClickHandler, defineLinkCommands, defineLinkEditKeymap, defineLinkHoverHandler, defineLinkPaste, defineMath, definePendingReplacementHandler, definePlaceholder, defineReadonly, defineSearchStatusHandler, defineSubstitution, defineTagClickHandler, defineViewAttributes, defineVirtualCaret, defineWikilinkClickHandler, defineWikilinkHoverHandler, defineWikilinkTrigger, defineXPostMediaClickHandler, defineYouTubeVideoClickHandler, docToMarkdown, formatFileSize, formatSizedWikiEmbed, getCodeTokens, getEditorConfig, getFileKind, getIsComposing, getLinkText, getLinkUnitAt, getMarkBuilders, getPendingReplacement, getSearchStatus, getSelectedText, getTableColumnAlign, getTextblockDisplayText, getVirtualElementFromRange, inlineTextToMarkChunks, inlineTextToMarkChunksWithContext, insertLink, isCodeBlockPreviewHiddenDecoration, isLinkTextForHref, isMarkOfType, isModEvent, isNodeOfType, isReferenceDefinitionNode, isSelectionInTableCell, loadKaTeX, markdownToDoc, normalizeHref, parsePostEmbedSnapshot, parseWikiEmbed, removeLink, renderMathInto, updateEditorConfig, updateLink, wikiEmbedBasename, withPriority };
9179
+ export { EDITOR_KEY_BINDINGS, Priority, buildFileMarkdown, checkRoundTrip, codeBlockLanguages, collectReferenceDefinitions, defaultResolveImageUrl, defaultResolveXPost, defaultResolveYouTubeVideo, defineBulletAfterHeading, defineCodeBlockPreviewPlugin, defineCodeBlockSyntaxHighlight, defineEditorExtension, defineEmbedPaste, defineExitBoundaryHandler, defineFileClickHandler, defineFilePaste, defineFileView, defineFollowLinkHandler, defineHTMLComment, defineImage, defineImageClickHandler, defineLinkClickHandler, defineLinkCommands, defineLinkEditKeymap, defineLinkHoverHandler, defineLinkPaste, defineMath, definePendingReplacementHandler, definePlaceholder, defineReadonly, defineSearchStatusHandler, defineSubstitution, defineTagClickHandler, defineViewAttributes, defineVirtualCaret, defineWikilinkClickHandler, defineWikilinkHoverHandler, defineWikilinkTrigger, defineXPostMediaClickHandler, defineYouTubeVideoClickHandler, dismissLinkHover, docToMarkdown, formatFileSize, formatSizedWikiEmbed, getCodeTokens, getEditorConfig, getFileKind, getIsComposing, getLinkText, getLinkUnitAt, getMarkBuilders, getPendingReplacement, getSearchStatus, getSelectedText, getTableColumnAlign, getTextblockDisplayText, getVirtualElementFromRange, inlineTextToMarkChunks, inlineTextToMarkChunksWithContext, insertLink, isCodeBlockPreviewHiddenDecoration, isLinkTextForHref, isMarkOfType, isModEvent, isNodeOfType, isReferenceDefinitionNode, isSelectionInTableCell, loadKaTeX, markdownToDoc, normalizeHref, parsePostEmbedSnapshot, parseWikiEmbed, removeLink, renderMathInto, updateEditorConfig, updateLink, wikiEmbedBasename, withPriority };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meowdown/core",
3
3
  "type": "module",
4
- "version": "0.74.1",
4
+ "version": "0.75.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",