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(`
+
+ `);
+
+ 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(`
+
+ `);
+ 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
+
+
+
+
+
+