mirror of
https://github.com/discourse/discourse.git
synced 2025-04-26 15:44:30 +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 emoji from "./emoji";
|
||||||
import hashtag from "./hashtag";
|
import hashtag from "./hashtag";
|
||||||
import heading from "./heading";
|
import heading from "./heading";
|
||||||
|
import image from "./image";
|
||||||
import mention from "./mention";
|
import mention from "./mention";
|
||||||
import underline from "./underline";
|
import underline from "./underline";
|
||||||
|
|
||||||
@ -11,7 +12,7 @@ import underline from "./underline";
|
|||||||
*
|
*
|
||||||
* @type {RichEditorExtension[]}
|
* @type {RichEditorExtension[]}
|
||||||
*/
|
*/
|
||||||
const defaultExtensions = [emoji, heading, hashtag, mention, underline];
|
const defaultExtensions = [emoji, image, heading, hashtag, mention, underline];
|
||||||
|
|
||||||
defaultExtensions.forEach(registerRichEditorExtension);
|
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