From d158f05df3fd91a9463d76ad7d37bdf1ba3d0f6d Mon Sep 17 00:00:00 2001
From: ocavue
Date: Sat, 27 Jun 2026 20:28:19 +1000
Subject: [PATCH 1/2] refactor: anchor mdImageView on the image URL run
---
.../src/extensions/image-insert-caret.test.ts | 16 ++++++++--------
packages/core/src/extensions/image.ts | 8 ++++----
packages/core/src/extensions/inline-marks.ts | 6 +++---
.../inline-text-to-mark-chunks.test.ts | 16 ++++++++--------
.../extensions/inline-text-to-mark-chunks.ts | 19 ++++++++-----------
.../core/src/extensions/mark-mode.test.ts | 16 +++++++++-------
packages/core/src/style.css | 14 +++++++++++++-
7 files changed, 53 insertions(+), 42 deletions(-)
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 `` 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
+// `` 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', 'AB')
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('')
- // 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 `` 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 ``.
+ * 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 ``.
*/
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 ``. 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 `` (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('')).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 ``.
*
* 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'] {
From 2933102bbf1e1a34d331b54fc948f96f8560b9e8 Mon Sep 17 00:00:00 2001
From: ocavue
Date: Sat, 27 Jun 2026 20:29:52 +1000
Subject: [PATCH 2/2] chore: trigger ci