diff --git a/packages/core/src/extensions/image.ts b/packages/core/src/extensions/image.ts index dd2bf762..4734ea9f 100644 --- a/packages/core/src/extensions/image.ts +++ b/packages/core/src/extensions/image.ts @@ -10,7 +10,7 @@ import { Plugin, PluginKey } from '@prosekit/pm/state' 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' import type { MarkName } from './mark-names.ts' type ImageUrlResolver = (src: string) => string | undefined @@ -54,6 +54,7 @@ function buildEmbedIframe(embed: EmbedDescriptor): HTMLIFrameElement { function renderImagePreview( src: string, alt: string, + // TODO: add a title options: ImageOptions, ): HTMLElement | undefined { const embed = matchEmbed(src) @@ -63,45 +64,73 @@ function renderImagePreview( 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) + }, } } } @@ -170,7 +199,7 @@ function createImageInputPlugin(options: ImageOptions): Plugin { 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 diff --git a/packages/core/src/extensions/inline-marks.ts b/packages/core/src/extensions/inline-marks.ts index 8efb90b9..263c954c 100644 --- a/packages/core/src/extensions/inline-marks.ts +++ b/packages/core/src/extensions/inline-marks.ts @@ -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>({ @@ -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 @@ -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' }], }) } @@ -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(), @@ -257,6 +283,7 @@ export function defineInlineMarks() { defineMdWikilinkView(), defineMdImageSource(), defineMdImageView(), + defineMdImageV2(), defineMdPack(), ) } 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..89711fbf 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 @@ -274,39 +274,25 @@ 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) " `) }) @@ -314,15 +300,17 @@ describe('image', () => { 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] " `) }) 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..caf2ce37 100644 --- a/packages/core/src/extensions/inline-text-to-mark-chunks.ts +++ b/packages/core/src/extensions/inline-text-to-mark-chunks.ts @@ -7,7 +7,7 @@ import { LEZER_NODE_IDS } from '../lezer/node-ids.ts' import type { MdImageSourceAttrs, - MdImageViewAttrs, + MdImageV2Attrs, MdLinkTextAttrs, MdPackAttrs, MdPackSimpleKey, @@ -154,7 +154,7 @@ function walk( } /** - * 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 `]`, @@ -231,14 +231,6 @@ 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 - * 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, @@ -254,43 +246,52 @@ function walkImage( 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)) + // } } /** 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/extensions/mark-names.ts b/packages/core/src/extensions/mark-names.ts index 708ef726..aa0f5968 100644 --- a/packages/core/src/extensions/mark-names.ts +++ b/packages/core/src/extensions/mark-names.ts @@ -1,4 +1,5 @@ export const MARK_NAMES = [ + 'mdImageV2', 'mdImageView', 'mdImageSource', 'mdMark', @@ -14,6 +15,7 @@ export const MARK_NAMES = [ 'mdWikilinkSource', 'mdWikilinkView', 'mdPack', + 'mdHide', ] as const export type MarkName = (typeof MARK_NAMES)[number] diff --git a/packages/core/src/lezer/inline.test.ts b/packages/core/src/lezer/inline.test.ts index 9294ab32..cc992cdd 100644 --- a/packages/core/src/lezer/inline.test.ts +++ b/packages/core/src/lezer/inline.test.ts @@ -96,6 +96,20 @@ describe('image', () => { `) }) + it('can parse image with title', () => { + expect(parse('![alt](url "title")')).toMatchInlineSnapshot(` + " + Image [0, 19] "![alt](url \\"title\\")" + LinkMark [0, 2] "![" + LinkMark [5, 6] "]" + LinkMark [6, 7] "(" + URL [7, 10] "url" + LinkTitle [11, 18] "\\"title\\"" + LinkMark [18, 19] ")" + " + `) + }) + it('can parse image with inline HTML comment', () => { expect(parse('![alt](url)')).toMatchInlineSnapshot(` " diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 0ab0df48..6e1a2a8b 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -432,17 +432,18 @@ * decoration, so match the mark view that contains a selected span and ring it * like a node selection (`.ProseMirror-selectednode`). In show mode the raw * source is visible and uses the native selection highlight instead. */ -.ProseMirror[data-mark-mode='hide'] .md-image-view:has(.md-atomic-selected) .md-image-preview, +/*.ProseMirror[data-mark-mode='hide'] .md-image-view:has(.md-atomic-selected) .md-image-preview,*/ .ProseMirror .md-wikilink-view:has(.md-atomic-selected) .md-wikilink-label { outline: 2px solid var(--meowdown-node-outline); outline-offset: 2px; border-radius: 0.25rem; background: var(--meowdown-node-selection); } -/* The image preview keeps its own corner radius. */ +/*/* The image preview keeps its own corner radius. */ .ProseMirror[data-mark-mode='hide'] .md-image-view:has(.md-atomic-selected) .md-image-preview { border-radius: var(--meowdown-image-radius, 0.5rem); } +*/ /* While a whole atomic unit is selected, the selection covers its (often hidden) * source, so hide the text caret: the ring/native selection is the affordance, @@ -550,17 +551,50 @@ } } -/* The trailing source char inside a view contentDOM is hidden by the general - * `display:none` above; that leaves it without a box, so the caret falls back - * before it. Put it back in the layout (kept invisible by the transparent/clip - * rules above) so the caret has a real stop right after the unit. */ +/* Source inside a view contentDOM is hidden by the general `display:none` above; + * that leaves it without a box, so the caret falls back before it. Put it back + * in the layout when hidden so the caret has a real stop right after the unit. */ .ProseMirror[data-mark-mode='hide'] .md-wikilink-view-content :is(.md-mark, .md-wikilink-source), -.ProseMirror[data-mark-mode='focus'] .md-wikilink-view-content :is(.md-mark, .md-wikilink-source), -.ProseMirror[data-mark-mode='hide'] .md-image-view-content :is(.md-mark, .md-image-source), -.ProseMirror[data-mark-mode='focus'] .md-image-view-content :is(.md-mark, .md-image-source) { +.ProseMirror[data-mark-mode='focus'] .md-wikilink-view-content :is(.md-mark, .md-wikilink-source) { display: inline-flex; width: 0px; max-width: 0px; min-width: 0px; overflow: visible; } + +.ProseMirror[data-mark-mode='hide'] + .md-image-view-content + :is(.md-mark, .md-link-uri, .md-image-source), +.ProseMirror[data-mark-mode='focus'] + .md-image-view-content:not(:has(.show)) + :is(.md-mark, .md-link-uri, .md-image-source) { + display: inline-flex; + width: 0px; + max-width: 0px; + min-width: 0px; + overflow: visible; +} + +.ProseMirror { + .md-image-view-v2 img { + display: inline-flex; + vertical-align: bottom; + } +} + +.ProseMirror { + .md-hide { + overflow: hidden; + display: inline-block; + vertical-align: bottom; + + letter-spacing: -2em; + width: 0px; + min-width: 0px; + max-width: 0px; + max-height: 0px; + color: red; + visibility: hidden; + } +} diff --git a/website/src/app.tsx b/website/src/app.tsx index 6139a92f..2629fa34 100644 --- a/website/src/app.tsx +++ b/website/src/app.tsx @@ -49,9 +49,7 @@ Track things two ways. Type \`+ \` for a circle checkbox task, or \`[] \` for a Drop in an image and it renders right where you wrote it. Paste or drag one in to upload your own: -![A sunny placeholder photo](https://static.photos/yellow/640x360/42) - -Small images flow inline ![dot](https://static.photos/yellow/16x16/3) with the surrounding text. +Small images flow inline ![](https://static.photos/yellow/16x16/3) with the surrounding text. Paste a YouTube or tweet link and it embeds itself. Undo once to get the plain link back: