Skip to content
Draft
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
55 changes: 42 additions & 13 deletions packages/core/src/extensions/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import type { EditorView, MarkViewConstructor } from '@prosekit/pm/view'

import { listenForTweetHeight, matchEmbed, type EmbedDescriptor } from './embed/index.ts'
import type { MdImageViewAttrs } from './inline-marks.ts'
import type { MdImageV2Attrs, MdImageViewAttrs } from './inline-marks.ts'

Check failure on line 13 in packages/core/src/extensions/image.ts

View workflow job for this annotation

GitHub Actions / lint

'MdImageViewAttrs' is declared but never used.
import type { MarkName } from './mark-names.ts'

type ImageUrlResolver = (src: string) => string | undefined
Expand Down Expand Up @@ -54,6 +54,7 @@
function renderImagePreview(
src: string,
alt: string,
// TODO: add a title
options: ImageOptions,
): HTMLElement | undefined {
const embed = matchEmbed(src)
Expand All @@ -63,45 +64,73 @@
wrapper.appendChild(buildEmbedIframe(embed))
return wrapper
}

const url = (options.resolveImageUrl ?? defaultResolveImageUrl)(src)
if (!url) return undefined

const wrapper = document.createElement('span')
wrapper.className = 'md-image-preview md-image-preview-img'
wrapper.dataset.testid = 'image-preview'
// wrapper.className = 'md-image-preview md-image-preview-img'
// wrapper.dataset.testid = 'image-preview'
const img = document.createElement('img')
img.src = url
img.alt = alt
img.draggable = false
wrapper.appendChild(img)

return wrapper
}

/**
* 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` (wrapped around the image URI) as the inline image: the
* URI 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)`.
*/
function createImageMarkView(options: ImageOptions): MarkViewConstructor {
return (mark) => {
const attrs = mark.attrs as MdImageViewAttrs
const attrs = mark.attrs as MdImageV2Attrs
const { src, alt } = attrs

const span1 = document.createElement('span')
const span2 = document.createElement('span')

span1.style.width = '20px'
span1.style.height = '20px'
span1.style.display = 'inline-block'
span1.style.margin = '5px'
span1.contentEditable = 'false'

span2.style.width = '20px'
span2.style.height = '20px'
span2.style.display = 'inline-block'
span2.style.margin = '5px'

span1.style.backgroundColor = 'pink'
span2.style.backgroundColor = 'lightblue'

const dom = document.createElement('span')
dom.className = 'md-image-view'
dom.append(span1)
dom.className = 'md-image-view-v2'
const contentDOM = document.createElement('span')
contentDOM.className = 'md-image-view-content'
contentDOM.className = 'md-image-view-content-v2'
contentDOM.contentEditable = 'true'

// dom.append(document.createElement('span'))
dom.appendChild(contentDOM)

const preview = renderImagePreview(attrs.src, attrs.alt, options)
const preview = renderImagePreview(src, alt, options)
if (preview) {
preview.contentEditable = 'false'
dom.appendChild(preview)
}
dom.append(span2)

return {
dom,
contentDOM,
ignoreMutation: (mutation) => !contentDOM.contains(mutation.target),
ignoreMutation: (mutation) => {
return !contentDOM.contains(mutation.target)
},
}
}
}
Expand Down Expand Up @@ -170,7 +199,7 @@
export function defineImage(options: ImageOptions = {}): PlainExtension {
return union(
defineMarkView({
name: 'mdImageView' satisfies MarkName,
name: 'mdImageV2' satisfies MarkName,
constructor: createImageMarkView(options),
}),
// High priority so the drop/paste handler runs before ProseKit's
Expand Down
37 changes: 32 additions & 5 deletions packages/core/src/extensions/inline-marks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ 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`.
* Wraps the parsed URI of `![alt](url)`. A mark view (see `defineImage`) renders
* the image; without it the URI renders as text. Carries the parsed `src`/`alt`.
*/
function defineMdImageView() {
return defineMarkSpec<'mdImageView', MdImageViewAttrs>({
Expand All @@ -22,6 +21,22 @@ export interface MdImageViewAttrs {
alt: string
}

export interface MdImageV2Attrs {
src: string
alt: string
title: string
}

function defineMdImageV2() {
return defineMarkSpec<'mdImageV2', MdImageV2Attrs>({
name: 'mdImageV2' satisfies MarkName,
inclusive: false,
attrs: { src: { default: '' }, alt: { default: '' }, title: { default: '' } },
toDOM: () => ['span', { class: 'md-image-v2' }, 0],
parseDOM: [{ tag: 'span.md-image-v2' }],
})
}

/**
* Covers the whole `![alt](url)` source. This is the mark `defineMarkMode`
* hides in hide/focus mode so the rendered image replaces the raw syntax. The
Expand All @@ -34,8 +49,8 @@ function defineMdImageSource() {
name: 'mdImageSource' satisfies MarkName,
inclusive: false,
attrs: { src: { default: '' }, alt: { default: '' } },
toDOM: () => ['span', { class: 'md-image-source' }, 0],
parseDOM: [{ tag: 'span.md-image-source' }],
toDOM: () => ['span', { class: 'md-image-source-v2' }, 0],
parseDOM: [{ tag: 'span.md-image-source-v2' }],
})
}

Expand Down Expand Up @@ -236,12 +251,23 @@ function defineMdPack() {
})
}

function defineMdHide() {
return defineMarkSpec({
name: 'mdHide' satisfies MarkName,
inclusive: false,
toDOM: () => ['span', { class: 'md-hide' }, 0],
parseDOM: [{ tag: 'span.md-hide' }],
})
}

export function defineInlineMarks() {
// The last mark registered gets the lowest rank and becomes the outermost DOM
// wrapper, so the wikilink/image marks go last: the view mark (mdWikilinkView /
// mdImageView) wraps the source, which wraps the syntax marks. The pack mark
// goes last of all, so it wraps the whole unit (including a mark view).
return union(
defineMdHide(),

defineMdMark(),
defineMdEm(),
defineMdStrong(),
Expand All @@ -257,6 +283,7 @@ export function defineInlineMarks() {
defineMdWikilinkView(),
defineMdImageSource(),
defineMdImageView(),
defineMdImageV2(),
defineMdPack(),
)
}
50 changes: 19 additions & 31 deletions packages/core/src/extensions/inline-text-to-mark-chunks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,55 +274,43 @@ describe('link', () => {

describe('image', () => {
it('image', () => {
expect(parse('see ![alt](http://x/p.png) end')).toMatchInlineSnapshot(`
expect(parse('![ALT](URL "TITLE")')).toMatchInlineSnapshot(`
"
[0, 4]
[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
[26, 30]
[0, 19] mdImageV2(src=URL,alt=ALT,title=TITLE) + mdImageSource(src=URL,alt=ALT)
"
`)
})

it('empty alt', () => {
expect(parse('![](z.png)')).toMatchInlineSnapshot(`
it('only URL', () => {
expect(parse('![](image.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
[0, 14] mdImageV2(src=image.png) + mdImageSource(src=image.png)
"
`)
})

it('title', () => {
expect(parse('![a](http://x "t")')).toMatchInlineSnapshot(`
it('empty title', () => {
expect(parse('![a](http://x "")')).toMatchInlineSnapshot(`
"
[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
[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
[0, 17] mdImageV2(src=http://x,alt=a) + mdImageSource(src=http://x,alt=a)
"
`)
})

it('formatted alt', () => {
expect(parse('![a **b** c](http://x)')).toMatchInlineSnapshot(`
"
[0, 2] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a **b** c) + mdMark
[2, 4] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a **b** c)
[4, 6] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a **b** c) + mdStrong + mdMark
[6, 7] mdPack(key=image,data={"src":"http://x"}) + mdImageSource(src=http://x,alt=a **b** c) + mdStrong
[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
[0, 22] mdImageV2(src=http://x,alt=a **b** c) + mdImageSource(src=http://x,alt=a **b** c)
"
`)
})

it('wrapped by text', () => {
expect(parse('text ![a](url) text')).toMatchInlineSnapshot(`
"
[0, 5]
[5, 14] mdImageV2(src=url,alt=a) + mdImageSource(src=url,alt=a)
[14, 19]
"
`)
})
Expand Down
87 changes: 44 additions & 43 deletions packages/core/src/extensions/inline-text-to-mark-chunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import { LEZER_NODE_IDS } from '../lezer/node-ids.ts'

import type {
MdImageSourceAttrs,

Check failure on line 9 in packages/core/src/extensions/inline-text-to-mark-chunks.ts

View workflow job for this annotation

GitHub Actions / lint

'MdImageSourceAttrs' is declared but never used.
MdImageViewAttrs,
MdImageV2Attrs,

Check failure on line 10 in packages/core/src/extensions/inline-text-to-mark-chunks.ts

View workflow job for this annotation

GitHub Actions / lint

'MdImageV2Attrs' is declared but never used.
MdLinkTextAttrs,
MdPackAttrs,
MdPackSimpleKey,
Expand Down Expand Up @@ -154,7 +154,7 @@
}

/**
* Special walker for `Link` / `Image` nodes.
* Special walker for `Link` nodes.
*
* Lezer's flat child list looks like:
* LinkMark `[` (or `![`), [label children + implicit gaps], LinkMark `]`,
Expand Down Expand Up @@ -231,14 +231,6 @@

/**
* 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
* 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.
*/
function walkImage(
node: InlineElement,
Expand All @@ -254,43 +246,52 @@
walkLink(node, parentMarks, text, marks, out)
return
}
const brackets = node.children.filter((child) => child.type === LEZER_NODE_IDS.LinkMark)
const src = text.slice(urlNode.from, urlNode.to)
const alt = brackets.length >= 2 ? text.slice(brackets[0].to, brackets[1].from) : ''

const source = marks.mdImageSource.create({ src, alt } satisfies MdImageSourceAttrs)
const view = marks.mdImageView.create({ src, alt } satisfies MdImageViewAttrs)
const pack = marks.mdPack.create({ key: `image`, data: { src } } satisfies MdPackAttrs)
const bracketNodes = node.children.filter((child) => child.type === LEZER_NODE_IDS.LinkMark)
const titleNode = node.children.find((child) => child.type === LEZER_NODE_IDS.LinkTitle)

// The image's final character, where `mdImageView` is anchored: `)` today, a
// future `]` for `![alt][id]`.
const anchorFrom = node.to - 1
const src: string = 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)) : ''

// 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.
const baseAt = (from: number): Mark[] =>
from >= anchorFrom ? [...parentMarks, pack, source, view] : [...parentMarks, pack, source]
// const source = marks.mdImageSource.create({ src, alt } satisfies MdImageSourceAttrs)
// const view = marks.mdImageView.create({ src, alt } satisfies MdImageViewAttrs)
// const pack = marks.mdPack.create({ key: `image`, data: { src } } satisfies MdPackAttrs)

let pos = node.from
for (const child of node.children) {
if (child.from > pos) {
emit(out, pos, child.from, baseAt(pos))
}
const maybeMarkName = MARK_NAME_BY_TYPE_ID.get(child.type)
const childMarks = maybeMarkName
? [...baseAt(child.from), marks[maybeMarkName].create()]
: baseAt(child.from)
if (child.children.length === 0) {
emit(out, child.from, child.to, childMarks)
} else {
walk(child.children, childMarks, child.from, child.to, text, marks, out)
}
pos = child.to
}
if (pos < node.to) {
emit(out, pos, node.to, baseAt(pos))
}
emit(out, node.from, node.to, [
...parentMarks,

marks.mdImageV2.create({ src, alt, title }),
marks.mdImageSource.create({ src, alt }),
marks.mdHide.create(),
])

// // Marks shared by every chunk at `from`: the `mdPack` envelope plus
// // `mdImageSource` over the whole source, plus `mdImageView` across the URL
// // range. Each child layers its own syntax mark on top.
// const baseAt = (from: number): Mark[] =>
// from >= viewFrom && from < viewTo ? viewMarks : sourceMarks

// let pos = node.from
// for (const child of node.children) {
// if (child.from > pos) {
// emit(out, pos, child.from, baseAt(pos))
// }
// const maybeMarkName = MARK_NAME_BY_TYPE_ID.get(child.type)
// const childMarks = maybeMarkName
// ? [...baseAt(child.from), marks[maybeMarkName].create()]
// : baseAt(child.from)
// if (child.children.length === 0) {
// emit(out, child.from, child.to, childMarks)
// } else {
// walk(child.children, childMarks, child.from, child.to, text, marks, out)
// }
// pos = child.to
// }
// if (pos < node.to) {
// emit(out, pos, node.to, baseAt(pos))
// }
}

/**
Expand Down
Loading
Loading