User options were serialized at the root level of CurrentUserSerializer, but UserSerializer has a user_option field. This inconsistency caused issues in the past because user_option fields had to be duplicated on the frontend.
548 lines
16 KiB
JavaScript
548 lines
16 KiB
JavaScript
import afterTransition from "discourse/lib/after-transition";
|
|
import { propertyEqual } from "discourse/lib/computed";
|
|
import { ajax } from "discourse/lib/ajax";
|
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
|
import {
|
|
postUrl,
|
|
selectedElement,
|
|
selectedRange,
|
|
selectedText,
|
|
setCaretPosition,
|
|
translateModKey,
|
|
} from "discourse/lib/utilities";
|
|
import Component from "@ember/component";
|
|
import I18n from "I18n";
|
|
import { INPUT_DELAY } from "discourse-common/config/environment";
|
|
import KeyEnterEscape from "discourse/mixins/key-enter-escape";
|
|
import Sharing from "discourse/lib/sharing";
|
|
import { action } from "@ember/object";
|
|
import { alias } from "@ember/object/computed";
|
|
import discourseComputed from "discourse-common/utils/decorators";
|
|
import discourseDebounce from "discourse-common/lib/debounce";
|
|
import { getAbsoluteURL } from "discourse-common/lib/get-url";
|
|
import { next, schedule } from "@ember/runloop";
|
|
import toMarkdown from "discourse/lib/to-markdown";
|
|
import escapeRegExp from "discourse-common/utils/escape-regexp";
|
|
|
|
function getQuoteTitle(element) {
|
|
const titleEl = element.querySelector(".title");
|
|
if (!titleEl) {
|
|
return;
|
|
}
|
|
|
|
const titleLink = titleEl.querySelector("a:not(.back)");
|
|
if (titleLink) {
|
|
return titleLink.textContent.trim();
|
|
}
|
|
|
|
return titleEl.textContent.trim().replace(/:$/, "");
|
|
}
|
|
|
|
function fixQuotes(str) {
|
|
// u+201c “
|
|
// u+201d ”
|
|
return str.replace(/[\u201C\u201D]/g, '"');
|
|
}
|
|
|
|
export default Component.extend(KeyEnterEscape, {
|
|
classNames: ["quote-button"],
|
|
classNameBindings: [
|
|
"visible",
|
|
"_displayFastEditInput:fast-editing",
|
|
"animated",
|
|
],
|
|
visible: false,
|
|
animated: false,
|
|
privateCategory: alias("topic.category.read_restricted"),
|
|
editPost: null,
|
|
|
|
_isFastEditable: false,
|
|
_displayFastEditInput: false,
|
|
_fastEditInitalSelection: null,
|
|
_fastEditNewSelection: null,
|
|
_isSavingFastEdit: false,
|
|
_canEditPost: false,
|
|
_saveEditButtonTitle: I18n.t("composer.title", {
|
|
modifier: translateModKey("Meta+"),
|
|
}),
|
|
|
|
_isMouseDown: false,
|
|
_reselected: false,
|
|
|
|
_hideButton() {
|
|
this.quoteState.clear();
|
|
this.set("visible", false);
|
|
this.set("animated", false);
|
|
|
|
this.set("_isFastEditable", false);
|
|
this.set("_displayFastEditInput", false);
|
|
this.set("_fastEditInitalSelection", null);
|
|
this.set("_fastEditNewSelection", null);
|
|
},
|
|
|
|
_getRangeBoundaryRect(range, atEnd) {
|
|
// Don't mess with the original range as it results in weird behaviours
|
|
// where certain browsers will deselect the selection
|
|
const clone = range.cloneRange(range);
|
|
|
|
// create a marker element containing a single invisible character
|
|
const markerElement = document.createElement("span");
|
|
markerElement.appendChild(document.createTextNode("\ufeff"));
|
|
|
|
// on mobile, collapse the range at the end of the selection
|
|
if (atEnd) {
|
|
clone.collapse();
|
|
}
|
|
// insert the marker
|
|
clone.insertNode(markerElement);
|
|
|
|
// retrieve the position of the marker
|
|
const boundaryRect = markerElement.getBoundingClientRect();
|
|
boundaryRect.x += document.documentElement.scrollLeft;
|
|
boundaryRect.y += document.documentElement.scrollTop;
|
|
|
|
// remove the marker
|
|
const parent = markerElement.parentNode;
|
|
parent.removeChild(markerElement);
|
|
|
|
// merge back all text nodes so they don't get messed up
|
|
parent.normalize();
|
|
|
|
// work around Safari that would sometimes lose the selection
|
|
if (this.capabilities.isSafari) {
|
|
this._reselected = true;
|
|
window.getSelection().removeAllRanges();
|
|
window.getSelection().addRange(range);
|
|
}
|
|
|
|
return boundaryRect;
|
|
},
|
|
|
|
_selectionChanged() {
|
|
if (this._displayFastEditInput) {
|
|
return;
|
|
}
|
|
|
|
const quoteState = this.quoteState;
|
|
|
|
const selection = window.getSelection();
|
|
if (selection.isCollapsed) {
|
|
if (this.visible) {
|
|
this._hideButton();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ensure we selected content inside 1 post *only*
|
|
let firstRange, postId;
|
|
for (let r = 0; r < selection.rangeCount; r++) {
|
|
const range = selection.getRangeAt(r);
|
|
const $selectionStart = $(range.startContainer);
|
|
const $ancestor = $(range.commonAncestorContainer);
|
|
|
|
if ($selectionStart.closest(".cooked").length === 0) {
|
|
return;
|
|
}
|
|
|
|
firstRange = firstRange || range;
|
|
postId = postId || $ancestor.closest(".boxed, .reply").data("post-id");
|
|
|
|
if ($ancestor.closest(".contents").length === 0 || !postId) {
|
|
if (this.visible) {
|
|
this._hideButton();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const _selectedElement = selectedElement();
|
|
const _selectedText = selectedText();
|
|
|
|
const $selectedElement = $(_selectedElement);
|
|
const cooked =
|
|
$selectedElement.find(".cooked")[0] ||
|
|
$selectedElement.closest(".cooked")[0];
|
|
|
|
// computing markdown takes a lot of time on long posts
|
|
// this code attempts to compute it only when we can't fast track
|
|
let opts = {
|
|
full:
|
|
selectedRange().startOffset > 0
|
|
? false
|
|
: _selectedText === toMarkdown(cooked.innerHTML),
|
|
};
|
|
|
|
for (
|
|
let element = _selectedElement;
|
|
element && element.tagName !== "ARTICLE";
|
|
element = element.parentElement
|
|
) {
|
|
if (element.tagName === "ASIDE" && element.classList.contains("quote")) {
|
|
opts.username = element.dataset.username || getQuoteTitle(element);
|
|
opts.post = element.dataset.post;
|
|
opts.topic = element.dataset.topic;
|
|
break;
|
|
}
|
|
}
|
|
|
|
quoteState.selected(postId, _selectedText, opts);
|
|
this.set("visible", quoteState.buffer.length > 0);
|
|
|
|
if (this.siteSettings.enable_fast_edit) {
|
|
this.set(
|
|
"_canEditPost",
|
|
this.topic.postStream.findLoadedPost(postId)?.can_edit
|
|
);
|
|
|
|
if (this._canEditPost) {
|
|
const regexp = new RegExp(escapeRegExp(quoteState.buffer), "gi");
|
|
const matches = cooked.innerHTML.match(regexp);
|
|
|
|
if (
|
|
quoteState.buffer.length < 1 ||
|
|
quoteState.buffer.includes("|") || // tables are too complex
|
|
quoteState.buffer.match(/\n/g) || // linebreaks are too complex
|
|
matches?.length > 1 // duplicates are too complex
|
|
) {
|
|
this.set("_isFastEditable", false);
|
|
this.set("_fastEditInitalSelection", null);
|
|
this.set("_fastEditNewSelection", null);
|
|
} else if (matches?.length === 1) {
|
|
this.set("_isFastEditable", true);
|
|
this.set("_fastEditInitalSelection", quoteState.buffer);
|
|
this.set("_fastEditNewSelection", quoteState.buffer);
|
|
}
|
|
}
|
|
}
|
|
|
|
// avoid hard loops in quote selection unconditionally
|
|
// this can happen if you triple click text in firefox
|
|
if (this._prevSelection === _selectedText) {
|
|
return;
|
|
}
|
|
|
|
this._prevSelection = _selectedText;
|
|
|
|
// on Desktop, shows the button at the beginning of the selection
|
|
// on Mobile, shows the button at the end of the selection
|
|
const isMobileDevice = this.site.isMobileDevice;
|
|
const { isIOS, isAndroid, isOpera } = this.capabilities;
|
|
const showAtEnd = isMobileDevice || isIOS || isAndroid || isOpera;
|
|
|
|
const boundaryPosition = this._getRangeBoundaryRect(firstRange, showAtEnd);
|
|
|
|
// change the position of the button
|
|
schedule("afterRender", () => {
|
|
if (!this.element || this.isDestroying || this.isDestroyed) {
|
|
return;
|
|
}
|
|
|
|
let top = 0;
|
|
let left = 0;
|
|
const pxFromSelection = 5;
|
|
|
|
if (showAtEnd) {
|
|
// The selection-handles on iOS have a hit area of ~50px radius
|
|
// so we need to make sure our buttons are outside that radius
|
|
// Apply the same logic on all mobile devices for consistency
|
|
|
|
top = boundaryPosition.bottom + pxFromSelection;
|
|
left = boundaryPosition.left;
|
|
|
|
const safeRadius = 50;
|
|
|
|
const topicArea = document
|
|
.querySelector(".topic-area")
|
|
.getBoundingClientRect();
|
|
topicArea.x += document.documentElement.scrollLeft;
|
|
topicArea.y += document.documentElement.scrollTop;
|
|
|
|
const endHandlePosition = boundaryPosition;
|
|
const width = this.element.clientWidth;
|
|
|
|
const possiblePositions = [
|
|
{
|
|
// move to left
|
|
top,
|
|
left: left - width - safeRadius,
|
|
},
|
|
{
|
|
// move to right
|
|
top,
|
|
left: left + safeRadius,
|
|
},
|
|
{
|
|
// centered below end handle
|
|
top: top + safeRadius,
|
|
left: left - width / 2,
|
|
},
|
|
];
|
|
|
|
for (const pos of possiblePositions) {
|
|
// Ensure buttons are entirely within the .topic-area
|
|
pos.left = Math.max(topicArea.left, pos.left);
|
|
pos.left = Math.min(topicArea.right - width, pos.left);
|
|
|
|
let clearOfStartHandle = true;
|
|
if (isAndroid) {
|
|
// On android, the start-selection handle extends below the line, so we need to avoid it as well:
|
|
const startHandlePosition = this._getRangeBoundaryRect(
|
|
firstRange,
|
|
false
|
|
);
|
|
|
|
clearOfStartHandle =
|
|
pos.top - startHandlePosition.bottom >= safeRadius ||
|
|
pos.left + width <= startHandlePosition.left - safeRadius ||
|
|
pos.left >= startHandlePosition.left + safeRadius;
|
|
}
|
|
|
|
const clearOfEndHandle =
|
|
pos.top - endHandlePosition.top >= safeRadius ||
|
|
pos.left + width <= endHandlePosition.left - safeRadius ||
|
|
pos.left >= endHandlePosition.left + safeRadius;
|
|
|
|
if (clearOfStartHandle && clearOfEndHandle) {
|
|
left = pos.left;
|
|
top = pos.top;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// Desktop
|
|
top =
|
|
boundaryPosition.top - this.element.clientHeight - pxFromSelection;
|
|
left = boundaryPosition.left;
|
|
}
|
|
|
|
Object.assign(this.element.style, { top: `${top}px`, left: `${left}px` });
|
|
|
|
if (!this.animated) {
|
|
// We only enable CSS transitions after the initial positioning
|
|
// otherwise the button can appear to fly in from off-screen
|
|
next(() => this.set("animated", true));
|
|
}
|
|
});
|
|
},
|
|
|
|
didInsertElement() {
|
|
this._super(...arguments);
|
|
|
|
const { isWinphone, isAndroid } = this.capabilities;
|
|
const wait = isWinphone || isAndroid ? INPUT_DELAY : 25;
|
|
const onSelectionChanged = () => {
|
|
discourseDebounce(this, this._selectionChanged, wait);
|
|
};
|
|
|
|
$(document)
|
|
.on("mousedown.quote-button", (e) => {
|
|
this._prevSelection = null;
|
|
this._isMouseDown = true;
|
|
this._reselected = false;
|
|
|
|
// prevents fast-edit input event to trigger mousedown
|
|
if (e.target.classList.contains("fast-edit-input")) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
$(e.target).closest(".quote-button, .create, .share, .reply-new")
|
|
.length === 0
|
|
) {
|
|
this._hideButton();
|
|
}
|
|
})
|
|
.on("mouseup.quote-button", (e) => {
|
|
// prevents fast-edit input event to trigger mouseup
|
|
if (e.target.classList.contains("fast-edit-input")) {
|
|
return;
|
|
}
|
|
|
|
this._prevSelection = null;
|
|
this._isMouseDown = false;
|
|
onSelectionChanged();
|
|
})
|
|
.on("selectionchange.quote-button", () => {
|
|
if (!this._isMouseDown && !this._reselected) {
|
|
onSelectionChanged();
|
|
}
|
|
});
|
|
this.appEvents.on("quote-button:quote", this, "insertQuote");
|
|
this.appEvents.on("quote-button:edit", this, "_toggleFastEditForm");
|
|
},
|
|
|
|
willDestroyElement() {
|
|
$(document)
|
|
.off("mousedown.quote-button")
|
|
.off("mouseup.quote-button")
|
|
.off("selectionchange.quote-button");
|
|
this.appEvents.off("quote-button:quote", this, "insertQuote");
|
|
this.appEvents.off("quote-button:edit", this, "_toggleFastEditForm");
|
|
},
|
|
|
|
@discourseComputed("topic.{isPrivateMessage,invisible,category}")
|
|
quoteSharingEnabled(topic) {
|
|
if (
|
|
this.site.mobileView ||
|
|
this.siteSettings.share_quote_visibility === "none" ||
|
|
(this.currentUser &&
|
|
this.siteSettings.share_quote_visibility === "anonymous") ||
|
|
this.quoteSharingSources.length === 0 ||
|
|
this.privateCategory ||
|
|
(this.currentUser && topic.invisible)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
@discourseComputed("topic.isPrivateMessage")
|
|
quoteSharingSources(isPM) {
|
|
return Sharing.activeSources(
|
|
this.siteSettings.share_quote_buttons,
|
|
this.siteSettings.login_required || isPM
|
|
);
|
|
},
|
|
|
|
@discourseComputed("topic.{isPrivateMessage,invisible,category}")
|
|
quoteSharingShowLabel() {
|
|
return this.quoteSharingSources.length > 1;
|
|
},
|
|
|
|
@discourseComputed("topic.{id,slug}", "quoteState")
|
|
shareUrl(topic, quoteState) {
|
|
const postId = quoteState.postId;
|
|
const postNumber = topic.postStream.findLoadedPost(postId).post_number;
|
|
return getAbsoluteURL(postUrl(topic.slug, topic.id, postNumber));
|
|
},
|
|
|
|
@discourseComputed(
|
|
"topic.details.can_create_post",
|
|
"topic.details.can_reply_as_new_topic"
|
|
)
|
|
embedQuoteButton(canCreatePost, canReplyAsNewTopic) {
|
|
return (
|
|
(canCreatePost || canReplyAsNewTopic) &&
|
|
this.currentUser?.get("user_option.enable_quoting")
|
|
);
|
|
},
|
|
|
|
_saveFastEditDisabled: propertyEqual(
|
|
"_fastEditInitalSelection",
|
|
"_fastEditNewSelection"
|
|
),
|
|
|
|
@action
|
|
insertQuote() {
|
|
this.attrs.selectText().then(() => this._hideButton());
|
|
},
|
|
|
|
@action
|
|
_toggleFastEditForm() {
|
|
if (this._isFastEditable) {
|
|
this.toggleProperty("_displayFastEditInput");
|
|
|
|
schedule("afterRender", () => {
|
|
if (this.site.mobileView) {
|
|
this.element.style.left = `${
|
|
(window.innerWidth - this.element.clientWidth) / 2
|
|
}px`;
|
|
}
|
|
document.querySelector("#fast-edit-input")?.focus();
|
|
});
|
|
} else {
|
|
const postId = this.quoteState.postId;
|
|
const postModel = this.topic.postStream.findLoadedPost(postId);
|
|
return ajax(`/posts/${postModel.id}`, { type: "GET", cache: false }).then(
|
|
(result) => {
|
|
let bestIndex = 0;
|
|
const rows = result.raw.split("\n");
|
|
|
|
// selecting even a part of the text of a list item will include
|
|
// "* " at the beginning of the buffer, we remove it to be able
|
|
// to find it in row
|
|
const buffer = fixQuotes(
|
|
this.quoteState.buffer.split("\n")[0].replace(/^\* /, "")
|
|
);
|
|
|
|
rows.some((row, index) => {
|
|
if (row.length && row.includes(buffer)) {
|
|
bestIndex = index;
|
|
return true;
|
|
}
|
|
});
|
|
|
|
this?.editPost(postModel);
|
|
|
|
afterTransition(document.querySelector("#reply-control"), () => {
|
|
const textarea = document.querySelector(".d-editor-input");
|
|
if (!textarea || this.isDestroyed || this.isDestroying) {
|
|
return;
|
|
}
|
|
|
|
// best index brings us to one row before as slice start from 1
|
|
// we add 1 to be at the beginning of next line, unless we start from top
|
|
setCaretPosition(
|
|
textarea,
|
|
rows.slice(0, bestIndex).join("\n").length +
|
|
(bestIndex > 0 ? 1 : 0)
|
|
);
|
|
|
|
// ensures we correctly scroll to caret and reloads composer
|
|
// if we do another selection/edit
|
|
textarea.blur();
|
|
textarea.focus();
|
|
});
|
|
}
|
|
);
|
|
}
|
|
},
|
|
|
|
@action
|
|
_saveFastEdit() {
|
|
const postId = this.quoteState?.postId;
|
|
const postModel = this.topic.postStream.findLoadedPost(postId);
|
|
|
|
this.set("_isSavingFastEdit", true);
|
|
|
|
return ajax(`/posts/${postModel.id}`, { type: "GET", cache: false })
|
|
.then((result) => {
|
|
const newRaw = result.raw.replace(
|
|
fixQuotes(this._fastEditInitalSelection),
|
|
fixQuotes(this._fastEditNewSelection)
|
|
);
|
|
|
|
postModel
|
|
.save({ raw: newRaw })
|
|
.catch(popupAjaxError)
|
|
.finally(() => {
|
|
this.set("_isSavingFastEdit", false);
|
|
this._hideButton();
|
|
});
|
|
})
|
|
.catch(popupAjaxError);
|
|
},
|
|
|
|
@action
|
|
save() {
|
|
if (this._displayFastEditInput && !this._saveFastEditDisabled) {
|
|
this._saveFastEdit();
|
|
}
|
|
},
|
|
|
|
@action
|
|
cancelled() {
|
|
this._hideButton();
|
|
},
|
|
|
|
@action
|
|
share(source) {
|
|
Sharing.shareSource(source, {
|
|
url: this.shareUrl,
|
|
title: this.topic.title,
|
|
quote: window.getSelection().toString(),
|
|
});
|
|
},
|
|
});
|