diff --git a/packages/core/src/extensions/atom-mark-navigation.ts b/packages/core/src/extensions/atom-mark-navigation.ts index e602e9c1..f8a89b74 100644 --- a/packages/core/src/extensions/atom-mark-navigation.ts +++ b/packages/core/src/extensions/atom-mark-navigation.ts @@ -166,6 +166,7 @@ function createForwardDelete(marks: AtomMarks): Command { } const SELECTED_CLASS = 'md-atom-selected' +const SELECTED_TEST_ID = 'atom-selection' // Decorate each selected atom's source range with `md-atom-selected`, so its // mark view can ring the rendered preview/label. @@ -182,7 +183,10 @@ function createSelectionPlugin(marks: AtomMarks): Plugin { const range = getSelectedRange(state, markNames) if (range) { return DecorationSet.create(state.doc, [ - Decoration.inline(range.from, range.to, { class: SELECTED_CLASS }), + Decoration.inline(range.from, range.to, { + class: SELECTED_CLASS, + 'data-testid': SELECTED_TEST_ID, + }), ]) } @@ -192,7 +196,12 @@ function createSelectionPlugin(marks: AtomMarks): Plugin { const decorations: Decoration[] = [] state.doc.nodesBetween(from, to, (node, pos) => { if (node.marks.some((mark) => markNames.includes(mark.type.name as MarkName))) { - decorations.push(Decoration.inline(pos, pos + node.nodeSize, { class: SELECTED_CLASS })) + decorations.push( + Decoration.inline(pos, pos + node.nodeSize, { + class: SELECTED_CLASS, + 'data-testid': SELECTED_TEST_ID, + }), + ) } }) return DecorationSet.create(state.doc, decorations) diff --git a/packages/core/src/extensions/hidden-run-caret.test.ts b/packages/core/src/extensions/hidden-run-caret.test.ts index cc805f34..2dc487b7 100644 --- a/packages/core/src/extensions/hidden-run-caret.test.ts +++ b/packages/core/src/extensions/hidden-run-caret.test.ts @@ -266,10 +266,10 @@ describe('hide mode unformat deletion', () => { expect(docToMarkdown(fixture.doc)).toBe('**a**b\n') }) - it('a fully hidden unit deletes entirely', async () => { + it('a degenerate literal link deletes one character', async () => { using fixture = setupMode('hide', 'x[](https://a.io)y') await userEvent.keyboard('{Backspace}') - expect(fixture.selectionSnapshot).toMatchInlineSnapshot(`"x┃y"`) + expect(fixture.selectionSnapshot).toMatchInlineSnapshot(`"x[](https://a.io┃y"`) }) it('atom deletion stays with atom navigation at a shared boundary', async () => { diff --git a/packages/core/src/extensions/hidden-run.test.ts b/packages/core/src/extensions/hidden-run.test.ts index b4be6a84..805a136b 100644 --- a/packages/core/src/extensions/hidden-run.test.ts +++ b/packages/core/src/extensions/hidden-run.test.ts @@ -202,10 +202,10 @@ describe('getUnitMarkerRuns', () => { expect(runs.map((run) => runText(fixture, run))).toEqual(['***', '***']) }) - it('returns one run for a fully hidden unit', () => { + it('returns no runs for a degenerate literal link', () => { using fixture = setup('x[](https://a.io)y') const start = findText(fixture.doc, 'x') + 1 const runs = getUnitMarkerRuns(fixture.state, start) - expect(runs.map((run) => runText(fixture, run))).toEqual(['[](https://a.io)']) + expect(runs.map((run) => runText(fixture, run))).toEqual([]) }) }) diff --git a/packages/core/src/extensions/hidden-run.ts b/packages/core/src/extensions/hidden-run.ts index 5ca12145..ef5b479e 100644 --- a/packages/core/src/extensions/hidden-run.ts +++ b/packages/core/src/extensions/hidden-run.ts @@ -2,17 +2,7 @@ import { getMarkType } from '@prosekit/core' import type { Mark } from '@prosekit/pm/model' import type { EditorState } from '@prosekit/pm/state' -import type { MarkName } from './mark-names.ts' - -// The marks whose text hide mode renders at font-size 0. Mirrors the CSS rules -// in style.css and CLIPBOARD_STRIP_MARK_NAMES in mark-mode.ts. Atom sources -// (mdImage / mdWikilink / mdFile) are not here: defineAtomMarkNavigation owns -// them. -const HIDDEN_MARK_NAMES: ReadonlySet = new Set([ - 'mdMark', - 'mdLinkUri', - 'mdLinkTitle', -]) +import { HIDDEN_SYNTAX_MARK_NAMES, type MarkName } from './mark-names.ts' export interface HiddenRun { from: number @@ -32,7 +22,7 @@ function getCharMarks(state: EditorState, pos: number): readonly Mark[] | undefi export function isHiddenChar(state: EditorState, pos: number): boolean { const marks = getCharMarks(state, pos) if (marks == null) return false - return marks.some((mark) => HIDDEN_MARK_NAMES.has(mark.type.name as MarkName)) + return marks.some((mark) => HIDDEN_SYNTAX_MARK_NAMES.has(mark.type.name as MarkName)) } function isInsideNonCodeTextblock(state: EditorState, pos: number): boolean { diff --git a/packages/core/src/extensions/image.test.ts b/packages/core/src/extensions/image.test.ts index c511dc03..8836c28b 100644 --- a/packages/core/src/extensions/image.test.ts +++ b/packages/core/src/extensions/image.test.ts @@ -17,6 +17,8 @@ import type { MarkMode } from './mark-mode.ts' const pmRoot = page.locate('.ProseMirror') const preview = pmRoot.getByTestId('image-preview') +const imageSource = pmRoot.getByTestId('image-source') +const atomSelection = pmRoot.getByTestId('atom-selection') function getSVGImageURL(width: number, height: number): string { const svg = @@ -126,6 +128,60 @@ describe('image selection ring', () => { }) }) +describe('unresolvable image source', () => { + function setupUnresolvable(mode: MarkMode, text: string): Fixture { + const fixture = setupFixture({ extensionOptions: { markMode: mode } }) + const { editor, n } = fixture + editor.use(defineImage({ resolveImageUrl: () => undefined })) + fixture.set(n.doc(n.paragraph(text))) + fixture.view.focus() + return fixture + } + + it('shows the source in hide mode', async () => { + using fixture = setupUnresolvable('hide', '![alt](x.png)') + void fixture + await expect.element(imageSource).toBeVisible() + await expect.element(pmRoot.getByText('![alt](x.png)')).toBeVisible() + }) + + it('shows the source in focus mode', async () => { + using fixture = setupUnresolvable('focus', '![alt](x.png)') + void fixture + await expect.element(imageSource).toBeVisible() + await expect.element(pmRoot.getByText('![alt](x.png)')).toBeVisible() + }) + + it('shows the source in show mode', async () => { + using fixture = setupUnresolvable('show', '![alt](x.png)') + void fixture + await expect.element(imageSource).toBeVisible() + await expect.element(pmRoot.getByText('![alt](x.png)')).toBeVisible() + }) + + it('shows the selected source when arrowing onto it', async () => { + using fixture = setupUnresolvable('hide', 'ABC![alt](x.png)DEF') + expect(getSelectionSnapshot(fixture.state)).toMatchInlineSnapshot(`"ABC┃![alt](x.png)DEF"`) + await userEvent.keyboard('{ArrowRight}') + expect(getSelectionSnapshot(fixture.state)).toMatchInlineSnapshot(`"ABC❰![alt](x.png)❱DEF"`) + await expect.element(atomSelection).toBeVisible() + }) + + it('edits the revealed source text', async () => { + using fixture = setupUnresolvable('hide', '![alt](x.png)') + await userEvent.click(imageSource) + await userEvent.keyboard('X') + expect(fixture.doc.textContent).toContain('X') + }) + + it('keeps the source hidden when a preview renders', async () => { + using fixture = setup('hide', '![alt](url)') + void fixture + await expect.element(preview).toBeVisible() + await expect.element(imageSource).not.toBeVisible() + }) +}) + describe('image click callback', () => { // Render `markdown` with a click handler attached, showing http(s) images as-is. function setupClickable(markdown: string, onImageClick: ImageClickHandler): Fixture { @@ -335,6 +391,14 @@ describe('image resize', () => { expect(resizable.element()).toHaveAttribute('data-loading', '') await expect.element(resizable).not.toHaveAttribute('data-loading') }) + + it('keeps a placeholder when the image fails to load', async () => { + using fixture = setupResize('![cat](u)', 'https://example.invalid/missing.png') + void fixture + pmRoot.getByAltText('cat').element().dispatchEvent(new Event('error')) + await expect.element(resizable).toHaveAttribute('data-error', '') + await expect.element(resizable).not.toHaveAttribute('data-loading') + }) }) describe('typing after an inline image', () => { diff --git a/packages/core/src/extensions/image.ts b/packages/core/src/extensions/image.ts index 87b32ca3..5f433e7e 100644 --- a/packages/core/src/extensions/image.ts +++ b/packages/core/src/extensions/image.ts @@ -132,6 +132,7 @@ class ImageMarkView implements MarkView { this.#contentDOM = document.createElement('span') this.#contentDOM.className = 'md-image-view-content md-atom-view-content' + this.#contentDOM.dataset.testid = 'image-source' const preview = this.#renderPreview() if (preview) { @@ -222,6 +223,7 @@ class ImageMarkView implements MarkView { applyDisplaySize(root, image, this.#attrs.width, this.#attrs.height) image.addEventListener('load', () => { root.removeAttribute('data-loading') + root.removeAttribute('data-error') const ratio = image.naturalWidth / image.naturalHeight if (!Number.isFinite(ratio) || ratio <= 0) return root.setAttribute('data-aspect-ratio', String(ratio)) @@ -230,6 +232,7 @@ class ImageMarkView implements MarkView { }) image.addEventListener('error', () => { root.removeAttribute('data-loading') + root.setAttribute('data-error', '') }) root.appendChild(image) diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts index f3d1eee7..75daab77 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.test.ts @@ -784,6 +784,135 @@ describe('wikilink', () => { }) }) +describe('degenerate units', () => { + it('keeps an empty image literal', () => { + expect(parse('![]()')).toMatchInlineSnapshot(` + " + [0, 5] + " + `) + }) + + it('keeps an empty link literal', () => { + expect(parse('[]()')).toMatchInlineSnapshot(` + " + [0, 4] + " + `) + }) + + it('keeps a link with an empty label literal', () => { + expect(parse('[](https://example.com)')).toMatchInlineSnapshot(` + " + [0, 23] + " + `) + }) + + it('keeps a titled link with an empty label literal', () => { + expect(parse('[](https://example.com "title")')).toMatchInlineSnapshot(` + " + [0, 31] + " + `) + }) + + it('keeps a link with a whitespace label literal', () => { + expect(parse('[ ](https://example.com)')).toMatchInlineSnapshot(` + " + [0, 24] + " + `) + }) + + it('keeps a link with visible text as a link', () => { + expect(parse('[a]()')).toMatchInlineSnapshot(` + " + [0, 1] mdPack(key=link,data={"href":"","title":""}) + mdLinkText + mdMark + [1, 2] mdPack(key=link,data={"href":"","title":""}) + mdLinkText + [2, 5] mdPack(key=link,data={"href":"","title":""}) + mdMark + " + `) + }) + + it('keeps an image with visible alt text as a link-shaped source', () => { + expect(parse('![alt]()')).toMatchInlineSnapshot(` + " + [0, 2] mdPack(key=link,data={"href":"","title":""}) + mdMark + [2, 5] mdPack(key=link,data={"href":"","title":""}) + [5, 8] mdPack(key=link,data={"href":"","title":""}) + mdMark + " + `) + }) + + it('keeps a nested image label visible', () => { + expect(parse('[![alt](https://img.test/b.svg)](https://x.test)')).toMatchInlineSnapshot(` + " + [0, 3] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + mdMark + [3, 6] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + [6, 8] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + mdMark + [8, 30] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + mdLinkText(href=https://img.test/b.svg) + [30, 31] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + mdMark + [31, 33] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdMark + [33, 47] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkUri + [47, 48] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdMark + " + `) + }) + + it('keeps a pipe-only wikilink literal', () => { + expect(parse('[[|]]')).toMatchInlineSnapshot(` + " + [0, 5] + " + `) + }) + + it('keeps a spaced pipe-only wikilink literal', () => { + expect(parse('[[ | ]]')).toMatchInlineSnapshot(` + " + [0, 7] + " + `) + }) + + it('keeps an empty-target wikilink with an alias literal', () => { + expect(parse('[[|alias]]')).toMatchInlineSnapshot(` + " + [0, 10] + " + `) + }) + + it('keeps an empty-alias wikilink with a target as a wikilink', () => { + expect(parse('[[x|]]')).toMatchInlineSnapshot(` + " + [0, 6] mdWikilink(target=x) + " + `) + }) + + it('strips angle brackets from image destinations', () => { + expect(parse('![a]()')).toMatchInlineSnapshot(` + " + [0, 30] mdImage(src=https://x.test/a b.png,alt=a) + " + `) + }) + + it('strips angle brackets from link destinations', () => { + expect(parse('[a]()')).toMatchInlineSnapshot(` + " + [0, 1] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + mdMark + [1, 2] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkText(href=https://x.test) + [2, 4] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdMark + [4, 20] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdLinkUri + [20, 21] mdPack(key=link,data={"href":"https://x.test","title":""}) + mdMark + " + `) + }) +}) + describe('file link', () => { const claimAssets: FileLinkResolver = ({ href }) => href.startsWith('assets/') @@ -856,6 +985,15 @@ describe('file link', () => { `) }) + it('strips angle brackets before claiming a file link', () => { + expect(parse('[report.pdf]()', { resolveFileLink: claimAssets })) + .toMatchInlineSnapshot(` + " + [0, 33] mdFile(href=assets/report.pdf,name=report.pdf) + " + `) + }) + it('claims only what the resolver claims in mixed content', () => { expect( parse('see [report.pdf](assets/report.pdf) and [docs](https://example.com)', { diff --git a/packages/core/src/extensions/inline-text-to-mark-chunks.ts b/packages/core/src/extensions/inline-text-to-mark-chunks.ts index 2e764170..c3ecc55d 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.ts @@ -14,7 +14,7 @@ import type { } from './inline-marks.ts' import { parseMagicComment, type MagicComment } from './magic-comment.ts' import type { MarkChunk } from './mark-chunk.ts' -import type { MarkName } from './mark-names.ts' +import { HIDDEN_SYNTAX_MARK_NAMES, type MarkName } from './mark-names.ts' import { marksEqual } from './marks-equal.ts' import type { TypedMarkBuilders } from './schema.ts' import { parseWikilink } from './wikilink.ts' @@ -46,9 +46,15 @@ const MARK_NAME_BY_TYPE_ID: ReadonlyMap = new Map([ [LEZER_NODE_IDS.WikilinkMark, 'mdMark'], ]) +const ATOM_SOURCE_MARK_NAMES: ReadonlySet = new Set([ + 'mdImage', + 'mdWikilink', + 'mdFile', +]) + /** What {@link FileLinkResolver} sees for one `[label](url)` link. */ export interface FileLinkPayload { - /** The link destination, exactly as written in the source. */ + /** The link destination, with surrounding angle brackets removed. */ href: string /** The raw label slice between the brackets; may be empty or contain nested syntax. */ label: string @@ -94,6 +100,28 @@ function unquoteTitle(raw: string): string { return raw.slice(1, -1).replaceAll(/\\(.)/g, '$1') } +interface LiteralRange { + from: number + to: number +} + +function getEmptyTargetWikilinkLiteralRange( + node: InlineElement, + text: string, + rangeStart: number, + rangeEnd: number, +): LiteralRange | undefined { + if (node.type !== LEZER_NODE_IDS.Link) return + const outerFrom = node.from - 1 + const outerTo = node.to + 1 + if (outerFrom < rangeStart || outerTo > rangeEnd) return + const source = text.slice(outerFrom, outerTo) + if (!source.startsWith('[[') || !source.endsWith(']]')) return + const { target, display } = parseWikilink(source) + if (target || display) return + return { from: outerFrom, to: outerTo } +} + function walk( nodes: readonly InlineElement[], parentMarks: readonly Mark[], @@ -107,6 +135,15 @@ function walk( let pos = rangeStart for (let index = 0; index < nodes.length; index++) { const node = nodes[index] + const literalRange = getEmptyTargetWikilinkLiteralRange(node, text, rangeStart, rangeEnd) + if (literalRange && literalRange.from >= pos) { + if (literalRange.from > pos) { + emit(out, pos, literalRange.from, parentMarks) + } + emit(out, literalRange.from, literalRange.to, parentMarks) + pos = literalRange.to + continue + } if (node.from > pos) { emit(out, pos, node.from, parentMarks) } @@ -131,12 +168,12 @@ function walk( } else if (type === LEZER_NODE_IDS.URL) { // A standalone `URL` node is a GFM autolink (the address part of a real // `[text](url)` is handled inside `walkLink`, not here). Linkify the - // shapes we recognize; anything else keeps the muted `mdLinkUri`. + // shapes we recognize; anything else stays plain text. const href = getAutolinkHref(text.slice(node.from, node.to)) - const mark: Mark = href + const mark: Mark | undefined = href ? marks.mdLinkText.create({ href } satisfies MdLinkTextAttrs) - : marks.mdLinkUri.create() - emit(out, node.from, node.to, [...parentMarks, mark]) + : undefined + emit(out, node.from, node.to, mark ? [...parentMarks, mark] : parentMarks) } else { let packKey: MdPackSimpleKey | undefined @@ -179,6 +216,7 @@ interface LinkParts { labelTo: number urlNode: InlineElement | null titleNode: InlineElement | null + hasInlineDestination: boolean } /** @@ -203,7 +241,7 @@ function scanLinkParts(node: InlineElement): LinkParts { titleNode = child } } - return { labelFrom, labelTo, urlNode, titleNode } + return { labelFrom, labelTo, urlNode, titleNode, hasInlineDestination: bracketCount >= 4 } } /** The last path segment of `href` (query/hash stripped), decoded when possible. */ @@ -217,6 +255,22 @@ function hrefBasename(href: string): string { } } +function stripAngleBrackets(url: string): string { + if (url.startsWith('<') && url.endsWith('>')) return url.slice(1, -1) + return url +} + +function hasVisibleContent(chunks: readonly MarkChunk[], text: string): boolean { + for (const [from, to, marks] of chunks) { + if (!/\S/.test(text.slice(from, to))) continue + const markNames = marks.map((mark) => mark.type.name as MarkName) + if (markNames.some((markName) => HIDDEN_SYNTAX_MARK_NAMES.has(markName))) continue + if (markNames.some((markName) => ATOM_SOURCE_MARK_NAMES.has(markName))) continue + return true + } + return false +} + /** * The marks for a whole `[label](url)` link that the host's `resolveFileLink` * claimed as a file, or `undefined` when the link stays a regular link. The @@ -234,7 +288,7 @@ function claimFileLink( if (!resolveFileLink) return undefined const { labelFrom, labelTo, urlNode, titleNode } = scanLinkParts(node) if (labelFrom < 0 || labelTo < 0 || !urlNode) return undefined - const href = text.slice(urlNode.from, urlNode.to) + const href = stripAngleBrackets(text.slice(urlNode.from, urlNode.to)) if (!href) return undefined const label = text.slice(labelFrom, labelTo) const title = titleNode ? unquoteTitle(text.slice(titleNode.from, titleNode.to)) : '' @@ -267,26 +321,29 @@ function walkLink( marks: TypedMarkBuilders, out: MarkChunk[], ): void { - const { labelTo: labelEnd, urlNode, titleNode } = scanLinkParts(node) - const href = urlNode ? text.slice(urlNode.from, urlNode.to) : '' + const { labelTo: labelEnd, urlNode, titleNode, hasInlineDestination } = scanLinkParts(node) + const href = urlNode ? stripAngleBrackets(text.slice(urlNode.from, urlNode.to)) : '' const title = titleNode ? unquoteTitle(text.slice(titleNode.from, titleNode.to)) : '' - const linkTextMark = href ? marks.mdLinkText.create({ href } satisfies MdLinkTextAttrs) : null + const isMarkdownLink = node.type === LEZER_NODE_IDS.Link && hasInlineDestination + const linkTextMark = + href || isMarkdownLink ? marks.mdLinkText.create({ href } satisfies MdLinkTextAttrs) : null const inLabel = (pos: number): boolean => labelEnd >= 0 && pos < labelEnd && linkTextMark !== null const pack = marks.mdPack.create({ key: 'link', data: { href, title } } satisfies MdPackAttrs) const base = [...parentMarks, pack] + const unitChunks: MarkChunk[] = [] let pos = node.from for (const child of node.children) { if (child.from > pos) { const childMarks = inLabel(pos) ? [...base, linkTextMark!] : base - emit(out, pos, child.from, childMarks) + emit(unitChunks, pos, child.from, childMarks) } const baseForChild = inLabel(child.from) ? [...base, linkTextMark!] : base // A wikilink in the label needs its own source/view walk, not the generic // per-child mark mapping. if (child.type === LEZER_NODE_IDS.Wikilink) { - walkWikilink(child, baseForChild, text, marks, out) + walkWikilink(child, baseForChild, text, marks, unitChunks) pos = child.to continue } @@ -295,15 +352,22 @@ function walkLink( ? [...baseForChild, marks[maybeMarkName].create()] : baseForChild if (child.children.length === 0) { - emit(out, child.from, child.to, childMarks) + emit(unitChunks, child.from, child.to, childMarks) } else { // No options: a link label cannot contain another `[label](url)` link. - walk(child.children, childMarks, child.from, child.to, text, marks, out, undefined) + walk(child.children, childMarks, child.from, child.to, text, marks, unitChunks, undefined) } pos = child.to } if (pos < node.to) { - emit(out, pos, node.to, base) + emit(unitChunks, pos, node.to, base) + } + if (!hasVisibleContent(unitChunks, text)) { + emit(out, node.from, node.to, parentMarks) + return + } + for (const [from, to, chunkMarks] of unitChunks) { + emit(out, from, to, chunkMarks) } } @@ -350,7 +414,7 @@ function walkImage( const bracketNodes = node.children.filter((child) => child.type === LEZER_NODE_IDS.LinkMark) const titleNode = node.children.find((child) => child.type === LEZER_NODE_IDS.LinkTitle) - const src: string = text.slice(urlNode.from, urlNode.to) + const src: string = stripAngleBrackets(text.slice(urlNode.from, urlNode.to)) const alt: string = bracketNodes.length >= 2 ? text.slice(bracketNodes[0].to, bracketNodes[1].from) : '' const title: string = titleNode ? unquoteTitle(text.slice(titleNode.from, titleNode.to)) : '' @@ -406,6 +470,10 @@ function walkWikilink( out: MarkChunk[], ): void { const { target, display } = parseWikilink(text.slice(node.from, node.to)) + if (!target) { + emit(out, node.from, node.to, parentMarks) + return + } emit(out, node.from, node.to, [...parentMarks, marks.mdWikilink.create({ target, display })]) } diff --git a/packages/core/src/extensions/mark-mode.test.ts b/packages/core/src/extensions/mark-mode.test.ts index 47a8ea8c..de4ed12e 100644 --- a/packages/core/src/extensions/mark-mode.test.ts +++ b/packages/core/src/extensions/mark-mode.test.ts @@ -527,6 +527,16 @@ describe('focus mode', () => { `) }) + it('renders a degenerate image as literal text', () => { + expect(renderHTML('focus', 'a ![]() b')).toMatchInlineSnapshot(` + " +

+ a ![]() b +

+ " + `) + }) + it('reveals the whole link when the cursor sits right after the closing )', () => { expect(renderHTML('focus', 'ABC[link](https://example.com)
DEF')).toMatchInlineSnapshot(` " @@ -773,7 +783,10 @@ describe('focus mode', () => { - + ![alt](pic.png) @@ -942,6 +955,50 @@ describe('hide mode', () => { expectClipboard('hide', 'visit https://example.com now', 'visit https://example.com now') }) + it('renders a degenerate image as literal text', () => { + expect(renderHTML('hide', 'a ![]() b')).toMatchInlineSnapshot(` + " +

+ a ![]() b +

+ " + `) + }) + + it('renders an empty-label link as literal text', () => { + expect(renderHTML('hide', 'a [](https://x.test)
b')).toMatchInlineSnapshot(` + " +

+ a [](https://x.test) b +

+ " + `) + }) + + it('keeps an empty-label link in the copied text', () => { + expectClipboard('hide', 'a [](https://x.test) b', 'a [](https://x.test) b') + }) + + it('shows degenerate image text', async () => { + using fixture = setupFixture({ extensionOptions: { markMode: 'hide' } }) + const { n } = fixture + fixture.set(n.doc(n.paragraph('a ![]() b'))) + await expect.element(pmRoot.getByText('![]()')).toBeVisible() + }) + + it('keeps an incrementally inserted degenerate image visible', async () => { + using fixture = setupFixture({ extensionOptions: { markMode: 'hide' } }) + const { n } = fixture + fixture.set(n.doc(n.paragraph('
'))) + fixture.view.focus() + + const prefixes = ['!', '![', '![]', '![](', '![]()'] as const + for (const prefix of prefixes) { + fixture.view.dispatch(fixture.state.tr.insertText(prefix[prefix.length - 1])) + await expect.element(pmRoot.getByText(prefix, { exact: false })).toBeVisible() + } + }) + it('keeps the whole image source so a copied image stays markdown', () => { expectClipboard( 'hide', diff --git a/packages/core/src/extensions/mark-mode.ts b/packages/core/src/extensions/mark-mode.ts index 98ee6687..3863af0c 100644 --- a/packages/core/src/extensions/mark-mode.ts +++ b/packages/core/src/extensions/mark-mode.ts @@ -4,7 +4,7 @@ import type { Command, EditorState } from '@prosekit/pm/state' import { Plugin, PluginKey } from '@prosekit/pm/state' import { Decoration, DecorationSet } from '@prosekit/pm/view' -import type { MarkName } from './mark-names.ts' +import { HIDDEN_SYNTAX_MARK_NAMES, type MarkName } from './mark-names.ts' /** * Controls how markdown syntax characters are rendered and how the @@ -16,15 +16,6 @@ import type { MarkName } from './mark-names.ts' */ export type MarkMode = 'hide' | 'focus' | 'show' -// Marks whose text is dropped from a clean clipboard copy, so copied markdown -// omits the rendered syntax. The image/wikilink sources carry only their own -// mark, so they are kept verbatim (`![alt](url)`, `[[target]]`). -const CLIPBOARD_STRIP_MARK_NAMES: ReadonlySet = new Set([ - 'mdMark', - 'mdLinkUri', - 'mdLinkTitle', -]) - // Marks whose text survives a clean copy even when a strip mark also covers // it. A math dollar carries `mdMark` for hiding, but stripping it would paste // bare TeX; `$E=mc^2$` should copy whole, like an image source. @@ -94,7 +85,7 @@ function cleanCopySerializer(slice: Slice): string { if (!textNode.isText || !textNode.text) return true const textNodeMarks = textNode.marks.map((mark) => mark.type.name as MarkName) const stripped = - textNodeMarks.some((markName) => CLIPBOARD_STRIP_MARK_NAMES.has(markName)) && + textNodeMarks.some((markName) => HIDDEN_SYNTAX_MARK_NAMES.has(markName)) && !textNodeMarks.some((markName) => CLIPBOARD_KEEP_MARK_NAMES.has(markName)) if (!stripped) parts.push(textNode.text) return false diff --git a/packages/core/src/extensions/mark-names.ts b/packages/core/src/extensions/mark-names.ts index 8748e27b..f2914ee7 100644 --- a/packages/core/src/extensions/mark-names.ts +++ b/packages/core/src/extensions/mark-names.ts @@ -17,3 +17,10 @@ export const MARK_NAMES = [ ] as const export type MarkName = (typeof MARK_NAMES)[number] + +// Mirrors the hide/focus CSS rules that collapse syntax-only text. +export const HIDDEN_SYNTAX_MARK_NAMES: ReadonlySet = new Set([ + 'mdMark', + 'mdLinkUri', + 'mdLinkTitle', +]) diff --git a/packages/core/src/extensions/wikilink.test.ts b/packages/core/src/extensions/wikilink.test.ts index 9bdc3a3f..d0106131 100644 --- a/packages/core/src/extensions/wikilink.test.ts +++ b/packages/core/src/extensions/wikilink.test.ts @@ -63,6 +63,24 @@ describe.each(ALL_MODES)('wikilink rendering in %s mode', (mode) => { }) }) +describe('degenerate wikilink rendering', () => { + it('renders a pipe-only wikilink as literal text in hide mode', async () => { + using fixture = setupFixture({ extensionOptions: { markMode: 'hide' } }) + const { n } = fixture + fixture.set(n.doc(n.paragraph('see [[|]] here'))) + await expect.element(pmRoot.getByText('[[|]]')).toBeVisible() + await expect.element(label).not.toBeInTheDocument() + }) + + it('still renders a target with an empty alias as a wikilink', async () => { + using fixture = setupFixture({ extensionOptions: { markMode: 'hide' } }) + const { n } = fixture + fixture.set(n.doc(n.paragraph('see [[x|]] here'))) + await expect.element(label).toBeVisible() + await expect.element(label).toHaveTextContent('x') + }) +}) + // A wikilink is one caret stop in every mode: arrowing onto it selects the whole // `[[Note]]`, the next arrow steps past, and Backspace deletes it as a unit. The // selection positions are identical across modes; only the rendering differs. diff --git a/packages/core/src/lezer/wikilink.test.ts b/packages/core/src/lezer/wikilink.test.ts index 0bfb5f71..d8a386ba 100644 --- a/packages/core/src/lezer/wikilink.test.ts +++ b/packages/core/src/lezer/wikilink.test.ts @@ -72,6 +72,8 @@ describe('wikilink inline parser', () => { '[[]]', '[[ ]]', '[[\t]]', + '[[|]]', + '[[ | ]]', '[[a', '[[a]', '[a]]', diff --git a/packages/core/src/lezer/wikilink.ts b/packages/core/src/lezer/wikilink.ts index 2e51982c..8f3879a9 100644 --- a/packages/core/src/lezer/wikilink.ts +++ b/packages/core/src/lezer/wikilink.ts @@ -6,11 +6,12 @@ import { CHAR_RIGHT_SQUARE_BRACKET, CHAR_SPACE, CHAR_TAB, + CHAR_VERTICAL_LINE, } from '../unicode.ts' /** * Inline parser for `[[target]]`: any chars except `[`, `]` and - * newline, at least one of them not a space/tab. The first `]` must + * newline, at least one of them not a space/tab/pipe. The first `]` must * pair into `]]`. Registered before `Link` and claims the whole * element eagerly, so the target is atomic: no nested markdown, no * tags. @@ -39,7 +40,9 @@ export const wikilink: MarkdownConfig = { ) } if (code === CHAR_LEFT_SQUARE_BRACKET || code === CHAR_LINE_FEED) return -1 - if (code !== CHAR_SPACE && code !== CHAR_TAB) hasContent = true + if (code !== CHAR_SPACE && code !== CHAR_TAB && code !== CHAR_VERTICAL_LINE) { + hasContent = true + } } return -1 }, diff --git a/packages/core/src/style.css b/packages/core/src/style.css index c8158328..cbced491 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -735,6 +735,17 @@ letter-spacing: 0; opacity: 0; } + + .md-atom-view:not(:has(.md-atom-view-preview)) > .md-atom-view-content { + font-size: inherit; + letter-spacing: inherit; + opacity: 1; + color: var(--meowdown-mark); + } + + .md-atom-view:not(:has(.md-atom-view-preview)) .md-atom-selected { + background: var(--meowdown-node-selection); + } } /* inline math */ @@ -808,10 +819,10 @@ min-height: 2.5rem; } - /* While the image is still loading (e.g. a freshly dropped image), fill the - * box with a placeholder so it reads as an image, not an empty gap. Cleared - * once the image paints (`data-loading` is removed on load/error). */ - .md-image-resizable[data-loading] { + /* While the image is still loading or failed, fill the box with a placeholder + * so it reads as an image, not an empty gap. */ + .md-image-resizable[data-loading], + .md-image-resizable[data-error] { background: var(--meowdown-image-loading-bg); border-radius: var(--meowdown-image-radius, 0.5rem); } diff --git a/packages/react/src/components/link-menu.test.tsx b/packages/react/src/components/link-menu.test.tsx index 263d9170..398ec7e2 100644 --- a/packages/react/src/components/link-menu.test.tsx +++ b/packages/react/src/components/link-menu.test.tsx @@ -53,6 +53,15 @@ describe('LinkMenu', () => { expect(Math.abs(popCenter - linkCenter)).toBeLessThan(20) }) + it('opens the preview for an empty-destination link in hide mode', async () => { + const screen = await render() + await screen.getByText('Docs').hover() + await expect.element(popover.getByTestId('link-popover-read')).toBeVisible() + await popover.getByRole('button', { name: 'Edit link' }).click() + await expect.element(popover.getByTestId('link-popover-edit')).toBeVisible() + await expect.element(popover.getByTestId('link-popover-input')).toHaveValue('') + }) + it('creates a link from a selection with Mod-k', async () => { const ref = createRef() const screen = await render()