This is a big change to change over to using the uppy upload mixin in the composer by default. This gets rid of the temporary composer-editor-uppy component, as well as removing the old ComposerUpload mixin and copying over any missing functions that were not yet implemented by ComposerUploadUppy. This has been working well on our hosting for some time now and has led us to several bug fixes. This commit also deletes the old plugin API for adding preprocessors for the uploads. The accepted method of doing this now is via an uppy preprocessor plugin, which we have several examples of in the core codebase. Leaving the `enable_experimental_composer_uploader` site setting intact for now because some plugins still rely on it, this will be removed at a later date. One step closer to ending the jQuery file uploader saga...
997 lines
28 KiB
JavaScript
997 lines
28 KiB
JavaScript
import {
|
|
authorizedExtensions,
|
|
authorizesAllExtensions,
|
|
authorizesOneOrMoreImageExtensions,
|
|
} from "discourse/lib/uploads";
|
|
import { alias } from "@ember/object/computed";
|
|
import { BasePlugin } from "@uppy/core";
|
|
import { resolveAllShortUrls } from "pretty-text/upload-short-url";
|
|
import {
|
|
caretPosition,
|
|
formatUsername,
|
|
inCodeBlock,
|
|
tinyAvatar,
|
|
} from "discourse/lib/utilities";
|
|
import discourseComputed, {
|
|
bind,
|
|
observes,
|
|
on,
|
|
} from "discourse-common/utils/decorators";
|
|
import {
|
|
fetchUnseenHashtags,
|
|
linkSeenHashtags,
|
|
} from "discourse/lib/link-hashtags";
|
|
import {
|
|
fetchUnseenMentions,
|
|
linkSeenMentions,
|
|
} from "discourse/lib/link-mentions";
|
|
import { later, next, schedule, throttle } from "@ember/runloop";
|
|
import Component from "@ember/component";
|
|
import Composer from "discourse/models/composer";
|
|
import ComposerUploadUppy from "discourse/mixins/composer-upload-uppy";
|
|
import EmberObject from "@ember/object";
|
|
import I18n from "I18n";
|
|
import { ajax } from "discourse/lib/ajax";
|
|
import discourseDebounce from "discourse-common/lib/debounce";
|
|
import { findRawTemplate } from "discourse-common/lib/raw-templates";
|
|
import { iconHTML } from "discourse-common/lib/icon-library";
|
|
import { isTesting } from "discourse-common/config/environment";
|
|
import { loadOneboxes } from "discourse/lib/load-oneboxes";
|
|
import putCursorAtEnd from "discourse/lib/put-cursor-at-end";
|
|
import userSearch from "discourse/lib/user-search";
|
|
|
|
// original string ``
|
|
// group 1 `image|foo=bar`
|
|
// group 2 `690x220`
|
|
// group 3 `, 50%`
|
|
// group 4 '|bar=baz'
|
|
// group 5 'upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title"'
|
|
|
|
// Notes:
|
|
// Group 3 is optional. group 4 can match images with or without a markdown title.
|
|
// All matches are whitespace tolerant as long it's still valid markdown.
|
|
// If the image is inside a code block, we'll ignore it `(?!(.*`))`.
|
|
const IMAGE_MARKDOWN_REGEX = /!\[(.*?)\|(\d{1,4}x\d{1,4})(,\s*\d{1,3}%)?(.*?)\]\((upload:\/\/.*?)\)(?!(.*`))/g;
|
|
const REBUILD_SCROLL_MAP_EVENTS = ["composer:resized", "composer:typed-reply"];
|
|
|
|
let uploadHandlers = [];
|
|
export function addComposerUploadHandler(extensions, method) {
|
|
uploadHandlers.push({
|
|
extensions,
|
|
method,
|
|
});
|
|
}
|
|
export function cleanUpComposerUploadHandler() {
|
|
// we cannot set this to uploadHandlers = [] because that messes with
|
|
// the references to the original array that the component has. this only
|
|
// really affects tests, but without doing this you could addComposerUploadHandler
|
|
// in a beforeEach function in a test but then it's not adding to the
|
|
// existing reference that the component has, because an earlier test ran
|
|
// cleanUpComposerUploadHandler and lost it. setting the length to 0 empties
|
|
// the array but keeps the reference
|
|
uploadHandlers.length = 0;
|
|
}
|
|
|
|
let uploadPreProcessors = [];
|
|
export function addComposerUploadPreProcessor(pluginClass, optionsResolverFn) {
|
|
if (!(pluginClass.prototype instanceof BasePlugin)) {
|
|
throw new Error(
|
|
"Composer upload preprocessors must inherit from the Uppy BasePlugin class."
|
|
);
|
|
}
|
|
|
|
uploadPreProcessors.push({
|
|
pluginClass,
|
|
optionsResolverFn,
|
|
});
|
|
}
|
|
export function cleanUpComposerUploadPreProcessor() {
|
|
uploadPreProcessors = [];
|
|
}
|
|
|
|
let uploadMarkdownResolvers = [];
|
|
export function addComposerUploadMarkdownResolver(resolver) {
|
|
uploadMarkdownResolvers.push(resolver);
|
|
}
|
|
export function cleanUpComposerUploadMarkdownResolver() {
|
|
uploadMarkdownResolvers = [];
|
|
}
|
|
|
|
export default Component.extend(ComposerUploadUppy, {
|
|
classNameBindings: ["showToolbar:toolbar-visible", ":wmd-controls"],
|
|
|
|
fileUploadElementId: "file-uploader",
|
|
mobileFileUploaderId: "mobile-file-upload",
|
|
eventPrefix: "composer",
|
|
uploadType: "composer",
|
|
uppyId: "composer-editor-uppy",
|
|
composerModel: alias("composer"),
|
|
composerModelContentKey: "reply",
|
|
editorInputClass: ".d-editor-input",
|
|
shouldBuildScrollMap: true,
|
|
scrollMap: null,
|
|
processPreview: true,
|
|
|
|
uploadMarkdownResolvers,
|
|
uploadPreProcessors,
|
|
uploadHandlers,
|
|
|
|
@discourseComputed("composer.requiredCategoryMissing")
|
|
replyPlaceholder(requiredCategoryMissing) {
|
|
if (requiredCategoryMissing) {
|
|
return "composer.reply_placeholder_choose_category";
|
|
} else {
|
|
const key = authorizesOneOrMoreImageExtensions(
|
|
this.currentUser.staff,
|
|
this.siteSettings
|
|
)
|
|
? "reply_placeholder"
|
|
: "reply_placeholder_no_images";
|
|
return `composer.${key}`;
|
|
}
|
|
},
|
|
|
|
@discourseComputed
|
|
showLink() {
|
|
return this.currentUser && this.currentUser.link_posting_access !== "none";
|
|
},
|
|
|
|
@observes("focusTarget")
|
|
setFocus() {
|
|
if (this.focusTarget === "editor") {
|
|
putCursorAtEnd(this.element.querySelector("textarea"));
|
|
}
|
|
},
|
|
|
|
@discourseComputed
|
|
markdownOptions() {
|
|
return {
|
|
previewing: true,
|
|
|
|
formatUsername,
|
|
|
|
lookupAvatarByPostNumber: (postNumber, topicId) => {
|
|
const topic = this.topic;
|
|
if (!topic) {
|
|
return;
|
|
}
|
|
|
|
const posts = topic.get("postStream.posts");
|
|
if (posts && topicId === topic.get("id")) {
|
|
const quotedPost = posts.findBy("post_number", postNumber);
|
|
if (quotedPost) {
|
|
return tinyAvatar(quotedPost.get("avatar_template"));
|
|
}
|
|
}
|
|
},
|
|
|
|
lookupPrimaryUserGroupByPostNumber: (postNumber, topicId) => {
|
|
const topic = this.topic;
|
|
if (!topic) {
|
|
return;
|
|
}
|
|
|
|
const posts = topic.get("postStream.posts");
|
|
if (posts && topicId === topic.get("id")) {
|
|
const quotedPost = posts.findBy("post_number", postNumber);
|
|
if (quotedPost) {
|
|
return quotedPost.primary_group_name;
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
|
|
@bind
|
|
_userSearchTerm(term) {
|
|
const topicId = this.get("topic.id");
|
|
// maybe this is a brand new topic, so grab category from composer
|
|
const categoryId =
|
|
this.get("topic.category_id") || this.get("composer.categoryId");
|
|
|
|
return userSearch({
|
|
term,
|
|
topicId,
|
|
categoryId,
|
|
includeGroups: true,
|
|
});
|
|
},
|
|
|
|
@discourseComputed()
|
|
acceptsAllFormats() {
|
|
return authorizesAllExtensions(this.currentUser.staff, this.siteSettings);
|
|
},
|
|
|
|
@discourseComputed()
|
|
acceptedFormats() {
|
|
const extensions = authorizedExtensions(
|
|
this.currentUser.staff,
|
|
this.siteSettings
|
|
);
|
|
|
|
return extensions.map((ext) => `.${ext}`).join();
|
|
},
|
|
|
|
@bind
|
|
_afterMentionComplete(value) {
|
|
this.composer.set("reply", value);
|
|
|
|
// ensures textarea scroll position is correct
|
|
schedule("afterRender", () => {
|
|
const input = this.element.querySelector(".d-editor-input");
|
|
input?.blur();
|
|
input?.focus();
|
|
});
|
|
},
|
|
|
|
@on("didInsertElement")
|
|
_composerEditorInit() {
|
|
const $input = $(this.element.querySelector(".d-editor-input"));
|
|
|
|
if (this.siteSettings.enable_mentions) {
|
|
$input.autocomplete({
|
|
template: findRawTemplate("user-selector-autocomplete"),
|
|
dataSource: this._userSearchTerm,
|
|
key: "@",
|
|
transformComplete: (v) => v.username || v.name,
|
|
afterComplete: this._afterMentionComplete,
|
|
triggerRule: (textarea) =>
|
|
!inCodeBlock(textarea.value, caretPosition(textarea)),
|
|
});
|
|
}
|
|
|
|
if (this._enableAdvancedEditorPreviewSync()) {
|
|
const input = this.element.querySelector(".d-editor-input");
|
|
const preview = this.element.querySelector(".d-editor-preview-wrapper");
|
|
this._initInputPreviewSync(input, preview);
|
|
} else {
|
|
this.element
|
|
.querySelector(".d-editor-input")
|
|
?.addEventListener("scroll", this._throttledSyncEditorAndPreviewScroll);
|
|
}
|
|
|
|
// Focus on the body unless we have a title
|
|
if (!this.get("composer.canEditTitle")) {
|
|
putCursorAtEnd(this.element.querySelector(".d-editor-input"));
|
|
}
|
|
|
|
if (this.allowUpload) {
|
|
this._bindUploadTarget();
|
|
this._bindMobileUploadButton();
|
|
}
|
|
|
|
this.appEvents.trigger("composer:will-open");
|
|
},
|
|
|
|
@discourseComputed(
|
|
"composer.reply",
|
|
"composer.replyLength",
|
|
"composer.missingReplyCharacters",
|
|
"composer.minimumPostLength",
|
|
"lastValidatedAt"
|
|
)
|
|
validation(
|
|
reply,
|
|
replyLength,
|
|
missingReplyCharacters,
|
|
minimumPostLength,
|
|
lastValidatedAt
|
|
) {
|
|
const postType = this.get("composer.post.post_type");
|
|
if (postType === this.site.get("post_types.small_action")) {
|
|
return;
|
|
}
|
|
|
|
let reason;
|
|
if (replyLength < 1) {
|
|
reason = I18n.t("composer.error.post_missing");
|
|
} else if (missingReplyCharacters > 0) {
|
|
reason = I18n.t("composer.error.post_length", {
|
|
count: minimumPostLength,
|
|
});
|
|
const tl = this.get("currentUser.trust_level");
|
|
if (tl === 0 || tl === 1) {
|
|
reason +=
|
|
"<br/>" +
|
|
I18n.t("composer.error.try_like", {
|
|
heart: iconHTML("heart", {
|
|
label: I18n.t("likes_lowercase", { count: 1 }),
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (reason) {
|
|
return EmberObject.create({
|
|
failed: true,
|
|
reason,
|
|
lastShownAt: lastValidatedAt,
|
|
});
|
|
}
|
|
},
|
|
|
|
_enableAdvancedEditorPreviewSync() {
|
|
return this.siteSettings.enable_advanced_editor_preview_sync;
|
|
},
|
|
|
|
_resetShouldBuildScrollMap() {
|
|
this.set("shouldBuildScrollMap", true);
|
|
},
|
|
|
|
@bind
|
|
_handleInputInteraction(event) {
|
|
const preview = this.element.querySelector(".d-editor-preview-wrapper");
|
|
|
|
if (!$(preview).is(":visible")) {
|
|
return;
|
|
}
|
|
|
|
preview.removeEventListener("scroll", this._handleInputOrPreviewScroll);
|
|
event.target.addEventListener("scroll", this._handleInputOrPreviewScroll);
|
|
},
|
|
|
|
@bind
|
|
_handleInputOrPreviewScroll(event) {
|
|
this._syncScroll(
|
|
this._syncEditorAndPreviewScroll,
|
|
$(event.target),
|
|
$(this.element.querySelector(".d-editor-preview-wrapper"))
|
|
);
|
|
},
|
|
|
|
@bind
|
|
_handlePreviewInteraction(event) {
|
|
this.element
|
|
.querySelector(".d-editor-input")
|
|
?.removeEventListener("scroll", this._handleInputOrPreviewScroll);
|
|
|
|
event.target?.addEventListener("scroll", this._handleInputOrPreviewScroll);
|
|
},
|
|
|
|
_initInputPreviewSync(input, preview) {
|
|
REBUILD_SCROLL_MAP_EVENTS.forEach((event) => {
|
|
this.appEvents.on(event, this, this._resetShouldBuildScrollMap);
|
|
});
|
|
|
|
schedule("afterRender", () => {
|
|
input?.addEventListener("touchstart", this._handleInputInteraction, {
|
|
passive: true,
|
|
});
|
|
input?.addEventListener("mouseenter", this._handleInputInteraction);
|
|
|
|
preview?.addEventListener("touchstart", this._handlePreviewInteraction, {
|
|
passive: true,
|
|
});
|
|
preview?.addEventListener("mouseenter", this._handlePreviewInteraction);
|
|
});
|
|
},
|
|
|
|
_syncScroll($callback, $input, $preview) {
|
|
if (!this.scrollMap || this.shouldBuildScrollMap) {
|
|
this.set("scrollMap", this._buildScrollMap($input, $preview));
|
|
this.set("shouldBuildScrollMap", false);
|
|
}
|
|
|
|
throttle(this, $callback, $input, $preview, this.scrollMap, 20);
|
|
},
|
|
|
|
_teardownInputPreviewSync() {
|
|
const input = this.element.querySelector(".d-editor-input");
|
|
input?.removeEventListener("mouseEnter", this._handleInputInteraction);
|
|
input?.removeEventListener("touchstart", this._handleInputInteraction);
|
|
input?.removeEventListener("scroll", this._handleInputOrPreviewScroll);
|
|
|
|
const preview = this.element.querySelector(".d-editor-preview-wrapper");
|
|
preview?.removeEventListener("mouseEnter", this._handlePreviewInteraction);
|
|
preview?.removeEventListener("touchstart", this._handlePreviewInteraction);
|
|
preview?.removeEventListener("scroll", this._handleInputOrPreviewScroll);
|
|
|
|
REBUILD_SCROLL_MAP_EVENTS.forEach((event) => {
|
|
this.appEvents.off(event, this, this._resetShouldBuildScrollMap);
|
|
});
|
|
},
|
|
|
|
// Adapted from https://github.com/markdown-it/markdown-it.github.io
|
|
_buildScrollMap($input, $preview) {
|
|
let sourceLikeDiv = $("<div />")
|
|
.css({
|
|
position: "absolute",
|
|
height: "auto",
|
|
visibility: "hidden",
|
|
width: $input[0].clientWidth,
|
|
"font-size": $input.css("font-size"),
|
|
"font-family": $input.css("font-family"),
|
|
"line-height": $input.css("line-height"),
|
|
"white-space": $input.css("white-space"),
|
|
})
|
|
.appendTo("body");
|
|
|
|
const linesMap = [];
|
|
let numberOfLines = 0;
|
|
|
|
$input
|
|
.val()
|
|
.split("\n")
|
|
.forEach((text) => {
|
|
linesMap.push(numberOfLines);
|
|
|
|
if (text.length === 0) {
|
|
numberOfLines++;
|
|
} else {
|
|
sourceLikeDiv.text(text);
|
|
|
|
let height;
|
|
let lineHeight;
|
|
height = parseFloat(sourceLikeDiv.css("height"));
|
|
lineHeight = parseFloat(sourceLikeDiv.css("line-height"));
|
|
numberOfLines += Math.round(height / lineHeight);
|
|
}
|
|
});
|
|
|
|
linesMap.push(numberOfLines);
|
|
sourceLikeDiv.remove();
|
|
|
|
const previewOffsetTop = $preview.offset().top;
|
|
const offset =
|
|
$preview.scrollTop() -
|
|
previewOffsetTop -
|
|
($input.offset().top - previewOffsetTop);
|
|
const nonEmptyList = [];
|
|
const scrollMap = [];
|
|
for (let i = 0; i < numberOfLines; i++) {
|
|
scrollMap.push(-1);
|
|
}
|
|
|
|
nonEmptyList.push(0);
|
|
scrollMap[0] = 0;
|
|
|
|
$preview.find(".preview-sync-line").each((_, element) => {
|
|
let $element = $(element);
|
|
let lineNumber = $element.data("line-number");
|
|
let linesToTop = linesMap[lineNumber];
|
|
if (linesToTop !== 0) {
|
|
nonEmptyList.push(linesToTop);
|
|
}
|
|
scrollMap[linesToTop] = Math.round($element.offset().top + offset);
|
|
});
|
|
|
|
nonEmptyList.push(numberOfLines);
|
|
scrollMap[numberOfLines] = $preview[0].scrollHeight;
|
|
|
|
let position = 0;
|
|
|
|
for (let i = 1; i < numberOfLines; i++) {
|
|
if (scrollMap[i] !== -1) {
|
|
position++;
|
|
continue;
|
|
}
|
|
|
|
let top = nonEmptyList[position];
|
|
let bottom = nonEmptyList[position + 1];
|
|
|
|
scrollMap[i] = (
|
|
(scrollMap[bottom] * (i - top) + scrollMap[top] * (bottom - i)) /
|
|
(bottom - top)
|
|
).toFixed(2);
|
|
}
|
|
|
|
return scrollMap;
|
|
},
|
|
|
|
@bind
|
|
_throttledSyncEditorAndPreviewScroll(event) {
|
|
const $preview = $(this.element.querySelector(".d-editor-preview-wrapper"));
|
|
|
|
throttle(
|
|
this,
|
|
this._syncEditorAndPreviewScroll,
|
|
$(event.target),
|
|
$preview,
|
|
20
|
|
);
|
|
},
|
|
|
|
_syncEditorAndPreviewScroll($input, $preview, scrollMap) {
|
|
if (this._enableAdvancedEditorPreviewSync()) {
|
|
let scrollTop;
|
|
const inputHeight = $input.height();
|
|
const inputScrollHeight = $input[0].scrollHeight;
|
|
const inputClientHeight = $input[0].clientHeight;
|
|
const scrollable = inputScrollHeight > inputClientHeight;
|
|
|
|
if (
|
|
scrollable &&
|
|
inputHeight + $input.scrollTop() + 100 > inputScrollHeight
|
|
) {
|
|
scrollTop = $preview[0].scrollHeight;
|
|
} else {
|
|
const lineHeight = parseFloat($input.css("line-height"));
|
|
const lineNumber = Math.floor($input.scrollTop() / lineHeight);
|
|
scrollTop = scrollMap[lineNumber];
|
|
}
|
|
|
|
$preview.stop(true).animate({ scrollTop }, 100, "linear");
|
|
} else {
|
|
if (!$input) {
|
|
return;
|
|
}
|
|
|
|
if ($input.scrollTop() === 0) {
|
|
$preview.scrollTop(0);
|
|
return;
|
|
}
|
|
|
|
const inputHeight = $input[0].scrollHeight;
|
|
const previewHeight = $preview[0].scrollHeight;
|
|
|
|
if ($input.height() + $input.scrollTop() + 100 > inputHeight) {
|
|
// cheat, special case for bottom
|
|
$preview.scrollTop(previewHeight);
|
|
return;
|
|
}
|
|
|
|
const scrollPosition = $input.scrollTop();
|
|
const factor = previewHeight / inputHeight;
|
|
const desired = scrollPosition * factor;
|
|
$preview.scrollTop(desired + 50);
|
|
}
|
|
},
|
|
|
|
_syncPreviewAndEditorScroll($input, $preview, scrollMap) {
|
|
if (scrollMap.length < 1) {
|
|
return;
|
|
}
|
|
|
|
let scrollTop;
|
|
const previewScrollTop = $preview.scrollTop();
|
|
|
|
if ($preview.height() + previewScrollTop + 100 > $preview[0].scrollHeight) {
|
|
scrollTop = $input[0].scrollHeight;
|
|
} else {
|
|
const lineHeight = parseFloat($input.css("line-height"));
|
|
scrollTop =
|
|
lineHeight * scrollMap.findIndex((offset) => offset > previewScrollTop);
|
|
}
|
|
|
|
$input.stop(true).animate({ scrollTop }, 100, "linear");
|
|
},
|
|
|
|
_renderUnseenMentions(preview, unseen) {
|
|
// 'Create a New Topic' scenario is not supported (per conversation with codinghorror)
|
|
// https://meta.discourse.org/t/taking-another-1-7-release-task/51986/7
|
|
fetchUnseenMentions(unseen, this.get("composer.topic.id")).then((r) => {
|
|
linkSeenMentions(preview, this.siteSettings);
|
|
this._warnMentionedGroups(preview);
|
|
this._warnCannotSeeMention(preview);
|
|
this._warnHereMention(r.here_count);
|
|
});
|
|
},
|
|
|
|
_renderUnseenHashtags(preview) {
|
|
const unseen = linkSeenHashtags(preview);
|
|
if (unseen.length > 0) {
|
|
fetchUnseenHashtags(unseen).then(() => {
|
|
linkSeenHashtags(preview);
|
|
});
|
|
}
|
|
},
|
|
|
|
_warnMentionedGroups(preview) {
|
|
schedule("afterRender", () => {
|
|
let found = this.warnedGroupMentions || [];
|
|
preview?.querySelectorAll(".mention-group.notify")?.forEach((mention) => {
|
|
if (this._isInQuote(mention)) {
|
|
return;
|
|
}
|
|
|
|
let name = mention.dataset.name;
|
|
if (found.indexOf(name) === -1) {
|
|
this.groupsMentioned([
|
|
{
|
|
name,
|
|
user_count: mention.dataset.mentionableUserCount,
|
|
max_mentions: mention.dataset.maxMentions,
|
|
},
|
|
]);
|
|
found.push(name);
|
|
}
|
|
});
|
|
|
|
this.set("warnedGroupMentions", found);
|
|
});
|
|
},
|
|
|
|
_warnCannotSeeMention(preview) {
|
|
const composerDraftKey = this.get("composer.draftKey");
|
|
|
|
if (composerDraftKey === Composer.NEW_PRIVATE_MESSAGE_KEY) {
|
|
return;
|
|
}
|
|
|
|
schedule("afterRender", () => {
|
|
let found = this.warnedCannotSeeMentions || [];
|
|
|
|
preview?.querySelectorAll(".mention.cannot-see")?.forEach((mention) => {
|
|
let name = mention.dataset.name;
|
|
|
|
if (found.indexOf(name) === -1) {
|
|
// add a delay to allow for typing, so you don't open the warning right away
|
|
// previously we would warn after @bob even if you were about to mention @bob2
|
|
later(
|
|
this,
|
|
() => {
|
|
if (
|
|
preview?.querySelectorAll(
|
|
`.mention.cannot-see[data-name="${name}"]`
|
|
)?.length > 0
|
|
) {
|
|
this.cannotSeeMention([{ name }]);
|
|
found.push(name);
|
|
}
|
|
},
|
|
2000
|
|
);
|
|
}
|
|
});
|
|
|
|
this.set("warnedCannotSeeMentions", found);
|
|
});
|
|
},
|
|
|
|
_warnHereMention(hereCount) {
|
|
if (!hereCount || hereCount === 0) {
|
|
return;
|
|
}
|
|
|
|
later(
|
|
this,
|
|
() => {
|
|
this.hereMention(hereCount);
|
|
},
|
|
2000
|
|
);
|
|
},
|
|
|
|
@bind
|
|
_handleImageScaleButtonClick(event) {
|
|
if (!event.target.classList.contains("scale-btn")) {
|
|
return;
|
|
}
|
|
|
|
const index = parseInt(
|
|
event.target.closest(".button-wrapper").dataset.imageIndex,
|
|
10
|
|
);
|
|
|
|
const scale = event.target.dataset.scale;
|
|
const matchingPlaceholder = this.get("composer.reply").match(
|
|
IMAGE_MARKDOWN_REGEX
|
|
);
|
|
|
|
if (matchingPlaceholder) {
|
|
const match = matchingPlaceholder[index];
|
|
|
|
if (match) {
|
|
const replacement = match.replace(
|
|
IMAGE_MARKDOWN_REGEX,
|
|
``
|
|
);
|
|
|
|
this.appEvents.trigger(
|
|
"composer:replace-text",
|
|
matchingPlaceholder[index],
|
|
replacement,
|
|
{ regex: IMAGE_MARKDOWN_REGEX, index }
|
|
);
|
|
}
|
|
}
|
|
|
|
event.preventDefault();
|
|
return;
|
|
},
|
|
|
|
resetImageControls(buttonWrapper) {
|
|
const imageResize = buttonWrapper.querySelector(".scale-btn-container");
|
|
const readonlyContainer = buttonWrapper.querySelector(
|
|
".alt-text-readonly-container"
|
|
);
|
|
const editContainer = buttonWrapper.querySelector(
|
|
".alt-text-edit-container"
|
|
);
|
|
|
|
imageResize.removeAttribute("hidden");
|
|
readonlyContainer.removeAttribute("hidden");
|
|
buttonWrapper.removeAttribute("editing");
|
|
editContainer.setAttribute("hidden", "true");
|
|
},
|
|
|
|
commitAltText(buttonWrapper) {
|
|
const index = parseInt(buttonWrapper.getAttribute("data-image-index"), 10);
|
|
const matchingPlaceholder = this.get("composer.reply").match(
|
|
IMAGE_MARKDOWN_REGEX
|
|
);
|
|
const match = matchingPlaceholder[index];
|
|
const input = buttonWrapper.querySelector("input.alt-text-input");
|
|
const replacement = match.replace(
|
|
IMAGE_MARKDOWN_REGEX,
|
|
``
|
|
);
|
|
|
|
this.appEvents.trigger("composer:replace-text", match, replacement);
|
|
|
|
this.resetImageControls(buttonWrapper);
|
|
},
|
|
|
|
@bind
|
|
_handleAltTextInputKeypress(event) {
|
|
if (!event.target.classList.contains("alt-text-input")) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === "[" || event.key === "]") {
|
|
event.preventDefault();
|
|
}
|
|
|
|
if (event.key === "Enter") {
|
|
const buttonWrapper = event.target.closest(".button-wrapper");
|
|
this.commitAltText(buttonWrapper);
|
|
}
|
|
},
|
|
|
|
@bind
|
|
_handleAltTextEditButtonClick(event) {
|
|
if (!event.target.classList.contains("alt-text-edit-btn")) {
|
|
return;
|
|
}
|
|
|
|
const buttonWrapper = event.target.closest(".button-wrapper");
|
|
const imageResize = buttonWrapper.querySelector(".scale-btn-container");
|
|
|
|
const readonlyContainer = buttonWrapper.querySelector(
|
|
".alt-text-readonly-container"
|
|
);
|
|
const altText = readonlyContainer.querySelector(".alt-text");
|
|
|
|
const editContainer = buttonWrapper.querySelector(
|
|
".alt-text-edit-container"
|
|
);
|
|
const editContainerInput = editContainer.querySelector(".alt-text-input");
|
|
|
|
buttonWrapper.setAttribute("editing", "true");
|
|
imageResize.setAttribute("hidden", "true");
|
|
readonlyContainer.setAttribute("hidden", "true");
|
|
editContainerInput.value = altText.textContent;
|
|
editContainer.removeAttribute("hidden");
|
|
editContainerInput.focus();
|
|
event.preventDefault();
|
|
},
|
|
|
|
@bind
|
|
_handleAltTextOkButtonClick(event) {
|
|
if (!event.target.classList.contains("alt-text-edit-ok")) {
|
|
return;
|
|
}
|
|
|
|
const buttonWrapper = event.target.closest(".button-wrapper");
|
|
this.commitAltText(buttonWrapper);
|
|
},
|
|
|
|
@bind
|
|
_handleAltTextCancelButtonClick(event) {
|
|
if (!event.target.classList.contains("alt-text-edit-cancel")) {
|
|
return;
|
|
}
|
|
|
|
const buttonWrapper = event.target.closest(".button-wrapper");
|
|
this.resetImageControls(buttonWrapper);
|
|
},
|
|
|
|
_registerImageAltTextButtonClick(preview) {
|
|
preview.addEventListener("click", this._handleAltTextEditButtonClick);
|
|
preview.addEventListener("click", this._handleAltTextOkButtonClick);
|
|
preview.addEventListener("click", this._handleAltTextCancelButtonClick);
|
|
preview.addEventListener("keypress", this._handleAltTextInputKeypress);
|
|
},
|
|
|
|
@on("willDestroyElement")
|
|
_composerClosed() {
|
|
this._unbindMobileUploadButton();
|
|
this.appEvents.trigger("composer:will-close");
|
|
next(() => {
|
|
// need to wait a bit for the "slide down" transition of the composer
|
|
later(
|
|
() => this.appEvents.trigger("composer:closed"),
|
|
isTesting() ? 0 : 400
|
|
);
|
|
});
|
|
|
|
if (this._enableAdvancedEditorPreviewSync()) {
|
|
this._teardownInputPreviewSync();
|
|
}
|
|
|
|
if (!this._enableAdvancedEditorPreviewSync()) {
|
|
this.element
|
|
.querySelector(".d-editor-input")
|
|
?.removeEventListener(
|
|
"scroll",
|
|
this._throttledSyncEditorAndPreviewScroll
|
|
);
|
|
}
|
|
|
|
const preview = this.element.querySelector(".d-editor-preview-wrapper");
|
|
preview?.removeEventListener("click", this._handleImageScaleButtonClick);
|
|
preview?.removeEventListener("click", this._handleAltTextEditButtonClick);
|
|
preview?.removeEventListener("click", this._handleAltTextOkButtonClick);
|
|
preview?.removeEventListener("click", this._handleAltTextCancelButtonClick);
|
|
preview?.removeEventListener("keypress", this._handleAltTextInputKeypress);
|
|
},
|
|
|
|
onExpandPopupMenuOptions(toolbarEvent) {
|
|
const selected = toolbarEvent.selected;
|
|
toolbarEvent.selectText(selected.start, selected.end - selected.start);
|
|
this.storeToolbarState(toolbarEvent);
|
|
},
|
|
|
|
showPreview() {
|
|
this.send("togglePreview");
|
|
},
|
|
|
|
_isInQuote(element) {
|
|
let parent = element.parentElement;
|
|
while (parent && !this._isPreviewRoot(parent)) {
|
|
if (this._isQuote(parent)) {
|
|
return true;
|
|
}
|
|
|
|
parent = parent.parentElement;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
_isPreviewRoot(element) {
|
|
return (
|
|
element.tagName === "DIV" &&
|
|
element.classList.contains("d-editor-preview")
|
|
);
|
|
},
|
|
|
|
_isQuote(element) {
|
|
return element.tagName === "ASIDE" && element.classList.contains("quote");
|
|
},
|
|
|
|
_cursorIsOnEmptyLine() {
|
|
const textArea = this.element.querySelector(".d-editor-input");
|
|
const selectionStart = textArea.selectionStart;
|
|
if (selectionStart === 0) {
|
|
return true;
|
|
} else if (textArea.value.charAt(selectionStart - 1) === "\n") {
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
_findMatchingUploadHandler(fileName) {
|
|
return this.uploadHandlers.find((handler) => {
|
|
const ext = handler.extensions.join("|");
|
|
const regex = new RegExp(`\\.(${ext})$`, "i");
|
|
return regex.test(fileName);
|
|
});
|
|
},
|
|
|
|
actions: {
|
|
importQuote(toolbarEvent) {
|
|
this.importQuote(toolbarEvent);
|
|
},
|
|
|
|
onExpandPopupMenuOptions(toolbarEvent) {
|
|
this.onExpandPopupMenuOptions(toolbarEvent);
|
|
},
|
|
|
|
togglePreview() {
|
|
this.togglePreview();
|
|
},
|
|
|
|
extraButtons(toolbar) {
|
|
toolbar.addButton({
|
|
tabindex: "0",
|
|
id: "quote",
|
|
group: "fontStyles",
|
|
icon: "far-comment",
|
|
sendAction: this.importQuote,
|
|
title: "composer.quote_post_title",
|
|
unshift: true,
|
|
});
|
|
|
|
if (this.allowUpload && this.uploadIcon && !this.site.mobileView) {
|
|
toolbar.addButton({
|
|
id: "upload",
|
|
group: "insertions",
|
|
icon: this.uploadIcon,
|
|
title: "upload",
|
|
sendAction: this.showUploadModal,
|
|
});
|
|
}
|
|
|
|
toolbar.addButton({
|
|
id: "options",
|
|
group: "extras",
|
|
icon: "cog",
|
|
title: "composer.options",
|
|
sendAction: this.onExpandPopupMenuOptions.bind(this),
|
|
popupMenu: true,
|
|
});
|
|
},
|
|
|
|
previewUpdated(preview) {
|
|
// cache jquery objects for functions still using jquery
|
|
const $preview = $(preview);
|
|
|
|
// Paint mentions
|
|
const unseenMentions = linkSeenMentions(preview, this.siteSettings);
|
|
if (unseenMentions.length) {
|
|
discourseDebounce(
|
|
this,
|
|
this._renderUnseenMentions,
|
|
preview,
|
|
unseenMentions,
|
|
450
|
|
);
|
|
}
|
|
|
|
this._warnMentionedGroups(preview);
|
|
this._warnCannotSeeMention(preview);
|
|
|
|
// Paint category and tag hashtags
|
|
const unseenHashtags = linkSeenHashtags(preview);
|
|
if (unseenHashtags.length > 0) {
|
|
discourseDebounce(this, this._renderUnseenHashtags, preview, 450);
|
|
}
|
|
|
|
// Paint oneboxes
|
|
const paintFunc = () => {
|
|
const post = this.get("composer.post");
|
|
let refresh = false;
|
|
|
|
//If we are editing a post, we'll refresh its contents once.
|
|
if (post && !post.get("refreshedPost")) {
|
|
refresh = true;
|
|
}
|
|
|
|
const paintedCount = loadOneboxes(
|
|
preview,
|
|
ajax,
|
|
this.get("composer.topic.id"),
|
|
this.get("composer.category.id"),
|
|
this.siteSettings.max_oneboxes_per_post,
|
|
refresh
|
|
);
|
|
|
|
if (refresh && paintedCount > 0) {
|
|
post.set("refreshedPost", true);
|
|
}
|
|
};
|
|
|
|
discourseDebounce(this, paintFunc, 450);
|
|
|
|
// Short upload urls need resolution
|
|
resolveAllShortUrls(ajax, this.siteSettings, preview);
|
|
|
|
if (this._enableAdvancedEditorPreviewSync()) {
|
|
this._syncScroll(
|
|
this._syncEditorAndPreviewScroll,
|
|
$(this.element.querySelector(".d-editor-input")),
|
|
$preview
|
|
);
|
|
}
|
|
|
|
preview.addEventListener("click", this._handleImageScaleButtonClick);
|
|
this._registerImageAltTextButtonClick(preview);
|
|
|
|
this.trigger("previewRefreshed", preview);
|
|
this.afterRefresh($preview);
|
|
},
|
|
},
|
|
});
|