DEV: Move composer-editor upload functions into mixin (#13923)

This PR moves all the upload related functions into a new
ComposerUpload mixin that is extended by the composer-editor
component. This is being done so I can introduce a ComposerUploadUppy
mixin that overrides functions in the regular ComposerUpload mixin,
via a new composer-editor-uppy component that inherits from
ComposerEditor. The proposed structure, which will be in the next PR,
looks like this:

composer-editor-uppy

```javascript
import ComposerEditor from "discourse/components/composer-editor"
import ComposerUploadUppy from "discourse/mixins/composer-upload-uppy"

export default ComposerEditor.extend(ComposerUploadUppy, {
  layoutName: "components/composer-editor"
});
```

This way the new composer-editor is a dumb component purely used for
testing uppy safely, and within the template for composer.hbs we do
this:

```javascript
@discourseComputed
composerComponent() {
  return this.siteSettings.enable_experimental_composer_uploader
    ? "composer-editor-uppy"
    : "composer-editor";
},
```

```handlebars
{{component composerComponent ...}}
```

This is the only way I can think to do it, because it is not possible to
access the site settings when the component is first declared I can't do
something like:

```javascript
const uploaderMixin = this.siteSettings.use_experimental_uploader?
ComposerUploaderUppy : ComposerUploader;

Component.extend(uploaderMixin, {});
```

An additional change in this PR is explicitly passing in these four
plugin data structures to the composer-editor Component, rather
than relying on JS closures which the mixin cannot do:

* uploadMarkdownResolvers
* uploadProcessorActions
* uploadProcessorQueue
* uploadHandlers
This commit is contained in:
Martin Brennan 2021-08-03 14:46:32 +10:00 committed by GitHub
parent 4ec2c1e9a9
commit 8eabbdae5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 363 additions and 342 deletions

View File

@ -2,17 +2,10 @@ import {
authorizedExtensions,
authorizesAllExtensions,
authorizesOneOrMoreImageExtensions,
displayErrorForUpload,
getUploadMarkdown,
validateUploadedFiles,
} from "discourse/lib/uploads";
import {
cacheShortUploadUrl,
resolveAllShortUrls,
} from "pretty-text/upload-short-url";
import { resolveAllShortUrls } from "pretty-text/upload-short-url";
import {
caretPosition,
clipboardHelpers,
formatUsername,
inCodeBlock,
tinyAvatar,
@ -29,16 +22,15 @@ import {
fetchUnseenMentions,
linkSeenMentions,
} from "discourse/lib/link-mentions";
import { later, next, run, schedule, throttle } from "@ember/runloop";
import { later, next, schedule, throttle } from "@ember/runloop";
import Component from "@ember/component";
import Composer from "discourse/models/composer";
import ComposerUpload from "discourse/mixins/composer-upload";
import EmberObject from "@ember/object";
import I18n from "I18n";
import { ajax } from "discourse/lib/ajax";
import bootbox from "bootbox";
import discourseDebounce from "discourse-common/lib/debounce";
import { findRawTemplate } from "discourse-common/lib/raw-templates";
import getURL from "discourse-common/lib/get-url";
import { iconHTML } from "discourse-common/lib/icon-library";
import { isTesting } from "discourse-common/config/environment";
import { loadOneboxes } from "discourse/lib/load-oneboxes";
@ -77,31 +69,16 @@ export function cleanUpComposerUploadMarkdownResolver() {
uploadMarkdownResolvers = [];
}
export default Component.extend({
export default Component.extend(ComposerUpload, {
classNameBindings: ["showToolbar:toolbar-visible", ":wmd-controls"],
uploadProgress: 0,
_xhr: null,
shouldBuildScrollMap: true,
scrollMap: null,
uploadFilenamePlaceholder: null,
uploadProcessingFilename: null,
uploadProcessingPlaceholdersAdded: false,
@discourseComputed("uploadFilenamePlaceholder")
uploadPlaceholder(uploadFilenamePlaceholder) {
const clipboard = I18n.t("clipboard");
const filename = uploadFilenamePlaceholder
? uploadFilenamePlaceholder
: clipboard;
let placeholder = `[${I18n.t("uploading_filename", { filename })}]()\n`;
if (!this._cursorIsOnEmptyLine()) {
placeholder = `\n${placeholder}`;
}
return placeholder;
},
uploadMarkdownResolvers,
uploadProcessorActions,
uploadProcessorQueue,
uploadHandlers,
@discourseComputed("composer.requiredCategoryMissing")
replyPlaceholder(requiredCategoryMissing) {
@ -130,20 +107,6 @@ export default Component.extend({
return requiredCategoryMissing && replyLength === 0;
},
@observes("composer.uploadCancelled")
_cancelUpload() {
if (!this.get("composer.uploadCancelled")) {
return;
}
this.set("composer.uploadCancelled", false);
if (this._xhr) {
this._xhr._userCancelled = true;
this._xhr.abort();
}
this._resetUpload(true);
},
@observes("focusTarget")
setFocus() {
if (this.focusTarget === "editor") {
@ -305,54 +268,6 @@ export default Component.extend({
}
},
_setUploadPlaceholderSend(data) {
const filename = this._filenamePlaceholder(data);
this.set("uploadFilenamePlaceholder", filename);
// when adding two separate files with the same filename search for matching
// placeholder already existing in the editor ie [Uploading: test.png...]
// and add order nr to the next one: [Uploading: test.png(1)...]
const escapedFilename = filename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regexString = `\\[${I18n.t("uploading_filename", {
filename: escapedFilename + "(?:\\()?([0-9])?(?:\\))?",
})}\\]\\(\\)`;
const globalRegex = new RegExp(regexString, "g");
const matchingPlaceholder = this.get("composer.reply").match(globalRegex);
if (matchingPlaceholder) {
// get last matching placeholder and its consecutive nr in regex
// capturing group and apply +1 to the placeholder
const lastMatch = matchingPlaceholder[matchingPlaceholder.length - 1];
const regex = new RegExp(regexString);
const orderNr = regex.exec(lastMatch)[1]
? parseInt(regex.exec(lastMatch)[1], 10) + 1
: 1;
data.orderNr = orderNr;
const filenameWithOrderNr = `${filename}(${orderNr})`;
this.set("uploadFilenamePlaceholder", filenameWithOrderNr);
}
},
_setUploadPlaceholderDone(data) {
const filename = this._filenamePlaceholder(data);
const filenameWithSize = `${filename} (${data.total})`;
this.set("uploadFilenamePlaceholder", filenameWithSize);
if (data.orderNr) {
const filenameWithOrderNr = `${filename}(${data.orderNr})`;
this.set("uploadFilenamePlaceholder", filenameWithOrderNr);
} else {
this.set("uploadFilenamePlaceholder", filename);
}
},
_filenamePlaceholder(data) {
return data.files[0].name.replace(/\u200B-\u200D\uFEFF]/g, "");
},
_resetUploadFilenamePlaceholder() {
this.set("uploadFilenamePlaceholder", null);
},
_enableAdvancedEditorPreviewSync() {
return this.siteSettings.enable_advanced_editor_preview_sync;
},
@ -645,237 +560,6 @@ export default Component.extend({
});
},
_resetUpload(removePlaceholder) {
next(() => {
if (this._validUploads > 0) {
this._validUploads--;
}
if (this._validUploads === 0) {
this.setProperties({
uploadProgress: 0,
isUploading: false,
isCancellable: false,
});
}
if (removePlaceholder) {
this.appEvents.trigger(
"composer:replace-text",
this.uploadPlaceholder,
""
);
}
this._resetUploadFilenamePlaceholder();
});
},
_bindUploadTarget() {
this._unbindUploadTarget(); // in case it's still bound, let's clean it up first
this._pasted = false;
const $element = $(this.element);
$.blueimp.fileupload.prototype.processActions = uploadProcessorActions;
$element.fileupload({
url: getURL(`/uploads.json?client_id=${this.messageBus.clientId}`),
dataType: "json",
pasteZone: $element,
processQueue: uploadProcessorQueue,
});
$element
.on("fileuploadprocessstart", () => {
this.setProperties({
uploadProgress: 0,
isUploading: true,
isProcessingUpload: true,
isCancellable: false,
});
})
.on("fileuploadprocess", (e, data) => {
if (!this.uploadProcessingPlaceholdersAdded) {
data.originalFiles
.map((f) => f.name)
.forEach((f) => {
this.appEvents.trigger(
"composer:insert-text",
`[${I18n.t("processing_filename", {
filename: f,
})}]()\n`
);
});
this.uploadProcessingPlaceholdersAdded = true;
}
this.uploadProcessingFilename = data.files[data.index].name;
})
.on("fileuploadprocessstop", () => {
this.setProperties({
uploadProgress: 0,
isUploading: false,
isProcessingUpload: false,
isCancellable: false,
});
this.uploadProcessingPlaceholdersAdded = false;
});
$element.on("fileuploadpaste", (e) => {
this._pasted = true;
if (!$(".d-editor-input").is(":focus")) {
return;
}
const { canUpload, canPasteHtml, types } = clipboardHelpers(e, {
siteSettings: this.siteSettings,
canUpload: true,
});
if (!canUpload || canPasteHtml || types.includes("text/plain")) {
e.preventDefault();
}
});
$element.on("fileuploadsubmit", (e, data) => {
const max = this.siteSettings.simultaneous_uploads;
// Limit the number of simultaneous uploads
if (max > 0 && data.files.length > max) {
bootbox.alert(
I18n.t("post.errors.too_many_dragged_and_dropped_files", {
count: max,
})
);
return false;
}
// Look for a matching file upload handler contributed from a plugin
const matcher = (handler) => {
const ext = handler.extensions.join("|");
const regex = new RegExp(`\\.(${ext})$`, "i");
return regex.test(data.files[0].name);
};
const matchingHandler = uploadHandlers.find(matcher);
if (data.files.length === 1 && matchingHandler) {
if (!matchingHandler.method(data.files[0], this)) {
return false;
}
}
// If no plugin, continue as normal
const isPrivateMessage = this.get("composer.privateMessage");
data.formData = { type: "composer" };
if (isPrivateMessage) {
data.formData.for_private_message = true;
}
if (this._pasted) {
data.formData.pasted = true;
}
const opts = {
user: this.currentUser,
siteSettings: this.siteSettings,
isPrivateMessage,
allowStaffToUploadAnyFileInPm: this.siteSettings
.allow_staff_to_upload_any_file_in_pm,
};
const isUploading = validateUploadedFiles(data.files, opts);
run(() => {
this.setProperties({ uploadProgress: 0, isUploading });
});
return isUploading;
});
$element.on("fileuploadprogressall", (e, data) => {
run(() => {
this.set(
"uploadProgress",
parseInt((data.loaded / data.total) * 100, 10)
);
});
});
$element.on("fileuploadsend", (e, data) => {
run(() => {
this._pasted = false;
this._validUploads++;
this._setUploadPlaceholderSend(data);
if (this.uploadProcessingFilename) {
this.appEvents.trigger(
"composer:replace-text",
`[${I18n.t("processing_filename", {
filename: this.uploadProcessingFilename,
})}]()`,
this.uploadPlaceholder.trim()
);
this.uploadProcessingFilename = null;
} else {
this.appEvents.trigger(
"composer:insert-text",
this.uploadPlaceholder
);
}
if (data.xhr && data.originalFiles.length === 1) {
this.set("isCancellable", true);
this._xhr = data.xhr();
}
});
});
$element.on("fileuploaddone", (e, data) => {
run(() => {
let upload = data.result;
this._setUploadPlaceholderDone(data);
if (!this._xhr || !this._xhr._userCancelled) {
const markdown = uploadMarkdownResolvers.reduce(
(md, resolver) => resolver(upload) || md,
getUploadMarkdown(upload)
);
cacheShortUploadUrl(upload.short_url, upload);
this.appEvents.trigger(
"composer:replace-text",
this.uploadPlaceholder.trim(),
markdown
);
this._resetUpload(false);
} else {
this._resetUpload(true);
}
});
});
$element.on("fileuploadfail", (e, data) => {
run(() => {
this._setUploadPlaceholderDone(data);
this._resetUpload(true);
const userCancelled = this._xhr && this._xhr._userCancelled;
this._xhr = null;
if (!userCancelled) {
displayErrorForUpload(data, this.siteSettings, data.files[0].name);
}
});
});
if (this.site.mobileView) {
const uploadButton = document.getElementById("mobile-file-upload");
uploadButton.addEventListener(
"click",
() => document.getElementById("file-uploader").click(),
false
);
}
},
_registerImageScaleButtonClick($preview) {
// original string `![image|foo=bar|690x220, 50%|bar=baz](upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title")`
// group 1 `image|foo=bar`
@ -920,20 +604,6 @@ export default Component.extend({
});
},
@on("willDestroyElement")
_unbindUploadTarget() {
this._validUploads = 0;
$("#reply-control .mobile-file-upload").off("click.uploader");
this.messageBus.unsubscribe("/uploads/composer");
const $uploadTarget = $(this.element);
try {
$uploadTarget.fileupload("destroy");
} catch (e) {
/* wasn't initialized yet */
}
$uploadTarget.off();
},
@on("willDestroyElement")
_composerClosed() {
this.appEvents.trigger("composer:will-close");
@ -950,10 +620,6 @@ export default Component.extend({
}
},
showUploadSelector(toolbarEvent) {
this.send("showUploadSelector", toolbarEvent);
},
onExpandPopupMenuOptions(toolbarEvent) {
const selected = toolbarEvent.selected;
toolbarEvent.selectText(selected.start, selected.end - selected.start);

View File

@ -0,0 +1,355 @@
import Mixin from "@ember/object/mixin";
import I18n from "I18n";
import { next, run } from "@ember/runloop";
import getURL from "discourse-common/lib/get-url";
import { clipboardHelpers } from "discourse/lib/utilities";
import discourseComputed, {
observes,
on,
} from "discourse-common/utils/decorators";
import {
displayErrorForUpload,
getUploadMarkdown,
validateUploadedFiles,
} from "discourse/lib/uploads";
import { cacheShortUploadUrl } from "pretty-text/upload-short-url";
export default Mixin.create({
_xhr: null,
uploadProgress: 0,
uploadFilenamePlaceholder: null,
uploadProcessingFilename: null,
uploadProcessingPlaceholdersAdded: false,
@discourseComputed("uploadFilenamePlaceholder")
uploadPlaceholder(uploadFilenamePlaceholder) {
const clipboard = I18n.t("clipboard");
const filename = uploadFilenamePlaceholder
? uploadFilenamePlaceholder
: clipboard;
let placeholder = `[${I18n.t("uploading_filename", { filename })}]()\n`;
if (!this._cursorIsOnEmptyLine()) {
placeholder = `\n${placeholder}`;
}
return placeholder;
},
@observes("composer.uploadCancelled")
_cancelUpload() {
if (!this.get("composer.uploadCancelled")) {
return;
}
this.set("composer.uploadCancelled", false);
if (this._xhr) {
this._xhr._userCancelled = true;
this._xhr.abort();
}
this._resetUpload(true);
},
_setUploadPlaceholderSend(data) {
const filename = this._filenamePlaceholder(data);
this.set("uploadFilenamePlaceholder", filename);
// when adding two separate files with the same filename search for matching
// placeholder already existing in the editor ie [Uploading: test.png...]
// and add order nr to the next one: [Uploading: test.png(1)...]
const escapedFilename = filename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regexString = `\\[${I18n.t("uploading_filename", {
filename: escapedFilename + "(?:\\()?([0-9])?(?:\\))?",
})}\\]\\(\\)`;
const globalRegex = new RegExp(regexString, "g");
const matchingPlaceholder = this.get("composer.reply").match(globalRegex);
if (matchingPlaceholder) {
// get last matching placeholder and its consecutive nr in regex
// capturing group and apply +1 to the placeholder
const lastMatch = matchingPlaceholder[matchingPlaceholder.length - 1];
const regex = new RegExp(regexString);
const orderNr = regex.exec(lastMatch)[1]
? parseInt(regex.exec(lastMatch)[1], 10) + 1
: 1;
data.orderNr = orderNr;
const filenameWithOrderNr = `${filename}(${orderNr})`;
this.set("uploadFilenamePlaceholder", filenameWithOrderNr);
}
},
_setUploadPlaceholderDone(data) {
const filename = this._filenamePlaceholder(data);
if (data.orderNr) {
const filenameWithOrderNr = `${filename}(${data.orderNr})`;
this.set("uploadFilenamePlaceholder", filenameWithOrderNr);
} else {
this.set("uploadFilenamePlaceholder", filename);
}
},
_filenamePlaceholder(data) {
if (data.files) {
return data.files[0].name.replace(/\u200B-\u200D\uFEFF]/g, "");
} else {
return data.name.replace(/\u200B-\u200D\uFEFF]/g, "");
}
},
_resetUploadFilenamePlaceholder() {
this.set("uploadFilenamePlaceholder", null);
},
_resetUpload(removePlaceholder) {
next(() => {
if (this._validUploads > 0) {
this._validUploads--;
}
if (this._validUploads === 0) {
this.setProperties({
uploadProgress: 0,
isUploading: false,
isCancellable: false,
});
}
if (removePlaceholder) {
this.appEvents.trigger(
"composer:replace-text",
this.uploadPlaceholder,
""
);
}
this._resetUploadFilenamePlaceholder();
});
},
_bindUploadTarget() {
this._unbindUploadTarget(); // in case it's still bound, let's clean it up first
this._pasted = false;
const $element = $(this.element);
$.blueimp.fileupload.prototype.processActions = this.uploadProcessorActions;
$element.fileupload({
url: getURL(`/uploads.json?client_id=${this.messageBus.clientId}`),
dataType: "json",
pasteZone: $element,
processQueue: this.uploadProcessorQueue,
});
$element
.on("fileuploadprocessstart", () => {
this.setProperties({
uploadProgress: 0,
isUploading: true,
isProcessingUpload: true,
isCancellable: false,
});
})
.on("fileuploadprocess", (e, data) => {
if (!this.uploadProcessingPlaceholdersAdded) {
data.originalFiles
.map((f) => f.name)
.forEach((f) => {
this.appEvents.trigger(
"composer:insert-text",
`[${I18n.t("processing_filename", {
filename: f,
})}]()\n`
);
});
this.uploadProcessingPlaceholdersAdded = true;
}
this.uploadProcessingFilename = data.files[data.index].name;
})
.on("fileuploadprocessstop", () => {
this.setProperties({
uploadProgress: 0,
isUploading: false,
isProcessingUpload: false,
isCancellable: false,
});
this.uploadProcessingPlaceholdersAdded = false;
});
$element.on("fileuploadpaste", (e) => {
this._pasted = true;
if (!$(".d-editor-input").is(":focus")) {
return;
}
const { canUpload, canPasteHtml, types } = clipboardHelpers(e, {
siteSettings: this.siteSettings,
canUpload: true,
});
if (!canUpload || canPasteHtml || types.includes("text/plain")) {
e.preventDefault();
}
});
$element.on("fileuploadsubmit", (e, data) => {
const max = this.siteSettings.simultaneous_uploads;
// Limit the number of simultaneous uploads
if (max > 0 && data.files.length > max) {
bootbox.alert(
I18n.t("post.errors.too_many_dragged_and_dropped_files", {
count: max,
})
);
return false;
}
// Look for a matching file upload handler contributed from a plugin
const matcher = (handler) => {
const ext = handler.extensions.join("|");
const regex = new RegExp(`\\.(${ext})$`, "i");
return regex.test(data.files[0].name);
};
const matchingHandler = this.uploadHandlers.find(matcher);
if (data.files.length === 1 && matchingHandler) {
if (!matchingHandler.method(data.files[0], this)) {
return false;
}
}
// If no plugin, continue as normal
const isPrivateMessage = this.get("composer.privateMessage");
data.formData = { type: "composer" };
if (isPrivateMessage) {
data.formData.for_private_message = true;
}
if (this._pasted) {
data.formData.pasted = true;
}
const opts = {
user: this.currentUser,
siteSettings: this.siteSettings,
isPrivateMessage,
allowStaffToUploadAnyFileInPm: this.siteSettings
.allow_staff_to_upload_any_file_in_pm,
};
const isUploading = validateUploadedFiles(data.files, opts);
run(() => {
this.setProperties({ uploadProgress: 0, isUploading });
});
return isUploading;
});
$element.on("fileuploadprogressall", (e, data) => {
run(() => {
this.set(
"uploadProgress",
parseInt((data.loaded / data.total) * 100, 10)
);
});
});
$element.on("fileuploadsend", (e, data) => {
run(() => {
this._pasted = false;
this._validUploads++;
this._setUploadPlaceholderSend(data);
if (this.uploadProcessingFilename) {
this.appEvents.trigger(
"composer:replace-text",
`[${I18n.t("processing_filename", {
filename: this.uploadProcessingFilename,
})}]()`,
this.uploadPlaceholder.trim()
);
this.uploadProcessingFilename = null;
} else {
this.appEvents.trigger(
"composer:insert-text",
this.uploadPlaceholder
);
}
if (data.xhr && data.originalFiles.length === 1) {
this.set("isCancellable", true);
this._xhr = data.xhr();
}
});
});
$element.on("fileuploaddone", (e, data) => {
run(() => {
let upload = data.result;
this._setUploadPlaceholderDone(data);
if (!this._xhr || !this._xhr._userCancelled) {
const markdown = this.uploadMarkdownResolvers.reduce(
(md, resolver) => resolver(upload) || md,
getUploadMarkdown(upload)
);
cacheShortUploadUrl(upload.short_url, upload);
this.appEvents.trigger(
"composer:replace-text",
this.uploadPlaceholder.trim(),
markdown
);
this._resetUpload(false);
} else {
this._resetUpload(true);
}
});
});
$element.on("fileuploadfail", (e, data) => {
run(() => {
this._setUploadPlaceholderDone(data);
this._resetUpload(true);
const userCancelled = this._xhr && this._xhr._userCancelled;
this._xhr = null;
if (!userCancelled) {
displayErrorForUpload(data, this.siteSettings, data.files[0].name);
}
});
});
this._bindMobileUploadButton();
},
_bindMobileUploadButton() {
if (this.site.mobileView) {
const uploadButton = document.getElementById("mobile-file-upload");
uploadButton.addEventListener(
"click",
() => document.getElementById("file-uploader").click(),
false
);
}
},
@on("willDestroyElement")
_unbindUploadTarget() {
this._validUploads = 0;
$("#reply-control .mobile-file-upload").off("click.uploader");
this.messageBus.unsubscribe("/uploads/composer");
const $uploadTarget = $(this.element);
try {
$uploadTarget.fileupload("destroy");
} catch (e) {
/* wasn't initialized yet */
}
$uploadTarget.off();
},
showUploadSelector(toolbarEvent) {
this.send("showUploadSelector", toolbarEvent);
},
});