From: Cyperghost Date: Wed, 18 Dec 2024 12:52:45 +0000 (+0100) Subject: Mark `WoltLabSuite/Core/Ui/Message/Quote` as deprecated X-Git-Url: https://git.stricted.de/?a=commitdiff_plain;h=refs%2Fheads%2F6.2-quote-tab;p=GitHub%2FWoltLab%2FWCF.git Mark `WoltLabSuite/Core/Ui/Message/Quote` as deprecated --- diff --git a/ts/WoltLabSuite/Core/Component/Quote/List.ts b/ts/WoltLabSuite/Core/Component/Quote/List.ts index 2af4d50bc0..a83d633b2c 100644 --- a/ts/WoltLabSuite/Core/Component/Quote/List.ts +++ b/ts/WoltLabSuite/Core/Component/Quote/List.ts @@ -5,12 +5,15 @@ * @copyright 2001-2024 WoltLab GmbH * @license GNU Lesser General Public License * @since 6.2 + * @woltlabExcludeBundle tiny */ + import * as Core from "WoltLabSuite/Core/Core"; import { listenToCkeditor } from "WoltLabSuite/Core/Component/Ckeditor/Event"; import type { CKEditor } from "WoltLabSuite/Core/Component/Ckeditor"; import { getTabMenu } from "WoltLabSuite/Core/Component/Message/MessageTabMenu"; import { getPhrase } from "WoltLabSuite/Core/Language"; +import { setActiveEditor } from "WoltLabSuite/Core/Component/Quote/Message"; export const STORAGE_KEY = Core.getStoragePrefix() + "quotes"; const quoteLists = new Map(); @@ -68,6 +71,12 @@ export function setup(editorId: string): void { listenToCkeditor(editor).ready(({ ckeditor }) => { if (ckeditor.features.quoteBlock) { quoteLists.set(editorId, new QuoteList(editorId, ckeditor)); + + setActiveEditor(ckeditor, true); + } else { + setActiveEditor(ckeditor, false); } + + //TODO handle active editor changed }); } diff --git a/ts/WoltLabSuite/Core/Component/Quote/Message.ts b/ts/WoltLabSuite/Core/Component/Quote/Message.ts new file mode 100644 index 0000000000..a8c2665057 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Quote/Message.ts @@ -0,0 +1,393 @@ +/** + * Handles quotes selection in messages. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle tiny + */ + +import DomUtil from "WoltLabSuite/Core/Dom/Util"; +import { getPhrase } from "WoltLabSuite/Core/Language"; +import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; +import { set as setAlignment } from "WoltLabSuite/Core/Ui/Alignment"; +import { CKEditor } from "WoltLabSuite/Core/Component/Ckeditor"; + +interface Container { + element: HTMLElement; + messageBodySelector: string; +} + +const containers = new Map(); +let activeMessageId = ""; +let message = ""; +let activeEditor: CKEditor | undefined = undefined; +let timerSelectionChange: number | undefined = undefined; +let isMouseDown = false; +let objectId: number | undefined = undefined; +const copyQuote = document.createElement("div"); + +export function registerContainer(containerSelector: string, messageBodySelector: string): void { + wheneverFirstSeen(containerSelector, (container: HTMLElement) => { + const id = DomUtil.identify(container); + containers.set(id, { + element: container, + messageBodySelector: messageBodySelector, + }); + + if (container.classList.contains("jsInvalidQuoteTarget")) { + return; + } + + container.addEventListener("mousedown", (event) => onMouseDown(event)); + container.classList.add("jsQuoteMessageContainer"); + + container.querySelector(".jsQuoteMessage")?.addEventListener("click", () => { + //TODO + }); + }); +} + +export function setActiveEditor(editor: CKEditor, supportDirectInsert: boolean) { + copyQuote.querySelector(".jsQuoteManagerQuoteAndInsert")!.hidden = !supportDirectInsert; + + activeEditor = editor; +} + +function setup() { + copyQuote.classList.add("balloonTooltip", "interactive", "quoteManagerCopy"); + + const buttonSaveQuote = document.createElement("button"); + buttonSaveQuote.type = "button"; + buttonSaveQuote.classList.add("jsQuoteManagerStore"); + buttonSaveQuote.textContent = getPhrase("wcf.message.quote.quoteSelected"); + buttonSaveQuote.addEventListener("click", () => { + //TODO + }); + copyQuote.appendChild(buttonSaveQuote); + const buttonSaveAndInsertQuote = document.createElement("button"); + buttonSaveAndInsertQuote.type = "button"; + buttonSaveAndInsertQuote.hidden = true; + buttonSaveAndInsertQuote.classList.add("jsQuoteManagerQuoteAndInsert"); + buttonSaveAndInsertQuote.textContent = getPhrase("wcf.message.quote.quoteAndReply"); + buttonSaveAndInsertQuote.addEventListener("click", () => { + //TODO + }); + copyQuote.appendChild(buttonSaveAndInsertQuote); + + document.body.appendChild(copyQuote); + + document.addEventListener("mouseup", (event) => onMouseUp(event)); + document.addEventListener("selectionchange", () => onSelectionchange()); + + // Prevent the tooltip from being selectable while the touch pointer is being moved. + document.addEventListener( + "touchstart", + (event) => { + const target = event.target as HTMLElement; + if (target !== copyQuote && !copyQuote.contains(target)) { + copyQuote.classList.add("touchForceInaccessible"); + + document.addEventListener( + "touchend", + () => { + copyQuote.classList.remove("touchForceInaccessible"); + }, + { once: true, passive: false }, + ); + } + }, + { passive: false }, + ); + + window.addEventListener( + "resize", + () => { + copyQuote.classList.remove("active"); + }, + { passive: true }, + ); +} + +setup(); + +function getSelectedText(): string { + const selection = window.getSelection()!; + if (selection.rangeCount) { + return getNodeText(selection.getRangeAt(0).cloneContents()); + } + + return ""; +} + +/** + * Returns the text of a node and its children. + */ +function getNodeText(node: Node): string { + const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, { + acceptNode(node: Node): number { + if (node.nodeName === "BLOCKQUOTE" || node.nodeName === "SCRIPT") { + return NodeFilter.FILTER_REJECT; + } + + if (node instanceof HTMLImageElement) { + // Skip any image that is not a smiley or contains no alt text. + if (!node.classList.contains("smiley") || !node.alt) { + return NodeFilter.FILTER_REJECT; + } + } + + return NodeFilter.FILTER_ACCEPT; + }, + }); + + let text = ""; + const ignoreLinks: HTMLAnchorElement[] = []; + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode as HTMLElement | Text; + + if (node instanceof Text) { + const parent = node.parentElement!; + if (parent instanceof HTMLAnchorElement && ignoreLinks.includes(parent)) { + // ignore text content of links that have already been captured + continue; + } + + // Firefox loves to arbitrarily wrap pasted text at weird line lengths, causing + // pointless linebreaks to be inserted. Replacing them with a simple space will + // preserve the spacing between words that would otherwise be lost. + text += node.nodeValue!.replace(/\n/g, " "); + + continue; + } + + if (node instanceof HTMLAnchorElement) { + // \u2026 === … + const value = node.textContent!; + if (value.indexOf("\u2026") > 0) { + const tmp = value.split(/\u2026/); + if (tmp.length === 2) { + const href = node.href; + if (href.indexOf(tmp[0]) === 0 && href.substring(tmp[1].length * -1) === tmp[1]) { + // This is a truncated url, use the original href instead to preserve the link. + text += href; + ignoreLinks.push(node); + } + } + } + } + + switch (node.nodeName) { + case "BR": + case "LI": + case "TD": + case "UL": + text += "\n"; + break; + + case "P": + text += "\n\n"; + break; + + // smilies + case "IMG": { + const img = node as HTMLImageElement; + text += ` ${img.alt} `; + break; + } + + // Code listing + case "DIV": + if (node.classList.contains("codeBoxHeadline") || node.classList.contains("codeBoxLine")) { + text += "\n"; + } + break; + } + } + + return text; +} + +function normalizeTextForComparison(text: string): string { + return text + .replace(/\r?\n|\r/g, "\n") + .replace(/\s/g, " ") + .replace(/\s{2,}/g, " "); +} + +function onSelectionchange(): void { + if (isMouseDown) { + return; + } + + if (activeMessageId === "") { + // check if the selection is non-empty and is entirely contained + // inside a single message container that is registered for quoting + const selection = window.getSelection()!; + if (selection.rangeCount !== 1 || selection.isCollapsed) { + return; + } + + const range = selection.getRangeAt(0); + const startContainer = DomUtil.closest(range.startContainer, ".jsQuoteMessageContainer"); + const endContainer = DomUtil.closest(range.endContainer, ".jsQuoteMessageContainer"); + if ( + startContainer && + startContainer === endContainer && + !startContainer.classList.contains("jsInvalidQuoteTarget") + ) { + // Check if the selection is visible, such as text marked inside containers with an + // active overflow handling attached to it. This can be a side effect of the browser + // search which modifies the text selection, but cannot be distinguished from manual + // selections initiated by the user. + let commonAncestor = range.commonAncestorContainer as HTMLElement; + if (commonAncestor.nodeType !== Node.ELEMENT_NODE) { + commonAncestor = commonAncestor.parentElement!; + } + + const offsetParent = commonAncestor.offsetParent!; + if (startContainer.contains(offsetParent)) { + if (offsetParent.scrollTop + offsetParent.clientHeight < commonAncestor.offsetTop) { + // The selected text is not visible to the user. + return; + } + } + + activeMessageId = startContainer.id; + } + } + + if (timerSelectionChange) { + window.clearTimeout(timerSelectionChange); + } + + timerSelectionChange = window.setTimeout(() => onMouseUp(), 100); +} + +function onMouseDown(event: MouseEvent): void { + // hide copy quote + copyQuote.classList.remove("active"); + + const message = event.currentTarget as HTMLElement; + activeMessageId = message.classList.contains("jsInvalidQuoteTarget") ? "" : message.id; + + if (timerSelectionChange) { + window.clearTimeout(timerSelectionChange); + timerSelectionChange = undefined; + } + + isMouseDown = true; +} + +function onMouseUp(event?: MouseEvent): void { + if (event instanceof Event) { + if (timerSelectionChange) { + // Prevent collisions of the `selectionchange` and the `mouseup` event. + window.clearTimeout(timerSelectionChange); + timerSelectionChange = undefined; + } + + isMouseDown = false; + } + + // ignore event + if (activeMessageId === "") { + copyQuote.classList.remove("active"); + + return; + } + + const selection = window.getSelection()!; + if (selection.rangeCount !== 1 || selection.isCollapsed) { + copyQuote.classList.remove("active"); + + return; + } + + const container = containers.get(activeMessageId); + if (container === undefined) { + // Since 5.4 we listen for global mouse events, because those are much + // more reliable on mobile devices. However, this can cause conflicts + // if two or more types of message types with quote support coexist on + // the same page. + return; + } + + const content = container.messageBodySelector + ? (container.element.querySelector(container.messageBodySelector) as HTMLElement) + : container; + + let anchorNode = selection.anchorNode; + while (anchorNode) { + if (anchorNode === content) { + break; + } + + anchorNode = anchorNode.parentNode; + } + + // selection spans unrelated nodes + if (anchorNode !== content) { + copyQuote.classList.remove("active"); + + return; + } + + const selectedText = getSelectedText(); + const text = selectedText.trim(); + if (text === "") { + copyQuote.classList.remove("active"); + + return; + } + + // check if mousedown/mouseup took place inside a blockquote + const range = selection.getRangeAt(0); + const startContainer = DomUtil.getClosestElement(range.startContainer); + const endContainer = DomUtil.getClosestElement(range.endContainer); + if (startContainer.closest("blockquote") || endContainer.closest("blockquote")) { + copyQuote.classList.remove("active"); + + return; + } + + // compare selection with message text of given container + const messageText = getNodeText(content); + + // selected text is not part of $messageText or contains text from unrelated nodes + if (!normalizeTextForComparison(messageText).includes(normalizeTextForComparison(text))) { + return; + } + + copyQuote.classList.add("active"); + const wasInaccessible = copyQuote.classList.contains("touchForceInaccessible"); + if (wasInaccessible) { + copyQuote.classList.remove("touchForceInaccessible"); + } + + setAlignment(copyQuote, endContainer); + + copyQuote.classList.remove("active"); + if (wasInaccessible) { + copyQuote.classList.add("touchForceInaccessible"); + } + + if (!timerSelectionChange) { + // reset containerID + activeMessageId = ""; + } else { + window.clearTimeout(timerSelectionChange); + timerSelectionChange = undefined; + } + + // show element after a delay, to prevent display if text was unmarked again (clicking into marked text) + window.setTimeout(() => { + const text = getSelectedText().trim(); + if (text !== "") { + copyQuote.classList.add("active"); + message = text; + objectId = ~~container.element.dataset.objectId!; + } + }, 10); +} diff --git a/ts/WoltLabSuite/Core/Ui/Message/Quote.ts b/ts/WoltLabSuite/Core/Ui/Message/Quote.ts index 0487723d93..8a2ee18458 100644 --- a/ts/WoltLabSuite/Core/Ui/Message/Quote.ts +++ b/ts/WoltLabSuite/Core/Ui/Message/Quote.ts @@ -1,31 +1,10 @@ /** * @woltlabExcludeBundle tiny + * + * @deprecated 6.2 use `WoltLabSuite/Core/Component/Quote/Message` instead */ -import * as Ajax from "../../Ajax"; -import * as Core from "../../Core"; -import * as EventHandler from "../../Event/Handler"; -import * as Language from "../../Language"; -import DomChangeListener from "../../Dom/Change/Listener"; -import DomUtil from "../../Dom/Util"; -import { AjaxCallbackObject, AjaxCallbackSetup } from "../../Ajax/Data"; - -interface AjaxResponse { - actionName: string; - returnValues: { - count?: number; - fullQuoteMessageIDs?: unknown; - fullQuoteObjectIDs?: unknown; - renderedQuote?: string; - }; -} - -interface ElementBoundaries { - bottom: number; - left: number; - right: number; - top: number; -} +import { registerContainer } from "WoltLabSuite/Core/Component/Quote/Message"; // see WCF.Message.Quote.Manager export interface WCFMessageQuoteManager { @@ -33,31 +12,7 @@ export interface WCFMessageQuoteManager { updateCount: (number, object) => void; } -export class UiMessageQuote implements AjaxCallbackObject { - private activeMessageId = ""; - - private readonly className: string; - - private containers = new Map(); - - private containerSelector = ""; - - private readonly copyQuote = document.createElement("div"); - - private message = ""; - - private readonly messageBodySelector: string; - - private objectId = 0; - - private objectType = ""; - - private timerSelectionChange?: number = undefined; - - private isMouseDown = false; - - private readonly quoteManager: WCFMessageQuoteManager; - +export class UiMessageQuote { /** * Initializes the quote handler for given object type. */ @@ -70,510 +25,7 @@ export class UiMessageQuote implements AjaxCallbackObject { messageContentSelector: string, supportDirectInsert: boolean, ) { - this.className = className; - this.objectType = objectType; - this.containerSelector = containerSelector; - this.messageBodySelector = messageBodySelector; - - this.initContainers(); - - supportDirectInsert = supportDirectInsert && quoteManager.supportPaste(); - this.quoteManager = quoteManager; - this.initCopyQuote(supportDirectInsert); - - document.addEventListener("mouseup", (event) => this.onMouseUp(event)); - document.addEventListener("selectionchange", () => this.onSelectionchange()); - - DomChangeListener.add("UiMessageQuote", () => this.initContainers()); - - // Prevent the tooltip from being selectable while the touch pointer is being moved. - document.addEventListener( - "touchstart", - (event) => { - const target = event.target as HTMLElement; - if (target !== this.copyQuote && !this.copyQuote.contains(target)) { - this.copyQuote.classList.add("touchForceInaccessible"); - - document.addEventListener( - "touchend", - () => { - this.copyQuote.classList.remove("touchForceInaccessible"); - }, - { once: true, passive: false }, - ); - } - }, - { passive: false }, - ); - - window.addEventListener( - "resize", - () => { - this.copyQuote.classList.remove("active"); - }, - { passive: true }, - ); - } - - /** - * Initializes message containers. - */ - private initContainers(): void { - document.querySelectorAll(this.containerSelector).forEach((container: HTMLElement) => { - const id = DomUtil.identify(container); - if (this.containers.has(id)) { - return; - } - - this.containers.set(id, container); - if (container.classList.contains("jsInvalidQuoteTarget")) { - return; - } - - container.addEventListener("mousedown", (event) => this.onMouseDown(event)); - container.classList.add("jsQuoteMessageContainer"); - - container - .querySelector(".jsQuoteMessage") - ?.addEventListener("click", (event: MouseEvent) => this.saveFullQuote(event)); - }); - } - - private onSelectionchange(): void { - if (this.isMouseDown) { - return; - } - - if (this.activeMessageId === "") { - // check if the selection is non-empty and is entirely contained - // inside a single message container that is registered for quoting - const selection = window.getSelection()!; - if (selection.rangeCount !== 1 || selection.isCollapsed) { - return; - } - - const range = selection.getRangeAt(0); - const startContainer = DomUtil.closest(range.startContainer, ".jsQuoteMessageContainer"); - const endContainer = DomUtil.closest(range.endContainer, ".jsQuoteMessageContainer"); - if ( - startContainer && - startContainer === endContainer && - !startContainer.classList.contains("jsInvalidQuoteTarget") - ) { - // Check if the selection is visible, such as text marked inside containers with an - // active overflow handling attached to it. This can be a side effect of the browser - // search which modifies the text selection, but cannot be distinguished from manual - // selections initiated by the user. - let commonAncestor = range.commonAncestorContainer as HTMLElement; - if (commonAncestor.nodeType !== Node.ELEMENT_NODE) { - commonAncestor = commonAncestor.parentElement!; - } - - const offsetParent = commonAncestor.offsetParent!; - if (startContainer.contains(offsetParent)) { - if (offsetParent.scrollTop + offsetParent.clientHeight < commonAncestor.offsetTop) { - // The selected text is not visible to the user. - return; - } - } - - this.activeMessageId = startContainer.id; - } - } - - if (this.timerSelectionChange) { - window.clearTimeout(this.timerSelectionChange); - } - - this.timerSelectionChange = window.setTimeout(() => this.onMouseUp(), 100); - } - - private onMouseDown(event: MouseEvent): void { - // hide copy quote - this.copyQuote.classList.remove("active"); - - const message = event.currentTarget as HTMLElement; - this.activeMessageId = message.classList.contains("jsInvalidQuoteTarget") ? "" : message.id; - - if (this.timerSelectionChange) { - window.clearTimeout(this.timerSelectionChange); - this.timerSelectionChange = undefined; - } - - this.isMouseDown = true; - } - - /** - * Returns the text of a node and its children. - */ - private getNodeText(node: Node): string { - const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, { - acceptNode(node: Node): number { - if (node.nodeName === "BLOCKQUOTE" || node.nodeName === "SCRIPT") { - return NodeFilter.FILTER_REJECT; - } - - if (node instanceof HTMLImageElement) { - // Skip any image that is not a smiley or contains no alt text. - if (!node.classList.contains("smiley") || !node.alt) { - return NodeFilter.FILTER_REJECT; - } - } - - return NodeFilter.FILTER_ACCEPT; - }, - }); - - let text = ""; - const ignoreLinks: HTMLAnchorElement[] = []; - while (treeWalker.nextNode()) { - const node = treeWalker.currentNode as HTMLElement | Text; - - if (node instanceof Text) { - const parent = node.parentElement!; - if (parent instanceof HTMLAnchorElement && ignoreLinks.includes(parent)) { - // ignore text content of links that have already been captured - continue; - } - - // Firefox loves to arbitrarily wrap pasted text at weird line lengths, causing - // pointless linebreaks to be inserted. Replacing them with a simple space will - // preserve the spacing between words that would otherwise be lost. - text += node.nodeValue!.replace(/\n/g, " "); - - continue; - } - - if (node instanceof HTMLAnchorElement) { - // \u2026 === … - const value = node.textContent!; - if (value.indexOf("\u2026") > 0) { - const tmp = value.split(/\u2026/); - if (tmp.length === 2) { - const href = node.href; - if (href.indexOf(tmp[0]) === 0 && href.substr(tmp[1].length * -1) === tmp[1]) { - // This is a truncated url, use the original href instead to preserve the link. - text += href; - ignoreLinks.push(node); - } - } - } - } - - switch (node.nodeName) { - case "BR": - case "LI": - case "TD": - case "UL": - text += "\n"; - break; - - case "P": - text += "\n\n"; - break; - - // smilies - case "IMG": { - const img = node as HTMLImageElement; - text += ` ${img.alt} `; - break; - } - - // Code listing - case "DIV": - if (node.classList.contains("codeBoxHeadline") || node.classList.contains("codeBoxLine")) { - text += "\n"; - } - break; - } - } - - return text; - } - - private onMouseUp(event?: MouseEvent): void { - if (event instanceof Event) { - if (this.timerSelectionChange) { - // Prevent collisions of the `selectionchange` and the `mouseup` event. - window.clearTimeout(this.timerSelectionChange); - this.timerSelectionChange = undefined; - } - - this.isMouseDown = false; - } - - // ignore event - if (this.activeMessageId === "") { - this.copyQuote.classList.remove("active"); - - return; - } - - const selection = window.getSelection()!; - if (selection.rangeCount !== 1 || selection.isCollapsed) { - this.copyQuote.classList.remove("active"); - - return; - } - - const container = this.containers.get(this.activeMessageId); - if (container === undefined) { - // Since 5.4 we listen for global mouse events, because those are much - // more reliable on mobile devices. However, this can cause conflicts - // if two or more types of message types with quote support coexist on - // the same page. - return; - } - - const objectId = ~~container.dataset.objectId!; - const content = this.messageBodySelector - ? (container.querySelector(this.messageBodySelector) as HTMLElement) - : container; - - let anchorNode = selection.anchorNode; - while (anchorNode) { - if (anchorNode === content) { - break; - } - - anchorNode = anchorNode.parentNode; - } - - // selection spans unrelated nodes - if (anchorNode !== content) { - this.copyQuote.classList.remove("active"); - - return; - } - - const selectedText = this.getSelectedText(); - const text = selectedText.trim(); - if (text === "") { - this.copyQuote.classList.remove("active"); - - return; - } - - // check if mousedown/mouseup took place inside a blockquote - const range = selection.getRangeAt(0); - const startContainer = DomUtil.getClosestElement(range.startContainer); - const endContainer = DomUtil.getClosestElement(range.endContainer); - if (startContainer.closest("blockquote") || endContainer.closest("blockquote")) { - this.copyQuote.classList.remove("active"); - - return; - } - - // compare selection with message text of given container - const messageText = this.getNodeText(content); - - // selected text is not part of $messageText or contains text from unrelated nodes - if (!this.normalizeTextForComparison(messageText).includes(this.normalizeTextForComparison(text))) { - return; - } - - this.copyQuote.classList.add("active"); - const wasInaccessible = this.copyQuote.classList.contains("touchForceInaccessible"); - if (wasInaccessible) { - this.copyQuote.classList.remove("touchForceInaccessible"); - } - - const coordinates = this.getElementBoundaries(selection)!; - const dimensions = { height: this.copyQuote.offsetHeight, width: this.copyQuote.offsetWidth }; - let left = (coordinates.right - coordinates.left) / 2 - dimensions.width / 2 + coordinates.left; - - // Prevent the overlay from overflowing the left or right boundary of the container. - const containerBoundaries = content.getBoundingClientRect(); - if (left < containerBoundaries.left) { - left = containerBoundaries.left; - } else if (left + dimensions.width > containerBoundaries.right) { - left = containerBoundaries.right - dimensions.width; - } - - this.copyQuote.style.setProperty("top", `${coordinates.bottom + 7}px`); - this.copyQuote.style.setProperty("left", `${left}px`); - this.copyQuote.classList.remove("active"); - if (wasInaccessible) { - this.copyQuote.classList.add("touchForceInaccessible"); - } - - if (!this.timerSelectionChange) { - // reset containerID - this.activeMessageId = ""; - } else { - window.clearTimeout(this.timerSelectionChange); - this.timerSelectionChange = undefined; - } - - // show element after a delay, to prevent display if text was unmarked again (clicking into marked text) - window.setTimeout(() => { - const text = this.getSelectedText().trim(); - if (text !== "") { - this.copyQuote.classList.add("active"); - this.message = text; - this.objectId = objectId; - } - }, 10); - } - - private normalizeTextForComparison(text: string): string { - return text - .replace(/\r?\n|\r/g, "\n") - .replace(/\s/g, " ") - .replace(/\s{2,}/g, " "); - } - - private getElementBoundaries(selection: Selection): ElementBoundaries | null { - let coordinates: ElementBoundaries | null = null; - - if (selection.rangeCount > 0) { - // The coordinates returned by getBoundingClientRect() are relative to the - // viewport, not the document. - const rect = selection.getRangeAt(0).getBoundingClientRect(); - - const scrollTop = window.pageYOffset; - coordinates = { - bottom: rect.bottom + scrollTop, - left: rect.left, - right: rect.right, - top: rect.top + scrollTop, - }; - } - - return coordinates; - } - - private initCopyQuote(supportDirectInsert: boolean): void { - this.copyQuote.classList.add("balloonTooltip", "interactive", "quoteManagerCopy"); - - const buttonSaveQuote = document.createElement("span"); - buttonSaveQuote.classList.add("jsQuoteManagerStore"); - buttonSaveQuote.textContent = Language.get("wcf.message.quote.quoteSelected"); - buttonSaveQuote.addEventListener("click", (event) => this.saveQuote(event)); - this.copyQuote.appendChild(buttonSaveQuote); - - if (supportDirectInsert) { - const buttonSaveAndInsertQuote = document.createElement("span"); - buttonSaveAndInsertQuote.classList.add("jsQuoteManagerQuoteAndInsert"); - buttonSaveAndInsertQuote.textContent = Language.get("wcf.message.quote.quoteAndReply"); - buttonSaveAndInsertQuote.addEventListener("click", (event) => this.saveAndInsertQuote(event)); - this.copyQuote.appendChild(buttonSaveAndInsertQuote); - } - - document.body.appendChild(this.copyQuote); - } - - private getSelectedText(): string { - const selection = window.getSelection()!; - if (selection.rangeCount) { - return this.getNodeText(selection.getRangeAt(0).cloneContents()); - } - - return ""; - } - - private saveFullQuote(event: MouseEvent): void { - event.preventDefault(); - - const listItem = event.currentTarget as HTMLElement; - - Ajax.api(this, { - actionName: "saveFullQuote", - objectIDs: [listItem.dataset.objectId], - }); - - // mark element as quoted - const quoteLink = listItem.querySelector("a")!; - if (Core.stringToBool(listItem.dataset.isQuoted || "")) { - listItem.dataset.isQuoted = "false"; - quoteLink.classList.remove("active"); - } else { - listItem.dataset.isQuoted = "true"; - quoteLink.classList.add("active"); - } - - // close navigation on mobile - const navigationList: HTMLUListElement | null = listItem.closest(".buttonGroupNavigation"); - if (navigationList && navigationList.classList.contains("jsMobileButtonGroupNavigation")) { - const dropDownLabel = navigationList.querySelector(".dropdownLabel") as HTMLElement; - dropDownLabel.click(); - } - } - - private saveQuote(event?: MouseEvent, renderQuote = false) { - event?.preventDefault(); - - Ajax.api(this, { - actionName: "saveQuote", - objectIDs: [this.objectId], - parameters: { - message: this.message, - renderQuote, - }, - }); - - const selection = window.getSelection()!; - if (selection.rangeCount) { - selection.removeAllRanges(); - this.copyQuote.classList.remove("active"); - } - } - - private saveAndInsertQuote(event: MouseEvent) { - event.preventDefault(); - - this.saveQuote(undefined, true); - } - - _ajaxSuccess(data: AjaxResponse): void { - if (data.returnValues.count !== undefined) { - if (data.returnValues.fullQuoteMessageIDs !== undefined) { - data.returnValues.fullQuoteObjectIDs = data.returnValues.fullQuoteMessageIDs; - } - - const fullQuoteObjectIDs = data.returnValues.fullQuoteObjectIDs || {}; - this.quoteManager.updateCount(data.returnValues.count, fullQuoteObjectIDs); - } - - switch (data.actionName) { - case "saveQuote": - case "saveFullQuote": - if (data.returnValues.renderedQuote) { - EventHandler.fire("com.woltlab.wcf.message.quote", "insert", { - forceInsert: data.actionName === "saveQuote", - quote: data.returnValues.renderedQuote, - }); - } - break; - } - } - - _ajaxSetup(): ReturnType { - return { - data: { - className: this.className, - interfaceName: "wcf\\data\\IMessageQuoteAction", - }, - }; - } - - /** - * Updates the full quote data for all matching objects. - */ - updateFullQuoteObjectIDs(objectIds: number[]): void { - this.containers.forEach((message) => { - const quoteButton = message.querySelector(".jsQuoteMessage") as HTMLLIElement; - quoteButton.dataset.isQuoted = "false"; - - const quoteButtonLink = quoteButton.querySelector("a")!; - quoteButton.classList.remove("active"); - - const objectId = ~~quoteButton.dataset.objectID!; - if (objectIds.includes(objectId)) { - quoteButton.dataset.isQuoted = "true"; - quoteButtonLink.classList.add("active"); - } - }); + registerContainer(containerSelector, messageBodySelector); } } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/List.js index 39105e436a..179fa611ef 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/List.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/List.js @@ -1,4 +1,13 @@ -define(["require", "exports", "tslib", "WoltLabSuite/Core/Core", "WoltLabSuite/Core/Component/Ckeditor/Event", "WoltLabSuite/Core/Component/Message/MessageTabMenu", "WoltLabSuite/Core/Language"], function (require, exports, tslib_1, Core, Event_1, MessageTabMenu_1, Language_1) { +/** + * Handles quotes for CKEditor 5 message fields. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle tiny + */ +define(["require", "exports", "tslib", "WoltLabSuite/Core/Core", "WoltLabSuite/Core/Component/Ckeditor/Event", "WoltLabSuite/Core/Component/Message/MessageTabMenu", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Component/Quote/Message"], function (require, exports, tslib_1, Core, Event_1, MessageTabMenu_1, Language_1, Message_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.STORAGE_KEY = void 0; @@ -49,7 +58,12 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Core", "WoltLabSuite/C (0, Event_1.listenToCkeditor)(editor).ready(({ ckeditor }) => { if (ckeditor.features.quoteBlock) { quoteLists.set(editorId, new QuoteList(editorId, ckeditor)); + (0, Message_1.setActiveEditor)(ckeditor, true); } + else { + (0, Message_1.setActiveEditor)(ckeditor, false); + } + //TODO handle active editor changed }); } }); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/Message.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/Message.js new file mode 100644 index 0000000000..3068f6aad6 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/Message.js @@ -0,0 +1,311 @@ +/** + * Handles quotes selection in messages. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle tiny + */ +define(["require", "exports", "tslib", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Ui/Alignment"], function (require, exports, tslib_1, Util_1, Language_1, Selector_1, Alignment_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.registerContainer = registerContainer; + exports.setActiveEditor = setActiveEditor; + Util_1 = tslib_1.__importDefault(Util_1); + const containers = new Map(); + let activeMessageId = ""; + let message = ""; + let activeEditor = undefined; + let timerSelectionChange = undefined; + let isMouseDown = false; + let objectId = undefined; + const copyQuote = document.createElement("div"); + function registerContainer(containerSelector, messageBodySelector) { + (0, Selector_1.wheneverFirstSeen)(containerSelector, (container) => { + const id = Util_1.default.identify(container); + containers.set(id, { + element: container, + messageBodySelector: messageBodySelector, + }); + if (container.classList.contains("jsInvalidQuoteTarget")) { + return; + } + container.addEventListener("mousedown", (event) => onMouseDown(event)); + container.classList.add("jsQuoteMessageContainer"); + container.querySelector(".jsQuoteMessage")?.addEventListener("click", () => { + //TODO + }); + }); + } + function setActiveEditor(editor, supportDirectInsert) { + copyQuote.querySelector(".jsQuoteManagerQuoteAndInsert").hidden = !supportDirectInsert; + activeEditor = editor; + } + function setup() { + copyQuote.classList.add("balloonTooltip", "interactive", "quoteManagerCopy"); + const buttonSaveQuote = document.createElement("button"); + buttonSaveQuote.type = "button"; + buttonSaveQuote.classList.add("jsQuoteManagerStore"); + buttonSaveQuote.textContent = (0, Language_1.getPhrase)("wcf.message.quote.quoteSelected"); + buttonSaveQuote.addEventListener("click", () => { + //TODO + }); + copyQuote.appendChild(buttonSaveQuote); + const buttonSaveAndInsertQuote = document.createElement("button"); + buttonSaveAndInsertQuote.type = "button"; + buttonSaveAndInsertQuote.hidden = true; + buttonSaveAndInsertQuote.classList.add("jsQuoteManagerQuoteAndInsert"); + buttonSaveAndInsertQuote.textContent = (0, Language_1.getPhrase)("wcf.message.quote.quoteAndReply"); + buttonSaveAndInsertQuote.addEventListener("click", () => { + //TODO + }); + copyQuote.appendChild(buttonSaveAndInsertQuote); + document.body.appendChild(copyQuote); + document.addEventListener("mouseup", (event) => onMouseUp(event)); + document.addEventListener("selectionchange", () => onSelectionchange()); + // Prevent the tooltip from being selectable while the touch pointer is being moved. + document.addEventListener("touchstart", (event) => { + const target = event.target; + if (target !== copyQuote && !copyQuote.contains(target)) { + copyQuote.classList.add("touchForceInaccessible"); + document.addEventListener("touchend", () => { + copyQuote.classList.remove("touchForceInaccessible"); + }, { once: true, passive: false }); + } + }, { passive: false }); + window.addEventListener("resize", () => { + copyQuote.classList.remove("active"); + }, { passive: true }); + } + setup(); + function getSelectedText() { + const selection = window.getSelection(); + if (selection.rangeCount) { + return getNodeText(selection.getRangeAt(0).cloneContents()); + } + return ""; + } + /** + * Returns the text of a node and its children. + */ + function getNodeText(node) { + const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (node.nodeName === "BLOCKQUOTE" || node.nodeName === "SCRIPT") { + return NodeFilter.FILTER_REJECT; + } + if (node instanceof HTMLImageElement) { + // Skip any image that is not a smiley or contains no alt text. + if (!node.classList.contains("smiley") || !node.alt) { + return NodeFilter.FILTER_REJECT; + } + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + let text = ""; + const ignoreLinks = []; + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode; + if (node instanceof Text) { + const parent = node.parentElement; + if (parent instanceof HTMLAnchorElement && ignoreLinks.includes(parent)) { + // ignore text content of links that have already been captured + continue; + } + // Firefox loves to arbitrarily wrap pasted text at weird line lengths, causing + // pointless linebreaks to be inserted. Replacing them with a simple space will + // preserve the spacing between words that would otherwise be lost. + text += node.nodeValue.replace(/\n/g, " "); + continue; + } + if (node instanceof HTMLAnchorElement) { + // \u2026 === … + const value = node.textContent; + if (value.indexOf("\u2026") > 0) { + const tmp = value.split(/\u2026/); + if (tmp.length === 2) { + const href = node.href; + if (href.indexOf(tmp[0]) === 0 && href.substring(tmp[1].length * -1) === tmp[1]) { + // This is a truncated url, use the original href instead to preserve the link. + text += href; + ignoreLinks.push(node); + } + } + } + } + switch (node.nodeName) { + case "BR": + case "LI": + case "TD": + case "UL": + text += "\n"; + break; + case "P": + text += "\n\n"; + break; + // smilies + case "IMG": { + const img = node; + text += ` ${img.alt} `; + break; + } + // Code listing + case "DIV": + if (node.classList.contains("codeBoxHeadline") || node.classList.contains("codeBoxLine")) { + text += "\n"; + } + break; + } + } + return text; + } + function normalizeTextForComparison(text) { + return text + .replace(/\r?\n|\r/g, "\n") + .replace(/\s/g, " ") + .replace(/\s{2,}/g, " "); + } + function onSelectionchange() { + if (isMouseDown) { + return; + } + if (activeMessageId === "") { + // check if the selection is non-empty and is entirely contained + // inside a single message container that is registered for quoting + const selection = window.getSelection(); + if (selection.rangeCount !== 1 || selection.isCollapsed) { + return; + } + const range = selection.getRangeAt(0); + const startContainer = Util_1.default.closest(range.startContainer, ".jsQuoteMessageContainer"); + const endContainer = Util_1.default.closest(range.endContainer, ".jsQuoteMessageContainer"); + if (startContainer && + startContainer === endContainer && + !startContainer.classList.contains("jsInvalidQuoteTarget")) { + // Check if the selection is visible, such as text marked inside containers with an + // active overflow handling attached to it. This can be a side effect of the browser + // search which modifies the text selection, but cannot be distinguished from manual + // selections initiated by the user. + let commonAncestor = range.commonAncestorContainer; + if (commonAncestor.nodeType !== Node.ELEMENT_NODE) { + commonAncestor = commonAncestor.parentElement; + } + const offsetParent = commonAncestor.offsetParent; + if (startContainer.contains(offsetParent)) { + if (offsetParent.scrollTop + offsetParent.clientHeight < commonAncestor.offsetTop) { + // The selected text is not visible to the user. + return; + } + } + activeMessageId = startContainer.id; + } + } + if (timerSelectionChange) { + window.clearTimeout(timerSelectionChange); + } + timerSelectionChange = window.setTimeout(() => onMouseUp(), 100); + } + function onMouseDown(event) { + // hide copy quote + copyQuote.classList.remove("active"); + const message = event.currentTarget; + activeMessageId = message.classList.contains("jsInvalidQuoteTarget") ? "" : message.id; + if (timerSelectionChange) { + window.clearTimeout(timerSelectionChange); + timerSelectionChange = undefined; + } + isMouseDown = true; + } + function onMouseUp(event) { + if (event instanceof Event) { + if (timerSelectionChange) { + // Prevent collisions of the `selectionchange` and the `mouseup` event. + window.clearTimeout(timerSelectionChange); + timerSelectionChange = undefined; + } + isMouseDown = false; + } + // ignore event + if (activeMessageId === "") { + copyQuote.classList.remove("active"); + return; + } + const selection = window.getSelection(); + if (selection.rangeCount !== 1 || selection.isCollapsed) { + copyQuote.classList.remove("active"); + return; + } + const container = containers.get(activeMessageId); + if (container === undefined) { + // Since 5.4 we listen for global mouse events, because those are much + // more reliable on mobile devices. However, this can cause conflicts + // if two or more types of message types with quote support coexist on + // the same page. + return; + } + const content = container.messageBodySelector + ? container.element.querySelector(container.messageBodySelector) + : container; + let anchorNode = selection.anchorNode; + while (anchorNode) { + if (anchorNode === content) { + break; + } + anchorNode = anchorNode.parentNode; + } + // selection spans unrelated nodes + if (anchorNode !== content) { + copyQuote.classList.remove("active"); + return; + } + const selectedText = getSelectedText(); + const text = selectedText.trim(); + if (text === "") { + copyQuote.classList.remove("active"); + return; + } + // check if mousedown/mouseup took place inside a blockquote + const range = selection.getRangeAt(0); + const startContainer = Util_1.default.getClosestElement(range.startContainer); + const endContainer = Util_1.default.getClosestElement(range.endContainer); + if (startContainer.closest("blockquote") || endContainer.closest("blockquote")) { + copyQuote.classList.remove("active"); + return; + } + // compare selection with message text of given container + const messageText = getNodeText(content); + // selected text is not part of $messageText or contains text from unrelated nodes + if (!normalizeTextForComparison(messageText).includes(normalizeTextForComparison(text))) { + return; + } + copyQuote.classList.add("active"); + const wasInaccessible = copyQuote.classList.contains("touchForceInaccessible"); + if (wasInaccessible) { + copyQuote.classList.remove("touchForceInaccessible"); + } + (0, Alignment_1.set)(copyQuote, endContainer); + copyQuote.classList.remove("active"); + if (wasInaccessible) { + copyQuote.classList.add("touchForceInaccessible"); + } + if (!timerSelectionChange) { + // reset containerID + activeMessageId = ""; + } + else { + window.clearTimeout(timerSelectionChange); + timerSelectionChange = undefined; + } + // show element after a delay, to prevent display if text was unmarked again (clicking into marked text) + window.setTimeout(() => { + const text = getSelectedText().trim(); + if (text !== "") { + copyQuote.classList.add("active"); + message = text; + objectId = ~~container.element.dataset.objectId; + } + }, 10); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Quote.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Quote.js index b094092560..fa5784e730 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Quote.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Quote.js @@ -1,439 +1,18 @@ /** * @woltlabExcludeBundle tiny + * + * @deprecated 6.2 use `WoltLabSuite/Core/Component/Quote/Message` instead */ -define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Event/Handler", "../../Language", "../../Dom/Change/Listener", "../../Dom/Util"], function (require, exports, tslib_1, Ajax, Core, EventHandler, Language, Listener_1, Util_1) { +define(["require", "exports", "WoltLabSuite/Core/Component/Quote/Message"], function (require, exports, Message_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.UiMessageQuote = void 0; - Ajax = tslib_1.__importStar(Ajax); - Core = tslib_1.__importStar(Core); - EventHandler = tslib_1.__importStar(EventHandler); - Language = tslib_1.__importStar(Language); - Listener_1 = tslib_1.__importDefault(Listener_1); - Util_1 = tslib_1.__importDefault(Util_1); class UiMessageQuote { - activeMessageId = ""; - className; - containers = new Map(); - containerSelector = ""; - copyQuote = document.createElement("div"); - message = ""; - messageBodySelector; - objectId = 0; - objectType = ""; - timerSelectionChange = undefined; - isMouseDown = false; - quoteManager; /** * Initializes the quote handler for given object type. */ constructor(quoteManager, className, objectType, containerSelector, messageBodySelector, messageContentSelector, supportDirectInsert) { - this.className = className; - this.objectType = objectType; - this.containerSelector = containerSelector; - this.messageBodySelector = messageBodySelector; - this.initContainers(); - supportDirectInsert = supportDirectInsert && quoteManager.supportPaste(); - this.quoteManager = quoteManager; - this.initCopyQuote(supportDirectInsert); - document.addEventListener("mouseup", (event) => this.onMouseUp(event)); - document.addEventListener("selectionchange", () => this.onSelectionchange()); - Listener_1.default.add("UiMessageQuote", () => this.initContainers()); - // Prevent the tooltip from being selectable while the touch pointer is being moved. - document.addEventListener("touchstart", (event) => { - const target = event.target; - if (target !== this.copyQuote && !this.copyQuote.contains(target)) { - this.copyQuote.classList.add("touchForceInaccessible"); - document.addEventListener("touchend", () => { - this.copyQuote.classList.remove("touchForceInaccessible"); - }, { once: true, passive: false }); - } - }, { passive: false }); - window.addEventListener("resize", () => { - this.copyQuote.classList.remove("active"); - }, { passive: true }); - } - /** - * Initializes message containers. - */ - initContainers() { - document.querySelectorAll(this.containerSelector).forEach((container) => { - const id = Util_1.default.identify(container); - if (this.containers.has(id)) { - return; - } - this.containers.set(id, container); - if (container.classList.contains("jsInvalidQuoteTarget")) { - return; - } - container.addEventListener("mousedown", (event) => this.onMouseDown(event)); - container.classList.add("jsQuoteMessageContainer"); - container - .querySelector(".jsQuoteMessage") - ?.addEventListener("click", (event) => this.saveFullQuote(event)); - }); - } - onSelectionchange() { - if (this.isMouseDown) { - return; - } - if (this.activeMessageId === "") { - // check if the selection is non-empty and is entirely contained - // inside a single message container that is registered for quoting - const selection = window.getSelection(); - if (selection.rangeCount !== 1 || selection.isCollapsed) { - return; - } - const range = selection.getRangeAt(0); - const startContainer = Util_1.default.closest(range.startContainer, ".jsQuoteMessageContainer"); - const endContainer = Util_1.default.closest(range.endContainer, ".jsQuoteMessageContainer"); - if (startContainer && - startContainer === endContainer && - !startContainer.classList.contains("jsInvalidQuoteTarget")) { - // Check if the selection is visible, such as text marked inside containers with an - // active overflow handling attached to it. This can be a side effect of the browser - // search which modifies the text selection, but cannot be distinguished from manual - // selections initiated by the user. - let commonAncestor = range.commonAncestorContainer; - if (commonAncestor.nodeType !== Node.ELEMENT_NODE) { - commonAncestor = commonAncestor.parentElement; - } - const offsetParent = commonAncestor.offsetParent; - if (startContainer.contains(offsetParent)) { - if (offsetParent.scrollTop + offsetParent.clientHeight < commonAncestor.offsetTop) { - // The selected text is not visible to the user. - return; - } - } - this.activeMessageId = startContainer.id; - } - } - if (this.timerSelectionChange) { - window.clearTimeout(this.timerSelectionChange); - } - this.timerSelectionChange = window.setTimeout(() => this.onMouseUp(), 100); - } - onMouseDown(event) { - // hide copy quote - this.copyQuote.classList.remove("active"); - const message = event.currentTarget; - this.activeMessageId = message.classList.contains("jsInvalidQuoteTarget") ? "" : message.id; - if (this.timerSelectionChange) { - window.clearTimeout(this.timerSelectionChange); - this.timerSelectionChange = undefined; - } - this.isMouseDown = true; - } - /** - * Returns the text of a node and its children. - */ - getNodeText(node) { - const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, { - acceptNode(node) { - if (node.nodeName === "BLOCKQUOTE" || node.nodeName === "SCRIPT") { - return NodeFilter.FILTER_REJECT; - } - if (node instanceof HTMLImageElement) { - // Skip any image that is not a smiley or contains no alt text. - if (!node.classList.contains("smiley") || !node.alt) { - return NodeFilter.FILTER_REJECT; - } - } - return NodeFilter.FILTER_ACCEPT; - }, - }); - let text = ""; - const ignoreLinks = []; - while (treeWalker.nextNode()) { - const node = treeWalker.currentNode; - if (node instanceof Text) { - const parent = node.parentElement; - if (parent instanceof HTMLAnchorElement && ignoreLinks.includes(parent)) { - // ignore text content of links that have already been captured - continue; - } - // Firefox loves to arbitrarily wrap pasted text at weird line lengths, causing - // pointless linebreaks to be inserted. Replacing them with a simple space will - // preserve the spacing between words that would otherwise be lost. - text += node.nodeValue.replace(/\n/g, " "); - continue; - } - if (node instanceof HTMLAnchorElement) { - // \u2026 === … - const value = node.textContent; - if (value.indexOf("\u2026") > 0) { - const tmp = value.split(/\u2026/); - if (tmp.length === 2) { - const href = node.href; - if (href.indexOf(tmp[0]) === 0 && href.substr(tmp[1].length * -1) === tmp[1]) { - // This is a truncated url, use the original href instead to preserve the link. - text += href; - ignoreLinks.push(node); - } - } - } - } - switch (node.nodeName) { - case "BR": - case "LI": - case "TD": - case "UL": - text += "\n"; - break; - case "P": - text += "\n\n"; - break; - // smilies - case "IMG": { - const img = node; - text += ` ${img.alt} `; - break; - } - // Code listing - case "DIV": - if (node.classList.contains("codeBoxHeadline") || node.classList.contains("codeBoxLine")) { - text += "\n"; - } - break; - } - } - return text; - } - onMouseUp(event) { - if (event instanceof Event) { - if (this.timerSelectionChange) { - // Prevent collisions of the `selectionchange` and the `mouseup` event. - window.clearTimeout(this.timerSelectionChange); - this.timerSelectionChange = undefined; - } - this.isMouseDown = false; - } - // ignore event - if (this.activeMessageId === "") { - this.copyQuote.classList.remove("active"); - return; - } - const selection = window.getSelection(); - if (selection.rangeCount !== 1 || selection.isCollapsed) { - this.copyQuote.classList.remove("active"); - return; - } - const container = this.containers.get(this.activeMessageId); - if (container === undefined) { - // Since 5.4 we listen for global mouse events, because those are much - // more reliable on mobile devices. However, this can cause conflicts - // if two or more types of message types with quote support coexist on - // the same page. - return; - } - const objectId = ~~container.dataset.objectId; - const content = this.messageBodySelector - ? container.querySelector(this.messageBodySelector) - : container; - let anchorNode = selection.anchorNode; - while (anchorNode) { - if (anchorNode === content) { - break; - } - anchorNode = anchorNode.parentNode; - } - // selection spans unrelated nodes - if (anchorNode !== content) { - this.copyQuote.classList.remove("active"); - return; - } - const selectedText = this.getSelectedText(); - const text = selectedText.trim(); - if (text === "") { - this.copyQuote.classList.remove("active"); - return; - } - // check if mousedown/mouseup took place inside a blockquote - const range = selection.getRangeAt(0); - const startContainer = Util_1.default.getClosestElement(range.startContainer); - const endContainer = Util_1.default.getClosestElement(range.endContainer); - if (startContainer.closest("blockquote") || endContainer.closest("blockquote")) { - this.copyQuote.classList.remove("active"); - return; - } - // compare selection with message text of given container - const messageText = this.getNodeText(content); - // selected text is not part of $messageText or contains text from unrelated nodes - if (!this.normalizeTextForComparison(messageText).includes(this.normalizeTextForComparison(text))) { - return; - } - this.copyQuote.classList.add("active"); - const wasInaccessible = this.copyQuote.classList.contains("touchForceInaccessible"); - if (wasInaccessible) { - this.copyQuote.classList.remove("touchForceInaccessible"); - } - const coordinates = this.getElementBoundaries(selection); - const dimensions = { height: this.copyQuote.offsetHeight, width: this.copyQuote.offsetWidth }; - let left = (coordinates.right - coordinates.left) / 2 - dimensions.width / 2 + coordinates.left; - // Prevent the overlay from overflowing the left or right boundary of the container. - const containerBoundaries = content.getBoundingClientRect(); - if (left < containerBoundaries.left) { - left = containerBoundaries.left; - } - else if (left + dimensions.width > containerBoundaries.right) { - left = containerBoundaries.right - dimensions.width; - } - this.copyQuote.style.setProperty("top", `${coordinates.bottom + 7}px`); - this.copyQuote.style.setProperty("left", `${left}px`); - this.copyQuote.classList.remove("active"); - if (wasInaccessible) { - this.copyQuote.classList.add("touchForceInaccessible"); - } - if (!this.timerSelectionChange) { - // reset containerID - this.activeMessageId = ""; - } - else { - window.clearTimeout(this.timerSelectionChange); - this.timerSelectionChange = undefined; - } - // show element after a delay, to prevent display if text was unmarked again (clicking into marked text) - window.setTimeout(() => { - const text = this.getSelectedText().trim(); - if (text !== "") { - this.copyQuote.classList.add("active"); - this.message = text; - this.objectId = objectId; - } - }, 10); - } - normalizeTextForComparison(text) { - return text - .replace(/\r?\n|\r/g, "\n") - .replace(/\s/g, " ") - .replace(/\s{2,}/g, " "); - } - getElementBoundaries(selection) { - let coordinates = null; - if (selection.rangeCount > 0) { - // The coordinates returned by getBoundingClientRect() are relative to the - // viewport, not the document. - const rect = selection.getRangeAt(0).getBoundingClientRect(); - const scrollTop = window.pageYOffset; - coordinates = { - bottom: rect.bottom + scrollTop, - left: rect.left, - right: rect.right, - top: rect.top + scrollTop, - }; - } - return coordinates; - } - initCopyQuote(supportDirectInsert) { - this.copyQuote.classList.add("balloonTooltip", "interactive", "quoteManagerCopy"); - const buttonSaveQuote = document.createElement("span"); - buttonSaveQuote.classList.add("jsQuoteManagerStore"); - buttonSaveQuote.textContent = Language.get("wcf.message.quote.quoteSelected"); - buttonSaveQuote.addEventListener("click", (event) => this.saveQuote(event)); - this.copyQuote.appendChild(buttonSaveQuote); - if (supportDirectInsert) { - const buttonSaveAndInsertQuote = document.createElement("span"); - buttonSaveAndInsertQuote.classList.add("jsQuoteManagerQuoteAndInsert"); - buttonSaveAndInsertQuote.textContent = Language.get("wcf.message.quote.quoteAndReply"); - buttonSaveAndInsertQuote.addEventListener("click", (event) => this.saveAndInsertQuote(event)); - this.copyQuote.appendChild(buttonSaveAndInsertQuote); - } - document.body.appendChild(this.copyQuote); - } - getSelectedText() { - const selection = window.getSelection(); - if (selection.rangeCount) { - return this.getNodeText(selection.getRangeAt(0).cloneContents()); - } - return ""; - } - saveFullQuote(event) { - event.preventDefault(); - const listItem = event.currentTarget; - Ajax.api(this, { - actionName: "saveFullQuote", - objectIDs: [listItem.dataset.objectId], - }); - // mark element as quoted - const quoteLink = listItem.querySelector("a"); - if (Core.stringToBool(listItem.dataset.isQuoted || "")) { - listItem.dataset.isQuoted = "false"; - quoteLink.classList.remove("active"); - } - else { - listItem.dataset.isQuoted = "true"; - quoteLink.classList.add("active"); - } - // close navigation on mobile - const navigationList = listItem.closest(".buttonGroupNavigation"); - if (navigationList && navigationList.classList.contains("jsMobileButtonGroupNavigation")) { - const dropDownLabel = navigationList.querySelector(".dropdownLabel"); - dropDownLabel.click(); - } - } - saveQuote(event, renderQuote = false) { - event?.preventDefault(); - Ajax.api(this, { - actionName: "saveQuote", - objectIDs: [this.objectId], - parameters: { - message: this.message, - renderQuote, - }, - }); - const selection = window.getSelection(); - if (selection.rangeCount) { - selection.removeAllRanges(); - this.copyQuote.classList.remove("active"); - } - } - saveAndInsertQuote(event) { - event.preventDefault(); - this.saveQuote(undefined, true); - } - _ajaxSuccess(data) { - if (data.returnValues.count !== undefined) { - if (data.returnValues.fullQuoteMessageIDs !== undefined) { - data.returnValues.fullQuoteObjectIDs = data.returnValues.fullQuoteMessageIDs; - } - const fullQuoteObjectIDs = data.returnValues.fullQuoteObjectIDs || {}; - this.quoteManager.updateCount(data.returnValues.count, fullQuoteObjectIDs); - } - switch (data.actionName) { - case "saveQuote": - case "saveFullQuote": - if (data.returnValues.renderedQuote) { - EventHandler.fire("com.woltlab.wcf.message.quote", "insert", { - forceInsert: data.actionName === "saveQuote", - quote: data.returnValues.renderedQuote, - }); - } - break; - } - } - _ajaxSetup() { - return { - data: { - className: this.className, - interfaceName: "wcf\\data\\IMessageQuoteAction", - }, - }; - } - /** - * Updates the full quote data for all matching objects. - */ - updateFullQuoteObjectIDs(objectIds) { - this.containers.forEach((message) => { - const quoteButton = message.querySelector(".jsQuoteMessage"); - quoteButton.dataset.isQuoted = "false"; - const quoteButtonLink = quoteButton.querySelector("a"); - quoteButton.classList.remove("active"); - const objectId = ~~quoteButton.dataset.objectID; - if (objectIds.includes(objectId)) { - quoteButton.dataset.isQuoted = "true"; - quoteButtonLink.classList.add("active"); - } - }); + (0, Message_1.registerContainer)(containerSelector, messageBodySelector); } } exports.UiMessageQuote = UiMessageQuote; diff --git a/wcfsetup/install/files/style/ui/tooltip.scss b/wcfsetup/install/files/style/ui/tooltip.scss index 202c3db064..ef2ccf7a1a 100644 --- a/wcfsetup/install/files/style/ui/tooltip.scss +++ b/wcfsetup/install/files/style/ui/tooltip.scss @@ -26,7 +26,7 @@ &.interactive { pointer-events: all; - > span { + > span, button { cursor: pointer; &:not(:first-child) {