Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-links-recover.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions packages/core/src/extensions/link-presentation.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
})
})
97 changes: 97 additions & 0 deletions packages/core/src/extensions/link-presentation.ts
Original file line number Diff line number Diff line change
@@ -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<number, PresentedLink>()
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)
Comment thread
Emerald-Realm marked this conversation as resolved.
Resolved
}
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
}
}
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down