diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index 8e58a0de04e..2781358616f 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -64,6 +64,11 @@ export function addComposerUploadHandler(extensions, method) { }); } +const uploadMarkdownResolvers = []; +export function addComposerUploadMarkdownResolver(resolver) { + uploadMarkdownResolvers.push(resolver); +} + export default Component.extend({ classNameBindings: ["showToolbar:toolbar-visible", ":wmd-controls"], @@ -745,7 +750,11 @@ export default Component.extend({ let upload = data.result; this._setUploadPlaceholderDone(data); if (!this._xhr || !this._xhr._userCancelled) { - const markdown = getUploadMarkdown(upload); + const markdown = uploadMarkdownResolvers.reduce( + (md, resolver) => resolver(upload) || md, + getUploadMarkdown(upload) + ); + cacheShortUploadUrl(upload.short_url, upload.url); this.appEvents.trigger( "composer:replace-text", diff --git a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 index 63f4400e654..e4f1362c864 100644 --- a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 +++ b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 @@ -40,7 +40,10 @@ import { registerCustomAvatarHelper } from "discourse/helpers/user-avatar"; import { disableNameSuppression } from "discourse/widgets/poster-name"; import { registerCustomPostMessageCallback as registerCustomPostMessageCallback1 } from "discourse/controllers/topic"; import Sharing from "discourse/lib/sharing"; -import { addComposerUploadHandler } from "discourse/components/composer-editor"; +import { + addComposerUploadHandler, + addComposerUploadMarkdownResolver +} from "discourse/components/composer-editor"; import { addCategorySortCriteria } from "discourse/components/edit-category-settings"; import { queryRegistry } from "discourse/widgets/widget"; import Composer from "discourse/models/composer"; @@ -867,6 +870,19 @@ class PluginApi { addComposerUploadHandler(extensions, method); } + /** + * Registers a function to generate Markdown after a file has been uploaded. + * + * Example: + * + * api.addComposerUploadMarkdownResolver(upload => { + * return `_uploaded ${upload.original_filename}_`; + * }) + */ + addComposerUploadMarkdownResolver(resolver) { + addComposerUploadMarkdownResolver(resolver); + } + /** * Registers a "beforeSave" function on the composer. This allows you to * implement custom logic that will happen before the user makes a post. diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 index c4914687450..ba5033b812c 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 @@ -124,64 +124,73 @@ function setupHoister(md) { md.renderer.rules.html_raw = renderHoisted; } +export function extractDataAttribute(str) { + let sep = str.indexOf("="); + if (sep === -1) { + return null; + } + + const key = `data-${str.substr(0, sep)}`.toLowerCase(); + if (!/^[A-Za-z]+[\w\-\:\.]*$/.test(key)) { + return null; + } + + const value = str.substr(sep + 1); + return [key, value]; +} + const IMG_SIZE_REGEX = /^([1-9]+[0-9]*)x([1-9]+[0-9]*)(\s*,\s*(x?)([1-9][0-9]{0,2}?)([%x]?))?$/; function renderImage(tokens, idx, options, env, slf) { - var token = tokens[idx]; + const token = tokens[idx]; + const alt = slf.renderInlineAsText(token.children, options, env); - let alt = slf.renderInlineAsText(token.children, options, env); + const split = alt.split("|"); + const altSplit = []; - let split = alt.split("|"); - if (split.length > 1) { - let match; - let info = split.splice(split.length - 1)[0]; + for (let i = 0, match, data; i < split.length; ++i) { + if ((match = split[i].match(IMG_SIZE_REGEX)) && match[1] && match[2]) { + let width = match[1]; + let height = match[2]; - if ((match = info.match(IMG_SIZE_REGEX))) { - if (match[1] && match[2]) { - alt = split.join("|"); - - let width = match[1]; - let height = match[2]; - - // calculate using percentage - if (match[5] && match[6] && match[6] === "%") { - let percent = parseFloat(match[5]) / 100.0; - width = parseInt(width * percent, 10); - height = parseInt(height * percent, 10); - } - - // calculate using only given width - if (match[5] && match[6] && match[6] === "x") { - let wr = parseFloat(match[5]) / width; - width = parseInt(match[5], 10); - height = parseInt(height * wr, 10); - } - - // calculate using only given height - if (match[5] && match[4] && match[4] === "x" && !match[6]) { - let hr = parseFloat(match[5]) / height; - height = parseInt(match[5], 10); - width = parseInt(width * hr, 10); - } - - if (token.attrIndex("width") === -1) { - token.attrs.push(["width", width]); - } - - if (token.attrIndex("height") === -1) { - token.attrs.push(["height", height]); - } - - if ( - options.discourse.previewing && - match[6] !== "x" && - match[4] !== "x" - ) - token.attrs.push(["class", "resizable"]); + // calculate using percentage + if (match[5] && match[6] && match[6] === "%") { + let percent = parseFloat(match[5]) / 100.0; + width = parseInt(width * percent, 10); + height = parseInt(height * percent, 10); } + + // calculate using only given width + if (match[5] && match[6] && match[6] === "x") { + let wr = parseFloat(match[5]) / width; + width = parseInt(match[5], 10); + height = parseInt(height * wr, 10); + } + + // calculate using only given height + if (match[5] && match[4] && match[4] === "x" && !match[6]) { + let hr = parseFloat(match[5]) / height; + height = parseInt(match[5], 10); + width = parseInt(width * hr, 10); + } + + if (token.attrIndex("width") === -1) { + token.attrs.push(["width", width]); + } + + if (token.attrIndex("height") === -1) { + token.attrs.push(["height", height]); + } + + if (options.discourse.previewing && match[6] !== "x" && match[4] !== "x") + token.attrs.push(["class", "resizable"]); + } else if ((data = extractDataAttribute(split[i]))) { + token.attrs.push(data); + } else { + altSplit.push(split[i]); } } - token.attrs[token.attrIndex("alt")][1] = alt; + token.attrs[token.attrIndex("alt")][1] = altSplit.join("|"); return slf.renderToken(tokens, idx, options); } @@ -190,16 +199,24 @@ function setupImageDimensions(md) { } function renderAttachment(tokens, idx, options, env, slf) { - const linkOpenToken = tokens[idx]; - const linkTextToken = tokens[idx + 1]; - const split = linkTextToken.content.split("|"); - const isValid = !linkOpenToken.attrs[ - linkOpenToken.attrIndex("data-orig-href") - ]; + const linkToken = tokens[idx]; + const textToken = tokens[idx + 1]; - if (isValid && split.length === 2 && split[1] === ATTACHMENT_CSS_CLASS) { - linkOpenToken.attrs.unshift(["class", split[1]]); - linkTextToken.content = split[0]; + const split = textToken.content.split("|"); + const contentSplit = []; + + for (let i = 0, data; i < split.length; ++i) { + if (split[i] === ATTACHMENT_CSS_CLASS) { + linkToken.attrs.unshift(["class", split[i]]); + } else if ((data = extractDataAttribute(split[i]))) { + linkToken.attrs.push(data); + } else { + contentSplit.push(split[i]); + } + } + + if (contentSplit.length > 0) { + textToken.content = contentSplit.join("|"); } return slf.renderToken(tokens, idx, options); diff --git a/app/assets/javascripts/pretty-text/upload-short-url.js.es6 b/app/assets/javascripts/pretty-text/upload-short-url.js.es6 index 916f899b6ac..9201a12b2a8 100644 --- a/app/assets/javascripts/pretty-text/upload-short-url.js.es6 +++ b/app/assets/javascripts/pretty-text/upload-short-url.js.es6 @@ -53,8 +53,11 @@ function _loadCachedShortUrls($uploads) { if (url !== MISSING) { $upload.attr("href", url); - const content = $upload.text().split("|"); + // Replace "|attachment" with class='attachment' + // TODO: This is a part of the cooking process now and should be + // removed in the future. + const content = $upload.text().split("|"); if (content[1] === ATTACHMENT_CSS_CLASS) { $upload.addClass(ATTACHMENT_CSS_CLASS); $upload.text(content[0]); diff --git a/app/assets/javascripts/pretty-text/white-lister.js.es6 b/app/assets/javascripts/pretty-text/white-lister.js.es6 index 5fd4f3cb277..2fec9b4f437 100644 --- a/app/assets/javascripts/pretty-text/white-lister.js.es6 +++ b/app/assets/javascripts/pretty-text/white-lister.js.es6 @@ -115,7 +115,7 @@ export default class WhiteLister { // Only add to `default` when you always want your whitelist to occur. In other words, // don't change this for a plugin or a feature that can be disabled -const DEFAULT_LIST = [ +export const DEFAULT_LIST = [ "a.attachment", "a.hashtag", "a.mention", diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index 794c787b5a3..d87cd1c0ce3 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -1450,7 +1450,7 @@ HTML cooked = <<~HTML

upload

-

some attachment|attachment

+

some attachment

HTML expect(PrettyText.cook(raw)).to eq(cooked.strip) diff --git a/test/javascripts/acceptance/composer-attachment-test.js.es6 b/test/javascripts/acceptance/composer-attachment-test.js.es6 index fa16a62cf85..edf6bc43742 100644 --- a/test/javascripts/acceptance/composer-attachment-test.js.es6 +++ b/test/javascripts/acceptance/composer-attachment-test.js.es6 @@ -34,6 +34,6 @@ QUnit.test("attachments are cooked properly", async assert => { find(".d-editor-preview:visible") .html() .trim(), - '

test

' + '

test

' ); }); diff --git a/test/javascripts/lib/pretty-text-test.js.es6 b/test/javascripts/lib/pretty-text-test.js.es6 index 5a509fbeb7a..d43310392eb 100644 --- a/test/javascripts/lib/pretty-text-test.js.es6 +++ b/test/javascripts/lib/pretty-text-test.js.es6 @@ -7,6 +7,7 @@ import { applyCachedInlineOnebox, deleteCachedInlineOnebox } from "pretty-text/inline-oneboxer"; +import { extractDataAttribute } from "pretty-text/engines/discourse-markdown-it"; QUnit.module("lib:pretty-text"); @@ -1365,3 +1366,11 @@ QUnit.test("emoji - emojiSet", assert => { `

:smile:

` ); }); + +QUnit.test("extractDataAttribute", assert => { + assert.deepEqual(extractDataAttribute("foo="), ["data-foo", ""]); + assert.deepEqual(extractDataAttribute("foo=bar"), ["data-foo", "bar"]); + + assert.notOk(extractDataAttribute("foo?=bar")); + assert.notOk(extractDataAttribute("https://discourse.org/?q=hello")); +});