DEV: Extend plugin API for uploads (#8440)

* DEV: Add API to alter uploads Markdown

* DEV: Extract data attributes from image / download Markdown

For example '[test|attachment|hello=world]' will generate an 'a' element
with a data attribute: 'data-hello=world'.

This commit also makes MarkdownIt to transform '|attachment' into
'class="attachment"'. This transformation used to be a part of the
process which resolves short URLs (i.e. upload://).

* DEV: Export imageNameFromFileName
This commit is contained in:
Dan Ungureanu 2019-12-09 16:20:03 +02:00 committed by GitHub
parent ebe6fa95be
commit aa24be1a9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 118 additions and 64 deletions

View File

@ -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",

View File

@ -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.

View File

@ -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);

View File

@ -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]);

View File

@ -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",

View File

@ -1450,7 +1450,7 @@ HTML
cooked = <<~HTML
<p><img src="/images/transparent.png" alt="upload" data-orig-src="upload://abcABC.png"></p>
<p><a href="/404" data-orig-href="upload://abcdefg.png">some attachment|attachment</a></p>
<p><a class="attachment" href="/404" data-orig-href="upload://abcdefg.png">some attachment</a></p>
HTML
expect(PrettyText.cook(raw)).to eq(cooked.strip)

View File

@ -34,6 +34,6 @@ QUnit.test("attachments are cooked properly", async assert => {
find(".d-editor-preview:visible")
.html()
.trim(),
'<p><a href="/uploads/short-url/asdsad.png" class="attachment">test</a></p>'
'<p><a class="attachment" href="/uploads/short-url/asdsad.png">test</a></p>'
);
});

View File

@ -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 => {
`<p><img src="/images/emoji/twitter/smile.png?v=${v}" title=":smile:" class="emoji" alt=":smile:"></p>`
);
});
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"));
});