mirror of
https://github.com/discourse/discourse.git
synced 2025-04-25 18:24:29 +08:00
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:
parent
6168636de8
commit
18a639916b
@ -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(``);
|
||||
},
|
||||
},
|
||||
|
||||
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;
|
@ -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);
|
||||
|
||||
|
@ -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: [
|
||||
[
|
||||
"",
|
||||
'<p><img src="https://example.com/image.jpg" alt="alt text" contenteditable="false" draggable="true"></p>',
|
||||
"",
|
||||
],
|
||||
[
|
||||
"",
|
||||
'<p><img src="https://example.com/image.jpg" alt="alt text" title="title" contenteditable="false" draggable="true"></p>',
|
||||
'',
|
||||
],
|
||||
[
|
||||
'',
|
||||
'<p><img src="https://example.com/image.jpg" alt="alt text" title="title" width="100" height="200" contenteditable="false" draggable="true"></p>',
|
||||
'',
|
||||
],
|
||||
[
|
||||
"",
|
||||
'<p><img src="https://example.com/image.jpg" alt="alt text" width="50" height="100" data-scale="50" contenteditable="false" draggable="true"></p>',
|
||||
"",
|
||||
],
|
||||
[
|
||||
"",
|
||||
'<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>',
|
||||
"",
|
||||
],
|
||||
[
|
||||
".jpg)",
|
||||
'<p><img src="https://example.com/image(1).jpg" alt="alt text" contenteditable="false" draggable="true"></p>',
|
||||
".jpg)",
|
||||
],
|
||||
[
|
||||
"",
|
||||
'<p><div class="onebox-placeholder-container" contenteditable="false" draggable="true"><span class="placeholder-icon video"></span></div></p>',
|
||||
"",
|
||||
],
|
||||
[
|
||||
"",
|
||||
'<p><audio preload="metadata" controls="false" contenteditable="false" draggable="true"><source data-orig-src="upload://hash"></audio></p>',
|
||||
"",
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
Loading…
x
Reference in New Issue
Block a user