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..c9b88434 --- /dev/null +++ b/packages/core/src/extensions/link-presentation.test.tsx @@ -0,0 +1,74 @@ +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('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 + 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..2e27d278 --- /dev/null +++ b/packages/core/src/extensions/link-presentation.ts @@ -0,0 +1,97 @@ +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' +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 title: 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, 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', + }) + } + return + } + const link = getLinkUnitAt(state, position) + if (link && matchEmbed(link.href)) { + found.set(link.unit.from, { + ...link.unit, + href: link.href, + text: getLinkText(link), + title: link.title, + 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 = 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}${title})` + : `[${label}](${destination}${title})` + transaction.insertText(markdown, link.from, link.to) + } + 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)), + ) + 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'