FEATURE: add image rich editor extension (#31146)

Continues the work done on
https://github.com/discourse/discourse/pull/30815.

Adds an `image` node, its Markdown serializing/parsing logic, and a
plugin to auto-resolve `upload://hash` short urls.
This commit is contained in:
Renato Atilio 2025-03-04 11:34:19 -03:00 committed by GitHub
parent 6168636de8
commit 18a639916b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 267 additions and 1 deletions

View File

@ -0,0 +1,200 @@
import {
lookupCachedUploadUrl,
lookupUncachedUploadUrls,
} from "pretty-text/upload-short-url";
import { ajax } from "discourse/lib/ajax";
import { isNumeric } from "discourse/lib/utilities";
const PLACEHOLDER_IMG = "/images/transparent.png";
const ALT_TEXT_REGEX =
/^(.*?)(?:\|(\d{1,4}x\d{1,4}))?(?:,\s*(\d{1,3})%)?(?:\|(.*))?$/;
/** @type {RichEditorExtension} */
const extension = {
nodeSpec: {
image: {
inline: true,
attrs: {
src: {},
alt: { default: null },
title: { default: null },
// Overriding ProseMirror's default node to support these attrs
width: { default: null },
height: { default: null },
originalSrc: { default: null },
extras: { default: null },
"data-scale": { default: null },
"data-placeholder": { default: null },
},
group: "inline",
draggable: true,
parseDOM: [
{
tag: "img[src]",
getAttrs(dom) {
return {
src: dom.getAttribute("src"),
title: dom.getAttribute("title"),
alt: dom.getAttribute("alt"),
width: dom.getAttribute("width"),
height: dom.getAttribute("height"),
originalSrc: dom.dataset.origSrc,
extras: dom.hasAttribute("data-thumbnail")
? "thumbnail"
: undefined,
};
},
},
],
toDOM(node) {
const width = node.attrs.width
? (node.attrs.width * (node.attrs["data-scale"] || 100)) / 100
: undefined;
const height = node.attrs.height
? (node.attrs.height * (node.attrs["data-scale"] || 100)) / 100
: undefined;
if (node.attrs.extras === "audio") {
return [
"audio",
{ preload: "metadata", controls: false },
["source", { "data-orig-src": node.attrs.originalSrc }],
];
}
if (node.attrs.extras === "video") {
return [
"div",
{
class: "onebox-placeholder-container",
"data-orig-src": node.attrs.originalSrc,
},
["span", { class: "placeholder-icon video" }],
];
}
const { originalSrc, extras, ...attrs } = node.attrs;
attrs["data-orig-src"] = originalSrc;
if (extras === "thumbnail") {
attrs["data-thumbnail"] = true;
}
return ["img", { ...attrs, width, height }];
},
},
},
parse: {
image: {
node: "image",
getAttrs(token) {
const [, altText, dimensions, percent, extras] =
token.content.match(ALT_TEXT_REGEX);
const [width, height] = dimensions?.split("x") ?? [];
return {
src: token.attrGet("src"),
title: token.attrGet("title"),
alt: altText,
originalSrc: token.attrGet("data-orig-src"),
width,
height,
"data-scale":
percent && isNumeric(percent) ? parseInt(percent, 10) : undefined,
extras,
};
},
},
},
serializeNode: {
image(state, node) {
if (node.attrs["data-placeholder"]) {
return;
}
const alt = (node.attrs.alt || "").replace(/([\\[\]`])/g, "\\$1");
const scale = node.attrs["data-scale"]
? `, ${node.attrs["data-scale"]}%`
: "";
const dimensions =
node.attrs.width && node.attrs.height
? `|${node.attrs.width}x${node.attrs.height}${scale}`
: "";
const extras = node.attrs.extras ? `|${node.attrs.extras}` : "";
const src = node.attrs.originalSrc ?? node.attrs.src ?? "";
const escapedSrc = src.replace(/[\(\)]/g, "\\$&");
const title = node.attrs.title
? ' "' + node.attrs.title.replace(/"/g, '\\"') + '"'
: "";
state.write(`![${alt}${dimensions}${extras}](${escapedSrc}${title})`);
},
},
plugins({ pmState: { Plugin } }) {
const shortUrlResolver = new Plugin({
state: {
init() {
return [];
},
apply(tr, value) {
let updated = value.slice();
// we should only track the changes
tr.doc.descendants((node, pos) => {
if (node.type.name === "image" && node.attrs.originalSrc) {
if (node.attrs.src.endsWith(PLACEHOLDER_IMG)) {
updated.push({ pos, src: node.attrs.originalSrc });
} else {
updated = updated.filter(
(u) => u.src !== node.attrs.originalSrc
);
}
}
});
return updated;
},
},
view() {
return {
update: async (view, prevState) => {
if (prevState.doc.eq(view.state.doc)) {
return;
}
const unresolvedUrls = shortUrlResolver.getState(view.state);
for (const unresolved of unresolvedUrls) {
const cachedUrl = lookupCachedUploadUrl(unresolved.src).url;
const url =
cachedUrl ||
(await lookupUncachedUploadUrls([unresolved.src], ajax))[0]
?.url;
if (url) {
const node = view.state.doc.nodeAt(unresolved.pos);
if (node) {
const attrs = { ...node.attrs, src: url };
const transaction = view.state.tr
.setNodeMarkup(unresolved.pos, null, attrs)
.setMeta("addToHistory", false);
view.dispatch(transaction);
}
}
}
},
};
},
});
return shortUrlResolver;
},
};
export default extension;

View File

@ -2,6 +2,7 @@ import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-
import emoji from "./emoji";
import hashtag from "./hashtag";
import heading from "./heading";
import image from "./image";
import mention from "./mention";
import underline from "./underline";
@ -11,7 +12,7 @@ import underline from "./underline";
*
* @type {RichEditorExtension[]}
*/
const defaultExtensions = [emoji, heading, hashtag, mention, underline];
const defaultExtensions = [emoji, image, heading, hashtag, mention, underline];
defaultExtensions.forEach(registerRichEditorExtension);

View File

@ -0,0 +1,65 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { testMarkdown } from "discourse/tests/helpers/rich-editor-helper";
module(
"Integration | Component | prosemirror-editor - image extension",
function (hooks) {
setupRenderingTest(hooks);
const testCases = {
image: [
[
"![alt text](https://example.com/image.jpg)",
'<p><img src="https://example.com/image.jpg" alt="alt text" contenteditable="false" draggable="true"></p>',
"![alt text](https://example.com/image.jpg)",
],
[
"![alt text](https://example.com/image.jpg 'title')",
'<p><img src="https://example.com/image.jpg" alt="alt text" title="title" contenteditable="false" draggable="true"></p>',
'![alt text](https://example.com/image.jpg "title")',
],
[
'![alt text|100x200](https://example.com/image.jpg "title")',
'<p><img src="https://example.com/image.jpg" alt="alt text" title="title" width="100" height="200" contenteditable="false" draggable="true"></p>',
'![alt text|100x200](https://example.com/image.jpg "title")',
],
[
"![alt text|100x200, 50%](https://example.com/image.jpg)",
'<p><img src="https://example.com/image.jpg" alt="alt text" width="50" height="100" data-scale="50" contenteditable="false" draggable="true"></p>',
"![alt text|100x200, 50%](https://example.com/image.jpg)",
],
[
"![alt text|100x200, 50%|thumbnail](https://example.com/image.jpg)",
'<p><img src="https://example.com/image.jpg" alt="alt text" width="50" height="100" data-scale="50" data-thumbnail="true" contenteditable="false" draggable="true"></p>',
"![alt text|100x200, 50%|thumbnail](https://example.com/image.jpg)",
],
[
"![alt text](https://example.com/image(1).jpg)",
'<p><img src="https://example.com/image(1).jpg" alt="alt text" contenteditable="false" draggable="true"></p>',
"![alt text](https://example.com/image\\(1\\).jpg)",
],
[
"![alt text|video](uploads://hash)",
'<p><div class="onebox-placeholder-container" contenteditable="false" draggable="true"><span class="placeholder-icon video"></span></div></p>',
"![alt text|video](uploads://hash)",
],
[
"![alt text|audio](upload://hash)",
'<p><audio preload="metadata" controls="false" contenteditable="false" draggable="true"><source data-orig-src="upload://hash"></audio></p>',
"![alt text|audio](upload://hash)",
],
],
};
Object.entries(testCases).forEach(([name, tests]) => {
tests.forEach(([markdown, expectedHtml, expectedMarkdown]) => {
test(name, async function (assert) {
this.siteSettings.rich_editor = true;
await testMarkdown(assert, markdown, expectedHtml, expectedMarkdown);
});
});
});
}
);