diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index dafc514b632..792b2a06753 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -200,6 +200,7 @@ export default class ComposerEditor extends Component { @bind setupEditor(textManipulation) { this.textManipulation = textManipulation; + this.uppyComposerUpload.textManipulation = textManipulation; const input = this.element.querySelector(".d-editor-input"); diff --git a/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js b/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js index 6206624a42d..29c29680f2f 100644 --- a/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js +++ b/app/assets/javascripts/discourse/app/lib/textarea-text-manipulation.js @@ -15,6 +15,7 @@ import { } from "discourse/lib/utilities"; import { isTesting } from "discourse-common/config/environment"; import { bind } from "discourse-common/utils/decorators"; +import escapeRegExp from "discourse-common/utils/escape-regexp"; import { i18n } from "discourse-i18n"; const INDENT_DIRECTION_LEFT = "left"; @@ -51,8 +52,11 @@ export default class TextareaTextManipulation { textarea; $textarea; + placeholder; + constructor(owner, { markdownOptions, textarea, eventPrefix = "composer" }) { setOwner(this, owner); + this.placeholder = new TextareaPlaceholderHandler(owner, this); this.eventPrefix = eventPrefix; this.textarea = textarea; @@ -829,3 +833,134 @@ export default class TextareaTextManipulation { return this.$textarea.autocomplete(...arguments); } } + +class TextareaPlaceholderHandler { + @service composer; + + textManipulation; + + #placeholders = {}; + + constructor(owner, textManipulation) { + setOwner(this, owner); + + this.textManipulation = textManipulation; + } + + #uploadPlaceholder(file, currentMarkdown) { + const clipboard = i18n("clipboard"); + const uploadFilenamePlaceholder = this.#uploadFilenamePlaceholder( + file, + currentMarkdown + ); + const filename = uploadFilenamePlaceholder + ? uploadFilenamePlaceholder + : clipboard; + + let placeholder = `[${i18n("uploading_filename", { filename })}]()\n`; + if (!this.#cursorIsOnEmptyLine()) { + placeholder = `\n${placeholder}`; + } + + return placeholder; + } + + #cursorIsOnEmptyLine() { + const selectionStart = this.textManipulation.textarea.selectionStart; + return ( + selectionStart === 0 || + this.textManipulation.value.charAt(selectionStart - 1) === "\n" + ); + } + + #uploadFilenamePlaceholder(file, currentMarkdown) { + const filename = this.#filenamePlaceholder(file); + + // 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 = escapeRegExp(filename); + const regexString = `\\[${i18n("uploading_filename", { + filename: escapedFilename + "(?:\\()?([0-9])?(?:\\))?", + })}\\]\\(\\)`; + const globalRegex = new RegExp(regexString, "g"); + const matchingPlaceholder = currentMarkdown.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; + return `${filename}(${orderNr})`; + } + + return filename; + } + + #filenamePlaceholder(data) { + return data.name.replace(/\u200B-\u200D\uFEFF]/g, ""); + } + + insert(file) { + const placeholder = this.#uploadPlaceholder( + file, + this.composer.model.reply + ); + + this.textManipulation.insertText(placeholder); + + this.#placeholders[file.id] = { uploadPlaceholder: placeholder }; + } + + progress(file) { + let placeholderData = this.#placeholders[file.id]; + placeholderData.processingPlaceholder = `[${i18n("processing_filename", { + filename: file.name, + })}]()\n`; + + this.textManipulation.replaceText( + placeholderData.uploadPlaceholder, + placeholderData.processingPlaceholder + ); + + // Safari applies user-defined replacements to text inserted programmatically. + // One of the most common replacements is ... -> …, so we take care of the case + // where that transformation has been applied to the original placeholder + this.textManipulation.replaceText( + placeholderData.uploadPlaceholder.replace("...", "…"), + placeholderData.processingPlaceholder + ); + } + + progressComplete(file) { + let placeholderData = this.#placeholders[file.id]; + this.textManipulation.replaceText( + placeholderData.processingPlaceholder, + placeholderData.uploadPlaceholder + ); + } + + cancelAll() { + Object.values(this.#placeholders).forEach((data) => { + this.textManipulation.replaceText(data.uploadPlaceholder, ""); + }); + } + + cancel(file) { + if (this.#placeholders[file.id]) { + this.textManipulation.replaceText( + this.#placeholders[file.id].uploadPlaceholder, + "" + ); + } + } + + success(file, markdown) { + this.textManipulation.replaceText( + this.#placeholders[file.id].uploadPlaceholder.trim(), + markdown + ); + } +} diff --git a/app/assets/javascripts/discourse/app/lib/uppy/composer-upload.js b/app/assets/javascripts/discourse/app/lib/uppy/composer-upload.js index 5d01eac626d..c0531b74a09 100644 --- a/app/assets/javascripts/discourse/app/lib/uppy/composer-upload.js +++ b/app/assets/javascripts/discourse/app/lib/uppy/composer-upload.js @@ -23,7 +23,6 @@ import UppyChecksum from "discourse/lib/uppy-checksum-plugin"; import { clipboardHelpers } from "discourse/lib/utilities"; import getURL from "discourse-common/lib/get-url"; import { bind } from "discourse-common/utils/decorators"; -import escapeRegExp from "discourse-common/utils/escape-regexp"; import { i18n } from "discourse-i18n"; export default class UppyComposerUpload { @@ -53,12 +52,12 @@ export default class UppyComposerUpload { uploadPreProcessors; uploadHandlers; + textManipulation; + #inProgressUploads = []; #bufferedUploadErrors = []; - #placeholders = {}; #consecutiveImages = []; - #useUploadPlaceholders = true; #uploadTargetBound = false; #userCancelled = false; @@ -288,7 +287,7 @@ export default class UppyComposerUpload { ); file.meta.cancelled = true; this.#removeInProgressUpload(file.id); - this.#resetUpload(file, { removePlaceholder: true }); + this.#resetUpload(file); if (this.#inProgressUploads.length === 0) { this.#userCancelled = true; this.uppyWrapper.uppyInstance.cancelAll(); @@ -335,17 +334,7 @@ export default class UppyComposerUpload { }) ); - const placeholder = this.#uploadPlaceholder(file); - this.#placeholders[file.id] = { - uploadPlaceholder: placeholder, - }; - - if (this.#useUploadPlaceholders) { - this.appEvents.trigger( - `${this.composerEventPrefix}:insert-text`, - placeholder - ); - } + this.textManipulation.placeholder.insert(file); this.appEvents.trigger( `${this.composerEventPrefix}:upload-started`, @@ -366,28 +355,22 @@ export default class UppyComposerUpload { return; } let upload = response.body; - const markdown = await this.uploadMarkdownResolvers.reduce( - (md, resolver) => resolver(upload) || md, - getUploadMarkdown(upload) - ); // Only remove in progress after async resolvers finish: this.#removeInProgressUpload(file.id); cacheShortUploadUrl(upload.short_url, upload); + const markdown = await this.uploadMarkdownResolvers.reduce( + (md, resolver) => resolver(upload) || md, + getUploadMarkdown(upload) + ); + new ComposerVideoThumbnailUppy(getOwner(this)).generateVideoThumbnail( file, upload.url, () => { - if (this.#useUploadPlaceholders) { - this.appEvents.trigger( - `${this.composerEventPrefix}:replace-text`, - this.#placeholders[file.id].uploadPlaceholder.trim(), - markdown - ); - } + this.textManipulation.placeholder.success(file, markdown); - this.#resetUpload(file, { removePlaceholder: false }); this.appEvents.trigger( `${this.composerEventPrefix}:upload-success`, file.name, @@ -412,18 +395,7 @@ export default class UppyComposerUpload { this.uppyWrapper.uppyInstance.on("cancel-all", () => { // Do the manual cancelling work only if the user clicked cancel if (this.#userCancelled) { - Object.values(this.#placeholders).forEach((data) => { - run(() => { - if (this.#useUploadPlaceholders) { - this.appEvents.trigger( - `${this.composerEventPrefix}:replace-text`, - data.uploadPlaceholder, - "" - ); - } - }); - }); - + this.textManipulation.placeholder.cancelAll(); this.#userCancelled = false; this.#reset(); @@ -442,7 +414,7 @@ export default class UppyComposerUpload { @bind _handleUploadError(file, error, response) { this.#removeInProgressUpload(file.id); - this.#resetUpload(file, { removePlaceholder: true }); + this.#resetUpload(file); file.meta.error = error; @@ -508,36 +480,13 @@ export default class UppyComposerUpload { }); this.uppyWrapper.onPreProcessProgress((file) => { - let placeholderData = this.#placeholders[file.id]; - placeholderData.processingPlaceholder = `[${i18n("processing_filename", { - filename: file.name, - })}]()\n`; - - this.appEvents.trigger( - `${this.composerEventPrefix}:replace-text`, - placeholderData.uploadPlaceholder, - placeholderData.processingPlaceholder - ); - - // Safari applies user-defined replacements to text inserted programmatically. - // One of the most common replacements is ... -> …, so we take care of the case - // where that transformation has been applied to the original placeholder - this.appEvents.trigger( - `${this.composerEventPrefix}:replace-text`, - placeholderData.uploadPlaceholder.replace("...", "…"), - placeholderData.processingPlaceholder - ); + this.textManipulation.placeholder.progress(file); }); this.uppyWrapper.onPreProcessComplete( (file) => { run(() => { - let placeholderData = this.#placeholders[file.id]; - this.appEvents.trigger( - `${this.composerEventPrefix}:replace-text`, - placeholderData.processingPlaceholder, - placeholderData.uploadPlaceholder - ); + this.textManipulation.placeholder.progressComplete(file); }); }, () => { @@ -554,47 +503,6 @@ export default class UppyComposerUpload { ); } - #uploadFilenamePlaceholder(file) { - const filename = this.#filenamePlaceholder(file); - - // 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 = escapeRegExp(filename); - const regexString = `\\[${i18n("uploading_filename", { - filename: escapedFilename + "(?:\\()?([0-9])?(?:\\))?", - })}\\]\\(\\)`; - const globalRegex = new RegExp(regexString, "g"); - const matchingPlaceholder = this.composerModel.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; - return `${filename}(${orderNr})`; - } - - return filename; - } - - #uploadPlaceholder(file) { - const clipboard = i18n("clipboard"); - const uploadFilenamePlaceholder = this.#uploadFilenamePlaceholder(file); - const filename = uploadFilenamePlaceholder - ? uploadFilenamePlaceholder - : clipboard; - - let placeholder = `[${i18n("uploading_filename", { filename })}]()\n`; - if (!this.#cursorIsOnEmptyLine()) { - placeholder = `\n${placeholder}`; - } - - return placeholder; - } - #useXHRUploads() { this.uppyWrapper.uppyInstance.use(XHRUpload, { endpoint: getURL(`/uploads.json?client_id=${this.messageBus.clientId}`), @@ -620,14 +528,8 @@ export default class UppyComposerUpload { this.#fileInputEl.value = ""; } - #resetUpload(file, opts) { - if (opts.removePlaceholder && this.#placeholders[file.id]) { - this.appEvents.trigger( - `${this.composerEventPrefix}:replace-text`, - this.#placeholders[file.id].uploadPlaceholder, - "" - ); - } + #resetUpload(file) { + this.textManipulation.placeholder.cancel(file); } @bind @@ -704,10 +606,6 @@ export default class UppyComposerUpload { ); } - #filenamePlaceholder(data) { - return data.name.replace(/\u200B-\u200D\uFEFF]/g, ""); - } - #findMatchingUploadHandler(fileName) { return this.uploadHandlers.find((handler) => { const ext = handler.extensions.join("|"); @@ -716,14 +614,6 @@ export default class UppyComposerUpload { }); } - #cursorIsOnEmptyLine() { - const textArea = this.#editorEl.querySelector(this.editorInputClass); - const selectionStart = textArea.selectionStart; - return ( - selectionStart === 0 || textArea.value.charAt(selectionStart - 1) === "\n" - ); - } - #autoGridImages() { const reply = this.composerModel.get("reply"); const imagesToWrapGrid = new Set(this.#consecutiveImages);