This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.
osr-discourse-src/app/assets/javascripts/discourse/app/components/quote-button.js
Bianca Nenciu 7d7551adfc
DEV: Remove user options from current user serializer (#19089)
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.
2022-12-05 18:25:30 +02:00

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(),
});
},
});