@meowdown/core 0.66.0 → 0.67.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
@@ -267,27 +267,36 @@ type MdPackAttrs = {
267
267
  revealInFocus: true;
268
268
  revealInHide?: null;
269
269
  } | {
270
- key: 'link';
270
+ key: 'link-inline' | 'link-reference';
271
271
  data: {
272
- form: 'inline' | 'reference';
273
272
  href: string;
274
273
  title: string;
275
- } | {
276
- form: 'angle';
274
+ };
275
+ slot?: 1 | null;
276
+ revealInFocus: true;
277
+ revealInHide?: null;
278
+ } | {
279
+ key: 'link-angle';
280
+ data: {
277
281
  href: string;
278
282
  };
279
283
  slot?: 1 | null;
280
284
  revealInFocus: true;
281
285
  revealInHide?: null;
282
286
  } | {
283
- key: 'link';
287
+ key: 'link-bare';
284
288
  data: {
285
- form: 'bare';
286
289
  href: string;
287
290
  };
288
291
  slot?: 1 | null;
289
292
  revealInFocus?: null;
290
293
  revealInHide?: null;
294
+ } | {
295
+ key: 'noLink';
296
+ data?: null;
297
+ slot?: 1 | null;
298
+ revealInFocus: true;
299
+ revealInHide?: null;
291
300
  } | {
292
301
  key: 'math';
293
302
  data?: null;
@@ -364,15 +373,30 @@ export declare function getLinkUnitAt(state: EditorState, pos: number): LinkUnit
364
373
  interface LinkAttrs {
365
374
  href?: string;
366
375
  title?: string;
376
+ text?: string;
367
377
  }
378
+ /**
379
+ * Normalize a typed URL with the existing autolink logic, else keep it verbatim.
380
+ */
381
+ export declare function normalizeHref(raw: string): string;
382
+ /**
383
+ * Get a link's plain visible text.
384
+ */
385
+ export declare function getLinkText(state: EditorState, link: LinkUnit): string;
386
+ /**
387
+ * Whether plain visible link text names the same destination as the link.
388
+ */
389
+ export declare function isLinkTextForHref(text: string, href: string): boolean;
368
390
  interface InsertLinkOptions {
369
391
  href?: string;
370
392
  title?: string;
393
+ text?: string;
371
394
  wrapText?: boolean;
372
395
  }
373
- export declare function insertLink({ href, title, wrapText }?: InsertLinkOptions): Command;
396
+ export declare function insertLink({ href, title, text, wrapText }?: InsertLinkOptions): Command;
374
397
  /**
375
- * Rewrite the `( ... )` of the link at the caret/selection.
398
+ * Rewrite a mutable link. Autolinks are promoted to inline Markdown when
399
+ * their visible text or destination changes.
376
400
  */
377
401
  export declare function updateLink(attrs: LinkAttrs): Command;
378
402
  /**
@@ -390,6 +414,7 @@ interface LinkEditOptions {
390
414
  from: number;
391
415
  to: number;
392
416
  link: LinkUnit | undefined;
417
+ text: string;
393
418
  }
394
419
  type LinkEditHandler = (options: LinkEditOptions) => void;
395
420
  export declare function defineLinkEditKeymap(onLinkEdit: LinkEditHandler): PlainExtension;
@@ -1364,7 +1389,21 @@ interface MarkHoverHit<Payload> {
1364
1389
  //#endregion
1365
1390
  //#region src/extensions/link-hover.d.ts
1366
1391
  type LinkHoverHandler = (hit: MarkHoverHit<LinkUnit> | undefined) => void;
1367
- export declare function defineLinkHoverHandler(onHoverChange: LinkHoverHandler): PlainExtension;
1392
+ interface LinkHoverOptions {
1393
+ /**
1394
+ * Return `false` while the pointer is on the popup the link opened: a
1395
+ * pending leave then re-checks later instead of firing.
1396
+ */
1397
+ canLeave?: () => boolean;
1398
+ }
1399
+ /**
1400
+ * Track the link under the user's attention: a mouse hover after a short
1401
+ * dwell, or a touch tap immediately, since touch has no hover. Without the
1402
+ * tap entry, the popup's preview and actions would stay unreachable on
1403
+ * phones, where a tap on a link only places the caret and raises the
1404
+ * software keyboard.
1405
+ */
1406
+ export declare function defineLinkHoverHandler(onHoverChange: LinkHoverHandler, { canLeave }?: LinkHoverOptions): PlainExtension;
1368
1407
  //#endregion
1369
1408
  //#region src/extensions/link-paste.d.ts
1370
1409
  /**
@@ -1382,6 +1421,22 @@ export declare function defineLinkHoverHandler(onHoverChange: LinkHoverHandler):
1382
1421
  */
1383
1422
  export declare function defineLinkPaste(): PlainExtension;
1384
1423
  //#endregion
1424
+ //#region src/extensions/link-preview.d.ts
1425
+ /**
1426
+ * Metadata a host application can supply for a web link.
1427
+ */
1428
+ interface LinkPreview {
1429
+ readonly title: string;
1430
+ readonly description?: string;
1431
+ readonly iconSrc?: string;
1432
+ }
1433
+ /**
1434
+ * Resolve display metadata for a link, or return `undefined` when unavailable.
1435
+ * Deliberately signal-free to keep the API simple: callers drop stale results
1436
+ * instead of cancelling in-flight work.
1437
+ */
1438
+ type LinkPreviewResolver = (href: string) => LinkPreview | undefined | Promise<LinkPreview | undefined>;
1439
+ //#endregion
1385
1440
  //#region src/extensions/mark-names.d.ts
1386
1441
  declare const MARK_NAMES: readonly ["mdWikilink", "mdImage", "mdFile", "mdMath", "mdMark", "mdEm", "mdStrong", "mdCode", "mdLinkText", "mdLinkUri", "mdLinkTitle", "mdDel", "mdHighlight", "mdTag", "mdPack"];
1387
1442
  type MarkName = (typeof MARK_NAMES)[number];
@@ -1538,4 +1593,4 @@ export declare function getSelectedText(state: EditorState): string;
1538
1593
  */
1539
1594
  export declare function getVirtualElementFromRange(view: EditorView, range: PositionRange): VirtualElement;
1540
1595
  //#endregion
1541
- export { type AcceptPendingReplacementOptions, type CheckRoundTripOptions, type CodeBlockAttrs, type CodeBlockFenceStyle, type CodeToken, type DocToMarkdownOptions, type EditorExtension, type EditorExtensionOptions, type EmbedDescriptor, 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 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, 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, defineCodeBlockPreviewPlugin, definePlaceholder, defineReadonly, defineSearchStatusHandler, getSearchStatus, isCodeBlockPreviewHiddenDecoration, withPriority };
1596
+ export { type AcceptPendingReplacementOptions, type CheckRoundTripOptions, type CodeBlockAttrs, type CodeBlockFenceStyle, type CodeToken, type DocToMarkdownOptions, type EditorExtension, type EditorExtensionOptions, type EmbedDescriptor, 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, 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, defineCodeBlockPreviewPlugin, definePlaceholder, defineReadonly, defineSearchStatusHandler, getSearchStatus, isCodeBlockPreviewHiddenDecoration, withPriority };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Priority, Priority as Priority$1, createMarkBuilders, createNodeBuilders, defineBaseCommands, defineBaseKeymap, defineClipboardSerializer, defineCommands, defineHistory, defineKeymap, defineMarkSpec, defineMarkView, defineNodeAttr, defineNodeSpec, definePlugin, getMarkRange, getMarkType, getNodeType, isAllSelection, isApple, isAtBlockStart, isNodeSelection, isTextSelection, setBlockType, toggleNode, union, unsetBlockType, withPriority, withPriority as withPriority$1, withSkipCodeBlock } from "@prosekit/core";
1
+ import { Priority, Priority as Priority$1, createMarkBuilders, createNodeBuilders, defineBaseCommands, defineBaseKeymap, defineClipboardSerializer, defineCommands, defineHistory, defineKeymap, defineMarkSpec, defineMarkView, defineNodeAttr, defineNodeSpec, definePlugin, findMarkRange, getMarkRange, getMarkType, getNodeType, isAllSelection, isApple, isAtBlockStart, isNodeSelection, isTextSelection, setBlockType, toggleNode, union, unsetBlockType, withPriority, withPriority as withPriority$1, withSkipCodeBlock } from "@prosekit/core";
2
2
  import { defineCodeBlock, defineCodeBlockHighlight, defineCodeBlockPreviewPlugin, isCodeBlockPreviewHiddenDecoration } from "@prosekit/extensions/code-block";
3
3
  import { definePlaceholder } from "@prosekit/extensions/placeholder";
4
4
  import { defineReadonly } from "@prosekit/extensions/readonly";
@@ -354,13 +354,13 @@ function resolvePosition(state, pos) {
354
354
  }
355
355
  /**
356
356
  * Returns the run of the mark covering `pos`, or `undefined` when none covers
357
- * it. `attrs` narrows the match to marks whose attributes contain it, which
358
- * is how a caller picks one level out of a nested stack of the same mark.
357
+ * it. `predicate` narrows the match to marks it accepts, which is how a
358
+ * caller picks one level out of a nested stack of the same mark.
359
359
  */
360
- function getMarkRangeAt(state, pos, markName, attrs) {
360
+ function getMarkRangeAt(state, pos, markName, predicate) {
361
361
  const $pos = resolvePosition(state, pos);
362
362
  if (!$pos) return;
363
- return ATOM_MARK_NAMES.includes(markName) ? getAtomUnitRange(state, $pos, markName) : getMarkRange($pos, markName, attrs);
363
+ return ATOM_MARK_NAMES.includes(markName) ? getAtomUnitRange(state, $pos, markName) : findMarkRange($pos, (mark) => isMarkOfType(mark, markName) && (!predicate || predicate(mark)));
364
364
  }
365
365
  /**
366
366
  * The range of the atom unit touching `$pos`, preferring the child to the
@@ -368,7 +368,7 @@ function getMarkRangeAt(state, pos, markName, attrs) {
368
368
  * marks, so eq-based expansion would run across both; the unit boundary comes
369
369
  * from the innermost pack instead, which `slot` keeps distinct. A text node
370
370
  * without a pack (a document not produced by the inline parser) falls back to
371
- * the eq-based range. Does not support `attrs` filtering.
371
+ * the eq-based range. Does not support `predicate` filtering.
372
372
  */
373
373
  function getAtomUnitRange(state, $pos, name) {
374
374
  const parent = $pos.parent;
@@ -2843,10 +2843,12 @@ function parseMagicComment(comment) {
2843
2843
  if (!isObject(data)) return;
2844
2844
  const width = toPositiveNumber(data.width);
2845
2845
  const height = toPositiveNumber(data.height);
2846
- if (!width && !height) return;
2846
+ const noLink = data.noLink === true ? true : void 0;
2847
+ if (!width && !height && !noLink) return;
2847
2848
  return {
2848
2849
  width,
2849
- height
2850
+ height,
2851
+ noLink
2850
2852
  };
2851
2853
  }
2852
2854
  function toPositiveNumber(value) {
@@ -3185,6 +3187,14 @@ function walk(nodes, parentMarks, rangeStart, rangeEnd, text, marks, out, option
3185
3187
  pos = atomEnd;
3186
3188
  continue;
3187
3189
  }
3190
+ if (node.type === LEZER_NODE_IDS.URL) {
3191
+ const trailing = takeMagicComments(nodes, index, text);
3192
+ if (trailing?.magic.noLink) {
3193
+ walkUnlinkedURL(node, trailing, parentMarks, marks, out);
3194
+ pos = trailing.to;
3195
+ continue;
3196
+ }
3197
+ }
3188
3198
  walkNode(node, parentMarks, text, marks, out, options, context);
3189
3199
  pos = node.to;
3190
3200
  }
@@ -3251,11 +3261,8 @@ function walkAutolink(node, parentMarks, text, marks, out) {
3251
3261
  const urlNode = node.children.find((child) => child.type === LEZER_NODE_IDS.URL);
3252
3262
  const href = urlNode ? getAutolinkHref(text.slice(urlNode.from, urlNode.to)) ?? "" : "";
3253
3263
  const base = [...parentMarks, createUnitPack(marks, out, parentMarks, node.from, {
3254
- key: "link",
3255
- data: {
3256
- form: "angle",
3257
- href
3258
- },
3264
+ key: "link-angle",
3265
+ data: { href },
3259
3266
  revealInFocus: true
3260
3267
  })];
3261
3268
  let pos = node.from;
@@ -3282,15 +3289,26 @@ function walkURL(node, parentMarks, text, marks, out) {
3282
3289
  emit(out, node.from, node.to, [
3283
3290
  ...parentMarks,
3284
3291
  createUnitPack(marks, out, parentMarks, node.from, {
3285
- key: "link",
3286
- data: {
3287
- form: "bare",
3288
- href
3289
- }
3292
+ key: "link-bare",
3293
+ data: { href }
3290
3294
  }),
3291
3295
  marks.mdLinkText.create({ href })
3292
3296
  ]);
3293
3297
  }
3298
+ /**
3299
+ * A URL followed directly by a `<!-- {"noLink":true} -->` magic comment
3300
+ * stays plain text instead of autolinking. The comment rides behind the
3301
+ * address as hidden syntax that reveals in focus, so it can be edited or
3302
+ * deleted in place.
3303
+ */
3304
+ function walkUnlinkedURL(node, trailing, parentMarks, marks, out) {
3305
+ const base = [...parentMarks, createUnitPack(marks, out, parentMarks, node.from, {
3306
+ key: "noLink",
3307
+ revealInFocus: true
3308
+ })];
3309
+ emit(out, node.from, node.to, base);
3310
+ emit(out, node.to, trailing.to, [...base, marks.mdMark.create()]);
3311
+ }
3294
3312
  function walkLink(node, parentMarks, text, marks, out, options, context) {
3295
3313
  const parts = scanLinkParts(node);
3296
3314
  const resolution = resolveLink(parts, text, context);
@@ -3432,9 +3450,8 @@ function walkResolvedLink(node, parts, resolution, parentMarks, text, marks, out
3432
3450
  const linkTextMark = marks.mdLinkText.create({ href });
3433
3451
  const inLabel = (pos) => labelEnd >= 0 && pos < labelEnd;
3434
3452
  const pack = createUnitPack(marks, out, parentMarks, node.from, {
3435
- key: "link",
3453
+ key: isReference ? "link-reference" : "link-inline",
3436
3454
  data: {
3437
- form: isReference ? "reference" : "inline",
3438
3455
  href,
3439
3456
  title
3440
3457
  },
@@ -3475,9 +3492,9 @@ function walkResolvedLink(node, parts, resolution, parentMarks, text, marks, out
3475
3492
  }
3476
3493
  /**
3477
3494
  * The run of magic comments chained immediately behind `nodes[index]` (an
3478
- * image), or undefined when no magic comment directly abuts it. The first
3479
- * comment's data wins: a rewrite of an unfolded image inserted the fresh
3480
- * comment right at the image's end, so in a stacked run left is newest.
3495
+ * image or a URL), or undefined when no magic comment directly abuts it. The
3496
+ * first comment's data wins: a rewrite of an unfolded image inserted the
3497
+ * fresh comment right at the image's end, so in a stacked run left is newest.
3481
3498
  */
3482
3499
  function takeMagicComments(nodes, index, text) {
3483
3500
  let magic;
@@ -4496,32 +4513,32 @@ function lastMarkRunIn(state, range, markName) {
4496
4513
  * the `mdLinkUri` run locates the `( )` body.
4497
4514
  */
4498
4515
  function getLinkUnitAt(state, pos) {
4499
- const unit = getMarkRangeAt(state, pos, "mdPack", { key: "link" });
4516
+ const unit = getMarkRangeAt(state, pos, "mdPack", (mark) => mark.attrs.key.startsWith("link-"));
4500
4517
  if (!unit) return;
4501
- const { data } = unit.mark.attrs;
4518
+ const attrs = unit.mark.attrs;
4502
4519
  const unitRange = {
4503
4520
  from: unit.from,
4504
4521
  to: unit.to
4505
4522
  };
4506
- switch (data.form) {
4507
- case "bare": return {
4523
+ switch (attrs.key) {
4524
+ case "link-bare": return {
4508
4525
  form: "bare",
4509
4526
  unit: unitRange,
4510
4527
  text: unitRange,
4511
- href: data.href,
4528
+ href: attrs.data.href,
4512
4529
  title: ""
4513
4530
  };
4514
- case "angle": return {
4531
+ case "link-angle": return {
4515
4532
  form: "angle",
4516
4533
  unit: unitRange,
4517
4534
  text: {
4518
4535
  from: unit.from + 1,
4519
4536
  to: unit.to - 1
4520
4537
  },
4521
- href: data.href,
4538
+ href: attrs.data.href,
4522
4539
  title: ""
4523
4540
  };
4524
- case "reference": {
4541
+ case "link-reference": {
4525
4542
  const linkText = getMarkRangeAt(state, pos, "mdLinkText");
4526
4543
  return {
4527
4544
  form: "reference",
@@ -4530,11 +4547,11 @@ function getLinkUnitAt(state, pos) {
4530
4547
  from: linkText.from + 1,
4531
4548
  to: linkText.to
4532
4549
  },
4533
- href: data.href,
4534
- title: data.title
4550
+ href: attrs.data.href,
4551
+ title: attrs.data.title
4535
4552
  };
4536
4553
  }
4537
- case "inline": {
4554
+ case "link-inline": {
4538
4555
  const uri = lastMarkRunIn(state, unitRange, "mdLinkUri");
4539
4556
  const closeBracket = uri ? uri.from - 2 : unit.to - 3;
4540
4557
  const destFrom = uri ? uri.from : unit.to - 1;
@@ -4551,8 +4568,8 @@ function getLinkUnitAt(state, pos) {
4551
4568
  from: destFrom,
4552
4569
  to: unit.to - 1
4553
4570
  },
4554
- href: data.href,
4555
- title: data.title
4571
+ href: attrs.data.href,
4572
+ title: attrs.data.title
4556
4573
  };
4557
4574
  }
4558
4575
  }
@@ -4574,6 +4591,39 @@ function destText(href, title) {
4574
4591
  return href + (title ? ` "${title.replaceAll(/(["\\])/g, String.raw`\$1`)}"` : "");
4575
4592
  }
4576
4593
  /**
4594
+ * Escape a plain-text label so it cannot pick up inline semantics: the
4595
+ * CommonMark delimiters plus meowdown's `==highlight==`, `$math$`, and `#tag`.
4596
+ */
4597
+ function linkLabelText(text) {
4598
+ return text.replaceAll(/([&<\\[\]_*`~=$#])/g, String.raw`\$1`);
4599
+ }
4600
+ /**
4601
+ * Get a range's plain visible text, skipping hidden syntax runs.
4602
+ */
4603
+ function getVisibleText(state, range) {
4604
+ let text = "";
4605
+ state.doc.nodesBetween(range.from, range.to, (node, pos) => {
4606
+ if (!node.isText || !node.text || hasSyntaxMark(node.marks)) return;
4607
+ const from = Math.max(range.from, pos) - pos;
4608
+ const to = Math.min(range.to, pos + node.nodeSize) - pos;
4609
+ text += node.text.slice(from, to);
4610
+ });
4611
+ return text;
4612
+ }
4613
+ /**
4614
+ * Get a link's plain visible text.
4615
+ */
4616
+ function getLinkText(state, link) {
4617
+ return getVisibleText(state, link.text);
4618
+ }
4619
+ /**
4620
+ * Whether plain visible link text names the same destination as the link.
4621
+ */
4622
+ function isLinkTextForHref(text, href) {
4623
+ const value = text.trim();
4624
+ return value === href || getAutolinkHref(value) === href;
4625
+ }
4626
+ /**
4577
4627
  * The range a new link would wrap: the current selection when it is a
4578
4628
  * non-empty text selection inside a single non-code textblock, trimmed of
4579
4629
  * surrounding whitespace. `undefined` when there is nothing to wrap.
@@ -4592,16 +4642,24 @@ function getWrapRange(state) {
4592
4642
  to: base + to
4593
4643
  };
4594
4644
  }
4595
- function insertLink({ href, title, wrapText = true } = {}) {
4645
+ function insertLink({ href, title, text, wrapText = true } = {}) {
4596
4646
  return (state, dispatch) => {
4597
4647
  const range = getWrapRange(state);
4598
4648
  if (!range) return false;
4599
4649
  if (dispatch) {
4600
4650
  const { from, to } = range;
4601
4651
  const tr = state.tr;
4602
- const close = `](${destText(normalizeHref(href ?? ""), title ?? "")})`;
4603
- tr.insertText(close, to).insertText("[", from);
4604
- const linkTo = to + 1 + close.length;
4652
+ const dest = destText(normalizeHref(href ?? ""), title ?? "");
4653
+ let linkTo;
4654
+ if (text !== void 0 && text !== getVisibleText(state, range)) {
4655
+ const markdown = `[${linkLabelText(text)}](${dest})`;
4656
+ tr.insertText(markdown, from, to);
4657
+ linkTo = from + markdown.length;
4658
+ } else {
4659
+ const close = `](${dest})`;
4660
+ tr.insertText(close, to).insertText("[", from);
4661
+ linkTo = to + 1 + close.length;
4662
+ }
4605
4663
  tr.setSelection(wrapText ? TextSelection.create(tr.doc, from, linkTo) : TextSelection.create(tr.doc, linkTo));
4606
4664
  tr.scrollIntoView();
4607
4665
  dispatch(tr);
@@ -4610,14 +4668,18 @@ function insertLink({ href, title, wrapText = true } = {}) {
4610
4668
  };
4611
4669
  }
4612
4670
  /**
4613
- * Rewrite the `( ... )` of the link at the caret/selection.
4671
+ * Rewrite a mutable link. Autolinks are promoted to inline Markdown when
4672
+ * their visible text or destination changes.
4614
4673
  */
4615
4674
  function updateLink(attrs) {
4616
4675
  return (state, dispatch) => {
4617
4676
  const link = getLinkUnitAt(state, state.selection.from);
4618
- if (link?.form !== "inline") return false;
4619
- const dest = destText(normalizeHref(attrs.href ?? link.href), attrs.title ?? link.title);
4620
- if (dispatch) dispatch(state.tr.insertText(dest, link.dest.from, link.dest.to).scrollIntoView());
4677
+ if (!link || link.form === "reference") return false;
4678
+ const href = normalizeHref(attrs.href ?? link.href);
4679
+ const title = attrs.title ?? link.title;
4680
+ const text = attrs.text ?? getLinkText(state, link);
4681
+ const replacingUnit = attrs.text !== void 0 || link.form !== "inline";
4682
+ if (dispatch) dispatch((replacingUnit ? state.tr.insertText(`[${linkLabelText(text)}](${destText(href, title)})`, link.unit.from, link.unit.to) : state.tr.insertText(destText(href, title), link.dest.from, link.dest.to)).scrollIntoView());
4621
4683
  return true;
4622
4684
  };
4623
4685
  }
@@ -4627,8 +4689,11 @@ function updateLink(attrs) {
4627
4689
  function removeLink() {
4628
4690
  return (state, dispatch) => {
4629
4691
  const link = getLinkUnitAt(state, state.selection.from);
4630
- if (link?.form !== "inline") return false;
4631
- if (dispatch) dispatch(state.tr.delete(link.label.to, link.unit.to).delete(link.unit.from, link.label.from).scrollIntoView());
4692
+ if (!link || link.form === "reference") return false;
4693
+ if (dispatch) {
4694
+ const text = getLinkText(state, link);
4695
+ dispatch((link.form === "inline" && !getAutolinkHref(text) ? state.tr.delete(link.label.to, link.unit.to).delete(link.unit.from, link.label.from) : state.tr.insertText(text + formatMagicComment({ noLink: true }), link.unit.from, link.unit.to)).scrollIntoView());
4696
+ }
4632
4697
  return true;
4633
4698
  };
4634
4699
  }
@@ -4643,7 +4708,7 @@ function openLinkEdit(onLinkEdit) {
4643
4708
  return (state, dispatch, view) => {
4644
4709
  const link = getLinkUnitAt(state, state.selection.from);
4645
4710
  if (link) {
4646
- if (link.form !== "inline") return false;
4711
+ if (link.form === "reference") return false;
4647
4712
  if (dispatch && view) {
4648
4713
  const { unit: { from, to } } = link;
4649
4714
  dispatch(state.tr.setSelection(TextSelection.create(state.doc, from, to)).scrollIntoView());
@@ -4651,7 +4716,8 @@ function openLinkEdit(onLinkEdit) {
4651
4716
  onLinkEdit({
4652
4717
  from,
4653
4718
  to,
4654
- link
4719
+ link,
4720
+ text: getLinkText(state, link)
4655
4721
  });
4656
4722
  }
4657
4723
  return true;
@@ -4665,7 +4731,8 @@ function openLinkEdit(onLinkEdit) {
4665
4731
  onLinkEdit({
4666
4732
  from,
4667
4733
  to,
4668
- link: void 0
4734
+ link: void 0,
4735
+ text: getVisibleText(state, wrapRange)
4669
4736
  });
4670
4737
  }
4671
4738
  return true;
@@ -7986,36 +8053,98 @@ function defineLinkClickHandler(onClick) {
7986
8053
  * after every editor update, so deleting, replacing, or rewriting a hovered
7987
8054
  * mark emits leave even when the pointer itself never moves. Destroying the
7988
8055
  * editor or removing the extension emits leave as well.
8056
+ *
8057
+ * With `tap`, a touch tap enters too. The browser is the tap recognizer:
8058
+ * only a recognized stationary single-finger tap synthesizes the
8059
+ * compatibility mouse events and the trailing `click`; scrolls, drags,
8060
+ * long-presses, and multi-finger gestures never produce them.
7989
8061
  */
7990
8062
  function defineMarkHoverHandler(config) {
7991
- let current;
8063
+ const { openDelay, closeDelay, tap } = config;
8064
+ /**
8065
+ * What `onHoverChange` last received, while that is a hit.
8066
+ */
8067
+ let emitted;
8068
+ let timer;
8069
+ /**
8070
+ * The element of a scheduled enter, `null` for a scheduled leave,
8071
+ * `undefined` when nothing is scheduled.
8072
+ */
8073
+ let scheduledElement;
8074
+ /**
8075
+ * The type of the pointer that spawned the current event sequence:
8076
+ * compatibility mouse events carry no pointer type of their own.
8077
+ */
8078
+ let lastPointerType = "";
8079
+ const findClosestMark = (target) => {
8080
+ return isElementLike(target) ? target.closest(config.selector) : null;
8081
+ };
7992
8082
  const findPayloadForElement = (view, element) => {
7993
8083
  return config.findPayloadForElement ? config.findPayloadForElement(view, element) : config.findPayloadAt(view.state, view.posAtDOM(element, 0));
7994
8084
  };
7995
- const leave = () => {
7996
- if (!current) return;
7997
- current = void 0;
7998
- config.onHoverChange(void 0);
8085
+ const cancel = () => {
8086
+ clearTimeout(timer);
8087
+ scheduledElement = void 0;
7999
8088
  };
8000
- const handleOver = (view, event) => {
8001
- const target = event.target;
8002
- if (!target || !isElementLike(target)) return;
8003
- const element = target.closest(config.selector);
8004
- if (!element || !view.dom.contains(element) || element === current?.element) return;
8005
- leave();
8089
+ const emit = (hit) => {
8090
+ cancel();
8091
+ if (!hit && !emitted) return;
8092
+ emitted = hit;
8093
+ config.onHoverChange(hit);
8094
+ };
8095
+ const scheduleLeave = () => {
8096
+ cancel();
8097
+ scheduledElement = null;
8098
+ const fire = () => {
8099
+ if (config.canLeave?.() === false) {
8100
+ timer = setTimeout(fire, closeDelay);
8101
+ return;
8102
+ }
8103
+ emit(void 0);
8104
+ };
8105
+ timer = setTimeout(fire, closeDelay);
8106
+ };
8107
+ const enter = (view, element) => {
8006
8108
  const payload = findPayloadForElement(view, element);
8007
8109
  if (payload == null) return;
8008
- current = {
8009
- payload,
8010
- element
8011
- };
8012
- config.onHoverChange(current);
8110
+ if (emitted) {
8111
+ emit({
8112
+ payload,
8113
+ element
8114
+ });
8115
+ return;
8116
+ }
8117
+ cancel();
8118
+ scheduledElement = element;
8119
+ timer = setTimeout(() => {
8120
+ const fresh = element.isConnected ? findPayloadForElement(view, element) : void 0;
8121
+ if (fresh == null) {
8122
+ cancel();
8123
+ return;
8124
+ }
8125
+ emit({
8126
+ payload: fresh,
8127
+ element
8128
+ });
8129
+ }, openDelay);
8130
+ };
8131
+ const handleOver = (view, event) => {
8132
+ const element = findClosestMark(event.target);
8133
+ if (!element || !view.dom.contains(element)) return;
8134
+ if (element === emitted?.element) {
8135
+ if (scheduledElement === null) cancel();
8136
+ return;
8137
+ }
8138
+ if (element === scheduledElement) return;
8139
+ enter(view, element);
8013
8140
  };
8014
8141
  const handleOut = (event) => {
8015
- if (!current) return;
8142
+ const active = emitted?.element ?? scheduledElement;
8143
+ if (!active) return;
8016
8144
  const related = event.relatedTarget;
8017
- if (related instanceof Node && current.element.contains(related)) return;
8018
- leave();
8145
+ if (related instanceof Node && active.contains(related)) return;
8146
+ if (emitted) scheduleLeave();
8147
+ else cancel();
8019
8148
  };
8020
8149
  return definePlugin(new Plugin({
8021
8150
  key: config.key,
@@ -8027,26 +8156,52 @@ function defineMarkHoverHandler(config) {
8027
8156
  mouseout: (_view, event) => {
8028
8157
  handleOut(event);
8029
8158
  return false;
8159
+ },
8160
+ ...tap && {
8161
+ pointerdown: (_view, event) => {
8162
+ lastPointerType = event.pointerType;
8163
+ return false;
8164
+ },
8165
+ mousedown: (_view, event) => {
8166
+ if (lastPointerType !== "touch" || !findClosestMark(event.target)) return false;
8167
+ event.preventDefault();
8168
+ return true;
8169
+ },
8170
+ click: (view, event) => {
8171
+ if (lastPointerType !== "touch") return false;
8172
+ const element = findClosestMark(event.target);
8173
+ if (!element || !view.dom.contains(element)) {
8174
+ emit(void 0);
8175
+ return false;
8176
+ }
8177
+ event.preventDefault();
8178
+ const payload = findPayloadForElement(view, element);
8179
+ if (payload != null) emit({
8180
+ payload,
8181
+ element
8182
+ });
8183
+ return true;
8184
+ }
8030
8185
  }
8031
8186
  } },
8032
8187
  view: () => ({
8033
8188
  update: (view) => {
8034
- if (!current) return;
8035
- if (!current.element.isConnected || !view.dom.contains(current.element)) {
8036
- leave();
8189
+ if (!emitted) return;
8190
+ if (!emitted.element.isConnected || !view.dom.contains(emitted.element)) {
8191
+ emit(void 0);
8037
8192
  return;
8038
8193
  }
8039
- const payload = findPayloadForElement(view, current.element);
8040
- if (payload == null || !config.isSamePayload(current.payload, payload)) {
8041
- leave();
8194
+ const payload = findPayloadForElement(view, emitted.element);
8195
+ if (payload == null || !config.isSamePayload(emitted.payload, payload)) {
8196
+ emit(void 0);
8042
8197
  return;
8043
8198
  }
8044
- current = {
8045
- ...current,
8199
+ emitted = {
8200
+ ...emitted,
8046
8201
  payload
8047
8202
  };
8048
8203
  },
8049
- destroy: leave
8204
+ destroy: () => emit(void 0)
8050
8205
  })
8051
8206
  }));
8052
8207
  }
@@ -8054,10 +8209,30 @@ function defineMarkHoverHandler(config) {
8054
8209
  //#endregion
8055
8210
  //#region src/extensions/link-hover.ts
8056
8211
  const linkHoverKey = new PluginKey("meowdown-link-hover");
8057
- function defineLinkHoverHandler(onHoverChange) {
8212
+ /**
8213
+ * Dwell before a cold hover enters, in ms.
8214
+ */
8215
+ const OPEN_DELAY = 300;
8216
+ /**
8217
+ * Grace before a leave fires, in ms. The window lets a pointer travel from
8218
+ * the hovered link onto the popup it anchors.
8219
+ */
8220
+ const CLOSE_DELAY = 100;
8221
+ /**
8222
+ * Track the link under the user's attention: a mouse hover after a short
8223
+ * dwell, or a touch tap immediately, since touch has no hover. Without the
8224
+ * tap entry, the popup's preview and actions would stay unreachable on
8225
+ * phones, where a tap on a link only places the caret and raises the
8226
+ * software keyboard.
8227
+ */
8228
+ function defineLinkHoverHandler(onHoverChange, { canLeave } = {}) {
8058
8229
  return defineMarkHoverHandler({
8059
8230
  key: linkHoverKey,
8060
8231
  selector: ".md-link",
8232
+ openDelay: OPEN_DELAY,
8233
+ closeDelay: CLOSE_DELAY,
8234
+ tap: true,
8235
+ canLeave,
8061
8236
  findPayloadAt: (state, pos) => {
8062
8237
  return getLinkUnitAt(state, pos);
8063
8238
  },
@@ -8444,6 +8619,9 @@ function defineWikilinkHoverHandler(onHoverChange) {
8444
8619
  return defineMarkHoverHandler({
8445
8620
  key: wikilinkHoverKey,
8446
8621
  selector: ".md-wikilink-view-preview",
8622
+ openDelay: 0,
8623
+ closeDelay: 0,
8624
+ tap: false,
8447
8625
  findPayloadAt: findWikilinkAt,
8448
8626
  findPayloadForElement: findWikilinkForElement,
8449
8627
  isSamePayload: (previous, next) => previous.target === next.target,
@@ -8611,4 +8789,4 @@ function getVirtualElementFromRange(view, range) {
8611
8789
  }
8612
8790
 
8613
8791
  //#endregion
8614
- export { EDITOR_KEY_BINDINGS, Priority, buildFileMarkdown, checkRoundTrip, codeBlockLanguages, collectReferenceDefinitions, defaultResolveImageUrl, 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, docToMarkdown, formatFileSize, formatSizedWikiEmbed, getCodeTokens, getFileKind, getIsComposing, getLinkUnitAt, getMarkBuilders, getPendingReplacement, getSearchStatus, getSelectedText, getTableColumnAlign, getTextblockDisplayText, getVirtualElementFromRange, inlineTextToMarkChunks, inlineTextToMarkChunksWithContext, insertLink, isCodeBlockPreviewHiddenDecoration, isMarkOfType, isModEvent, isNodeOfType, isSelectionInTableCell, listenForTweetHeight, loadKaTeX, markdownToDoc, matchEmbed, parseWikiEmbed, removeLink, renderMathInto, updateLink, wikiEmbedBasename, withPriority };
8792
+ export { EDITOR_KEY_BINDINGS, Priority, buildFileMarkdown, checkRoundTrip, codeBlockLanguages, collectReferenceDefinitions, defaultResolveImageUrl, 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, docToMarkdown, formatFileSize, formatSizedWikiEmbed, getCodeTokens, getFileKind, getIsComposing, getLinkText, getLinkUnitAt, getMarkBuilders, getPendingReplacement, getSearchStatus, getSelectedText, getTableColumnAlign, getTextblockDisplayText, getVirtualElementFromRange, inlineTextToMarkChunks, inlineTextToMarkChunksWithContext, insertLink, isCodeBlockPreviewHiddenDecoration, isLinkTextForHref, isMarkOfType, isModEvent, isNodeOfType, isSelectionInTableCell, listenForTweetHeight, loadKaTeX, markdownToDoc, matchEmbed, normalizeHref, parseWikiEmbed, removeLink, renderMathInto, updateLink, wikiEmbedBasename, withPriority };
package/dist/style.css CHANGED
@@ -155,6 +155,7 @@
155
155
  --meowdown-heading: light-dark(oklch(21% .006 286), oklch(98.5% 0 0));
156
156
  --meowdown-muted: light-dark(oklch(44.2% .015 286), oklch(71.2% .013 286));
157
157
  --meowdown-accent: light-dark(oklch(64.6% .194 41), oklch(75.8% .159 56));
158
+ --meowdown-accent-contrast: light-dark(oklch(100% 0 0), oklch(21% .006 286));
158
159
  --meowdown-caret: var(--meowdown-accent);
159
160
  --meowdown-caret-glide: 80ms;
160
161
  --meowdown-mark: var(--meowdown-muted);
@@ -189,6 +190,9 @@
189
190
  --meowdown-table-header-bg: light-dark(oklch(98.5% 0 0), oklch(27.4% .005 286));
190
191
  --meowdown-popover-bg: light-dark(oklch(100% 0 0), oklch(21% .006 286));
191
192
  --meowdown-popover-hover-bg: light-dark(oklch(96.7% 0 0), oklch(27.4% .005 286));
193
+ --meowdown-hover-card-border: var(--meowdown-border);
194
+ --meowdown-hover-card-radius: .75rem;
195
+ --meowdown-hover-card-shadow: 0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;
192
196
  --meowdown-danger: light-dark(oklch(63.7% .208 25), oklch(71.1% .166 22));
193
197
  --meowdown-placeholder: var(--meowdown-muted);
194
198
  --meowdown-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meowdown/core",
3
3
  "type": "module",
4
- "version": "0.66.0",
4
+ "version": "0.67.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -26,10 +26,10 @@
26
26
  "@floating-ui/dom": "^1.8.0",
27
27
  "@lezer/highlight": "^1.2.3",
28
28
  "@ocavue/utils": "^1.7.0",
29
- "@prosekit/core": "^0.13.0",
30
- "@prosekit/extensions": "^0.18.0",
29
+ "@prosekit/core": "^0.13.1",
30
+ "@prosekit/extensions": "^0.18.1",
31
31
  "@prosekit/pm": "^0.1.19",
32
- "@prosekit/web": "^0.9.0",
32
+ "@prosekit/web": "^0.9.1",
33
33
  "hast-util-to-mdast": "^10.1.2",
34
34
  "katex": "^0.18.1",
35
35
  "mdast-util-to-markdown": "^2.1.2",
@@ -43,20 +43,20 @@
43
43
  "remark-stringify": "^11.0.0",
44
44
  "unicode-by-name": "^0.2.0",
45
45
  "unified": "^11.0.5",
46
- "@meowdown/markdown": "^0.66.0"
46
+ "@meowdown/markdown": "^0.67.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@ocavue/tsconfig": "^0.7.1",
50
50
  "@tsdown/css": "^0.23.0-beta.2",
51
51
  "@types/hast": "^3.0.5",
52
52
  "@types/mdast": "^4.0.4",
53
- "@vitest/browser-playwright": "^4.1.10",
53
+ "@vitest/browser-playwright": "^4.1.11",
54
54
  "commonmark.json": "^0.31.0",
55
55
  "dedent": "^1.7.2",
56
56
  "diffable-html-snapshot": "^0.3.0",
57
57
  "fast-check": "^4.9.0",
58
58
  "tsdown": "^0.23.0-beta.2",
59
- "vitest": "^4.1.10",
59
+ "vitest": "^4.1.11",
60
60
  "@meowdown/vitest": "0.0.0"
61
61
  },
62
62
  "publishConfig": {