diff --git a/packages/core/src/extensions/image-insert-caret.test.ts b/packages/core/src/extensions/image-insert-caret.test.ts index 0706832a..fd1b0142 100644 --- a/packages/core/src/extensions/image-insert-caret.test.ts +++ b/packages/core/src/extensions/image-insert-caret.test.ts @@ -52,10 +52,11 @@ describe.each(['hide', 'focus'] as MarkMode[])( }, ) -// Regression: the image is NOT reordered (its contentDOM must stay before the -// preview so show mode reads `![alt](url)` then the preview). Confirm show mode -// keeps both the raw source and the preview, in that order. -describe('image show mode keeps source then preview', () => { +// The image mark view wraps the URL run, so its contentDOM (the URL) sits before +// the preview: in show mode the preview paints right after the URL, with the raw +// `![img](url)` still fully visible around it. Confirm show mode keeps both the +// raw source and the preview. +describe('image show mode paints the preview after the URL', () => { it('shows the raw source and the preview together', async () => { using fixture = setup('show', 'A![img](url)B') await expect.element(preview).toBeVisible() @@ -63,14 +64,13 @@ describe('image show mode keeps source then preview', () => { // The raw markdown stays visible (not collapsed) in show mode. expect(fixture.dom.querySelector('p')?.innerText).toContain('![img](url)') - // The preview is painted after the source text, not inside or before it. + // The preview is painted next to the URL inside the still-visible source. const previewRect = (preview.element() as HTMLElement).getBoundingClientRect() const range = document.createRange() const paragraph = fixture.dom.querySelector('p')! range.selectNodeContents(paragraph) - // The trailing `B` is the last visible glyph; the preview sits before it but - // after the `![img](url)` text. A coarse check: the preview has real width and - // is within the paragraph's horizontal box. + // A coarse check: the preview has real width and is within the paragraph's + // horizontal box. const paragraphRect = paragraph.getBoundingClientRect() expect(previewRect.width).toBeGreaterThan(0) expect(previewRect.left).toBeGreaterThanOrEqual(paragraphRect.left - 1) diff --git a/packages/core/src/extensions/image.ts b/packages/core/src/extensions/image.ts index dd2bf762..d7e8897c 100644 --- a/packages/core/src/extensions/image.ts +++ b/packages/core/src/extensions/image.ts @@ -77,10 +77,10 @@ function renderImagePreview( } /** - * Render `mdImageView` (anchored on the image's final character) as the inline - * image: the anchor char stays editable inside `contentDOM`, and the preview is - * a non-editable sibling. Mark-mode hides the surrounding `mdImageSource`, so - * what remains visible is the preview, in place of the raw `![alt](url)`. + * Render `mdImageView` (anchored on the image's URL run) as the inline image: + * the URL stays editable inside `contentDOM`, and the preview is a non-editable + * sibling painted right after it. Mark-mode hides the surrounding `mdImageSource`, + * so what remains visible is the preview, in place of the raw `![alt](url)`. */ function createImageMarkView(options: ImageOptions): MarkViewConstructor { return (mark) => { diff --git a/packages/core/src/extensions/inline-marks.ts b/packages/core/src/extensions/inline-marks.ts index 8efb90b9..7b4ae5b8 100644 --- a/packages/core/src/extensions/inline-marks.ts +++ b/packages/core/src/extensions/inline-marks.ts @@ -3,9 +3,9 @@ import { defineMarkSpec, union } from '@prosekit/core' import type { MarkName } from './mark-names.ts' /** - * Anchors an inline image preview on the final character of `![alt](url)`. A - * mark view (see `defineImage`) renders the image; without it the anchor char - * just renders as text. Carries the parsed `src`/`alt`. + * Anchors an inline image preview on the URL run of `![alt](url)` (the same + * range as `mdLinkUri`). A mark view (see `defineImage`) renders the image next + * to the URL; without it the URL just renders as text. Carries the parsed `src`/`alt`. */ function defineMdImageView() { return defineMarkSpec<'mdImageView', MdImageViewAttrs>({ 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 ce038f24..8541b4c2 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 @@ -280,8 +280,8 @@ describe('image', () => { [4, 6] mdPack(key=image,data={"src":"http://x/p.png"}) + mdImageSource(src=http://x/p.png,alt=alt) + mdMark [6, 9] mdPack(key=image,data={"src":"http://x/p.png"}) + mdImageSource(src=http://x/p.png,alt=alt) [9, 11] mdPack(key=image,data={"src":"http://x/p.png"}) + mdImageSource(src=http://x/p.png,alt=alt) + mdMark - [11, 25] mdPack(key=image,data={"src":"http://x/p.png"}) + mdImageSource(src=http://x/p.png,alt=alt) + mdLinkUri - [25, 26] mdPack(key=image,data={"src":"http://x/p.png"}) + mdImageSource(src=http://x/p.png,alt=alt) + mdImageView(src=http://x/p.png,alt=alt) + mdMark + [11, 25] mdPack(key=image,data={"src":"http://x/p.png"}) + mdImageSource(src=http://x/p.png,alt=alt) + mdImageView(src=http://x/p.png,alt=alt) + mdLinkUri + [25, 26] mdPack(key=image,data={"src":"http://x/p.png"}) + mdImageSource(src=http://x/p.png,alt=alt) + mdMark [26, 30] " `) @@ -291,8 +291,8 @@ describe('image', () => { expect(parse('![](z.png)')).toMatchInlineSnapshot(` " [0, 4] mdPack(key=image,data={"src":"z.png"}) + mdImageSource(src=z.png) + mdMark - [4, 9] mdPack(key=image,data={"src":"z.png"}) + mdImageSource(src=z.png) + mdLinkUri - [9, 10] mdPack(key=image,data={"src":"z.png"}) + mdImageSource(src=z.png) + mdImageView(src=z.png) + mdMark + [4, 9] mdPack(key=image,data={"src":"z.png"}) + mdImageSource(src=z.png) + mdImageView(src=z.png) + mdLinkUri + [9, 10] mdPack(key=image,data={"src":"z.png"}) + mdImageSource(src=z.png) + mdMark " `) }) @@ -303,10 +303,10 @@ describe('image', () => { [0, 2] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a) + mdMark [2, 3] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a) [3, 5] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a) + mdMark - [5, 13] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a) + mdLinkUri + [5, 13] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a) + mdImageView(src=http://x,alt=a) + mdLinkUri [13, 14] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a) [14, 17] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a) + mdLinkTitle - [17, 18] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a) + mdImageView(src=http://x,alt=a) + mdMark + [17, 18] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a) + mdMark " `) }) @@ -321,8 +321,8 @@ describe('image', () => { [7, 9] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a **b** c) + mdStrong + mdMark [9, 11] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a **b** c) [11, 13] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a **b** c) + mdMark - [13, 21] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a **b** c) + mdLinkUri - [21, 22] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a **b** c) + mdImageView(src=http://x,alt=a **b** c) + mdMark + [13, 21] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a **b** c) + mdImageView(src=http://x,alt=a **b** c) + mdLinkUri + [21, 22] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a **b** c) + mdMark " `) }) 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 61d9839a..ac7dacf7 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.ts @@ -233,10 +233,9 @@ function walkLink( * Special walker for a direct image `![alt](url)`. * * Emits `mdImageSource` across the whole node (the mark `defineMarkMode` hides) - * and `mdImageView({ src, alt })` on the node's final character, which is the - * anchor a mark view renders the inline image on. The final character is `)` - * today and would be `]` for a future reference image `![alt][id]`, so the - * anchor is `node.to - 1`, never a hardcoded `)`. `mdMark`/`mdLinkUri` style the + * and `mdImageView({ src, alt })` over the `URL` run, the anchor a mark view + * renders the inline image on. The view wraps the same range as `mdLinkUri`, so + * the preview paints next to the URL it came from. `mdMark`/`mdLinkUri` style the * source for show mode; the alt carries no `mdLinkText` (it is not a link), but * inline emphasis inside it is still highlighted like any other syntax. */ @@ -262,15 +261,13 @@ function walkImage( const view = marks.mdImageView.create({ src, alt } satisfies MdImageViewAttrs) const pack = marks.mdPack.create({ key: `image`, data: { src } } satisfies MdPackAttrs) - // The image's final character, where `mdImageView` is anchored: `)` today, a - // future `]` for `![alt][id]`. - const anchorFrom = node.to - 1 - // Marks shared by every chunk at `from`: the `mdPack` envelope plus - // `mdImageSource` over the whole source, plus `mdImageView` once we reach the - // final character (the render anchor). Each child layers its own syntax mark on top. + // `mdImageSource` over the whole source, plus `mdImageView` while inside the + // `URL` run (the render anchor, coincident with `mdLinkUri`). Each child layers + // its own syntax mark on top. + const inUrl = (from: number): boolean => from >= urlNode.from && from < urlNode.to const baseAt = (from: number): Mark[] => - from >= anchorFrom ? [...parentMarks, pack, source, view] : [...parentMarks, pack, source] + inUrl(from) ? [...parentMarks, pack, source, view] : [...parentMarks, pack, source] let pos = node.from for (const child of node.children) { diff --git a/packages/core/src/extensions/mark-mode.test.ts b/packages/core/src/extensions/mark-mode.test.ts index 97bbffbf..25d18b10 100644 --- a/packages/core/src/extensions/mark-mode.test.ts +++ b/packages/core/src/extensions/mark-mode.test.ts @@ -728,18 +728,13 @@ describe('focus mode', () => { ]( - - - pic.png - - - + - ) + pic.png @@ -756,6 +751,13 @@ describe('focus mode', () => { > + + + + ) + + +

" diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 0ab0df48..f5a0c6df 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -492,10 +492,22 @@ .md-mark, .md-link-uri, .md-link-title, - .md-image-source, .md-wikilink-source { display: none; } + + /* Keep the image source and its inner syntax chars in layout as zero-width, + * clipped boxes (their text is clipped to nothing, so only the preview shows) + * so a caret can stop on a source char that follows the preview, e.g. the + * trailing `)` or title now that `mdImageView` covers only the URL run. */ + .md-image-source, + .md-image-source :is(.md-mark, .md-link-uri, .md-link-title) { + display: inline-flex; + width: 0px; + max-width: 0px; + min-width: 0px; + overflow: hidden; + } } .ProseMirror[data-mark-mode='focus'] {