Skip to content
Merged
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
41 changes: 41 additions & 0 deletions packages/core/src/extensions/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)<!-- {"width":200} -->](/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)<!-- {"width":320,"height":100} -->](/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)<!-- {"width":100} -->](/target)')
await expect.element(resizable).toHaveAttribute('data-width', '100')
endResize(320)
await vi.waitFor(() => {
expect(fixture.doc.textContent).toBe(
'[![cat](u)<!-- {"width":320,"height":100} -->](/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)<!-- {"width":300} --><!-- {"width":100} -->')
await expect.element(resizable).toHaveAttribute('data-width', '300')
endResize(320)
await vi.waitFor(() => {
expect(fixture.doc.textContent).toBe('![cat](u)<!-- {"width":320,"height":100} -->')
})
})

it('keeps the same preview DOM when resized', async () => {
using fixture = setupResize('![cat](u)<!-- {"width":100} -->')
void fixture
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/extensions/inline-text-to-mark-chunks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,48 @@ describe('image', () => {
"
`)
})

it('folds a trailing width comment on an image inside a link label', () => {
expect(parse('[![a](u)<!-- {"width":320} -->](/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)<!-- {"width":100} --><!-- {"width":320} -->')).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)<!-- {"width":100} --><!-- {"width":320} -->](/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)<!-- {"width":100} --><!-- note -->')).toMatchInlineSnapshot(`
"
[0, 29] mdPack(key=image) + mdImage(src=u,alt=a,width=100)
[29, 42]
"
`)
})
})

describe('wiki embed', () => {
Expand Down
121 changes: 83 additions & 38 deletions packages/core/src/extensions/inline-text-to-mark-chunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
* `<!-- {"width":320} -->`) is folded into the mark range so it round-trips as
* source while supplying the image's `width`.
*/
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/extensions/magic-comment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,10 @@ describe('formatMagicComment / stripMagicComment', () => {
expect(stripMagicComment('![a](u)<!-- {"width":320} -->')).toBe('![a](u)')
expect(stripMagicComment('![a](u)')).toBe('![a](u)')
})

it('strips a whole stacked run of trailing comments', () => {
expect(stripMagicComment('![a](u)<!-- {"width":320} --><!-- {"height":240} -->')).toBe(
'![a](u)',
)
})
})
8 changes: 5 additions & 3 deletions packages/core/src/extensions/magic-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ export interface MagicComment {

// A whole inline comment carrying a JSON object: `<!-- {...} -->`.
const MAGIC_COMMENT_RE = /^<!--\s*(\{[^}]*\})\s*-->$/
// Same, anchored to the end of a string, for stripping a trailing comment.
const TRAILING_MAGIC_COMMENT_RE = /<!--\s*\{[^}]*\}\s*-->$/
// 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 = /(?:<!--\s*\{[^}]*\}\s*-->)+$/

/**
* Read the metadata out of a `<!-- {...} -->` comment, or `undefined` when the
Expand Down Expand Up @@ -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, '')
Expand Down
22 changes: 21 additions & 1 deletion packages/markdown/src/inline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand Down Expand Up @@ -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)<!-- {"width":100} -->](/target)')).toMatchInlineSnapshot(`
"
Link [0, 40] "[![a](u)<!-- {\\"width\\":100} -->](/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] "<!-- {\\"width\\":100} -->"
LinkMark [30, 31] "]"
LinkMark [31, 32] "("
URL [32, 39] "/target"
LinkMark [39, 40] ")"
"
`)
})
})

describe('highlight', () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/components/markdown-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)<!-- {"width":320} -->](/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' }),
Expand Down
Loading