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
Penar Musaraj 7592754c90
FIX: Allow users to quote in closed topics (#17645)
Previously, non-staff users could only quote if they had an open composer.

This change shows the quote control when selecting text in closed topics
at all times and if the composer isn't already open, it will default to
creating a linked topic.
2022-07-26 15:45:34 -04: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("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(),
});
},
});