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
2 changes: 1 addition & 1 deletion packages/core/src/extensions/follow-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
33 changes: 31 additions & 2 deletions packages/core/src/extensions/get-link-unit-at.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<MdPackAttrs, { key: `link-${string}` }>
const attrs = unit.mark.attrs as Extract<MdPackAttrs, { key: `link-${string}` | 'noLink' }>
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 {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/extensions/hidden-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { getMarkType } from '@prosekit/core'
import type { Mark } from '@prosekit/pm/model'
import type { EditorState } from '@prosekit/pm/state'

import { isMarkOfType, isMarkOfTypes, SYNTAX_MARK_NAMES, type MarkName } from './mark-names.ts'
import { getMarkMode } from './mark-mode.ts'
import { isMarkOfType, isMarkOfTypes, SYNTAX_MARK_NAMES, type MarkName } from './mark-names.ts'

export interface HiddenRun {
from: number
Expand Down Expand Up @@ -32,7 +32,7 @@ export function isHiddenChar(state: EditorState, pos: number): boolean {
* A character hidden in every mark mode: editor-managed magic-comment
* metadata (`mdMagic`), which style.css never renders.
*/
function isMagicChar(state: EditorState, pos: number): boolean {
export function isMagicChar(state: EditorState, pos: number): boolean {
const marks = getCharMarks(state, pos)
if (marks == null) return false
return marks.some((mark) => isMarkOfType(mark, 'mdMagic'))
Expand Down
54 changes: 51 additions & 3 deletions packages/core/src/extensions/link-commands.test.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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')
}
}

Expand Down Expand Up @@ -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<!-- {"noLink":true} --> 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<!-- {"noLink":true} --> 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<!-- {"noLink":true} --> 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<!-- {"noLink":true} --> 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',
)
})
})
17 changes: 15 additions & 2 deletions packages/core/src/extensions/link-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export {
isLinkTextForHref,
insertLink,
normalizeHref,
relinkURL,
removeLink,
updateLink,
type LinkAttrs,
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/components/link-menu.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
}

.RemoveButton,
.RelinkButton,
.UseTitleButton {
min-height: 2rem;
padding: 0.375rem 0.625rem;
Expand All @@ -302,6 +303,10 @@
color: var(--meowdown-danger, #c33);
}

.RelinkButton {
color: var(--meowdown-accent);
}

.UseTitleButton {
margin-left: auto;
color: var(--meowdown-accent);
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/components/link-menu.module.d.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
16 changes: 16 additions & 0 deletions packages/react/src/components/link-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EditorHandle>()
await render(
<MeowdownEditor
handleRef={ref}
initialMarkdown={'see www.example.com<!-- {"noLink":true} --> 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<EditorHandle>()
const screen = await render(
Expand Down
15 changes: 14 additions & 1 deletion packages/react/src/components/link-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '')
Expand Down Expand Up @@ -368,6 +370,11 @@ function LinkEditContent({
Remove link
</button>
)}
{onRelink && (
<button type="button" className={styles.RelinkButton} onClick={onRelink}>
Relink
</button>
)}
{canUseTitle && previewState.status === 'resolved' && (
<button
type="button"
Expand Down Expand Up @@ -439,6 +446,11 @@ export function LinkMenu({
closeEdit()
}, [editor, closeEdit])

const handleEditRelink = useCallback(() => {
editor.commands.relinkURL()
closeEdit()
}, [editor, closeEdit])

const handleEditSubmit = useCallback(
(text: string, href: string) => {
if (!edit) return
Expand Down Expand Up @@ -519,7 +531,8 @@ export function LinkMenu({
<LinkEditContent
edit={edit}
resolveLinkPreview={resolveLinkPreview}
onRemove={handleEditRemove}
onRemove={edit.link?.form === 'noLink' ? undefined : handleEditRemove}
onRelink={edit.link?.form === 'noLink' ? handleEditRelink : undefined}
onSubmit={handleEditSubmit}
/>
) : link ? (
Expand Down
Loading