From efd592834fd47e2c39fd5c47825204a631a7dca5 Mon Sep 17 00:00:00 2001 From: Oyegoke Praise Date: Fri, 30 Oct 2026 21:43:29 +0100 Subject: [PATCH 1/2] feat: allow switching supported links between text and embeds --- .changeset/bright-links-recover.md | 5 ++ .../src/extensions/link-presentation.test.tsx | 53 +++++++++++ .../core/src/extensions/link-presentation.ts | 87 +++++++++++++++++++ packages/core/src/index.ts | 6 ++ 4 files changed, 151 insertions(+) create mode 100644 .changeset/bright-links-recover.md create mode 100644 packages/core/src/extensions/link-presentation.test.tsx create mode 100644 packages/core/src/extensions/link-presentation.ts diff --git a/.changeset/bright-links-recover.md b/.changeset/bright-links-recover.md new file mode 100644 index 00000000..19a99d93 --- /dev/null +++ b/.changeset/bright-links-recover.md @@ -0,0 +1,5 @@ +--- +'@meowdown/core': minor +--- + +Add local, undoable commands to switch selected supported web links between plain links and rich embeds, including recovery from unavailable embeds. diff --git a/packages/core/src/extensions/link-presentation.test.tsx b/packages/core/src/extensions/link-presentation.test.tsx new file mode 100644 index 00000000..4c7104a5 --- /dev/null +++ b/packages/core/src/extensions/link-presentation.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { markdownToDoc } from '../converters/md-to-pm.ts' +import { setupFixture } from '../testing/index.ts' + +import { getPresentedLinks, setLinkPresentation } from './link-presentation.ts' + +const TWEET = 'https://x.com/jack/status/20' +const VIDEO = 'https://youtu.be/aqz-KE-bpKQ' + +function setup(markdown: string) { + const fixture = setupFixture({ + extensionOptions: { resolveXPost: () => undefined, resolveYouTubeVideo: () => undefined }, + }) + fixture.editor.setContent(markdownToDoc(markdown, { nodes: fixture.editor.nodes })) + fixture.editor.commands.selectAll() + return fixture +} + +describe('link presentation', () => { + it('recovers a failed embed as an ordinary link, with undo', () => { + using fixture = setup(`![](${TWEET})`) + const { editor, view } = fixture + expect(getPresentedLinks(editor.state).map((link) => link.presentation)).toEqual(['embed']) + expect(setLinkPresentation('link')(editor.state, view.dispatch, view)).toBe(true) + expect(editor.state.doc.textContent).toBe(`[${TWEET}](${TWEET})`) + expect(getPresentedLinks(editor.state).map((link) => link.presentation)).toEqual(['link']) + editor.commands.undo() + expect(editor.state.doc.textContent).toBe(`![](${TWEET})`) + }) + + it('converts selected supported links only, preserving surrounding text and code', () => { + using fixture = setup( + `Before ${TWEET} and [video](${VIDEO}) after.\n\n\`${TWEET}\`\n\n\`\`\`\n${TWEET}\n\`\`\`\n\nhttps://example.com`, + ) + const { editor, view } = fixture + expect(getPresentedLinks(editor.state)).toHaveLength(2) + expect(setLinkPresentation('embed')(editor.state, view.dispatch, view)).toBe(true) + expect(editor.state.doc.textContent).toContain( + `Before ![${TWEET}](${TWEET}) and ![video](${VIDEO}) after.`, + ) + expect(editor.state.doc.textContent).toContain(`\`${TWEET}\``) + expect(editor.state.doc.textContent).toContain('https://example.com') + expect(getPresentedLinks(editor.state)).toHaveLength(2) + expect(setLinkPresentation('link')(editor.state, view.dispatch, view)).toBe(true) + expect(editor.state.doc.textContent).toContain(`[video](${VIDEO})`) + }) + + it('leaves unrelated selected links untouched', () => { + using fixture = setup('[example](https://example.com)') + expect(setLinkPresentation('embed')(fixture.editor.state)).toBe(false) + }) +}) diff --git a/packages/core/src/extensions/link-presentation.ts b/packages/core/src/extensions/link-presentation.ts new file mode 100644 index 00000000..b081eaa2 --- /dev/null +++ b/packages/core/src/extensions/link-presentation.ts @@ -0,0 +1,87 @@ +import { matchEmbed } from '@meowdown/markdown' +import { closeHistory } from '@prosekit/pm/history' +import type { Command, EditorState } from '@prosekit/pm/state' +import { TextSelection } from '@prosekit/pm/state' + +import { getLinkUnitAt } from './get-link-unit-at.ts' +import type { MdImageAttrs } from './inline-marks.ts' +import { getLinkText } from './link-commands.ts' +import { getMarkRangeAt } from './mark-range.ts' + +export type LinkPresentation = 'link' | 'embed' + +export interface PresentedLink { + readonly from: number + readonly to: number + readonly href: string + readonly text: string + readonly presentation: LinkPresentation +} + +/** + * Supported web links and embeds touched by the current selection, excluding code. + */ +export function getPresentedLinks(state: EditorState): PresentedLink[] { + const found = new Map() + const { from, to, empty } = state.selection + const visit = (position: number): void => { + const image = getMarkRangeAt(state, position, 'mdImage') + if (image) { + const { src, alt } = image.mark.attrs as MdImageAttrs + if (matchEmbed(src)) { + found.set(image.from, { + from: image.from, + to: image.to, + href: src, + text: alt, + presentation: 'embed', + }) + } + return + } + const link = getLinkUnitAt(state, position) + if (link && matchEmbed(link.href)) { + found.set(link.unit.from, { + ...link.unit, + href: link.href, + text: getLinkText(link), + presentation: 'link', + }) + } + } + if (empty) visit(from) + else { + state.doc.nodesBetween(from, to, (node, position) => { + if (node.isText) visit(Math.max(position, from)) + }) + } + return [...found.values()].sort((left, right) => left.from - right.from) +} + +/** + * Switch selected supported links between ordinary Markdown links and embeds. + * The change is local and undoable; it never resolves a URL or performs I/O. + */ +export function setLinkPresentation(presentation: LinkPresentation): Command { + return (state, dispatch) => { + const links = getPresentedLinks(state).filter((link) => link.presentation !== presentation) + if (links.length === 0) return false + if (dispatch) { + const transaction = closeHistory(state.tr) + for (const link of links.toReversed()) { + const destination = link.href.replaceAll('(', '%28').replaceAll(')', '%29') + const label = (link.text || link.href).replaceAll(/([\\[\]_*`])/g, String.raw`\$1`) + const markdown = + presentation === 'embed' ? `![${label}](${destination})` : `[${label}](${destination})` + transaction.insertText(markdown, link.from, link.to) + } + const start = transaction.mapping.map(state.selection.from, -1) + const end = transaction.mapping.map(state.selection.to, 1) + transaction.setSelection( + TextSelection.between(transaction.doc.resolve(start), transaction.doc.resolve(end)), + ) + dispatch(transaction.scrollIntoView()) + } + return true + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 673b9aed..87bdfd8b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,6 +113,12 @@ export { type LinkHoverOptions, } from './extensions/link-hover.ts' export { defineLinkPaste } from './extensions/link-paste.ts' +export { + getPresentedLinks, + setLinkPresentation, + type LinkPresentation, + type PresentedLink, +} from './extensions/link-presentation.ts' export type { LinkPreview, LinkPreviewResolver } from './extensions/link-preview.ts' export type { ListMarker, MeowdownListAttrs } from './extensions/list.ts' export type { MarkChunk } from './extensions/mark-chunk.ts' From 0f856aaa096d3b3d7d2c82930448093083962f44 Mon Sep 17 00:00:00 2001 From: Oyegoke Praise Date: Fri, 30 Oct 2026 21:52:44 +0100 Subject: [PATCH 2/2] fix: preserve link titles, escapes and caret on conversion --- .../src/extensions/link-presentation.test.tsx | 21 +++++++++++++++++++ .../core/src/extensions/link-presentation.ts | 20 +++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/core/src/extensions/link-presentation.test.tsx b/packages/core/src/extensions/link-presentation.test.tsx index 4c7104a5..c9b88434 100644 --- a/packages/core/src/extensions/link-presentation.test.tsx +++ b/packages/core/src/extensions/link-presentation.test.tsx @@ -18,6 +18,27 @@ function setup(markdown: string) { } describe('link presentation', () => { + it('preserves parsed titles and escaped labels through both conversions', () => { + using fixture = setup(`[a \\[b\\] \\*c\\*](${VIDEO} 'Demo "title"')`) + const { editor, view } = fixture + expect(setLinkPresentation('embed')(editor.state, view.dispatch, view)).toBe(true) + expect(getPresentedLinks(editor.state)[0]?.title).toBe('Demo "title"') + expect(editor.state.doc.textContent).toContain('![a \\[b\\] \\*c\\*]') + expect(setLinkPresentation('link')(editor.state, view.dispatch, view)).toBe(true) + expect(getPresentedLinks(editor.state)[0]?.title).toBe('Demo "title"') + expect(editor.state.doc.textContent).toContain('[a \\[b\\] \\*c\\*]') + expect(editor.state.doc.textContent).not.toContain('\\\\[') + }) + + it('keeps a collapsed caret collapsed inside a converted link', () => { + using fixture = setup(VIDEO) + const { editor, view } = fixture + editor.commands.selectText(8) + expect(setLinkPresentation('embed')(editor.state, view.dispatch, view)).toBe(true) + expect(editor.state.selection.empty).toBe(true) + expect(editor.state.selection.from).toBeGreaterThan(8) + }) + it('recovers a failed embed as an ordinary link, with undo', () => { using fixture = setup(`![](${TWEET})`) const { editor, view } = fixture diff --git a/packages/core/src/extensions/link-presentation.ts b/packages/core/src/extensions/link-presentation.ts index b081eaa2..2e27d278 100644 --- a/packages/core/src/extensions/link-presentation.ts +++ b/packages/core/src/extensions/link-presentation.ts @@ -2,6 +2,7 @@ import { matchEmbed } from '@meowdown/markdown' import { closeHistory } from '@prosekit/pm/history' import type { Command, EditorState } from '@prosekit/pm/state' import { TextSelection } from '@prosekit/pm/state' +import { decodeString } from 'micromark-util-decode-string' import { getLinkUnitAt } from './get-link-unit-at.ts' import type { MdImageAttrs } from './inline-marks.ts' @@ -15,6 +16,7 @@ export interface PresentedLink { readonly to: number readonly href: string readonly text: string + readonly title: string readonly presentation: LinkPresentation } @@ -27,13 +29,14 @@ export function getPresentedLinks(state: EditorState): PresentedLink[] { const visit = (position: number): void => { const image = getMarkRangeAt(state, position, 'mdImage') if (image) { - const { src, alt } = image.mark.attrs as MdImageAttrs + const { src, alt, title } = image.mark.attrs as MdImageAttrs if (matchEmbed(src)) { found.set(image.from, { from: image.from, to: image.to, href: src, text: alt, + title, presentation: 'embed', }) } @@ -45,6 +48,7 @@ export function getPresentedLinks(state: EditorState): PresentedLink[] { ...link.unit, href: link.href, text: getLinkText(link), + title: link.title, presentation: 'link', }) } @@ -70,13 +74,19 @@ export function setLinkPresentation(presentation: LinkPresentation): Command { const transaction = closeHistory(state.tr) for (const link of links.toReversed()) { const destination = link.href.replaceAll('(', '%28').replaceAll(')', '%29') - const label = (link.text || link.href).replaceAll(/([\\[\]_*`])/g, String.raw`\$1`) + const label = decodeString(link.text || link.href).replaceAll( + /([&<\\[\]_*`~=$#])/g, + String.raw`\$1`, + ) + const title = link.title ? ` "${link.title.replaceAll(/(["\\])/g, String.raw`\$1`)}"` : '' const markdown = - presentation === 'embed' ? `![${label}](${destination})` : `[${label}](${destination})` + presentation === 'embed' + ? `![${label}](${destination}${title})` + : `[${label}](${destination}${title})` transaction.insertText(markdown, link.from, link.to) } - const start = transaction.mapping.map(state.selection.from, -1) - const end = transaction.mapping.map(state.selection.to, 1) + const start = transaction.mapping.map(state.selection.from, state.selection.empty ? 1 : -1) + const end = state.selection.empty ? start : transaction.mapping.map(state.selection.to, 1) transaction.setSelection( TextSelection.between(transaction.doc.resolve(start), transaction.doc.resolve(end)), )