diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/image.js b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/image.js new file mode 100644 index 00000000000..f4268e20354 --- /dev/null +++ b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/image.js @@ -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; diff --git a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-default.js b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-default.js index 8bcd45d2968..0c9cfcb3eaa 100644 --- a/app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-default.js +++ b/app/assets/javascripts/discourse/app/static/prosemirror/extensions/register-default.js @@ -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); diff --git a/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/image-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/image-test.gjs new file mode 100644 index 00000000000..b047ff961a3 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/prosemirror-editor/image-test.gjs @@ -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)", + '

alt text

', + "![alt text](https://example.com/image.jpg)", + ], + [ + "![alt text](https://example.com/image.jpg 'title')", + '

alt text

', + '![alt text](https://example.com/image.jpg "title")', + ], + [ + '![alt text|100x200](https://example.com/image.jpg "title")', + '

alt text

', + '![alt text|100x200](https://example.com/image.jpg "title")', + ], + [ + "![alt text|100x200, 50%](https://example.com/image.jpg)", + '

alt text

', + "![alt text|100x200, 50%](https://example.com/image.jpg)", + ], + [ + "![alt text|100x200, 50%|thumbnail](https://example.com/image.jpg)", + '

alt text

', + "![alt text|100x200, 50%|thumbnail](https://example.com/image.jpg)", + ], + [ + "![alt text](https://example.com/image(1).jpg)", + '

alt text

', + "![alt text](https://example.com/image\\(1\\).jpg)", + ], + [ + "![alt text|video](uploads://hash)", + '

', + "![alt text|video](uploads://hash)", + ], + [ + "![alt text|audio](upload://hash)", + '

', + "![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); + }); + }); + }); + } +);