From edf31dcedca673e5a0ba3abafba9386ab3f7d62a Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 31 Aug 2026 03:40:36 +1200 Subject: [PATCH 1/4] feat(core): add `relinkURL` and a `noLink` link unit form --- packages/core/src/extensions/follow-link.ts | 2 +- .../core/src/extensions/get-link-unit-at.ts | 33 +++++++++++- .../core/src/extensions/link-commands.test.ts | 54 +++++++++++++++++-- packages/core/src/extensions/link-commands.ts | 17 +++++- 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/packages/core/src/extensions/follow-link.ts b/packages/core/src/extensions/follow-link.ts index 076d6eb1..125f645a 100644 --- a/packages/core/src/extensions/follow-link.ts +++ b/packages/core/src/extensions/follow-link.ts @@ -115,7 +115,7 @@ function handlerTextMarkTrigger( } const link = onLinkClick && getLinkUnitAt(state, pos) - if (link && link.unit.from < pos && pos < link.unit.to) { + if (link && link.form !== 'noLink' && link.unit.from < pos && pos < link.unit.to) { onLinkClick({ href: link.href, event, mod }) return true } diff --git a/packages/core/src/extensions/get-link-unit-at.ts b/packages/core/src/extensions/get-link-unit-at.ts index b7adc91b..a17374f4 100644 --- a/packages/core/src/extensions/get-link-unit-at.ts +++ b/packages/core/src/extensions/get-link-unit-at.ts @@ -1,7 +1,9 @@ +import { getAutolinkHref } from '@meowdown/markdown' import type { EditorState } from '@prosekit/pm/state' import type { PositionRange } from '../utils/range.ts' +import { getHiddenRunBefore, isMagicChar } from './hidden-run.ts' import type { MdPackAttrs } from './inline-marks.ts' import { isMarkOfType, type MarkName } from './mark-names.ts' import { getMarkRangeAt } from './mark-range.ts' @@ -56,6 +58,16 @@ export type LinkUnit = label?: undefined dest?: undefined }) + | (LinkUnitBase & { + /** + * A URL kept plain by a trailing `noLink` magic comment. `href` is the + * address relinking would resolve, or `''` when the text no longer + * autolinks. + */ + form: 'noLink' + label?: undefined + dest?: undefined + }) /** * The last text run carrying `markName` inside `range`. "Last" so a linked @@ -93,14 +105,31 @@ export function getLinkUnitAt(state: EditorState, pos: number): LinkUnit | undef // the pack by `key`: a link inside `**bold**` must find its own pack, not // the outer unit's. const unit = getMarkRangeAt(state, pos, 'mdPack', (mark) => { - return (mark.attrs as MdPackAttrs).key.startsWith('link-') + const key = (mark.attrs as MdPackAttrs).key + return key.startsWith('link-') || key === 'noLink' }) if (!unit) return - const attrs = unit.mark.attrs as Extract + const attrs = unit.mark.attrs as Extract const unitRange = { from: unit.from, to: unit.to } switch (attrs.key) { + // An unlinked URL: the visible address is the unit minus its trailing + // magic comment. + case 'noLink': { + const comment = getHiddenRunBefore(state, unit.to, isMagicChar) + const text = { from: unit.from, to: comment?.from ?? unit.to } + const address = state.doc.textBetween(text.from, text.to) + return { + state, + form: 'noLink', + unit: unitRange, + text, + href: getAutolinkHref(address) ?? '', + title: '', + } + } + // A bare autolink is its own visible text. case 'link-bare': return { diff --git a/packages/core/src/extensions/link-commands.test.ts b/packages/core/src/extensions/link-commands.test.ts index a2865ed6..05e47ae0 100644 --- a/packages/core/src/extensions/link-commands.test.ts +++ b/packages/core/src/extensions/link-commands.test.ts @@ -1,11 +1,14 @@ import type { EditorState } from '@prosekit/pm/state' import { describe, expect, it } from 'vitest' +import { page } from 'vitest/browser' import { findText } from '../testing/find-text.ts' import { setupFixture } from '../testing/index.ts' import { getLinkUnitAt } from './get-link-unit-at.ts' +const pmRoot = page.locate('.ProseMirror') + describe('insertLink', () => { it('wraps the selection as a link', () => { using fixture = setupFixture() @@ -136,13 +139,14 @@ describe('updateLink', () => { }) /** - * Assert no link unit remains anywhere: `textContent` alone cannot prove + * Assert nothing linkable remains anywhere: `textContent` alone cannot prove * unlinking, because re-autolinked text keeps identical source characters - * and only the marks change. + * and only the marks change. An unlinked URL still reports its `noLink` unit. */ function expectNoLink(state: EditorState): void { for (let pos = 0; pos <= state.doc.content.size; pos++) { - expect(getLinkUnitAt(state, pos)).toBeUndefined() + const unit = getLinkUnitAt(state, pos) + if (unit) expect(unit.form).toBe('noLink') } } @@ -230,3 +234,47 @@ describe('removeLink', () => { expect(fixture.doc.textContent).toContain('[Docs][doc]') }) }) + +describe('relinkURL', () => { + it('deletes the magic comment so the address autolinks again', async () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('see https://example.com now'))) + editor.commands.selectText(findText(fixture.doc, 'example.com') + 1) + expect(editor.commands.relinkURL()).toBe(true) + expect(fixture.doc.child(0).textContent).toBe('see https://example.com now') + await expect + .element(pmRoot.getByRole('link', { name: 'https://example.com' })) + .toBeInTheDocument() + }) + + it('declines on a real link', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('see https://example.com now'))) + editor.commands.selectText(findText(fixture.doc, 'example.com') + 1) + expect(editor.commands.relinkURL()).toBe(false) + }) + + it('removeLink declines on an already unlinked URL', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('see https://example.com now'))) + editor.commands.selectText(findText(fixture.doc, 'example.com') + 1) + expect(editor.commands.removeLink()).toBe(false) + expect(fixture.doc.child(0).textContent).toBe( + 'see https://example.com now', + ) + }) + + it('updateLink replaces the whole unit, comment included', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('see https://example.com now'))) + editor.commands.selectText(findText(fixture.doc, 'example.com') + 1) + expect(editor.commands.updateLink({ href: 'https://other.dev' })).toBe(true) + expect(fixture.doc.child(0).textContent).toBe( + 'see [https://example.com](https://other.dev) now', + ) + }) +}) diff --git a/packages/core/src/extensions/link-commands.ts b/packages/core/src/extensions/link-commands.ts index 949517c2..ab11e9b2 100644 --- a/packages/core/src/extensions/link-commands.ts +++ b/packages/core/src/extensions/link-commands.ts @@ -181,7 +181,7 @@ export function updateLink(attrs: LinkAttrs): Command { export function removeLink(): Command { return (state, dispatch) => { const link = getLinkUnitAt(state, state.selection.from) - if (!link || link.form === 'reference') return false + if (!link || link.form === 'reference' || link.form === 'noLink') return false if (dispatch) { const text = getLinkText(link) // Keep authored label Markdown intact when it cannot immediately become @@ -201,14 +201,27 @@ export function removeLink(): Command { } } +/** + * Delete the trailing `noLink` magic comment so the address autolinks again. + */ +export function relinkURL(): Command { + return (state, dispatch) => { + const link = getLinkUnitAt(state, state.selection.from) + if (!link || link.form !== 'noLink' || link.text.to === link.unit.to) return false + dispatch?.(state.tr.delete(link.text.to, link.unit.to).scrollIntoView()) + return true + } +} + export function defineLinkCommands(): Extension<{ Commands: { insertLink: [options?: InsertLinkOptions] updateLink: [attrs: LinkAttrs] removeLink: [] + relinkURL: [] } }> { - return defineCommands({ insertLink, updateLink, removeLink }) + return defineCommands({ insertLink, updateLink, removeLink, relinkURL }) } export interface LinkEditOptions { From 89d374d5e281467522d4aa3347b1bb8d949ef6ae Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 31 Aug 2026 03:40:37 +1200 Subject: [PATCH 2/4] feat(react): offer a relink action for `noLink` units in the link edit popover --- .../react/src/components/link-menu.module.css | 5 +++++ .../src/components/link-menu.module.d.css.ts | 2 ++ packages/react/src/components/link-menu.test.tsx | 16 ++++++++++++++++ packages/react/src/components/link-menu.tsx | 15 ++++++++++++++- 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/link-menu.module.css b/packages/react/src/components/link-menu.module.css index de94b1ff..ef6d86a9 100644 --- a/packages/react/src/components/link-menu.module.css +++ b/packages/react/src/components/link-menu.module.css @@ -284,6 +284,7 @@ } .RemoveButton, +.RelinkButton, .UseTitleButton { min-height: 2rem; padding: 0.375rem 0.625rem; @@ -302,6 +303,10 @@ color: var(--meowdown-danger, #c33); } +.RelinkButton { + color: var(--meowdown-accent); +} + .UseTitleButton { margin-left: auto; color: var(--meowdown-accent); diff --git a/packages/react/src/components/link-menu.module.d.css.ts b/packages/react/src/components/link-menu.module.d.css.ts index 674bdadb..10efe088 100644 --- a/packages/react/src/components/link-menu.module.d.css.ts +++ b/packages/react/src/components/link-menu.module.d.css.ts @@ -27,8 +27,10 @@ declare const styles = { 'Input': '' as string, 'FormActions': '' as string, 'RemoveButton': '' as string, + 'RelinkButton': '' as string, 'UseTitleButton': '' as string, 'RemoveButton': '' as string, + 'RelinkButton': '' as string, 'UseTitleButton': '' as string, } as const; export default styles; diff --git a/packages/react/src/components/link-menu.test.tsx b/packages/react/src/components/link-menu.test.tsx index a6795c77..807fa526 100644 --- a/packages/react/src/components/link-menu.test.tsx +++ b/packages/react/src/components/link-menu.test.tsx @@ -291,6 +291,22 @@ describe('LinkMenu', () => { expect(ref.current?.getMarkdown()).toContain('[Docs](https://example.com)') }) + it('relinks an unlinked URL from the edit form', async () => { + const ref = createRef() + await render( + now'} + />, + ) + await pmRoot.getByText('www.example.com').click() + await userEvent.keyboard('{ControlOrMeta>}k{/ControlOrMeta}') + await expect.element(popover.getByTestId('link-popover-edit')).toBeVisible() + await popover.getByRole('button', { name: 'Relink' }).click() + await expect.element(pmRoot.getByRole('link', { name: 'www.example.com' })).toBeInTheDocument() + expect(ref.current?.getMarkdown()).toBe('see www.example.com now\n') + }) + it('removes a link from the read preview', async () => { const ref = createRef() const screen = await render( diff --git a/packages/react/src/components/link-menu.tsx b/packages/react/src/components/link-menu.tsx index 22030f31..87bdc2e4 100644 --- a/packages/react/src/components/link-menu.tsx +++ b/packages/react/src/components/link-menu.tsx @@ -303,11 +303,13 @@ function LinkEditContent({ resolveLinkPreview, onSubmit, onRemove, + onRelink, }: { edit: LinkEditOptions resolveLinkPreview?: LinkPreviewResolver onSubmit: (text: string, href: string) => void onRemove?: () => void + onRelink?: () => void }) { const [text, setText] = useState(edit.text) const [href, setHref] = useState(edit.link?.href ?? '') @@ -368,6 +370,11 @@ function LinkEditContent({ Remove link )} + {onRelink && ( + + )} {canUseTitle && previewState.status === 'resolved' && (