diff --git a/com.woltlab.wcf/coreObject.xml b/com.woltlab.wcf/coreObject.xml index 835d5b2f4e3..b2510101672 100644 --- a/com.woltlab.wcf/coreObject.xml +++ b/com.woltlab.wcf/coreObject.xml @@ -55,6 +55,9 @@ wcf\system\file\upload\UploadHandler + + wcf\system\message\quote\MessageQuoteManager + diff --git a/com.woltlab.wcf/objectTypeDefinition.xml b/com.woltlab.wcf/objectTypeDefinition.xml index 775710634fd..5887bd8a1d9 100644 --- a/com.woltlab.wcf/objectTypeDefinition.xml +++ b/com.woltlab.wcf/objectTypeDefinition.xml @@ -27,7 +27,6 @@ com.woltlab.wcf.message.quote - wcf\system\message\quote\IMessageQuoteHandler com.woltlab.wcf.user.recentActivityEvent diff --git a/com.woltlab.wcf/templates/__messageFormQuote.tpl b/com.woltlab.wcf/templates/__messageFormQuote.tpl new file mode 100644 index 00000000000..54fb4352fea --- /dev/null +++ b/com.woltlab.wcf/templates/__messageFormQuote.tpl @@ -0,0 +1,8 @@ +
+ + diff --git a/com.woltlab.wcf/templates/headIncludeJavaScript.tpl b/com.woltlab.wcf/templates/headIncludeJavaScript.tpl index 4c72ec8b218..12170f5a41b 100644 --- a/com.woltlab.wcf/templates/headIncludeJavaScript.tpl +++ b/com.woltlab.wcf/templates/headIncludeJavaScript.tpl @@ -101,7 +101,12 @@ window.addEventListener('pageshow', function(event) { {event name='javascriptShareButtonProviders'} ], {/if} - styleChanger: {if $__wcf->getStyleHandler()->showStyleChanger()}true{else}false{/if} + styleChanger: {if $__wcf->getStyleHandler()->showStyleChanger()}true{else}false{/if}, + {if $__wcf->user->userID && !$__wcf->getMessageQuoteManager()->getRemoveQuoteIDs()|empty}removeQuotes: [{implode from=$__wcf->getMessageQuoteManager()->getRemoveQuoteIDs() item=uuid}'{$uuid|encodeJS}'{/implode}],{/if} + {if $__wcf->user->userID && !$__wcf->getMessageQuoteManager()->getUsedQuotes()|empty}usedQuotes: new Map([ + {foreach from=$__wcf->getMessageQuoteManager()->getUsedQuotes() key=editorID item=uuids}['{$editorID|encodeJS}', [{implode from=$uuids item=uuid}'{$uuid|encodeJS}'{/implode}]]{/foreach} + ]), + {/if} }); }); diff --git a/com.woltlab.wcf/templates/messageFormTabs.tpl b/com.woltlab.wcf/templates/messageFormTabs.tpl index bfc4726dc0d..c17ae4efc95 100644 --- a/com.woltlab.wcf/templates/messageFormTabs.tpl +++ b/com.woltlab.wcf/templates/messageFormTabs.tpl @@ -41,6 +41,13 @@ {/if} {event name='tabMenuTabs'} + + @@ -53,4 +60,6 @@ {include file='__messageFormPoll'} {event name='tabMenuContents'} - \ No newline at end of file + + {include file='__messageFormQuote'} + diff --git a/com.woltlab.wcf/templates/messageFormTabsInline.tpl b/com.woltlab.wcf/templates/messageFormTabsInline.tpl index 71c23dbcb20..d11d8a6a92d 100644 --- a/com.woltlab.wcf/templates/messageFormTabsInline.tpl +++ b/com.woltlab.wcf/templates/messageFormTabsInline.tpl @@ -44,6 +44,13 @@ {/if} {event name='tabMenuTabs'} + + @@ -57,4 +64,6 @@ {include file='__messageFormPollInline'} {event name='tabMenuContents'} - \ No newline at end of file + + {include file='__messageFormQuote'} + diff --git a/com.woltlab.wcf/templates/shared_messageQuoteManager.tpl b/com.woltlab.wcf/templates/shared_messageQuoteManager.tpl index e8c9c7b0da3..55d98b49470 100644 --- a/com.woltlab.wcf/templates/shared_messageQuoteManager.tpl +++ b/com.woltlab.wcf/templates/shared_messageQuoteManager.tpl @@ -1,14 +1,2 @@ -WCF.Language.addObject({ - 'wcf.message.quote.insertAllQuotes': '{jslang}wcf.message.quote.insertAllQuotes{/jslang}', - 'wcf.message.quote.insertSelectedQuotes': '{jslang}wcf.message.quote.insertSelectedQuotes{/jslang}', - 'wcf.message.quote.manageQuotes': '{jslang}wcf.message.quote.manageQuotes{/jslang}', - 'wcf.message.quote.quoteSelected': '{jslang}wcf.message.quote.quoteSelected{/jslang}', - 'wcf.message.quote.quoteAndReply': '{jslang}wcf.message.quote.quoteAndReply{/jslang}', - 'wcf.message.quote.removeAllQuotes': '{jslang}wcf.message.quote.removeAllQuotes{/jslang}', - 'wcf.message.quote.removeSelectedQuotes': '{jslang}wcf.message.quote.removeSelectedQuotes{/jslang}', - 'wcf.message.quote.showQuotes': '{jslang __literal=true}wcf.message.quote.showQuotes{/jslang}' -}); - -{if !$wysiwygSelector|isset}{assign var=wysiwygSelector value=''}{/if} -{if !$supportPaste|isset}{assign var=supportPaste value=false}{/if} -var $quoteManager = new WCF.Message.Quote.Manager({@$__quoteCount}, '{$wysiwygSelector|encodeJS}', {if $supportPaste}true{else}false{/if}, [ {implode from=$__quoteRemove item=quoteID}'{$quoteID}'{/implode} ]); +{* Deprecated since 6.2 *} +var $quoteManager = undefined; diff --git a/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl b/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl index 46eacccdd54..b95afd3b631 100644 --- a/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl +++ b/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl @@ -14,26 +14,3 @@ *}>{$field->getValue()} {include file='shared_wysiwyg' wysiwygSelector=$field->getPrefixedId()} - -{if $field->supportsQuotes()} - -{/if} diff --git a/com.woltlab.wcf/templates/shared_wysiwygQuoteFormContainer.tpl b/com.woltlab.wcf/templates/shared_wysiwygQuoteFormContainer.tpl new file mode 100644 index 00000000000..94d92f3fda6 --- /dev/null +++ b/com.woltlab.wcf/templates/shared_wysiwygQuoteFormContainer.tpl @@ -0,0 +1,16 @@ +
+ + + +{include file='shared_formContainerDependencies'} + + diff --git a/com.woltlab.wcf/templates/shared_wysiwygTabFormContainer.tpl b/com.woltlab.wcf/templates/shared_wysiwygTabFormContainer.tpl new file mode 100644 index 00000000000..a03a8a84652 --- /dev/null +++ b/com.woltlab.wcf/templates/shared_wysiwygTabFormContainer.tpl @@ -0,0 +1,15 @@ +
getClasses()|empty} class="{implode from=$container->getClasses() item='class' glue=' '}{$class}{/implode}"{/if}{* + *}{foreach from=$container->getAttributes() key='attributeName' item='attributeValue'} {$attributeName}="{$attributeValue}"{/foreach}{* + *}{if !$container->checkDependencies()} hidden{/if}{* +*}> + {include file='shared_formContainerChildren'} +
+ +{include file='shared_formContainerDependencies'} + + diff --git a/com.woltlab.wcf/templates/shared_wysiwygTabMenuFormContainer.tpl b/com.woltlab.wcf/templates/shared_wysiwygTabMenuFormContainer.tpl index 62d4b60d46e..7c6ce1313f0 100644 --- a/com.woltlab.wcf/templates/shared_wysiwygTabMenuFormContainer.tpl +++ b/com.woltlab.wcf/templates/shared_wysiwygTabMenuFormContainer.tpl @@ -1 +1,29 @@ -{include file='shared_tabMenuFormContainer' __tabMenuCSSClassName='messageTabMenuNavigation'} +
getClasses()|empty} class="{implode from=$container->getClasses() item='class' glue=' '}{$class}{/implode}"{/if}{* + *}{foreach from=$container->getAttributes() key='attributeName' item='attributeValue'} {$attributeName}="{$attributeValue}"{/foreach}{* + *}{if !$container->checkDependencies()} hidden{/if}> + + + {include file='shared_formContainerChildren'} +
+ +{include file='shared_formContainerDependencies'} + + diff --git a/ts/WoltLabSuite/Core/Api/Messages/Author.ts b/ts/WoltLabSuite/Core/Api/Messages/Author.ts new file mode 100644 index 00000000000..593256b4466 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Messages/Author.ts @@ -0,0 +1,37 @@ +/** + * Requests render a full quote of a message. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle tiny + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; + +type Response = { + objectID: number; + authorID: number; + author: string; + time: string; + title: string; + link: string; + avatar: string; +}; + +export async function messageAuthor(className: string, objectID: number): Promise> { + const url = new URL(window.WSC_RPC_API_URL + "core/messages/message-author"); + url.searchParams.set("className", className); + url.searchParams.set("objectID", objectID.toString()); + + let response: Response; + try { + response = (await prepareRequest(url).get().allowCaching().fetchAsJson()) as Response; + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue(response); +} diff --git a/ts/WoltLabSuite/Core/Api/Messages/RenderQuote.ts b/ts/WoltLabSuite/Core/Api/Messages/RenderQuote.ts new file mode 100644 index 00000000000..f426872ea73 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Messages/RenderQuote.ts @@ -0,0 +1,45 @@ +/** + * Requests render a full quote of a message. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle tiny + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; + +type Response = { + objectID: number; + authorID: number | null; + author: string; + time: string; + link: string; + title: string; + avatar: string; + message: string | null; + rawMessage: string | null; +}; + +export async function renderQuote( + objectType: string, + className: string, + objectID: number, +): Promise> { + const url = new URL(window.WSC_RPC_API_URL + "core/messages/render-quote"); + url.searchParams.set("objectType", objectType); + url.searchParams.set("className", className); + url.searchParams.set("fullQuote", "true"); + url.searchParams.set("objectID", objectID.toString()); + + let response: Response; + try { + response = (await prepareRequest(url).get().fetchAsJson()) as Response; + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue(response); +} diff --git a/ts/WoltLabSuite/Core/Api/Messages/ResetRemovalQuotes.ts b/ts/WoltLabSuite/Core/Api/Messages/ResetRemovalQuotes.ts new file mode 100644 index 00000000000..9f887667402 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Messages/ResetRemovalQuotes.ts @@ -0,0 +1,24 @@ +/** + * Requests to reset the removal quotes. + * + * @author Olaf Braun + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle tiny + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; + +export async function resetRemovalQuotes(): Promise> { + const url = new URL(window.WSC_RPC_API_URL + "core/messages/reset-removal-quotes"); + + try { + await prepareRequest(url).post().fetchAsJson(); + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue([]); +} diff --git a/ts/WoltLabSuite/Core/BootstrapFrontend.ts b/ts/WoltLabSuite/Core/BootstrapFrontend.ts index efc594736f8..e599227d8fb 100644 --- a/ts/WoltLabSuite/Core/BootstrapFrontend.ts +++ b/ts/WoltLabSuite/Core/BootstrapFrontend.ts @@ -37,6 +37,8 @@ interface BootstrapOptions { executeCronjobs: string | undefined; shareButtonProviders?: ShareProvider[]; styleChanger: boolean; + removeQuotes?: string[]; + usedQuotes?: Map; } /** @@ -86,6 +88,20 @@ export function setup(options: BootstrapOptions): void { enableMobileMenu: true, pageMenuMainProvider: new UiPageMenuMainFrontend(), }); + + if (options.removeQuotes?.length) { + void import("./Component/Quote/Storage").then(({ removeQuotes }) => removeQuotes(options.removeQuotes!)); + } + if (options.usedQuotes?.size) { + void import("./Component/Quote/Storage").then(({ markQuoteAsUsed }) => { + options.usedQuotes!.forEach((uuids, editorId) => { + for (const uuid of uuids) { + markQuoteAsUsed(editorId, uuid); + } + }); + }); + } + UiPageHeaderMenu.init(); if (options.styleChanger) { diff --git a/ts/WoltLabSuite/Core/Component/Ckeditor.ts b/ts/WoltLabSuite/Core/Component/Ckeditor.ts index 06bfe16affb..efa2dda1983 100644 --- a/ts/WoltLabSuite/Core/Component/Ckeditor.ts +++ b/ts/WoltLabSuite/Core/Component/Ckeditor.ts @@ -163,6 +163,10 @@ class Ckeditor { get sourceElement(): HTMLElement { return this.#editor.sourceElement!; } + + get focusTracker(): CKEditor5.Utils.FocusTracker { + return this.#editor.ui.focusTracker; + } } function* findModelForRemoval( diff --git a/ts/WoltLabSuite/Core/Component/Comment/Add.ts b/ts/WoltLabSuite/Core/Component/Comment/Add.ts index 988d39f0772..e74ec184e8e 100644 --- a/ts/WoltLabSuite/Core/Component/Comment/Add.ts +++ b/ts/WoltLabSuite/Core/Component/Comment/Add.ts @@ -17,6 +17,8 @@ import { listenToCkeditor } from "../Ckeditor/Event"; import { createComment } from "WoltLabSuite/Core/Api/Comments/CreateComment"; import { getGuestToken } from "../GuestTokenDialog"; import User from "WoltLabSuite/Core/User"; +import { clearQuotesForEditor } from "WoltLabSuite/Core/Component/Quote/Storage"; +import { setActiveEditor } from "WoltLabSuite/Core/Component/Quote/Message"; type CallbackInsertComment = (commentId: number) => void; @@ -180,6 +182,8 @@ export class CommentAdd { */ #reset(): void { this.#getEditor().reset(); + clearQuotesForEditor(this.#getEditor().sourceElement.id); + setActiveEditor(); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); diff --git a/ts/WoltLabSuite/Core/Component/Comment/Response/Add.ts b/ts/WoltLabSuite/Core/Component/Comment/Response/Add.ts index 3d80c75ca19..b7d7dfe4380 100644 --- a/ts/WoltLabSuite/Core/Component/Comment/Response/Add.ts +++ b/ts/WoltLabSuite/Core/Component/Comment/Response/Add.ts @@ -17,6 +17,8 @@ import { listenToCkeditor } from "../../Ckeditor/Event"; import User from "WoltLabSuite/Core/User"; import { getGuestToken } from "../../GuestTokenDialog"; import { createResponse } from "WoltLabSuite/Core/Api/Comments/Responses/CreateResponse"; +import { clearQuotesForEditor } from "WoltLabSuite/Core/Component/Quote/Storage"; +import { setActiveEditor } from "WoltLabSuite/Core/Component/Quote/Message"; type CallbackInsertResponse = (commentId: number, responseId: number) => void; @@ -131,6 +133,8 @@ export class CommentResponseAdd { */ #reset(): void { this.#getEditor().reset(); + clearQuotesForEditor(this.#getEditor().sourceElement.id); + setActiveEditor(); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); diff --git a/ts/WoltLabSuite/Core/Component/Message/MessageTabMenu.ts b/ts/WoltLabSuite/Core/Component/Message/MessageTabMenu.ts index 0d3d4d75796..afa7deefe26 100644 --- a/ts/WoltLabSuite/Core/Component/Message/MessageTabMenu.ts +++ b/ts/WoltLabSuite/Core/Component/Message/MessageTabMenu.ts @@ -65,6 +65,37 @@ class TabMenu { this.#activeTabName = tabName; } + showTab(tabName: string): void { + const tab = this.#tabs.find((element) => element.dataset.name === tabName); + if (tab === undefined) { + return; + } + + tab.hidden = false; + } + + hideTab(tabName: string): void { + const tab = this.#tabs.find((element) => element.dataset.name === tabName); + if (tab === undefined) { + return; + } + + tab.hidden = true; + + if (tab.classList.contains("active")) { + this.#closeAllTabs(); + } + } + + isHiddenTab(tabName: string): boolean { + const tab = this.#tabs.find((element) => element.dataset.name === tabName); + if (tab === undefined) { + return true; + } + + return tab.hidden; + } + setTabCounter(tabName: string, value: number): void { const tab = this.#tabs.find((element) => element.dataset.name === tabName); if (tab === undefined) { diff --git a/ts/WoltLabSuite/Core/Component/Quote/List.ts b/ts/WoltLabSuite/Core/Component/Quote/List.ts new file mode 100644 index 00000000000..dcfa6fd67e5 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Quote/List.ts @@ -0,0 +1,160 @@ +/** + * 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 + */ + +import { listenToCkeditor, dispatchToCkeditor } from "WoltLabSuite/Core/Component/Ckeditor/Event"; +import { getTabMenu } from "WoltLabSuite/Core/Component/Message/MessageTabMenu"; +import { getPhrase } from "WoltLabSuite/Core/Language"; +import { setActiveEditor, removeQuoteStatus } from "WoltLabSuite/Core/Component/Quote/Message"; +import { + getQuotes, + getMessage, + removeQuote, + markQuoteAsUsed, + getUsedQuotes, +} from "WoltLabSuite/Core/Component/Quote/Storage"; +import DomUtil from "WoltLabSuite/Core/Dom/Util"; +import { escapeHTML } from "WoltLabSuite/Core/StringUtil"; + +const quoteLists = new Map(); + +class QuoteList { + #container: HTMLElement; + #editor: HTMLElement; + #editorId: string; + + constructor(editorId: string, editor: HTMLElement, containerId?: string) { + this.#editorId = editorId; + this.#editor = editor; + this.#container = document.getElementById(containerId ? containerId : `quotes_${editorId}`)!; + if (this.#container === null) { + throw new Error(`The quotes container for '${editorId}' does not exist.`); + } + + this.#editor.closest("form")?.addEventListener("submit", () => { + this.#formSubmitted(); + }); + + this.renderQuotes(); + } + + public renderQuotes(): void { + this.#container.innerHTML = ""; + + let quotesCount = 0; + for (const [key, quotes] of getQuotes()) { + const message = getMessage(key)!; + quotesCount += quotes.size; + + quotes.forEach((quote, uuid) => { + const fragment = DomUtil.createFragmentFromHtml(` +
+
+ +
+ +
+ + +
+
+ ${quote.rawMessage === undefined ? quote.message : quote.rawMessage} +
+
+ `); + + fragment.querySelector('button[data-action="insert"]')!.addEventListener("click", () => { + markQuoteAsUsed(this.#editorId, uuid); + + dispatchToCkeditor(this.#editor).insertQuote({ + author: message.author, + content: quote.rawMessage === undefined ? quote.message : quote.rawMessage, + isText: quote.rawMessage === undefined, + link: message.link, + }); + }); + + fragment.querySelector('button[data-action="delete"]')!.addEventListener("click", () => { + removeQuote(key, uuid); + removeQuoteStatus(key); + }); + + this.#container.append(fragment); + }); + } + + const tabMenu = getTabMenu(this.#editorId); + if (tabMenu === undefined) { + throw new Error(`Could not find the tab menu for '${this.#editorId}'.`); + } + + tabMenu.setTabCounter("quotes", quotesCount); + + if (quotesCount > 0) { + tabMenu.showTab("quotes"); + } else { + tabMenu.hideTab("quotes"); + } + } + + #formSubmitted(): void { + const formSubmit = this.#editor.closest("form")!.querySelector(".formSubmit")!; + + getUsedQuotes(this.#editorId).forEach((uuid) => { + formSubmit.append( + DomUtil.createFragmentFromHtml( + ``, + ), + ); + }); + } +} + +export function getQuoteList(editorId: string): QuoteList | undefined { + return quoteLists.get(editorId); +} + +export function refreshQuoteLists() { + for (const quoteList of quoteLists.values()) { + quoteList.renderQuotes(); + } +} + +export function setup(editorId: string, containerId?: string): void { + if (quoteLists.has(editorId)) { + return; + } + + const editor = document.getElementById(editorId); + if (editor === null) { + throw new Error(`The editor '${editorId}' does not exist.`); + } + + listenToCkeditor(editor).ready(({ ckeditor }) => { + if (ckeditor.features.quoteBlock) { + quoteLists.set(editorId, new QuoteList(editorId, editor, containerId)); + } + + if (ckeditor.isVisible()) { + setActiveEditor(ckeditor, ckeditor.features.quoteBlock); + } + + ckeditor.focusTracker.on("change:isFocused", (_evt: unknown, _name: unknown, isFocused: boolean) => { + if (isFocused) { + setActiveEditor(ckeditor, ckeditor.features.quoteBlock); + } + }); + }); +} diff --git a/ts/WoltLabSuite/Core/Component/Quote/Message.ts b/ts/WoltLabSuite/Core/Component/Quote/Message.ts new file mode 100644 index 00000000000..7ca07fcc823 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Quote/Message.ts @@ -0,0 +1,494 @@ +/** + * 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"; +import { + saveQuote, + saveFullQuote, + markQuoteAsUsed, + isFullQuoted, + getKey, +} from "WoltLabSuite/Core/Component/Quote/Storage"; +import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex"; +import { dispatchToCkeditor } from "WoltLabSuite/Core/Component/Ckeditor/Event"; + +interface Container { + element: HTMLElement; + messageBodySelector: string; + objectType: string; + className: string; + objectId: number; +} + +let selectedMessage: + | undefined + | { + message: string; + container: Container; + }; + +const containers = new Map(); +const quoteMessageButtons = new Map(); +let activeMessageId = ""; +let activeEditor: CKEditor | undefined = undefined; +let timerSelectionChange: number | undefined = undefined; +let isMouseDown = false; +const copyQuote = document.createElement("div"); + +export function registerContainer( + containerSelector: string, + messageBodySelector: string, + className: string, + objectType: string, +): void { + wheneverFirstSeen(containerSelector, (container: HTMLElement) => { + const id = DomUtil.identify(container); + const objectId = ~~container.dataset.objectId!; + + containers.set(id, { + element: container, + messageBodySelector: messageBodySelector, + objectType: objectType, + className: className, + objectId: objectId, + }); + + if (container.classList.contains("jsInvalidQuoteTarget")) { + return; + } + + container.addEventListener("mousedown", (event) => onMouseDown(event)); + container.classList.add("jsQuoteMessageContainer"); + + const quoteMessage = container.querySelector(".jsQuoteMessage"); + const quoteMessageButton = quoteMessage?.querySelector(".button"); + if (quoteMessageButton) { + quoteMessageButtons.set(getKey(objectType, objectId), quoteMessageButton); + + if (isFullQuoted(objectType, objectId)) { + quoteMessageButton.classList.add("active"); + } + } + + quoteMessage?.addEventListener( + "click", + promiseMutex(async (event: MouseEvent) => { + event.preventDefault(); + + const quoteMessage = await saveFullQuote(objectType, className, ~~container.dataset.objectId!); + quoteMessageButton!.classList.add("active"); + + if (activeEditor !== undefined) { + dispatchToCkeditor(activeEditor.sourceElement).insertQuote({ + author: quoteMessage.author, + content: quoteMessage.rawMessage === undefined ? quoteMessage.message : quoteMessage.rawMessage, + isText: quoteMessage.rawMessage === undefined, + link: quoteMessage.link, + }); + + markQuoteAsUsed(activeEditor.sourceElement.id, quoteMessage.uuid); + } + }), + ); + }); +} + +export function setActiveEditor(editor?: CKEditor, supportDirectInsert: boolean = false) { + copyQuote.querySelector(".jsQuoteManagerQuoteAndInsert")!.hidden = !supportDirectInsert; + + activeEditor = editor; +} + +export function removeQuoteStatus(key: string): void { + quoteMessageButtons.get(key)?.classList.remove("active"); +} + +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", + promiseMutex(async () => { + await saveQuote( + selectedMessage!.container.objectType, + selectedMessage!.container.objectId, + selectedMessage!.container.className, + selectedMessage!.message, + ); + + removeSelection(); + }), + ); + 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", + promiseMutex(async () => { + const quoteMessage = await saveQuote( + selectedMessage!.container.objectType, + selectedMessage!.container.objectId, + selectedMessage!.container.className, + selectedMessage!.message, + ); + + if (activeEditor !== undefined) { + dispatchToCkeditor(activeEditor.sourceElement).insertQuote({ + author: quoteMessage.author, + content: quoteMessage.rawMessage === undefined ? quoteMessage.message : quoteMessage.rawMessage, + isText: quoteMessage.rawMessage === undefined, + link: quoteMessage.link, + }); + + markQuoteAsUsed(activeEditor.sourceElement.id, quoteMessage.uuid); + } + + removeSelection(); + }), + ); + 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"); + selectedMessage = { + message: text, + container: container, + }; + } + }, 10); +} + +function removeSelection(): void { + copyQuote.classList.remove("active"); + + const selection = window.getSelection()!; + if (selection.rangeCount) { + selection.removeAllRanges(); + } +} diff --git a/ts/WoltLabSuite/Core/Component/Quote/Storage.ts b/ts/WoltLabSuite/Core/Component/Quote/Storage.ts new file mode 100644 index 00000000000..a26b5e17d95 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Quote/Storage.ts @@ -0,0 +1,298 @@ +/** + * Stores the quote data. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle tiny + */ + +import * as Core from "WoltLabSuite/Core/Core"; +import { renderQuote } from "WoltLabSuite/Core/Api/Messages/RenderQuote"; +import { messageAuthor } from "WoltLabSuite/Core/Api/Messages/Author"; +import { refreshQuoteLists } from "WoltLabSuite/Core/Component/Quote/List"; +import { resetRemovalQuotes } from "WoltLabSuite/Core/Api/Messages/ResetRemovalQuotes"; +import { removeQuoteStatus } from "WoltLabSuite/Core/Component/Quote/Message"; + +interface Message { + objectID: number; + time: string; + title: string; + link: string; + authorID: number | null; + author: string; + avatar: string; +} + +interface Quote { + message: string; + rawMessage?: string; +} + +interface StorageData { + quotes: Map>; + messages: Map; +} + +const STORAGE_KEY = Core.getStoragePrefix() + "quotes"; +const usedQuotes = new Map>(); + +export async function saveQuote( + objectType: string, + objectId: number, + objectClassName: string, + message: string, +): Promise { + const result = await messageAuthor(objectClassName, objectId); + if (!result.ok) { + throw new Error("Error fetching author data"); + } + + const uuid = storeQuote(objectType, result.value, { + message, + }); + + refreshQuoteLists(); + + return { + ...result.value, + message, + uuid, + }; +} + +export async function saveFullQuote( + objectType: string, + objectClassName: string, + objectId: number, +): Promise { + const result = await renderQuote(objectType, objectClassName, objectId); + if (!result.ok) { + throw new Error("Error fetching quote data"); + } + + const message = { + objectID: result.value.objectID, + time: result.value.time, + title: result.value.title, + link: result.value.link, + authorID: result.value.authorID, + author: result.value.author, + avatar: result.value.avatar, + }; + + const quote = { + message: result.value.message!, + rawMessage: result.value.rawMessage!, + }; + + const uuid = storeQuote(objectType, message, quote); + + refreshQuoteLists(); + + return { + ...message, + ...quote, + uuid, + }; +} + +export function getQuotes(): Map> { + return getStorage().quotes; +} + +export function getMessage(objectType: string, objectId?: number): Message | undefined { + const key = objectId ? getKey(objectType, objectId) : objectType; + + return getStorage().messages.get(key); +} + +export function removeQuotes(uuids: string[]): void { + const storage = getStorage(); + + for (const uuid of uuids) { + for (const quotes of storage.quotes.values()) { + quotes.delete(uuid); + } + } + + for (const [key, quotes] of storage.quotes) { + if (quotes.size === 0) { + storage.quotes.delete(key); + storage.messages.delete(key); + } + } + + saveStorage(storage); + refreshQuoteLists(); + + void resetRemovalQuotes(); +} + +export function removeQuote(key: string, uuid: string): void { + const storage = getStorage(); + if (!storage.quotes.has(key)) { + return; + } + + storage.quotes.get(key)!.delete(uuid); + + if (storage.quotes.get(key)!.size === 0) { + storage.quotes.delete(key); + storage.messages.delete(key); + } + + saveStorage(storage); + + refreshQuoteLists(); +} + +export function markQuoteAsUsed(editorId: string, uuid: string): void { + if (!usedQuotes.has(editorId)) { + usedQuotes.set(editorId, new Set()); + } + + usedQuotes.get(editorId)!.add(uuid); +} + +export function getUsedQuotes(editorId: string): Set { + return usedQuotes.get(editorId) ?? new Set(); +} + +export function clearQuotesForEditor(editorId: string): void { + const storage = getStorage(); + const fullQuotes: string[] = []; + + usedQuotes.get(editorId)?.forEach((uuid) => { + for (const [key, quotes] of storage.quotes) { + const quote = quotes.get(uuid); + if (quote?.rawMessage !== undefined) { + fullQuotes.push(key); + } + + quotes.delete(uuid); + } + }); + + usedQuotes.delete(editorId); + + for (const [key, quotes] of storage.quotes) { + if (quotes.size === 0) { + storage.quotes.delete(key); + storage.messages.delete(key); + } + } + + saveStorage(storage); + refreshQuoteLists(); + + fullQuotes.forEach((key) => { + removeQuoteStatus(key); + }); +} + +export function isFullQuoted(objectType: string, objectId: number): boolean { + const key = getKey(objectType, objectId); + const storage = getStorage(); + const quotes = storage.quotes.get(key); + + if (quotes === undefined) { + return false; + } + + return ( + Array.from(quotes).filter(([, quote]) => { + if (quote.rawMessage !== undefined) { + return true; + } + }).length > 0 + ); +} + +function storeQuote(objectType: string, message: Message, quote: Quote): string { + const storage = getStorage(); + + const key = getKey(objectType, message.objectID); + if (!storage.quotes.has(key)) { + storage.quotes.set(key, new Map()); + } + + storage.messages.set(key, message); + + for (const [uuid, q] of storage.quotes.get(key)!) { + if ((q.rawMessage !== undefined && q.rawMessage === quote.rawMessage) || q.message === quote.message) { + return uuid; + } + } + + const uuid = Core.getUuid(); + storage.quotes.get(key)!.set(uuid, quote); + + saveStorage(storage); + + return uuid; +} + +function getStorage(): StorageData { + const data = window.localStorage.getItem(STORAGE_KEY); + + return parseJson(data); +} + +function parseJson(data: string | null): StorageData { + if (!data) { + return { + quotes: new Map(), + messages: new Map(), + }; + } + + return JSON.parse(data, (key, value) => { + if (key === "quotes") { + const result = new Map>(value); + for (const [key, quotes] of result) { + result.set(key, new Map(quotes)); + } + + return result; + } else if (key === "messages") { + return new Map(value); + } + + return value; + }); +} + +export function getKey(objectType: string, objectId: number): string { + return `${objectType}:${objectId}`; +} + +function saveStorage(data: StorageData) { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify(data, (_key, value) => { + if (value instanceof Map) { + return Array.from(value.entries()); + } + + return value; + }), + ); +} + +window.addEventListener("storage", (event) => { + refreshQuoteLists(); + + const oldValue = parseJson(event.oldValue); + const newValue = parseJson(event.newValue); + + // Update the quote status if the quote was removed in another tab + for (const [key, quotes] of oldValue.quotes) { + for (const [, quote] of quotes) { + if (quote.rawMessage !== undefined && !newValue.quotes.has(key)) { + removeQuoteStatus(key); + } + } + } +}); diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTab.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTab.ts new file mode 100644 index 00000000000..aa270207c4c --- /dev/null +++ b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTab.ts @@ -0,0 +1,48 @@ +/** + * Container visibility handler implementation for a wysiwyg tab menu tab that, in addition to the + * tab itself, also handles the visibility of the tab menu list item. + * + * @author Olaf Braun + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ + +import Abstract from "./Abstract"; +import * as DependencyManager from "../Manager"; +import { getTabMenu } from "WoltLabSuite/Core/Component/Message/MessageTabMenu"; + +export class WysiwygTab extends Abstract { + readonly #tabName: string; + readonly #wysiwygId: string; + + constructor(containerId: string, tabName: string, wysiwygId: string) { + super(containerId); + + this.#tabName = tabName; + this.#wysiwygId = wysiwygId; + } + + public checkContainer(): void { + // only consider containers that have not been hidden by their own dependencies + if (DependencyManager.isHiddenByDependencies(this._container)) { + return; + } + + const containerIsVisible = !this._container.hidden; + const tabMenu = getTabMenu(this.#wysiwygId)!; + const containerShouldBeVisible = tabMenu.isHiddenTab(this.#tabName); + + if (containerIsVisible !== containerShouldBeVisible) { + if (containerShouldBeVisible) { + tabMenu?.showTab(this.#tabName); + } else { + tabMenu?.hideTab(this.#tabName); + } + + // Check containers again to make sure parent containers can react to changing the visibility + // of this container. + DependencyManager.checkContainers(); + } + } +} diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTabMenu.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTabMenu.ts new file mode 100644 index 00000000000..ed14b8d883a --- /dev/null +++ b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTabMenu.ts @@ -0,0 +1,36 @@ +/** + * Container visibility handler implementation for a wysiwyg tab menu that checks visibility + * based on the visibility of its tab menu list items. + * + * @author Olaf BRaun + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ + +import Abstract from "./Abstract"; +import * as DependencyManager from "../Manager"; +import * as DomUtil from "../../../../../Dom/Util"; + +export class WysiwygTabMenu extends Abstract { + public checkContainer(): void { + // only consider containers that have not been hidden by their own dependencies + if (DependencyManager.isHiddenByDependencies(this._container)) { + return; + } + + const containerIsVisible = !this._container.hidden; + const listItems = this._container.parentNode!.querySelectorAll( + "#" + DomUtil.identify(this._container) + " > nav > ul > li", + ); + const containerShouldBeVisible = Array.from(listItems).some((child: HTMLElement) => !child.hidden); + + if (containerIsVisible !== containerShouldBeVisible) { + this._container.hidden = !containerShouldBeVisible; + + // check containers again to make sure parent containers can react to + // changing the visibility of this container + DependencyManager.checkContainers(); + } + } +} diff --git a/ts/WoltLabSuite/Core/Ui/Message/Quote.ts b/ts/WoltLabSuite/Core/Ui/Message/Quote.ts index 0487723d93d..a3dad998ce7 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,547 +12,25 @@ 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. */ constructor( - quoteManager: WCFMessageQuoteManager, + _quoteManager: WCFMessageQuoteManager, className: string, objectType: string, containerSelector: string, messageBodySelector: string, - messageContentSelector: string, - supportDirectInsert: boolean, + _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()); + // remove "Action" from className + if (className.endsWith("Action")) { + className = className.substring(0, className.length - 6); } - 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, className, objectType); } } diff --git a/ts/WoltLabSuite/Core/Ui/Message/Reply.ts b/ts/WoltLabSuite/Core/Ui/Message/Reply.ts index e8a915958d7..27f9228c3a5 100644 --- a/ts/WoltLabSuite/Core/Ui/Message/Reply.ts +++ b/ts/WoltLabSuite/Core/Ui/Message/Reply.ts @@ -22,6 +22,8 @@ import * as UiScroll from "../Scroll"; import { CKEditor, getCkeditor } from "../../Component/Ckeditor"; import { dispatchToCkeditor } from "WoltLabSuite/Core/Component/Ckeditor/Event"; +import { clearQuotesForEditor } from "WoltLabSuite/Core/Component/Quote/Storage"; +import { setActiveEditor } from "WoltLabSuite/Core/Component/Quote/Message"; interface MessageReplyOptions { ajax: { @@ -386,6 +388,11 @@ class UiMessageReply { this._guestDialogId = guestDialogId; } else { + clearQuotesForEditor(this._textarea.id); + if (!this._getCKEditor().isVisible()) { + setActiveEditor(); + } + this._insertMessage(data); if (!User.userId) { diff --git a/wcfsetup/install/files/js/WCF.Message.js b/wcfsetup/install/files/js/WCF.Message.js index db2204b95f5..3806624fb03 100644 --- a/wcfsetup/install/files/js/WCF.Message.js +++ b/wcfsetup/install/files/js/WCF.Message.js @@ -1031,685 +1031,6 @@ else { }; } -/** - * Namespace for message quotes. - */ -WCF.Message.Quote = { }; - -if (COMPILER_TARGET_DEFAULT) { - /** - * Handles message quotes. - * - * @deprecated 5.4 Use `WoltLabSuite/Core/Ui/Message/Quote` instead - */ - WCF.Message.Quote.Handler = Class.extend({ - init: function (quoteManager, className, objectType, containerSelector, messageBodySelector, messageContentSelector, supportDirectInsert) { - require(["WoltLabSuite/Core/Ui/Message/Quote"], (UiMessageQuote) => { - new UiMessageQuote.default( - quoteManager, - className, - objectType, - containerSelector, - messageBodySelector, - messageContentSelector, - supportDirectInsert, - ); - }); - }, - }); - - /** - * Manages stored quotes. - * - * @param integer count - */ - WCF.Message.Quote.Manager = Class.extend({ - /** - * list of form buttons - * @var {Object} - */ - _buttons: {}, - - /** - * number of stored quotes - * @var {int} - */ - _count: 0, - - /** - * dialog overlay - * @var {jQuery} - */ - _dialog: null, - - /** - * editor element id - * @var {string} - */ - _editorId: '', - - /** - * alternative editor element id - * @var {string} - */ - _editorIdAlternative: '', - - /** - * form element - * @var {jQuery} - */ - _form: null, - - /** - * list of quote handlers - * @var {Object} - */ - _handlers: {}, - - /** - * true, if an up-to-date template exists - * @var {boolean} - */ - _hasTemplate: false, - - /** - * true, if related quotes should be inserted - * @var {boolean} - */ - _insertQuotes: true, - - /** - * action proxy - * @var {WCF.Action.Proxy} - */ - _proxy: null, - - /** - * list of quotes to remove upon submit - * @var {Array} - */ - _removeOnSubmit: [], - - /** - * allow pasting - * @var {boolean} - */ - _supportPaste: false, - - /** - * pasting was temporarily enabled due to an alternative editor being set - * @var boolean - */ - _supportPasteOverride: false, - - /** - * Initializes the quote manager. - * - * @param {int} count - * @param {string} elementID - * @param {boolean} supportPaste - * @param {Array} removeOnSubmit - */ - init: function (count, elementID, supportPaste, removeOnSubmit) { - this._buttons = { - insert: null, - remove: null - }; - this._count = parseInt(count) || 0; - this._dialog = null; - this._editorId = ''; - this._editorIdAlternative = ''; - this._form = null; - this._handlers = {}; - this._hasTemplate = false; - this._insertQuotes = true; - this._removeOnSubmit = []; - this._supportPaste = false; - this._supportPasteOverride = false; - - if (elementID) { - var element = $('#' + elementID); - if (element.length) { - this._editorId = elementID; - this._supportPaste = true; - - // get surrounding form-tag - this._form = element.parents('form:eq(0)'); - if (this._form.length) { - this._form.submit(this._submit.bind(this)); - this._removeOnSubmit = removeOnSubmit || []; - } - else { - this._form = null; - - // allow override - this._supportPaste = (supportPaste === true); - } - } - } - - this._proxy = new WCF.Action.Proxy({ - showLoadingOverlay: false, - success: $.proxy(this._success, this), - url: 'index.php?message-quote/&t=' + SECURITY_TOKEN - }); - - this._toggleShowQuotes(); - - WCF.System.Event.addListener('com.woltlab.wcf.quote', 'reload', this.countQuotes.bind(this)); - - // event forwarding - WCF.System.Event.addListener('com.woltlab.wcf.message.quote', 'insert', (function (data) { - const element = document.getElementById( - this._editorIdAlternative ? this._editorIdAlternative : this._editorId - ); - - require(["WoltLabSuite/Core/Component/Ckeditor/Event"], ({ dispatchToCkeditor }) => { - dispatchToCkeditor(element).insertQuote({ - author: data.quote.username, - content: data.quote.text, - isText: !data.quote.isFullQuote, - link: data.quote.link, - }); - }); - }).bind(this)); - }, - - /** - * Sets an alternative editor element id on runtime. - * - * @param {(string|jQuery)} elementId element id or jQuery element - */ - setAlternativeEditor: function (elementId) { - if (!this._editorIdAlternative && !this._supportPaste) { - this._hasTemplate = false; - this._supportPaste = true; - this._supportPasteOverride = true; - } - - if (typeof elementId === 'object') elementId = elementId[0].id; - this._editorIdAlternative = elementId; - }, - - /** - * Clears alternative editor element id. - */ - clearAlternativeEditor: function () { - if (this._supportPasteOverride) { - this._hasTemplate = false; - this._supportPaste = false; - this._supportPasteOverride = false; - } - - this._editorIdAlternative = ''; - }, - - /** - * Registers a quote handler. - * - * @param {string} objectType - * @param {WCF.Message.Quote.Handler} handler - */ - register: function (objectType, handler) { - this._handlers[objectType] = handler; - }, - - /** - * Updates number of stored quotes. - * - * @param {int} count - * @param {Object} fullQuoteObjectIDs - */ - updateCount: function (count, fullQuoteObjectIDs) { - this._count = parseInt(count) || 0; - - this._toggleShowQuotes(); - - // update full quote ids of handlers - for (var $objectType in this._handlers) { - if (this._handlers.hasOwnProperty($objectType)) { - var $objectIDs = fullQuoteObjectIDs[$objectType] || []; - this._handlers[$objectType].updateFullQuoteObjectIDs($objectIDs); - } - } - }, - - /** - * Inserts all associated quotes upon first time using quick reply. - * - * @param {string} className - * @param {int} parentObjectID - * @param {Object} callback - */ - insertQuotes: function (className, parentObjectID, callback) { - if (!this._insertQuotes) { - this._insertQuotes = true; - - return; - } - - new WCF.Action.Proxy({ - autoSend: true, - data: { - actionName: 'getRenderedQuotes', - className: className, - interfaceName: 'wcf\\data\\IMessageQuoteAction', - parameters: { - parentObjectID: parentObjectID - } - }, - success: callback - }); - }, - - /** - * Toggles the display of the 'Show quotes' button - */ - _toggleShowQuotes: function () { - require(['WoltLabSuite/Core/Ui/Page/Action'], (function (UiPageAction) { - var buttonName = 'showQuotes'; - - if (this._count) { - var button = UiPageAction.get(buttonName); - if (button === undefined) { - button = elCreate('a'); - button.addEventListener('mousedown', this._click.bind(this)); - - UiPageAction.add(buttonName, button); - } - - button.textContent = WCF.Language.get('wcf.message.quote.showQuotes', { - count: this._count - }); - - UiPageAction.show(buttonName); - } - else { - UiPageAction.remove(buttonName); - } - - this._hasTemplate = false; - }).bind(this)); - }, - - /** - * Handles clicks on 'Show quotes'. - */ - _click: function () { - if (this._hasTemplate) { - this._dialog.wcfDialog('open'); - } - else { - this._proxy.showLoadingOverlayOnce(); - - this._proxy.setOption('data', { - actionName: 'getQuotes', - supportPaste: this._supportPaste - }); - this._proxy.sendRequest(); - } - }, - - /** - * Renders the dialog. - * - * @param {string} template - */ - renderDialog: function (template) { - // create dialog if not exists - if (this._dialog === null) { - this._dialog = $('#messageQuoteList'); - if (!this._dialog.length) { - this._dialog = $('
').hide().appendTo(document.body); - } - } - - // add template - this._dialog.html(template); - - // add 'insert' and 'delete' buttons - var $formSubmit = $('
').appendTo(this._dialog); - if (this._supportPaste) this._buttons.insert = $('').click($.proxy(this._insertSelected, this)).appendTo($formSubmit); - this._buttons.remove = $('').click($.proxy(this._removeSelected, this)).appendTo($formSubmit); - - // show dialog - this._dialog.wcfDialog({ - title: WCF.Language.get('wcf.message.quote.manageQuotes') - }); - this._dialog.wcfDialog('render'); - this._hasTemplate = true; - - // bind event listener - var $insertQuoteButtons = this._dialog.find('.jsInsertQuote'); - if (this._supportPaste) { - $insertQuoteButtons.click($.proxy(this._insertQuote, this)); - } - else { - $insertQuoteButtons.hide(); - } - - this._dialog.find('input.jsCheckbox').change($.proxy(this._changeButtons, this)); - - // mark quotes for removal - if (this._removeOnSubmit.length) { - var self = this; - this._dialog.find('input.jsRemoveQuote').each(function (index, input) { - var $input = $(input).change($.proxy(this._change, this)); - - // mark for deletion - if (WCF.inArray($input.parent('li').attr('data-quote-id'), self._removeOnSubmit)) { - $input.attr('checked', 'checked'); - } - }); - } - }, - - /** - * Updates button labels if a checkbox is checked or unchecked. - */ - _changeButtons: function () { - // selection - if (this._dialog.find('input.jsCheckbox:checked').length) { - if (this._supportPaste) this._buttons.insert.html(WCF.Language.get('wcf.message.quote.insertSelectedQuotes')); - this._buttons.remove.html(WCF.Language.get('wcf.message.quote.removeSelectedQuotes')); - } - else { - // no selection, pick all - if (this._supportPaste) this._buttons.insert.html(WCF.Language.get('wcf.message.quote.insertAllQuotes')); - this._buttons.remove.html(WCF.Language.get('wcf.message.quote.removeAllQuotes')); - } - }, - - /** - * Checks for change event on delete-checkboxes. - * - * @param {Object} event - */ - _change: function (event) { - var $input = $(event.currentTarget); - var $quoteID = $input.parent('li').attr('data-quote-id'); - - if ($input.prop('checked')) { - this._removeOnSubmit.push($quoteID); - } - else { - var index = this._removeOnSubmit.indexOf($quoteID); - if (index !== -1) { - this._removeOnSubmit.splice(index, 1); - } - } - }, - - /** - * Inserts the selected quotes. - */ - _insertSelected: function () { - if (!this._dialog.find('input.jsCheckbox:checked').length) { - this._dialog.find('input.jsCheckbox').prop('checked', 'checked'); - } - - // close dialog - this._dialog.wcfDialog('close'); - - // insert all quotes - window.setTimeout(() => { - this._dialog.find('input.jsCheckbox:checked').each($.proxy(function (index, input) { - this._insertQuote(null, input); - }, this)); - }, 0); - }, - - /** - * Inserts a quote. - * - * @param {Event} event - * @param {Object} inputElement - */ - _insertQuote: function (event, inputElement) { - var listItem = $(event ? event.currentTarget : inputElement).parents('li:eq(0)'); - var text = listItem.children('.jsFullQuote')[0].textContent.trim(); - - var message = listItem.parents('.message:eq(0)'); - var author = message.data('username'); - var link = message.data('link'); - var isText = !elDataBool(listItem[0], 'is-full-quote'); - - const element = document.getElementById( - this._editorIdAlternative ? this._editorIdAlternative : this._editorId - ); - - require(["WoltLabSuite/Core/Component/Ckeditor/Event"], ({ dispatchToCkeditor }) => { - dispatchToCkeditor(element).insertQuote({ - author, - content: text, - isText, - link, - }); - }); - - // remove quote upon submit or upon request - this._removeOnSubmit.push(listItem.data('quote-id')); - - // close dialog - if (event !== null) { - require(["WoltLabSuite/Core/Environment"], (function (Environment) { - var callback = (function () { - this._dialog.wcfDialog("close"); - }).bind(this); - - // Slightly delay the closing of the overlay, preventing some unexpected - // changes to the scroll position on iOS. - if (Environment.platform() === "ios") { - window.setTimeout(callback, 100); - } else { - callback(); - } - }.bind(this))); - } - }, - - /** - * Removes selected quotes. - */ - _removeSelected: function () { - if (!this._dialog.find('input.jsCheckbox:checked').length) { - this._dialog.find('input.jsCheckbox').prop('checked', 'checked'); - } - - var $quoteIDs = []; - this._dialog.find('input.jsCheckbox:checked').each(function (index, input) { - $quoteIDs.push($(input).parents('li').attr('data-quote-id')); - }); - - if ($quoteIDs.length) { - // get object types - var $objectTypes = []; - for (var $objectType in this._handlers) { - if (this._handlers.hasOwnProperty($objectType)) { - $objectTypes.push($objectType); - } - } - - this._proxy.setOption('data', { - actionName: 'remove', - getFullQuoteObjectIDs: this._handlers.length > 0, - objectTypes: $objectTypes, - quoteIDs: $quoteIDs - }); - this._proxy.sendRequest(); - - this._dialog.wcfDialog('close'); - } - }, - - /** - * Appends list of quote ids to remove after successful submit. - */ - _submit: function () { - if (this._supportPaste && this._removeOnSubmit.length > 0) { - var $formSubmit = this._form.find('.formSubmit'); - for (var i = 0, length = this._removeOnSubmit.length; i < length; i++) { - $('').appendTo($formSubmit); - } - } - }, - - /** - * Returns a list of quote ids marked for removal. - * - * @return {Array} - */ - getQuotesMarkedForRemoval: function () { - return this._removeOnSubmit; - }, - - /** - * @deprecated 5.5 This method is no longer used since 3.0. - */ - markQuotesForRemoval: function () { - if (this._removeOnSubmit.length) { - this._proxy.setOption('data', { - actionName: 'markForRemoval', - quoteIDs: this._removeOnSubmit - }); - this._proxy.suppressErrors(); - this._proxy.sendRequest(); - } - }, - - /** - * Removes all marked quote ids. - */ - removeMarkedQuotes: function () { - if (this._removeOnSubmit.length) { - this._proxy.setOption('data', { - actionName: 'removeMarkedQuotes', - getFullQuoteObjectIDs: this._handlers.length > 0 - }); - this._proxy.sendRequest(); - } - }, - - /** - * Counts stored quotes. - */ - countQuotes: function () { - var $objectTypes = []; - for (var $objectType in this._handlers) { - if (this._handlers.hasOwnProperty($objectType)) { - $objectTypes.push($objectType); - } - } - - this._proxy.setOption('data', { - actionName: 'count', - getFullQuoteObjectIDs: ($objectTypes.length > 0), - objectTypes: $objectTypes - }); - this._proxy.sendRequest(); - }, - - /** - * Handles successful AJAX requests. - * - * @param {Object} data - */ - _success: function (data) { - if (data === null) { - return; - } - - if (data.count !== undefined) { - var $fullQuoteObjectIDs = (data.fullQuoteObjectIDs !== undefined) ? data.fullQuoteObjectIDs : {}; - this.updateCount(data.count, $fullQuoteObjectIDs); - } - - if (data.template !== undefined) { - if ($.trim(data.template) == '') { - this.updateCount(0, {}); - } - else { - this.renderDialog(data.template); - } - } - }, - - /** - * Returns true if pasting is supported. - * - * @return boolean - */ - supportPaste: function () { - return this._supportPaste; - } - }); -} -else { - WCF.Message.Quote.Handler = Class.extend({ - _activeContainerID: "", - _className: "", - _containers: {}, - _containerSelector: "", - _copyQuote: {}, - _message: "", - _messageBodySelector: "", - _objectID: 0, - _objectType: "", - _proxy: {}, - _quoteManager: {}, - init: function() {}, - _initContainers: function() {}, - _mouseDown: function() {}, - _getNodeText: function() {}, - _mouseUp: function() {}, - _normalize: function() {}, - _getBoundingRectangle: function() {}, - _initCopyQuote: function() {}, - _getSelectedText: function() {}, - _saveFullQuote: function() {}, - _saveQuote: function() {}, - _saveAndInsertQuote: function() {}, - _success: function() {}, - updateFullQuoteObjectIDs: function() {} - }); - - WCF.Message.Quote.Manager = Class.extend({ - _buttons: {}, - _count: 0, - _dialog: {}, - _editorId: "", - _editorIdAlternative: "", - _form: {}, - _handlers: {}, - _hasTemplate: false, - _insertQuotes: true, - _proxy: {}, - _removeOnSubmit: {}, - _supportPaste: false, - init: function() {}, - setAlternativeEditor: function() {}, - clearAlternativeEditor: function() {}, - register: function() {}, - updateCount: function() {}, - insertQuotes: function() {}, - _toggleShowQuotes: function() {}, - _click: function() {}, - renderDialog: function() {}, - _changeButtons: function() {}, - _change: function() {}, - _insertSelected: function() {}, - _insertQuote: function() {}, - _removeSelected: function() {}, - _submit: function() {}, - getQuotesMarkedForRemoval: function() {}, - markQuotesForRemoval: function() {}, - removeMarkedQuotes: function() {}, - countQuotes: function() {}, - _success: function() {}, - supportPaste: function() {} - }); -} - /** * Namespace for message sharing related classes. */ diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Messages/Author.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Messages/Author.js new file mode 100644 index 00000000000..8f16cf02eaf --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Messages/Author.js @@ -0,0 +1,27 @@ +/** + * Requests render a full quote of a message. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle tiny + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.messageAuthor = messageAuthor; + async function messageAuthor(className, objectID) { + const url = new URL(window.WSC_RPC_API_URL + "core/messages/message-author"); + url.searchParams.set("className", className); + url.searchParams.set("objectID", objectID.toString()); + let response; + try { + response = (await (0, Backend_1.prepareRequest)(url).get().allowCaching().fetchAsJson()); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)(response); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Messages/RenderQuote.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Messages/RenderQuote.js new file mode 100644 index 00000000000..5d1c7fd4849 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Messages/RenderQuote.js @@ -0,0 +1,29 @@ +/** + * Requests render a full quote of a message. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle tiny + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.renderQuote = renderQuote; + async function renderQuote(objectType, className, objectID) { + const url = new URL(window.WSC_RPC_API_URL + "core/messages/render-quote"); + url.searchParams.set("objectType", objectType); + url.searchParams.set("className", className); + url.searchParams.set("fullQuote", "true"); + url.searchParams.set("objectID", objectID.toString()); + let response; + try { + response = (await (0, Backend_1.prepareRequest)(url).get().fetchAsJson()); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)(response); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Messages/ResetRemovalQuotes.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Messages/ResetRemovalQuotes.js new file mode 100644 index 00000000000..110981720fc --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Messages/ResetRemovalQuotes.js @@ -0,0 +1,24 @@ +/** + * Requests to reset the removal quotes. + * + * @author Olaf Braun + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + * @woltlabExcludeBundle tiny + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.resetRemovalQuotes = resetRemovalQuotes; + async function resetRemovalQuotes() { + const url = new URL(window.WSC_RPC_API_URL + "core/messages/reset-removal-quotes"); + try { + await (0, Backend_1.prepareRequest)(url).post().fetchAsJson(); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)([]); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js b/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js index 5188e74aa2e..84efa2e46e4 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js @@ -59,9 +59,21 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui enableMobileMenu: true, pageMenuMainProvider: new Frontend_1.default(), }); + if (options.removeQuotes?.length) { + void new Promise((resolve_3, reject_3) => { require(["./Component/Quote/Storage"], resolve_3, reject_3); }).then(tslib_1.__importStar).then(({ removeQuotes }) => removeQuotes(options.removeQuotes)); + } + if (options.usedQuotes?.size) { + void new Promise((resolve_4, reject_4) => { require(["./Component/Quote/Storage"], resolve_4, reject_4); }).then(tslib_1.__importStar).then(({ markQuoteAsUsed }) => { + options.usedQuotes.forEach((uuids, editorId) => { + for (const uuid of uuids) { + markQuoteAsUsed(editorId, uuid); + } + }); + }); + } UiPageHeaderMenu.init(); if (options.styleChanger) { - void new Promise((resolve_3, reject_3) => { require(["./Controller/Style/Changer"], resolve_3, reject_3); }).then(tslib_1.__importStar).then((ControllerStyleChanger) => { + void new Promise((resolve_5, reject_5) => { require(["./Controller/Style/Changer"], resolve_5, reject_5); }).then(tslib_1.__importStar).then((ControllerStyleChanger) => { ControllerStyleChanger.setup(); }); } @@ -96,22 +108,22 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui } } (0, LazyLoader_1.whenFirstSeen)("woltlab-core-reaction-summary", () => { - void new Promise((resolve_4, reject_4) => { require(["./Ui/Reaction/SummaryDetails"], resolve_4, reject_4); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_6, reject_6) => { require(["./Ui/Reaction/SummaryDetails"], resolve_6, reject_6); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-comment", () => { - void new Promise((resolve_5, reject_5) => { require(["./Component/Comment/woltlab-core-comment"], resolve_5, reject_5); }).then(tslib_1.__importStar); + void new Promise((resolve_7, reject_7) => { require(["./Component/Comment/woltlab-core-comment"], resolve_7, reject_7); }).then(tslib_1.__importStar); }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-comment-response", () => { - void new Promise((resolve_6, reject_6) => { require(["./Component/Comment/Response/woltlab-core-comment-response"], resolve_6, reject_6); }).then(tslib_1.__importStar); + void new Promise((resolve_8, reject_8) => { require(["./Component/Comment/Response/woltlab-core-comment-response"], resolve_8, reject_8); }).then(tslib_1.__importStar); }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-emoji-picker", () => { - void new Promise((resolve_7, reject_7) => { require(["./Component/EmojiPicker/woltlab-core-emoji-picker"], resolve_7, reject_7); }).then(tslib_1.__importStar); + void new Promise((resolve_9, reject_9) => { require(["./Component/EmojiPicker/woltlab-core-emoji-picker"], resolve_9, reject_9); }).then(tslib_1.__importStar); }); (0, LazyLoader_1.whenFirstSeen)("[data-follow-user]", () => { - void new Promise((resolve_8, reject_8) => { require(["./Component/User/Follow"], resolve_8, reject_8); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_10, reject_10) => { require(["./Component/User/Follow"], resolve_10, reject_10); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)("[data-ignore-user]", () => { - void new Promise((resolve_9, reject_9) => { require(["./Component/User/Ignore"], resolve_9, reject_9); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_11, reject_11) => { require(["./Component/User/Ignore"], resolve_11, reject_11); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); } }); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Ckeditor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Ckeditor.js index 45c91c58c9b..092f913af9c 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Ckeditor.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Ckeditor.js @@ -125,6 +125,9 @@ define(["require", "exports", "tslib", "./Ckeditor/Attachment", "./Ckeditor/Medi get sourceElement() { return this.#editor.sourceElement; } + get focusTracker() { + return this.#editor.ui.focusTracker; + } } function* findModelForRemoval(element, model, attributes) { if (element.is("element", model)) { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Comment/Add.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Comment/Add.js index c27c8d2fa93..8622aa0b13b 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Comment/Add.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Comment/Add.js @@ -6,7 +6,7 @@ * @license GNU Lesser General Public License * @since 6.0 */ -define(["require", "exports", "tslib", "../../Ui/Scroll", "../../Ui/Notification", "../../Language", "../../Event/Handler", "../../Dom/Util", "../Ckeditor", "../Ckeditor/Event", "WoltLabSuite/Core/Api/Comments/CreateComment", "../GuestTokenDialog", "WoltLabSuite/Core/User"], function (require, exports, tslib_1, UiScroll, UiNotification, Language_1, EventHandler, Util_1, Ckeditor_1, Event_1, CreateComment_1, GuestTokenDialog_1, User_1) { +define(["require", "exports", "tslib", "../../Ui/Scroll", "../../Ui/Notification", "../../Language", "../../Event/Handler", "../../Dom/Util", "../Ckeditor", "../Ckeditor/Event", "WoltLabSuite/Core/Api/Comments/CreateComment", "../GuestTokenDialog", "WoltLabSuite/Core/User", "WoltLabSuite/Core/Component/Quote/Storage", "WoltLabSuite/Core/Component/Quote/Message"], function (require, exports, tslib_1, UiScroll, UiNotification, Language_1, EventHandler, Util_1, Ckeditor_1, Event_1, CreateComment_1, GuestTokenDialog_1, User_1, Storage_1, Message_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CommentAdd = void 0; @@ -149,6 +149,8 @@ define(["require", "exports", "tslib", "../../Ui/Scroll", "../../Ui/Notification */ #reset() { this.#getEditor().reset(); + (0, Storage_1.clearQuotesForEditor)(this.#getEditor().sourceElement.id); + (0, Message_1.setActiveEditor)(); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Comment/Response/Add.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Comment/Response/Add.js index d22c92530f6..3d483b720da 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Comment/Response/Add.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Comment/Response/Add.js @@ -6,7 +6,7 @@ * @license GNU Lesser General Public License * @since 6.0 */ -define(["require", "exports", "tslib", "../../../Dom/Util", "../../../Language", "../../../Event/Handler", "../../../Ui/Scroll", "../../../Ui/Notification", "../../Ckeditor", "../../Ckeditor/Event", "WoltLabSuite/Core/User", "../../GuestTokenDialog", "WoltLabSuite/Core/Api/Comments/Responses/CreateResponse"], function (require, exports, tslib_1, Util_1, Language_1, EventHandler, UiScroll, UiNotification, Ckeditor_1, Event_1, User_1, GuestTokenDialog_1, CreateResponse_1) { +define(["require", "exports", "tslib", "../../../Dom/Util", "../../../Language", "../../../Event/Handler", "../../../Ui/Scroll", "../../../Ui/Notification", "../../Ckeditor", "../../Ckeditor/Event", "WoltLabSuite/Core/User", "../../GuestTokenDialog", "WoltLabSuite/Core/Api/Comments/Responses/CreateResponse", "WoltLabSuite/Core/Component/Quote/Storage", "WoltLabSuite/Core/Component/Quote/Message"], function (require, exports, tslib_1, Util_1, Language_1, EventHandler, UiScroll, UiNotification, Ckeditor_1, Event_1, User_1, GuestTokenDialog_1, CreateResponse_1, Storage_1, Message_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CommentResponseAdd = void 0; @@ -106,6 +106,8 @@ define(["require", "exports", "tslib", "../../../Dom/Util", "../../../Language", */ #reset() { this.#getEditor().reset(); + (0, Storage_1.clearQuotesForEditor)(this.#getEditor().sourceElement.id); + (0, Message_1.setActiveEditor)(); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Message/MessageTabMenu.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Message/MessageTabMenu.js index fcbfb6d4fe1..27bae31a2af 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Message/MessageTabMenu.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Message/MessageTabMenu.js @@ -53,6 +53,30 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol this.#tabContainers[tabIndex].classList.add("active"); this.#activeTabName = tabName; } + showTab(tabName) { + const tab = this.#tabs.find((element) => element.dataset.name === tabName); + if (tab === undefined) { + return; + } + tab.hidden = false; + } + hideTab(tabName) { + const tab = this.#tabs.find((element) => element.dataset.name === tabName); + if (tab === undefined) { + return; + } + tab.hidden = true; + if (tab.classList.contains("active")) { + this.#closeAllTabs(); + } + } + isHiddenTab(tabName) { + const tab = this.#tabs.find((element) => element.dataset.name === tabName); + if (tab === undefined) { + return true; + } + return tab.hidden; + } setTabCounter(tabName, value) { const tab = this.#tabs.find((element) => element.dataset.name === tabName); if (tab === undefined) { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/List.js new file mode 100644 index 00000000000..42ea0a3efd2 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/List.js @@ -0,0 +1,127 @@ +/** + * 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/Component/Ckeditor/Event", "WoltLabSuite/Core/Component/Message/MessageTabMenu", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/Component/Quote/Message", "WoltLabSuite/Core/Component/Quote/Storage", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/StringUtil"], function (require, exports, tslib_1, Event_1, MessageTabMenu_1, Language_1, Message_1, Storage_1, Util_1, StringUtil_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.getQuoteList = getQuoteList; + exports.refreshQuoteLists = refreshQuoteLists; + exports.setup = setup; + Util_1 = tslib_1.__importDefault(Util_1); + const quoteLists = new Map(); + class QuoteList { + #container; + #editor; + #editorId; + constructor(editorId, editor, containerId) { + this.#editorId = editorId; + this.#editor = editor; + this.#container = document.getElementById(containerId ? containerId : `quotes_${editorId}`); + if (this.#container === null) { + throw new Error(`The quotes container for '${editorId}' does not exist.`); + } + this.#editor.closest("form")?.addEventListener("submit", () => { + this.#formSubmitted(); + }); + this.renderQuotes(); + } + renderQuotes() { + this.#container.innerHTML = ""; + let quotesCount = 0; + for (const [key, quotes] of (0, Storage_1.getQuotes)()) { + const message = (0, Storage_1.getMessage)(key); + quotesCount += quotes.size; + quotes.forEach((quote, uuid) => { + const fragment = Util_1.default.createFragmentFromHtml(` +
+
+ +
+ +
+ + +
+
+ ${quote.rawMessage === undefined ? quote.message : quote.rawMessage} +
+
+ `); + fragment.querySelector('button[data-action="insert"]').addEventListener("click", () => { + (0, Storage_1.markQuoteAsUsed)(this.#editorId, uuid); + (0, Event_1.dispatchToCkeditor)(this.#editor).insertQuote({ + author: message.author, + content: quote.rawMessage === undefined ? quote.message : quote.rawMessage, + isText: quote.rawMessage === undefined, + link: message.link, + }); + }); + fragment.querySelector('button[data-action="delete"]').addEventListener("click", () => { + (0, Storage_1.removeQuote)(key, uuid); + (0, Message_1.removeQuoteStatus)(key); + }); + this.#container.append(fragment); + }); + } + const tabMenu = (0, MessageTabMenu_1.getTabMenu)(this.#editorId); + if (tabMenu === undefined) { + throw new Error(`Could not find the tab menu for '${this.#editorId}'.`); + } + tabMenu.setTabCounter("quotes", quotesCount); + if (quotesCount > 0) { + tabMenu.showTab("quotes"); + } + else { + tabMenu.hideTab("quotes"); + } + } + #formSubmitted() { + const formSubmit = this.#editor.closest("form").querySelector(".formSubmit"); + (0, Storage_1.getUsedQuotes)(this.#editorId).forEach((uuid) => { + formSubmit.append(Util_1.default.createFragmentFromHtml(``)); + }); + } + } + function getQuoteList(editorId) { + return quoteLists.get(editorId); + } + function refreshQuoteLists() { + for (const quoteList of quoteLists.values()) { + quoteList.renderQuotes(); + } + } + function setup(editorId, containerId) { + if (quoteLists.has(editorId)) { + return; + } + const editor = document.getElementById(editorId); + if (editor === null) { + throw new Error(`The editor '${editorId}' does not exist.`); + } + (0, Event_1.listenToCkeditor)(editor).ready(({ ckeditor }) => { + if (ckeditor.features.quoteBlock) { + quoteLists.set(editorId, new QuoteList(editorId, editor, containerId)); + } + if (ckeditor.isVisible()) { + (0, Message_1.setActiveEditor)(ckeditor, ckeditor.features.quoteBlock); + } + ckeditor.focusTracker.on("change:isFocused", (_evt, _name, isFocused) => { + if (isFocused) { + (0, Message_1.setActiveEditor)(ckeditor, ckeditor.features.quoteBlock); + } + }); + }); + } +}); 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 00000000000..e6c299521c0 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/Message.js @@ -0,0 +1,358 @@ +/** + * 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", "WoltLabSuite/Core/Component/Quote/Storage", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Component/Ckeditor/Event"], function (require, exports, tslib_1, Util_1, Language_1, Selector_1, Alignment_1, Storage_1, PromiseMutex_1, Event_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.registerContainer = registerContainer; + exports.setActiveEditor = setActiveEditor; + exports.removeQuoteStatus = removeQuoteStatus; + Util_1 = tslib_1.__importDefault(Util_1); + let selectedMessage; + const containers = new Map(); + const quoteMessageButtons = new Map(); + let activeMessageId = ""; + let activeEditor = undefined; + let timerSelectionChange = undefined; + let isMouseDown = false; + const copyQuote = document.createElement("div"); + function registerContainer(containerSelector, messageBodySelector, className, objectType) { + (0, Selector_1.wheneverFirstSeen)(containerSelector, (container) => { + const id = Util_1.default.identify(container); + const objectId = ~~container.dataset.objectId; + containers.set(id, { + element: container, + messageBodySelector: messageBodySelector, + objectType: objectType, + className: className, + objectId: objectId, + }); + if (container.classList.contains("jsInvalidQuoteTarget")) { + return; + } + container.addEventListener("mousedown", (event) => onMouseDown(event)); + container.classList.add("jsQuoteMessageContainer"); + const quoteMessage = container.querySelector(".jsQuoteMessage"); + const quoteMessageButton = quoteMessage?.querySelector(".button"); + if (quoteMessageButton) { + quoteMessageButtons.set((0, Storage_1.getKey)(objectType, objectId), quoteMessageButton); + if ((0, Storage_1.isFullQuoted)(objectType, objectId)) { + quoteMessageButton.classList.add("active"); + } + } + quoteMessage?.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(async (event) => { + event.preventDefault(); + const quoteMessage = await (0, Storage_1.saveFullQuote)(objectType, className, ~~container.dataset.objectId); + quoteMessageButton.classList.add("active"); + if (activeEditor !== undefined) { + (0, Event_1.dispatchToCkeditor)(activeEditor.sourceElement).insertQuote({ + author: quoteMessage.author, + content: quoteMessage.rawMessage === undefined ? quoteMessage.message : quoteMessage.rawMessage, + isText: quoteMessage.rawMessage === undefined, + link: quoteMessage.link, + }); + (0, Storage_1.markQuoteAsUsed)(activeEditor.sourceElement.id, quoteMessage.uuid); + } + })); + }); + } + function setActiveEditor(editor, supportDirectInsert = false) { + copyQuote.querySelector(".jsQuoteManagerQuoteAndInsert").hidden = !supportDirectInsert; + activeEditor = editor; + } + function removeQuoteStatus(key) { + quoteMessageButtons.get(key)?.classList.remove("active"); + } + 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", (0, PromiseMutex_1.promiseMutex)(async () => { + await (0, Storage_1.saveQuote)(selectedMessage.container.objectType, selectedMessage.container.objectId, selectedMessage.container.className, selectedMessage.message); + removeSelection(); + })); + 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", (0, PromiseMutex_1.promiseMutex)(async () => { + const quoteMessage = await (0, Storage_1.saveQuote)(selectedMessage.container.objectType, selectedMessage.container.objectId, selectedMessage.container.className, selectedMessage.message); + if (activeEditor !== undefined) { + (0, Event_1.dispatchToCkeditor)(activeEditor.sourceElement).insertQuote({ + author: quoteMessage.author, + content: quoteMessage.rawMessage === undefined ? quoteMessage.message : quoteMessage.rawMessage, + isText: quoteMessage.rawMessage === undefined, + link: quoteMessage.link, + }); + (0, Storage_1.markQuoteAsUsed)(activeEditor.sourceElement.id, quoteMessage.uuid); + } + removeSelection(); + })); + 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"); + selectedMessage = { + message: text, + container: container, + }; + } + }, 10); + } + function removeSelection() { + copyQuote.classList.remove("active"); + const selection = window.getSelection(); + if (selection.rangeCount) { + selection.removeAllRanges(); + } + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/Storage.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/Storage.js new file mode 100644 index 00000000000..2791c44ac98 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Quote/Storage.js @@ -0,0 +1,218 @@ +/** + * Stores the quote data. + * + * @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/Api/Messages/RenderQuote", "WoltLabSuite/Core/Api/Messages/Author", "WoltLabSuite/Core/Component/Quote/List", "WoltLabSuite/Core/Api/Messages/ResetRemovalQuotes", "WoltLabSuite/Core/Component/Quote/Message"], function (require, exports, tslib_1, Core, RenderQuote_1, Author_1, List_1, ResetRemovalQuotes_1, Message_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.saveQuote = saveQuote; + exports.saveFullQuote = saveFullQuote; + exports.getQuotes = getQuotes; + exports.getMessage = getMessage; + exports.removeQuotes = removeQuotes; + exports.removeQuote = removeQuote; + exports.markQuoteAsUsed = markQuoteAsUsed; + exports.getUsedQuotes = getUsedQuotes; + exports.clearQuotesForEditor = clearQuotesForEditor; + exports.isFullQuoted = isFullQuoted; + exports.getKey = getKey; + Core = tslib_1.__importStar(Core); + const STORAGE_KEY = Core.getStoragePrefix() + "quotes"; + const usedQuotes = new Map(); + async function saveQuote(objectType, objectId, objectClassName, message) { + const result = await (0, Author_1.messageAuthor)(objectClassName, objectId); + if (!result.ok) { + throw new Error("Error fetching author data"); + } + const uuid = storeQuote(objectType, result.value, { + message, + }); + (0, List_1.refreshQuoteLists)(); + return { + ...result.value, + message, + uuid, + }; + } + async function saveFullQuote(objectType, objectClassName, objectId) { + const result = await (0, RenderQuote_1.renderQuote)(objectType, objectClassName, objectId); + if (!result.ok) { + throw new Error("Error fetching quote data"); + } + const message = { + objectID: result.value.objectID, + time: result.value.time, + title: result.value.title, + link: result.value.link, + authorID: result.value.authorID, + author: result.value.author, + avatar: result.value.avatar, + }; + const quote = { + message: result.value.message, + rawMessage: result.value.rawMessage, + }; + const uuid = storeQuote(objectType, message, quote); + (0, List_1.refreshQuoteLists)(); + return { + ...message, + ...quote, + uuid, + }; + } + function getQuotes() { + return getStorage().quotes; + } + function getMessage(objectType, objectId) { + const key = objectId ? getKey(objectType, objectId) : objectType; + return getStorage().messages.get(key); + } + function removeQuotes(uuids) { + const storage = getStorage(); + for (const uuid of uuids) { + for (const quotes of storage.quotes.values()) { + quotes.delete(uuid); + } + } + for (const [key, quotes] of storage.quotes) { + if (quotes.size === 0) { + storage.quotes.delete(key); + storage.messages.delete(key); + } + } + saveStorage(storage); + (0, List_1.refreshQuoteLists)(); + void (0, ResetRemovalQuotes_1.resetRemovalQuotes)(); + } + function removeQuote(key, uuid) { + const storage = getStorage(); + if (!storage.quotes.has(key)) { + return; + } + storage.quotes.get(key).delete(uuid); + if (storage.quotes.get(key).size === 0) { + storage.quotes.delete(key); + storage.messages.delete(key); + } + saveStorage(storage); + (0, List_1.refreshQuoteLists)(); + } + function markQuoteAsUsed(editorId, uuid) { + if (!usedQuotes.has(editorId)) { + usedQuotes.set(editorId, new Set()); + } + usedQuotes.get(editorId).add(uuid); + } + function getUsedQuotes(editorId) { + return usedQuotes.get(editorId) ?? new Set(); + } + function clearQuotesForEditor(editorId) { + const storage = getStorage(); + const fullQuotes = []; + usedQuotes.get(editorId)?.forEach((uuid) => { + for (const [key, quotes] of storage.quotes) { + const quote = quotes.get(uuid); + if (quote?.rawMessage !== undefined) { + fullQuotes.push(key); + } + quotes.delete(uuid); + } + }); + usedQuotes.delete(editorId); + for (const [key, quotes] of storage.quotes) { + if (quotes.size === 0) { + storage.quotes.delete(key); + storage.messages.delete(key); + } + } + saveStorage(storage); + (0, List_1.refreshQuoteLists)(); + fullQuotes.forEach((key) => { + (0, Message_1.removeQuoteStatus)(key); + }); + } + function isFullQuoted(objectType, objectId) { + const key = getKey(objectType, objectId); + const storage = getStorage(); + const quotes = storage.quotes.get(key); + if (quotes === undefined) { + return false; + } + return (Array.from(quotes).filter(([, quote]) => { + if (quote.rawMessage !== undefined) { + return true; + } + }).length > 0); + } + function storeQuote(objectType, message, quote) { + const storage = getStorage(); + const key = getKey(objectType, message.objectID); + if (!storage.quotes.has(key)) { + storage.quotes.set(key, new Map()); + } + storage.messages.set(key, message); + for (const [uuid, q] of storage.quotes.get(key)) { + if ((q.rawMessage !== undefined && q.rawMessage === quote.rawMessage) || q.message === quote.message) { + return uuid; + } + } + const uuid = Core.getUuid(); + storage.quotes.get(key).set(uuid, quote); + saveStorage(storage); + return uuid; + } + function getStorage() { + const data = window.localStorage.getItem(STORAGE_KEY); + return parseJson(data); + } + function parseJson(data) { + if (!data) { + return { + quotes: new Map(), + messages: new Map(), + }; + } + return JSON.parse(data, (key, value) => { + if (key === "quotes") { + const result = new Map(value); + for (const [key, quotes] of result) { + result.set(key, new Map(quotes)); + } + return result; + } + else if (key === "messages") { + return new Map(value); + } + return value; + }); + } + function getKey(objectType, objectId) { + return `${objectType}:${objectId}`; + } + function saveStorage(data) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data, (_key, value) => { + if (value instanceof Map) { + return Array.from(value.entries()); + } + return value; + })); + } + window.addEventListener("storage", (event) => { + (0, List_1.refreshQuoteLists)(); + const oldValue = parseJson(event.oldValue); + const newValue = parseJson(event.newValue); + // Update the quote status if the quote was removed in another tab + for (const [key, quotes] of oldValue.quotes) { + for (const [, quote] of quotes) { + if (quote.rawMessage !== undefined && !newValue.quotes.has(key)) { + (0, Message_1.removeQuoteStatus)(key); + } + } + } + }); +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTab.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTab.js new file mode 100644 index 00000000000..647f3a6809f --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTab.js @@ -0,0 +1,46 @@ +/** + * Container visibility handler implementation for a wysiwyg tab menu tab that, in addition to the + * tab itself, also handles the visibility of the tab menu list item. + * + * @author Olaf Braun + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +define(["require", "exports", "tslib", "./Abstract", "../Manager", "WoltLabSuite/Core/Component/Message/MessageTabMenu"], function (require, exports, tslib_1, Abstract_1, DependencyManager, MessageTabMenu_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.WysiwygTab = void 0; + Abstract_1 = tslib_1.__importDefault(Abstract_1); + DependencyManager = tslib_1.__importStar(DependencyManager); + class WysiwygTab extends Abstract_1.default { + #tabName; + #wysiwygId; + constructor(containerId, tabName, wysiwygId) { + super(containerId); + this.#tabName = tabName; + this.#wysiwygId = wysiwygId; + } + checkContainer() { + // only consider containers that have not been hidden by their own dependencies + if (DependencyManager.isHiddenByDependencies(this._container)) { + return; + } + const containerIsVisible = !this._container.hidden; + const tabMenu = (0, MessageTabMenu_1.getTabMenu)(this.#wysiwygId); + const containerShouldBeVisible = tabMenu.isHiddenTab(this.#tabName); + if (containerIsVisible !== containerShouldBeVisible) { + if (containerShouldBeVisible) { + tabMenu?.showTab(this.#tabName); + } + else { + tabMenu?.hideTab(this.#tabName); + } + // Check containers again to make sure parent containers can react to changing the visibility + // of this container. + DependencyManager.checkContainers(); + } + } + } + exports.WysiwygTab = WysiwygTab; +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTabMenu.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTabMenu.js new file mode 100644 index 00000000000..5d1766b3f35 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/WysiwygTabMenu.js @@ -0,0 +1,35 @@ +/** + * Container visibility handler implementation for a wysiwyg tab menu that checks visibility + * based on the visibility of its tab menu list items. + * + * @author Olaf BRaun + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +define(["require", "exports", "tslib", "./Abstract", "../Manager", "../../../../../Dom/Util"], function (require, exports, tslib_1, Abstract_1, DependencyManager, DomUtil) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.WysiwygTabMenu = void 0; + Abstract_1 = tslib_1.__importDefault(Abstract_1); + DependencyManager = tslib_1.__importStar(DependencyManager); + DomUtil = tslib_1.__importStar(DomUtil); + class WysiwygTabMenu extends Abstract_1.default { + checkContainer() { + // only consider containers that have not been hidden by their own dependencies + if (DependencyManager.isHiddenByDependencies(this._container)) { + return; + } + const containerIsVisible = !this._container.hidden; + const listItems = this._container.parentNode.querySelectorAll("#" + DomUtil.identify(this._container) + " > nav > ul > li"); + const containerShouldBeVisible = Array.from(listItems).some((child) => !child.hidden); + if (containerIsVisible !== containerShouldBeVisible) { + this._container.hidden = !containerShouldBeVisible; + // check containers again to make sure parent containers can react to + // changing the visibility of this container + DependencyManager.checkContainers(); + } + } + } + exports.WysiwygTabMenu = WysiwygTabMenu; +}); 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 b0940925609..0d65a51114d 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,22 @@ /** * @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; + constructor(_quoteManager, className, objectType, containerSelector, messageBodySelector, _messageContentSelector, _supportDirectInsert) { + // remove "Action" from className + if (className.endsWith("Action")) { + className = className.substring(0, className.length - 6); } - 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, className, objectType); } } exports.UiMessageQuote = UiMessageQuote; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Reply.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Reply.js index cb9030319bb..8f7e2c77e14 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Reply.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Reply.js @@ -6,7 +6,7 @@ * @license GNU Lesser General Public License * @woltlabExcludeBundle tiny */ -define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Event/Handler", "../../Language", "../../Dom/Change/Listener", "../../Dom/Util", "../Dialog", "../Notification", "../../User", "../../Controller/Captcha", "../Scroll", "../../Component/Ckeditor", "WoltLabSuite/Core/Component/Ckeditor/Event"], function (require, exports, tslib_1, Ajax, Core, EventHandler, Language, Listener_1, Util_1, Dialog_1, UiNotification, User_1, Captcha_1, UiScroll, Ckeditor_1, Event_1) { +define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Event/Handler", "../../Language", "../../Dom/Change/Listener", "../../Dom/Util", "../Dialog", "../Notification", "../../User", "../../Controller/Captcha", "../Scroll", "../../Component/Ckeditor", "WoltLabSuite/Core/Component/Ckeditor/Event", "WoltLabSuite/Core/Component/Quote/Storage", "WoltLabSuite/Core/Component/Quote/Message"], function (require, exports, tslib_1, Ajax, Core, EventHandler, Language, Listener_1, Util_1, Dialog_1, UiNotification, User_1, Captcha_1, UiScroll, Ckeditor_1, Event_1, Storage_1, Message_1) { "use strict"; Ajax = tslib_1.__importStar(Ajax); Core = tslib_1.__importStar(Core); @@ -306,6 +306,10 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Event/ this._guestDialogId = guestDialogId; } else { + (0, Storage_1.clearQuotesForEditor)(this._textarea.id); + if (!this._getCKEditor().isVisible()) { + (0, Message_1.setActiveEditor)(); + } this._insertMessage(data); if (!User_1.default.userId) { Dialog_1.default.close(data.returnValues.guestDialogID); diff --git a/wcfsetup/install/files/lib/action/MessageQuoteAction.class.php b/wcfsetup/install/files/lib/action/MessageQuoteAction.class.php deleted file mode 100644 index eee83f8e0c1..00000000000 --- a/wcfsetup/install/files/lib/action/MessageQuoteAction.class.php +++ /dev/null @@ -1,224 +0,0 @@ - - */ -final class MessageQuoteAction extends AJAXProxyAction -{ - /** - * indicates if the WCF.Message.Quote.Manager object requesting data has any - * quote handlers which require updated object ids of full quotes - * @var int - */ - public $_getFullQuoteObjectIDs = false; - - /** - * list of quote ids - * @var string[] - */ - public $quoteIDs = []; - - /** - * list of object types - * @var string[] - */ - public $objectTypes = []; - - /** - * @inheritDoc - */ - public function readParameters() - { - AbstractSecureAction::readParameters(); - - if (isset($_POST['actionName'])) { - $this->actionName = StringUtil::trim($_POST['actionName']); - - if ( - !\in_array( - $this->actionName, - ['count', 'getQuotes', 'markForRemoval', 'remove', 'removeMarkedQuotes'] - ) - ) { - throw new UserInputException('actionName', 'invalid'); - } - } else { - throw new UserInputException('actionName'); - } - if (isset($_POST['getFullQuoteObjectIDs'])) { - $this->_getFullQuoteObjectIDs = \intval($_POST['getFullQuoteObjectIDs']); - } - if (isset($_POST['objectTypes']) && \is_array($_POST['objectTypes'])) { - $this->objectTypes = ArrayUtil::trim($_POST['objectTypes']); - } - if (isset($_POST['quoteIDs'])) { - $this->quoteIDs = ArrayUtil::trim($_POST['quoteIDs']); - - // validate quote ids - foreach ($this->quoteIDs as $key => $quoteID) { - if (MessageQuoteManager::getInstance()->getQuote($quoteID) === null) { - unset($this->quoteIDs[$key]); - } - } - } - } - - /** - * @inheritDoc - */ - public function execute() - { - AbstractAction::execute(); - - $returnValues = null; - switch ($this->actionName) { - case 'count': - $returnValues = [ - 'count' => $this->count(), - ]; - break; - - case 'getQuotes': - $returnValues = [ - 'template' => $this->getQuotes(), - ]; - break; - - case 'markForRemoval': - $this->markForRemoval(); - break; - - case 'remove': - $returnValues = [ - 'count' => $this->remove(), - ]; - break; - - case 'removeMarkedQuotes': - $returnValues = [ - 'count' => $this->removeMarkedQuotes(), - ]; - break; - - default: - throw new SystemException("Unknown action '" . $this->actionName . "'"); - break; - } - - if (\is_array($returnValues) && $this->_getFullQuoteObjectIDs) { - $returnValues['fullQuoteObjectIDs'] = $this->getFullQuoteObjectIDs(); - } - - $this->executed(); - - // force session update - WCF::getSession()->update(); - WCF::getSession()->disableUpdate(); - - if ($returnValues !== null) { - return new JsonResponse($returnValues); - } else { - return new EmptyResponse(200); - } - } - - /** - * Returns the count of stored quotes. - * - * @return int - */ - protected function count() - { - return MessageQuoteManager::getInstance()->countQuotes(); - } - - /** - * Returns the quote list template. - * - * @return string - */ - protected function getQuotes() - { - $supportPaste = isset($_POST['supportPaste']) ? (bool)$_POST['supportPaste'] : false; - - return MessageQuoteManager::getInstance()->getQuotes($supportPaste); - } - - /** - * @deprecated 5.5 This method is no longer used since 3.0. - */ - protected function markForRemoval() - { - if (!empty($this->quoteIDs)) { - MessageQuoteManager::getInstance()->markQuotesForRemoval($this->quoteIDs); - } - } - - /** - * Removes a list of quotes from storage and returns the remaining count. - * - * @return int - * @throws SystemException - * @throws UserInputException - */ - protected function remove() - { - if (empty($this->quoteIDs)) { - throw new UserInputException('quoteIDs'); - } - - foreach ($this->quoteIDs as $quoteID) { - if (!MessageQuoteManager::getInstance()->removeQuote($quoteID)) { - throw new SystemException("Unable to remove quote identified by '" . $quoteID . "'"); - } - } - - return $this->count(); - } - - /** - * Removes all quotes marked for removal and returns the remaining count. - * - * @return int - */ - protected function removeMarkedQuotes() - { - MessageQuoteManager::getInstance()->removeMarkedQuotes(); - - return $this->count(); - } - - /** - * Returns a list of full quotes by object ids for given object types. - * - * @return array - * @throws UserInputException - */ - protected function getFullQuoteObjectIDs() - { - if (empty($this->objectTypes)) { - throw new UserInputException('objectTypes'); - } - - try { - return MessageQuoteManager::getInstance()->getFullQuoteObjectIDs($this->objectTypes); - } catch (SystemException $e) { - throw new UserInputException('objectTypes'); - } - } -} diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index 5c673485c3a..70c9fb9523a 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -137,6 +137,9 @@ static function (\wcf\event\endpoint\ControllerCollecting $event) { $event->register(new \wcf\system\endpoint\controller\core\comments\responses\UpdateResponse()); $event->register(new \wcf\system\endpoint\controller\core\cronjobs\logs\ClearLogs()); $event->register(new \wcf\system\endpoint\controller\core\messages\GetMentionSuggestions()); + $event->register(new \wcf\system\endpoint\controller\core\messages\RenderQuote()); + $event->register(new \wcf\system\endpoint\controller\core\messages\GetMessageAuthor()); + $event->register(new \wcf\system\endpoint\controller\core\messages\ResetRemovalQuotes()); $event->register(new \wcf\system\endpoint\controller\core\sessions\DeleteSession()); $event->register(new \wcf\system\endpoint\controller\core\versionTrackers\RevertVersion()); $event->register(new \wcf\system\endpoint\controller\core\moderationQueues\ChangeJustifiedStatus()); diff --git a/wcfsetup/install/files/lib/data/IEmbeddedMessageObject.class.php b/wcfsetup/install/files/lib/data/IEmbeddedMessageObject.class.php new file mode 100644 index 00000000000..da483e1eb68 --- /dev/null +++ b/wcfsetup/install/files/lib/data/IEmbeddedMessageObject.class.php @@ -0,0 +1,19 @@ + + * @since 6.2 + */ +interface IEmbeddedMessageObject +{ + /** + * Loads embedded objects for the given object type and object IDs. + */ + public function loadEmbeddedObjects(): void; +} diff --git a/wcfsetup/install/files/lib/data/IMessageQuoteAction.class.php b/wcfsetup/install/files/lib/data/IMessageQuoteAction.class.php index 2ba2fed7bdd..c711053b299 100644 --- a/wcfsetup/install/files/lib/data/IMessageQuoteAction.class.php +++ b/wcfsetup/install/files/lib/data/IMessageQuoteAction.class.php @@ -8,6 +8,8 @@ * @author Alexander Ebert * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * + * @deprecated 6.2 */ interface IMessageQuoteAction { diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/GetMessageAuthor.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/GetMessageAuthor.class.php new file mode 100644 index 00000000000..e292ce4402c --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/GetMessageAuthor.class.php @@ -0,0 +1,65 @@ + + * @since 6.2 + */ +#[GetRequest('/core/messages/message-author')] +final class GetMessageAuthor implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $parameters = Helper::mapApiParameters($request, GetMessageAuthorParameters::class); + + $object = Helper::fetchObjectFromRequestParameter($parameters->objectID, $parameters->className); + \assert($object instanceof IMessage); + + $userProfile = UserProfileRuntimeCache::getInstance()->getObject($object->getUserID()); + + return new JsonResponse( + [ + "objectID" => $object->getObjectID(), + "authorID" => $userProfile->getUserID(), + "author" => $userProfile->getUsername(), + "title" => $object->getTitle(), + "avatar" => $userProfile->getAvatar()->getURL(), + "time" => (new \DateTime('@' . $object->getTime()))->format("c"), + "link" => $object->getLink(), + ], + 200, + [ + 'cache-control' => [ + 'max-age=300', + ], + ] + ); + } +} + +/** @internal */ +final class GetMessageAuthorParameters +{ + public function __construct( + /** @var non-empty-string */ + public readonly string $className, + /** @var positive-int */ + public readonly int $objectID, + ) { + } +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/RenderQuote.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/RenderQuote.class.php new file mode 100644 index 00000000000..c400e646d42 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/RenderQuote.class.php @@ -0,0 +1,85 @@ + + * @since 6.2 + */ +#[GetRequest('/core/messages/render-quote')] +final class RenderQuote implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $parameters = Helper::mapApiParameters($request, GetRenderQuoteParameters::class); + + $object = Helper::fetchObjectFromRequestParameter($parameters->objectID, $parameters->className); + \assert($object instanceof IMessage); + + $userProfile = UserProfileRuntimeCache::getInstance()->getObject($object->getUserID()); + if ($userProfile === null) { + $userProfile = UserProfile::getGuestUserProfile($object->getUsername()); + } + + if ($object instanceof IEmbeddedMessageObject) { + $object->loadEmbeddedObjects(); + } + + return new JsonResponse( + [ + "objectID" => $object->getObjectID(), + "authorID" => $userProfile->getUserID(), + "author" => $userProfile->getUsername(), + "avatar" => $userProfile->getAvatar()->getURL(), + "time" => (new \DateTime('@' . $object->getTime()))->format("c"), + "title" => $object->getTitle(), + "link" => $object->getLink(), + "rawMessage" => $parameters->fullQuote ? $this->renderFullQuote($object) : null, + "message" => $parameters->fullQuote ? $object->getFormattedMessage() : null + ], + 200, + ); + } + + private function renderFullQuote(IMessage $object): string + { + $htmlInputProcessor = new HtmlInputProcessor(); + $htmlInputProcessor->processIntermediate($object->getMessage()); + + if (MESSAGE_MAX_QUOTE_DEPTH) { + $htmlInputProcessor->enforceQuoteDepth(MESSAGE_MAX_QUOTE_DEPTH - 1, true); + } + + return $htmlInputProcessor->getHtml(); + } +} + +/** @internal */ +final class GetRenderQuoteParameters +{ + public function __construct( + /** @var non-empty-string */ + public readonly string $className, + /** @var positive-int */ + public readonly int $objectID, + public readonly bool $fullQuote = false, + ) { + } +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/ResetRemovalQuotes.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/ResetRemovalQuotes.class.php new file mode 100644 index 00000000000..53bb44aebe0 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/messages/ResetRemovalQuotes.class.php @@ -0,0 +1,30 @@ + + * @since 6.2 + */ +#[PostRequest('/core/messages/reset-removal-quotes')] +final class ResetRemovalQuotes implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + MessageQuoteManager::getInstance()->reset(); + + return new JsonResponse([]); + } +} diff --git a/wcfsetup/install/files/lib/system/event/listener/PreloadPhrasesCollectingListener.class.php b/wcfsetup/install/files/lib/system/event/listener/PreloadPhrasesCollectingListener.class.php index 155befa343b..503adcdfd09 100644 --- a/wcfsetup/install/files/lib/system/event/listener/PreloadPhrasesCollectingListener.class.php +++ b/wcfsetup/install/files/lib/system/event/listener/PreloadPhrasesCollectingListener.class.php @@ -136,6 +136,11 @@ public function __invoke(PreloadPhrasesCollecting $event): void $event->preload('wcf.message.share.permalink.html'); $event->preload('wcf.message.share.socialMedia'); + $event->preload('wcf.message.quote.quoteSelected'); + $event->preload('wcf.message.quote.quoteAndReply'); + $event->preload('wcf.message.quote.quoteMessage'); + $event->preload('wcf.message.quote.insertQuote'); + $event->preload('wcf.moderation.report.reportContent'); $event->preload('wcf.page.jumpTo'); diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/IWysiwygTabFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/IWysiwygTabFormContainer.class.php new file mode 100644 index 00000000000..92760f76156 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/IWysiwygTabFormContainer.class.php @@ -0,0 +1,27 @@ + + * @since 6.2 + */ +interface IWysiwygTabFormContainer extends IFormContainer +{ + /** + * Gets the icon associated with the tab. + */ + public function getIcon(): ?FontAwesomeIcon; + + /** + * Gets the name associated with the tab. + */ + public function getName(): string; +} diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php index 50c3d755ea1..99d9eebdc7b 100644 --- a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php @@ -8,13 +8,13 @@ use wcf\system\event\EventHandler; use wcf\system\form\builder\button\wysiwyg\WysiwygPreviewFormButton; use wcf\system\form\builder\container\FormContainer; -use wcf\system\form\builder\container\TabFormContainer; use wcf\system\form\builder\field\TMaximumLengthFormField; use wcf\system\form\builder\field\TMinimumLengthFormField; use wcf\system\form\builder\field\wysiwyg\WysiwygAttachmentFormField; use wcf\system\form\builder\field\wysiwyg\WysiwygFormField; use wcf\system\form\builder\IFormChildNode; use wcf\system\form\builder\TWysiwygFormNode; +use wcf\system\style\FontAwesomeIcon; /** * Represents the whole container with a WYSIWYG editor and the associated tab menu below it with @@ -85,12 +85,6 @@ class WysiwygFormContainer extends FormContainer */ protected $pollContainer; - /** - * quote-related data used to create the JavaScript quote manager - * @var null|array - */ - protected $quoteData; - /** * `true` if the wysiwyg field has to be filled out and `false` otherwise * @var bool @@ -139,6 +133,8 @@ class WysiwygFormContainer extends FormContainer */ protected $wysiwygField; + protected WysiwygQuoteFormContainer $quoteContainer; + /** * @inheritDoc * @return static @@ -479,13 +475,6 @@ public function populate() ->required($this->isRequired()) ->supportMentions($this->supportMentions) ->supportQuotes($this->supportQuotes); - if ($this->quoteData !== null) { - $this->wysiwygField->quoteData( - $this->quoteData['objectType'], - $this->quoteData['actionClass'], - $this->quoteData['selectors'] - ); - } $this->smiliesContainer = WysiwygSmileyFormContainer::create($this->wysiwygId . 'SmiliesTab') ->wysiwygId($this->getWysiwygId()) ->label('wcf.message.smilies') @@ -500,6 +489,10 @@ public function populate() $this->pollContainer->objectType($this->pollObjectType); } + $this->quoteContainer = WysiwygQuoteFormContainer::create($this->wysiwygId . 'QuoteContainer') + ->wysiwygId($this->getWysiwygId()) + ->available($this->supportQuotes); + $this->appendChildren([ $this->wysiwygField, WysiwygTabMenuFormContainer::create($this->wysiwygId . 'Tabs') @@ -509,21 +502,32 @@ public function populate() ->appendChildren([ $this->smiliesContainer, - TabFormContainer::create($this->wysiwygId . 'AttachmentsTab') + WysiwygTabFormContainer::create($this->wysiwygId . 'AttachmentsTab') ->addClass('formAttachmentContent') ->label('wcf.attachment.attachments') + ->name("attachments") + ->icon(FontAwesomeIcon::fromValues('paperclip')) + ->wysiwygId($this->getWysiwygId()) ->appendChild( FormContainer::create($this->wysiwygId . 'AttachmentsContainer') ->appendChild($this->attachmentField) ), - TabFormContainer::create($this->wysiwygId . 'SettingsTab') + WysiwygTabFormContainer::create($this->wysiwygId . 'SettingsTab') ->label('wcf.message.settings') + ->icon(FontAwesomeIcon::fromValues('gear')) + ->name('settings') + ->wysiwygId($this->getWysiwygId()) ->appendChild($this->settingsContainer), - TabFormContainer::create($this->wysiwygId . 'PollTab') + WysiwygTabFormContainer::create($this->wysiwygId . 'PollTab') ->label('wcf.poll.management') + ->icon(FontAwesomeIcon::fromValues('chart-bar')) + ->name('poll') + ->wysiwygId($this->getWysiwygId()) ->appendChild($this->pollContainer), + + $this->quoteContainer, ]), ]); @@ -567,23 +571,13 @@ public function preselect($preselect = 'true') * @param string $objectType name of the relevant `com.woltlab.wcf.message.quote` object type * @param string $actionClass action class implementing `wcf\data\IMessageQuoteAction` * @param string[] $selectors selectors for the quotable content (required keys: `container`, `messageBody`, and `messageContent`) + * * @return static + * + * @deprecated 6.2 */ public function quoteData($objectType, $actionClass, array $selectors = []) { - if ($this->wysiwygField !== null) { - $this->wysiwygField->quoteData($objectType, $actionClass, $selectors); - } else { - $this->supportQuotes(); - - // the parameters are validated by `WysiwygFormField` - $this->quoteData = [ - 'actionClass' => $actionClass, - 'objectType' => $objectType, - 'selectors' => $selectors, - ]; - } - return $this; } @@ -646,12 +640,14 @@ public function supportMentions($supportMentions = true) */ public function supportQuotes($supportQuotes = true) { - if ($this->wysiwygField !== null) { - $this->wysiwygField->supportQuotes($supportQuotes); - } else { - $this->supportQuotes = $supportQuotes; + $this->supportQuotes = $supportQuotes; + + if (isset($this->quoteContainer)) { + $this->quoteContainer->available($supportQuotes); } + $this->wysiwygField?->supportQuotes($supportQuotes); + return $this; } diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygQuoteFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygQuoteFormContainer.class.php new file mode 100644 index 00000000000..be8474f842b --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygQuoteFormContainer.class.php @@ -0,0 +1,31 @@ + + * @since 6.2 + */ +class WysiwygQuoteFormContainer extends WysiwygTabFormContainer +{ + protected $templateName = 'shared_wysiwygQuoteFormContainer'; + + public function __construct() + { + $this->icon(FontAwesomeIcon::fromValues('quote-left')) + ->name('quotes') + ->label('wcf.bbcode.quote'); + } + + #[\Override] + public function isAvailable() + { + return $this->available; + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygSmileyFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygSmileyFormContainer.class.php index dfefc168fdd..83c8ba9b9cb 100644 --- a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygSmileyFormContainer.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygSmileyFormContainer.class.php @@ -8,6 +8,7 @@ use wcf\system\form\builder\container\TabTabMenuFormContainer; use wcf\system\form\builder\TWysiwygFormNode; use wcf\system\form\builder\wysiwyg\WysiwygSmileyFormNode; +use wcf\system\style\FontAwesomeIcon; use wcf\util\StringUtil; /** @@ -18,7 +19,7 @@ * @license GNU Lesser General Public License * @since 5.2 */ -class WysiwygSmileyFormContainer extends TabTabMenuFormContainer +class WysiwygSmileyFormContainer extends TabTabMenuFormContainer implements IWysiwygTabFormContainer { use TWysiwygFormNode; @@ -76,4 +77,16 @@ public function populate() $this->addClass('messageTabMenu'); } } + + #[\Override] + public function getIcon(): ?FontAwesomeIcon + { + return FontAwesomeIcon::fromValues('face-smile'); + } + + #[\Override] + public function getName(): string + { + return 'smilies'; + } } diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygTabFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygTabFormContainer.class.php new file mode 100644 index 00000000000..68923c34914 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygTabFormContainer.class.php @@ -0,0 +1,60 @@ + + * @since 6.2 + */ +class WysiwygTabFormContainer extends TabFormContainer implements IWysiwygTabFormContainer +{ + use TWysiwygFormNode; + + /** + * @inheritDoc + */ + protected $templateName = 'shared_wysiwygTabFormContainer'; + + protected ?FontAwesomeIcon $icon = null; + protected string $name = ''; + + #[\Override] + public function getIcon(): ?FontAwesomeIcon + { + return $this->icon; + } + + /** + * Sets the icon associated with the tab. + */ + public function icon(?FontAwesomeIcon $icon): static + { + $this->icon = $icon; + + return $this; + } + + #[\Override] + public function getName(): string + { + return $this->name; + } + + /** + * Sets the name associated with the tab. + */ + public function name(string $name): static + { + $this->name = $name; + + return $this; + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php index c6022901843..1f98ffff1b8 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php @@ -2,8 +2,6 @@ namespace wcf\system\form\builder\field\wysiwyg; -use wcf\data\IMessageQuoteAction; -use wcf\data\object\type\ObjectTypeCache; use wcf\system\bbcode\BBCodeHandler; use wcf\system\form\builder\data\processor\CustomFormDataProcessor; use wcf\system\form\builder\field\AbstractFormField; @@ -63,12 +61,6 @@ final class WysiwygFormField extends AbstractFormField implements */ protected $lastEditTime = 0; - /** - * quote-related data used to create the JavaScript quote manager - * @var null|array - */ - protected $quoteData; - /** * is `true` if this form field supports attachments, otherwise `false` * @var bool @@ -110,16 +102,6 @@ public function autosaveId($autosaveId) return $this; } - /** - * @inheritDoc - */ - public function cleanup(): static - { - MessageQuoteManager::getInstance()->saved(); - - return $this; - } - /** * Returns the identifier used to autosave the field value. If autosave is disabled, * an empty string is returned. @@ -136,10 +118,6 @@ public function getAutosaveId() */ public function getFieldHtml() { - if ($this->supportsQuotes()) { - MessageQuoteManager::getInstance()->assignVariables(); - } - /** @noinspection PhpUndefinedFieldInspection */ $disallowedBBCodesPermission = $this->getObjectType()->disallowedBBCodesPermission; if ($disallowedBBCodesPermission === null) { @@ -177,10 +155,13 @@ public function getLastEditTime() * Returns all quote data or specific quote data if an argument is given. * * @param null|string $index quote data index + * * @return string[]|string * * @throws \BadMethodCallException if quotes are not supported for this field * @throws \InvalidArgumentException if unknown quote data is requested + * + * @deprecated 6.2 */ public function getQuoteData($index = null) { @@ -188,15 +169,7 @@ public function getQuoteData($index = null) throw new \BadMethodCallException("Quotes are not supported for field '{$this->getId()}'."); } - if ($index === null) { - return $this->quoteData; - } - - if (!isset($this->quoteData[$index])) { - throw new \InvalidArgumentException("Unknown quote data '{$index}' for field '{$this->getId()}'."); - } - - return $this->quoteData[$index]; + return ""; } /** @@ -234,6 +207,10 @@ function (IFormDocument $document, array $parameters) { $parameters[$this->getObjectProperty() . '_htmlInputProcessor'] = $this->htmlInputProcessor; } + if ($this->supportQuotes) { + MessageQuoteManager::getInstance()->saved(); + } + return $parameters; } )); @@ -250,48 +227,15 @@ function (IFormDocument $document, array $parameters) { * @param string $objectType name of the relevant `com.woltlab.wcf.message.quote` object type * @param string $actionClass action class implementing `wcf\data\IMessageQuoteAction` * @param string[] $selectors selectors for the quotable content (required keys: `container`, `messageBody`, and `messageContent`) + * * @return static * * @throws \InvalidArgumentException if any of the given arguments is invalid + * + * @deprecated 6.2 */ public function quoteData($objectType, $actionClass, array $selectors = []) { - if ( - ObjectTypeCache::getInstance()->getObjectTypeByName( - 'com.woltlab.wcf.message.quote', - $objectType - ) === null - ) { - throw new \InvalidArgumentException( - "Unknown message quote object type '{$objectType}' for field '{$this->getId()}'." - ); - } - - if (!\class_exists($actionClass)) { - throw new \InvalidArgumentException("Unknown class '{$actionClass}' for field '{$this->getId()}'."); - } - if (!\is_subclass_of($actionClass, IMessageQuoteAction::class)) { - throw new \InvalidArgumentException( - "'{$actionClass}' does not implement '" . IMessageQuoteAction::class . "' for field '{$this->getId()}'." - ); - } - - if (!empty($selectors)) { - foreach (['container', 'messageBody', 'messageContent'] as $selector) { - if (!isset($selectors[$selector])) { - throw new \InvalidArgumentException("Missing selector '{$selector}' for field '{$this->getId()}'."); - } - } - } - - $this->supportQuotes(); - - $this->quoteData = [ - 'actionClass' => $actionClass, - 'objectType' => $objectType, - 'selectors' => $selectors, - ]; - return $this; } @@ -351,13 +295,6 @@ public function supportQuotes($supportQuotes = true) { $this->supportQuotes = $supportQuotes; - if (!$this->supportsQuotes()) { - // unset previously set quote data - $this->quoteData = null; - } else { - MessageQuoteManager::getInstance()->readParameters(); - } - return $this; } diff --git a/wcfsetup/install/files/lib/system/message/quote/AbstractMessageQuoteHandler.class.php b/wcfsetup/install/files/lib/system/message/quote/AbstractMessageQuoteHandler.class.php index 35981c01c14..7534f6f2af9 100644 --- a/wcfsetup/install/files/lib/system/message/quote/AbstractMessageQuoteHandler.class.php +++ b/wcfsetup/install/files/lib/system/message/quote/AbstractMessageQuoteHandler.class.php @@ -12,6 +12,8 @@ * @author Alexander Ebert * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * + * @deprecated 6.2 */ abstract class AbstractMessageQuoteHandler extends SingletonFactory implements IMessageQuoteHandler { diff --git a/wcfsetup/install/files/lib/system/message/quote/IMessageQuoteHandler.class.php b/wcfsetup/install/files/lib/system/message/quote/IMessageQuoteHandler.class.php index a302dda0e1c..c86d11b0370 100644 --- a/wcfsetup/install/files/lib/system/message/quote/IMessageQuoteHandler.class.php +++ b/wcfsetup/install/files/lib/system/message/quote/IMessageQuoteHandler.class.php @@ -8,6 +8,8 @@ * @author Alexander Ebert * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * + * @deprecated 6.2 */ interface IMessageQuoteHandler { diff --git a/wcfsetup/install/files/lib/system/message/quote/MessageQuoteManager.class.php b/wcfsetup/install/files/lib/system/message/quote/MessageQuoteManager.class.php index 500108d3aea..2fa2c0b3c14 100644 --- a/wcfsetup/install/files/lib/system/message/quote/MessageQuoteManager.class.php +++ b/wcfsetup/install/files/lib/system/message/quote/MessageQuoteManager.class.php @@ -3,11 +3,8 @@ namespace wcf\system\message\quote; use wcf\data\IMessage; -use wcf\data\object\type\ObjectType; -use wcf\data\object\type\ObjectTypeCache; use wcf\system\event\EventHandler; use wcf\system\exception\SystemException; -use wcf\system\html\input\HtmlInputProcessor; use wcf\system\SingletonFactory; use wcf\system\WCF; use wcf\util\ArrayUtil; @@ -15,53 +12,24 @@ /** * Manages message quotes. * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH + * @author Olaf Braun, Alexander Ebert + * @copyright 2001-2025 WoltLab GmbH * @license GNU Lesser General Public License */ class MessageQuoteManager extends SingletonFactory { /** - * current object ids - * @var int[] - */ - protected $objectIDs = []; - - /** - * current object type name - * @var string - */ - protected $objectType = ''; - - /** - * list of object types - * @var ObjectType[] - */ - protected $objectTypes = []; - - /** - * list of stored quotes - * @var mixed[][] - */ - protected $quotes = []; - - /** - * list of quote messages by quote id - * @var array - */ - protected $quoteData = []; - - /** - * message id for quoting - * @var int + * list of quote ids to be removed + * @var string[] */ - protected $quoteMessageID = 0; + protected array $removeQuoteIDs = []; /** - * list of quote ids to be removed - * @var string[] + * list of quote that was used in the current request + * + * @var array */ - protected $removeQuoteIDs = []; + protected array $usedQuotes = []; /** * @inheritDoc @@ -71,16 +39,8 @@ protected function init() // load stored quotes from session $messageQuotes = WCF::getSession()->getVar('__messageQuotes'); if (\is_array($messageQuotes)) { - $this->quotes = $messageQuotes['quotes'] ?? []; - $this->quoteData = $messageQuotes['quoteData'] ?? []; $this->removeQuoteIDs = $messageQuotes['removeQuoteIDs'] ?? []; } - - // load object types - $objectTypes = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.message.quote'); - foreach ($objectTypes as $objectType) { - $this->objectTypes[$objectType->objectType] = $objectType; - } } /** @@ -94,8 +54,10 @@ protected function init() * @param string $message * @param string $fullQuote * @param bool $returnFalseIfExists + * * @return mixed * @throws SystemException + * @deprecated 6.2 */ public function addQuote( $objectType, @@ -105,61 +67,7 @@ public function addQuote( $fullQuote = '', $returnFalseIfExists = true ) { - if (!isset($this->objectTypes[$objectType])) { - throw new SystemException("Object type '" . $objectType . "' is unknown"); - } - - if (!isset($this->quotes[$objectType])) { - $this->quotes[$objectType] = []; - } - - if (!isset($this->quotes[$objectType][$objectID])) { - $this->quotes[$objectType][$objectID] = []; - } - - $quoteID = $this->getQuoteID($objectType, $objectID, $message, $fullQuote); - if (!isset($this->quotes[$objectType][$objectID][$quoteID])) { - $this->quotes[$objectType][$objectID][$quoteID] = 0; - $this->quoteData[$quoteID] = $message; - - // save parent object id - - if (!isset($this->quoteData['parents'])) { - $this->quoteData['parents'] = []; - } - - if (!isset($this->quoteData['parents'][$objectType])) { - $this->quoteData['parents'][$objectType] = []; - } - - if (!isset($this->quoteData['parents'][$objectType][$parentObjectID])) { - $this->quoteData['parents'][$objectType][$parentObjectID] = []; - } - - $this->quoteData['parents'][$objectType][$parentObjectID][] = $objectID; - $this->quoteData[$quoteID . '_pID'] = $parentObjectID; - - if (!empty($fullQuote)) { - $htmlInputProcessor = new HtmlInputProcessor(); - $htmlInputProcessor->processIntermediate($fullQuote); - - if (MESSAGE_MAX_QUOTE_DEPTH) { - $htmlInputProcessor->enforceQuoteDepth(MESSAGE_MAX_QUOTE_DEPTH - 1, true); - } - - $parameters = ['htmlInputProcessor' => $htmlInputProcessor]; - EventHandler::getInstance()->fireAction($this, 'addFullQuote', $parameters); - - $this->quotes[$objectType][$objectID][$quoteID] = 1; - $this->quoteData[$quoteID . '_fq'] = $htmlInputProcessor->getHtml(); - } - - $this->updateSession(); - } elseif ($returnFalseIfExists) { - return false; - } - - return $quoteID; + return false; } /** @@ -169,7 +77,9 @@ public function addQuote( * @param int $objectID * @param string $message * @param string $fullQuote + * * @return string + * @deprecated 6.2 */ public function getQuoteID($objectType, $objectID, $message, $fullQuote = '') { @@ -180,65 +90,12 @@ public function getQuoteID($objectType, $objectID, $message, $fullQuote = '') * Removes a quote from storage and returns true if the quote has successfully been removed. * * @param string $quoteID + * * @return bool + * @deprecated 6.2 */ public function removeQuote($quoteID) { - if (!isset($this->quoteData[$quoteID])) { - return false; - } - - foreach ($this->quotes as $objectType => $objectIDs) { - foreach ($objectIDs as $objectID => $quoteIDs) { - foreach ($quoteIDs as $qID => $isFullQuote) { - if ($qID == $quoteID) { - unset($this->quotes[$objectType][$objectID][$qID]); - - // clean-up structure - if (empty($this->quotes[$objectType][$objectID])) { - unset($this->quotes[$objectType][$objectID]); - - if (empty($this->quotes[$objectType])) { - unset($this->quotes[$objectType]); - } - } - - unset($this->quoteData[$quoteID]); - if ($isFullQuote) { - unset($this->quoteData[$quoteID . '_fq']); - } - - // remove parent object id reference - if (isset($this->quoteData[$quoteID . '_pID'])) { - $parentObjectID = $this->quoteData[$quoteID . '_pID']; - if (!isset($this->quotes[$objectType][$objectID])) { - if (isset($this->quoteData['parents'][$objectType][$parentObjectID][$objectID])) { - unset($this->quoteData['parents'][$objectType][$parentObjectID][$objectID]); - - // cleanup - if (empty($this->quoteData['parents'][$objectType][$parentObjectID])) { - unset($this->quoteData['parents'][$objectType][$parentObjectID]); - - if (empty($this->quoteData['parents'][$objectType])) { - unset($this->quoteData['parents'][$objectType]); - - if (empty($this->quoteData['parents'])) { - unset($this->quoteData['parents']); - } - } - } - } - } - } - - $this->updateSession(); - - return true; - } - } - } - } - return false; } @@ -246,34 +103,12 @@ public function removeQuote($quoteID) * Returns an array containing the quote author, link and text. * * @param string $quoteID + * * @return string[]|false + * @deprecated 6.2 */ public function getQuoteComponents($quoteID) { - if ($this->getQuote($quoteID, false) === null) { - return false; - } - - // find the quote and simulate a regular call to render quotes - foreach ($this->quotes as $objectType => $objectIDs) { - foreach ($objectIDs as $objectID => $quoteIDs) { - if (isset($quoteIDs[$quoteID])) { - $quoteHandler = \call_user_func([$this->objectTypes[$objectType]->className, 'getInstance']); - $renderedQuotes = $quoteHandler->renderQuotes([ - $objectID => [ - $quoteID => $quoteIDs[$quoteID], - ], - ], true, false); - - $this->markQuotesForRemoval([$quoteID]); - - $renderedQuotes[0]['isFullQuote'] = (isset($this->quoteData[$quoteID . '_fq'])); - - return $renderedQuotes[0]; - } - } - } - return false; } @@ -281,18 +116,14 @@ public function getQuoteComponents($quoteID) * Returns a list of quotes. * * @param bool $supportPaste + * * @return string + * + * @deprecated 6.2 */ public function getQuotes($supportPaste = false) { - $template = ''; - - foreach ($this->quotes as $objectType => $objectData) { - $quoteHandler = \call_user_func([$this->objectTypes[$objectType]->className, 'getInstance']); - $template .= $quoteHandler->render($objectData, $supportPaste); - } - - return $template; + return ''; } /** @@ -301,40 +132,14 @@ public function getQuotes($supportPaste = false) * @param string $objectType * @param int[] $objectIDs * @param bool $markForRemoval + * * @return string[] + * + * @deprecated 6.2 */ public function getQuotesByObjectIDs($objectType, array $objectIDs, $markForRemoval = true) { - if (!isset($this->quotes[$objectType])) { - return []; - } - - $data = []; - $removeQuoteIDs = []; - foreach ($this->quotes[$objectType] as $objectID => $quoteIDs) { - if (\in_array($objectID, $objectIDs)) { - $data[$objectID] = $quoteIDs; - - // mark quotes for removal - if ($markForRemoval) { - $removeQuoteIDs = \array_merge($removeQuoteIDs, \array_keys($quoteIDs)); - } - } - } - - // no quotes found - if (empty($data)) { - return []; - } - - // mark quotes for removal - if (!empty($removeQuoteIDs)) { - $this->markQuotesForRemoval($removeQuoteIDs); - } - - $quoteHandler = \call_user_func([$this->objectTypes[$objectType]->className, 'getInstance']); - - return $quoteHandler->renderQuotes($data); + return []; } /** @@ -343,40 +148,14 @@ public function getQuotesByObjectIDs($objectType, array $objectIDs, $markForRemo * @param string $objectType * @param int $parentObjectID * @param bool $markForRemoval + * * @return string[] + * + * @deprecated 6.2 */ public function getQuotesByParentObjectID($objectType, $parentObjectID, $markForRemoval = true) { - if (!isset($this->quoteData['parents'][$objectType][$parentObjectID])) { - return []; - } - - $data = []; - $removeQuoteIDs = []; - foreach ($this->quoteData['parents'][$objectType][$parentObjectID] as $objectID) { - if (isset($this->quotes[$objectType][$objectID])) { - $data[$objectID] = $this->quotes[$objectType][$objectID]; - - // mark quotes for removal - if ($markForRemoval) { - $removeQuoteIDs = \array_merge($removeQuoteIDs, \array_keys($data[$objectID])); - } - } - } - - // no quotes found - if (empty($data)) { - return []; - } - - // mark quotes for removal - if (!empty($removeQuoteIDs)) { - $this->markQuotesForRemoval($removeQuoteIDs); - } - - $quoteHandler = \call_user_func([$this->objectTypes[$objectType]->className, 'getInstance']); - - return $quoteHandler->renderQuotes($data, false); + return []; } /** @@ -384,16 +163,13 @@ public function getQuotesByParentObjectID($objectType, $parentObjectID, $markFor * * @param string $quoteID * @param bool $useFullQuote + * * @return string|null + * + * @deprecated 6.2 */ public function getQuote($quoteID, $useFullQuote = true) { - if ($useFullQuote && isset($this->quoteData[$quoteID . '_fq'])) { - return $this->quoteData[$quoteID . '_fq']; - } elseif (isset($this->quoteData[$quoteID])) { - return $this->quoteData[$quoteID]; - } - return null; } @@ -401,20 +177,13 @@ public function getQuote($quoteID, $useFullQuote = true) * Returns the object id by quote id. * * @param string $quoteID + * * @return int|null + * + * @deprecated 6.2 */ public function getObjectID($quoteID) { - if (isset($this->quoteData[$quoteID])) { - foreach ($this->quotes as $objectIDs) { - foreach ($objectIDs as $objectID => $quoteIDs) { - if (isset($quoteIDs[$quoteID])) { - return $objectID; - } - } - } - } - return null; } @@ -423,10 +192,10 @@ public function getObjectID($quoteID) * * @param string[] $quoteIDs */ - public function markQuotesForRemoval(array $quoteIDs) + public function markQuotesForRemoval(array $quoteIDs): void { foreach ($quoteIDs as $index => $quoteID) { - if (!isset($this->quoteData[$quoteID]) || \in_array($quoteID, $this->removeQuoteIDs)) { + if (\in_array($quoteID, $this->removeQuoteIDs)) { unset($quoteIDs[$index]); } } @@ -443,7 +212,9 @@ public function markQuotesForRemoval(array $quoteIDs) * @param IMessage $message * @param string $text * @param bool $renderAsString + * * @return array|string + * @deprecated 6.2 */ public function renderQuote(IMessage $message, $text, $renderAsString = true) { @@ -469,68 +240,36 @@ public function renderQuote(IMessage $message, $text, $renderAsString = true) /** * Removes quotes marked for removal. + * + * @deprecated 6.2 */ public function removeMarkedQuotes() { - if (!empty($this->removeQuoteIDs)) { - foreach ($this->removeQuoteIDs as $quoteID) { - $this->removeQuote($quoteID); - } - - // reset list of quote ids marked for removal - $this->removeQuoteIDs = []; - - $this->updateSession(); - } } /** * Returns the number of stored quotes. * * @return int + * @deprecated 6.2 */ public function countQuotes() { - $count = 0; - foreach ($this->quoteData as $quoteID => $quote) { - if (\strlen($quoteID) == 8) { - $count++; - } - } - - return $count; + return 0; } /** * Returns a list of full quotes by object id for given object types. * * @param string[] $objectTypes + * * @return mixed[][] * @throws SystemException + * @deprecated 6.2 */ public function getFullQuoteObjectIDs(array $objectTypes) { - $objectIDs = []; - - foreach ($objectTypes as $objectType) { - if (!isset($this->objectTypes[$objectType])) { - throw new SystemException("Object type '" . $objectType . "' is unknown"); - } - - $objectIDs[$objectType] = []; - if (isset($this->quotes[$objectType])) { - foreach ($this->quotes[$objectType] as $objectID => $quotes) { - foreach ($quotes as $isFullQuote) { - if ($isFullQuote) { - $objectIDs[$objectType][] = $objectID; - break; - } - } - } - } - } - - return $objectIDs; + return []; } /** @@ -538,126 +277,144 @@ public function getFullQuoteObjectIDs(array $objectTypes) * * @param string $objectType * @param int[] $objectIDs + * * @throws SystemException + * + * @deprecated 6.2 */ public function initObjects($objectType, array $objectIDs) { - if (!isset($this->objectTypes[$objectType])) { - throw new SystemException("Object type '" . $objectType . "' is unknown"); - } - - $this->objectIDs = ArrayUtil::toIntegerArray($objectIDs); - $this->objectType = $objectType; } /** * Reads the quote message id. + * + * @deprecated 6.2 */ public function readParameters() { - if (isset($_REQUEST['quoteMessageID'])) { - $this->quoteMessageID = \intval($_REQUEST['quoteMessageID']); - } } /** * Reads a list of quote ids to remove. */ - public function readFormParameters() + public function readFormParameters(): void { if (isset($_REQUEST['__removeQuoteIDs']) && \is_array($_REQUEST['__removeQuoteIDs'])) { $quoteIDs = ArrayUtil::trim($_REQUEST['__removeQuoteIDs']); - foreach ($quoteIDs as $index => $quoteID) { - if (!isset($this->quoteData[$quoteID])) { - unset($quoteIDs[$index]); + foreach ($quoteIDs as $editorID => $uuids) { + if (!\is_string($editorID) || !\is_array($uuids)) { + unset($quoteIDs[$editorID]); } } if (!empty($quoteIDs)) { - $this->removeQuoteIDs = \array_merge($this->removeQuoteIDs, $quoteIDs); + $this->usedQuotes = \array_merge($this->usedQuotes, $quoteIDs); } } } /** - * Removes quotes after saving current message. + * Store the quote uuids that should be removed in the next request. */ - public function saved() + public function saved(): void { - $this->removeMarkedQuotes(); + foreach ($this->usedQuotes as $quoteIDs) { + $this->removeQuoteIDs = \array_merge($this->removeQuoteIDs, $quoteIDs); + } + + $this->usedQuotes = []; + + $this->updateSession(); } /** * Assigns variables on page load. + * + * @deprecated 6.2 */ public function assignVariables() { - $fullQuoteObjectIDs = []; - if (!empty($this->objectType) && !empty($this->objectIDs) && isset($this->quotes[$this->objectType])) { - foreach ($this->quotes[$this->objectType] as $objectID => $quotes) { - if (!\in_array($objectID, $this->objectIDs)) { - continue; - } - - foreach ($quotes as $isFullQuote) { - if ($isFullQuote) { - $fullQuoteObjectIDs[] = $objectID; - break; - } - } - } - } - - WCF::getTPL()->assign([ - '__quoteCount' => $this->countQuotes(), - '__quoteFullQuote' => $fullQuoteObjectIDs, - '__quoteRemove' => $this->removeQuoteIDs, - ]); } /** * Returns quote message id. * * @return int + * @deprecated 6.2 */ public function getQuoteMessageID() { - return $this->quoteMessageID; + return 0; } /** * Removes orphaned quote ids * * @param int[] $quoteIDs + * + * @deprecated 6.2 */ public function removeOrphanedQuotes(array $quoteIDs) { - foreach ($quoteIDs as $quoteID) { - $this->removeQuote($quoteID); - } - - $this->updateSession(); } /** * Returns true if a quote id represents a full quote. * * @param string $quoteID + * * @return bool + * @deprecated 6.2 */ public function isFullQuote($quoteID) { - return isset($this->quoteData[$quoteID . '_fq']); + return false; + } + + /** + * Returns the list of quote uuids to be removed. + * + * @return string[] + * @since 6.2 + */ + public function getRemoveQuoteIDs(): array + { + return $this->removeQuoteIDs; + } + + /** + * Returns the list of quote uuids that are used in the current request, + * but the creation of the message wasn't successful. + * This means that these others are only marked as having been used + * and only deleted when the message has been successfully saved. + * + * @return array + * @since 6.2 + */ + public function getUsedQuotes(): array + { + return $this->usedQuotes; + } + + /** + * Resets the list of quote uuids to be removed. + * + * @since 6.2 + */ + public function reset(): void + { + $this->removeQuoteIDs = []; + + $this->updateSession(); } /** * Updates data stored in session, */ - protected function updateSession() + protected function updateSession(): void { WCF::getSession()->register('__messageQuotes', [ - 'quotes' => $this->quotes, - 'quoteData' => $this->quoteData, 'removeQuoteIDs' => $this->removeQuoteIDs, ]); } diff --git a/wcfsetup/install/files/lib/system/message/quote/QuotedMessage.class.php b/wcfsetup/install/files/lib/system/message/quote/QuotedMessage.class.php index 709ea1accb8..db856fa4708 100644 --- a/wcfsetup/install/files/lib/system/message/quote/QuotedMessage.class.php +++ b/wcfsetup/install/files/lib/system/message/quote/QuotedMessage.class.php @@ -21,6 +21,8 @@ * @method int getUserID() * @method string getUsername() * @method bool isVisible() + * + * @deprecated 6.2 */ class QuotedMessage implements \Countable, \Iterator { diff --git a/wcfsetup/install/files/style/bbcode/quote.scss b/wcfsetup/install/files/style/bbcode/quote.scss index c2e2490cd70..0d4b07f314a 100644 --- a/wcfsetup/install/files/style/bbcode/quote.scss +++ b/wcfsetup/install/files/style/bbcode/quote.scss @@ -8,7 +8,7 @@ display: grid; font-style: normal; grid-template-areas: - "icon title" + "icon title" "content content"; grid-template-columns: 24px auto; margin: 2em 0 1em 0; @@ -79,3 +79,38 @@ margin-bottom: 0 !important; } } + +.quoteBox.quoteBox--tabMenu { + grid-template-areas: + "icon title buttons" + "content content content"; + grid-template-columns: 24px auto minmax(0, min-content); + margin: 0; +} + +.quoteBox.quoteBox--tabMenu + .quoteBox.quoteBox--tabMenu { + margin-top: 10px; +} + +.quoteBoxButtons { + align-self: center; + column-gap: 5px; + display: flex; + grid-area: buttons; + white-space: nowrap; +} + +.quoteBox.quoteBox--tabMenu :is(.quoteBoxIcon, .quoteBoxTitle) { + align-self: center; +} + +.quoteBox.quoteBox--tabMenu .quoteBoxContent { + pointer-events: none !important; +} + +@include screen-xs { + .messageTabMenu:not(.messageTabMenuContent) > .messageTabMenuContent.messageTabMenuContent--quotes.active { + padding-left: 10px; + padding-right: 10px; + } +} diff --git a/wcfsetup/install/files/style/ui/tooltip.scss b/wcfsetup/install/files/style/ui/tooltip.scss index 202c3db064b..ef2ccf7a1a3 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) { diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index e57287ce376..445413a35fc 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -4246,16 +4246,10 @@ Erlaubte Dateiendungen: gif, jpg, jpeg, png, webp]]> - - - - - - @@ -7596,5 +7590,11 @@ Benachrichtigungen auf {PAGE_TITLE|phra + + + + + + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index a8daa290693..508c609acec 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -4192,16 +4192,10 @@ Allowed extensions: gif, jpg, jpeg, png, webp]]> - - - - - - @@ -7487,5 +7481,11 @@ your notifications on {PAGE_TITLE|phras + + + + + +