diff --git a/packages/core/src/extensions/image.test.ts b/packages/core/src/extensions/image.test.ts index 8457cca3..244f9880 100644 --- a/packages/core/src/extensions/image.test.ts +++ b/packages/core/src/extensions/image.test.ts @@ -379,6 +379,47 @@ describe('image resize', () => { await expect.element(resizable).toHaveAttribute('data-width', '320') }) + it('applies a persisted width to a linked image', async () => { + using fixture = setupResize('[![cat](u)](/target)') + void fixture + await expect.element(resizable).toHaveAttribute('data-width', '200') + }) + + it('writes the size comment inside the link label when a linked image is resized', async () => { + using fixture = setupResize('[![cat](u)](/target)') + await expect.element(resizable).toBeInTheDocument() + endResize(320) + await vi.waitFor(() => { + expect(fixture.doc.textContent).toBe( + '[![cat](u)](/target)', + ) + }) + await expect.element(resizable).toHaveAttribute('data-width', '320') + }) + + it('replaces the size comment of a linked image when resized again', async () => { + using fixture = setupResize('[![cat](u)](/target)') + await expect.element(resizable).toHaveAttribute('data-width', '100') + endResize(320) + await vi.waitFor(() => { + expect(fixture.doc.textContent).toBe( + '[![cat](u)](/target)', + ) + }) + }) + + // Stacked comments are what a formerly buggy rewrite left behind: each + // resize inserted a fresh comment before the unfolded old one. The first + // comment's size applies, and one resize collapses the run. + it('collapses a stacked run of size comments when resized', async () => { + using fixture = setupResize('![cat](u)') + await expect.element(resizable).toHaveAttribute('data-width', '300') + endResize(320) + await vi.waitFor(() => { + expect(fixture.doc.textContent).toBe('![cat](u)') + }) + }) + it('keeps the same preview DOM when resized', async () => { using fixture = setupResize('![cat](u)') void fixture 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 1b042b53..48b966c4 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 @@ -671,6 +671,48 @@ describe('image', () => { " `) }) + + it('folds a trailing width comment on an image inside a link label', () => { + expect(parse('[![a](u)](/target)')).toMatchInlineSnapshot(` + " + [0, 1] mdPack(key=link,data={"form":"inline","href":"/target","title":""},revealInFocus=true) + mdLinkText(href=/target) + mdMark + [1, 30] mdPack(key=link,data={"form":"inline","href":"/target","title":""},revealInFocus=true) + mdLinkText(href=/target) + mdPack(key=image) + mdImage(src=u,alt=a,width=320) + [30, 32] mdPack(key=link,data={"form":"inline","href":"/target","title":""},revealInFocus=true) + mdMark + [32, 39] mdPack(key=link,data={"form":"inline","href":"/target","title":""},revealInFocus=true) + mdLinkUri + [39, 40] mdPack(key=link,data={"form":"inline","href":"/target","title":""},revealInFocus=true) + mdMark + " + `) + }) + + it('folds a stacked run of size comments, first data winning', () => { + expect(parse('![a](u)')).toMatchInlineSnapshot(` + " + [0, 51] mdPack(key=image) + mdImage(src=u,alt=a,width=100) + " + `) + }) + + it('folds a stacked run of size comments inside a link label', () => { + expect(parse('[![a](u)](/target)')) + .toMatchInlineSnapshot(` + " + [0, 1] mdPack(key=link,data={"form":"inline","href":"/target","title":""},revealInFocus=true) + mdLinkText(href=/target) + mdMark + [1, 52] mdPack(key=link,data={"form":"inline","href":"/target","title":""},revealInFocus=true) + mdLinkText(href=/target) + mdPack(key=image) + mdImage(src=u,alt=a,width=100) + [52, 54] mdPack(key=link,data={"form":"inline","href":"/target","title":""},revealInFocus=true) + mdMark + [54, 61] mdPack(key=link,data={"form":"inline","href":"/target","title":""},revealInFocus=true) + mdLinkUri + [61, 62] mdPack(key=link,data={"form":"inline","href":"/target","title":""},revealInFocus=true) + mdMark + " + `) + }) + + it('stops the fold at a non-metadata comment', () => { + expect(parse('![a](u)')).toMatchInlineSnapshot(` + " + [0, 29] mdPack(key=image) + mdImage(src=u,alt=a,width=100) + [29, 42] + " + `) + }) }) describe('wiki embed', () => { 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 c742e159..83c13e77 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.ts @@ -202,16 +202,15 @@ function walk( let pos = rangeStart for (let index = 0; index < nodes.length; index++) { const node = nodes[index] + // A previous child may have consumed this one (e.g. an image folding its + // trailing magic comments), so anything fully behind `pos` is done. + if (node.to <= pos) continue if (node.from > pos) { emit(out, pos, node.from, parentMarks) } - // An image may fold the next sibling into its own range, so it is the one - // node type the loop resolves itself. - if (node.type === LEZER_NODE_IDS.Image) { - const trailing = takeMagicComment(node, nodes[index + 1], text) - walkImage(node, parentMarks, text, marks, out, options, context, trailing) - if (trailing) index++ // skip the folded comment - pos = trailing ? trailing.to : node.to + const atomEnd = walkAtomChild(nodes, index, parentMarks, text, marks, out, options, context) + if (atomEnd != null) { + pos = atomEnd continue } walkNode(node, parentMarks, text, marks, out, options, context) @@ -234,10 +233,6 @@ function walkNode( switch (node.type) { case LEZER_NODE_IDS.Link: return walkLink(node, parentMarks, text, marks, out, options, context) - case LEZER_NODE_IDS.Wikilink: - return walkWikilink(node, parentMarks, text, marks, out) - case LEZER_NODE_IDS.WikiEmbed: - return walkWikiEmbed(node, parentMarks, text, marks, out, options) case LEZER_NODE_IDS.InlineMath: return walkMath(node, parentMarks, text, marks, out) case LEZER_NODE_IDS.Autolink: @@ -249,6 +244,42 @@ function walkNode( } } +/** + * Walk `nodes[index]` when it is a source-backed atom (wikilink, wiki embed, + * or image); returns the source position after everything the atom consumed, + * or undefined for any other node type. An image also consumes the magic + * comments chained behind it, so callers must skip children ending at or + * before the returned position. Shared by `walk` and `walkResolvedLink` so an + * atom behaves the same at the top level and inside a link label. + */ +function walkAtomChild( + nodes: readonly InlineElement[], + index: number, + parentMarks: readonly Mark[], + text: string, + marks: TypedMarkBuilders, + out: MarkChunk[], + options: InlineMarkOptions | undefined, + context: InlineMarkContext | undefined, +): number | undefined { + const node = nodes[index] + switch (node.type) { + case LEZER_NODE_IDS.Wikilink: + walkWikilink(node, parentMarks, text, marks, out) + return node.to + case LEZER_NODE_IDS.WikiEmbed: + walkWikiEmbed(node, parentMarks, text, marks, out, options) + return node.to + case LEZER_NODE_IDS.Image: { + const trailing = takeMagicComments(nodes, index, text) + walkImage(node, parentMarks, text, marks, out, options, context, trailing) + return trailing ? trailing.to : node.to + } + default: + return undefined + } +} + /** * A node with no source-backed atom of its own: it contributes its `mdPack` and * syntax marks, then recurses into its children. @@ -564,27 +595,28 @@ function walkResolvedLink( const base = [...parentMarks, pack] let pos = node.from - for (const child of node.children) { + for (let index = 0; index < node.children.length; index++) { + const child = node.children[index] + // A previous child may have consumed this one (e.g. an image folding its + // trailing magic comments), so anything fully behind `pos` is done. + if (child.to <= pos) continue if (child.from > pos) { const childMarks = inLabel(pos) ? [...base, linkTextMark] : base emit(out, 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) - pos = child.to - continue - } - if (child.type === LEZER_NODE_IDS.WikiEmbed) { - walkWikiEmbed(child, baseForChild, text, marks, out, options) - pos = child.to - continue - } - if (child.type === LEZER_NODE_IDS.Image) { - walkImage(child, baseForChild, text, marks, out, options, context) - pos = child.to + const atomEnd = walkAtomChild( + node.children, + index, + baseForChild, + text, + marks, + out, + options, + context, + ) + if (atomEnd != null) { + pos = atomEnd continue } if (isReference && child.type === LEZER_NODE_IDS.LinkLabel) { @@ -617,27 +649,40 @@ function walkResolvedLink( } } -interface AdjacentMagicComment { +interface FoldedMagicComments { magic: MagicComment to: number } -// A magic comment sitting immediately after `image`, or undefined. -function takeMagicComment( - image: InlineElement, - next: InlineElement | undefined, +/** + * The run of magic comments chained immediately behind `nodes[index]` (an + * image), or undefined when no magic comment directly abuts it. The first + * comment's data wins: a rewrite of an unfolded image inserted the fresh + * comment right at the image's end, so in a stacked run left is newest. + */ +function takeMagicComments( + nodes: readonly InlineElement[], + index: number, text: string, -): AdjacentMagicComment | undefined { - if (!next || next.type !== LEZER_NODE_IDS.Comment || next.from !== image.to) return undefined - const magic = parseMagicComment(text.slice(next.from, next.to)) +): FoldedMagicComments | undefined { + let magic: MagicComment | undefined + let to = nodes[index].to + for (let i = index + 1; i < nodes.length; i++) { + const next = nodes[i] + if (next.type !== LEZER_NODE_IDS.Comment || next.from !== to) break + const parsed = parseMagicComment(text.slice(next.from, next.to)) + if (!parsed) break + magic ??= parsed + to = next.to + } if (!magic) return undefined - return { magic, to: next.to } + return { magic, to } } /** * Special walker for a direct image `![alt](url)`. * - * A `trailing` magic comment immediately after the image (e.g. + * A `trailing` run of magic comments immediately after the image (e.g. * ``) is folded into the mark range so it round-trips as * source while supplying the image's `width`. */ @@ -649,7 +694,7 @@ function walkImage( out: MarkChunk[], options: InlineMarkOptions | undefined, context: InlineMarkContext | undefined, - trailing?: AdjacentMagicComment, + trailing?: FoldedMagicComments, ): void { const parts = scanLinkParts(node) const resolution = resolveLink(parts, text, context) diff --git a/packages/core/src/extensions/magic-comment.test.ts b/packages/core/src/extensions/magic-comment.test.ts index 7f5c5e8a..d4338403 100644 --- a/packages/core/src/extensions/magic-comment.test.ts +++ b/packages/core/src/extensions/magic-comment.test.ts @@ -40,4 +40,10 @@ describe('formatMagicComment / stripMagicComment', () => { expect(stripMagicComment('![a](u)')).toBe('![a](u)') expect(stripMagicComment('![a](u)')).toBe('![a](u)') }) + + it('strips a whole stacked run of trailing comments', () => { + expect(stripMagicComment('![a](u)')).toBe( + '![a](u)', + ) + }) }) diff --git a/packages/core/src/extensions/magic-comment.ts b/packages/core/src/extensions/magic-comment.ts index a7d0b067..33513e9a 100644 --- a/packages/core/src/extensions/magic-comment.ts +++ b/packages/core/src/extensions/magic-comment.ts @@ -16,8 +16,10 @@ export interface MagicComment { // A whole inline comment carrying a JSON object: ``. const MAGIC_COMMENT_RE = /^$/ -// Same, anchored to the end of a string, for stripping a trailing comment. -const TRAILING_MAGIC_COMMENT_RE = /$/ +// Same, anchored to the end of a string, for stripping the whole trailing +// run: a rewrite of an image whose comment had not folded could stack a +// second comment behind the first. +const TRAILING_MAGIC_COMMENT_RE = /(?:)+$/ /** * Read the metadata out of a `` comment, or `undefined` when the @@ -58,7 +60,7 @@ export function formatMagicComment(magic: MagicComment): string { } /** - * Drop a trailing magic comment from the source text. + * Drop the trailing run of magic comments from the source text. */ export function stripMagicComment(source: string): string { return source.replace(TRAILING_MAGIC_COMMENT_RE, '') diff --git a/packages/markdown/src/inline.test.ts b/packages/markdown/src/inline.test.ts index 20cd674a..10b54c59 100644 --- a/packages/markdown/src/inline.test.ts +++ b/packages/markdown/src/inline.test.ts @@ -85,7 +85,7 @@ describe('link', () => { // signature in the tree: only two LinkMark children (`[` and `]`); a // reference label, when present, is a single LinkLabel node, never more // LinkMarks. Inline links carry at least three LinkMarks (`[`, `]`, `(`). - // `hasInlineDestination` in `inline-text-to-mark-chunks.ts` relies on this. + // `resolveLink` in `inline-text-to-mark-chunks.ts` relies on this. it('parses a shortcut reference link with two LinkMarks', () => { expect(parse('[foo]')).toMatchInlineSnapshot(` @@ -227,6 +227,26 @@ describe('image', () => { " `) }) + + it('parses a comment after a linked image as a direct child of the Link', () => { + expect(parse('[![a](u)](/target)')).toMatchInlineSnapshot(` + " + Link [0, 40] "[![a](u)](/target)" + LinkMark [0, 1] "[" + Image [1, 8] "![a](u)" + LinkMark [1, 3] "![" + LinkMark [4, 5] "]" + LinkMark [5, 6] "(" + URL [6, 7] "u" + LinkMark [7, 8] ")" + Comment [8, 30] "" + LinkMark [30, 31] "]" + LinkMark [31, 32] "(" + URL [32, 39] "/target" + LinkMark [39, 40] ")" + " + `) + }) }) describe('highlight', () => { diff --git a/packages/react/src/components/markdown-view.test.tsx b/packages/react/src/components/markdown-view.test.tsx index efd3bf97..c881be09 100644 --- a/packages/react/src/components/markdown-view.test.tsx +++ b/packages/react/src/components/markdown-view.test.tsx @@ -54,6 +54,12 @@ describe('MarkdownView', () => { await expect.element(img).toHaveAttribute('alt', 'cat') }) + it('applies a size comment to an image inside a link', async () => { + await renderView('[![cat](https://example.com/cat.png)](/target)') + const img = view.getByTestId('image-preview').locate('img') + await expect.element(img).toHaveStyle({ width: '320px' }) + }) + it('renders a resolved wiki image with its alias and width', async () => { await renderView('![[assets/cat.png|120]]', { resolveWikiEmbed: () => ({ kind: 'image' }),