A while ago in 27b97e4 the
pick-files-input was added but only used once for data-explorer. This commit uses it
for the composer-editor, and cleans it up to be usable either via uppy
handling the uploads or with this component handling the uploads.
This can then be used in other places in the app and also for plugins.
884 lines
24 KiB
JavaScript
884 lines
24 KiB
JavaScript
import { 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 {
|
|
cannotSee,
|
|
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;
|
|
|
|
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"],
|
|
|
|
editorClass: ".d-editor",
|
|
fileUploadElementId: "file-uploader",
|
|
mobileFileUploaderId: "mobile-file-upload",
|
|
|
|
// TODO (martin) Remove this once the chat plugin is using the new composerEventPrefix
|
|
eventPrefix: "composer",
|
|
composerEventPrefix: "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,
|
|
});
|
|
},
|
|
|
|
@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)),
|
|
});
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
},
|
|
|
|
_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);
|
|
},
|
|
|
|
_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);
|
|
},
|
|
|
|
// 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) {
|
|
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);
|
|
},
|
|
|
|
_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, reason: cannotSee[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
|
|
);
|
|
});
|
|
|
|
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);
|
|
|
|
preview.addEventListener("click", this._handleImageScaleButtonClick);
|
|
this._registerImageAltTextButtonClick(preview);
|
|
|
|
this.trigger("previewRefreshed", preview);
|
|
this.afterRefresh($preview);
|
|
},
|
|
},
|
|
});
|