- name: Run prettier
run: |
shopt -s globstar
- npx prettier -w wcfsetup/install/files/ts/**/*.ts
+ npx prettier -w ts/**/*.ts
- run: echo "::add-matcher::.github/diff.json"
- name: Show diff
run: |
-import DatePicker from "./wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker";
-import Devtools from "./wcfsetup/install/files/ts/WoltLabSuite/Core/Devtools";
-import DomUtil from "./wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util";
-import * as ColorUtil from "./wcfsetup/install/files/ts/WoltLabSuite/Core/ColorUtil";
-import * as EventHandler from "./wcfsetup/install/files/ts/WoltLabSuite/Core/Event/Handler";
-import UiDropdownSimple from "./wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Simple";
+import DatePicker from "./ts/WoltLabSuite/Core/Date/Picker";
+import Devtools from "./ts/WoltLabSuite/Core/Devtools";
+import DomUtil from "./ts/WoltLabSuite/Core/Dom/Util";
+import * as ColorUtil from "./ts/WoltLabSuite/Core/ColorUtil";
+import * as EventHandler from "./ts/WoltLabSuite/Core/Event/Handler";
+import UiDropdownSimple from "./ts/WoltLabSuite/Core/Ui/Dropdown/Simple";
import "@woltlab/zxcvbn";
-import { Reaction } from "./wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Data";
+import { Reaction } from "./ts/WoltLabSuite/Core/Ui/Reaction/Data";
declare global {
interface Window {
--- /dev/null
+/**
+ * Bootstraps WCF's JavaScript with additions for the ACP usage.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Bootstrap
+ */
+
+import * as Core from "../Core";
+import { BoostrapOptions, setup as bootstrapSetup } from "../Bootstrap";
+import * as UiPageMenu from "./Ui/Page/Menu";
+
+interface AcpBootstrapOptions {
+ bootstrap: BoostrapOptions;
+}
+
+/**
+ * Bootstraps general modules and frontend exclusive ones.
+ *
+ * @param {Object=} options bootstrap options
+ */
+export function setup(options: AcpBootstrapOptions): void {
+ options = Core.extend(
+ {
+ bootstrap: {
+ enableMobileMenu: true,
+ },
+ },
+ options,
+ ) as AcpBootstrapOptions;
+
+ bootstrapSetup(options.bootstrap);
+ UiPageMenu.init();
+}
--- /dev/null
+/**
+ * Abstract implementation of the JavaScript component of a form field handling a list of packages.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList
+ * @since 5.2
+ */
+
+import * as Core from "../../../../../../Core";
+import * as Language from "../../../../../../Language";
+import * as DomTraverse from "../../../../../../Dom/Traverse";
+import DomChangeListener from "../../../../../../Dom/Change/Listener";
+import DomUtil from "../../../../../../Dom/Util";
+import { PackageData } from "./Data";
+
+abstract class AbstractPackageList<TPackageData extends PackageData = PackageData> {
+ protected readonly addButton: HTMLAnchorElement;
+ protected readonly form: HTMLFormElement;
+ protected readonly formFieldId: string;
+ protected readonly packageList: HTMLOListElement;
+ protected readonly packageIdentifier: HTMLInputElement;
+
+ // see `wcf\data\package\Package::isValidPackageName()`
+ protected static readonly packageIdentifierRegExp = new RegExp(/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/);
+
+ // see `wcf\data\package\Package::isValidVersion()`
+ protected static readonly versionRegExp = new RegExp(
+ /^([0-9]+).([0-9]+)\.([0-9]+)( (a|alpha|b|beta|d|dev|rc|pl) ([0-9]+))?$/i,
+ );
+
+ constructor(formFieldId: string, existingPackages: TPackageData[]) {
+ this.formFieldId = formFieldId;
+
+ this.packageList = document.getElementById(`${this.formFieldId}_packageList`) as HTMLOListElement;
+ if (this.packageList === null) {
+ throw new Error(`Cannot find package list for packages field with id '${this.formFieldId}'.`);
+ }
+
+ this.packageIdentifier = document.getElementById(`${this.formFieldId}_packageIdentifier`) as HTMLInputElement;
+ if (this.packageIdentifier === null) {
+ throw new Error(`Cannot find package identifier form field for packages field with id '${this.formFieldId}'.`);
+ }
+ this.packageIdentifier.addEventListener("keypress", (ev) => this.keyPress(ev));
+
+ this.addButton = document.getElementById(`${this.formFieldId}_addButton`) as HTMLAnchorElement;
+ if (this.addButton === null) {
+ throw new Error(`Cannot find add button for packages field with id '${this.formFieldId}'.`);
+ }
+ this.addButton.addEventListener("click", (ev) => this.addPackage(ev));
+
+ this.form = this.packageList.closest("form") as HTMLFormElement;
+ if (this.form === null) {
+ throw new Error(`Cannot find form element for packages field with id '${this.formFieldId}'.`);
+ }
+ this.form.addEventListener("submit", () => this.submit());
+
+ existingPackages.forEach((data) => this.addPackageByData(data));
+ }
+
+ /**
+ * Adds a package to the package list as a consequence of the given event.
+ *
+ * If the package data is invalid, an error message is shown and no package is added.
+ */
+ protected addPackage(event: Event): void {
+ event.preventDefault();
+ event.stopPropagation();
+
+ // validate data
+ if (!this.validateInput()) {
+ return;
+ }
+
+ this.addPackageByData(this.getInputData());
+
+ // empty fields
+ this.emptyInput();
+
+ this.packageIdentifier.focus();
+ }
+
+ /**
+ * Adds a package to the package list using the given package data.
+ */
+ protected addPackageByData(packageData: TPackageData): void {
+ // add package to list
+ const listItem = document.createElement("li");
+ this.populateListItem(listItem, packageData);
+
+ // add delete button
+ const deleteButton = document.createElement("span");
+ deleteButton.className = "icon icon16 fa-times pointer jsTooltip";
+ deleteButton.title = Language.get("wcf.global.button.delete");
+ deleteButton.addEventListener("click", (ev) => this.removePackage(ev));
+ listItem.insertAdjacentElement("afterbegin", deleteButton);
+
+ this.packageList.appendChild(listItem);
+
+ DomChangeListener.trigger();
+ }
+
+ /**
+ * Creates the hidden fields when the form is submitted.
+ */
+ protected createSubmitFields(listElement: HTMLLIElement, index: number): void {
+ const packageIdentifier = document.createElement("input");
+ packageIdentifier.type = "hidden";
+ packageIdentifier.name = `${this.formFieldId}[${index}][packageIdentifier]`;
+ packageIdentifier.value = listElement.dataset.packageIdentifier!;
+ this.form.appendChild(packageIdentifier);
+ }
+
+ /**
+ * Empties the input fields.
+ */
+ protected emptyInput(): void {
+ this.packageIdentifier.value = "";
+ }
+
+ /**
+ * Returns the current data of the input fields to add a new package.
+ */
+ protected getInputData(): TPackageData {
+ return {
+ packageIdentifier: this.packageIdentifier.value,
+ } as TPackageData;
+ }
+
+ /**
+ * Adds a package to the package list after pressing ENTER in a text field.
+ */
+ protected keyPress(event: KeyboardEvent): void {
+ if (event.key === "Enter") {
+ this.addPackage(event);
+ }
+ }
+
+ /**
+ * Adds all necessary package-relavant data to the given list item.
+ */
+ protected populateListItem(listItem: HTMLLIElement, packageData: TPackageData): void {
+ listItem.dataset.packageIdentifier = packageData.packageIdentifier;
+ }
+
+ /**
+ * Removes a package by clicking on its delete button.
+ */
+ protected removePackage(event: Event): void {
+ (event.currentTarget as HTMLElement).closest("li")!.remove();
+
+ // remove field errors if the last package has been deleted
+ DomUtil.innerError(this.packageList, "");
+ }
+
+ /**
+ * Adds all necessary (hidden) form fields to the form when submitting the form.
+ */
+ protected submit(): void {
+ DomTraverse.childrenByTag(this.packageList, "LI").forEach((listItem, index) =>
+ this.createSubmitFields(listItem, index),
+ );
+ }
+
+ /**
+ * Returns `true` if the currently entered package data is valid. Otherwise `false` is returned and relevant error
+ * messages are shown.
+ */
+ protected validateInput(): boolean {
+ return this.validatePackageIdentifier();
+ }
+
+ /**
+ * Returns `true` if the currently entered package identifier is valid. Otherwise `false` is returned and an error
+ * message is shown.
+ */
+ protected validatePackageIdentifier(): boolean {
+ const packageIdentifier = this.packageIdentifier.value;
+
+ if (packageIdentifier === "") {
+ DomUtil.innerError(this.packageIdentifier, Language.get("wcf.global.form.error.empty"));
+
+ return false;
+ }
+
+ if (packageIdentifier.length < 3) {
+ DomUtil.innerError(
+ this.packageIdentifier,
+ Language.get("wcf.acp.devtools.project.packageIdentifier.error.minimumLength"),
+ );
+
+ return false;
+ } else if (packageIdentifier.length > 191) {
+ DomUtil.innerError(
+ this.packageIdentifier,
+ Language.get("wcf.acp.devtools.project.packageIdentifier.error.maximumLength"),
+ );
+
+ return false;
+ }
+
+ if (!AbstractPackageList.packageIdentifierRegExp.test(packageIdentifier)) {
+ DomUtil.innerError(
+ this.packageIdentifier,
+ Language.get("wcf.acp.devtools.project.packageIdentifier.error.format"),
+ );
+
+ return false;
+ }
+
+ // check if package has already been added
+ const duplicate = DomTraverse.childrenByTag(this.packageList, "LI").some(
+ (listItem) => listItem.dataset.packageIdentifier === packageIdentifier,
+ );
+
+ if (duplicate) {
+ DomUtil.innerError(
+ this.packageIdentifier,
+ Language.get("wcf.acp.devtools.project.packageIdentifier.error.duplicate"),
+ );
+
+ return false;
+ }
+
+ // remove outdated errors
+ DomUtil.innerError(this.packageIdentifier, "");
+
+ return true;
+ }
+
+ /**
+ * Returns `true` if the given version is valid. Otherwise `false` is returned and an error message is shown.
+ */
+ protected validateVersion(versionElement: HTMLInputElement): boolean {
+ const version = versionElement.value;
+
+ // see `wcf\data\package\Package::isValidVersion()`
+ // the version is no a required attribute
+ if (version !== "") {
+ if (version.length > 255) {
+ DomUtil.innerError(versionElement, Language.get("wcf.acp.devtools.project.packageVersion.error.maximumLength"));
+
+ return false;
+ }
+
+ if (!AbstractPackageList.versionRegExp.test(version)) {
+ DomUtil.innerError(versionElement, Language.get("wcf.acp.devtools.project.packageVersion.error.format"));
+
+ return false;
+ }
+ }
+
+ // remove outdated errors
+ DomUtil.innerError(versionElement, "");
+
+ return true;
+ }
+}
+
+Core.enableLegacyInheritance(AbstractPackageList);
+
+export = AbstractPackageList;
--- /dev/null
+export interface PackageData {
+ packageIdentifier: string;
+}
+
+export interface ExcludedPackageData extends PackageData {
+ version: string;
+}
+
+export interface RequiredPackageData extends PackageData {
+ file: boolean;
+ minVersion: string;
+}
--- /dev/null
+/**
+ * Manages the packages entered in a devtools project excluded package form field.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/ExcludedPackages
+ * @see module:WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList
+ * @since 5.2
+ */
+
+import AbstractPackageList from "./AbstractPackageList";
+import * as Core from "../../../../../../Core";
+import * as Language from "../../../../../../Language";
+import { ExcludedPackageData } from "./Data";
+
+class ExcludedPackages<
+ TPackageData extends ExcludedPackageData = ExcludedPackageData
+> extends AbstractPackageList<TPackageData> {
+ protected readonly version: HTMLInputElement;
+
+ constructor(formFieldId: string, existingPackages: TPackageData[]) {
+ super(formFieldId, existingPackages);
+
+ this.version = document.getElementById(`${this.formFieldId}_version`) as HTMLInputElement;
+ if (this.version === null) {
+ throw new Error(`Cannot find version form field for packages field with id '${this.formFieldId}'.`);
+ }
+ this.version.addEventListener("keypress", (ev) => this.keyPress(ev));
+ }
+
+ protected createSubmitFields(listElement: HTMLLIElement, index: number): void {
+ super.createSubmitFields(listElement, index);
+
+ const version = document.createElement("input");
+ version.type = "hidden";
+ version.name = `${this.formFieldId}[${index}][version]`;
+ version.value = listElement.dataset.version!;
+ this.form.appendChild(version);
+ }
+
+ protected emptyInput(): void {
+ super.emptyInput();
+
+ this.version.value = "";
+ }
+
+ protected getInputData(): TPackageData {
+ return Core.extend(super.getInputData(), {
+ version: this.version.value,
+ }) as TPackageData;
+ }
+
+ protected populateListItem(listItem: HTMLLIElement, packageData: TPackageData): void {
+ super.populateListItem(listItem, packageData);
+
+ listItem.dataset.version = packageData.version;
+
+ listItem.innerHTML = ` ${Language.get("wcf.acp.devtools.project.excludedPackage.excludedPackage", {
+ packageIdentifier: packageData.packageIdentifier,
+ version: packageData.version,
+ })}`;
+ }
+
+ protected validateInput(): boolean {
+ return super.validateInput() && this.validateVersion(this.version);
+ }
+}
+
+Core.enableLegacyInheritance(ExcludedPackages);
+
+export = ExcludedPackages;
--- /dev/null
+/**
+ * Manages the instructions entered in a devtools project instructions form field.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions
+ * @since 5.2
+ */
+
+import * as Core from "../../../../../../Core";
+import Template from "../../../../../../Template";
+import * as Language from "../../../../../../Language";
+import * as DomTraverse from "../../../../../../Dom/Traverse";
+import DomChangeListener from "../../../../../../Dom/Change/Listener";
+import DomUtil from "../../../../../../Dom/Util";
+import UiSortableList from "../../../../../../Ui/Sortable/List";
+import UiDialog from "../../../../../../Ui/Dialog";
+import * as UiConfirmation from "../../../../../../Ui/Confirmation";
+
+interface Instruction {
+ application: string;
+ errors?: string[];
+ pip: string;
+ runStandalone: number;
+ value: string;
+}
+
+interface InstructionsData {
+ errors?: string[];
+ fromVersion?: string;
+ instructions?: Instruction[];
+ type: InstructionsType;
+}
+
+type InstructionsType = "install" | "update";
+type InstructionsId = number | string;
+type PipFilenameMap = { [k: string]: string };
+
+class Instructions {
+ protected readonly addButton: HTMLAnchorElement;
+ protected readonly form: HTMLFormElement;
+ protected readonly formFieldId: string;
+ protected readonly fromVersion: HTMLInputElement;
+ protected instructionCounter = 0;
+ protected instructionsCounter = 0;
+ protected readonly instructionsEditDialogTemplate: Template;
+ protected readonly instructionsList: HTMLUListElement;
+ protected readonly instructionsType: HTMLSelectElement;
+ protected readonly instructionsTemplate: Template;
+ protected readonly instructionEditDialogTemplate: Template;
+ protected readonly pipDefaultFilenames: PipFilenameMap;
+
+ protected static readonly applicationPips = ["acpTemplate", "file", "script", "template"];
+
+ // see `wcf\data\package\Package::isValidPackageName()`
+ protected static readonly packageIdentifierRegExp = new RegExp(/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/);
+
+ // see `wcf\data\package\Package::isValidVersion()`
+ protected static readonly versionRegExp = new RegExp(
+ /^([0-9]+).([0-9]+)\.([0-9]+)( (a|alpha|b|beta|d|dev|rc|pl) ([0-9]+))?$/i,
+ );
+
+ constructor(
+ formFieldId: string,
+ instructionsTemplate: Template,
+ instructionsEditDialogTemplate: Template,
+ instructionEditDialogTemplate: Template,
+ pipDefaultFilenames: PipFilenameMap,
+ existingInstructions: InstructionsData[],
+ ) {
+ this.formFieldId = formFieldId;
+ this.instructionsTemplate = instructionsTemplate;
+ this.instructionsEditDialogTemplate = instructionsEditDialogTemplate;
+ this.instructionEditDialogTemplate = instructionEditDialogTemplate;
+ this.pipDefaultFilenames = pipDefaultFilenames;
+
+ this.instructionsList = document.getElementById(`${this.formFieldId}_instructionsList`) as HTMLUListElement;
+ if (this.instructionsList === null) {
+ throw new Error(`Cannot find package list for packages field with id '${this.formFieldId}'.`);
+ }
+
+ this.instructionsType = document.getElementById(`${this.formFieldId}_instructionsType`) as HTMLSelectElement;
+ if (this.instructionsType === null) {
+ throw new Error(`Cannot find instruction type form field for instructions field with id '${this.formFieldId}'.`);
+ }
+ this.instructionsType.addEventListener("change", () => this.toggleFromVersionFormField());
+
+ this.fromVersion = document.getElementById(`${this.formFieldId}_fromVersion`) as HTMLInputElement;
+ if (this.fromVersion === null) {
+ throw new Error(`Cannot find from version form field for instructions field with id '${this.formFieldId}'.`);
+ }
+ this.fromVersion.addEventListener("keypress", (ev) => this.instructionsKeyPress(ev));
+
+ this.addButton = document.getElementById(`${this.formFieldId}_addButton`) as HTMLAnchorElement;
+ if (this.addButton === null) {
+ throw new Error(`Cannot find add button form field for instructions field with id '${this.formFieldId}'.`);
+ }
+ this.addButton.addEventListener("click", (ev) => this.addInstructions(ev));
+
+ this.form = this.instructionsList.closest("form")!;
+ if (this.form === null) {
+ throw new Error(`Cannot find form element for instructions field with id '${this.formFieldId}'.`);
+ }
+ this.form.addEventListener("submit", () => this.submit());
+
+ const hasInstallInstructions = existingInstructions.some((instructions) => instructions.type === "install");
+
+ // ensure that there are always installation instructions
+ if (!hasInstallInstructions) {
+ this.addInstructionsByData({
+ fromVersion: "",
+ type: "install",
+ });
+ }
+
+ existingInstructions.forEach((instructions) => this.addInstructionsByData(instructions));
+
+ DomChangeListener.trigger();
+ }
+
+ /**
+ * Adds an instruction to a set of instructions as a consequence of the given event.
+ * If the instruction data is invalid, an error message is shown and no instruction is added.
+ */
+ protected addInstruction(event: Event): void {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const instructionsId = ((event.currentTarget as HTMLElement).closest("li.section") as HTMLElement).dataset
+ .instructionsId!;
+
+ // note: data will be validated/filtered by the server
+
+ const pipField = document.getElementById(
+ `${this.formFieldId}_instructions${instructionsId}_pip`,
+ ) as HTMLInputElement;
+
+ // ignore pressing button if no PIP has been selected
+ if (!pipField.value) {
+ return;
+ }
+
+ const valueField = document.getElementById(
+ `${this.formFieldId}_instructions${instructionsId}_value`,
+ ) as HTMLInputElement;
+ const runStandaloneField = document.getElementById(
+ `${this.formFieldId}_instructions${instructionsId}_runStandalone`,
+ ) as HTMLInputElement;
+ const applicationField = document.getElementById(
+ `${this.formFieldId}_instructions${instructionsId}_application`,
+ ) as HTMLSelectElement;
+
+ this.addInstructionByData(instructionsId, {
+ application: Instructions.applicationPips.indexOf(pipField.value) !== -1 ? applicationField.value : "",
+ pip: pipField.value,
+ runStandalone: ~~runStandaloneField.checked,
+ value: valueField.value,
+ });
+
+ // empty fields
+ pipField.value = "";
+ valueField.value = "";
+ runStandaloneField.checked = false;
+ applicationField.value = "";
+ document.getElementById(
+ `${this.formFieldId}_instructions${instructionsId}_valueDescription`,
+ )!.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
+ this.toggleApplicationFormField(instructionsId);
+
+ DomChangeListener.trigger();
+ }
+
+ /**
+ * Adds an instruction to the set of instructions with the given id.
+ */
+ protected addInstructionByData(instructionsId: InstructionsId, instructionData: Instruction): void {
+ const instructionId = ++this.instructionCounter;
+
+ const instructionList = document.getElementById(
+ `${this.formFieldId}_instructions${instructionsId}_instructionList`,
+ )!;
+
+ const listItem = document.createElement("li");
+ listItem.className = "sortableNode";
+ listItem.id = `${this.formFieldId}_instructions${instructionsId}`;
+ listItem.dataset.instructionId = instructionId.toString();
+ listItem.dataset.application = instructionData.application;
+ listItem.dataset.pip = instructionData.pip;
+ listItem.dataset.runStandalone = instructionData.runStandalone ? "true" : "false";
+ listItem.dataset.value = instructionData.value;
+
+ let content = `
+ <div class="sortableNodeLabel">
+ <div class="jsDevtoolsProjectInstruction">
+ ${Language.get("wcf.acp.devtools.project.instruction.instruction", instructionData)}
+ `;
+
+ if (instructionData.errors) {
+ instructionData.errors.forEach((error) => {
+ content += `<small class="innerError">${error}</small>`;
+ });
+ }
+
+ content += `
+ </div>
+ <span class="statusDisplay sortableButtonContainer">
+ <span class="icon icon16 fa-pencil pointer jsTooltip" id="${
+ this.formFieldId
+ }_instruction${instructionId}_editButton" title="${Language.get("wcf.global.button.edit")}"></span>
+ <span class="icon icon16 fa-times pointer jsTooltip" id="${
+ this.formFieldId
+ }_instruction${instructionId}_deleteButton" title="${Language.get("wcf.global.button.delete")}"></span>
+ </span>
+ </div>
+ `;
+
+ listItem.innerHTML = content;
+
+ instructionList.appendChild(listItem);
+
+ document
+ .getElementById(`${this.formFieldId}_instruction${instructionsId}_deleteButton`)!
+ .addEventListener("click", (ev) => this.removeInstruction(ev));
+ document
+ .getElementById(`${this.formFieldId}_instruction${instructionsId}_editButton`)!
+ .addEventListener("click", (ev) => this.editInstruction(ev));
+ }
+
+ /**
+ * Adds a set of instructions.
+ *
+ * If the instructions data is invalid, an error message is shown and no instruction set is added.
+ */
+ protected addInstructions(event: Event): void {
+ event.preventDefault();
+ event.stopPropagation();
+
+ // validate data
+ if (
+ !this.validateInstructionsType() ||
+ (this.instructionsType.value === "update" && !this.validateFromVersion(this.fromVersion))
+ ) {
+ return;
+ }
+
+ this.addInstructionsByData({
+ fromVersion: this.instructionsType.value === "update" ? this.fromVersion.value : "",
+ type: this.instructionsType.value as InstructionsType,
+ });
+
+ // empty fields
+ this.instructionsType.value = "";
+ this.fromVersion.value = "";
+
+ this.toggleFromVersionFormField();
+
+ DomChangeListener.trigger();
+ }
+
+ /**
+ * Adds a set of instructions.
+ */
+ protected addInstructionsByData(instructionsData: InstructionsData): void {
+ const instructionsId = ++this.instructionsCounter;
+
+ const listItem = document.createElement("li");
+ listItem.className = "section";
+ listItem.innerHTML = this.instructionsTemplate.fetch({
+ instructionsId: instructionsId,
+ sectionTitle: Language.get(`wcf.acp.devtools.project.instructions.type.${instructionsData.type}.title`, {
+ fromVersion: instructionsData.fromVersion,
+ }),
+ type: instructionsData.type,
+ });
+ listItem.id = `${this.formFieldId}_instructions${instructionsId}`;
+ listItem.dataset.instructionsId = instructionsId.toString();
+ listItem.dataset.type = instructionsData.type;
+ listItem.dataset.fromVersion = instructionsData.fromVersion;
+
+ this.instructionsList.appendChild(listItem);
+
+ const instructionListContainer = document.getElementById(
+ `${this.formFieldId}_instructions${instructionsId}_instructionListContainer`,
+ )!;
+ if (Array.isArray(instructionsData.errors)) {
+ instructionsData.errors.forEach((errorMessage) => {
+ DomUtil.innerError(instructionListContainer, errorMessage, true);
+ });
+ }
+
+ new UiSortableList({
+ containerId: instructionListContainer.id,
+ isSimpleSorting: true,
+ options: {
+ toleranceElement: "> div",
+ },
+ });
+
+ if (instructionsData.type === "update") {
+ document
+ .getElementById(`${this.formFieldId}_instructions${instructionsId}_deleteButton`)!
+ .addEventListener("click", (ev) => this.removeInstructions(ev));
+ document
+ .getElementById(`${this.formFieldId}_instructions${instructionsId}_editButton`)!
+ .addEventListener("click", (ev) => this.editInstructions(ev));
+ }
+
+ document
+ .getElementById(`${this.formFieldId}_instructions${instructionsId}_pip`)!
+ .addEventListener("change", (ev) => this.changeInstructionPip(ev));
+
+ document
+ .getElementById(`${this.formFieldId}_instructions${instructionsId}_value`)!
+ .addEventListener("keypress", (ev) => this.instructionKeyPress(ev));
+
+ document
+ .getElementById(`${this.formFieldId}_instructions${instructionsId}_addButton`)!
+ .addEventListener("click", (ev) => this.addInstruction(ev));
+
+ if (instructionsData.instructions) {
+ instructionsData.instructions.forEach((instruction) => {
+ this.addInstructionByData(instructionsId, instruction);
+ });
+ }
+ }
+
+ /**
+ * Is called if the selected package installation plugin of an instruction is changed.
+ */
+ protected changeInstructionPip(event: Event): void {
+ const target = event.currentTarget as HTMLInputElement;
+
+ const pip = target.value;
+ const instructionsId = (target.closest("li.section") as HTMLElement).dataset.instructionsId!;
+ const description = document.getElementById(`${this.formFieldId}_instructions${instructionsId}_valueDescription`)!;
+
+ // update value description
+ if (this.pipDefaultFilenames[pip] !== "") {
+ description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description.defaultFilename", {
+ defaultFilename: this.pipDefaultFilenames[pip],
+ });
+ } else {
+ description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
+ }
+
+ // toggle application selector
+ this.toggleApplicationFormField(instructionsId);
+ }
+
+ /**
+ * Opens a dialog to edit an existing instruction.
+ */
+ protected editInstruction(event: Event): void {
+ const listItem = (event.currentTarget as HTMLElement).closest("li")!;
+
+ const instructionId = listItem.dataset.instructionId!;
+ const application = listItem.dataset.application!;
+ const pip = listItem.dataset.pip!;
+ const runStandalone = Core.stringToBool(listItem.dataset.runStandalone!);
+ const value = listItem.dataset.value!;
+
+ const dialogContent = this.instructionEditDialogTemplate.fetch({
+ runStandalone: runStandalone,
+ value: value,
+ });
+
+ const dialogId = "instructionEditDialog" + instructionId;
+ if (!UiDialog.getDialog(dialogId)) {
+ UiDialog.openStatic(dialogId, dialogContent, {
+ onSetup: (content) => {
+ const applicationSelect = content.querySelector("select[name=application]") as HTMLSelectElement;
+ const pipSelect = content.querySelector("select[name=pip]") as HTMLInputElement;
+ const runStandaloneInput = content.querySelector("input[name=runStandalone]") as HTMLInputElement;
+ const valueInput = content.querySelector("input[name=value]") as HTMLInputElement;
+
+ // set values of `select` elements
+ applicationSelect.value = application;
+ pipSelect.value = pip;
+
+ const submit = () => {
+ const listItem = document.getElementById(`${this.formFieldId}_instruction${instructionId}`)!;
+ listItem.dataset.application =
+ Instructions.applicationPips.indexOf(pipSelect.value) !== -1 ? applicationSelect.value : "";
+ listItem.dataset.pip = pipSelect.value;
+ listItem.dataset.runStandalone = runStandaloneInput.checked ? "1" : "0";
+ listItem.dataset.value = valueInput.value;
+
+ // note: data will be validated/filtered by the server
+
+ listItem.querySelector(".jsDevtoolsProjectInstruction")!.innerHTML = Language.get(
+ "wcf.acp.devtools.project.instruction.instruction",
+ {
+ application: listItem.dataset.application,
+ pip: listItem.dataset.pip,
+ runStandalone: listItem.dataset.runStandalone,
+ value: listItem.dataset.value,
+ },
+ );
+
+ DomChangeListener.trigger();
+
+ UiDialog.close(dialogId);
+ };
+
+ valueInput.addEventListener("keypress", (event) => {
+ if (event.key === "Enter") {
+ submit();
+ }
+ });
+
+ content.querySelector("button[data-type=submit]")!.addEventListener("click", submit);
+
+ const pipChange = () => {
+ const pip = pipSelect.value;
+
+ if (Instructions.applicationPips.indexOf(pip) !== -1) {
+ DomUtil.show(applicationSelect.closest("dl")!);
+ } else {
+ DomUtil.hide(applicationSelect.closest("dl")!);
+ }
+
+ const description = DomTraverse.nextByTag(valueInput, "SMALL")!;
+ if (this.pipDefaultFilenames[pip] !== "") {
+ description.innerHTML = Language.get(
+ "wcf.acp.devtools.project.instruction.value.description.defaultFilename",
+ {
+ defaultFilename: this.pipDefaultFilenames[pip],
+ },
+ );
+ } else {
+ description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
+ }
+ };
+
+ pipSelect.addEventListener("change", pipChange);
+ pipChange();
+ },
+ title: Language.get("wcf.acp.devtools.project.instruction.edit"),
+ });
+ } else {
+ UiDialog.openStatic(dialogId, null);
+ }
+ }
+
+ /**
+ * Opens a dialog to edit an existing set of instructions.
+ */
+ protected editInstructions(event: Event): void {
+ const listItem = (event.currentTarget as HTMLElement).closest("li")!;
+
+ const instructionsId = listItem.dataset.instructionsId!;
+ const fromVersion = listItem.dataset.fromVersion;
+
+ const dialogContent = this.instructionsEditDialogTemplate.fetch({
+ fromVersion: fromVersion,
+ });
+
+ const dialogId = "instructionsEditDialog" + instructionsId;
+ if (!UiDialog.getDialog(dialogId)) {
+ UiDialog.openStatic(dialogId, dialogContent, {
+ onSetup: (content) => {
+ const fromVersion = content.querySelector("input[name=fromVersion]") as HTMLInputElement;
+
+ const submit = () => {
+ if (!this.validateFromVersion(fromVersion)) {
+ return;
+ }
+
+ const instructions = document.getElementById(`${this.formFieldId}_instructions${instructionsId}`)!;
+ instructions.dataset.fromVersion = fromVersion.value;
+
+ instructions.querySelector(".jsInstructionsTitle")!.innerHTML = Language.get(
+ "wcf.acp.devtools.project.instructions.type.update.title",
+ {
+ fromVersion: fromVersion.value,
+ },
+ );
+
+ DomChangeListener.trigger();
+
+ UiDialog.close(dialogId);
+ };
+
+ fromVersion.addEventListener("keypress", (event) => {
+ if (event.key === "Enter") {
+ submit();
+ }
+ });
+
+ content.querySelector("button[data-type=submit]")!.addEventListener("click", submit);
+ },
+ title: Language.get("wcf.acp.devtools.project.instructions.edit"),
+ });
+ } else {
+ UiDialog.openStatic(dialogId, null);
+ }
+ }
+
+ /**
+ * Adds an instruction after pressing ENTER in a relevant text field.
+ */
+ protected instructionKeyPress(event: KeyboardEvent): void {
+ if (event.key === "Enter") {
+ this.addInstruction(event);
+ }
+ }
+
+ /**
+ * Adds a set of instruction after pressing ENTER in a relevant text field.
+ */
+ protected instructionsKeyPress(event: KeyboardEvent): void {
+ if (event.key === "Enter") {
+ this.addInstructions(event);
+ }
+ }
+
+ /**
+ * Removes an instruction by clicking on its delete button.
+ */
+ protected removeInstruction(event: Event): void {
+ const instruction = (event.currentTarget as HTMLElement).closest("li")!;
+
+ UiConfirmation.show({
+ confirm: () => {
+ instruction.remove();
+ },
+ message: Language.get("wcf.acp.devtools.project.instruction.delete.confirmMessages"),
+ });
+ }
+
+ /**
+ * Removes a set of instructions by clicking on its delete button.
+ *
+ * @param {Event} event delete button click event
+ */
+ protected removeInstructions(event: Event): void {
+ const instructions = (event.currentTarget as HTMLElement).closest("li")!;
+
+ UiConfirmation.show({
+ confirm: () => {
+ instructions.remove();
+ },
+ message: Language.get("wcf.acp.devtools.project.instructions.delete.confirmMessages"),
+ });
+ }
+
+ /**
+ * Adds all necessary (hidden) form fields to the form when submitting the form.
+ */
+ protected submit(): void {
+ DomTraverse.childrenByTag(this.instructionsList, "LI").forEach((instructions, instructionsIndex) => {
+ const namePrefix = `${this.formFieldId}[${instructionsIndex}]`;
+
+ const instructionsType = document.createElement("input");
+ instructionsType.type = "hidden";
+ instructionsType.name = `${namePrefix}[type]`;
+ instructionsType.value = instructions.dataset.type!;
+ this.form.appendChild(instructionsType);
+
+ if (instructionsType.value === "update") {
+ const fromVersion = document.createElement("input");
+ fromVersion.type = "hidden";
+ fromVersion.name = `${this.formFieldId}[${instructionsIndex}][fromVersion]`;
+ fromVersion.value = instructions.dataset.fromVersion!;
+ this.form.appendChild(fromVersion);
+ }
+
+ DomTraverse.childrenByTag(document.getElementById(`${instructions.id}_instructionList`)!, "LI").forEach(
+ (instruction, instructionIndex) => {
+ const namePrefix = `${this.formFieldId}[${instructionsIndex}][instructions][${instructionIndex}]`;
+
+ ["pip", "value", "runStandalone"].forEach((property) => {
+ const element = document.createElement("input");
+ element.type = "hidden";
+ element.name = `${namePrefix}[${property}]`;
+ element.value = instruction.dataset[property]!;
+ this.form.appendChild(element);
+ });
+
+ if (Instructions.applicationPips.indexOf(instruction.dataset.pip!) !== -1) {
+ const application = document.createElement("input");
+ application.type = "hidden";
+ application.name = `${namePrefix}[application]`;
+ application.value = instruction.dataset.application!;
+ this.form.appendChild(application);
+ }
+ },
+ );
+ });
+ }
+
+ /**
+ * Toggles the visibility of the application form field based on the selected pip for the instructions with the given id.
+ */
+ protected toggleApplicationFormField(instructionsId: InstructionsId): void {
+ const pip = (document.getElementById(`${this.formFieldId}_instructions${instructionsId}_pip`) as HTMLInputElement)
+ .value;
+
+ const valueDlClassList = document
+ .getElementById(`${this.formFieldId}_instructions${instructionsId}_value`)!
+ .closest("dl")!.classList;
+ const applicationDl = document
+ .getElementById(`${this.formFieldId}_instructions${instructionsId}_application`)!
+ .closest("dl")!;
+
+ if (Instructions.applicationPips.indexOf(pip) !== -1) {
+ valueDlClassList.remove("col-md-9");
+ valueDlClassList.add("col-md-7");
+ DomUtil.show(applicationDl);
+ } else {
+ valueDlClassList.remove("col-md-7");
+ valueDlClassList.add("col-md-9");
+ DomUtil.hide(applicationDl);
+ }
+ }
+
+ /**
+ * Toggles the visibility of the `fromVersion` form field based on the selected instructions type.
+ */
+ protected toggleFromVersionFormField(): void {
+ const instructionsTypeList = this.instructionsType.closest("dl")!.classList;
+ const fromVersionDl = this.fromVersion.closest("dl")!;
+
+ if (this.instructionsType.value === "update") {
+ instructionsTypeList.remove("col-md-10");
+ instructionsTypeList.add("col-md-5");
+ DomUtil.show(fromVersionDl);
+ } else {
+ instructionsTypeList.remove("col-md-5");
+ instructionsTypeList.add("col-md-10");
+ DomUtil.hide(fromVersionDl);
+ }
+ }
+
+ /**
+ * Returns `true` if the currently entered update "from version" is valid. Otherwise `false` is returned and an error
+ * message is shown.
+ */
+ protected validateFromVersion(inputField: HTMLInputElement): boolean {
+ const version = inputField.value;
+
+ if (version === "") {
+ DomUtil.innerError(inputField, Language.get("wcf.global.form.error.empty"));
+
+ return false;
+ }
+
+ if (version.length > 50) {
+ DomUtil.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.maximumLength"));
+
+ return false;
+ }
+
+ // wildcard versions are checked on the server side
+ if (version.indexOf("*") === -1) {
+ if (!Instructions.versionRegExp.test(version)) {
+ DomUtil.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.format"));
+
+ return false;
+ }
+ } else if (!Instructions.versionRegExp.test(version.replace("*", "0"))) {
+ DomUtil.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.format"));
+
+ return false;
+ }
+
+ // remove outdated errors
+ DomUtil.innerError(inputField, "");
+
+ return true;
+ }
+
+ /**
+ * Returns `true` if the entered update instructions type is valid.
+ * Otherwise `false` is returned and an error message is shown.
+ */
+ protected validateInstructionsType(): boolean {
+ if (this.instructionsType.value !== "install" && this.instructionsType.value !== "update") {
+ if (this.instructionsType.value === "") {
+ DomUtil.innerError(this.instructionsType, Language.get("wcf.global.form.error.empty"));
+ } else {
+ DomUtil.innerError(this.instructionsType, Language.get("wcf.global.form.error.noValidSelection"));
+ }
+
+ return false;
+ }
+
+ // there may only be one set of installation instructions
+ if (this.instructionsType.value === "install") {
+ const hasInstall = Array.from(this.instructionsList.children).some(
+ (instructions: HTMLElement) => instructions.dataset.type === "install",
+ );
+
+ if (hasInstall) {
+ DomUtil.innerError(
+ this.instructionsType,
+ Language.get("wcf.acp.devtools.project.instructions.type.update.error.duplicate"),
+ );
+
+ return false;
+ }
+ }
+
+ // remove outdated errors
+ DomUtil.innerError(this.instructionsType, "");
+
+ return true;
+ }
+}
+
+Core.enableLegacyInheritance(Instructions);
+
+export = Instructions;
--- /dev/null
+/**
+ * Manages the packages entered in a devtools project optional package form field.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/OptionalPackages
+ * @see module:WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList
+ * @since 5.2
+ */
+
+import AbstractPackageList from "./AbstractPackageList";
+import * as Core from "../../../../../../Core";
+import * as Language from "../../../../../../Language";
+import { PackageData } from "./Data";
+
+class OptionalPackages extends AbstractPackageList {
+ protected populateListItem(listItem: HTMLLIElement, packageData: PackageData): void {
+ super.populateListItem(listItem, packageData);
+
+ listItem.innerHTML = ` ${Language.get("wcf.acp.devtools.project.optionalPackage.optionalPackage", {
+ packageIdentifier: packageData.packageIdentifier,
+ })}`;
+ }
+}
+
+Core.enableLegacyInheritance(OptionalPackages);
+
+export = OptionalPackages;
--- /dev/null
+/**
+ * Manages the packages entered in a devtools project required package form field.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Acp/Builder/Field/Devtools/Project/RequiredPackages
+ * @see module:WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList
+ * @since 5.2
+ */
+
+import AbstractPackageList from "./AbstractPackageList";
+import * as Core from "../../../../../../Core";
+import * as Language from "../../../../../../Language";
+import { RequiredPackageData } from "./Data";
+
+class RequiredPackages<
+ TPackageData extends RequiredPackageData = RequiredPackageData
+> extends AbstractPackageList<TPackageData> {
+ protected readonly file: HTMLInputElement;
+ protected readonly minVersion: HTMLInputElement;
+
+ constructor(formFieldId: string, existingPackages: TPackageData[]) {
+ super(formFieldId, existingPackages);
+
+ this.minVersion = document.getElementById(`${this.formFieldId}_minVersion`) as HTMLInputElement;
+ if (this.minVersion === null) {
+ throw new Error(`Cannot find minimum version form field for packages field with id '${this.formFieldId}'.`);
+ }
+ this.minVersion.addEventListener("keypress", (ev) => this.keyPress(ev));
+
+ this.file = document.getElementById(`${this.formFieldId}_file`) as HTMLInputElement;
+ if (this.file === null) {
+ throw new Error(`Cannot find file form field for required field with id '${this.formFieldId}'.`);
+ }
+ }
+
+ protected createSubmitFields(listElement: HTMLLIElement, index: number): void {
+ super.createSubmitFields(listElement, index);
+
+ ["minVersion", "file"].forEach((property) => {
+ const element = document.createElement("input");
+ element.type = "hidden";
+ element.name = `${this.formFieldId}[${index}][${property}]`;
+ element.value = listElement.dataset[property]!;
+ this.form.appendChild(element);
+ });
+ }
+
+ protected emptyInput(): void {
+ super.emptyInput();
+
+ this.minVersion.value = "";
+ this.file.checked = false;
+ }
+
+ protected getInputData(): TPackageData {
+ return Core.extend(super.getInputData(), {
+ file: this.file.checked,
+ minVersion: this.minVersion.value,
+ }) as TPackageData;
+ }
+
+ protected populateListItem(listItem: HTMLLIElement, packageData: TPackageData): void {
+ super.populateListItem(listItem, packageData);
+
+ listItem.dataset.minVersion = packageData.minVersion;
+ listItem.dataset.file = packageData.file ? "1" : "0";
+
+ listItem.innerHTML = ` ${Language.get("wcf.acp.devtools.project.requiredPackage.requiredPackage", {
+ file: packageData.file,
+ minVersion: packageData.minVersion,
+ packageIdentifier: packageData.packageIdentifier,
+ })}`;
+ }
+
+ protected validateInput(): boolean {
+ return super.validateInput() && this.validateVersion(this.minVersion);
+ }
+}
+
+Core.enableLegacyInheritance(RequiredPackages);
+
+export = RequiredPackages;
--- /dev/null
+/**
+ * Provides the dialog overlay to add a new article.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Article/Add
+ */
+
+import * as Language from "../../../Language";
+import UiDialog from "../../../Ui/Dialog";
+import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
+
+class ArticleAdd implements DialogCallbackObject {
+ constructor(private readonly link: string) {
+ document.querySelectorAll(".jsButtonArticleAdd").forEach((button: HTMLElement) => {
+ button.addEventListener("click", (ev) => this.openDialog(ev));
+ });
+ }
+
+ openDialog(event?: MouseEvent): void {
+ if (event instanceof Event) {
+ event.preventDefault();
+ }
+
+ UiDialog.open(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "articleAddDialog",
+ options: {
+ onSetup: (content) => {
+ const button = content.querySelector("button") as HTMLElement;
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ const input = content.querySelector('input[name="isMultilingual"]:checked') as HTMLInputElement;
+
+ window.location.href = this.link.replace("{$isMultilingual}", input.value);
+ });
+ },
+ title: Language.get("wcf.acp.article.add"),
+ },
+ };
+ }
+}
+
+let articleAdd: ArticleAdd;
+
+/**
+ * Initializes the article add handler.
+ */
+export function init(link: string): void {
+ if (!articleAdd) {
+ articleAdd = new ArticleAdd(link);
+ }
+}
+
+/**
+ * Opens the 'Add Article' dialog.
+ */
+export function openDialog(): void {
+ articleAdd.openDialog();
+}
--- /dev/null
+/**
+ * Handles article trash, restore and delete.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Article/InlineEditor
+ */
+
+import * as Ajax from "../../../Ajax";
+import { AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../../Ajax/Data";
+import * as ControllerClipboard from "../../../Controller/Clipboard";
+import * as Core from "../../../Core";
+import DomUtil from "../../../Dom/Util";
+import * as EventHandler from "../../../Event/Handler";
+import * as Language from "../../../Language";
+import * as UiConfirmation from "../../../Ui/Confirmation";
+import UiDialog from "../../../Ui/Dialog";
+import * as UiNotification from "../../../Ui/Notification";
+
+interface InlineEditorOptions {
+ i18n: {
+ defaultLanguageId: number;
+ isI18n: boolean;
+ languages: {
+ [key: string]: string;
+ };
+ };
+ redirectUrl: string;
+}
+
+interface ArticleData {
+ buttons: {
+ delete: HTMLAnchorElement;
+ restore: HTMLAnchorElement;
+ trash: HTMLAnchorElement;
+ };
+ element: HTMLElement | undefined;
+ isArticleEdit: boolean;
+}
+
+interface ClipboardResponseData {
+ objectIDs: number[];
+}
+
+interface ClipboardActionData {
+ data: {
+ actionName: string;
+ internalData: {
+ template: string;
+ };
+ };
+ responseData: ClipboardResponseData | null;
+}
+
+const articles = new Map<number, ArticleData>();
+
+class AcpUiArticleInlineEditor {
+ private readonly options: InlineEditorOptions;
+
+ /**
+ * Initializes the ACP inline editor for articles.
+ */
+ constructor(objectId: number, options: InlineEditorOptions) {
+ this.options = Core.extend(
+ {
+ i18n: {
+ defaultLanguageId: 0,
+ isI18n: false,
+ languages: {},
+ },
+ redirectUrl: "",
+ },
+ options,
+ ) as InlineEditorOptions;
+
+ if (objectId) {
+ this.initArticle(undefined, ~~objectId);
+ } else {
+ document.querySelectorAll(".jsArticleRow").forEach((article: HTMLElement) => this.initArticle(article, 0));
+
+ EventHandler.add("com.woltlab.wcf.clipboard", "com.woltlab.wcf.article", (data) => this.clipboardAction(data));
+ }
+ }
+
+ /**
+ * Reacts to executed clipboard actions.
+ */
+ private clipboardAction(actionData: ClipboardActionData): void {
+ // only consider events if the action has been executed
+ if (actionData.responseData !== null) {
+ const callbackFunction = new Map([
+ ["com.woltlab.wcf.article.delete", (articleId: number) => this.triggerDelete(articleId)],
+ ["com.woltlab.wcf.article.publish", (articleId: number) => this.triggerPublish(articleId)],
+ ["com.woltlab.wcf.article.restore", (articleId: number) => this.triggerRestore(articleId)],
+ ["com.woltlab.wcf.article.trash", (articleId: number) => this.triggerTrash(articleId)],
+ ["com.woltlab.wcf.article.unpublish", (articleId: number) => this.triggerUnpublish(articleId)],
+ ]);
+
+ const triggerFunction = callbackFunction.get(actionData.data.actionName);
+ if (triggerFunction) {
+ actionData.responseData.objectIDs.forEach((objectId) => triggerFunction(objectId));
+
+ UiNotification.show();
+ }
+ } else if (actionData.data.actionName === "com.woltlab.wcf.article.setCategory") {
+ const dialog = UiDialog.openStatic("articleCategoryDialog", actionData.data.internalData.template, {
+ title: Language.get("wcf.acp.article.setCategory"),
+ });
+
+ const submitButton = dialog.content.querySelector("[data-type=submit]") as HTMLButtonElement;
+ submitButton.addEventListener("click", (ev) => this.submitSetCategory(ev, dialog.content));
+ }
+ }
+
+ /**
+ * Is called, if the set category dialog form is submitted.
+ */
+ private submitSetCategory(event: MouseEvent, content: HTMLElement): void {
+ event.preventDefault();
+
+ const innerError = content.querySelector(".innerError");
+ const select = content.querySelector("select[name=categoryID]") as HTMLSelectElement;
+
+ const categoryId = ~~select.value;
+ if (categoryId) {
+ Ajax.api(this, {
+ actionName: "setCategory",
+ parameters: {
+ categoryID: categoryId,
+ useMarkedArticles: true,
+ },
+ });
+
+ if (innerError) {
+ innerError.remove();
+ }
+
+ UiDialog.close("articleCategoryDialog");
+ } else if (!innerError) {
+ DomUtil.innerError(select, Language.get("wcf.global.form.error.empty"));
+ }
+ }
+
+ /**
+ * Initializes an article row element.
+ */
+ private initArticle(article: HTMLElement | undefined, objectId: number): void {
+ let isArticleEdit = false;
+ if (!article && ~~objectId > 0) {
+ isArticleEdit = true;
+ article = undefined;
+ } else {
+ objectId = ~~article!.dataset.objectId!;
+ }
+
+ const scope = article || document;
+
+ const buttonDelete = scope.querySelector(".jsButtonDelete") as HTMLAnchorElement;
+ buttonDelete.addEventListener("click", (ev) => this.prompt(ev, objectId, "delete"));
+
+ const buttonRestore = scope.querySelector(".jsButtonRestore") as HTMLAnchorElement;
+ buttonRestore.addEventListener("click", (ev) => this.prompt(ev, objectId, "restore"));
+
+ const buttonTrash = scope.querySelector(".jsButtonTrash") as HTMLAnchorElement;
+ buttonTrash.addEventListener("click", (ev) => this.prompt(ev, objectId, "trash"));
+
+ if (isArticleEdit) {
+ const buttonToggleI18n = scope.querySelector(".jsButtonToggleI18n") as HTMLAnchorElement;
+ if (buttonToggleI18n !== null) {
+ buttonToggleI18n.addEventListener("click", (ev) => this.toggleI18n(ev, objectId));
+ }
+ }
+
+ articles.set(objectId, {
+ buttons: {
+ delete: buttonDelete,
+ restore: buttonRestore,
+ trash: buttonTrash,
+ },
+ element: article,
+ isArticleEdit: isArticleEdit,
+ });
+ }
+
+ /**
+ * Prompts a user to confirm the clicked action before executing it.
+ */
+ private prompt(event: MouseEvent, objectId: number, actionName: string): void {
+ event.preventDefault();
+
+ const article = articles.get(objectId)!;
+
+ UiConfirmation.show({
+ confirm: () => {
+ this.invoke(objectId, actionName);
+ },
+ message: article.buttons[actionName].dataset.confirmMessageHtml,
+ messageIsHtml: true,
+ });
+ }
+
+ /**
+ * Toggles an article between i18n and monolingual.
+ */
+ private toggleI18n(event: MouseEvent, objectId: number): void {
+ event.preventDefault();
+
+ const phrase = Language.get(
+ "wcf.acp.article.i18n." + (this.options.i18n.isI18n ? "fromI18n" : "toI18n") + ".confirmMessage",
+ );
+ let html = `<p>${phrase}</p>`;
+
+ // build language selection
+ if (this.options.i18n.isI18n) {
+ html += `<dl><dt>${Language.get("wcf.acp.article.i18n.source")}</dt><dd>`;
+
+ const defaultLanguageId = this.options.i18n.defaultLanguageId.toString();
+ html += Object.entries(this.options.i18n.languages)
+ .map(([languageId, languageName]) => {
+ return `<label><input type="radio" name="i18nLanguage" value="${languageId}" ${
+ defaultLanguageId === languageId ? "checked" : ""
+ }> ${languageName}</label>`;
+ })
+ .join("");
+ html += "</dd></dl>";
+ }
+
+ UiConfirmation.show({
+ confirm: (parameters, content) => {
+ let languageId = 0;
+ if (this.options.i18n.isI18n) {
+ const input = content.parentElement!.querySelector("input[name='i18nLanguage']:checked") as HTMLInputElement;
+ languageId = ~~input.value;
+ }
+
+ Ajax.api(this, {
+ actionName: "toggleI18n",
+ objectIDs: [objectId],
+ parameters: {
+ languageID: languageId,
+ },
+ });
+ },
+ message: html,
+ messageIsHtml: true,
+ });
+ }
+
+ /**
+ * Invokes the selected action.
+ */
+ private invoke(objectId: number, actionName: string): void {
+ Ajax.api(this, {
+ actionName: actionName,
+ objectIDs: [objectId],
+ });
+ }
+
+ /**
+ * Handles an article being deleted.
+ */
+ private triggerDelete(articleId: number): void {
+ const article = articles.get(articleId);
+ if (!article) {
+ // The affected article might be hidden by the filter settings.
+ return;
+ }
+
+ if (article.isArticleEdit) {
+ window.location.href = this.options.redirectUrl;
+ } else {
+ const tbody = article.element!.parentElement!;
+ article.element!.remove();
+
+ if (tbody.querySelector("tr") === null) {
+ window.location.reload();
+ }
+ }
+ }
+
+ /**
+ * Handles publishing an article via clipboard.
+ */
+ private triggerPublish(articleId: number): void {
+ const article = articles.get(articleId);
+ if (!article) {
+ // The affected article might be hidden by the filter settings.
+ return;
+ }
+
+ if (article.isArticleEdit) {
+ // unsupported
+ } else {
+ const notice = article.element!.querySelector(".jsUnpublishedArticle")!;
+ notice.remove();
+ }
+ }
+
+ /**
+ * Handles an article being restored.
+ */
+ private triggerRestore(articleId: number): void {
+ const article = articles.get(articleId);
+ if (!article) {
+ // The affected article might be hidden by the filter settings.
+ return;
+ }
+
+ DomUtil.hide(article.buttons.delete);
+ DomUtil.hide(article.buttons.restore);
+ DomUtil.show(article.buttons.trash);
+
+ if (article.isArticleEdit) {
+ const notice = document.querySelector(".jsArticleNoticeTrash") as HTMLElement;
+ DomUtil.hide(notice);
+ } else {
+ const icon = article.element!.querySelector(".jsIconDeleted")!;
+ icon.remove();
+ }
+ }
+
+ /**
+ * Handles an article being trashed.
+ */
+ private triggerTrash(articleId: number): void {
+ const article = articles.get(articleId);
+ if (!article) {
+ // The affected article might be hidden by the filter settings.
+ return;
+ }
+
+ DomUtil.show(article.buttons.delete);
+ DomUtil.show(article.buttons.restore);
+ DomUtil.hide(article.buttons.trash);
+
+ if (article.isArticleEdit) {
+ const notice = document.querySelector(".jsArticleNoticeTrash") as HTMLElement;
+ DomUtil.show(notice);
+ } else {
+ const badge = document.createElement("span");
+ badge.className = "badge label red jsIconDeleted";
+ badge.textContent = Language.get("wcf.message.status.deleted");
+
+ const h3 = article.element!.querySelector(".containerHeadline > h3") as HTMLHeadingElement;
+ h3.insertAdjacentElement("afterbegin", badge);
+ }
+ }
+
+ /**
+ * Handles unpublishing an article via clipboard.
+ */
+ private triggerUnpublish(articleId: number): void {
+ const article = articles.get(articleId);
+ if (!article) {
+ // The affected article might be hidden by the filter settings.
+ return;
+ }
+
+ if (article.isArticleEdit) {
+ // unsupported
+ } else {
+ const badge = document.createElement("span");
+ badge.className = "badge jsUnpublishedArticle";
+ badge.textContent = Language.get("wcf.acp.article.publicationStatus.unpublished");
+
+ const h3 = article.element!.querySelector(".containerHeadline > h3") as HTMLHeadingElement;
+ const a = h3.querySelector("a");
+
+ h3.insertBefore(badge, a);
+ h3.insertBefore(document.createTextNode(" "), a);
+ }
+ }
+
+ _ajaxSuccess(data: DatabaseObjectActionResponse): void {
+ let notificationCallback;
+
+ switch (data.actionName) {
+ case "delete":
+ this.triggerDelete(data.objectIDs[0]);
+ break;
+
+ case "restore":
+ this.triggerRestore(data.objectIDs[0]);
+ break;
+
+ case "setCategory":
+ case "toggleI18n":
+ notificationCallback = () => window.location.reload();
+ break;
+
+ case "trash":
+ this.triggerTrash(data.objectIDs[0]);
+ break;
+ }
+
+ UiNotification.show(undefined, notificationCallback);
+ ControllerClipboard.reload();
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ className: "wcf\\data\\article\\ArticleAction",
+ },
+ };
+ }
+}
+
+Core.enableLegacyInheritance(AcpUiArticleInlineEditor);
+
+export = AcpUiArticleInlineEditor;
--- /dev/null
+/**
+ * Provides the dialog overlay to add a new box.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Box/Add
+ */
+
+import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
+import * as Language from "../../../Language";
+import UiDialog from "../../../Ui/Dialog";
+
+class AcpUiBoxAdd implements DialogCallbackObject {
+ private supportsI18n = false;
+ private link = "";
+
+ /**
+ * Initializes the box add handler.
+ */
+ init(link: string, supportsI18n: boolean): void {
+ this.link = link;
+ this.supportsI18n = supportsI18n;
+
+ document.querySelectorAll(".jsButtonBoxAdd").forEach((button: HTMLElement) => {
+ button.addEventListener("click", (ev) => this.openDialog(ev));
+ });
+ }
+
+ /**
+ * Opens the 'Add Box' dialog.
+ */
+ openDialog(event?: MouseEvent): void {
+ if (event instanceof Event) {
+ event.preventDefault();
+ }
+
+ UiDialog.open(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "boxAddDialog",
+ options: {
+ onSetup: (content) => {
+ content.querySelector("button")!.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ const boxTypeSelection = content.querySelector('input[name="boxType"]:checked') as HTMLInputElement;
+ const boxType = boxTypeSelection.value;
+ let isMultilingual = "0";
+ if (boxType !== "system" && this.supportsI18n) {
+ const i18nSelection = content.querySelector('input[name="isMultilingual"]:checked') as HTMLInputElement;
+ isMultilingual = i18nSelection.value;
+ }
+
+ window.location.href = this.link
+ .replace("{$boxType}", boxType)
+ .replace("{$isMultilingual}", isMultilingual);
+ });
+
+ content.querySelectorAll('input[type="radio"][name="boxType"]').forEach((boxType: HTMLInputElement) => {
+ boxType.addEventListener("change", () => {
+ content
+ .querySelectorAll('input[type="radio"][name="isMultilingual"]')
+ .forEach((i18nSelection: HTMLInputElement) => {
+ i18nSelection.disabled = boxType.value === "system";
+ });
+ });
+ });
+ },
+ title: Language.get("wcf.acp.box.add"),
+ },
+ };
+ }
+}
+
+let acpUiDialogAdd: AcpUiBoxAdd;
+
+function getAcpUiDialogAdd(): AcpUiBoxAdd {
+ if (!acpUiDialogAdd) {
+ acpUiDialogAdd = new AcpUiBoxAdd();
+ }
+
+ return acpUiDialogAdd;
+}
+
+/**
+ * Initializes the box add handler.
+ */
+export function init(link: string, availableLanguages: number): void {
+ getAcpUiDialogAdd().init(link, availableLanguages > 1);
+}
+
+/**
+ * Opens the 'Add Box' dialog.
+ */
+export function openDialog(event?: MouseEvent): void {
+ getAcpUiDialogAdd().openDialog(event);
+}
--- /dev/null
+/**
+ * Provides the interface logic to add and edit boxes.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Box/Controller/Handler
+ */
+
+import * as Ajax from "../../../../Ajax";
+import DomUtil from "../../../../Dom/Util";
+import * as EventHandler from "../../../../Event/Handler";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../../Ajax/Data";
+
+interface AjaxResponse {
+ returnValues: {
+ template: string;
+ };
+}
+
+class AcpUiBoxControllerHandler implements AjaxCallbackObject {
+ private readonly boxConditions: HTMLElement;
+ private readonly boxController: HTMLInputElement;
+ private readonly boxControllerContainer: HTMLElement;
+
+ constructor(initialObjectTypeId: number | undefined) {
+ this.boxControllerContainer = document.getElementById("boxControllerContainer")!;
+ this.boxController = document.getElementById("boxControllerID") as HTMLInputElement;
+ this.boxConditions = document.getElementById("boxConditions")!;
+
+ this.boxController.addEventListener("change", () => this.updateConditions());
+
+ DomUtil.show(this.boxControllerContainer);
+
+ if (initialObjectTypeId === undefined) {
+ this.updateConditions();
+ }
+ }
+
+ /**
+ * Sets up ajax request object.
+ */
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "getBoxConditionsTemplate",
+ className: "wcf\\data\\box\\BoxAction",
+ },
+ };
+ }
+
+ /**
+ * Handles successful AJAX requests.
+ */
+ _ajaxSuccess(data: AjaxResponse): void {
+ DomUtil.setInnerHtml(this.boxConditions, data.returnValues.template);
+ }
+
+ /**
+ * Updates the displayed box conditions based on the selected dynamic box controller.
+ */
+ private updateConditions(): void {
+ EventHandler.fire("com.woltlab.wcf.boxControllerHandler", "updateConditions");
+
+ Ajax.api(this, {
+ parameters: {
+ objectTypeID: ~~this.boxController.value,
+ },
+ });
+ }
+}
+
+let acpUiBoxControllerHandler: AcpUiBoxControllerHandler;
+
+export function init(initialObjectTypeId: number | undefined): void {
+ if (!acpUiBoxControllerHandler) {
+ acpUiBoxControllerHandler = new AcpUiBoxControllerHandler(initialObjectTypeId);
+ }
+}
--- /dev/null
+import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
+import * as Language from "../../../Language";
+import * as UiDialog from "../../../Ui/Dialog";
+
+class AcpUiBoxCopy implements DialogCallbackObject {
+ constructor() {
+ document.querySelectorAll(".jsButtonCopyBox").forEach((button: HTMLElement) => {
+ button.addEventListener("click", (ev) => this.click(ev));
+ });
+ }
+
+ private click(event: MouseEvent): void {
+ event.preventDefault();
+
+ UiDialog.open(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "acpBoxCopyDialog",
+ options: {
+ title: Language.get("wcf.acp.box.copy"),
+ },
+ };
+ }
+}
+
+let acpUiBoxCopy: AcpUiBoxCopy;
+
+export function init(): void {
+ if (!acpUiBoxCopy) {
+ acpUiBoxCopy = new AcpUiBoxCopy();
+ }
+}
--- /dev/null
+/**
+ * Provides the interface logic to add and edit boxes.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Box/Handler
+ */
+
+import Dictionary from "../../../Dictionary";
+import DomUtil from "../../../Dom/Util";
+import * as Language from "../../../Language";
+import * as UiPageSearchHandler from "../../../Ui/Page/Search/Handler";
+
+class AcpUiBoxHandler {
+ private activePageId = 0;
+ private readonly boxController: HTMLSelectElement | null;
+ private readonly boxType: string;
+ private readonly cache = new Map<number, number>();
+ private readonly containerExternalLink: HTMLElement;
+ private readonly containerPageId: HTMLElement;
+ private readonly containerPageObjectId: HTMLElement;
+ private readonly handlers: Map<number, string>;
+ private readonly pageId: HTMLSelectElement;
+ private readonly pageObjectId: HTMLInputElement;
+ private readonly position: HTMLSelectElement;
+
+ /**
+ * Initializes the interface logic.
+ */
+ constructor(handlers: Map<number, string>, boxType: string) {
+ this.boxType = boxType;
+ this.handlers = handlers;
+
+ this.boxController = document.getElementById("boxControllerID") as HTMLSelectElement;
+
+ if (boxType !== "system") {
+ this.containerPageId = document.getElementById("linkPageIDContainer")!;
+ this.containerExternalLink = document.getElementById("externalURLContainer")!;
+ this.containerPageObjectId = document.getElementById("linkPageObjectIDContainer")!;
+
+ if (this.handlers.size) {
+ this.pageId = document.getElementById("linkPageID") as HTMLSelectElement;
+ this.pageId.addEventListener("change", () => this.togglePageId());
+
+ this.pageObjectId = document.getElementById("linkPageObjectID") as HTMLInputElement;
+
+ this.cache = new Map();
+ this.activePageId = ~~this.pageId.value;
+ if (this.activePageId && this.handlers.has(this.activePageId)) {
+ this.cache.set(this.activePageId, ~~this.pageObjectId.value);
+ }
+
+ const searchButton = document.getElementById("searchLinkPageObjectID")!;
+ searchButton.addEventListener("click", (ev) => this.openSearch(ev));
+
+ // toggle page object id container on init
+ if (this.handlers.has(~~this.pageId.value)) {
+ DomUtil.show(this.containerPageObjectId);
+ }
+ }
+
+ document.querySelectorAll('input[name="linkType"]').forEach((input: HTMLInputElement) => {
+ input.addEventListener("change", () => this.toggleLinkType(input.value));
+
+ if (input.checked) {
+ this.toggleLinkType(input.value);
+ }
+ });
+ }
+
+ if (this.boxController) {
+ this.position = document.getElementById("position") as HTMLSelectElement;
+ this.boxController.addEventListener("change", () => this.setAvailableBoxPositions());
+
+ // update positions on init
+ this.setAvailableBoxPositions();
+ }
+ }
+
+ /**
+ * Toggles between the interface for internal and external links.
+ */
+ private toggleLinkType(value: string): void {
+ switch (value) {
+ case "none":
+ DomUtil.hide(this.containerPageId);
+ DomUtil.hide(this.containerPageObjectId);
+ DomUtil.hide(this.containerExternalLink);
+ break;
+
+ case "internal":
+ DomUtil.show(this.containerPageId);
+ DomUtil.hide(this.containerExternalLink);
+ if (this.handlers.size) {
+ this.togglePageId();
+ }
+ break;
+
+ case "external":
+ DomUtil.hide(this.containerPageId);
+ DomUtil.hide(this.containerPageObjectId);
+ DomUtil.show(this.containerExternalLink);
+ break;
+ }
+ }
+
+ /**
+ * Handles the changed page selection.
+ */
+ private togglePageId(): void {
+ if (this.handlers.has(this.activePageId)) {
+ this.cache.set(this.activePageId, ~~this.pageObjectId.value);
+ }
+
+ this.activePageId = ~~this.pageId.value;
+
+ // page w/o pageObjectID support, discard value
+ if (!this.handlers.has(this.activePageId)) {
+ this.pageObjectId.value = "";
+
+ DomUtil.hide(this.containerPageObjectId);
+
+ return;
+ }
+
+ const newValue = this.cache.get(this.activePageId);
+ this.pageObjectId.value = newValue ? newValue.toString() : "";
+
+ const selectedOption = this.pageId.options[this.pageId.selectedIndex];
+ const pageIdentifier = selectedOption.dataset.identifier!;
+ let languageItem = `wcf.page.pageObjectID.${pageIdentifier}`;
+ if (Language.get(languageItem) === languageItem) {
+ languageItem = "wcf.page.pageObjectID";
+ }
+
+ this.containerPageObjectId.querySelector("label")!.textContent = Language.get(languageItem);
+
+ DomUtil.show(this.containerPageObjectId);
+ }
+
+ /**
+ * Opens the handler lookup dialog.
+ */
+ private openSearch(event: MouseEvent): void {
+ event.preventDefault();
+
+ const selectedOption = this.pageId.options[this.pageId.selectedIndex];
+ const pageIdentifier = selectedOption.dataset.identifier!;
+ const languageItem = `wcf.page.pageObjectID.search.${pageIdentifier}`;
+
+ let labelLanguageItem;
+ if (Language.get(languageItem) !== languageItem) {
+ labelLanguageItem = languageItem;
+ }
+
+ UiPageSearchHandler.open(
+ this.activePageId,
+ selectedOption.textContent!.trim(),
+ (objectId) => {
+ this.pageObjectId.value = objectId.toString();
+ this.cache.set(this.activePageId, objectId);
+ },
+ labelLanguageItem,
+ );
+ }
+
+ /**
+ * Updates the available box positions per box controller.
+ */
+ private setAvailableBoxPositions(): void {
+ const selectedOption = this.boxController!.options[this.boxController!.selectedIndex];
+ const supportedPositions: string[] = JSON.parse(selectedOption.dataset.supportedPositions!);
+
+ Array.from(this.position).forEach((option: HTMLOptionElement) => {
+ option.disabled = !supportedPositions.includes(option.value);
+ });
+ }
+}
+
+let acpUiBoxHandler: AcpUiBoxHandler;
+
+/**
+ * Initializes the interface logic.
+ */
+export function init(handlers: Dictionary<string> | Map<number, string>, boxType: string): void {
+ if (!acpUiBoxHandler) {
+ let map: Map<number, string>;
+ if (!(handlers instanceof Map)) {
+ map = new Map();
+ handlers.forEach((value, key) => {
+ map.set(~~key, value);
+ });
+ } else {
+ map = handlers;
+ }
+
+ acpUiBoxHandler = new AcpUiBoxHandler(map, boxType);
+ }
+}
--- /dev/null
+import { Media, MediaInsertType } from "../../../Media/Data";
+import MediaManagerEditor from "../../../Media/Manager/Editor";
+import * as Core from "../../../Core";
+
+class AcpUiCodeMirrorMedia {
+ protected readonly element: HTMLElement;
+
+ constructor(elementId: string) {
+ this.element = document.getElementById(elementId) as HTMLElement;
+
+ const button = document.getElementById(`codemirror-${elementId}-media`)!;
+ button.classList.add(button.id);
+
+ new MediaManagerEditor({
+ buttonClass: button.id,
+ callbackInsert: (media, insertType, thumbnailSize) => this.insert(media, insertType, thumbnailSize),
+ });
+ }
+
+ protected insert(mediaList: Map<number, Media>, insertType: MediaInsertType, thumbnailSize: string): void {
+ switch (insertType) {
+ case MediaInsertType.Separate: {
+ const content = Array.from(mediaList.values())
+ .map((item) => `{{ media="${item.mediaID}" size="${thumbnailSize}" }}`)
+ .join("");
+
+ (this.element as any).codemirror.replaceSelection(content);
+ }
+ }
+ }
+}
+
+Core.enableLegacyInheritance(AcpUiCodeMirrorMedia);
+
+export = AcpUiCodeMirrorMedia;
--- /dev/null
+import * as Core from "../../../Core";
+import * as UiPageSearch from "../../../Ui/Page/Search";
+
+class AcpUiCodeMirrorPage {
+ private element: HTMLElement;
+
+ constructor(elementId: string) {
+ this.element = document.getElementById(elementId)!;
+
+ const insertButton = document.getElementById(`codemirror-${elementId}-page`)!;
+ insertButton.addEventListener("click", (ev) => this._click(ev));
+ }
+
+ private _click(event: MouseEvent): void {
+ event.preventDefault();
+
+ UiPageSearch.open((pageID) => this._insert(pageID));
+ }
+
+ _insert(pageID: string): void {
+ (this.element as any).codemirror.replaceSelection(`{{ page="${pageID}" }}`);
+ }
+}
+
+Core.enableLegacyInheritance(AcpUiCodeMirrorPage);
+
+export = AcpUiCodeMirrorPage;
--- /dev/null
+/**
+ * Executes user notification tests.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Devtools/Project/QuickSetup
+ */
+
+import * as Ajax from "../../../../Ajax";
+import * as Language from "../../../../Language";
+import UiDialog from "../../../../Ui/Dialog";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../../Ajax/Data";
+import { DialogCallbackObject, DialogCallbackSetup } from "../../../../Ui/Dialog/Data";
+import DomUtil from "../../../../Dom/Util";
+
+interface AjaxResponse {
+ returnValues: {
+ eventID: number;
+ template: string;
+ };
+}
+
+class AcpUiDevtoolsNotificationTest implements AjaxCallbackObject, DialogCallbackObject {
+ private readonly buttons: HTMLButtonElement[];
+ private readonly titles = new Map<number, string>();
+
+ /**
+ * Initializes the user notification test handler.
+ */
+ constructor() {
+ this.buttons = Array.from(document.querySelectorAll(".jsTestEventButton"));
+
+ this.buttons.forEach((button) => {
+ button.addEventListener("click", (ev) => this.test(ev));
+
+ const eventId = ~~button.dataset.eventId!;
+ const title = button.dataset.title!;
+ this.titles.set(eventId, title);
+ });
+ }
+
+ /**
+ * Returns the data used to setup the AJAX request object.
+ */
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "testEvent",
+ className: "wcf\\data\\user\\notification\\event\\UserNotificationEventAction",
+ },
+ };
+ }
+
+ /**
+ * Handles successful AJAX request.
+ */
+ _ajaxSuccess(data: AjaxResponse): void {
+ UiDialog.open(this, data.returnValues.template);
+ UiDialog.setTitle(this, this.titles.get(~~data.returnValues.eventID)!);
+
+ const dialog = UiDialog.getDialog(this)!.dialog;
+
+ dialog.querySelectorAll(".formSubmit button").forEach((button: HTMLButtonElement) => {
+ button.addEventListener("click", (ev) => this.changeView(ev));
+ });
+
+ // fix some margin issues
+ const errors: HTMLElement[] = Array.from(dialog.querySelectorAll(".error"));
+ if (errors.length === 1) {
+ errors[0].style.setProperty("margin-top", "0px");
+ errors[0].style.setProperty("margin-bottom", "20px");
+ }
+
+ dialog.querySelectorAll(".notificationTestSection").forEach((section: HTMLElement) => {
+ section.style.setProperty("margin-top", "0px");
+ });
+
+ document.getElementById("notificationTestDialog")!.parentElement!.scrollTop = 0;
+
+ // restore buttons
+ this.buttons.forEach((button) => {
+ button.innerHTML = Language.get("wcf.acp.devtools.notificationTest.button.test");
+ button.disabled = false;
+ });
+ }
+
+ /**
+ * Changes the view after clicking on one of the buttons.
+ */
+ private changeView(event: MouseEvent): void {
+ const button = event.currentTarget as HTMLButtonElement;
+
+ const dialog = UiDialog.getDialog(this)!.dialog;
+
+ dialog.querySelectorAll(".notificationTestSection").forEach((section: HTMLElement) => DomUtil.hide(section));
+ const containerId = button.id.replace("Button", "");
+ DomUtil.show(document.getElementById(containerId)!);
+
+ const primaryButton = dialog.querySelector(".formSubmit .buttonPrimary") as HTMLElement;
+ primaryButton.classList.remove("buttonPrimary");
+ primaryButton.classList.add("button");
+
+ button.classList.remove("button");
+ button.classList.add("buttonPrimary");
+
+ document.getElementById("notificationTestDialog")!.parentElement!.scrollTop = 0;
+ }
+
+ /**
+ * Returns the data used to setup the dialog.
+ */
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "notificationTestDialog",
+ source: null,
+ };
+ }
+
+ /**
+ * Executes a test after clicking on a test button.
+ */
+ private test(event: MouseEvent): void {
+ const button = event.currentTarget as HTMLButtonElement;
+
+ button.innerHTML = '<span class="icon icon16 fa-spinner"></span>';
+
+ this.buttons.forEach((button) => (button.disabled = true));
+
+ Ajax.api(this, {
+ parameters: {
+ eventID: ~~button.dataset.eventId!,
+ },
+ });
+ }
+}
+
+let acpUiDevtoolsNotificationTest: AcpUiDevtoolsNotificationTest;
+
+/**
+ * Initializes the user notification test handler.
+ */
+export function init(): void {
+ if (!acpUiDevtoolsNotificationTest) {
+ acpUiDevtoolsNotificationTest = new AcpUiDevtoolsNotificationTest();
+ }
+}
--- /dev/null
+/**
+ * Handles installing a project as a package.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Devtools/Project/Installation/Confirmation
+ */
+
+import * as Ajax from "../../../../../Ajax";
+import * as Language from "../../../../../Language";
+import * as UiConfirmation from "../../../../../Ui/Confirmation";
+
+let _projectId: number;
+let _projectName: string;
+
+/**
+ * Starts the package installation.
+ */
+function installPackage(): void {
+ Ajax.apiOnce({
+ data: {
+ actionName: "installPackage",
+ className: "wcf\\data\\devtools\\project\\DevtoolsProjectAction",
+ objectIDs: [_projectId],
+ },
+ success: (data) => {
+ const packageInstallation = new window.WCF.ACP.Package.Installation(
+ data.returnValues.queueID,
+ "DevtoolsInstallPackage",
+ data.returnValues.isApplication,
+ false,
+ { projectID: _projectId },
+ );
+
+ packageInstallation.prepareInstallation();
+ },
+ });
+}
+
+/**
+ * Shows the confirmation to start package installation.
+ */
+function showConfirmation(event: Event): void {
+ event.preventDefault();
+
+ UiConfirmation.show({
+ confirm: () => installPackage(),
+ message: Language.get("wcf.acp.devtools.project.installPackage.confirmMessage", {
+ packageIdentifier: _projectName,
+ }),
+ messageIsHtml: true,
+ });
+}
+
+/**
+ * Initializes the confirmation to install a project as a package.
+ */
+export function init(projectId: number, projectName: string): void {
+ _projectId = projectId;
+ _projectName = projectName;
+
+ document.querySelectorAll(".jsDevtoolsInstallPackage").forEach((element: HTMLElement) => {
+ element.addEventListener("click", (ev) => showConfirmation(ev));
+ });
+}
--- /dev/null
+/**
+ * Handles the JavaScript part of the devtools project pip entry list.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Devtools/Project/Pip/Entry/List
+ */
+
+import * as Ajax from "../../../../../../Ajax";
+import * as Core from "../../../../../../Core";
+import * as Language from "../../../../../../Language";
+import { ConfirmationCallbackParameters, show as showConfirmation } from "../../../../../../Ui/Confirmation";
+import * as UiNotification from "../../../../../../Ui/Notification";
+import { AjaxCallbackSetup } from "../../../../../../Ajax/Data";
+
+interface AjaxResponse {
+ returnValues: {
+ identifier: string;
+ };
+}
+
+class DevtoolsProjectPipEntryList {
+ private readonly entryType: string;
+ private readonly pip: string;
+ private readonly projectId: number;
+ private readonly supportsDeleteInstruction: boolean;
+ private readonly table: HTMLTableElement;
+
+ /**
+ * Initializes the devtools project pip entry list handler.
+ */
+ constructor(tableId: string, projectId: number, pip: string, entryType: string, supportsDeleteInstruction: boolean) {
+ const table = document.getElementById(tableId);
+ if (table === null) {
+ throw new Error(`Unknown element with id '${tableId}'.`);
+ } else if (!(table instanceof HTMLTableElement)) {
+ throw new Error(`Element with id '${tableId}' is no table.`);
+ }
+ this.table = table;
+
+ this.projectId = projectId;
+ this.pip = pip;
+ this.entryType = entryType;
+ this.supportsDeleteInstruction = supportsDeleteInstruction;
+
+ this.table.querySelectorAll(".jsDeleteButton").forEach((button: HTMLElement) => {
+ button.addEventListener("click", (ev) => this._confirmDeletePipEntry(ev));
+ });
+ }
+
+ /**
+ * Returns the data used to setup the AJAX request object.
+ */
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "deletePipEntry",
+ className: "wcf\\data\\devtools\\project\\DevtoolsProjectAction",
+ },
+ };
+ }
+
+ /**
+ * Handles successful AJAX request.
+ */
+ _ajaxSuccess(data: AjaxResponse): void {
+ UiNotification.show();
+
+ this.table.querySelectorAll("tbody > tr").forEach((pipEntry: HTMLTableRowElement) => {
+ if (pipEntry.dataset.identifier === data.returnValues.identifier) {
+ pipEntry.remove();
+ }
+ });
+
+ // Reload page if the table is now empty.
+ if (this.table.querySelector("tbody > tr") === null) {
+ window.location.reload();
+ }
+ }
+
+ /**
+ * Shows the confirmation dialog when deleting a pip entry.
+ */
+ private _confirmDeletePipEntry(event: MouseEvent): void {
+ event.preventDefault();
+
+ const button = event.currentTarget as HTMLElement;
+ const pipEntry = button.closest("tr")!;
+
+ let template = "";
+ if (this.supportsDeleteInstruction) {
+ template = `
+<dl>
+ <dt></dt>
+ <dd>
+ <label>
+ <input type="checkbox" name="addDeleteInstruction" checked> ${Language.get(
+ "wcf.acp.devtools.project.pip.entry.delete.addDeleteInstruction",
+ )}
+ </label>
+ <small>${Language.get("wcf.acp.devtools.project.pip.entry.delete.addDeleteInstruction.description")}</small>
+ </dd>
+</dl>`;
+ }
+
+ showConfirmation({
+ confirm: (parameters, content) => this.deletePipEntry(parameters, content),
+ message: Language.get("wcf.acp.devtools.project.pip.entry.delete.confirmMessage"),
+ template,
+ parameters: {
+ pipEntry: pipEntry,
+ },
+ });
+ }
+
+ /**
+ * Sends the AJAX request to delete a pip entry.
+ */
+ private deletePipEntry(parameters: ConfirmationCallbackParameters, content: HTMLElement): void {
+ let addDeleteInstruction = false;
+ if (this.supportsDeleteInstruction) {
+ const input = content.querySelector("input[name=addDeleteInstruction]") as HTMLInputElement;
+ addDeleteInstruction = input.checked;
+ }
+
+ const pipEntry = parameters.pipEntry as HTMLTableRowElement;
+ Ajax.api(this, {
+ objectIDs: [this.projectId],
+ parameters: {
+ addDeleteInstruction,
+ entryType: this.entryType,
+ identifier: pipEntry.dataset.identifier,
+ pip: this.pip,
+ },
+ });
+ }
+}
+
+Core.enableLegacyInheritance(DevtoolsProjectPipEntryList);
+
+export = DevtoolsProjectPipEntryList;
--- /dev/null
+/**
+ * Handles quick setup of all projects within a path.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Devtools/Project/QuickSetup
+ */
+
+import * as Ajax from "../../../../Ajax";
+import DomUtil from "../../../../Dom/Util";
+import * as Language from "../../../../Language";
+import UiDialog from "../../../../Ui/Dialog";
+import * as UiNotification from "../../../../Ui/Notification";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../../Ajax/Data";
+import { DialogCallbackObject, DialogCallbackSetup } from "../../../../Ui/Dialog/Data";
+
+interface AjaxResponse {
+ returnValues: {
+ errorMessage?: string;
+ successMessage: string;
+ };
+}
+
+class AcpUiDevtoolsProjectQuickSetup implements AjaxCallbackObject, DialogCallbackObject {
+ private readonly pathInput: HTMLInputElement;
+ private readonly submitButton: HTMLButtonElement;
+
+ /**
+ * Initializes the project quick setup handler.
+ */
+ constructor() {
+ document.querySelectorAll(".jsDevtoolsProjectQuickSetupButton").forEach((button: HTMLAnchorElement) => {
+ button.addEventListener("click", (ev) => this.showDialog(ev));
+ });
+
+ this.submitButton = document.getElementById("projectQuickSetupSubmit") as HTMLButtonElement;
+ this.submitButton.addEventListener("click", (ev) => this.submit(ev));
+
+ this.pathInput = document.getElementById("projectQuickSetupPath") as HTMLInputElement;
+ this.pathInput.addEventListener("keypress", (ev) => this.keyPress(ev));
+ }
+
+ /**
+ * Returns the data used to setup the AJAX request object.
+ */
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "quickSetup",
+ className: "wcf\\data\\devtools\\project\\DevtoolsProjectAction",
+ },
+ };
+ }
+
+ /**
+ * Handles successful AJAX request.
+ */
+ _ajaxSuccess(data: AjaxResponse): void {
+ if (data.returnValues.errorMessage) {
+ DomUtil.innerError(this.pathInput, data.returnValues.errorMessage);
+
+ this.submitButton.disabled = false;
+
+ return;
+ }
+
+ UiDialog.close(this);
+
+ UiNotification.show(data.returnValues.successMessage, () => {
+ window.location.reload();
+ });
+ }
+
+ /**
+ * Returns the data used to setup the dialog.
+ */
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "projectQuickSetup",
+ options: {
+ onShow: () => this.onDialogShow(),
+ title: Language.get("wcf.acp.devtools.project.quickSetup"),
+ },
+ };
+ }
+
+ /**
+ * Handles the `[ENTER]` key to submit the form.
+ */
+ private keyPress(event: KeyboardEvent): void {
+ if (event.key === "Enter") {
+ this.submit(event);
+ }
+ }
+
+ /**
+ * Is called every time the dialog is shown.
+ */
+ private onDialogShow(): void {
+ // reset path input
+ this.pathInput.value = "";
+ this.pathInput.focus();
+
+ // hide error
+ DomUtil.innerError(this.pathInput, false);
+ }
+
+ /**
+ * Shows the dialog after clicking on the related button.
+ */
+ private showDialog(event: MouseEvent): void {
+ event.preventDefault();
+
+ UiDialog.open(this);
+ }
+
+ /**
+ * Is called if the dialog form is submitted.
+ */
+ private submit(event: Event): void {
+ event.preventDefault();
+
+ // check if path is empty
+ if (this.pathInput.value === "") {
+ DomUtil.innerError(this.pathInput, Language.get("wcf.global.form.error.empty"));
+
+ return;
+ }
+
+ Ajax.api(this, {
+ parameters: {
+ path: this.pathInput.value,
+ },
+ });
+
+ this.submitButton.disabled = true;
+ }
+}
+
+let acpUiDevtoolsProjectQuickSetup: AcpUiDevtoolsProjectQuickSetup;
+
+/**
+ * Initializes the project quick setup handler.
+ */
+export function init(): void {
+ if (!acpUiDevtoolsProjectQuickSetup) {
+ acpUiDevtoolsProjectQuickSetup = new AcpUiDevtoolsProjectQuickSetup();
+ }
+}
--- /dev/null
+import * as Ajax from "../../../../Ajax";
+import * as Language from "../../../../Language";
+import UiDialog from "../../../../Ui/Dialog";
+import * as UiNotification from "../../../../Ui/Notification";
+import { AjaxCallbackSetup, AjaxResponseException } from "../../../../Ajax/Data";
+import { DialogCallbackSetup } from "../../../../Ui/Dialog/Data";
+
+interface PipData {
+ dependencies: string[];
+ pluginName: string;
+ targets: string[];
+}
+
+type PendingPip = [string, string];
+
+interface AjaxResponse {
+ returnValues: {
+ pluginName: string;
+ target: string;
+ timeElapsed: string;
+ };
+}
+
+interface RequestData {
+ parameters: {
+ pluginName: string;
+ target: string;
+ };
+}
+
+class AcpUiDevtoolsProjectSync {
+ private readonly buttons = new Map<string, HTMLButtonElement>();
+ private readonly buttonStatus = new Map<string, HTMLElement>();
+ private buttonSyncAll?: HTMLAnchorElement = undefined;
+ private readonly container = document.getElementById("syncPipMatches")!;
+ private readonly pips: PipData[] = [];
+ private readonly projectId: number;
+ private queue: PendingPip[] = [];
+
+ constructor(projectId: number) {
+ this.projectId = projectId;
+
+ const restrictedSync = document.getElementById("syncShowOnlyMatches") as HTMLInputElement;
+ restrictedSync.addEventListener("change", () => {
+ this.container.classList.toggle("jsShowOnlyMatches");
+ });
+
+ const existingPips: string[] = [];
+ const knownPips: string[] = [];
+ const tmpPips: PipData[] = [];
+ this.container
+ .querySelectorAll(".jsHasPipTargets:not(.jsSkipTargetDetection)")
+ .forEach((pip: HTMLTableRowElement) => {
+ const pluginName = pip.dataset.pluginName!;
+ const targets: string[] = [];
+
+ this.container
+ .querySelectorAll(`.jsHasPipTargets[data-plugin-name="${pluginName}"] .jsInvokePip`)
+ .forEach((button: HTMLButtonElement) => {
+ const target = button.dataset.target!;
+ targets.push(target);
+
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ if (this.queue.length > 0) {
+ return;
+ }
+
+ this.sync(pluginName, target);
+ });
+
+ const identifier = this.getButtonIdentifier(pluginName, target);
+ this.buttons.set(identifier, button);
+ this.buttonStatus.set(
+ identifier,
+ this.container.querySelector(
+ `.jsHasPipTargets[data-plugin-name="${pluginName}"] .jsInvokePipResult[data-target="${target}"]`,
+ ) as HTMLElement,
+ );
+ });
+
+ const data: PipData = {
+ dependencies: JSON.parse(pip.dataset.syncDependencies!),
+ pluginName,
+ targets,
+ };
+
+ if (data.dependencies.length > 0) {
+ tmpPips.push(data);
+ } else {
+ this.pips.push(data);
+ knownPips.push(pluginName);
+ }
+
+ existingPips.push(pluginName);
+ });
+
+ let resolvedDependency = false;
+ while (tmpPips.length > 0) {
+ resolvedDependency = false;
+
+ tmpPips.forEach((item, index) => {
+ if (resolvedDependency) {
+ return;
+ }
+
+ const openDependencies = item.dependencies.filter((dependency) => {
+ // Ignore any dependencies that are not present.
+ if (existingPips.indexOf(dependency) === -1) {
+ window.console.info(`The dependency "${dependency}" does not exist and has been ignored.`);
+ return false;
+ }
+
+ return !knownPips.includes(dependency);
+ });
+
+ if (openDependencies.length === 0) {
+ knownPips.push(item.pluginName);
+ this.pips.push(item);
+ tmpPips.splice(index, 1);
+
+ resolvedDependency = true;
+ }
+ });
+
+ if (!resolvedDependency) {
+ // We could not resolve any dependency, either because there is no more pip
+ // in `tmpPips` or we're facing a circular dependency. In case there are items
+ // left, we simply append them to the end and hope for the operation to
+ // complete anyway, despite unmatched dependencies.
+ tmpPips.forEach((pip) => {
+ window.console.warn("Unable to resolve dependencies for", pip);
+
+ this.pips.push(pip);
+ });
+
+ break;
+ }
+ }
+
+ const syncAll = document.createElement("li");
+ syncAll.innerHTML = `<a href="#" class="button"><span class="icon icon16 fa-refresh"></span> ${Language.get(
+ "wcf.acp.devtools.sync.syncAll",
+ )}</a>`;
+ this.buttonSyncAll = syncAll.children[0] as HTMLAnchorElement;
+ this.buttonSyncAll.addEventListener("click", this.syncAll.bind(this));
+
+ const list = document.querySelector(".contentHeaderNavigation > ul") as HTMLUListElement;
+ list.insertAdjacentElement("afterbegin", syncAll);
+ }
+
+ private sync(pluginName: string, target: string): void {
+ const identifier = this.getButtonIdentifier(pluginName, target);
+ this.buttons.get(identifier)!.disabled = true;
+ this.buttonStatus.get(identifier)!.innerHTML = '<span class="icon icon16 fa-spinner"></span>';
+
+ Ajax.api(this, {
+ parameters: {
+ pluginName,
+ target,
+ },
+ });
+ }
+
+ private syncAll(event: MouseEvent): void {
+ event.preventDefault();
+
+ if (this.buttonSyncAll!.classList.contains("disabled")) {
+ return;
+ }
+
+ this.buttonSyncAll!.classList.add("disabled");
+
+ this.queue = [];
+ this.pips.forEach((pip) => {
+ pip.targets.forEach((target) => {
+ this.queue.push([pip.pluginName, target]);
+ });
+ });
+ this.syncNext();
+ }
+
+ private syncNext(): void {
+ if (this.queue.length === 0) {
+ this.buttonSyncAll!.classList.remove("disabled");
+
+ UiNotification.show();
+
+ return;
+ }
+
+ const next = this.queue.shift()!;
+ this.sync(next[0], next[1]);
+ }
+
+ private getButtonIdentifier(pluginName: string, target: string): string {
+ return `${pluginName}-${target}`;
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ const identifier = this.getButtonIdentifier(data.returnValues.pluginName, data.returnValues.target);
+ this.buttons.get(identifier)!.disabled = false;
+ this.buttonStatus.get(identifier)!.innerHTML = data.returnValues.timeElapsed;
+
+ this.syncNext();
+ }
+
+ _ajaxFailure(
+ data: AjaxResponseException,
+ responseText: string,
+ xhr: XMLHttpRequest,
+ requestData: RequestData,
+ ): boolean {
+ const identifier = this.getButtonIdentifier(requestData.parameters.pluginName, requestData.parameters.target);
+ this.buttons.get(identifier)!.disabled = false;
+
+ const buttonStatus = this.buttonStatus.get(identifier)!;
+ buttonStatus.innerHTML = '<a href="#">' + Language.get("wcf.acp.devtools.sync.status.failure") + "</a>";
+ buttonStatus.children[0].addEventListener("click", (event) => {
+ event.preventDefault();
+
+ UiDialog.open(this, Ajax.getRequestObject(this).getErrorHtml(data, xhr));
+ });
+
+ this.buttonSyncAll!.classList.remove("disabled");
+
+ return false;
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "invoke",
+ className: "wcf\\data\\package\\installation\\plugin\\PackageInstallationPluginAction",
+ parameters: {
+ projectID: this.projectId,
+ },
+ },
+ };
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "devtoolsProjectSyncPipError",
+ options: {
+ title: Language.get("wcf.global.error.title"),
+ },
+ source: null,
+ };
+ }
+}
+
+let acpUiDevtoolsProjectSync: AcpUiDevtoolsProjectSync;
+
+export function init(projectId: number): void {
+ if (!acpUiDevtoolsProjectSync) {
+ acpUiDevtoolsProjectSync = new AcpUiDevtoolsProjectSync(projectId);
+ }
+}
--- /dev/null
+/**
+ * Provides the interface logic to add and edit menu items.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Menu/Item/Handler
+ */
+
+import Dictionary from "../../../../Dictionary";
+import DomUtil from "../../../../Dom/Util";
+import * as Language from "../../../../Language";
+import * as UiPageSearchHandler from "../../../../Ui/Page/Search/Handler";
+
+class AcpUiMenuItemHandler {
+ private activePageId = 0;
+ private readonly cache = new Map<number, number>();
+ private readonly containerExternalLink: HTMLElement;
+ private readonly containerInternalLink: HTMLElement;
+ private readonly containerPageObjectId: HTMLElement;
+ private readonly handlers: Map<number, string>;
+ private readonly pageId: HTMLSelectElement;
+ private readonly pageObjectId: HTMLInputElement;
+
+ /**
+ * Initializes the interface logic.
+ */
+ constructor(handlers: Map<number, string>) {
+ this.handlers = handlers;
+
+ this.containerInternalLink = document.getElementById("pageIDContainer")!;
+ this.containerExternalLink = document.getElementById("externalURLContainer")!;
+ this.containerPageObjectId = document.getElementById("pageObjectIDContainer")!;
+
+ if (this.handlers.size) {
+ this.pageId = document.getElementById("pageID") as HTMLSelectElement;
+ this.pageId.addEventListener("change", this.togglePageId.bind(this));
+
+ this.pageObjectId = document.getElementById("pageObjectID") as HTMLInputElement;
+
+ this.activePageId = ~~this.pageId.value;
+ if (this.activePageId && this.handlers.has(this.activePageId)) {
+ this.cache.set(this.activePageId, ~~this.pageObjectId.value);
+ }
+
+ const searchButton = document.getElementById("searchPageObjectID")!;
+ searchButton.addEventListener("click", (ev) => this.openSearch(ev));
+
+ // toggle page object id container on init
+ if (this.handlers.has(~~this.pageId.value)) {
+ DomUtil.show(this.containerPageObjectId);
+ }
+ }
+
+ document.querySelectorAll('input[name="isInternalLink"]').forEach((input: HTMLInputElement) => {
+ input.addEventListener("change", () => this.toggleIsInternalLink(input.value));
+
+ if (input.checked) {
+ this.toggleIsInternalLink(input.value);
+ }
+ });
+ }
+
+ /**
+ * Toggles between the interface for internal and external links.
+ */
+ private toggleIsInternalLink(value: string): void {
+ if (~~value) {
+ DomUtil.show(this.containerInternalLink);
+ DomUtil.hide(this.containerExternalLink);
+ if (this.handlers.size) {
+ this.togglePageId();
+ }
+ } else {
+ DomUtil.hide(this.containerInternalLink);
+ DomUtil.hide(this.containerPageObjectId);
+ DomUtil.show(this.containerExternalLink);
+ }
+ }
+
+ /**
+ * Handles the changed page selection.
+ */
+ private togglePageId(): void {
+ if (this.handlers.has(this.activePageId)) {
+ this.cache.set(this.activePageId, ~~this.pageObjectId.value);
+ }
+
+ this.activePageId = ~~this.pageId.value;
+
+ // page w/o pageObjectID support, discard value
+ if (!this.handlers.has(this.activePageId)) {
+ this.pageObjectId.value = "";
+
+ DomUtil.hide(this.containerPageObjectId);
+
+ return;
+ }
+
+ const newValue = this.cache.get(this.activePageId);
+ this.pageObjectId.value = newValue ? newValue.toString() : "";
+
+ const selectedOption = this.pageId.options[this.pageId.selectedIndex];
+ const pageIdentifier = selectedOption.dataset.identifier!;
+ let languageItem = `wcf.page.pageObjectID.${pageIdentifier}`;
+ if (Language.get(languageItem) === languageItem) {
+ languageItem = "wcf.page.pageObjectID";
+ }
+
+ this.containerPageObjectId.querySelector("label")!.textContent = Language.get(languageItem);
+
+ DomUtil.show(this.containerPageObjectId);
+ }
+
+ /**
+ * Opens the handler lookup dialog.
+ */
+ private openSearch(event: MouseEvent): void {
+ event.preventDefault();
+
+ const selectedOption = this.pageId.options[this.pageId.selectedIndex];
+ const pageIdentifier = selectedOption.dataset.identifier!;
+ const languageItem = `wcf.page.pageObjectID.search.${pageIdentifier}`;
+
+ let labelLanguageItem;
+ if (Language.get(languageItem) !== languageItem) {
+ labelLanguageItem = languageItem;
+ }
+
+ UiPageSearchHandler.open(
+ this.activePageId,
+ selectedOption.textContent!.trim(),
+ (objectId) => {
+ this.pageObjectId.value = objectId.toString();
+ this.cache.set(this.activePageId, objectId);
+ },
+ labelLanguageItem,
+ );
+ }
+}
+
+let acpUiMenuItemHandler: AcpUiMenuItemHandler;
+
+export function init(handlers: Dictionary<string> | Map<number, string>): void {
+ if (!acpUiMenuItemHandler) {
+ let map: Map<number, string>;
+ if (!(handlers instanceof Map)) {
+ map = new Map();
+ handlers.forEach((value, key) => {
+ map.set(~~~key, value);
+ });
+ } else {
+ map = handlers;
+ }
+
+ acpUiMenuItemHandler = new AcpUiMenuItemHandler(map);
+ }
+}
--- /dev/null
+/**
+ * Simple SMTP connection testing.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2018 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Option/EmailSmtpTest
+ */
+
+import * as Ajax from "../../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../Ajax/Data";
+import DomUtil from "../../../Dom/Util";
+import * as Language from "../../../Language";
+
+interface AjaxResponse {
+ returnValues: {
+ fieldName?: string;
+ validationResult: string;
+ };
+}
+
+class EmailSmtpTest implements AjaxCallbackObject {
+ private readonly buttonRunTest: HTMLAnchorElement;
+ private readonly container: HTMLDListElement;
+
+ constructor() {
+ let smtpCheckbox: HTMLInputElement | null = null;
+ const methods = document.querySelectorAll('input[name="values[mail_send_method]"]');
+ methods.forEach((checkbox: HTMLInputElement) => {
+ checkbox.addEventListener("change", () => this.onChange(checkbox));
+
+ if (checkbox.value === "smtp") {
+ smtpCheckbox = checkbox;
+ }
+ });
+
+ // This configuration part is unavailable when running in enterprise mode.
+ if (methods.length === 0) {
+ return;
+ }
+
+ this.container = document.createElement("dl");
+ this.container.innerHTML = `<dt>${Language.get("wcf.acp.email.smtp.test")}</dt>
+<dd>
+ <a href="#" class="button">${Language.get("wcf.acp.email.smtp.test.run")}</a>
+ <small>${Language.get("wcf.acp.email.smtp.test.description")}</small>
+</dd>`;
+
+ this.buttonRunTest = this.container.querySelector("a")!;
+ this.buttonRunTest.addEventListener("click", (ev) => this.onClick(ev));
+
+ if (smtpCheckbox) {
+ this.onChange(smtpCheckbox);
+ }
+ }
+
+ private onChange(checkbox: HTMLInputElement): void {
+ if (checkbox.value === "smtp" && checkbox.checked) {
+ if (this.container.parentElement === null) {
+ this.initUi(checkbox);
+ }
+
+ DomUtil.show(this.container);
+ } else if (this.container.parentElement !== null) {
+ DomUtil.hide(this.container);
+ }
+ }
+
+ private initUi(checkbox: HTMLInputElement): void {
+ const insertAfter = checkbox.closest("dl") as HTMLDListElement;
+ insertAfter.insertAdjacentElement("afterend", this.container);
+ }
+
+ private onClick(event: MouseEvent) {
+ event.preventDefault();
+
+ this.buttonRunTest.classList.add("disabled");
+ this.buttonRunTest.innerHTML = `<span class="icon icon16 fa-spinner"></span> ${Language.get("wcf.global.loading")}`;
+
+ DomUtil.innerError(this.buttonRunTest, false);
+
+ window.setTimeout(() => {
+ const startTls = document.querySelector('input[name="values[mail_smtp_starttls]"]:checked') as HTMLInputElement;
+
+ const host = document.getElementById("mail_smtp_host") as HTMLInputElement;
+ const port = document.getElementById("mail_smtp_port") as HTMLInputElement;
+ const user = document.getElementById("mail_smtp_user") as HTMLInputElement;
+ const password = document.getElementById("mail_smtp_password") as HTMLInputElement;
+
+ Ajax.api(this, {
+ parameters: {
+ host: host.value,
+ port: port.value,
+ startTls: startTls ? startTls.value : "",
+ user: user.value,
+ password: password.value,
+ },
+ });
+ }, 100);
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ const result = data.returnValues.validationResult;
+ if (result === "") {
+ this.resetButton(true);
+ } else {
+ this.resetButton(false, result);
+ }
+ }
+
+ _ajaxFailure(data: AjaxResponse): boolean {
+ let result = "";
+ if (data && data.returnValues && data.returnValues.fieldName) {
+ result = Language.get(`wcf.acp.email.smtp.test.error.empty.${data.returnValues.fieldName}`);
+ }
+
+ this.resetButton(false, result);
+
+ return result === "";
+ }
+
+ private resetButton(success: boolean, errorMessage?: string): void {
+ this.buttonRunTest.classList.remove("disabled");
+
+ if (success) {
+ this.buttonRunTest.innerHTML = `<span class="icon icon16 fa-check green"></span> ${Language.get(
+ "wcf.acp.email.smtp.test.run.success",
+ )}`;
+ } else {
+ this.buttonRunTest.innerHTML = Language.get("wcf.acp.email.smtp.test.run");
+ }
+
+ if (errorMessage) {
+ DomUtil.innerError(this.buttonRunTest, errorMessage);
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "emailSmtpTest",
+ className: "wcf\\data\\option\\OptionAction",
+ },
+ silent: true,
+ };
+ }
+}
+
+let emailSmtpTest: EmailSmtpTest;
+
+export function init(): void {
+ if (!emailSmtpTest) {
+ emailSmtpTest = new EmailSmtpTest();
+ }
+}
--- /dev/null
+/**
+ * Automatic URL rewrite rule generation.
+ *
+ * @author Florian Gail
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Option/RewriteGenerator
+ */
+
+import * as Ajax from "../../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "../../../Ajax/Data";
+import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
+import * as Language from "../../../Language";
+import UiDialog from "../../../Ui/Dialog";
+
+class RewriteGenerator implements AjaxCallbackObject, DialogCallbackObject {
+ private readonly buttonGenerate: HTMLAnchorElement;
+ private readonly container: HTMLDListElement;
+
+ /**
+ * Initializes the generator for rewrite rules
+ */
+ constructor() {
+ const urlOmitIndexPhp = document.getElementById("url_omit_index_php");
+
+ // This configuration part is unavailable when running in enterprise mode.
+ if (urlOmitIndexPhp === null) {
+ return;
+ }
+
+ this.container = document.createElement("dl");
+ const dt = document.createElement("dt");
+ dt.classList.add("jsOnly");
+ const dd = document.createElement("dd");
+
+ this.buttonGenerate = document.createElement("a");
+ this.buttonGenerate.className = "button";
+ this.buttonGenerate.href = "#";
+ this.buttonGenerate.textContent = Language.get("wcf.acp.rewrite.generate");
+ this.buttonGenerate.addEventListener("click", (ev) => this._onClick(ev));
+ dd.appendChild(this.buttonGenerate);
+
+ const description = document.createElement("small");
+ description.textContent = Language.get("wcf.acp.rewrite.description");
+ dd.appendChild(description);
+
+ this.container.appendChild(dt);
+ this.container.appendChild(dd);
+
+ const insertAfter = urlOmitIndexPhp.closest("dl")!;
+ insertAfter.insertAdjacentElement("afterend", this.container);
+ }
+
+ /**
+ * Fires an AJAX request and opens the dialog
+ */
+ _onClick(event: MouseEvent): void {
+ event.preventDefault();
+
+ Ajax.api(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "dialogRewriteRules",
+ source: null,
+ options: {
+ title: Language.get("wcf.acp.rewrite"),
+ },
+ };
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "generateRewriteRules",
+ className: "wcf\\data\\option\\OptionAction",
+ },
+ };
+ }
+
+ _ajaxSuccess(data: ResponseData): void {
+ UiDialog.open(this, data.returnValues);
+ }
+}
+
+let rewriteGenerator: RewriteGenerator;
+
+export function init(): void {
+ if (!rewriteGenerator) {
+ rewriteGenerator = new RewriteGenerator();
+ }
+}
--- /dev/null
+/**
+ * Automatic URL rewrite support testing.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Option/RewriteTest
+ */
+
+import AjaxRequest from "../../../Ajax/Request";
+import * as Language from "../../../Language";
+import UiDialog from "../../../Ui/Dialog";
+import { DialogCallbackSetup } from "../../../Ui/Dialog/Data";
+import DomUtil from "../../../Dom/Util";
+
+interface TestResult {
+ app: string;
+ pass: boolean;
+}
+
+class RewriteTest {
+ private readonly apps: Map<string, string>;
+ private readonly buttonStartTest = document.getElementById("rewriteTestStart") as HTMLAnchorElement;
+ private readonly callbackChange: (ev: MouseEvent) => void;
+ private passed = false;
+ private readonly urlOmitIndexPhp: HTMLInputElement;
+
+ /**
+ * Initializes the rewrite test, but aborts early if URL rewriting was
+ * enabled at page init.
+ */
+ constructor(apps: Map<string, string>) {
+ const urlOmitIndexPhp = document.getElementById("url_omit_index_php") as HTMLInputElement;
+
+ // This configuration part is unavailable when running in enterprise mode.
+ if (urlOmitIndexPhp === null) {
+ return;
+ }
+
+ this.urlOmitIndexPhp = urlOmitIndexPhp;
+ if (this.urlOmitIndexPhp.checked) {
+ // option is already enabled, ignore it
+ return;
+ }
+
+ this.callbackChange = (ev) => this.onChange(ev);
+ this.urlOmitIndexPhp.addEventListener("change", this.callbackChange);
+ this.apps = apps;
+ }
+
+ /**
+ * Forces the rewrite test when attempting to enable the URL rewriting.
+ */
+ private onChange(event: Event): void {
+ event.preventDefault();
+
+ UiDialog.open(this);
+ }
+
+ /**
+ * Runs the actual rewrite test.
+ */
+ private async runTest(event?: MouseEvent): Promise<void> {
+ if (event instanceof Event) {
+ event.preventDefault();
+ }
+
+ if (this.buttonStartTest.classList.contains("disabled")) {
+ return;
+ }
+
+ this.buttonStartTest.classList.add("disabled");
+ this.setStatus("running");
+
+ const tests: Promise<TestResult>[] = Array.from(this.apps).map(([app, url]) => {
+ return new Promise((resolve, reject) => {
+ const request = new AjaxRequest({
+ ignoreError: true,
+ // bypass the LinkHandler, because rewrites aren't enabled yet
+ url: url,
+ type: "GET",
+ includeRequestedWith: false,
+ success: (data) => {
+ if (
+ !Object.prototype.hasOwnProperty.call(data, "core_rewrite_test") ||
+ data.core_rewrite_test !== "passed"
+ ) {
+ reject({ app, pass: false });
+ } else {
+ resolve({ app, pass: true });
+ }
+ },
+ failure: () => {
+ reject({ app, pass: false });
+
+ return true;
+ },
+ });
+
+ request.sendRequest(false);
+ });
+ });
+
+ const results: TestResult[] = await Promise.all(tests.map((test) => test.catch((result) => result)));
+
+ const passed = results.every((result) => result.pass);
+
+ // Delay the status update to prevent UI flicker.
+ await new Promise((resolve) => window.setTimeout(resolve, 500));
+
+ if (passed) {
+ this.passed = true;
+
+ this.setStatus("success");
+
+ this.urlOmitIndexPhp.removeEventListener("change", this.callbackChange);
+
+ await new Promise((resolve) => window.setTimeout(resolve, 1000));
+
+ if (UiDialog.isOpen(this)) {
+ UiDialog.close(this);
+ }
+ } else {
+ this.buttonStartTest.classList.remove("disabled");
+
+ const testFailureResults = document.getElementById("dialogRewriteTestFailureResults")!;
+ testFailureResults.innerHTML = results
+ .map((result) => {
+ return `<li><span class="badge label ${result.pass ? "green" : "red"}">${Language.get(
+ "wcf.acp.option.url_omit_index_php.test.status." + (result.pass ? "success" : "failure"),
+ )}</span> ${result.app}</li>`;
+ })
+ .join("");
+
+ this.setStatus("failure");
+ }
+ }
+
+ /**
+ * Displays the appropriate dialog message.
+ */
+ private setStatus(status: string): void {
+ const containers = [
+ document.getElementById("dialogRewriteTestRunning")!,
+ document.getElementById("dialogRewriteTestSuccess")!,
+ document.getElementById("dialogRewriteTestFailure")!,
+ ];
+
+ containers.forEach((element) => DomUtil.hide(element));
+
+ let i = 0;
+ if (status === "success") {
+ i = 1;
+ } else if (status === "failure") {
+ i = 2;
+ }
+
+ DomUtil.show(containers[i]);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "dialogRewriteTest",
+ options: {
+ onClose: () => {
+ if (!this.passed) {
+ const urlOmitIndexPhpNo = document.getElementById("url_omit_index_php_no") as HTMLInputElement;
+ urlOmitIndexPhpNo.checked = true;
+ }
+ },
+ onSetup: () => {
+ this.buttonStartTest.addEventListener("click", (ev) => {
+ void this.runTest(ev);
+ });
+ },
+ onShow: () => this.runTest(),
+ title: Language.get("wcf.acp.option.url_omit_index_php"),
+ },
+ };
+ }
+}
+
+let rewriteTest: RewriteTest;
+
+export function init(apps: Map<string, string>): void {
+ if (!rewriteTest) {
+ rewriteTest = new RewriteTest(apps);
+ }
+}
--- /dev/null
+/**
+ * Attempts to download the requested package from the file and prompts for the
+ * authentication credentials on rejection.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Package/PrepareInstallation
+ */
+
+import * as Ajax from "../../../Ajax";
+import { AjaxCallbackSetup } from "../../../Ajax/Data";
+import * as Core from "../../../Core";
+import { DialogCallbackSetup } from "../../../Ui/Dialog/Data";
+import * as Language from "../../../Language";
+import UiDialog from "../../../Ui/Dialog";
+import DomUtil from "../../../Dom/Util";
+
+interface AjaxResponse {
+ returnValues: {
+ queueID?: number;
+ template?: string;
+ };
+}
+
+class AcpUiPackagePrepareInstallation {
+ private identifier = "";
+ private version = "";
+
+ start(identifier: string, version: string): void {
+ this.identifier = identifier;
+ this.version = version;
+
+ this.prepare({});
+ }
+
+ private prepare(authData: ArbitraryObject): void {
+ const packages = {};
+ packages[this.identifier] = this.version;
+
+ Ajax.api(this, {
+ parameters: {
+ authData: authData,
+ packages: packages,
+ },
+ });
+ }
+
+ private submit(packageUpdateServerId: number): void {
+ const usernameInput = document.getElementById("packageUpdateServerUsername") as HTMLInputElement;
+ const passwordInput = document.getElementById("packageUpdateServerPassword") as HTMLInputElement;
+
+ DomUtil.innerError(usernameInput, false);
+ DomUtil.innerError(passwordInput, false);
+
+ const username = usernameInput.value.trim();
+ if (username === "") {
+ DomUtil.innerError(usernameInput, Language.get("wcf.global.form.error.empty"));
+ } else {
+ const password = passwordInput.value.trim();
+ if (password === "") {
+ DomUtil.innerError(passwordInput, Language.get("wcf.global.form.error.empty"));
+ } else {
+ const saveCredentials = document.getElementById("packageUpdateServerSaveCredentials") as HTMLInputElement;
+
+ this.prepare({
+ packageUpdateServerID: packageUpdateServerId,
+ password,
+ saveCredentials: saveCredentials.checked,
+ username,
+ });
+ }
+ }
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ if (data.returnValues.queueID) {
+ if (UiDialog.isOpen(this)) {
+ UiDialog.close(this);
+ }
+
+ const installation = new window.WCF.ACP.Package.Installation(data.returnValues.queueID, undefined, false);
+ installation.prepareInstallation();
+ } else if (data.returnValues.template) {
+ UiDialog.open(this, data.returnValues.template);
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "prepareInstallation",
+ className: "wcf\\data\\package\\update\\PackageUpdateAction",
+ },
+ };
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "packageDownloadAuthorization",
+ options: {
+ onSetup: (content) => {
+ const button = content.querySelector(".formSubmit > button") as HTMLButtonElement;
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ const packageUpdateServerId = ~~button.dataset.packageUpdateServerId!;
+ this.submit(packageUpdateServerId);
+ });
+ },
+ title: Language.get("wcf.acp.package.update.unauthorized"),
+ },
+ source: null,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(AcpUiPackagePrepareInstallation);
+
+export = AcpUiPackagePrepareInstallation;
--- /dev/null
+/**
+ * Search interface for the package server lists.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Package/Search
+ */
+
+import AcpUiPackagePrepareInstallation from "./PrepareInstallation";
+import * as Ajax from "../../../Ajax";
+import AjaxRequest from "../../../Ajax/Request";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../Ajax/Data";
+import * as Core from "../../../Core";
+
+interface AjaxResponse {
+ actionName: string;
+ returnValues: {
+ count: number;
+ template: string;
+ };
+}
+
+interface SearchOptions {
+ delay: number;
+ minLength: number;
+}
+
+class AcpUiPackageSearch implements AjaxCallbackObject {
+ private readonly input: HTMLInputElement;
+ private readonly installation: AcpUiPackagePrepareInstallation;
+ private isBusy = false;
+ private isFirstRequest = true;
+ private lastValue = "";
+ private options: SearchOptions;
+ private request?: AjaxRequest = undefined;
+ private readonly resultList: HTMLElement;
+ private readonly resultListContainer: HTMLElement;
+ private readonly resultCounter: HTMLElement;
+ private timerDelay?: number = undefined;
+
+ constructor() {
+ this.input = document.getElementById("packageSearchInput") as HTMLInputElement;
+ this.installation = new AcpUiPackagePrepareInstallation();
+ this.options = {
+ delay: 300,
+ minLength: 3,
+ };
+ this.resultList = document.getElementById("packageSearchResultList")!;
+ this.resultListContainer = document.getElementById("packageSearchResultContainer")!;
+ this.resultCounter = document.getElementById("packageSearchResultCounter")!;
+
+ this.input.addEventListener("keyup", () => this.keyup());
+ }
+
+ private keyup(): void {
+ const value = this.input.value.trim();
+ if (this.lastValue === value) {
+ return;
+ }
+
+ this.lastValue = value;
+
+ if (value.length < this.options.minLength) {
+ this.setStatus("idle");
+ return;
+ }
+
+ if (this.isFirstRequest) {
+ if (!this.isBusy) {
+ this.isBusy = true;
+
+ this.setStatus("refreshDatabase");
+
+ Ajax.api(this, {
+ actionName: "refreshDatabase",
+ });
+ }
+
+ return;
+ }
+
+ if (this.timerDelay !== null) {
+ window.clearTimeout(this.timerDelay);
+ }
+
+ this.timerDelay = window.setTimeout(() => {
+ this.setStatus("loading");
+ this.search(value);
+ }, this.options.delay);
+ }
+
+ private search(value: string): void {
+ if (this.request) {
+ this.request.abortPrevious();
+ }
+
+ this.request = Ajax.api(this, {
+ parameters: {
+ searchString: value,
+ },
+ });
+ }
+
+ private setStatus(status: string): void {
+ this.resultListContainer.dataset.status = status;
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ switch (data.actionName) {
+ case "refreshDatabase":
+ this.isFirstRequest = false;
+
+ this.lastValue = "";
+ this.keyup();
+ break;
+
+ case "search":
+ if (data.returnValues.count > 0) {
+ this.resultList.innerHTML = data.returnValues.template;
+ this.resultCounter.textContent = data.returnValues.count.toString();
+
+ this.setStatus("showResults");
+
+ this.resultList.querySelectorAll(".jsInstallPackage").forEach((button: HTMLAnchorElement) => {
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ button.blur();
+
+ this.installation.start(button.dataset.package!, button.dataset.packageVersion!);
+ });
+ });
+ } else {
+ this.setStatus("noResults");
+ }
+ break;
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "search",
+ className: "wcf\\data\\package\\update\\PackageUpdateAction",
+ },
+ silent: true,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(AcpUiPackageSearch);
+
+export = AcpUiPackageSearch;
--- /dev/null
+/**
+ * Provides the dialog overlay to add a new page.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Page/Add
+ */
+
+import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
+import * as Language from "../../../Language";
+import UiDialog from "../../../Ui/Dialog";
+
+class AcpUiPageAdd implements DialogCallbackObject {
+ private readonly isI18n: boolean;
+ private readonly link: string;
+
+ constructor(link: string, isI18n: boolean) {
+ this.link = link;
+ this.isI18n = isI18n;
+
+ document.querySelectorAll(".jsButtonPageAdd").forEach((button: HTMLAnchorElement) => {
+ button.addEventListener("click", (ev) => this.openDialog(ev));
+ });
+ }
+
+ /**
+ * Opens the 'Add Page' dialog.
+ */
+ openDialog(event?: MouseEvent): void {
+ if (event instanceof Event) {
+ event.preventDefault();
+ }
+
+ UiDialog.open(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "pageAddDialog",
+ options: {
+ onSetup: (content) => {
+ const button = content.querySelector("button") as HTMLButtonElement;
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ const pageType = (content.querySelector('input[name="pageType"]:checked') as HTMLInputElement).value;
+ let isMultilingual = "0";
+ if (this.isI18n) {
+ isMultilingual = (content.querySelector('input[name="isMultilingual"]:checked') as HTMLInputElement)
+ .value;
+ }
+
+ window.location.href = this.link
+ .replace("{$pageType}", pageType)
+ .replace("{$isMultilingual}", isMultilingual);
+ });
+ },
+ title: Language.get("wcf.acp.page.add"),
+ },
+ };
+ }
+}
+
+let acpUiPageAdd: AcpUiPageAdd;
+
+/**
+ * Initializes the page add handler.
+ */
+export function init(link: string, languages: number): void {
+ if (!acpUiPageAdd) {
+ acpUiPageAdd = new AcpUiPageAdd(link, languages > 0);
+ }
+}
+
+/**
+ * Opens the 'Add Page' dialog.
+ */
+export function openDialog(): void {
+ acpUiPageAdd.openDialog();
+}
--- /dev/null
+/**
+ * Provides helper functions to sort boxes per page.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Page/BoxOrder
+ */
+
+import * as Ajax from "../../../Ajax";
+import DomChangeListener from "../../../Dom/Change/Listener";
+import * as Language from "../../../Language";
+import * as UiConfirmation from "../../../Ui/Confirmation";
+import * as UiNotification from "../../../Ui/Notification";
+import { AjaxCallbackSetup } from "../../../Ajax/Data";
+
+interface AjaxResponse {
+ actionName: string;
+}
+
+interface BoxData {
+ boxId: number;
+ isDisabled: boolean;
+ name: string;
+}
+
+class AcpUiPageBoxOrder {
+ private readonly pageId: number;
+ private readonly pbo: HTMLElement;
+
+ /**
+ * Initializes the sorting capabilities.
+ */
+ constructor(pageId: number, boxes: Map<string, BoxData[]>) {
+ this.pageId = pageId;
+ this.pbo = document.getElementById("pbo")!;
+
+ boxes.forEach((boxData, position) => {
+ const container = document.createElement("ul");
+ boxData.forEach((box) => {
+ const item = document.createElement("li");
+ item.dataset.boxId = box.boxId.toString();
+
+ let icon = "";
+ if (box.isDisabled) {
+ icon = ` <span class="icon icon16 fa-exclamation-triangle red jsTooltip" title="${Language.get(
+ "wcf.acp.box.isDisabled",
+ )}"></span>`;
+ }
+
+ item.innerHTML = box.name + icon;
+
+ container.appendChild(item);
+ });
+
+ if (boxData.length > 1) {
+ window.jQuery(container).sortable({
+ opacity: 0.6,
+ placeholder: "sortablePlaceholder",
+ });
+ }
+
+ const wrapper = this.pbo.querySelector(`[data-placeholder="${position}"]`) as HTMLElement;
+ wrapper.appendChild(container);
+ });
+
+ const submitButton = document.querySelector('button[data-type="submit"]') as HTMLButtonElement;
+ submitButton.addEventListener("click", (ev) => this.save(ev));
+
+ const buttonDiscard = document.querySelector(".jsButtonCustomShowOrder") as HTMLAnchorElement;
+ if (buttonDiscard) buttonDiscard.addEventListener("click", (ev) => this.discard(ev));
+
+ DomChangeListener.trigger();
+ }
+
+ /**
+ * Saves the order of all boxes per position.
+ */
+ private save(event: MouseEvent): void {
+ event.preventDefault();
+
+ const data = {};
+
+ // collect data
+ this.pbo.querySelectorAll("[data-placeholder]").forEach((position: HTMLElement) => {
+ const boxIds = Array.from(position.querySelectorAll("li"))
+ .map((element) => ~~element.dataset.boxId!)
+ .filter((id) => id > 0);
+
+ const placeholder = position.dataset.placeholder!;
+ data[placeholder] = boxIds;
+ });
+
+ Ajax.api(this, {
+ parameters: {
+ position: data,
+ },
+ });
+ }
+
+ /**
+ * Shows an dialog to discard the individual box show order for this page.
+ */
+ private discard(event: MouseEvent): void {
+ event.preventDefault();
+
+ UiConfirmation.show({
+ confirm: () => {
+ Ajax.api(this, {
+ actionName: "resetPosition",
+ });
+ },
+ message: Language.get("wcf.acp.page.boxOrder.discard.confirmMessage"),
+ });
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ switch (data.actionName) {
+ case "updatePosition":
+ UiNotification.show();
+ break;
+
+ case "resetPosition":
+ UiNotification.show(undefined, () => {
+ window.location.reload();
+ });
+ break;
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "updatePosition",
+ className: "wcf\\data\\page\\PageAction",
+ interfaceName: "wcf\\data\\ISortableAction",
+ objectIDs: [this.pageId],
+ },
+ };
+ }
+}
+
+let acpUiPageBoxOrder: AcpUiPageBoxOrder;
+
+/**
+ * Initializes the sorting capabilities.
+ */
+export function init(pageId: number, boxes: Map<string, BoxData[]>): void {
+ if (!acpUiPageBoxOrder) {
+ acpUiPageBoxOrder = new AcpUiPageBoxOrder(pageId, boxes);
+ }
+}
--- /dev/null
+import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
+import * as Language from "../../../Language";
+import UiDialog from "../../../Ui/Dialog";
+
+class AcpUiPageCopy implements DialogCallbackObject {
+ constructor() {
+ document.querySelectorAll(".jsButtonCopyPage").forEach((button: HTMLAnchorElement) => {
+ button.addEventListener("click", (ev) => this.click(ev));
+ });
+ }
+
+ private click(event: MouseEvent): void {
+ event.preventDefault();
+
+ UiDialog.open(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "acpPageCopyDialog",
+ options: {
+ title: Language.get("wcf.acp.page.copy"),
+ },
+ };
+ }
+}
+
+let acpUiPageCopy: AcpUiPageCopy;
+
+export function init(): void {
+ if (!acpUiPageCopy) {
+ acpUiPageCopy = new AcpUiPageCopy();
+ }
+}
--- /dev/null
+/**
+ * Provides the ACP menu navigation.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Page/Menu
+ */
+
+import perfectScrollbar from "perfect-scrollbar";
+
+import * as EventHandler from "../../../Event/Handler";
+import * as UiScreen from "../../../Ui/Screen";
+
+const _acpPageMenu = document.getElementById("acpPageMenu") as HTMLElement;
+const _acpPageSubMenu = document.getElementById("acpPageSubMenu") as HTMLElement;
+let _activeMenuItem = "";
+const _menuItems = new Map<string, HTMLAnchorElement>();
+const _menuItemContainers = new Map<string, HTMLOListElement>();
+const _pageContainer = document.getElementById("pageContainer") as HTMLElement;
+let _perfectScrollbarActive = false;
+
+/**
+ * Initializes the ACP menu navigation.
+ */
+export function init(): void {
+ document.querySelectorAll(".acpPageMenuLink").forEach((link: HTMLAnchorElement) => {
+ const menuItem = link.dataset.menuItem!;
+ if (link.classList.contains("active")) {
+ _activeMenuItem = menuItem;
+ }
+
+ link.addEventListener("click", (ev) => toggle(ev));
+
+ _menuItems.set(menuItem, link);
+ });
+
+ document.querySelectorAll(".acpPageSubMenuCategoryList").forEach((container: HTMLOListElement) => {
+ const menuItem = container.dataset.menuItem!;
+ _menuItemContainers.set(menuItem, container);
+ });
+
+ // menu is missing on the login page or during WCFSetup
+ if (_acpPageMenu === null) {
+ return;
+ }
+
+ UiScreen.on("screen-lg", {
+ match: enablePerfectScrollbar,
+ unmatch: disablePerfectScrollbar,
+ setup: enablePerfectScrollbar,
+ });
+
+ window.addEventListener("resize", () => {
+ if (_perfectScrollbarActive) {
+ perfectScrollbar.update(_acpPageMenu);
+ perfectScrollbar.update(_acpPageSubMenu);
+ }
+ });
+}
+
+function enablePerfectScrollbar(): void {
+ const options = {
+ wheelPropagation: false,
+ swipePropagation: false,
+ suppressScrollX: true,
+ };
+
+ perfectScrollbar.initialize(_acpPageMenu, options);
+ perfectScrollbar.initialize(_acpPageSubMenu, options);
+
+ _perfectScrollbarActive = true;
+}
+
+function disablePerfectScrollbar(): void {
+ perfectScrollbar.destroy(_acpPageMenu);
+ perfectScrollbar.destroy(_acpPageSubMenu);
+
+ _perfectScrollbarActive = false;
+}
+
+/**
+ * Toggles a menu item.
+ */
+function toggle(event: MouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const link = event.currentTarget as HTMLAnchorElement;
+ const menuItem = link.dataset.menuItem!;
+ let acpPageSubMenuActive = false;
+
+ // remove active marking from currently active menu
+ if (_activeMenuItem) {
+ _menuItems.get(_activeMenuItem)!.classList.remove("active");
+ _menuItemContainers.get(_activeMenuItem)!.classList.remove("active");
+ }
+
+ if (_activeMenuItem === menuItem) {
+ // current item was active before
+ _activeMenuItem = "";
+ } else {
+ link.classList.add("active");
+ _menuItemContainers.get(menuItem)!.classList.add("active");
+
+ _activeMenuItem = menuItem;
+ acpPageSubMenuActive = true;
+ }
+
+ if (acpPageSubMenuActive) {
+ _pageContainer.classList.add("acpPageSubMenuActive");
+ } else {
+ _pageContainer.classList.remove("acpPageSubMenuActive");
+ }
+
+ if (_perfectScrollbarActive) {
+ _acpPageSubMenu.scrollTop = 0;
+ perfectScrollbar.update(_acpPageSubMenu);
+ }
+
+ EventHandler.fire("com.woltlab.wcf.AcpMenu", "resize");
+}
--- /dev/null
+/**
+ * Provides the style editor.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Style/Editor
+ */
+
+import * as Ajax from "../../../Ajax";
+import * as Core from "../../../Core";
+import DomUtil from "../../../Dom/Util";
+import * as EventHandler from "../../../Event/Handler";
+import * as UiScreen from "../../../Ui/Screen";
+
+const _stylePreviewRegions = new Map<string, HTMLElement>();
+let _stylePreviewRegionMarker: HTMLElement;
+const _stylePreviewWindow = document.getElementById("spWindow")!;
+
+let _isVisible = true;
+let _isSmartphone = false;
+let _updateRegionMarker: () => void;
+
+interface StyleRuleMap {
+ [key: string]: string;
+}
+
+interface StyleEditorOptions {
+ isTainted: boolean;
+ styleId: number;
+ styleRuleMap: StyleRuleMap;
+}
+
+/**
+ * Handles the switch between static and fluid layout.
+ */
+function handleLayoutWidth(): void {
+ const useFluidLayout = document.getElementById("useFluidLayout") as HTMLInputElement;
+ const fluidLayoutMinWidth = document.getElementById("fluidLayoutMinWidth") as HTMLInputElement;
+ const fluidLayoutMaxWidth = document.getElementById("fluidLayoutMaxWidth") as HTMLInputElement;
+ const fixedLayoutVariables = document.getElementById("fixedLayoutVariables") as HTMLDListElement;
+
+ function change(): void {
+ if (useFluidLayout.checked) {
+ DomUtil.show(fluidLayoutMinWidth);
+ DomUtil.show(fluidLayoutMaxWidth);
+ DomUtil.hide(fixedLayoutVariables);
+ } else {
+ DomUtil.hide(fluidLayoutMinWidth);
+ DomUtil.hide(fluidLayoutMaxWidth);
+ DomUtil.show(fixedLayoutVariables);
+ }
+ }
+
+ useFluidLayout.addEventListener("change", change);
+
+ change();
+}
+
+/**
+ * Handles SCSS input fields.
+ */
+function handleScss(isTainted: boolean): void {
+ const individualScss = document.getElementById("individualScss")!;
+ const overrideScss = document.getElementById("overrideScss")!;
+
+ if (isTainted) {
+ EventHandler.add("com.woltlab.wcf.simpleTabMenu_styleTabMenuContainer", "select", () => {
+ (individualScss as any).codemirror.refresh();
+ (overrideScss as any).codemirror.refresh();
+ });
+ } else {
+ EventHandler.add("com.woltlab.wcf.simpleTabMenu_advanced", "select", (data: { activeName: string }) => {
+ if (data.activeName === "advanced-custom") {
+ (document.getElementById("individualScssCustom") as any).codemirror.refresh();
+ (document.getElementById("overrideScssCustom") as any).codemirror.refresh();
+ } else if (data.activeName === "advanced-original") {
+ (individualScss as any).codemirror.refresh();
+ (overrideScss as any).codemirror.refresh();
+ }
+ });
+ }
+}
+
+function handleProtection(styleId: number): void {
+ const button = document.getElementById("styleDisableProtectionSubmit") as HTMLButtonElement;
+ const checkbox = document.getElementById("styleDisableProtectionConfirm") as HTMLInputElement;
+
+ checkbox.addEventListener("change", () => {
+ button.disabled = !checkbox.checked;
+ });
+
+ button.addEventListener("click", () => {
+ Ajax.apiOnce({
+ data: {
+ actionName: "markAsTainted",
+ className: "wcf\\data\\style\\StyleAction",
+ objectIDs: [styleId],
+ },
+ success: () => {
+ window.location.reload();
+ },
+ });
+ });
+}
+
+function initVisualEditor(styleRuleMap: StyleRuleMap): void {
+ _stylePreviewWindow.querySelectorAll("[data-region]").forEach((region: HTMLElement) => {
+ _stylePreviewRegions.set(region.dataset.region!, region);
+ });
+
+ _stylePreviewRegionMarker = document.createElement("div");
+ _stylePreviewRegionMarker.id = "stylePreviewRegionMarker";
+ _stylePreviewRegionMarker.innerHTML = '<div id="stylePreviewRegionMarkerBottom"></div>';
+ DomUtil.hide(_stylePreviewRegionMarker);
+ document.getElementById("colors")!.appendChild(_stylePreviewRegionMarker);
+
+ const container = document.getElementById("spSidebar")!;
+ const select = document.getElementById("spCategories") as HTMLSelectElement;
+ let lastValue = select.value;
+
+ _updateRegionMarker = (): void => {
+ if (_isSmartphone) {
+ return;
+ }
+
+ if (lastValue === "none") {
+ DomUtil.hide(_stylePreviewRegionMarker);
+ return;
+ }
+
+ const region = _stylePreviewRegions.get(lastValue)!;
+ const rect = region.getBoundingClientRect();
+
+ let top = rect.top + (window.scrollY || window.pageYOffset);
+
+ DomUtil.setStyles(_stylePreviewRegionMarker, {
+ height: `${region.clientHeight + 20}px`,
+ left: `${rect.left + document.body.scrollLeft - 10}px`,
+ top: `${top - 10}px`,
+ width: `${region.clientWidth + 20}px`,
+ });
+
+ DomUtil.show(_stylePreviewRegionMarker);
+
+ top = DomUtil.offset(region).top;
+ // `+ 80` = account for sticky header + selection markers (20px)
+ const firstVisiblePixel = (window.pageYOffset || window.scrollY) + 80;
+ if (firstVisiblePixel > top) {
+ window.scrollTo(0, Math.max(top - 80, 0));
+ } else {
+ const lastVisiblePixel = window.innerHeight + (window.pageYOffset || window.scrollY);
+ if (lastVisiblePixel < top) {
+ window.scrollTo(0, top);
+ } else {
+ const bottom = top + region.offsetHeight + 20;
+ if (lastVisiblePixel < bottom) {
+ window.scrollBy(0, bottom - top);
+ }
+ }
+ }
+ };
+
+ const apiVersions = container.querySelector('.spSidebarBox[data-category="apiVersion"]') as HTMLElement;
+ const callbackChange = () => {
+ let element = container.querySelector(`.spSidebarBox[data-category="${lastValue}"]`) as HTMLElement;
+ DomUtil.hide(element);
+
+ lastValue = select.value;
+ element = container.querySelector(`.spSidebarBox[data-category="${lastValue}"]`) as HTMLElement;
+ DomUtil.show(element);
+
+ const showCompatibilityNotice = element.querySelector(".spApiVersion") !== null;
+ if (showCompatibilityNotice) {
+ DomUtil.show(apiVersions);
+ } else {
+ DomUtil.hide(apiVersions);
+ }
+
+ // set region marker
+ _updateRegionMarker();
+ };
+ select.addEventListener("change", callbackChange);
+
+ // apply CSS rules
+ const style = document.createElement("style");
+ style.appendChild(document.createTextNode(""));
+ style.dataset.createdBy = "WoltLab/Acp/Ui/Style/Editor";
+ document.head.appendChild(style);
+
+ function updateCSSRule(identifier: string, value: string): void {
+ if (styleRuleMap[identifier] === undefined) {
+ return;
+ }
+
+ const rule = styleRuleMap[identifier].replace(/VALUE/g, value + " !important");
+ if (!rule) {
+ return;
+ }
+
+ let rules: string[];
+ if (rule.indexOf("__COMBO_RULE__")) {
+ rules = rule.split("__COMBO_RULE__");
+ } else {
+ rules = [rule];
+ }
+
+ rules.forEach((rule) => {
+ try {
+ style.sheet!.insertRule(rule, style.sheet!.cssRules.length);
+ } catch (e) {
+ // ignore errors for unknown placeholder selectors
+ if (!/[a-z]+-placeholder/.test(rule)) {
+ console.debug(e.message);
+ }
+ }
+ });
+ }
+
+ const wrapper = document.getElementById("spVariablesWrapper")!;
+ wrapper.querySelectorAll(".styleVariableColor").forEach((colorField: HTMLElement) => {
+ const variableName = colorField.dataset.store!.replace(/_value$/, "");
+
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ if (mutation.attributeName === "style") {
+ updateCSSRule(variableName, colorField.style.getPropertyValue("background-color"));
+ }
+ });
+ });
+
+ observer.observe(colorField, {
+ attributes: true,
+ });
+
+ updateCSSRule(variableName, colorField.style.getPropertyValue("background-color"));
+ });
+
+ // category selection by clicking on the area
+ const buttonToggleColorPalette = document.querySelector(".jsButtonToggleColorPalette") as HTMLAnchorElement;
+ const buttonSelectCategoryByClick = document.querySelector(".jsButtonSelectCategoryByClick") as HTMLAnchorElement;
+
+ function toggleSelectionMode(): void {
+ buttonSelectCategoryByClick.classList.toggle("active");
+ buttonToggleColorPalette.classList.toggle("disabled");
+ _stylePreviewWindow.classList.toggle("spShowRegions");
+ _stylePreviewRegionMarker.classList.toggle("forceHide");
+ select.disabled = !select.disabled;
+ }
+
+ buttonSelectCategoryByClick.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ toggleSelectionMode();
+ });
+
+ _stylePreviewWindow.querySelectorAll("[data-region]").forEach((region: HTMLElement) => {
+ region.addEventListener("click", (event) => {
+ if (!_stylePreviewWindow.classList.contains("spShowRegions")) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ toggleSelectionMode();
+
+ select.value = region.dataset.region!;
+
+ // Programmatically trigger the change event handler, rather than dispatching an event,
+ // because Firefox fails to execute the event if it has previously been disabled.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1426856
+ callbackChange();
+ });
+ });
+
+ // toggle view
+ const spSelectCategory = document.getElementById("spSelectCategory") as HTMLSelectElement;
+ buttonToggleColorPalette.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ buttonSelectCategoryByClick.classList.toggle("disabled");
+ DomUtil.toggle(spSelectCategory);
+ buttonToggleColorPalette.classList.toggle("active");
+ _stylePreviewWindow.classList.toggle("spColorPalette");
+ _stylePreviewRegionMarker.classList.toggle("forceHide");
+ select.disabled = !select.disabled;
+ });
+}
+
+/**
+ * Sets up dynamic style options.
+ */
+export function setup(options: StyleEditorOptions): void {
+ handleLayoutWidth();
+ handleScss(options.isTainted);
+
+ if (!options.isTainted) {
+ handleProtection(options.styleId);
+ }
+
+ initVisualEditor(options.styleRuleMap);
+
+ UiScreen.on("screen-sm-down", {
+ match() {
+ hideVisualEditor();
+ },
+ unmatch() {
+ showVisualEditor();
+ },
+ setup() {
+ hideVisualEditor();
+ },
+ });
+
+ function callbackRegionMarker(): void {
+ if (_isVisible) {
+ _updateRegionMarker();
+ }
+ }
+
+ window.addEventListener("resize", callbackRegionMarker);
+ EventHandler.add("com.woltlab.wcf.AcpMenu", "resize", callbackRegionMarker);
+ EventHandler.add("com.woltlab.wcf.simpleTabMenu_styleTabMenuContainer", "select", function (data) {
+ _isVisible = data.activeName === "colors";
+ callbackRegionMarker();
+ });
+}
+
+export function hideVisualEditor(): void {
+ DomUtil.hide(_stylePreviewWindow);
+ document.getElementById("spVariablesWrapper")!.style.removeProperty("transform");
+ DomUtil.hide(document.getElementById("stylePreviewRegionMarker")!);
+
+ _isSmartphone = true;
+}
+
+export function showVisualEditor(): void {
+ DomUtil.show(_stylePreviewWindow);
+
+ window.setTimeout(() => {
+ Core.triggerEvent(document.getElementById("spCategories")!, "change");
+ }, 100);
+
+ _isSmartphone = false;
+}
--- /dev/null
+/**
+ * Provides a dialog to copy an existing template group.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Template/Group/Copy
+ */
+
+import * as Ajax from "../../../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../../Ajax/Data";
+import { DialogCallbackObject, DialogCallbackSetup } from "../../../../Ui/Dialog/Data";
+import * as Language from "../../../../Language";
+import UiDialog from "../../../../Ui/Dialog";
+import * as UiNotification from "../../../../Ui/Notification";
+import DomUtil from "../../../../Dom/Util";
+
+interface AjaxResponse {
+ returnValues: {
+ redirectURL: string;
+ };
+}
+
+interface AjaxResponseError {
+ returnValues?: {
+ fieldName?: string;
+ errorType?: string;
+ };
+}
+
+class AcpUiTemplateGroupCopy implements AjaxCallbackObject, DialogCallbackObject {
+ private folderName?: HTMLInputElement = undefined;
+ private name?: HTMLInputElement = undefined;
+ private readonly templateGroupId: number;
+
+ constructor(templateGroupId: number) {
+ this.templateGroupId = templateGroupId;
+
+ const button = document.querySelector(".jsButtonCopy") as HTMLAnchorElement;
+ button.addEventListener("click", (ev) => this.click(ev));
+ }
+
+ private click(event: MouseEvent): void {
+ event.preventDefault();
+
+ UiDialog.open(this);
+ }
+
+ _dialogSubmit(): void {
+ Ajax.api(this, {
+ parameters: {
+ templateGroupName: this.name!.value,
+ templateGroupFolderName: this.folderName!.value,
+ },
+ });
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ UiDialog.close(this);
+
+ UiNotification.show(undefined, () => {
+ window.location.href = data.returnValues.redirectURL;
+ });
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "templateGroupCopy",
+ options: {
+ onSetup: () => {
+ ["Name", "FolderName"].forEach((type) => {
+ const input = document.getElementById("copyTemplateGroup" + type) as HTMLInputElement;
+ input.value = (document.getElementById("templateGroup" + type) as HTMLInputElement).value;
+
+ if (type === "Name") {
+ this.name = input;
+ } else {
+ this.folderName = input;
+ }
+ });
+ },
+ title: Language.get("wcf.acp.template.group.copy"),
+ },
+ source: `<dl>
+ <dt>
+ <label for="copyTemplateGroupName">${Language.get("wcf.global.name")}</label>
+ </dt>
+ <dd>
+ <input type="text" id="copyTemplateGroupName" class="long" data-dialog-submit-on-enter="true" required>
+ </dd>
+</dl>
+<dl>
+ <dt>
+ <label for="copyTemplateGroupFolderName">${Language.get("wcf.acp.template.group.folderName")}</label>
+ </dt>
+ <dd>
+ <input type="text" id="copyTemplateGroupFolderName" class="long" data-dialog-submit-on-enter="true" required>
+ </dd>
+</dl>
+<div class="formSubmit">
+ <button class="buttonPrimary" data-type="submit">${Language.get("wcf.global.button.submit")}</button>
+</div>`,
+ };
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "copy",
+ className: "wcf\\data\\template\\group\\TemplateGroupAction",
+ objectIDs: [this.templateGroupId],
+ },
+ failure: (data: AjaxResponseError) => {
+ if (data && data.returnValues && data.returnValues.fieldName && data.returnValues.errorType) {
+ if (data.returnValues.fieldName === "templateGroupName") {
+ DomUtil.innerError(
+ this.name!,
+ Language.get(`wcf.acp.template.group.name.error.${data.returnValues.errorType}`),
+ );
+ } else {
+ DomUtil.innerError(
+ this.folderName!,
+ Language.get(`wcf.acp.template.group.folderName.error.${data.returnValues.errorType}`),
+ );
+ }
+
+ return false;
+ }
+
+ return true;
+ },
+ };
+ }
+}
+
+let acpUiTemplateGroupCopy: AcpUiTemplateGroupCopy;
+
+export function init(templateGroupId: number): void {
+ if (!acpUiTemplateGroupCopy) {
+ acpUiTemplateGroupCopy = new AcpUiTemplateGroupCopy(templateGroupId);
+ }
+}
--- /dev/null
+/**
+ * Provides the trophy icon designer.
+ *
+ * @author Joshua Ruesweg
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Trophy/Badge
+ */
+
+import * as Language from "../../../Language";
+import UiDialog from "../../../Ui/Dialog";
+import * as UiStyleFontAwesome from "../../../Ui/Style/FontAwesome";
+import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
+
+interface Rgba {
+ r: number;
+ g: number;
+ b: number;
+ a: number;
+}
+
+type Color = string | Rgba;
+
+/**
+ * @exports WoltLabSuite/Core/Acp/Ui/Trophy/Badge
+ */
+class AcpUiTrophyBadge implements DialogCallbackObject {
+ private badgeColor?: HTMLSpanElement = undefined;
+ private readonly badgeColorInput: HTMLInputElement;
+ private dialogContent?: HTMLElement = undefined;
+ private icon?: HTMLSpanElement = undefined;
+ private iconColor?: HTMLSpanElement = undefined;
+ private readonly iconColorInput: HTMLInputElement;
+ private readonly iconNameInput: HTMLInputElement;
+
+ /**
+ * Initializes the badge designer.
+ */
+ constructor() {
+ const iconContainer = document.getElementById("badgeContainer")!;
+ const button = iconContainer.querySelector(".button") as HTMLElement;
+ button.addEventListener("click", (ev) => this.click(ev));
+
+ this.iconNameInput = iconContainer.querySelector('input[name="iconName"]') as HTMLInputElement;
+ this.iconColorInput = iconContainer.querySelector('input[name="iconColor"]') as HTMLInputElement;
+ this.badgeColorInput = iconContainer.querySelector('input[name="badgeColor"]') as HTMLInputElement;
+ }
+
+ /**
+ * Opens the icon designer.
+ */
+ private click(event: MouseEvent): void {
+ event.preventDefault();
+
+ UiDialog.open(this);
+ }
+
+ /**
+ * Sets the icon name.
+ */
+ private setIcon(iconName: string): void {
+ this.icon!.textContent = iconName;
+
+ this.renderIcon();
+ }
+
+ /**
+ * Sets the icon color, can be either a string or an object holding the
+ * individual r, g, b and a values.
+ */
+ private setIconColor(color: Color): void {
+ if (typeof color !== "string") {
+ color = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
+ }
+
+ this.iconColor!.dataset.color = color;
+ this.iconColor!.style.setProperty("background-color", color, "");
+
+ this.renderIcon();
+ }
+
+ /**
+ * Sets the badge color, can be either a string or an object holding the
+ * individual r, g, b and a values.
+ */
+ private setBadgeColor(color: Color): void {
+ if (typeof color !== "string") {
+ color = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
+ }
+
+ this.badgeColor!.dataset.color = color;
+ this.badgeColor!.style.setProperty("background-color", color, "");
+
+ this.renderIcon();
+ }
+
+ /**
+ * Renders the custom icon preview.
+ */
+ private renderIcon(): void {
+ const iconColor = this.iconColor!.style.getPropertyValue("background-color");
+ const badgeColor = this.badgeColor!.style.getPropertyValue("background-color");
+
+ const icon = this.dialogContent!.querySelector(".jsTrophyIcon") as HTMLElement;
+
+ // set icon
+ icon.className = icon.className.replace(/\b(fa-[a-z0-9-]+)\b/, "");
+ icon.classList.add(`fa-${this.icon!.textContent!}`);
+
+ icon.style.setProperty("color", iconColor, "");
+ icon.style.setProperty("background-color", badgeColor, "");
+ }
+
+ /**
+ * Saves the custom icon design.
+ */
+ private save(event: MouseEvent): void {
+ event.preventDefault();
+
+ const iconColor = this.iconColor!.style.getPropertyValue("background-color");
+ const badgeColor = this.badgeColor!.style.getPropertyValue("background-color");
+ const icon = this.icon!.textContent!;
+
+ this.iconNameInput.value = icon;
+ this.badgeColorInput.value = badgeColor;
+ this.iconColorInput.value = iconColor;
+
+ const iconContainer = document.getElementById("iconContainer")!;
+ const previewIcon = iconContainer.querySelector(".jsTrophyIcon") as HTMLElement;
+
+ // set icon
+ previewIcon.className = previewIcon.className.replace(/\b(fa-[a-z0-9-]+)\b/, "");
+ previewIcon.classList.add("fa-" + icon);
+ previewIcon.style.setProperty("color", iconColor, "");
+ previewIcon.style.setProperty("background-color", badgeColor, "");
+
+ UiDialog.close(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "trophyIconEditor",
+ options: {
+ onSetup: (context) => {
+ this.dialogContent = context;
+
+ this.iconColor = context.querySelector("#jsIconColorContainer .colorBoxValue") as HTMLSpanElement;
+ this.badgeColor = context.querySelector("#jsBadgeColorContainer .colorBoxValue") as HTMLSpanElement;
+ this.icon = context.querySelector(".jsTrophyIconName") as HTMLSpanElement;
+
+ const buttonIconPicker = context.querySelector(".jsTrophyIconName + .button") as HTMLAnchorElement;
+ buttonIconPicker.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ UiStyleFontAwesome.open((iconName) => this.setIcon(iconName));
+ });
+
+ const iconColorContainer = document.getElementById("jsIconColorContainer")!;
+ const iconColorPicker = iconColorContainer.querySelector(".jsButtonIconColorPicker") as HTMLAnchorElement;
+ iconColorPicker.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ const picker = iconColorContainer.querySelector(".jsColorPicker") as HTMLAnchorElement;
+ picker.click();
+ });
+
+ const badgeColorContainer = document.getElementById("jsBadgeColorContainer")!;
+ const badgeColorPicker = badgeColorContainer.querySelector(".jsButtonBadgeColorPicker") as HTMLAnchorElement;
+ badgeColorPicker.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ const picker = badgeColorContainer.querySelector(".jsColorPicker") as HTMLAnchorElement;
+ picker.click();
+ });
+
+ const colorPicker = new window.WCF.ColorPicker(".jsColorPicker");
+ colorPicker.setCallbackSubmit(() => this.renderIcon());
+
+ const submitButton = context.querySelector(".formSubmit > .buttonPrimary") as HTMLElement;
+ submitButton.addEventListener("click", (ev) => this.save(ev));
+ },
+ onShow: () => {
+ this.setIcon(this.iconNameInput.value);
+ this.setIconColor(this.iconColorInput.value);
+ this.setBadgeColor(this.badgeColorInput.value);
+ },
+ title: Language.get("wcf.acp.trophy.badge.edit"),
+ },
+ };
+ }
+}
+
+let acpUiTrophyBadge: AcpUiTrophyBadge;
+
+/**
+ * Initializes the badge designer.
+ */
+export function init(): void {
+ if (!acpUiTrophyBadge) {
+ acpUiTrophyBadge = new AcpUiTrophyBadge();
+ }
+}
--- /dev/null
+/**
+ * Handles the trophy image upload.
+ *
+ * @author Joshua Ruesweg
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Trophy/Upload
+ */
+
+import * as Core from "../../../Core";
+import DomUtil from "../../../Dom/Util";
+import * as Language from "../../../Language";
+import * as UiNotification from "../../../Ui/Notification";
+import Upload from "../../../Upload";
+import { UploadOptions } from "../../../Upload/Data";
+
+interface AjaxResponse {
+ returnValues: {
+ url: string;
+ };
+}
+
+interface AjaxResponseError {
+ returnValues: {
+ errorType: string;
+ };
+}
+
+class TrophyUpload extends Upload {
+ private readonly trophyId: number;
+ private readonly tmpHash: string;
+
+ constructor(trophyId: number, tmpHash: string, options: Partial<UploadOptions>) {
+ super(
+ "uploadIconFileButton",
+ "uploadIconFileContent",
+ Core.extend(
+ {
+ className: "wcf\\data\\trophy\\TrophyAction",
+ },
+ options,
+ ),
+ );
+
+ this.trophyId = ~~trophyId;
+ this.tmpHash = tmpHash;
+ }
+
+ protected _getParameters(): ArbitraryObject {
+ return {
+ trophyID: this.trophyId,
+ tmpHash: this.tmpHash,
+ };
+ }
+
+ protected _success(uploadId: number, data: AjaxResponse): void {
+ DomUtil.innerError(this._button, false);
+
+ this._target.innerHTML = `<img src="${data.returnValues.url}?timestamp=${Date.now()}" alt="">`;
+
+ UiNotification.show();
+ }
+
+ protected _failure(uploadId: number, data: AjaxResponseError): boolean {
+ DomUtil.innerError(this._button, Language.get(`wcf.acp.trophy.imageUpload.error.${data.returnValues.errorType}`));
+
+ // remove previous images
+ this._target.innerHTML = "";
+
+ return false;
+ }
+}
+
+Core.enableLegacyInheritance(TrophyUpload);
+
+export = TrophyUpload;
--- /dev/null
+/**
+ * Handles the user content remove clipboard action.
+ *
+ * @author Joshua Ruesweg
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard
+ * @since 5.4
+ */
+
+import AcpUiWorker from "../../../Worker";
+import * as Ajax from "../../../../../Ajax";
+import * as Language from "../../../../../Language";
+import UiDialog from "../../../../../Ui/Dialog";
+import { AjaxCallbackSetup } from "../../../../../Ajax/Data";
+import { DialogCallbackSetup } from "../../../../../Ui/Dialog/Data";
+import * as EventHandler from "../../../../../Event/Handler";
+
+interface AjaxResponse {
+ returnValues: {
+ template: string;
+ };
+}
+
+interface EventData {
+ data: {
+ actionName: string;
+ internalData: any[];
+ label: string;
+ parameters: {
+ objectIDs: number[];
+ url: string;
+ };
+ };
+ listItem: HTMLElement;
+}
+
+export class AcpUserContentRemoveClipboard {
+ public userIds: number[];
+ private readonly dialogId = "userContentRemoveClipboardPrepareDialog";
+
+ /**
+ * Initializes the content remove handler.
+ */
+ constructor() {
+ EventHandler.add("com.woltlab.wcf.clipboard", "com.woltlab.wcf.user", (data: EventData) => {
+ if (data.data.actionName === "com.woltlab.wcf.user.deleteUserContent") {
+ this.userIds = data.data.parameters.objectIDs;
+
+ Ajax.api(this);
+ }
+ });
+ }
+
+ /**
+ * Executes the remove content worker.
+ */
+ private executeWorker(objectTypes: string[]): void {
+ new AcpUiWorker({
+ // dialog
+ dialogId: "removeContentWorker",
+ dialogTitle: Language.get("wcf.acp.content.removeContent"),
+
+ // ajax
+ className: "wcf\\system\\worker\\UserContentRemoveWorker",
+ parameters: {
+ userIDs: this.userIds,
+ contentProvider: objectTypes,
+ },
+ });
+ }
+
+ /**
+ * Handles a click on the submit button in the overlay.
+ */
+ private submit(): void {
+ const objectTypes = Array.from<HTMLInputElement>(
+ this.dialogContent.querySelectorAll("input.contentProviderObjectType"),
+ )
+ .filter((element) => element.checked)
+ .map((element) => element.name);
+
+ UiDialog.close(this.dialogId);
+
+ if (objectTypes.length > 0) {
+ window.setTimeout(() => {
+ this.executeWorker(objectTypes);
+ }, 200);
+ }
+ }
+
+ get dialogContent(): HTMLElement {
+ return UiDialog.getDialog(this.dialogId)!.content;
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ UiDialog.open(this, data.returnValues.template);
+
+ const submitButton = this.dialogContent.querySelector('input[type="submit"]') as HTMLElement;
+ submitButton.addEventListener("click", () => this.submit());
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "prepareRemoveContent",
+ className: "wcf\\data\\user\\UserAction",
+ parameters: {
+ userIDs: this.userIds,
+ },
+ },
+ };
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: this.dialogId,
+ options: {
+ title: Language.get("wcf.acp.content.removeContent"),
+ },
+ source: null,
+ };
+ }
+}
+
+export default AcpUserContentRemoveClipboard;
--- /dev/null
+/**
+ * Provides the trophy icon designer.
+ *
+ * @author Joshua Ruesweg
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler
+ * @since 5.2
+ */
+
+import AcpUiWorker from "../../../Worker";
+import * as Ajax from "../../../../../Ajax";
+import * as Language from "../../../../../Language";
+import UiDialog from "../../../../../Ui/Dialog";
+import { AjaxCallbackSetup } from "../../../../../Ajax/Data";
+import { DialogCallbackSetup } from "../../../../../Ui/Dialog/Data";
+
+interface AjaxResponse {
+ returnValues: {
+ template: string;
+ };
+}
+
+class AcpUserContentRemoveHandler {
+ private readonly dialogId: string;
+ private readonly userId: number;
+
+ /**
+ * Initializes the content remove handler.
+ */
+ constructor(element: HTMLElement, userId: number) {
+ this.userId = userId;
+ this.dialogId = `userRemoveContentHandler-${this.userId}`;
+
+ element.addEventListener("click", (ev) => this.click(ev));
+ }
+
+ /**
+ * Click on the remove content button.
+ */
+ private click(event: MouseEvent): void {
+ event.preventDefault();
+
+ Ajax.api(this);
+ }
+
+ /**
+ * Executes the remove content worker.
+ */
+ private executeWorker(objectTypes: string[]): void {
+ new AcpUiWorker({
+ // dialog
+ dialogId: "removeContentWorker",
+ dialogTitle: Language.get("wcf.acp.content.removeContent"),
+
+ // ajax
+ className: "\\wcf\\system\\worker\\UserContentRemoveWorker",
+ parameters: {
+ userID: this.userId,
+ contentProvider: objectTypes,
+ },
+ });
+ }
+
+ /**
+ * Handles a click on the submit button in the overlay.
+ */
+ private submit(): void {
+ const objectTypes = Array.from<HTMLInputElement>(
+ this.dialogContent.querySelectorAll("input.contentProviderObjectType"),
+ )
+ .filter((element) => element.checked)
+ .map((element) => element.name);
+
+ UiDialog.close(this.dialogId);
+
+ if (objectTypes.length > 0) {
+ window.setTimeout(() => {
+ this.executeWorker(objectTypes);
+ }, 200);
+ }
+ }
+
+ get dialogContent(): HTMLElement {
+ return UiDialog.getDialog(this.dialogId)!.content;
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ UiDialog.open(this, data.returnValues.template);
+
+ const submitButton = this.dialogContent.querySelector('input[type="submit"]') as HTMLElement;
+ submitButton.addEventListener("click", () => this.submit());
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "prepareRemoveContent",
+ className: "wcf\\data\\user\\UserAction",
+ parameters: {
+ userID: this.userId,
+ },
+ },
+ };
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: this.dialogId,
+ options: {
+ title: Language.get("wcf.acp.content.removeContent"),
+ },
+ source: null,
+ };
+ }
+}
+
+export = AcpUserContentRemoveHandler;
--- /dev/null
+/**
+ * User editing capabilities for the user list.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/User/Editor
+ * @since 3.1
+ */
+
+import AcpUserContentRemoveHandler from "./Content/Remove/Handler";
+import * as Ajax from "../../../Ajax";
+import * as Core from "../../../Core";
+import * as EventHandler from "../../../Event/Handler";
+import * as Language from "../../../Language";
+import * as UiNotification from "../../../Ui/Notification";
+import UiDropdownSimple from "../../../Ui/Dropdown/Simple";
+import { AjaxCallbackObject, DatabaseObjectActionResponse } from "../../../Ajax/Data";
+import DomUtil from "../../../Dom/Util";
+
+interface RefreshUsersData {
+ userIds: number[];
+}
+
+class AcpUiUserEditor {
+ /**
+ * Initializes the edit dropdown for each user.
+ */
+ constructor() {
+ document.querySelectorAll(".jsUserRow").forEach((userRow: HTMLTableRowElement) => this.initUser(userRow));
+
+ EventHandler.add("com.woltlab.wcf.acp.user", "refresh", (data: RefreshUsersData) => this.refreshUsers(data));
+ }
+
+ /**
+ * Initializes the edit dropdown for a user.
+ */
+ private initUser(userRow: HTMLTableRowElement): void {
+ const userId = ~~userRow.dataset.objectId!;
+ const dropdownId = `userListDropdown${userId}`;
+ const dropdownMenu = UiDropdownSimple.getDropdownMenu(dropdownId)!;
+ const legacyButtonContainer = userRow.querySelector(".jsLegacyButtons") as HTMLElement;
+
+ if (dropdownMenu.childElementCount === 0 && legacyButtonContainer.childElementCount === 0) {
+ const toggleButton = userRow.querySelector(".dropdownToggle") as HTMLAnchorElement;
+ toggleButton.classList.add("disabled");
+
+ return;
+ }
+
+ UiDropdownSimple.registerCallback(dropdownId, (identifier, action) => {
+ if (action === "open") {
+ this.rebuild(dropdownMenu, legacyButtonContainer);
+ }
+ });
+
+ const editLink = dropdownMenu.querySelector(".jsEditLink") as HTMLAnchorElement;
+ if (editLink !== null) {
+ const toggleButton = userRow.querySelector(".dropdownToggle") as HTMLAnchorElement;
+ toggleButton.addEventListener("dblclick", (event) => {
+ event.preventDefault();
+
+ editLink.click();
+ });
+ }
+
+ const sendNewPassword = dropdownMenu.querySelector(".jsSendNewPassword") as HTMLAnchorElement;
+ if (sendNewPassword !== null) {
+ sendNewPassword.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ // emulate clipboard selection
+ EventHandler.fire("com.woltlab.wcf.clipboard", "com.woltlab.wcf.user", {
+ data: {
+ actionName: "com.woltlab.wcf.user.sendNewPassword",
+ parameters: {
+ confirmMessage: Language.get("wcf.acp.user.action.sendNewPassword.confirmMessage"),
+ objectIDs: [userId],
+ },
+ },
+ responseData: {
+ actionName: "com.woltlab.wcf.user.sendNewPassword",
+ objectIDs: [userId],
+ },
+ });
+ });
+ }
+
+ const deleteContent = dropdownMenu.querySelector(".jsDeleteContent") as HTMLAnchorElement;
+ if (deleteContent !== null) {
+ new AcpUserContentRemoveHandler(deleteContent, userId);
+ }
+
+ const toggleConfirmEmail = dropdownMenu.querySelector(".jsConfirmEmailToggle") as HTMLAnchorElement;
+ if (toggleConfirmEmail !== null) {
+ toggleConfirmEmail.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ Ajax.api(
+ {
+ _ajaxSetup: () => {
+ const isEmailConfirmed = Core.stringToBool(userRow.dataset.emailConfirmed!);
+
+ return {
+ data: {
+ actionName: (isEmailConfirmed ? "un" : "") + "confirmEmail",
+ className: "wcf\\data\\user\\UserAction",
+ objectIDs: [userId],
+ },
+ };
+ },
+ } as AjaxCallbackObject,
+ undefined,
+ (data: DatabaseObjectActionResponse) => {
+ document.querySelectorAll(".jsUserRow").forEach((userRow: HTMLTableRowElement) => {
+ const userId = ~~userRow.dataset.objectId!;
+ if (data.objectIDs.includes(userId)) {
+ const confirmEmailButton = dropdownMenu.querySelector(".jsConfirmEmailToggle") as HTMLAnchorElement;
+
+ switch (data.actionName) {
+ case "confirmEmail":
+ userRow.dataset.emailConfirmed = "true";
+ confirmEmailButton.textContent = confirmEmailButton.dataset.unconfirmEmailMessage!;
+ break;
+
+ case "unconfirmEmail":
+ userRow.dataset.emailEonfirmed = "false";
+ confirmEmailButton.textContent = confirmEmailButton.dataset.confirmEmailMessage!;
+ break;
+
+ default:
+ throw new Error("Unreachable");
+ }
+ }
+ });
+
+ UiNotification.show();
+ },
+ );
+ });
+ }
+ }
+
+ /**
+ * Rebuilds the dropdown by adding wrapper links for legacy buttons,
+ * that will eventually receive the click event.
+ */
+ private rebuild(dropdownMenu: HTMLElement, legacyButtonContainer: HTMLElement): void {
+ dropdownMenu.querySelectorAll(".jsLegacyItem").forEach((element) => element.remove());
+
+ // inject buttons
+ const items: HTMLLIElement[] = [];
+ let deleteButton: HTMLAnchorElement | null = null;
+ Array.from(legacyButtonContainer.children).forEach((button: HTMLAnchorElement) => {
+ if (button.classList.contains("jsDeleteButton")) {
+ deleteButton = button;
+
+ return;
+ }
+
+ const item = document.createElement("li");
+ item.className = "jsLegacyItem";
+ item.innerHTML = '<a href="#"></a>';
+
+ const link = item.children[0] as HTMLAnchorElement;
+ link.textContent = button.dataset.tooltip || button.title;
+ link.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ // forward click onto original button
+ if (button.nodeName === "A") {
+ button.click();
+ } else {
+ Core.triggerEvent(button, "click");
+ }
+ });
+
+ items.push(item);
+ });
+
+ items.forEach((item) => {
+ dropdownMenu.insertAdjacentElement("afterbegin", item);
+ });
+
+ if (deleteButton !== null) {
+ const dispatchDeleteButton = dropdownMenu.querySelector(".jsDispatchDelete") as HTMLAnchorElement;
+ dispatchDeleteButton.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ deleteButton!.click();
+ });
+ }
+
+ // check if there are visible items before each divider
+ const listItems = Array.from(dropdownMenu.children) as HTMLElement[];
+ listItems.forEach((element) => DomUtil.show(element));
+
+ let hasItem = false;
+ listItems.forEach((item) => {
+ if (item.classList.contains("dropdownDivider")) {
+ if (!hasItem) {
+ DomUtil.hide(item);
+ }
+ } else {
+ hasItem = true;
+ }
+ });
+ }
+
+ private refreshUsers(data: RefreshUsersData): void {
+ document.querySelectorAll(".jsUserRow").forEach((userRow: HTMLTableRowElement) => {
+ const userId = ~~userRow.dataset.objectId!;
+ if (data.userIds.includes(userId)) {
+ const userStatusIcons = userRow.querySelector(".userStatusIcons") as HTMLElement;
+
+ const banned = Core.stringToBool(userRow.dataset.banned!);
+ let iconBanned = userRow.querySelector(".jsUserStatusBanned") as HTMLElement;
+ if (banned && iconBanned === null) {
+ iconBanned = document.createElement("span");
+ iconBanned.className = "icon icon16 fa-lock jsUserStatusBanned jsTooltip";
+ iconBanned.title = Language.get("wcf.user.status.banned");
+
+ userStatusIcons.appendChild(iconBanned);
+ } else if (!banned && iconBanned !== null) {
+ iconBanned.remove();
+ }
+
+ const isDisabled = !Core.stringToBool(userRow.dataset.enabled!);
+ let iconIsDisabled = userRow.querySelector(".jsUserStatusIsDisabled") as HTMLElement;
+ if (isDisabled && iconIsDisabled === null) {
+ iconIsDisabled = document.createElement("span");
+ iconIsDisabled.className = "icon icon16 fa-power-off jsUserStatusIsDisabled jsTooltip";
+ iconIsDisabled.title = Language.get("wcf.user.status.isDisabled");
+ userStatusIcons.appendChild(iconIsDisabled);
+ } else if (!isDisabled && iconIsDisabled !== null) {
+ iconIsDisabled.remove();
+ }
+ }
+ });
+ }
+}
+
+export = AcpUiUserEditor;
--- /dev/null
+/**
+ * Worker manager with support for custom callbacks and loop counts.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Acp/Ui/Worker
+ */
+
+import * as Ajax from "../../Ajax";
+import * as Core from "../../Core";
+import * as Language from "../../Language";
+import UiDialog from "../../Ui/Dialog";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../../Ajax/Data";
+import { DialogCallbackObject, DialogCallbackSetup } from "../../Ui/Dialog/Data";
+import AjaxRequest from "../../Ajax/Request";
+
+interface AjaxResponse {
+ loopCount: number;
+ parameters: ArbitraryObject;
+ proceedURL: string;
+ progress: number;
+ template?: string;
+}
+
+type CallbackAbort = () => void;
+type CallbackSuccess = (data: AjaxResponse) => void;
+
+interface WorkerOptions {
+ // dialog
+ dialogId: string;
+ dialogTitle: string;
+
+ // ajax
+ className: string;
+ loopCount: number;
+ parameters: ArbitraryObject;
+
+ // callbacks
+ callbackAbort: CallbackAbort | null;
+ callbackSuccess: CallbackSuccess | null;
+}
+
+class AcpUiWorker implements AjaxCallbackObject, DialogCallbackObject {
+ private aborted = false;
+ private readonly options: WorkerOptions;
+ private readonly request: AjaxRequest;
+
+ /**
+ * Creates a new worker instance.
+ */
+ constructor(options: Partial<WorkerOptions>) {
+ this.options = Core.extend(
+ {
+ // dialog
+ dialogId: "",
+ dialogTitle: "",
+
+ // ajax
+ className: "",
+ loopCount: -1,
+ parameters: {},
+
+ // callbacks
+ callbackAbort: null,
+ callbackSuccess: null,
+ },
+ options,
+ ) as WorkerOptions;
+ this.options.dialogId += "Worker";
+
+ // update title
+ if (UiDialog.getDialog(this.options.dialogId) !== undefined) {
+ UiDialog.setTitle(this.options.dialogId, this.options.dialogTitle);
+ }
+
+ this.request = Ajax.api(this);
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ if (this.aborted) {
+ return;
+ }
+
+ if (typeof data.template === "string") {
+ UiDialog.open(this, data.template);
+ }
+
+ const content = UiDialog.getDialog(this)!.content;
+
+ // update progress
+ const progress = content.querySelector("progress")!;
+ progress.value = data.progress;
+ progress.nextElementSibling!.textContent = `${data.progress}%`;
+
+ // worker is still busy
+ if (data.progress < 100) {
+ Ajax.api(this, {
+ loopCount: data.loopCount,
+ parameters: data.parameters,
+ });
+ } else {
+ const spinner = content.querySelector(".fa-spinner") as HTMLSpanElement;
+ spinner.classList.remove("fa-spinner");
+ spinner.classList.add("fa-check", "green");
+
+ const formSubmit = document.createElement("div");
+ formSubmit.className = "formSubmit";
+ formSubmit.innerHTML = '<button class="buttonPrimary">' + Language.get("wcf.global.button.next") + "</button>";
+
+ content.appendChild(formSubmit);
+ UiDialog.rebuild(this);
+
+ const button = formSubmit.children[0] as HTMLButtonElement;
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ if (typeof this.options.callbackSuccess === "function") {
+ this.options.callbackSuccess(data);
+
+ UiDialog.close(this);
+ } else {
+ window.location.href = data.proceedURL;
+ }
+ });
+ button.focus();
+ }
+ }
+
+ _ajaxFailure(): boolean {
+ const dialog = UiDialog.getDialog(this);
+ if (dialog !== undefined) {
+ const spinner = dialog.content.querySelector(".fa-spinner") as HTMLSpanElement;
+ spinner.classList.remove("fa-spinner");
+ spinner.classList.add("fa-times", "red");
+ }
+
+ return true;
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ className: this.options.className,
+ loopCount: this.options.loopCount,
+ parameters: this.options.parameters,
+ },
+ silent: true,
+ url: "index.php?worker-proxy/&t=" + window.SECURITY_TOKEN,
+ };
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: this.options.dialogId,
+ options: {
+ backdropCloseOnClick: false,
+ onClose: () => {
+ this.aborted = true;
+ this.request.abortPrevious();
+
+ if (typeof this.options.callbackAbort === "function") {
+ this.options.callbackAbort();
+ } else {
+ window.location.reload();
+ }
+ },
+ title: this.options.dialogTitle,
+ },
+ source: null,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(AcpUiWorker);
+
+export = AcpUiWorker;
--- /dev/null
+/**
+ * Handles AJAX requests.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Ajax (alias)
+ * @module WoltLabSuite/Core/Ajax
+ */
+
+import AjaxRequest from "./Ajax/Request";
+import { AjaxCallbackObject, CallbackSuccess, CallbackFailure, RequestData, RequestOptions } from "./Ajax/Data";
+
+const _cache = new WeakMap();
+
+/**
+ * Shorthand function to perform a request against the WCF-API with overrides
+ * for success and failure callbacks.
+ */
+export function api(
+ callbackObject: AjaxCallbackObject,
+ data?: RequestData,
+ success?: CallbackSuccess,
+ failure?: CallbackFailure,
+): AjaxRequest {
+ if (typeof data !== "object") data = {};
+
+ let request = _cache.get(callbackObject);
+ if (request === undefined) {
+ if (typeof callbackObject._ajaxSetup !== "function") {
+ throw new TypeError("Callback object must implement at least _ajaxSetup().");
+ }
+
+ const options = callbackObject._ajaxSetup();
+
+ options.pinData = true;
+ options.callbackObject = callbackObject;
+
+ if (!options.url) {
+ options.url = "index.php?ajax-proxy/&t=" + window.SECURITY_TOKEN;
+ options.withCredentials = true;
+ }
+
+ request = new AjaxRequest(options);
+
+ _cache.set(callbackObject, request);
+ }
+
+ let oldSuccess = null;
+ let oldFailure = null;
+
+ if (typeof success === "function") {
+ oldSuccess = request.getOption("success");
+ request.setOption("success", success);
+ }
+ if (typeof failure === "function") {
+ oldFailure = request.getOption("failure");
+ request.setOption("failure", failure);
+ }
+
+ request.setData(data);
+ request.sendRequest();
+
+ // restore callbacks
+ if (oldSuccess !== null) request.setOption("success", oldSuccess);
+ if (oldFailure !== null) request.setOption("failure", oldFailure);
+
+ return request;
+}
+
+/**
+ * Shorthand function to perform a single request against the WCF-API.
+ *
+ * Please use `Ajax.api` if you're about to repeatedly send requests because this
+ * method will spawn an new and rather expensive `AjaxRequest` with each call.
+ */
+export function apiOnce(options: RequestOptions): void {
+ options.pinData = false;
+ options.callbackObject = null;
+ if (!options.url) {
+ options.url = "index.php?ajax-proxy/&t=" + window.SECURITY_TOKEN;
+ options.withCredentials = true;
+ }
+
+ const request = new AjaxRequest(options);
+ request.sendRequest(false);
+}
+
+/**
+ * Returns the request object used for an earlier call to `api()`.
+ */
+export function getRequestObject(callbackObject: AjaxCallbackObject): AjaxRequest {
+ if (!_cache.has(callbackObject)) {
+ throw new Error("Expected a previously used callback object, provided object is unknown.");
+ }
+
+ return _cache.get(callbackObject);
+}
--- /dev/null
+export interface RequestPayload {
+ [key: string]: any;
+}
+
+export interface DatabaseObjectActionPayload extends RequestPayload {
+ actionName: string;
+ className: string;
+ interfaceName?: string;
+ objectIDs?: number[];
+ parameters?: {
+ [key: string]: any;
+ };
+}
+
+export type RequestData = FormData | RequestPayload | DatabaseObjectActionPayload;
+
+export interface ResponseData {
+ [key: string]: any;
+}
+
+export interface DatabaseObjectActionResponse extends ResponseData {
+ actionName: string;
+ objectIDs: number[];
+ returnValues:
+ | {
+ [key: string]: any;
+ }
+ | any[];
+}
+
+/** Return `false` to suppress the error message. */
+export type CallbackFailure = (
+ data: ResponseData,
+ responseText: string,
+ xhr: XMLHttpRequest,
+ requestData: RequestData,
+) => boolean;
+export type CallbackFinalize = (xhr: XMLHttpRequest) => void;
+export type CallbackProgress = (event: ProgressEvent) => void;
+export type CallbackSuccess = (
+ data: ResponseData | DatabaseObjectActionResponse,
+ responseText: string,
+ xhr: XMLHttpRequest,
+ requestData: RequestData,
+) => void;
+export type CallbackUploadProgress = (event: ProgressEvent) => void;
+export type AjaxCallbackSetup = () => RequestOptions;
+
+export interface AjaxCallbackObject {
+ _ajaxFailure?: CallbackFailure;
+ _ajaxFinalize?: CallbackFinalize;
+ _ajaxProgress?: CallbackProgress;
+ _ajaxSuccess: CallbackSuccess;
+ _ajaxUploadProgress?: CallbackUploadProgress;
+ _ajaxSetup: AjaxCallbackSetup;
+}
+
+export interface RequestOptions {
+ // request data
+ data?: RequestData;
+ contentType?: string | false;
+ responseType?: string;
+ type?: string;
+ url?: string;
+ withCredentials?: boolean;
+
+ // behavior
+ autoAbort?: boolean;
+ ignoreError?: boolean;
+ pinData?: boolean;
+ silent?: boolean;
+ includeRequestedWith?: boolean;
+
+ // callbacks
+ failure?: CallbackFailure;
+ finalize?: CallbackFinalize;
+ success?: CallbackSuccess;
+ progress?: CallbackProgress;
+ uploadProgress?: CallbackUploadProgress;
+
+ callbackObject?: AjaxCallbackObject | null;
+}
+
+interface PreviousException {
+ message: string;
+ stacktrace: string;
+}
+
+export interface AjaxResponseException extends ResponseData {
+ exceptionID?: string;
+ previous: PreviousException[];
+ file?: string;
+ line?: number;
+ message: string;
+ returnValues?: {
+ description?: string;
+ };
+ stacktrace?: string;
+}
--- /dev/null
+/**
+ * Provides a utility class to issue JSONP requests.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module AjaxJsonp (alias)
+ * @module WoltLabSuite/Core/Ajax/Jsonp
+ */
+
+import * as Core from "../Core";
+
+/**
+ * Dispatch a JSONP request, the `url` must not contain a callback parameter.
+ */
+export function send(
+ url: string,
+ success: (...args: unknown[]) => void,
+ failure: () => void,
+ options?: JsonpOptions,
+): void {
+ url = typeof (url as any) === "string" ? url.trim() : "";
+ if (url.length === 0) {
+ throw new Error("Expected a non-empty string for parameter 'url'.");
+ }
+
+ if (typeof success !== "function") {
+ throw new TypeError("Expected a valid callback function for parameter 'success'.");
+ }
+
+ options = Core.extend(
+ {
+ parameterName: "callback",
+ timeout: 10,
+ },
+ options || {},
+ ) as JsonpOptions;
+
+ const callbackName = "wcf_jsonp_" + Core.getUuid().replace(/-/g, "").substr(0, 8);
+ const script = document.createElement("script");
+
+ const timeout = window.setTimeout(() => {
+ if (typeof failure === "function") {
+ failure();
+ }
+
+ window[callbackName] = undefined;
+ script.remove();
+ }, (~~options.timeout || 10) * 1_000);
+
+ window[callbackName] = (...args: any[]) => {
+ window.clearTimeout(timeout);
+
+ success(...args);
+
+ window[callbackName] = undefined;
+ script.remove();
+ };
+
+ url += url.indexOf("?") === -1 ? "?" : "&";
+ url += options.parameterName + "=" + callbackName;
+
+ script.async = true;
+ script.src = url;
+
+ document.head.appendChild(script);
+}
+
+interface JsonpOptions {
+ parameterName: string;
+ timeout: number;
+}
--- /dev/null
+/**
+ * Versatile AJAX request handling.
+ *
+ * In case you want to issue JSONP requests, please use `AjaxJsonp` instead.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module AjaxRequest (alias)
+ * @module WoltLabSuite/Core/Ajax/Request
+ */
+
+import * as AjaxStatus from "./Status";
+import { ResponseData, RequestOptions, RequestData, AjaxResponseException } from "./Data";
+import * as Core from "../Core";
+import DomChangeListener from "../Dom/Change/Listener";
+import DomUtil from "../Dom/Util";
+import * as Language from "../Language";
+
+let _didInit = false;
+let _ignoreAllErrors = false;
+
+/**
+ * @constructor
+ */
+class AjaxRequest {
+ private readonly _options: RequestOptions;
+ private readonly _data: RequestData;
+ private _previousXhr?: XMLHttpRequest;
+ private _xhr?: XMLHttpRequest;
+
+ constructor(options: RequestOptions) {
+ this._options = Core.extend(
+ {
+ data: {},
+ contentType: "application/x-www-form-urlencoded; charset=UTF-8",
+ responseType: "application/json",
+ type: "POST",
+ url: "",
+ withCredentials: false,
+
+ // behavior
+ autoAbort: false,
+ ignoreError: false,
+ pinData: false,
+ silent: false,
+ includeRequestedWith: true,
+
+ // callbacks
+ failure: null,
+ finalize: null,
+ success: null,
+ progress: null,
+ uploadProgress: null,
+
+ callbackObject: null,
+ },
+ options,
+ );
+
+ if (typeof options.callbackObject === "object") {
+ this._options.callbackObject = options.callbackObject;
+ }
+
+ this._options.url = Core.convertLegacyUrl(this._options.url!);
+ if (this._options.url.indexOf("index.php") === 0) {
+ this._options.url = window.WSC_API_URL + this._options.url;
+ }
+
+ if (this._options.url.indexOf(window.WSC_API_URL) === 0) {
+ this._options.includeRequestedWith = true;
+ // always include credentials when querying the very own server
+ this._options.withCredentials = true;
+ }
+
+ if (this._options.pinData) {
+ this._data = this._options.data!;
+ }
+
+ if (this._options.callbackObject) {
+ if (typeof this._options.callbackObject._ajaxFailure === "function") {
+ this._options.failure = this._options.callbackObject._ajaxFailure.bind(this._options.callbackObject);
+ }
+ if (typeof this._options.callbackObject._ajaxFinalize === "function") {
+ this._options.finalize = this._options.callbackObject._ajaxFinalize.bind(this._options.callbackObject);
+ }
+ if (typeof this._options.callbackObject._ajaxSuccess === "function") {
+ this._options.success = this._options.callbackObject._ajaxSuccess.bind(this._options.callbackObject);
+ }
+ if (typeof this._options.callbackObject._ajaxProgress === "function") {
+ this._options.progress = this._options.callbackObject._ajaxProgress.bind(this._options.callbackObject);
+ }
+ if (typeof this._options.callbackObject._ajaxUploadProgress === "function") {
+ this._options.uploadProgress = this._options.callbackObject._ajaxUploadProgress.bind(
+ this._options.callbackObject,
+ );
+ }
+ }
+
+ if (!_didInit) {
+ _didInit = true;
+
+ window.addEventListener("beforeunload", () => (_ignoreAllErrors = true));
+ }
+ }
+
+ /**
+ * Dispatches a request, optionally aborting a currently active request.
+ */
+ sendRequest(abortPrevious?: boolean): void {
+ if (abortPrevious || this._options.autoAbort) {
+ this.abortPrevious();
+ }
+
+ if (!this._options.silent) {
+ AjaxStatus.show();
+ }
+
+ if (this._xhr instanceof XMLHttpRequest) {
+ this._previousXhr = this._xhr;
+ }
+
+ this._xhr = new XMLHttpRequest();
+ this._xhr.open(this._options.type!, this._options.url!, true);
+ if (this._options.contentType) {
+ this._xhr.setRequestHeader("Content-Type", this._options.contentType);
+ }
+ if (this._options.withCredentials || this._options.includeRequestedWith) {
+ this._xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
+ }
+ if (this._options.withCredentials) {
+ this._xhr.withCredentials = true;
+ }
+
+ const options = Core.clone(this._options) as RequestOptions;
+
+ // Use a local variable in all callbacks, because `this._xhr` can be overwritten by
+ // subsequent requests while a request is still in-flight.
+ const xhr = this._xhr;
+ xhr.onload = () => {
+ if (xhr.readyState === XMLHttpRequest.DONE) {
+ if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
+ if (options.responseType && xhr.getResponseHeader("Content-Type")!.indexOf(options.responseType) !== 0) {
+ // request succeeded but invalid response type
+ this._failure(xhr, options);
+ } else {
+ this._success(xhr, options);
+ }
+ } else {
+ this._failure(xhr, options);
+ }
+ }
+ };
+ xhr.onerror = () => {
+ this._failure(xhr, options);
+ };
+
+ if (this._options.progress) {
+ xhr.onprogress = this._options.progress;
+ }
+ if (this._options.uploadProgress) {
+ xhr.upload.onprogress = this._options.uploadProgress;
+ }
+
+ if (this._options.type === "POST") {
+ let data: string | RequestData = this._options.data!;
+ if (typeof data === "object" && Core.getType(data) !== "FormData") {
+ data = Core.serialize(data);
+ }
+
+ xhr.send(data as any);
+ } else {
+ xhr.send();
+ }
+ }
+
+ /**
+ * Aborts a previous request.
+ */
+ abortPrevious(): void {
+ if (!this._previousXhr) {
+ return;
+ }
+
+ this._previousXhr.abort();
+ this._previousXhr = undefined;
+
+ if (!this._options.silent) {
+ AjaxStatus.hide();
+ }
+ }
+
+ /**
+ * Sets a specific option.
+ */
+ setOption(key: string, value: unknown): void {
+ this._options[key] = value;
+ }
+
+ /**
+ * Returns an option by key or undefined.
+ */
+ getOption(key: string): unknown | null {
+ if (Object.prototype.hasOwnProperty.call(this._options, key)) {
+ return this._options[key];
+ }
+
+ return null;
+ }
+
+ /**
+ * Sets request data while honoring pinned data from setup callback.
+ */
+ setData(data: RequestData): void {
+ if (this._data !== null && Core.getType(data) !== "FormData") {
+ data = Core.extend(this._data, data);
+ }
+
+ this._options.data = data;
+ }
+
+ /**
+ * Handles a successful request.
+ */
+ _success(xhr: XMLHttpRequest, options: RequestOptions): void {
+ if (!options.silent) {
+ AjaxStatus.hide();
+ }
+
+ if (typeof options.success === "function") {
+ let data: ResponseData | null = null;
+ if (xhr.getResponseHeader("Content-Type")!.split(";", 1)[0].trim() === "application/json") {
+ try {
+ data = JSON.parse(xhr.responseText) as ResponseData;
+ } catch (e) {
+ // invalid JSON
+ this._failure(xhr, options);
+
+ return;
+ }
+
+ // trim HTML before processing, see http://jquery.com/upgrade-guide/1.9/#jquery-htmlstring-versus-jquery-selectorstring
+ if (data && data.returnValues && data.returnValues.template !== undefined) {
+ data.returnValues.template = data.returnValues.template.trim();
+ }
+
+ // force-invoke the background queue
+ if (data && data.forceBackgroundQueuePerform) {
+ void import("../BackgroundQueue").then((backgroundQueue) => backgroundQueue.invoke());
+ }
+ }
+
+ options.success(data!, xhr.responseText, xhr, options.data!);
+ }
+
+ this._finalize(options);
+ }
+
+ /**
+ * Handles failed requests, this can be both a successful request with
+ * a non-success status code or an entirely failed request.
+ */
+ _failure(xhr: XMLHttpRequest, options: RequestOptions): void {
+ if (_ignoreAllErrors) {
+ return;
+ }
+
+ if (!options.silent) {
+ AjaxStatus.hide();
+ }
+
+ let data: ResponseData | null = null;
+ try {
+ data = JSON.parse(xhr.responseText);
+ } catch (e) {
+ // Ignore JSON parsing failure.
+ }
+
+ let showError = true;
+ if (typeof options.failure === "function") {
+ showError = options.failure(data || {}, xhr.responseText || "", xhr, options.data!);
+ }
+
+ if (options.ignoreError !== true && showError) {
+ const html = this.getErrorHtml(data as AjaxResponseException, xhr);
+
+ if (html) {
+ void import("../Ui/Dialog").then((UiDialog) => {
+ UiDialog.openStatic(DomUtil.getUniqueId(), html, {
+ title: Language.get("wcf.global.error.title"),
+ });
+ });
+ }
+ }
+
+ this._finalize(options);
+ }
+
+ /**
+ * Returns the inner HTML for an error/exception display.
+ */
+ getErrorHtml(data: AjaxResponseException | null, xhr: XMLHttpRequest): string | null {
+ let details = "";
+ let message: string;
+
+ if (data !== null) {
+ if (data.returnValues && data.returnValues.description) {
+ details += `<br><p>Description:</p><p>${data.returnValues.description}</p>`;
+ }
+
+ if (data.file && data.line) {
+ details += `<br><p>File:</p><p>${data.file} in line ${data.line}</p>`;
+ }
+
+ if (data.stacktrace) {
+ details += `<br><p>Stacktrace:</p><p>${data.stacktrace}</p>`;
+ } else if (data.exceptionID) {
+ details += `<br><p>Exception ID: <code>${data.exceptionID}</code></p>`;
+ }
+
+ message = data.message;
+
+ data.previous.forEach((previous) => {
+ details += `<hr><p>${previous.message}</p>`;
+ details += `<br><p>Stacktrace</p><p>${previous.stacktrace}</p>`;
+ });
+ } else {
+ message = xhr.responseText;
+ }
+
+ if (!message || message === "undefined") {
+ if (!window.ENABLE_DEBUG_MODE) {
+ return null;
+ }
+
+ message = "XMLHttpRequest failed without a responseText. Check your browser console.";
+ }
+
+ return `<div class="ajaxDebugMessage"><p>${message}</p>${details}</div>`;
+ }
+
+ /**
+ * Finalizes a request.
+ *
+ * @param {Object} options request options
+ */
+ _finalize(options: RequestOptions): void {
+ if (typeof options.finalize === "function") {
+ options.finalize(this._xhr!);
+ }
+
+ this._previousXhr = undefined;
+
+ DomChangeListener.trigger();
+
+ // fix anchor tags generated through WCF::getAnchor()
+ document.querySelectorAll('a[href*="#"]').forEach((link: HTMLAnchorElement) => {
+ let href = link.href;
+ if (href.indexOf("AJAXProxy") !== -1 || href.indexOf("ajax-proxy") !== -1) {
+ href = href.substr(href.indexOf("#"));
+ link.href = document.location.toString().replace(/#.*/, "") + href;
+ }
+ });
+ }
+}
+
+Core.enableLegacyInheritance(AjaxRequest);
+
+export = AjaxRequest;
--- /dev/null
+/**
+ * Provides the AJAX status overlay.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ajax/Status
+ */
+
+import * as Language from "../Language";
+
+class AjaxStatus {
+ private _activeRequests = 0;
+ private readonly _overlay: Element;
+ private _timer: number | null = null;
+
+ constructor() {
+ this._overlay = document.createElement("div");
+ this._overlay.classList.add("spinner");
+ this._overlay.setAttribute("role", "status");
+
+ const icon = document.createElement("span");
+ icon.className = "icon icon48 fa-spinner";
+ this._overlay.appendChild(icon);
+
+ const title = document.createElement("span");
+ title.textContent = Language.get("wcf.global.loading");
+ this._overlay.appendChild(title);
+
+ document.body.appendChild(this._overlay);
+ }
+
+ show(): void {
+ this._activeRequests++;
+
+ if (this._timer === null) {
+ this._timer = window.setTimeout(() => {
+ if (this._activeRequests) {
+ this._overlay.classList.add("active");
+ }
+
+ this._timer = null;
+ }, 250);
+ }
+ }
+
+ hide(): void {
+ if (--this._activeRequests === 0) {
+ if (this._timer !== null) {
+ window.clearTimeout(this._timer);
+ }
+
+ this._overlay.classList.remove("active");
+ }
+ }
+}
+
+let status: AjaxStatus;
+function getStatus(): AjaxStatus {
+ if (status === undefined) {
+ status = new AjaxStatus();
+ }
+
+ return status;
+}
+
+/**
+ * Shows the loading overlay.
+ */
+export function show(): void {
+ getStatus().show();
+}
+
+/**
+ * Hides the loading overlay.
+ */
+export function hide(): void {
+ getStatus().hide();
+}
--- /dev/null
+/**
+ * Manages the invocation of the background queue.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/BackgroundQueue
+ */
+
+import * as Ajax from "./Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "./Ajax/Data";
+
+class BackgroundQueue implements AjaxCallbackObject {
+ private _invocations = 0;
+ private _isBusy = false;
+ private readonly _url: string;
+
+ constructor(url: string) {
+ this._url = url;
+ }
+
+ invoke(): void {
+ if (this._isBusy) return;
+
+ this._isBusy = true;
+
+ Ajax.api(this);
+ }
+
+ _ajaxSuccess(data: ResponseData): void {
+ this._invocations++;
+
+ // invoke the queue up to 5 times in a row
+ if (((data as unknown) as number) > 0 && this._invocations < 5) {
+ window.setTimeout(() => {
+ this._isBusy = false;
+ this.invoke();
+ }, 1000);
+ } else {
+ this._isBusy = false;
+ this._invocations = 0;
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ url: this._url,
+ ignoreError: true,
+ silent: true,
+ };
+ }
+}
+
+let queue: BackgroundQueue;
+
+/**
+ * Sets the url of the background queue perform action.
+ */
+export function setUrl(url: string): void {
+ if (!queue) {
+ queue = new BackgroundQueue(url);
+ }
+}
+
+/**
+ * Invokes the background queue.
+ */
+export function invoke(): void {
+ if (!queue) {
+ console.error("The background queue has not been initialized yet.");
+ return;
+ }
+
+ queue.invoke();
+}
--- /dev/null
+/**
+ * Highlights code in the Code bbcode.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Bbcode/Code
+ */
+
+import * as Language from "../Language";
+import * as Clipboard from "../Clipboard";
+import * as UiNotification from "../Ui/Notification";
+import Prism from "../Prism";
+import * as PrismHelper from "../Prism/Helper";
+import PrismMeta from "../prism-meta";
+
+async function waitForIdle(): Promise<void> {
+ return new Promise((resolve, _reject) => {
+ if ((window as any).requestIdleCallback) {
+ (window as any).requestIdleCallback(resolve, { timeout: 5000 });
+ } else {
+ setTimeout(resolve, 0);
+ }
+ });
+}
+
+class Code {
+ private static readonly chunkSize = 50;
+
+ private readonly container: HTMLElement;
+ private codeContainer: HTMLElement;
+ private language: string | undefined;
+
+ constructor(container: HTMLElement) {
+ this.container = container;
+ this.codeContainer = this.container.querySelector(".codeBoxCode > code") as HTMLElement;
+
+ this.language = Array.from(this.codeContainer.classList)
+ .find((klass) => /^language-([a-z0-9_-]+)$/.test(klass))
+ ?.replace(/^language-/, "");
+ }
+
+ public static processAll(): void {
+ document.querySelectorAll(".codeBox:not([data-processed])").forEach((codeBox: HTMLElement) => {
+ codeBox.dataset.processed = "1";
+
+ const handle = new Code(codeBox);
+
+ if (handle.language) {
+ void handle.highlight();
+ }
+
+ handle.createCopyButton();
+ });
+ }
+
+ public createCopyButton(): void {
+ const header = this.container.querySelector(".codeBoxHeader");
+
+ if (!header) {
+ return;
+ }
+
+ const button = document.createElement("span");
+ button.className = "icon icon24 fa-files-o pointer jsTooltip";
+ button.setAttribute("title", Language.get("wcf.message.bbcode.code.copy"));
+ button.addEventListener("click", async () => {
+ await Clipboard.copyElementTextToClipboard(this.codeContainer);
+
+ UiNotification.show(Language.get("wcf.message.bbcode.code.copy.success"));
+ });
+
+ header.appendChild(button);
+ }
+
+ public async highlight(): Promise<void> {
+ if (!this.language) {
+ throw new Error("No language detected");
+ }
+ if (!PrismMeta[this.language]) {
+ throw new Error(`Unknown language '${this.language}'`);
+ }
+
+ this.container.classList.add("highlighting");
+
+ // Step 1) Load the requested grammar.
+ await import("prism/components/prism-" + PrismMeta[this.language].file);
+
+ // Step 2) Perform the highlighting into a temporary element.
+ await waitForIdle();
+
+ const grammar = Prism.languages[this.language];
+ if (!grammar) {
+ throw new Error(`Invalid language '${this.language}' given.`);
+ }
+
+ const container = document.createElement("div");
+ container.innerHTML = Prism.highlight(this.codeContainer.textContent!, grammar, this.language);
+
+ // Step 3) Insert the highlighted lines into the page.
+ // This is performed in small chunks to prevent the UI thread from being blocked for complex
+ // highlight results.
+ await waitForIdle();
+
+ const originalLines = this.codeContainer.querySelectorAll(".codeBoxLine > span");
+ const highlightedLines = PrismHelper.splitIntoLines(container);
+
+ for (let chunkStart = 0, max = originalLines.length; chunkStart < max; chunkStart += Code.chunkSize) {
+ await waitForIdle();
+
+ const chunkEnd = Math.min(chunkStart + Code.chunkSize, max);
+
+ for (let offset = chunkStart; offset < chunkEnd; offset++) {
+ const toReplace = originalLines[offset]!;
+ const replacement = highlightedLines.next().value as Element;
+ toReplace.parentNode!.replaceChild(replacement, toReplace);
+ }
+ }
+
+ this.container.classList.remove("highlighting");
+ this.container.classList.add("highlighted");
+ }
+}
+
+export = Code;
--- /dev/null
+/**
+ * Generic handler for collapsible bbcode boxes.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Bbcode/Collapsible
+ */
+
+function initContainer(container: HTMLElement, toggleButtons: HTMLElement[], overflowContainer: HTMLElement): void {
+ toggleButtons.forEach((toggleButton) => {
+ toggleButton.classList.add("jsToggleButtonEnabled");
+ toggleButton.addEventListener("click", (ev) => toggleContainer(container, toggleButtons, ev));
+ });
+
+ // expand boxes that are initially scrolled
+ if (overflowContainer.scrollTop !== 0) {
+ overflowContainer.scrollTop = 0;
+ toggleContainer(container, toggleButtons);
+ }
+ overflowContainer.addEventListener("scroll", () => {
+ overflowContainer.scrollTop = 0;
+ if (container.classList.contains("collapsed")) {
+ toggleContainer(container, toggleButtons);
+ }
+ });
+}
+
+function toggleContainer(container: HTMLElement, toggleButtons: HTMLElement[], event?: Event): void {
+ if (container.classList.toggle("collapsed")) {
+ toggleButtons.forEach((toggleButton) => {
+ const title = toggleButton.dataset.titleExpand!;
+ if (toggleButton.classList.contains("icon")) {
+ toggleButton.classList.remove("fa-compress");
+ toggleButton.classList.add("fa-expand");
+ toggleButton.title = title;
+ } else {
+ toggleButton.textContent = title;
+ }
+ });
+
+ if (event instanceof Event) {
+ // negative top value means the upper boundary is not within the viewport
+ const top = container.getBoundingClientRect().top;
+ if (top < 0) {
+ let y = window.pageYOffset + (top - 100);
+ if (y < 0) {
+ y = 0;
+ }
+
+ window.scrollTo(window.pageXOffset, y);
+ }
+ }
+ } else {
+ toggleButtons.forEach((toggleButton) => {
+ const title = toggleButton.dataset.titleCollapse!;
+ if (toggleButton.classList.contains("icon")) {
+ toggleButton.classList.add("fa-compress");
+ toggleButton.classList.remove("fa-expand");
+ toggleButton.title = title;
+ } else {
+ toggleButton.textContent = title;
+ }
+ });
+ }
+}
+
+export function observe(): void {
+ document.querySelectorAll(".jsCollapsibleBbcode").forEach((container: HTMLElement) => {
+ // find the matching toggle button
+ const toggleButtons = Array.from<HTMLElement>(
+ container.querySelectorAll(".toggleButton:not(.jsToggleButtonEnabled)"),
+ ).filter((button) => {
+ return button.closest(".jsCollapsibleBbcode") === container;
+ });
+
+ const overflowContainer = (container.querySelector(".collapsibleBbcodeOverflow") as HTMLElement) || container;
+
+ if (toggleButtons.length > 0) {
+ initContainer(container, toggleButtons, overflowContainer);
+ }
+
+ container.classList.remove("jsCollapsibleBbcode");
+ });
+}
--- /dev/null
+/**
+ * Generic handler for spoiler boxes.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Bbcode/Spoiler
+ */
+
+import * as Core from "../Core";
+import * as Language from "../Language";
+import DomUtil from "../Dom/Util";
+
+function onClick(event: Event, content: HTMLElement, toggleButton: HTMLAnchorElement): void {
+ event.preventDefault();
+
+ toggleButton.classList.toggle("active");
+
+ const isActive = toggleButton.classList.contains("active");
+ if (isActive) {
+ DomUtil.show(content);
+ } else {
+ DomUtil.hide(content);
+ }
+
+ toggleButton.setAttribute("aria-expanded", isActive ? "true" : "false");
+ content.setAttribute("aria-hidden", isActive ? "false" : "true");
+
+ if (!Core.stringToBool(toggleButton.dataset.hasCustomLabel || "")) {
+ toggleButton.textContent = Language.get(
+ toggleButton.classList.contains("active") ? "wcf.bbcode.spoiler.hide" : "wcf.bbcode.spoiler.show",
+ );
+ }
+}
+
+export function observe(): void {
+ const className = "jsSpoilerBox";
+ document.querySelectorAll(`.${className}`).forEach((container: HTMLElement) => {
+ container.classList.remove(className);
+
+ const toggleButton = container.querySelector(".jsSpoilerToggle") as HTMLAnchorElement;
+ const content = container.querySelector(".spoilerBoxContent") as HTMLElement;
+
+ toggleButton.addEventListener("click", (ev) => onClick(ev, content, toggleButton));
+ });
+}
--- /dev/null
+/**
+ * Bootstraps WCF's JavaScript.
+ * It defines globals needed for backwards compatibility
+ * and runs modules that are needed on page load.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Bootstrap
+ */
+
+import * as Core from "./Core";
+import DatePicker from "./Date/Picker";
+import * as DateTimeRelative from "./Date/Time/Relative";
+import Devtools from "./Devtools";
+import DomChangeListener from "./Dom/Change/Listener";
+import * as Environment from "./Environment";
+import * as EventHandler from "./Event/Handler";
+import * as Language from "./Language";
+import * as StringUtil from "./StringUtil";
+import UiDialog from "./Ui/Dialog";
+import UiDropdownSimple from "./Ui/Dropdown/Simple";
+import * as UiMobile from "./Ui/Mobile";
+import * as UiPageAction from "./Ui/Page/Action";
+import * as UiTabMenu from "./Ui/TabMenu";
+import * as UiTooltip from "./Ui/Tooltip";
+
+// perfectScrollbar does not need to be bound anywhere, it just has to be loaded for WCF.js
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import perfectScrollbar from "perfect-scrollbar";
+
+// non strict equals by intent
+if (window.WCF == null) {
+ window.WCF = {};
+}
+if (window.WCF.Language == null) {
+ window.WCF.Language = {};
+}
+window.WCF.Language.get = Language.get;
+window.WCF.Language.add = Language.add;
+window.WCF.Language.addObject = Language.addObject;
+// WCF.System.Event compatibility
+window.__wcf_bc_eventHandler = EventHandler;
+
+export interface BoostrapOptions {
+ enableMobileMenu: boolean;
+}
+
+function initA11y() {
+ document
+ .querySelectorAll("nav:not([aria-label]):not([aria-labelledby]):not([role])")
+ .forEach((element: HTMLElement) => {
+ element.setAttribute("role", "presentation");
+ });
+
+ document
+ .querySelectorAll("article:not([aria-label]):not([aria-labelledby]):not([role])")
+ .forEach((element: HTMLElement) => {
+ element.setAttribute("role", "presentation");
+ });
+}
+
+/**
+ * Initializes the core UI modifications and unblocks jQuery's ready event.
+ */
+export function setup(options: BoostrapOptions): void {
+ options = Core.extend(
+ {
+ enableMobileMenu: true,
+ },
+ options,
+ ) as BoostrapOptions;
+
+ StringUtil.setupI18n({
+ decimalPoint: Language.get("wcf.global.decimalPoint"),
+ thousandsSeparator: Language.get("wcf.global.thousandsSeparator"),
+ });
+
+ if (window.ENABLE_DEVELOPER_TOOLS) {
+ Devtools._internal_.enable();
+ }
+
+ Environment.setup();
+ DateTimeRelative.setup();
+ DatePicker.init();
+ UiDropdownSimple.setup();
+ UiMobile.setup(options.enableMobileMenu);
+ UiTabMenu.setup();
+ UiDialog.setup();
+ UiTooltip.setup();
+
+ // Convert forms with `method="get"` into `method="post"`
+ document.querySelectorAll("form[method=get]").forEach((form: HTMLFormElement) => {
+ form.method = "post";
+ });
+
+ if (Environment.browser() === "microsoft") {
+ window.onbeforeunload = () => {
+ /* Prevent "Back navigation caching" (http://msdn.microsoft.com/en-us/library/ie/dn265017%28v=vs.85%29.aspx) */
+ };
+ }
+
+ let interval = 0;
+ interval = window.setInterval(() => {
+ if (typeof window.jQuery === "function") {
+ window.clearInterval(interval);
+
+ // The 'jump to top' button triggers a style recalculation/"layout".
+ // Placing it at the end of the jQuery queue avoids trashing the
+ // layout too early and thus delaying the page initialization.
+ window.jQuery(() => {
+ UiPageAction.setup();
+ });
+
+ // jQuery.browser.mobile is a deprecated legacy property that was used
+ // to determine the class of devices being used.
+ const jq = window.jQuery as any;
+ jq.browser = jq.browser || {};
+ jq.browser.mobile = Environment.platform() !== "desktop";
+
+ window.jQuery.holdReady(false);
+ }
+ }, 20);
+
+ initA11y();
+
+ DomChangeListener.add("WoltLabSuite/Core/Bootstrap", () => initA11y);
+}
--- /dev/null
+/**
+ * Bootstraps WCF's JavaScript with additions for the frontend usage.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/BootstrapFrontend
+ */
+
+import * as BackgroundQueue from "./BackgroundQueue";
+import * as Bootstrap from "./Bootstrap";
+import * as ControllerStyleChanger from "./Controller/Style/Changer";
+import * as ControllerPopover from "./Controller/Popover";
+import * as UiUserIgnore from "./Ui/User/Ignore";
+import * as UiPageHeaderMenu from "./Ui/Page/Header/Menu";
+import * as UiMessageUserConsent from "./Ui/Message/UserConsent";
+
+interface BoostrapOptions {
+ backgroundQueue: {
+ url: string;
+ force: boolean;
+ };
+ enableUserPopover: boolean;
+ styleChanger: boolean;
+}
+
+/**
+ * Initializes user profile popover.
+ */
+function _initUserPopover(): void {
+ ControllerPopover.init({
+ className: "userLink",
+ dboAction: "wcf\\data\\user\\UserProfileAction",
+ identifier: "com.woltlab.wcf.user",
+ });
+
+ // @deprecated since 5.3
+ ControllerPopover.init({
+ attributeName: "data-user-id",
+ className: "userLink",
+ dboAction: "wcf\\data\\user\\UserProfileAction",
+ identifier: "com.woltlab.wcf.user.deprecated",
+ });
+}
+
+/**
+ * Bootstraps general modules and frontend exclusive ones.
+ */
+export function setup(options: BoostrapOptions): void {
+ // Modify the URL of the background queue URL to always target the current domain to avoid CORS.
+ options.backgroundQueue.url = window.WSC_API_URL + options.backgroundQueue.url.substr(window.WCF_PATH.length);
+
+ Bootstrap.setup({ enableMobileMenu: true });
+ UiPageHeaderMenu.init();
+
+ if (options.styleChanger) {
+ ControllerStyleChanger.setup();
+ }
+
+ if (options.enableUserPopover) {
+ _initUserPopover();
+ }
+
+ BackgroundQueue.setUrl(options.backgroundQueue.url);
+ if (Math.random() < 0.1 || options.backgroundQueue.force) {
+ // invoke the queue roughly every 10th request or on demand
+ BackgroundQueue.invoke();
+ }
+
+ if (globalThis.COMPILER_TARGET_DEFAULT) {
+ UiUserIgnore.init();
+ }
+
+ UiMessageUserConsent.init();
+}
--- /dev/null
+/**
+ * Simple API to store and invoke multiple callbacks per identifier.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module CallbackList (alias)
+ * @module WoltLabSuite/Core/CallbackList
+ */
+
+import * as Core from "./Core";
+
+class CallbackList {
+ private readonly _callbacks = new Map<string, Callback[]>();
+
+ /**
+ * Adds a callback for given identifier.
+ */
+ add(identifier: string, callback: Callback): void {
+ if (typeof callback !== "function") {
+ throw new TypeError("Expected a valid callback as second argument for identifier '" + identifier + "'.");
+ }
+
+ if (!this._callbacks.has(identifier)) {
+ this._callbacks.set(identifier, []);
+ }
+
+ this._callbacks.get(identifier)!.push(callback);
+ }
+
+ /**
+ * Removes all callbacks registered for given identifier
+ */
+ remove(identifier: string): void {
+ this._callbacks.delete(identifier);
+ }
+
+ /**
+ * Invokes callback function on each registered callback.
+ */
+ forEach(identifier: string | null, callback: (cb: Callback) => unknown): void {
+ if (identifier === null) {
+ this._callbacks.forEach((callbacks, _identifier) => {
+ callbacks.forEach(callback);
+ });
+ } else {
+ this._callbacks.get(identifier)?.forEach(callback);
+ }
+ }
+}
+
+type Callback = (...args: any[]) => void;
+
+Core.enableLegacyInheritance(CallbackList);
+
+export = CallbackList;
--- /dev/null
+/**
+ * Wrapper around the web browser's various clipboard APIs.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Clipboard
+ */
+
+export async function copyTextToClipboard(text: string): Promise<void> {
+ if (navigator.clipboard) {
+ return navigator.clipboard.writeText(text);
+ }
+
+ throw new Error("navigator.clipboard is not supported.");
+}
+
+export async function copyElementTextToClipboard(element: HTMLElement): Promise<void> {
+ return copyTextToClipboard(element.textContent!);
+}
--- /dev/null
+/**
+ * Helper functions to convert between different color formats.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module ColorUtil (alias)
+ * @module WoltLabSuite/Core/ColorUtil
+ */
+
+/**
+ * Converts a HSV color into RGB.
+ *
+ * @see https://secure.wikimedia.org/wikipedia/de/wiki/HSV-Farbraum#Transformation_von_RGB_und_HSV
+ */
+export function hsvToRgb(h: number, s: number, v: number): RGB {
+ const rgb: RGB = { r: 0, g: 0, b: 0 };
+
+ const h2 = Math.floor(h / 60);
+ const f = h / 60 - h2;
+
+ s /= 100;
+ v /= 100;
+
+ const p = v * (1 - s);
+ const q = v * (1 - s * f);
+ const t = v * (1 - s * (1 - f));
+
+ if (s == 0) {
+ rgb.r = rgb.g = rgb.b = v;
+ } else {
+ switch (h2) {
+ case 1:
+ rgb.r = q;
+ rgb.g = v;
+ rgb.b = p;
+ break;
+
+ case 2:
+ rgb.r = p;
+ rgb.g = v;
+ rgb.b = t;
+ break;
+
+ case 3:
+ rgb.r = p;
+ rgb.g = q;
+ rgb.b = v;
+ break;
+
+ case 4:
+ rgb.r = t;
+ rgb.g = p;
+ rgb.b = v;
+ break;
+
+ case 5:
+ rgb.r = v;
+ rgb.g = p;
+ rgb.b = q;
+ break;
+
+ case 0:
+ case 6:
+ rgb.r = v;
+ rgb.g = t;
+ rgb.b = p;
+ break;
+ }
+ }
+
+ return {
+ r: Math.round(rgb.r * 255),
+ g: Math.round(rgb.g * 255),
+ b: Math.round(rgb.b * 255),
+ };
+}
+
+/**
+ * Converts a RGB color into HSV.
+ *
+ * @see https://secure.wikimedia.org/wikipedia/de/wiki/HSV-Farbraum#Transformation_von_RGB_und_HSV
+ */
+export function rgbToHsv(r: number, g: number, b: number): HSV {
+ let h: number, s: number;
+
+ r /= 255;
+ g /= 255;
+ b /= 255;
+
+ const max = Math.max(Math.max(r, g), b);
+ const min = Math.min(Math.min(r, g), b);
+ const diff = max - min;
+
+ h = 0;
+ if (max !== min) {
+ switch (max) {
+ case r:
+ h = 60 * ((g - b) / diff);
+ break;
+
+ case g:
+ h = 60 * (2 + (b - r) / diff);
+ break;
+
+ case b:
+ h = 60 * (4 + (r - g) / diff);
+ break;
+ }
+
+ if (h < 0) {
+ h += 360;
+ }
+ }
+
+ if (max === 0) {
+ s = 0;
+ } else {
+ s = diff / max;
+ }
+
+ return {
+ h: Math.round(h),
+ s: Math.round(s * 100),
+ v: Math.round(max * 100),
+ };
+}
+
+/**
+ * Converts HEX into RGB.
+ */
+export function hexToRgb(hex: string): RGB | typeof Number.NaN {
+ if (/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)) {
+ // only convert #abc and #abcdef
+ const parts = hex.split("");
+
+ // drop the hashtag
+ if (parts[0] === "#") {
+ parts.shift();
+ }
+
+ // parse shorthand #xyz
+ if (parts.length === 3) {
+ return {
+ r: parseInt(parts[0] + "" + parts[0], 16),
+ g: parseInt(parts[1] + "" + parts[1], 16),
+ b: parseInt(parts[2] + "" + parts[2], 16),
+ };
+ } else {
+ return {
+ r: parseInt(parts[0] + "" + parts[1], 16),
+ g: parseInt(parts[2] + "" + parts[3], 16),
+ b: parseInt(parts[4] + "" + parts[5], 16),
+ };
+ }
+ }
+
+ return Number.NaN;
+}
+
+/**
+ * Converts a RGB into HEX.
+ *
+ * @see http://www.linuxtopia.org/online_books/javascript_guides/javascript_faq/rgbtohex.htm
+ */
+export function rgbToHex(r: number, g: number, b: number): string {
+ const charList = "0123456789ABCDEF";
+
+ if (g === undefined) {
+ if (/^rgba?\((\d+), ?(\d+), ?(\d+)(?:, ?[0-9.]+)?\)$/.exec(r.toString())) {
+ r = +RegExp.$1;
+ g = +RegExp.$2;
+ b = +RegExp.$3;
+ }
+ }
+
+ return (
+ charList.charAt((r - (r % 16)) / 16) +
+ "" +
+ charList.charAt(r % 16) +
+ "" +
+ (charList.charAt((g - (g % 16)) / 16) + "" + charList.charAt(g % 16)) +
+ "" +
+ (charList.charAt((b - (b % 16)) / 16) + "" + charList.charAt(b % 16))
+ );
+}
+
+interface RGB {
+ r: number;
+ g: number;
+ b: number;
+}
+
+interface HSV {
+ h: number;
+ s: number;
+ v: number;
+}
+
+// WCF.ColorPicker compatibility (color format conversion)
+window.__wcf_bc_colorUtil = {
+ hexToRgb,
+ hsvToRgb,
+ rgbToHex,
+ rgbToHsv,
+};
--- /dev/null
+/**
+ * Provides data of the active user.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Controller/Captcha
+ */
+
+type CallbackCaptcha = () => unknown;
+
+const _captchas = new Map<string, CallbackCaptcha>();
+
+const ControllerCaptcha = {
+ /**
+ * Registers a captcha with the given identifier and callback used to get captcha data.
+ */
+ add(captchaId: string, callback: CallbackCaptcha): void {
+ if (_captchas.has(captchaId)) {
+ throw new Error(`Captcha with id '${captchaId}' is already registered.`);
+ }
+
+ if (typeof callback !== "function") {
+ throw new TypeError("Expected a valid callback for parameter 'callback'.");
+ }
+
+ _captchas.set(captchaId, callback);
+ },
+
+ /**
+ * Deletes the captcha with the given identifier.
+ */
+ delete(captchaId: string): void {
+ if (!_captchas.has(captchaId)) {
+ throw new Error(`Unknown captcha with id '${captchaId}'.`);
+ }
+
+ _captchas.delete(captchaId);
+ },
+
+ /**
+ * Returns true if a captcha with the given identifier exists.
+ */
+ has(captchaId: string): boolean {
+ return _captchas.has(captchaId);
+ },
+
+ /**
+ * Returns the data of the captcha with the given identifier.
+ *
+ * @param {string} captchaId captcha identifier
+ * @return {Object} captcha data
+ */
+ getData(captchaId: string): unknown {
+ if (!_captchas.has(captchaId)) {
+ throw new Error(`Unknown captcha with id '${captchaId}'.`);
+ }
+
+ return _captchas.get(captchaId)!();
+ },
+};
+
+export = ControllerCaptcha;
--- /dev/null
+/**
+ * Clipboard API Handler.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Controller/Clipboard
+ */
+
+import * as Ajax from "../Ajax";
+import { AjaxCallbackSetup } from "../Ajax/Data";
+import * as Core from "../Core";
+import DomChangeListener from "../Dom/Change/Listener";
+import DomUtil from "../Dom/Util";
+import * as EventHandler from "../Event/Handler";
+import * as Language from "../Language";
+import * as UiConfirmation from "../Ui/Confirmation";
+import UiDropdownSimple from "../Ui/Dropdown/Simple";
+import * as UiPageAction from "../Ui/Page/Action";
+import * as UiScreen from "../Ui/Screen";
+
+interface ClipboardOptions {
+ hasMarkedItems: boolean;
+ pageClassName: string;
+ pageObjectId?: number;
+}
+
+interface ContainerData {
+ checkboxes: HTMLCollectionOf<HTMLInputElement>;
+ element: HTMLElement;
+ markAll: HTMLInputElement | null;
+ markedObjectIds: Set<number>;
+}
+
+interface ItemData {
+ items: { [key: string]: ClipboardActionData };
+ label: string;
+ reloadPageOnSuccess: string[];
+}
+
+interface ClipboardActionData {
+ actionName: string;
+ internalData: ArbitraryObject;
+ label: string;
+ parameters: {
+ actionName?: string;
+ className?: string;
+ objectIDs: number[];
+ template: string;
+ };
+ url: string;
+}
+
+interface AjaxResponseMarkedItems {
+ [key: string]: number[];
+}
+
+interface AjaxResponse {
+ actionName: string;
+ returnValues: {
+ action: string;
+ items?: {
+ // They key is the `typeName`
+ [key: string]: ItemData;
+ };
+ markedItems?: AjaxResponseMarkedItems;
+ objectType: string;
+ };
+}
+
+const _specialCheckboxSelector =
+ '.messageCheckboxLabel > input[type="checkbox"], .message .messageClipboardCheckbox > input[type="checkbox"], .messageGroupList .columnMark > label > input[type="checkbox"]';
+
+class ControllerClipboard {
+ private readonly containers = new Map<string, ContainerData>();
+ private readonly editors = new Map<string, HTMLAnchorElement>();
+ private readonly editorDropdowns = new Map<string, HTMLOListElement>();
+ private itemData = new WeakMap<HTMLLIElement, ClipboardActionData>();
+ private readonly knownCheckboxes = new WeakSet<HTMLInputElement>();
+ private readonly pageClassNames: string[] = [];
+ private pageObjectId? = 0;
+ private readonly reloadPageOnSuccess = new Map<string, string[]>();
+
+ /**
+ * Initializes the clipboard API handler.
+ */
+ setup(options: ClipboardOptions) {
+ if (!options.pageClassName) {
+ throw new Error("Expected a non-empty string for parameter 'pageClassName'.");
+ }
+
+ let hasMarkedItems = false;
+ if (this.pageClassNames.length === 0) {
+ hasMarkedItems = options.hasMarkedItems;
+ this.pageObjectId = options.pageObjectId;
+ }
+
+ this.pageClassNames.push(options.pageClassName);
+
+ this.initContainers();
+
+ if (hasMarkedItems && this.containers.size) {
+ this.loadMarkedItems();
+ }
+
+ DomChangeListener.add("WoltLabSuite/Core/Controller/Clipboard", () => this.initContainers());
+ }
+
+ /**
+ * Reloads the clipboard data.
+ */
+ reload(): void {
+ if (this.containers.size) {
+ this.loadMarkedItems();
+ }
+ }
+
+ /**
+ * Initializes clipboard containers.
+ */
+ private initContainers(): void {
+ document.querySelectorAll(".jsClipboardContainer").forEach((container: HTMLElement) => {
+ const containerId = DomUtil.identify(container);
+
+ let containerData = this.containers.get(containerId);
+ if (containerData === undefined) {
+ const markAll = container.querySelector(".jsClipboardMarkAll") as HTMLInputElement;
+
+ if (markAll !== null) {
+ if (markAll.matches(_specialCheckboxSelector)) {
+ const label = markAll.closest("label") as HTMLLabelElement;
+ label.setAttribute("role", "checkbox");
+ label.tabIndex = 0;
+ label.setAttribute("aria-checked", "false");
+ label.setAttribute("aria-label", Language.get("wcf.clipboard.item.markAll"));
+
+ label.addEventListener("keyup", (event) => {
+ if (event.key === "Enter" || event.key === "Space") {
+ markAll.click();
+ }
+ });
+ }
+
+ markAll.dataset.containerId = containerId;
+ markAll.addEventListener("click", (ev) => this.markAll(ev));
+ }
+
+ containerData = {
+ checkboxes: container.getElementsByClassName("jsClipboardItem") as HTMLCollectionOf<HTMLInputElement>,
+ element: container,
+ markAll: markAll,
+ markedObjectIds: new Set<number>(),
+ };
+ this.containers.set(containerId, containerData);
+ }
+
+ Array.from(containerData.checkboxes).forEach((checkbox) => {
+ if (this.knownCheckboxes.has(checkbox)) {
+ return;
+ }
+
+ checkbox.dataset.containerId = containerId;
+
+ if (checkbox.matches(_specialCheckboxSelector)) {
+ const label = checkbox.closest("label") as HTMLLabelElement;
+ label.setAttribute("role", "checkbox");
+ label.tabIndex = 0;
+ label.setAttribute("aria-checked", "false");
+ label.setAttribute("aria-label", Language.get("wcf.clipboard.item.mark"));
+
+ label.addEventListener("keyup", (event) => {
+ if (event.key === "Enter" || event.key === "Space") {
+ checkbox.click();
+ }
+ });
+ }
+
+ const link = checkbox.closest("a");
+ if (link === null) {
+ checkbox.addEventListener("click", (ev) => this.mark(ev));
+ } else {
+ // Firefox will always trigger the link if the checkbox is
+ // inside of one. Since 2000. Thanks Firefox.
+ checkbox.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ window.setTimeout(() => {
+ checkbox.checked = !checkbox.checked;
+
+ this.mark(checkbox);
+ }, 10);
+ });
+ }
+
+ this.knownCheckboxes.add(checkbox);
+ });
+ });
+ }
+
+ /**
+ * Loads marked items from clipboard.
+ */
+ private loadMarkedItems(): void {
+ Ajax.api(this, {
+ actionName: "getMarkedItems",
+ parameters: {
+ pageClassNames: this.pageClassNames,
+ pageObjectID: this.pageObjectId,
+ },
+ });
+ }
+
+ /**
+ * Marks or unmarks all visible items at once.
+ */
+ private markAll(event: MouseEvent): void {
+ const checkbox = event.currentTarget as HTMLInputElement;
+ const isMarked = checkbox.nodeName !== "INPUT" || checkbox.checked;
+
+ this.setParentAsMarked(checkbox, isMarked);
+
+ const objectIds: number[] = [];
+
+ const containerId = checkbox.dataset.containerId!;
+ const data = this.containers.get(containerId)!;
+ const type = data.element.dataset.type!;
+
+ Array.from(data.checkboxes).forEach((item) => {
+ const objectId = ~~item.dataset.objectId!;
+
+ if (isMarked) {
+ if (!item.checked) {
+ item.checked = true;
+
+ data.markedObjectIds.add(objectId);
+ objectIds.push(objectId);
+ }
+ } else {
+ if (item.checked) {
+ item.checked = false;
+
+ data.markedObjectIds["delete"](objectId);
+ objectIds.push(objectId);
+ }
+ }
+
+ this.setParentAsMarked(item, isMarked);
+
+ const clipboardObject = checkbox.closest(".jsClipboardObject");
+ if (clipboardObject !== null) {
+ if (isMarked) {
+ clipboardObject.classList.add("jsMarked");
+ } else {
+ clipboardObject.classList.remove("jsMarked");
+ }
+ }
+ });
+
+ this.saveState(type, objectIds, isMarked);
+ }
+
+ /**
+ * Marks or unmarks an individual item.
+ *
+ */
+ private mark(event: MouseEvent | HTMLInputElement): void {
+ const checkbox = event instanceof Event ? (event.currentTarget as HTMLInputElement) : event;
+
+ const objectId = ~~checkbox.dataset.objectId!;
+ const isMarked = checkbox.checked;
+ const containerId = checkbox.dataset.containerId!;
+ const data = this.containers.get(containerId)!;
+ const type = data.element.dataset.type!;
+
+ const clipboardObject = checkbox.closest(".jsClipboardObject") as HTMLElement;
+ if (isMarked) {
+ data.markedObjectIds.add(objectId);
+ clipboardObject.classList.add("jsMarked");
+ } else {
+ data.markedObjectIds.delete(objectId);
+ clipboardObject.classList.remove("jsMarked");
+ }
+
+ if (data.markAll !== null) {
+ data.markAll.checked = !Array.from(data.checkboxes).some((item) => !item.checked);
+
+ this.setParentAsMarked(data.markAll, isMarked);
+ }
+
+ this.setParentAsMarked(checkbox, checkbox.checked);
+
+ this.saveState(type, [objectId], isMarked);
+ }
+
+ /**
+ * Saves the state for given item object ids.
+ */
+ private saveState(objectType: string, objectIds: number[], isMarked: boolean): void {
+ Ajax.api(this, {
+ actionName: isMarked ? "mark" : "unmark",
+ parameters: {
+ pageClassNames: this.pageClassNames,
+ pageObjectID: this.pageObjectId,
+ objectIDs: objectIds,
+ objectType,
+ },
+ });
+ }
+
+ /**
+ * Executes an editor action.
+ */
+ private executeAction(event: MouseEvent): void {
+ const listItem = event.currentTarget as HTMLLIElement;
+ const data = this.itemData.get(listItem)!;
+
+ if (data.url) {
+ window.location.href = data.url;
+ return;
+ }
+
+ function triggerEvent() {
+ const type = listItem.dataset.type!;
+
+ EventHandler.fire("com.woltlab.wcf.clipboard", type, {
+ data,
+ listItem,
+ responseData: null,
+ });
+ }
+
+ const message = typeof data.internalData.confirmMessage === "string" ? data.internalData.confirmMessage : "";
+ let fireEvent = true;
+
+ if (Core.isPlainObject(data.parameters) && data.parameters.actionName && data.parameters.className) {
+ if (data.parameters.actionName === "unmarkAll" || Array.isArray(data.parameters.objectIDs)) {
+ if (message.length) {
+ const template = typeof data.internalData.template === "string" ? data.internalData.template : "";
+
+ UiConfirmation.show({
+ confirm: () => {
+ const formData = {};
+
+ if (template.length) {
+ UiConfirmation.getContentElement()
+ .querySelectorAll("input, select, textarea")
+ .forEach((item: HTMLInputElement) => {
+ const name = item.name;
+
+ switch (item.nodeName) {
+ case "INPUT":
+ if ((item.type !== "checkbox" && item.type !== "radio") || item.checked) {
+ formData[name] = item.value;
+ }
+ break;
+
+ case "SELECT":
+ formData[name] = item.value;
+ break;
+
+ case "TEXTAREA":
+ formData[name] = item.value.trim();
+ break;
+ }
+ });
+ }
+
+ this.executeProxyAction(listItem, data, formData);
+ },
+ message,
+ template,
+ });
+ } else {
+ this.executeProxyAction(listItem, data);
+ }
+ }
+ } else if (message.length) {
+ fireEvent = false;
+
+ UiConfirmation.show({
+ confirm: triggerEvent,
+ message,
+ });
+ }
+
+ if (fireEvent) {
+ triggerEvent();
+ }
+ }
+
+ /**
+ * Forwards clipboard actions to an individual handler.
+ */
+ private executeProxyAction(listItem: HTMLLIElement, data: ClipboardActionData, formData: ArbitraryObject = {}): void {
+ const objectIds = data.parameters.actionName !== "unmarkAll" ? data.parameters.objectIDs : [];
+ const parameters = { data: formData };
+
+ if (Core.isPlainObject(data.internalData.parameters)) {
+ Object.entries(data.internalData.parameters as ArbitraryObject).forEach(([key, value]) => {
+ parameters[key] = value;
+ });
+ }
+
+ Ajax.api(
+ this,
+ {
+ actionName: data.parameters.actionName,
+ className: data.parameters.className,
+ objectIDs: objectIds,
+ parameters,
+ },
+ (responseData: AjaxResponse) => {
+ if (data.actionName !== "unmarkAll") {
+ const type = listItem.dataset.type!;
+
+ EventHandler.fire("com.woltlab.wcf.clipboard", type, {
+ data,
+ listItem,
+ responseData,
+ });
+
+ const reloadPageOnSuccess = this.reloadPageOnSuccess.get(type);
+ if (reloadPageOnSuccess && reloadPageOnSuccess.includes(responseData.actionName)) {
+ window.location.reload();
+ return;
+ }
+ }
+
+ this.loadMarkedItems();
+ },
+ );
+ }
+
+ /**
+ * Unmarks all clipboard items for an object type.
+ */
+ private unmarkAll(event: MouseEvent): void {
+ const listItem = event.currentTarget as HTMLElement;
+
+ Ajax.api(this, {
+ actionName: "unmarkAll",
+ parameters: {
+ objectType: listItem.dataset.type!,
+ },
+ });
+ }
+
+ /**
+ * Sets up ajax request object.
+ */
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ className: "wcf\\data\\clipboard\\item\\ClipboardItemAction",
+ },
+ };
+ }
+
+ /**
+ * Handles successful AJAX requests.
+ */
+ _ajaxSuccess(data: AjaxResponse): void {
+ if (data.actionName === "unmarkAll") {
+ const objectType = data.returnValues.objectType;
+ this.containers.forEach((containerData) => {
+ if (containerData.element.dataset.type !== objectType) {
+ return;
+ }
+
+ containerData.element.querySelectorAll(".jsMarked").forEach((element) => element.classList.remove("jsMarked"));
+
+ if (containerData.markAll !== null) {
+ containerData.markAll.checked = false;
+
+ this.setParentAsMarked(containerData.markAll, false);
+ }
+
+ Array.from(containerData.checkboxes).forEach((checkbox) => {
+ checkbox.checked = false;
+
+ this.setParentAsMarked(checkbox, false);
+ });
+
+ UiPageAction.remove(`wcfClipboard-${objectType}`);
+ });
+
+ return;
+ }
+
+ this.itemData = new WeakMap<HTMLLIElement, ClipboardActionData>();
+ this.reloadPageOnSuccess.clear();
+
+ // rebuild markings
+ const markings = Core.isPlainObject(data.returnValues.markedItems) ? data.returnValues.markedItems! : {};
+ this.containers.forEach((containerData) => {
+ const typeName = containerData.element.dataset.type!;
+
+ const objectIds = Array.isArray(markings[typeName]) ? markings[typeName] : [];
+ this.rebuildMarkings(containerData, objectIds);
+ });
+
+ const keepEditors: string[] = Object.keys(data.returnValues.items || {});
+
+ // clear editors
+ this.editors.forEach((editor, typeName) => {
+ if (keepEditors.includes(typeName)) {
+ UiPageAction.remove(`wcfClipboard-${typeName}`);
+
+ this.editorDropdowns.get(typeName)!.innerHTML = "";
+ }
+ });
+
+ // no items
+ if (!data.returnValues.items) {
+ return;
+ }
+
+ // rebuild editors
+ Object.entries(data.returnValues.items).forEach(([typeName, typeData]) => {
+ this.reloadPageOnSuccess.set(typeName, typeData.reloadPageOnSuccess);
+
+ let created = false;
+
+ let editor = this.editors.get(typeName);
+ let dropdown = this.editorDropdowns.get(typeName)!;
+ if (editor === undefined) {
+ created = true;
+
+ editor = document.createElement("a");
+ editor.className = "dropdownToggle";
+ editor.textContent = typeData.label;
+
+ this.editors.set(typeName, editor);
+
+ dropdown = document.createElement("ol");
+ dropdown.className = "dropdownMenu";
+
+ this.editorDropdowns.set(typeName, dropdown);
+ } else {
+ editor.textContent = typeData.label;
+ dropdown.innerHTML = "";
+ }
+
+ // create editor items
+ Object.values(typeData.items).forEach((itemData) => {
+ const item = document.createElement("li");
+ const label = document.createElement("span");
+ label.textContent = itemData.label;
+ item.appendChild(label);
+ dropdown.appendChild(item);
+
+ item.dataset.type = typeName;
+ item.addEventListener("click", (ev) => this.executeAction(ev));
+
+ this.itemData.set(item, itemData);
+ });
+
+ const divider = document.createElement("li");
+ divider.classList.add("dropdownDivider");
+ dropdown.appendChild(divider);
+
+ // add 'unmark all'
+ const unmarkAll = document.createElement("li");
+ unmarkAll.dataset.type = typeName;
+ const label = document.createElement("span");
+ label.textContent = Language.get("wcf.clipboard.item.unmarkAll");
+ unmarkAll.appendChild(label);
+ unmarkAll.addEventListener("click", (ev) => this.unmarkAll(ev));
+ dropdown.appendChild(unmarkAll);
+
+ if (keepEditors.indexOf(typeName) !== -1) {
+ const actionName = `wcfClipboard-${typeName}`;
+
+ if (UiPageAction.has(actionName)) {
+ UiPageAction.show(actionName);
+ } else {
+ UiPageAction.add(actionName, editor);
+ }
+ }
+
+ if (created) {
+ const parent = editor.parentElement!;
+ parent.classList.add("dropdown");
+ parent.appendChild(dropdown);
+ UiDropdownSimple.init(editor);
+ }
+ });
+ }
+
+ /**
+ * Rebuilds the mark state for each item.
+ */
+ private rebuildMarkings(data: ContainerData, objectIds: number[]): void {
+ let markAll = true;
+
+ Array.from(data.checkboxes).forEach((checkbox) => {
+ const clipboardObject = checkbox.closest(".jsClipboardObject") as HTMLElement;
+
+ const isMarked = objectIds.includes(~~checkbox.dataset.objectId!);
+ if (!isMarked) {
+ markAll = false;
+ }
+
+ checkbox.checked = isMarked;
+ if (isMarked) {
+ clipboardObject.classList.add("jsMarked");
+ } else {
+ clipboardObject.classList.remove("jsMarked");
+ }
+
+ this.setParentAsMarked(checkbox, isMarked);
+ });
+
+ if (data.markAll !== null) {
+ data.markAll.checked = markAll;
+
+ this.setParentAsMarked(data.markAll, markAll);
+
+ const parent = data.markAll.closest(".columnMark");
+ if (parent) {
+ if (markAll) {
+ parent.classList.add("jsMarked");
+ } else {
+ parent.classList.remove("jsMarked");
+ }
+ }
+ }
+ }
+
+ private setParentAsMarked(element: HTMLElement, isMarked: boolean): void {
+ const parent = element.parentElement!;
+ if (parent.getAttribute("role") === "checkbox") {
+ parent.setAttribute("aria-checked", isMarked ? "true" : "false");
+ }
+ }
+
+ /**
+ * Hides the clipboard editor for the given object type.
+ */
+ hideEditor(objectType: string): void {
+ UiPageAction.remove("wcfClipboard-" + objectType);
+
+ UiScreen.pageOverlayOpen();
+ }
+
+ /**
+ * Shows the clipboard editor.
+ */
+ showEditor(): void {
+ this.loadMarkedItems();
+
+ UiScreen.pageOverlayClose();
+ }
+
+ /**
+ * Unmarks the objects with given clipboard object type and ids.
+ */
+ unmark(objectType: string, objectIds: number[]): void {
+ this.saveState(objectType, objectIds, false);
+ }
+}
+
+let controllerClipboard: ControllerClipboard;
+
+function getControllerClipboard(): ControllerClipboard {
+ if (!controllerClipboard) {
+ controllerClipboard = new ControllerClipboard();
+ }
+
+ return controllerClipboard;
+}
+
+/**
+ * Initializes the clipboard API handler.
+ */
+export function setup(options: ClipboardOptions): void {
+ getControllerClipboard().setup(options);
+}
+
+/**
+ * Reloads the clipboard data.
+ */
+export function reload(): void {
+ getControllerClipboard().reload();
+}
+
+/**
+ * Hides the clipboard editor for the given object type.
+ */
+export function hideEditor(objectType: string): void {
+ getControllerClipboard().hideEditor(objectType);
+}
+
+/**
+ * Shows the clipboard editor.
+ */
+export function showEditor(): void {
+ getControllerClipboard().showEditor();
+}
+
+/**
+ * Unmarks the objects with given clipboard object type and ids.
+ */
+export function unmark(objectType: string, objectIds: number[]): void {
+ getControllerClipboard().unmark(objectType, objectIds);
+}
--- /dev/null
+/**
+ * Shows and hides an element that depends on certain selected pages when setting up conditions.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Controller/Condition/Page/Dependence
+ */
+
+import DomUtil from "../../../Dom/Util";
+import * as EventHandler from "../../../Event/Handler";
+
+const _pages: HTMLInputElement[] = Array.from(document.querySelectorAll('input[name="pageIDs[]"]'));
+const _dependentElements: HTMLElement[] = [];
+const _pageIds = new WeakMap<HTMLElement, number[]>();
+const _hiddenElements = new WeakMap<HTMLElement, HTMLElement[]>();
+
+let _didInit = false;
+
+/**
+ * Checks if only relevant pages are selected. If that is the case, the dependent
+ * element is shown, otherwise it is hidden.
+ */
+function checkVisibility(): void {
+ _dependentElements.forEach((dependentElement) => {
+ const pageIds = _pageIds.get(dependentElement)!;
+
+ const checkedPageIds: number[] = [];
+ _pages.forEach((page) => {
+ if (page.checked) {
+ checkedPageIds.push(~~page.value);
+ }
+ });
+
+ const irrelevantPageIds = checkedPageIds.filter((pageId) => pageIds.includes(pageId));
+
+ if (!checkedPageIds.length || irrelevantPageIds.length) {
+ hideDependentElement(dependentElement);
+ } else {
+ showDependentElement(dependentElement);
+ }
+ });
+
+ EventHandler.fire("com.woltlab.wcf.pageConditionDependence", "checkVisivility");
+}
+
+/**
+ * Hides all elements that depend on the given element.
+ */
+function hideDependentElement(dependentElement: HTMLElement): void {
+ DomUtil.hide(dependentElement);
+
+ const hiddenElements = _hiddenElements.get(dependentElement)!;
+ hiddenElements.forEach((hiddenElement) => DomUtil.hide(hiddenElement));
+
+ _hiddenElements.set(dependentElement, []);
+}
+
+/**
+ * Shows all elements that depend on the given element.
+ */
+function showDependentElement(dependentElement: HTMLElement): void {
+ DomUtil.show(dependentElement);
+
+ // make sure that all parent elements are also visible
+ let parentElement = dependentElement;
+ while ((parentElement = parentElement.parentElement!) && parentElement) {
+ if (DomUtil.isHidden(parentElement)) {
+ _hiddenElements.get(dependentElement)!.push(parentElement);
+ }
+
+ DomUtil.show(parentElement);
+ }
+}
+
+export function register(dependentElement: HTMLElement, pageIds: number[]): void {
+ _dependentElements.push(dependentElement);
+ _pageIds.set(dependentElement, pageIds);
+ _hiddenElements.set(dependentElement, []);
+
+ if (!_didInit) {
+ _pages.forEach((page) => {
+ page.addEventListener("change", () => checkVisibility());
+ });
+
+ _didInit = true;
+ }
+
+ // remove the dependent element before submit if it is hidden
+ dependentElement.closest("form")!.addEventListener("submit", () => {
+ if (DomUtil.isHidden(dependentElement)) {
+ dependentElement.remove();
+ }
+ });
+
+ checkVisibility();
+}
--- /dev/null
+/**
+ * Map route planner based on Google Maps.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Controller/Map/Route/Planner
+ */
+
+import * as AjaxStatus from "../../../Ajax/Status";
+import * as Core from "../../../Core";
+import DomUtil from "../../../Dom/Util";
+import * as Language from "../../../Language";
+import UiDialog from "../../../Ui/Dialog";
+import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
+
+interface LocationData {
+ label?: string;
+ location: google.maps.LatLng;
+}
+
+class ControllerMapRoutePlanner implements DialogCallbackObject {
+ private readonly button: HTMLElement;
+ private readonly destination: google.maps.LatLng;
+ private didInitDialog = false;
+ private directionsRenderer?: google.maps.DirectionsRenderer = undefined;
+ private directionsService?: google.maps.DirectionsService = undefined;
+ private googleLink?: HTMLAnchorElement = undefined;
+ private lastOrigin?: google.maps.LatLng = undefined;
+ private map?: google.maps.Map = undefined;
+ private originInput?: HTMLInputElement = undefined;
+ private travelMode?: HTMLSelectElement = undefined;
+
+ constructor(buttonId: string, destination: google.maps.LatLng) {
+ const button = document.getElementById(buttonId);
+ if (button === null) {
+ throw new Error(`Unknown button with id '${buttonId}'`);
+ }
+ this.button = button;
+
+ this.button.addEventListener("click", (ev) => this.openDialog(ev));
+
+ this.destination = destination;
+ }
+
+ /**
+ * Calculates the route based on the given result of a location search.
+ */
+ _calculateRoute(data: LocationData): void {
+ const dialog = UiDialog.getDialog(this)!.dialog;
+
+ if (data.label) {
+ this.originInput!.value = data.label;
+ }
+
+ if (this.map === undefined) {
+ const mapContainer = dialog.querySelector(".googleMap") as HTMLElement;
+ this.map = new google.maps.Map(mapContainer, {
+ disableDoubleClickZoom: window.WCF.Location.GoogleMaps.Settings.get("disableDoubleClickZoom"),
+ draggable: window.WCF.Location.GoogleMaps.Settings.get("draggable"),
+ mapTypeId: google.maps.MapTypeId.ROADMAP,
+ scaleControl: window.WCF.Location.GoogleMaps.Settings.get("scaleControl"),
+ scrollwheel: window.WCF.Location.GoogleMaps.Settings.get("scrollwheel"),
+ });
+
+ this.directionsService = new google.maps.DirectionsService();
+ this.directionsRenderer = new google.maps.DirectionsRenderer();
+
+ this.directionsRenderer.setMap(this.map);
+ const directionsContainer = dialog.querySelector(".googleMapsDirections") as HTMLElement;
+ this.directionsRenderer.setPanel(directionsContainer);
+
+ this.googleLink = dialog.querySelector(".googleMapsDirectionsGoogleLink") as HTMLAnchorElement;
+ }
+
+ const request = {
+ destination: this.destination,
+ origin: data.location,
+ provideRouteAlternatives: true,
+ travelMode: google.maps.TravelMode[this.travelMode!.value.toUpperCase()],
+ };
+
+ AjaxStatus.show();
+ this.directionsService!.route(request, (result, status) => this.setRoute(result, status));
+
+ this.googleLink!.href = this.getGoogleMapsLink(data.location, this.travelMode!.value);
+
+ this.lastOrigin = data.location;
+ }
+
+ /**
+ * Returns the Google Maps link based on the given optional directions origin
+ * and optional travel mode.
+ */
+ private getGoogleMapsLink(origin?: google.maps.LatLng, travelMode?: string): string {
+ if (origin) {
+ let link = `https://www.google.com/maps/dir/?api=1&origin=${origin.lat()},${origin.lng()}&destination=${this.destination.lat()},${this.destination.lng()}`;
+
+ if (travelMode) {
+ link += `&travelmode=${travelMode}`;
+ }
+
+ return link;
+ }
+
+ return `https://www.google.com/maps/search/?api=1&query=${this.destination.lat()},${this.destination.lng()}`;
+ }
+
+ /**
+ * Initializes the route planning dialog.
+ */
+ private initDialog(): void {
+ if (!this.didInitDialog) {
+ const dialog = UiDialog.getDialog(this)!.dialog;
+
+ // make input element a location search
+ this.originInput = dialog.querySelector('input[name="origin"]') as HTMLInputElement;
+ new window.WCF.Location.GoogleMaps.LocationSearch(this.originInput, (data) => this._calculateRoute(data));
+
+ this.travelMode = dialog.querySelector('select[name="travelMode"]') as HTMLSelectElement;
+ this.travelMode.addEventListener("change", this.updateRoute.bind(this));
+
+ this.didInitDialog = true;
+ }
+ }
+
+ /**
+ * Opens the route planning dialog.
+ */
+ private openDialog(event: Event): void {
+ event.preventDefault();
+
+ UiDialog.open(this);
+ }
+
+ /**
+ * Handles the response of the direction service.
+ */
+ private setRoute(result: google.maps.DirectionsResult, status: google.maps.DirectionsStatus): void {
+ AjaxStatus.hide();
+
+ if (status === "OK") {
+ DomUtil.show(this.map!.getDiv().parentElement!);
+
+ google.maps.event.trigger(this.map, "resize");
+
+ this.directionsRenderer!.setDirections(result);
+
+ DomUtil.show(this.travelMode!.closest("dl")!);
+ DomUtil.show(this.googleLink!);
+
+ DomUtil.innerError(this.originInput!, false);
+ } else {
+ // map irrelevant errors to not found error
+ if (status !== "OVER_QUERY_LIMIT" && status !== "REQUEST_DENIED") {
+ status = google.maps.DirectionsStatus.NOT_FOUND;
+ }
+
+ DomUtil.innerError(this.originInput!, Language.get(`wcf.map.route.error.${status.toLowerCase()}`));
+ }
+ }
+
+ /**
+ * Updates the route after the travel mode has been changed.
+ */
+ private updateRoute(): void {
+ this._calculateRoute({
+ location: this.lastOrigin!,
+ });
+ }
+
+ /**
+ * Sets up the route planner dialog.
+ */
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: this.button.id + "Dialog",
+ options: {
+ onShow: this.initDialog.bind(this),
+ title: Language.get("wcf.map.route.planner"),
+ },
+ source: `
+<div class="googleMapsDirectionsContainer" style="display: none;">
+ <div class="googleMap"></div>
+ <div class="googleMapsDirections"></div>
+</div>
+<small class="googleMapsDirectionsGoogleLinkContainer">
+ <a href="${this.getGoogleMapsLink()}" class="googleMapsDirectionsGoogleLink" target="_blank" style="display: none;">${Language.get(
+ "wcf.map.route.viewOnGoogleMaps",
+ )}</a>
+</small>
+<dl>
+ <dt>${Language.get("wcf.map.route.origin")}</dt>
+ <dd>
+ <input type="text" name="origin" class="long" autofocus>
+ </dd>
+</dl>
+<dl style="display: none;">
+ <dt>${Language.get("wcf.map.route.travelMode")}</dt>
+ <dd>
+ <select name="travelMode">
+ <option value="driving">${Language.get("wcf.map.route.travelMode.driving")}</option>
+ <option value="walking">${Language.get("wcf.map.route.travelMode.walking")}</option>
+ <option value="bicycling">${Language.get("wcf.map.route.travelMode.bicycling")}</option>
+ <option value="transit">${Language.get("wcf.map.route.travelMode.transit")}</option>
+ </select>
+ </dd>
+</dl>`,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(ControllerMapRoutePlanner);
+
+export = ControllerMapRoutePlanner;
--- /dev/null
+/**
+ * Initializes modules required for media list view.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Controller/Media/List
+ */
+
+import MediaListUpload from "../../Media/List/Upload";
+import * as MediaClipboard from "../../Media/Clipboard";
+import * as EventHandler from "../../Event/Handler";
+import MediaEditor from "../../Media/Editor";
+import * as DomChangeListener from "../../Dom/Change/Listener";
+import * as Clipboard from "../../Controller/Clipboard";
+import { Media, MediaUploadSuccessEventData } from "../../Media/Data";
+import MediaManager from "../../Media/Manager/Base";
+
+const _mediaEditor = new MediaEditor({
+ _editorSuccess: (media: Media, oldCategoryId: number) => {
+ if (media.categoryID != oldCategoryId) {
+ window.setTimeout(() => {
+ window.location.reload();
+ }, 500);
+ }
+ },
+});
+const _tableBody = document.getElementById("mediaListTableBody")!;
+let _upload: MediaListUpload;
+
+interface MediaListOptions {
+ categoryId?: number;
+ hasMarkedItems?: boolean;
+}
+
+export function init(options: MediaListOptions): void {
+ options = options || {};
+ _upload = new MediaListUpload("uploadButton", "mediaListTableBody", {
+ categoryId: options.categoryId,
+ multiple: true,
+ elementTagSize: 48,
+ });
+
+ MediaClipboard.init("wcf\\acp\\page\\MediaListPage", options.hasMarkedItems || false, {
+ clipboardDeleteMedia: (mediaIds: number[]) => clipboardDeleteMedia(mediaIds),
+ } as MediaManager);
+
+ EventHandler.add("com.woltlab.wcf.media.upload", "removedErroneousUploadRow", () => deleteCallback());
+
+ // eslint-disable-next-line
+ //@ts-ignore
+ const deleteAction = new WCF.Action.Delete("wcf\\data\\media\\MediaAction", ".jsMediaRow");
+ deleteAction.setCallback(deleteCallback);
+
+ addButtonEventListeners();
+
+ DomChangeListener.add("WoltLabSuite/Core/Controller/Media/List", () => addButtonEventListeners());
+
+ EventHandler.add("com.woltlab.wcf.media.upload", "success", (data: MediaUploadSuccessEventData) =>
+ openEditorAfterUpload(data),
+ );
+}
+
+/**
+ * Adds the `click` event listeners to the media edit icons in new media table rows.
+ */
+function addButtonEventListeners(): void {
+ Array.from(_tableBody.getElementsByClassName("jsMediaEditButton")).forEach((button) => {
+ button.classList.remove("jsMediaEditButton");
+ button.addEventListener("click", (ev) => edit(ev));
+ });
+}
+
+/**
+ * Is triggered after media files have been deleted using the delete icon.
+ */
+function deleteCallback(objectIds?: number[]): void {
+ const tableRowCount = _tableBody.getElementsByTagName("tr").length;
+ if (objectIds === undefined) {
+ if (!tableRowCount) {
+ window.location.reload();
+ }
+ } else if (objectIds.length === tableRowCount) {
+ // table is empty, reload page
+ window.location.reload();
+ } else {
+ Clipboard.reload();
+ }
+}
+
+/**
+ * Is called when a media edit icon is clicked.
+ */
+function edit(event: Event): void {
+ _mediaEditor.edit(~~(event.currentTarget as HTMLElement).dataset.objectId!);
+}
+
+/**
+ * Opens the media editor after uploading a single file.
+ */
+function openEditorAfterUpload(data: MediaUploadSuccessEventData) {
+ if (data.upload === _upload && !data.isMultiFileUpload && !_upload.hasPendingUploads()) {
+ const keys = Object.keys(data.media);
+
+ if (keys.length) {
+ _mediaEditor.edit(data.media[keys[0]]);
+ }
+ }
+}
+
+/**
+ * Is called after the media files with the given ids have been deleted via clipboard.
+ */
+function clipboardDeleteMedia(mediaIds: number[]) {
+ Array.from(document.getElementsByClassName("jsMediaRow")).forEach((media) => {
+ const mediaID = ~~(media.querySelector(".jsClipboardItem") as HTMLElement).dataset.objectId!;
+
+ if (mediaIds.indexOf(mediaID) !== -1) {
+ media.remove();
+ }
+ });
+
+ if (!document.getElementsByClassName("jsMediaRow").length) {
+ window.location.reload();
+ }
+}
--- /dev/null
+/**
+ * Handles dismissible user notices.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Controller/Notice/Dismiss
+ */
+
+import * as Ajax from "../../Ajax";
+
+/**
+ * Initializes dismiss buttons.
+ */
+export function setup(): void {
+ document.querySelectorAll(".jsDismissNoticeButton").forEach((button) => {
+ button.addEventListener("click", (ev) => click(ev));
+ });
+}
+
+/**
+ * Sends a request to dismiss a notice and removes it afterwards.
+ */
+function click(event: Event): void {
+ const button = event.currentTarget as HTMLElement;
+
+ Ajax.apiOnce({
+ data: {
+ actionName: "dismiss",
+ className: "wcf\\data\\notice\\NoticeAction",
+ objectIDs: [button.dataset.objectId!],
+ },
+ success: () => {
+ button.parentElement!.remove();
+ },
+ });
+}
--- /dev/null
+/**
+ * Versatile popover manager.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Controller/Popover
+ */
+
+import * as Ajax from "../Ajax";
+import DomChangeListener from "../Dom/Change/Listener";
+import DomUtil from "../Dom/Util";
+import * as Environment from "../Environment";
+import * as UiAlignment from "../Ui/Alignment";
+import { AjaxCallbackObject, AjaxCallbackSetup, CallbackFailure, CallbackSuccess, RequestPayload } from "../Ajax/Data";
+
+const enum State {
+ None,
+ Loading,
+ Ready,
+}
+
+const enum Delay {
+ Hide = 500,
+ Show = 800,
+}
+
+type CallbackLoad = (objectId: number | string, popover: ControllerPopover, element: HTMLElement) => void;
+
+interface PopoverOptions {
+ attributeName?: string;
+ className: string;
+ dboAction: string;
+ identifier: string;
+ legacy?: boolean;
+ loadCallback?: CallbackLoad;
+}
+
+interface HandlerData {
+ attributeName: string;
+ dboAction: string;
+ legacy: boolean;
+ loadCallback?: CallbackLoad;
+ selector: string;
+}
+
+interface ElementData {
+ element: HTMLElement;
+ identifier: string;
+ objectId: number | string;
+}
+
+interface CacheData {
+ content: DocumentFragment | null;
+ state: State;
+}
+
+class ControllerPopover implements AjaxCallbackObject {
+ private activeId = "";
+ private readonly cache = new Map<string, CacheData>();
+ private readonly elements = new Map<string, ElementData>();
+ private readonly handlers = new Map<string, HandlerData>();
+ private hoverId = "";
+ private readonly popover: HTMLDivElement;
+ private readonly popoverContent: HTMLDivElement;
+ private suspended = false;
+ private timerEnter?: number = undefined;
+ private timerLeave?: number = undefined;
+
+ /**
+ * Builds popover DOM elements and binds event listeners.
+ */
+ constructor() {
+ this.popover = document.createElement("div");
+ this.popover.className = "popover forceHide";
+
+ this.popoverContent = document.createElement("div");
+ this.popoverContent.className = "popoverContent";
+ this.popover.appendChild(this.popoverContent);
+
+ const pointer = document.createElement("span");
+ pointer.className = "elementPointer";
+ pointer.appendChild(document.createElement("span"));
+ this.popover.appendChild(pointer);
+
+ document.body.appendChild(this.popover);
+
+ // event listener
+ this.popover.addEventListener("mouseenter", () => this.popoverMouseEnter());
+ this.popover.addEventListener("mouseleave", () => this.mouseLeave());
+
+ this.popover.addEventListener("animationend", () => this.clearContent());
+
+ window.addEventListener("beforeunload", () => {
+ this.suspended = true;
+
+ if (this.timerEnter) {
+ window.clearTimeout(this.timerEnter);
+ this.timerEnter = undefined;
+ }
+
+ this.hidePopover();
+ });
+
+ DomChangeListener.add("WoltLabSuite/Core/Controller/Popover", (identifier) => this.initHandler(identifier));
+ }
+
+ /**
+ * Initializes a popover handler.
+ *
+ * Usage:
+ *
+ * ControllerPopover.init({
+ * attributeName: 'data-object-id',
+ * className: 'fooLink',
+ * identifier: 'com.example.bar.foo',
+ * loadCallback: (objectId, popover) => {
+ * // request data for object id (e.g. via WoltLabSuite/Core/Ajax)
+ *
+ * // then call this to set the content
+ * popover.setContent('com.example.bar.foo', objectId, htmlTemplateString);
+ * }
+ * });
+ */
+ init(options: PopoverOptions): void {
+ if (Environment.platform() !== "desktop") {
+ return;
+ }
+
+ options.attributeName = options.attributeName || "data-object-id";
+ options.legacy = (options.legacy as unknown) === true;
+
+ if (this.handlers.has(options.identifier)) {
+ return;
+ }
+
+ // Legacy implementations provided a selector for `className`.
+ const selector = options.legacy ? options.className : `.${options.className}`;
+
+ this.handlers.set(options.identifier, {
+ attributeName: options.attributeName,
+ dboAction: options.dboAction,
+ legacy: options.legacy,
+ loadCallback: options.loadCallback,
+ selector,
+ });
+
+ this.initHandler(options.identifier);
+ }
+
+ /**
+ * Initializes a popover handler.
+ */
+ private initHandler(identifier?: string): void {
+ if (typeof identifier === "string" && identifier.length) {
+ this.initElements(this.handlers.get(identifier)!, identifier);
+ } else {
+ this.handlers.forEach((value, key) => {
+ this.initElements(value, key);
+ });
+ }
+ }
+
+ /**
+ * Binds event listeners for popover-enabled elements.
+ */
+ private initElements(options: HandlerData, identifier: string): void {
+ document.querySelectorAll(options.selector).forEach((element: HTMLElement) => {
+ const id = DomUtil.identify(element);
+ if (this.cache.has(id)) {
+ return;
+ }
+
+ // Skip elements that are located inside a popover.
+ if (element.closest(".popover") !== null) {
+ this.cache.set(id, {
+ content: null,
+ state: State.None,
+ });
+
+ return;
+ }
+
+ const objectId = options.legacy ? id : ~~element.getAttribute(options.attributeName)!;
+ if (objectId === 0) {
+ return;
+ }
+
+ element.addEventListener("mouseenter", (ev) => this.mouseEnter(ev));
+ element.addEventListener("mouseleave", () => this.mouseLeave());
+
+ if (element instanceof HTMLAnchorElement && element.href) {
+ element.addEventListener("click", () => this.hidePopover());
+ }
+
+ const cacheId = `${identifier}-${objectId}`;
+ element.dataset.cacheId = cacheId;
+
+ this.elements.set(id, {
+ element,
+ identifier,
+ objectId: objectId.toString(),
+ });
+
+ if (!this.cache.has(cacheId)) {
+ this.cache.set(cacheId, {
+ content: null,
+ state: State.None,
+ });
+ }
+ });
+ }
+
+ /**
+ * Sets the content for given identifier and object id.
+ */
+ setContent(identifier: string, objectId: number | string, content: string): void {
+ const cacheId = `${identifier}-${objectId}`;
+ const data = this.cache.get(cacheId);
+ if (data === undefined) {
+ throw new Error(`Unable to find element for object id '${objectId}' (identifier: '${identifier}').`);
+ }
+
+ let fragment = DomUtil.createFragmentFromHtml(content);
+ if (!fragment.childElementCount) {
+ fragment = DomUtil.createFragmentFromHtml("<p>" + content + "</p>");
+ }
+
+ data.content = fragment;
+ data.state = State.Ready;
+
+ if (this.activeId) {
+ const activeElement = this.elements.get(this.activeId)!.element;
+
+ if (activeElement.dataset.cacheId === cacheId) {
+ this.show();
+ }
+ }
+ }
+
+ /**
+ * Handles the mouse start hovering the popover-enabled element.
+ */
+ private mouseEnter(event: MouseEvent): void {
+ if (this.suspended) {
+ return;
+ }
+
+ if (this.timerEnter) {
+ window.clearTimeout(this.timerEnter);
+ this.timerEnter = undefined;
+ }
+
+ const id = DomUtil.identify(event.currentTarget as HTMLElement);
+ if (this.activeId === id && this.timerLeave) {
+ window.clearTimeout(this.timerLeave);
+ this.timerLeave = undefined;
+ }
+
+ this.hoverId = id;
+
+ this.timerEnter = window.setTimeout(() => {
+ this.timerEnter = undefined;
+
+ if (this.hoverId === id) {
+ this.show();
+ }
+ }, Delay.Show);
+ }
+
+ /**
+ * Handles the mouse leaving the popover-enabled element or the popover itself.
+ */
+ private mouseLeave(): void {
+ this.hoverId = "";
+
+ if (this.timerLeave) {
+ return;
+ }
+
+ this.timerLeave = window.setTimeout(() => this.hidePopover(), Delay.Hide);
+ }
+
+ /**
+ * Handles the mouse start hovering the popover element.
+ */
+ private popoverMouseEnter(): void {
+ if (this.timerLeave) {
+ window.clearTimeout(this.timerLeave);
+ this.timerLeave = undefined;
+ }
+ }
+
+ /**
+ * Shows the popover and loads content on-the-fly.
+ */
+ private show(): void {
+ if (this.timerLeave) {
+ window.clearTimeout(this.timerLeave);
+ this.timerLeave = undefined;
+ }
+
+ let forceHide = false;
+ if (this.popover.classList.contains("active")) {
+ if (this.activeId !== this.hoverId) {
+ this.hidePopover();
+
+ forceHide = true;
+ }
+ } else if (this.popoverContent.childElementCount) {
+ forceHide = true;
+ }
+
+ if (forceHide) {
+ this.popover.classList.add("forceHide");
+
+ // force layout
+ //noinspection BadExpressionStatementJS
+ this.popover.offsetTop;
+
+ this.clearContent();
+
+ this.popover.classList.remove("forceHide");
+ }
+
+ this.activeId = this.hoverId;
+
+ const elementData = this.elements.get(this.activeId);
+ // check if source element is already gone
+ if (elementData === undefined) {
+ return;
+ }
+
+ const cacheId = elementData.element.dataset.cacheId!;
+ const data = this.cache.get(cacheId)!;
+
+ switch (data.state) {
+ case State.Ready: {
+ this.popoverContent.appendChild(data.content!);
+
+ this.rebuild();
+
+ break;
+ }
+
+ case State.None: {
+ data.state = State.Loading;
+
+ const handler = this.handlers.get(elementData.identifier)!;
+ if (handler.loadCallback) {
+ handler.loadCallback(elementData.objectId, this, elementData.element);
+ } else if (handler.dboAction) {
+ const callback = (data) => {
+ this.setContent(elementData.identifier, elementData.objectId, data.returnValues.template);
+
+ return true;
+ };
+
+ this.ajaxApi(
+ {
+ actionName: "getPopover",
+ className: handler.dboAction,
+ interfaceName: "wcf\\data\\IPopoverAction",
+ objectIDs: [elementData.objectId],
+ },
+ callback,
+ callback,
+ );
+ }
+
+ break;
+ }
+
+ case State.Loading: {
+ // Do not interrupt inflight requests.
+ break;
+ }
+ }
+ }
+
+ /**
+ * Hides the popover element.
+ */
+ private hidePopover(): void {
+ if (this.timerLeave) {
+ window.clearTimeout(this.timerLeave);
+ this.timerLeave = undefined;
+ }
+
+ this.popover.classList.remove("active");
+ }
+
+ /**
+ * Clears popover content by moving it back into the cache.
+ */
+ private clearContent(): void {
+ if (this.activeId && this.popoverContent.childElementCount && !this.popover.classList.contains("active")) {
+ const cacheId = this.elements.get(this.activeId)!.element.dataset.cacheId!;
+ const activeElData = this.cache.get(cacheId)!;
+ while (this.popoverContent.childNodes.length) {
+ activeElData.content!.appendChild(this.popoverContent.childNodes[0]);
+ }
+ }
+ }
+
+ /**
+ * Rebuilds the popover.
+ */
+ private rebuild(): void {
+ if (this.popover.classList.contains("active")) {
+ return;
+ }
+
+ this.popover.classList.remove("forceHide");
+ this.popover.classList.add("active");
+
+ UiAlignment.set(this.popover, this.elements.get(this.activeId)!.element, {
+ pointer: true,
+ vertical: "top",
+ });
+ }
+
+ _ajaxSuccess() {
+ // This class was designed in a strange way without utilizing this method.
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ silent: true,
+ };
+ }
+
+ /**
+ * Sends an AJAX requests to the server, simple wrapper to reuse the request object.
+ */
+ ajaxApi(data: RequestPayload, success: CallbackSuccess, failure: CallbackFailure): void {
+ if (typeof success !== "function") {
+ throw new TypeError("Expected a valid callback for parameter 'success'.");
+ }
+
+ Ajax.api(this, data, success, failure);
+ }
+}
+
+let controllerPopover: ControllerPopover;
+
+function getControllerPopover(): ControllerPopover {
+ if (!controllerPopover) {
+ controllerPopover = new ControllerPopover();
+ }
+
+ return controllerPopover;
+}
+
+/**
+ * Initializes a popover handler.
+ *
+ * Usage:
+ *
+ * ControllerPopover.init({
+ * attributeName: 'data-object-id',
+ * className: 'fooLink',
+ * identifier: 'com.example.bar.foo',
+ * loadCallback: function(objectId, popover) {
+ * // request data for object id (e.g. via WoltLabSuite/Core/Ajax)
+ *
+ * // then call this to set the content
+ * popover.setContent('com.example.bar.foo', objectId, htmlTemplateString);
+ * }
+ * });
+ */
+export function init(options: PopoverOptions): void {
+ getControllerPopover().init(options);
+}
+
+/**
+ * Sets the content for given identifier and object id.
+ */
+export function setContent(identifier: string, objectId: number, content: string): void {
+ getControllerPopover().setContent(identifier, objectId, content);
+}
+
+/**
+ * Sends an AJAX requests to the server, simple wrapper to reuse the request object.
+ */
+export function ajaxApi(data: RequestPayload, success: CallbackSuccess, failure: CallbackFailure): void {
+ getControllerPopover().ajaxApi(data, success, failure);
+}
--- /dev/null
+/**
+ * Dialog based style changer.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Controller/Style/Changer
+ */
+
+import * as Ajax from "../../Ajax";
+import * as Language from "../../Language";
+import UiDialog from "../../Ui/Dialog";
+import { DialogCallbackSetup } from "../../Ui/Dialog/Data";
+
+class ControllerStyleChanger {
+ /**
+ * Adds the style changer to the bottom navigation.
+ */
+ constructor() {
+ document.querySelectorAll(".jsButtonStyleChanger").forEach((link: HTMLAnchorElement) => {
+ link.addEventListener("click", (ev) => this.showDialog(ev));
+ });
+ }
+
+ /**
+ * Loads and displays the style change dialog.
+ */
+ showDialog(event: MouseEvent): void {
+ event.preventDefault();
+
+ UiDialog.open(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "styleChanger",
+ options: {
+ disableContentPadding: true,
+ title: Language.get("wcf.style.changeStyle"),
+ },
+ source: {
+ data: {
+ actionName: "getStyleChooser",
+ className: "wcf\\data\\style\\StyleAction",
+ },
+ after: (content) => {
+ content.querySelectorAll(".styleList > li").forEach((style: HTMLLIElement) => {
+ style.classList.add("pointer");
+ style.addEventListener("click", (ev) => this.click(ev));
+ });
+ },
+ },
+ };
+ }
+
+ /**
+ * Changes the style and reloads current page.
+ */
+ private click(event: MouseEvent): void {
+ event.preventDefault();
+
+ const listElement = event.currentTarget as HTMLLIElement;
+
+ Ajax.apiOnce({
+ data: {
+ actionName: "changeStyle",
+ className: "wcf\\data\\style\\StyleAction",
+ objectIDs: [listElement.dataset.styleId],
+ },
+ success: function () {
+ window.location.reload();
+ },
+ });
+ }
+}
+
+let controllerStyleChanger: ControllerStyleChanger;
+
+/**
+ * Adds the style changer to the bottom navigation.
+ */
+export function setup(): void {
+ if (!controllerStyleChanger) {
+ new ControllerStyleChanger();
+ }
+}
+
+/**
+ * Loads and displays the style change dialog.
+ */
+export function showDialog(event: MouseEvent): void {
+ controllerStyleChanger.showDialog(event);
+}
--- /dev/null
+/**
+ * Handles email notification type for user notification settings.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Controller/User/Notification/Settings
+ */
+
+import * as Language from "../../../Language";
+import * as UiDropdownReusable from "../../../Ui/Dropdown/Reusable";
+
+let _dropDownMenu: HTMLUListElement;
+let _objectId = 0;
+
+function stateChange(event: Event): void {
+ const checkbox = event.currentTarget as HTMLInputElement;
+
+ const objectId = ~~checkbox.dataset.objectId!;
+ const emailSettingsType = document.querySelector(`.notificationSettingsEmailType[data-object-id="${objectId}"]`);
+ if (emailSettingsType !== null) {
+ if (checkbox.checked) {
+ emailSettingsType.classList.remove("disabled");
+ } else {
+ emailSettingsType.classList.add("disabled");
+ }
+ }
+}
+
+function click(event: Event): void {
+ event.preventDefault();
+
+ const button = event.currentTarget as HTMLAnchorElement;
+ _objectId = ~~button.dataset.objectId!;
+
+ createDropDown();
+
+ setCurrentEmailType(getCurrentEmailTypeInputElement().value);
+
+ showDropDown(button);
+}
+
+function createDropDown(): void {
+ if (_dropDownMenu) {
+ return;
+ }
+
+ _dropDownMenu = document.createElement("ul");
+ _dropDownMenu.className = "dropdownMenu";
+
+ ["instant", "daily", "divider", "none"].forEach((value) => {
+ const listItem = document.createElement("li");
+ if (value === "divider") {
+ listItem.className = "dropdownDivider";
+ } else {
+ const link = document.createElement("a");
+ link.href = "#";
+ link.textContent = Language.get(`wcf.user.notification.mailNotificationType.${value}`);
+ listItem.appendChild(link);
+ listItem.dataset.value = value;
+ listItem.addEventListener("click", (ev) => setEmailType(ev));
+ }
+
+ _dropDownMenu.appendChild(listItem);
+ });
+
+ UiDropdownReusable.init("UiNotificationSettingsEmailType", _dropDownMenu);
+}
+
+function setCurrentEmailType(currentValue: string): void {
+ _dropDownMenu.querySelectorAll("li").forEach((button) => {
+ const value = button.dataset.value!;
+ if (value === currentValue) {
+ button.classList.add("active");
+ } else {
+ button.classList.remove("active");
+ }
+ });
+}
+
+function showDropDown(referenceElement: HTMLAnchorElement): void {
+ UiDropdownReusable.toggleDropdown("UiNotificationSettingsEmailType", referenceElement);
+}
+
+function setEmailType(event: Event): void {
+ event.preventDefault();
+
+ const listItem = event.currentTarget as HTMLLIElement;
+ const value = listItem.dataset.value!;
+
+ getCurrentEmailTypeInputElement().value = value;
+
+ const button = document.querySelector(
+ `.notificationSettingsEmailType[data-object-id="${_objectId}"]`,
+ ) as HTMLLIElement;
+ button.title = Language.get(`wcf.user.notification.mailNotificationType.${value}`);
+
+ const icon = button.querySelector(".jsIconNotificationSettingsEmailType") as HTMLSpanElement;
+ icon.classList.remove("fa-clock-o", "fa-flash", "fa-times", "green", "red");
+
+ switch (value) {
+ case "daily":
+ icon.classList.add("fa-clock-o", "green");
+ break;
+
+ case "instant":
+ icon.classList.add("fa-flash", "green");
+ break;
+
+ case "none":
+ icon.classList.add("fa-times", "red");
+ break;
+ }
+
+ _objectId = 0;
+}
+
+function getCurrentEmailTypeInputElement(): HTMLInputElement {
+ return document.getElementById(`settings_${_objectId}_mailNotificationType`) as HTMLInputElement;
+}
+
+/**
+ * Binds event listeners for all notifications supporting emails.
+ */
+export function init(): void {
+ document.querySelectorAll(".jsCheckboxNotificationSettingsState").forEach((checkbox) => {
+ checkbox.addEventListener("change", (ev) => stateChange(ev));
+ });
+
+ document.querySelectorAll(".notificationSettingsEmailType").forEach((button) => {
+ button.addEventListener("click", (ev) => click(ev));
+ });
+}
--- /dev/null
+/**
+ * Provides the basic core functionality.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Core (alias)
+ * @module WoltLabSuite/Core/Core
+ */
+
+const _clone = function (variable: any): any {
+ if (typeof variable === "object" && (Array.isArray(variable) || isPlainObject(variable))) {
+ return _cloneObject(variable);
+ }
+
+ return variable;
+};
+
+const _cloneObject = function (obj: object | any[]): object | any[] | null {
+ if (!obj) {
+ return null;
+ }
+
+ if (Array.isArray(obj)) {
+ return obj.slice();
+ }
+
+ const newObj = {};
+ Object.keys(obj).forEach((key) => (newObj[key] = _clone(obj[key])));
+
+ return newObj;
+};
+
+const _prefix = "wsc" + window.WCF_PATH.hashCode() + "-";
+
+/**
+ * Deep clones an object.
+ */
+export function clone(obj: object | any[]): object | any[] {
+ return _clone(obj);
+}
+
+/**
+ * Converts WCF 2.0-style URLs into the default URL layout.
+ */
+export function convertLegacyUrl(url: string): string {
+ return url.replace(/^index\.php\/(.*?)\/\?/, (match: string, controller: string) => {
+ const parts = controller.split(/([A-Z][a-z0-9]+)/);
+ controller = "";
+ for (let i = 0, length = parts.length; i < length; i++) {
+ const part = parts[i].trim();
+ if (part.length) {
+ if (controller.length) {
+ controller += "-";
+ }
+ controller += part.toLowerCase();
+ }
+ }
+
+ return `index.php?${controller}/&`;
+ });
+}
+
+/**
+ * Merges objects with the first argument.
+ *
+ * @param {object} out destination object
+ * @param {...object} args variable number of objects to be merged into the destination object
+ * @return {object} destination object with all provided objects merged into
+ */
+export function extend(out: object, ...args: object[]): object {
+ out = out || {};
+ const newObj = clone(out);
+
+ for (let i = 0, length = args.length; i < length; i++) {
+ const obj = args[i];
+
+ if (!obj) {
+ continue;
+ }
+
+ Object.keys(obj).forEach((key) => {
+ if (!Array.isArray(obj[key]) && typeof obj[key] === "object") {
+ if (isPlainObject(obj[key])) {
+ // object literals have the prototype of Object which in return has no parent prototype
+ newObj[key] = extend(out[key], obj[key]);
+ } else {
+ newObj[key] = obj[key];
+ }
+ } else {
+ newObj[key] = obj[key];
+ }
+ });
+ }
+
+ return newObj;
+}
+
+/**
+ * Inherits the prototype methods from one constructor to another
+ * constructor.
+ *
+ * Usage:
+ *
+ * function MyDerivedClass() {}
+ * Core.inherit(MyDerivedClass, TheAwesomeBaseClass, {
+ * // regular prototype for `MyDerivedClass`
+ *
+ * overwrittenMethodFromBaseClass: function(foo, bar) {
+ * // do stuff
+ *
+ * // invoke parent
+ * MyDerivedClass._super.prototype.overwrittenMethodFromBaseClass.call(this, foo, bar);
+ * }
+ * });
+ *
+ * @see https://github.com/nodejs/node/blob/7d14dd9b5e78faabb95d454a79faa513d0bbc2a5/lib/util.js#L697-L735
+ * @deprecated 5.4 Use the native `class` and `extends` keywords instead.
+ */
+export function inherit(constructor: new () => any, superConstructor: new () => any, propertiesObject: object): void {
+ if (constructor === undefined || constructor === null) {
+ throw new TypeError("The constructor must not be undefined or null.");
+ }
+ if (superConstructor === undefined || superConstructor === null) {
+ throw new TypeError("The super constructor must not be undefined or null.");
+ }
+ if (superConstructor.prototype === undefined) {
+ throw new TypeError("The super constructor must have a prototype.");
+ }
+
+ (constructor as any)._super = superConstructor;
+ constructor.prototype = extend(
+ Object.create(superConstructor.prototype, {
+ constructor: {
+ configurable: true,
+ enumerable: false,
+ value: constructor,
+ writable: true,
+ },
+ }),
+ propertiesObject || {},
+ );
+}
+
+/**
+ * Returns true if `obj` is an object literal.
+ */
+export function isPlainObject(obj: unknown): boolean {
+ if (typeof obj !== "object" || obj === null) {
+ return false;
+ }
+
+ return Object.getPrototypeOf(obj) === Object.prototype;
+}
+
+/**
+ * Returns the object's class name.
+ */
+export function getType(obj: object): string {
+ return Object.prototype.toString.call(obj).replace(/^\[object (.+)]$/, "$1");
+}
+
+/**
+ * Returns a RFC4122 version 4 compilant UUID.
+ *
+ * @see http://stackoverflow.com/a/2117523
+ */
+export function getUuid(): string {
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0,
+ v = c == "x" ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+}
+
+/**
+ * Recursively serializes an object into an encoded URI parameter string.
+ */
+export function serialize(obj: object, prefix?: string): string {
+ if (obj === null) {
+ return "";
+ }
+
+ const parameters: string[] = [];
+ Object.keys(obj).forEach((key) => {
+ const parameterKey = prefix ? prefix + "[" + key + "]" : key;
+ const value = obj[key];
+
+ if (typeof value === "object") {
+ parameters.push(serialize(value, parameterKey));
+ } else {
+ parameters.push(encodeURIComponent(parameterKey) + "=" + encodeURIComponent(value));
+ }
+ });
+
+ return parameters.join("&");
+}
+
+/**
+ * Triggers a custom or built-in event.
+ */
+export function triggerEvent(element: EventTarget, eventName: string): void {
+ if (eventName === "click" && element instanceof HTMLElement) {
+ element.click();
+ return;
+ }
+
+ const event = new Event(eventName, {
+ bubbles: true,
+ cancelable: true,
+ });
+
+ element.dispatchEvent(event);
+}
+
+/**
+ * Returns the unique prefix for the localStorage.
+ */
+export function getStoragePrefix(): string {
+ return _prefix;
+}
+
+/**
+ * Interprets a string value as a boolean value similar to the behavior of the
+ * legacy functions `elAttrBool()` and `elDataBool()`.
+ */
+export function stringToBool(value: string | null): boolean {
+ return value === "1" || value === "true";
+}
+
+type DebounceCallback = (...args: any[]) => void;
+
+interface DebounceOptions {
+ isImmediate: boolean;
+}
+
+/**
+ * A function that emits a side effect and does not return anything.
+ *
+ * @see https://github.com/chodorowicz/ts-debounce/blob/62f30f2c3379b7b5e778fb1793e1fbfa17354894/src/index.ts
+ */
+export function debounce<F extends DebounceCallback>(
+ func: F,
+ waitMilliseconds = 50,
+ options: DebounceOptions = {
+ isImmediate: false,
+ },
+): (this: ThisParameterType<F>, ...args: Parameters<F>) => void {
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
+
+ return function (this: ThisParameterType<F>, ...args: Parameters<F>) {
+ const doLater = () => {
+ timeoutId = undefined;
+ if (!options.isImmediate) {
+ func.apply(this, args);
+ }
+ };
+
+ const shouldCallNow = options.isImmediate && timeoutId === undefined;
+
+ if (timeoutId !== undefined) {
+ clearTimeout(timeoutId);
+ }
+
+ timeoutId = setTimeout(doLater, waitMilliseconds);
+
+ if (shouldCallNow) {
+ func.apply(this, args);
+ }
+ };
+}
+
+export function enableLegacyInheritance<T>(legacyClass: T): void {
+ (legacyClass as any).call = function (thisValue, ...args) {
+ const constructed = Reflect.construct(legacyClass as any, args, thisValue.constructor);
+ Object.entries(constructed).forEach(([key, value]) => {
+ thisValue[key] = value;
+ });
+ };
+}
--- /dev/null
+/**
+ * Date picker with time support.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Date/Picker
+ */
+
+import * as Core from "../Core";
+import * as DateUtil from "./Util";
+import DomChangeListener from "../Dom/Change/Listener";
+import * as EventHandler from "../Event/Handler";
+import * as Language from "../Language";
+import * as UiAlignment from "../Ui/Alignment";
+import UiCloseOverlay from "../Ui/CloseOverlay";
+import DomUtil from "../Dom/Util";
+
+let _didInit = false;
+let _firstDayOfWeek = 0;
+let _wasInsidePicker = false;
+
+const _data = new Map<HTMLInputElement, DatePickerData>();
+let _input: HTMLInputElement | null = null;
+let _maxDate: Date;
+let _minDate: Date;
+
+const _dateCells: HTMLAnchorElement[] = [];
+let _dateGrid: HTMLUListElement;
+let _dateHour: HTMLSelectElement;
+let _dateMinute: HTMLSelectElement;
+let _dateMonth: HTMLSelectElement;
+let _dateMonthNext: HTMLAnchorElement;
+let _dateMonthPrevious: HTMLAnchorElement;
+let _dateTime: HTMLElement;
+let _dateYear: HTMLSelectElement;
+let _datePicker: HTMLElement | null = null;
+
+/**
+ * Creates the date picker DOM.
+ */
+function createPicker() {
+ if (_datePicker !== null) {
+ return;
+ }
+
+ _datePicker = document.createElement("div");
+ _datePicker.className = "datePicker";
+ _datePicker.addEventListener("click", (event) => {
+ event.stopPropagation();
+ });
+
+ const header = document.createElement("header");
+ _datePicker.appendChild(header);
+
+ _dateMonthPrevious = document.createElement("a");
+ _dateMonthPrevious.className = "previous jsTooltip";
+ _dateMonthPrevious.href = "#";
+ _dateMonthPrevious.setAttribute("role", "button");
+ _dateMonthPrevious.tabIndex = 0;
+ _dateMonthPrevious.title = Language.get("wcf.date.datePicker.previousMonth");
+ _dateMonthPrevious.setAttribute("aria-label", Language.get("wcf.date.datePicker.previousMonth"));
+ _dateMonthPrevious.innerHTML = '<span class="icon icon16 fa-arrow-left"></span>';
+ _dateMonthPrevious.addEventListener("click", (ev) => DatePicker.previousMonth(ev));
+ header.appendChild(_dateMonthPrevious);
+
+ const monthYearContainer = document.createElement("span");
+ header.appendChild(monthYearContainer);
+
+ _dateMonth = document.createElement("select");
+ _dateMonth.className = "month jsTooltip";
+ _dateMonth.title = Language.get("wcf.date.datePicker.month");
+ _dateMonth.setAttribute("aria-label", Language.get("wcf.date.datePicker.month"));
+ _dateMonth.addEventListener("change", changeMonth);
+ monthYearContainer.appendChild(_dateMonth);
+
+ let months = "";
+ const monthNames = Language.get("__monthsShort");
+ for (let i = 0; i < 12; i++) {
+ months += `<option value="${i}">${monthNames[i]}</option>`;
+ }
+ _dateMonth.innerHTML = months;
+
+ _dateYear = document.createElement("select");
+ _dateYear.className = "year jsTooltip";
+ _dateYear.title = Language.get("wcf.date.datePicker.year");
+ _dateYear.setAttribute("aria-label", Language.get("wcf.date.datePicker.year"));
+ _dateYear.addEventListener("change", changeYear);
+ monthYearContainer.appendChild(_dateYear);
+
+ _dateMonthNext = document.createElement("a");
+ _dateMonthNext.className = "next jsTooltip";
+ _dateMonthNext.href = "#";
+ _dateMonthNext.setAttribute("role", "button");
+ _dateMonthNext.tabIndex = 0;
+ _dateMonthNext.title = Language.get("wcf.date.datePicker.nextMonth");
+ _dateMonthNext.setAttribute("aria-label", Language.get("wcf.date.datePicker.nextMonth"));
+ _dateMonthNext.innerHTML = '<span class="icon icon16 fa-arrow-right"></span>';
+ _dateMonthNext.addEventListener("click", (ev) => DatePicker.nextMonth(ev));
+ header.appendChild(_dateMonthNext);
+
+ _dateGrid = document.createElement("ul");
+ _datePicker.appendChild(_dateGrid);
+
+ const item = document.createElement("li");
+ item.className = "weekdays";
+ _dateGrid.appendChild(item);
+
+ const weekdays = Language.get("__daysShort");
+ for (let i = 0; i < 7; i++) {
+ let day = i + _firstDayOfWeek;
+ if (day > 6) {
+ day -= 7;
+ }
+
+ const span = document.createElement("span");
+ span.textContent = weekdays[day];
+ item.appendChild(span);
+ }
+
+ // create date grid
+ for (let i = 0; i < 6; i++) {
+ const row = document.createElement("li");
+ _dateGrid.appendChild(row);
+
+ for (let j = 0; j < 7; j++) {
+ const cell = document.createElement("a");
+ cell.addEventListener("click", click);
+ _dateCells.push(cell);
+
+ row.appendChild(cell);
+ }
+ }
+
+ _dateTime = document.createElement("footer");
+ _datePicker.appendChild(_dateTime);
+
+ _dateHour = document.createElement("select");
+ _dateHour.className = "hour";
+ _dateHour.title = Language.get("wcf.date.datePicker.hour");
+ _dateHour.setAttribute("aria-label", Language.get("wcf.date.datePicker.hour"));
+ _dateHour.addEventListener("change", formatValue);
+
+ const date = new Date(2000, 0, 1);
+ const timeFormat = Language.get("wcf.date.timeFormat").replace(/:/, "").replace(/[isu]/g, "");
+ let tmp = "";
+ for (let i = 0; i < 24; i++) {
+ date.setHours(i);
+
+ const value = DateUtil.format(date, timeFormat);
+ tmp += `<option value="${i}">${value}</option>`;
+ }
+ _dateHour.innerHTML = tmp;
+
+ _dateTime.appendChild(_dateHour);
+
+ _dateTime.appendChild(document.createTextNode("\u00A0:\u00A0"));
+
+ _dateMinute = document.createElement("select");
+ _dateMinute.className = "minute";
+ _dateMinute.title = Language.get("wcf.date.datePicker.minute");
+ _dateMinute.setAttribute("aria-label", Language.get("wcf.date.datePicker.minute"));
+ _dateMinute.addEventListener("change", formatValue);
+
+ tmp = "";
+ for (let i = 0; i < 60; i++) {
+ const value = i < 10 ? "0" + i.toString() : i;
+ tmp += `<option value="${i}">${value}</option>`;
+ }
+ _dateMinute.innerHTML = tmp;
+
+ _dateTime.appendChild(_dateMinute);
+
+ document.body.appendChild(_datePicker);
+
+ document.body.addEventListener("focus", maintainFocus, { capture: true });
+}
+
+/**
+ * Initializes the minimum/maximum date range.
+ */
+function initDateRange(element: HTMLInputElement, now: Date, isMinDate: boolean): void {
+ const name = isMinDate ? "minDate" : "maxDate";
+ let value = (element.dataset[name] || "").trim();
+
+ if (/^(\d{4})-(\d{2})-(\d{2})$/.exec(value)) {
+ // YYYY-mm-dd
+ value = new Date(value).getTime().toString();
+ } else if (value === "now") {
+ value = now.getTime().toString();
+ } else if (/^\d{1,3}$/.exec(value)) {
+ // relative time span in years
+ const date = new Date(now.getTime());
+ date.setFullYear(date.getFullYear() + ~~value * (isMinDate ? -1 : 1));
+
+ value = date.getTime().toString();
+ } else if (/^datePicker-(.+)$/.exec(value)) {
+ // element id, e.g. `datePicker-someOtherElement`
+ value = RegExp.$1;
+
+ if (document.getElementById(value) === null) {
+ throw new Error(
+ "Reference date picker identified by '" + value + "' does not exists (element id: '" + element.id + "').",
+ );
+ }
+ } else if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
+ value = new Date(value).getTime().toString();
+ } else {
+ value = new Date(isMinDate ? 1902 : 2038, 0, 1).getTime().toString();
+ }
+
+ element.dataset[name] = value;
+}
+
+/**
+ * Sets up callbacks and event listeners.
+ */
+function setup() {
+ if (_didInit) {
+ return;
+ }
+ _didInit = true;
+
+ _firstDayOfWeek = parseInt(Language.get("wcf.date.firstDayOfTheWeek"), 10);
+
+ DomChangeListener.add("WoltLabSuite/Core/Date/Picker", () => DatePicker.init());
+ UiCloseOverlay.add("WoltLabSuite/Core/Date/Picker", () => close());
+}
+
+function getDateValue(attributeName: string): Date {
+ let date = _input!.dataset[attributeName] || "";
+ if (/^datePicker-(.+)$/.exec(date)) {
+ const referenceElement = document.getElementById(RegExp.$1);
+ if (referenceElement === null) {
+ throw new Error(`Unable to find an element with the id '${RegExp.$1}'.`);
+ }
+ date = referenceElement.dataset.value || "";
+ }
+
+ return new Date(parseInt(date, 10));
+}
+
+/**
+ * Opens the date picker.
+ */
+function open(event: MouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+
+ createPicker();
+
+ const target = event.currentTarget as HTMLInputElement;
+ const input = target.nodeName === "INPUT" ? target : (target.previousElementSibling as HTMLInputElement);
+ if (input === _input) {
+ close();
+ return;
+ }
+
+ const dialogContent = input.closest(".dialogContent") as HTMLElement;
+ if (dialogContent !== null) {
+ if (!Core.stringToBool(dialogContent.dataset.hasDatepickerScrollListener || "")) {
+ dialogContent.addEventListener("scroll", onDialogScroll);
+ dialogContent.dataset.hasDatepickerScrollListener = "1";
+ }
+ }
+
+ _input = input;
+ const data = _data.get(_input) as DatePickerData;
+ const value = _input.dataset.value!;
+ let date: Date;
+ if (value) {
+ date = new Date(parseInt(value, 10));
+
+ if (date.toString() === "Invalid Date") {
+ date = new Date();
+ }
+ } else {
+ date = new Date();
+ }
+
+ // set min/max date
+ _minDate = getDateValue("minDate");
+ if (_minDate.getTime() > date.getTime()) {
+ date = _minDate;
+ }
+
+ _maxDate = getDateValue("maxDate");
+
+ if (data.isDateTime) {
+ _dateHour.value = date.getHours().toString();
+ _dateMinute.value = date.getMinutes().toString();
+
+ _datePicker!.classList.add("datePickerTime");
+ } else {
+ _datePicker!.classList.remove("datePickerTime");
+ }
+
+ _datePicker!.classList[data.isTimeOnly ? "add" : "remove"]("datePickerTimeOnly");
+
+ renderPicker(date.getDate(), date.getMonth(), date.getFullYear());
+
+ UiAlignment.set(_datePicker!, _input);
+
+ _input.nextElementSibling!.setAttribute("aria-expanded", "true");
+
+ _wasInsidePicker = false;
+}
+
+/**
+ * Closes the date picker.
+ */
+function close() {
+ if (_datePicker === null || !_datePicker.classList.contains("active")) {
+ return;
+ }
+
+ _datePicker.classList.remove("active");
+
+ const data = _data.get(_input!) as DatePickerData;
+ if (typeof data.onClose === "function") {
+ data.onClose();
+ }
+
+ EventHandler.fire("WoltLabSuite/Core/Date/Picker", "close", { element: _input });
+
+ const sibling = _input!.nextElementSibling as HTMLElement;
+ sibling.setAttribute("aria-expanded", "false");
+ _input = null;
+}
+
+/**
+ * Updates the position of the date picker in a dialog if the dialog content
+ * is scrolled.
+ */
+function onDialogScroll(event: WheelEvent): void {
+ if (_input === null) {
+ return;
+ }
+
+ const dialogContent = event.currentTarget as HTMLElement;
+
+ const offset = DomUtil.offset(_input);
+ const dialogOffset = DomUtil.offset(dialogContent);
+
+ // check if date picker input field is still (partially) visible
+ if (offset.top + _input.clientHeight <= dialogOffset.top) {
+ // top check
+ close();
+ } else if (offset.top >= dialogOffset.top + dialogContent.offsetHeight) {
+ // bottom check
+ close();
+ } else if (offset.left <= dialogOffset.left) {
+ // left check
+ close();
+ } else if (offset.left >= dialogOffset.left + dialogContent.offsetWidth) {
+ // right check
+ close();
+ } else {
+ UiAlignment.set(_datePicker!, _input);
+ }
+}
+
+/**
+ * Renders the full picker on init.
+ */
+function renderPicker(day: number, month: number, year: number): void {
+ renderGrid(day, month, year);
+
+ // create options for month and year
+ let years = "";
+ for (let i = _minDate.getFullYear(), last = _maxDate.getFullYear(); i <= last; i++) {
+ years += `<option value="${i}">${i}</option>`;
+ }
+ _dateYear.innerHTML = years;
+ _dateYear.value = year.toString();
+
+ _dateMonth.value = month.toString();
+
+ _datePicker!.classList.add("active");
+}
+
+/**
+ * Updates the date grid.
+ */
+function renderGrid(day?: number, month?: number, year?: number): void {
+ const hasDay = day !== undefined;
+ const hasMonth = month !== undefined;
+
+ if (typeof day !== "number") {
+ day = parseInt(day || _dateGrid.dataset.day || "0", 10);
+ }
+ if (typeof month !== "number") {
+ month = parseInt(month || "0", 10);
+ }
+ if (typeof year !== "number") {
+ year = parseInt(year || "0", 10);
+ }
+
+ // rebuild cells
+ if (hasMonth || year) {
+ let rebuildMonths = year !== 0;
+
+ // rebuild grid
+ const fragment = document.createDocumentFragment();
+ fragment.appendChild(_dateGrid);
+
+ if (!hasMonth) {
+ month = parseInt(_dateGrid.dataset.month!, 10);
+ }
+ if (!year) {
+ year = parseInt(_dateGrid.dataset.year!, 10);
+ }
+
+ // check if current selection exceeds min/max date
+ let date = new Date(
+ year.toString() + "-" + ("0" + (month + 1).toString()).slice(-2) + "-" + ("0" + day.toString()).slice(-2),
+ );
+ if (date < _minDate) {
+ year = _minDate.getFullYear();
+ month = _minDate.getMonth();
+ day = _minDate.getDate();
+
+ _dateMonth.value = month.toString();
+ _dateYear.value = year.toString();
+
+ rebuildMonths = true;
+ } else if (date > _maxDate) {
+ year = _maxDate.getFullYear();
+ month = _maxDate.getMonth();
+ day = _maxDate.getDate();
+
+ _dateMonth.value = month.toString();
+ _dateYear.value = year.toString();
+
+ rebuildMonths = true;
+ }
+
+ date = new Date(year.toString() + "-" + ("0" + (month + 1).toString()).slice(-2) + "-01");
+
+ // shift until first displayed day equals first day of week
+ while (date.getDay() !== _firstDayOfWeek) {
+ date.setDate(date.getDate() - 1);
+ }
+
+ // show the last row
+ DomUtil.show(_dateCells[35].parentNode as HTMLElement);
+
+ let selectable: boolean;
+ const comparableMinDate = new Date(_minDate.getFullYear(), _minDate.getMonth(), _minDate.getDate());
+ for (let i = 0; i < 42; i++) {
+ if (i === 35 && date.getMonth() !== month) {
+ // skip the last row if it only contains the next month
+ DomUtil.hide(_dateCells[35].parentNode as HTMLElement);
+
+ break;
+ }
+
+ const cell = _dateCells[i];
+
+ cell.textContent = date.getDate().toString();
+ selectable = date.getMonth() === month;
+ if (selectable) {
+ if (date < comparableMinDate) {
+ selectable = false;
+ } else if (date > _maxDate) {
+ selectable = false;
+ }
+ }
+
+ cell.classList[selectable ? "remove" : "add"]("otherMonth");
+ if (selectable) {
+ cell.href = "#";
+ cell.setAttribute("role", "button");
+ cell.tabIndex = 0;
+ cell.title = DateUtil.formatDate(date);
+ cell.setAttribute("aria-label", DateUtil.formatDate(date));
+ }
+
+ date.setDate(date.getDate() + 1);
+ }
+
+ _dateGrid.dataset.month = month.toString();
+ _dateGrid.dataset.year = year.toString();
+
+ _datePicker!.insertBefore(fragment, _dateTime);
+
+ if (!hasDay) {
+ // check if date is valid
+ date = new Date(year, month, day);
+ if (date.getDate() !== day) {
+ while (date.getMonth() !== month) {
+ date.setDate(date.getDate() - 1);
+ }
+
+ day = date.getDate();
+ }
+ }
+
+ if (rebuildMonths) {
+ for (let i = 0; i < 12; i++) {
+ const currentMonth = _dateMonth.children[i] as HTMLOptionElement;
+
+ currentMonth.disabled =
+ (year === _minDate.getFullYear() && +currentMonth.value < _minDate.getMonth()) ||
+ (year === _maxDate.getFullYear() && +currentMonth.value > _maxDate.getMonth());
+ }
+
+ const nextMonth = new Date(year.toString() + "-" + ("0" + (month + 1).toString()).slice(-2) + "-01");
+ nextMonth.setMonth(nextMonth.getMonth() + 1);
+
+ _dateMonthNext.classList[nextMonth < _maxDate ? "add" : "remove"]("active");
+
+ const previousMonth = new Date(year.toString() + "-" + ("0" + (month + 1).toString()).slice(-2) + "-01");
+ previousMonth.setDate(previousMonth.getDate() - 1);
+
+ _dateMonthPrevious.classList[previousMonth > _minDate ? "add" : "remove"]("active");
+ }
+ }
+
+ // update active day
+ if (day) {
+ for (let i = 0; i < 35; i++) {
+ const cell = _dateCells[i];
+
+ cell.classList[!cell.classList.contains("otherMonth") && +cell.textContent! === day ? "add" : "remove"]("active");
+ }
+
+ _dateGrid.dataset.day = day.toString();
+ }
+
+ formatValue();
+}
+
+/**
+ * Sets the visible and shadow value
+ */
+function formatValue(): void {
+ const data = _data.get(_input!) as DatePickerData;
+ let date: Date;
+
+ if (Core.stringToBool(_input!.dataset.empty || "")) {
+ return;
+ }
+
+ if (data.isDateTime) {
+ date = new Date(
+ +_dateGrid.dataset.year!,
+ +_dateGrid.dataset.month!,
+ +_dateGrid.dataset.day!,
+ +_dateHour.value,
+ +_dateMinute.value,
+ );
+ } else {
+ date = new Date(+_dateGrid.dataset.year!, +_dateGrid.dataset.month!, +_dateGrid.dataset.day!);
+ }
+
+ DatePicker.setDate(_input!, date);
+}
+
+/**
+ * Handles changes to the month select element.
+ */
+function changeMonth(event: Event): void {
+ const target = event.currentTarget as HTMLSelectElement;
+ renderGrid(undefined, +target.value);
+}
+
+/**
+ * Handles changes to the year select element.
+ */
+function changeYear(event: Event): void {
+ const target = event.currentTarget as HTMLSelectElement;
+ renderGrid(undefined, undefined, +target.value);
+}
+
+/**
+ * Handles clicks on an individual day.
+ */
+function click(event: MouseEvent): void {
+ event.preventDefault();
+
+ const target = event.currentTarget as HTMLAnchorElement;
+ if (target.classList.contains("otherMonth")) {
+ return;
+ }
+
+ _input!.dataset.empty = "false";
+
+ renderGrid(+target.textContent!);
+
+ const data = _data.get(_input!) as DatePickerData;
+ if (!data.isDateTime) {
+ close();
+ }
+}
+
+/**
+ * Validates given element or id if it represents an active date picker.
+ */
+function getElement(element: InputElementOrString): HTMLInputElement {
+ if (typeof element === "string") {
+ element = document.getElementById(element) as HTMLInputElement;
+ }
+
+ if (!(element instanceof HTMLInputElement) || !element.classList.contains("inputDatePicker") || !_data.has(element)) {
+ throw new Error("Expected a valid date picker input element or id.");
+ }
+
+ return element;
+}
+
+function maintainFocus(event: FocusEvent): void {
+ if (_datePicker === null || !_datePicker.classList.contains("active")) {
+ return;
+ }
+
+ if (!_datePicker.contains(event.target as HTMLElement)) {
+ if (_wasInsidePicker) {
+ const sibling = _input!.nextElementSibling as HTMLElement;
+ sibling.focus();
+ _wasInsidePicker = false;
+ } else {
+ _datePicker.querySelector<HTMLElement>(".previous")!.focus();
+ }
+ } else {
+ _wasInsidePicker = true;
+ }
+}
+
+const DatePicker = {
+ /**
+ * Initializes all date and datetime input fields.
+ */
+ init(): void {
+ setup();
+
+ const now = new Date();
+ document
+ .querySelectorAll<HTMLInputElement>(
+ 'input[type="date"]:not(.inputDatePicker), input[type="datetime"]:not(.inputDatePicker)',
+ )
+ .forEach((element) => {
+ element.classList.add("inputDatePicker");
+ element.readOnly = true;
+
+ // Use `getAttribute()`, because `.type` is normalized to "text" for unknown values.
+ const isDateTime = element.getAttribute("type") === "datetime";
+ const isTimeOnly = isDateTime && Core.stringToBool(element.dataset.timeOnly || "");
+ const disableClear = Core.stringToBool(element.dataset.disableClear || "");
+ const ignoreTimezone = isDateTime && Core.stringToBool(element.dataset.ignoreTimezone || "");
+ const isBirthday = element.classList.contains("birthday");
+
+ element.dataset.isDateTime = isDateTime ? "true" : "false";
+ element.dataset.isTimeOnly = isTimeOnly ? "true" : "false";
+
+ // convert value
+ let date: Date | null = null;
+ let value = element.value;
+ if (!value) {
+ // Some legacy code may incorrectly use `setAttribute("value", value)`.
+ value = element.getAttribute("value") || "";
+ }
+
+ // ignore the timezone, if the value is only a date (YYYY-MM-DD)
+ const isDateOnly = /^\d+-\d+-\d+$/.test(value);
+
+ if (value) {
+ if (isTimeOnly) {
+ date = new Date();
+ const tmp = value.split(":");
+ date.setHours(+tmp[0], +tmp[1]);
+ } else {
+ if (ignoreTimezone || isBirthday || isDateOnly) {
+ let timezoneOffset = new Date(value).getTimezoneOffset();
+ let timezone = timezoneOffset > 0 ? "-" : "+"; // -120 equals GMT+0200
+ timezoneOffset = Math.abs(timezoneOffset);
+
+ const hours = Math.floor(timezoneOffset / 60).toString();
+ const minutes = (timezoneOffset % 60).toString();
+ timezone += hours.length === 2 ? hours : "0" + hours;
+ timezone += ":";
+ timezone += minutes.length === 2 ? minutes : "0" + minutes;
+
+ if (isBirthday || isDateOnly) {
+ value += "T00:00:00" + timezone;
+ } else {
+ value = value.replace(/[+-][0-9]{2}:[0-9]{2}$/, timezone);
+ }
+ }
+
+ date = new Date(value);
+ }
+
+ const time = date.getTime();
+
+ // check for invalid dates
+ if (isNaN(time)) {
+ value = "";
+ } else {
+ element.dataset.value = time.toString();
+ if (isTimeOnly) {
+ value = DateUtil.formatTime(date);
+ } else {
+ if (isDateTime) {
+ value = DateUtil.formatDateTime(date);
+ } else {
+ value = DateUtil.formatDate(date);
+ }
+ }
+ }
+ }
+
+ const isEmpty = value.length === 0;
+
+ // handle birthday input
+ if (isBirthday) {
+ element.dataset.minDate = "120";
+
+ // do not use 'now' here, all though it makes sense, it causes bad UX
+ element.dataset.maxDate = new Date().getFullYear().toString() + "-12-31";
+ } else {
+ if (element.min) {
+ element.dataset.minDate = element.min;
+ }
+ if (element.max) {
+ element.dataset.maxDate = element.max;
+ }
+ }
+
+ initDateRange(element, now, true);
+ initDateRange(element, now, false);
+
+ if ((element.dataset.minDate || "") === (element.dataset.maxDate || "")) {
+ throw new Error("Minimum and maximum date cannot be the same (element id '" + element.id + "').");
+ }
+
+ // change type to prevent browser's datepicker to trigger
+ element.type = "text";
+ element.value = value;
+ element.dataset.empty = isEmpty ? "true" : "false";
+
+ const placeholder = element.dataset.placeholder || "";
+ if (placeholder) {
+ element.placeholder = placeholder;
+ }
+
+ // add a hidden element to hold the actual date
+ const shadowElement = document.createElement("input");
+ shadowElement.id = element.id + "DatePicker";
+ shadowElement.name = element.name;
+ shadowElement.type = "hidden";
+
+ if (date !== null) {
+ if (isTimeOnly) {
+ shadowElement.value = DateUtil.format(date, "H:i");
+ } else if (ignoreTimezone) {
+ shadowElement.value = DateUtil.format(date, "Y-m-dTH:i:s");
+ } else {
+ shadowElement.value = DateUtil.format(date, isDateTime ? "c" : "Y-m-d");
+ }
+ }
+
+ element.parentNode!.insertBefore(shadowElement, element);
+ element.removeAttribute("name");
+
+ element.addEventListener("click", open);
+
+ let clearButton: HTMLAnchorElement | null = null;
+ if (!element.disabled) {
+ // create input addon
+ const container = document.createElement("div");
+ container.className = "inputAddon";
+
+ clearButton = document.createElement("a");
+
+ clearButton.className = "inputSuffix button jsTooltip";
+ clearButton.href = "#";
+ clearButton.setAttribute("role", "button");
+ clearButton.tabIndex = 0;
+ clearButton.title = Language.get("wcf.date.datePicker");
+ clearButton.setAttribute("aria-label", Language.get("wcf.date.datePicker"));
+ clearButton.setAttribute("aria-haspopup", "true");
+ clearButton.setAttribute("aria-expanded", "false");
+ clearButton.addEventListener("click", open);
+ container.appendChild(clearButton);
+
+ let icon = document.createElement("span");
+ icon.className = "icon icon16 fa-calendar";
+ clearButton.appendChild(icon);
+
+ element.parentNode!.insertBefore(container, element);
+ container.insertBefore(element, clearButton);
+
+ if (!disableClear) {
+ const button = document.createElement("a");
+ button.className = "inputSuffix button";
+ button.addEventListener("click", this.clear.bind(this, element));
+ if (isEmpty) {
+ button.style.setProperty("visibility", "hidden", "");
+ }
+
+ container.appendChild(button);
+
+ icon = document.createElement("span");
+ icon.className = "icon icon16 fa-times";
+ button.appendChild(icon);
+ }
+ }
+
+ // check if the date input has one of the following classes set otherwise default to 'short'
+ const knownClasses = ["tiny", "short", "medium", "long"];
+ let hasClass = false;
+ for (let j = 0; j < 4; j++) {
+ if (element.classList.contains(knownClasses[j])) {
+ hasClass = true;
+ }
+ }
+
+ if (!hasClass) {
+ element.classList.add("short");
+ }
+
+ _data.set(element, {
+ clearButton,
+ shadow: shadowElement,
+
+ disableClear,
+ isDateTime,
+ isEmpty,
+ isTimeOnly,
+ ignoreTimezone,
+
+ onClose: null,
+ });
+ });
+ },
+
+ /**
+ * Shows the previous month.
+ */
+ previousMonth(event: MouseEvent): void {
+ event.preventDefault();
+
+ if (_dateMonth.value === "0") {
+ _dateMonth.value = "11";
+ _dateYear.value = (+_dateYear.value - 1).toString();
+ } else {
+ _dateMonth.value = (+_dateMonth.value - 1).toString();
+ }
+
+ renderGrid(undefined, +_dateMonth.value, +_dateYear.value);
+ },
+
+ /**
+ * Shows the next month.
+ */
+ nextMonth(event: MouseEvent): void {
+ event.preventDefault();
+
+ if (_dateMonth.value === "11") {
+ _dateMonth.value = "0";
+ _dateYear.value = (+_dateYear.value + 1).toString();
+ } else {
+ _dateMonth.value = (+_dateMonth.value + 1).toString();
+ }
+
+ renderGrid(undefined, +_dateMonth.value, +_dateYear.value);
+ },
+
+ /**
+ * Returns the current Date object or null.
+ */
+ getDate(element: InputElementOrString): Date | null {
+ element = getElement(element);
+
+ const value = element.dataset.value || "";
+ if (value) {
+ return new Date(+value);
+ }
+
+ return null;
+ },
+
+ /**
+ * Sets the date of given element.
+ *
+ * @param {(HTMLInputElement|string)} element input element or id
+ * @param {Date} date Date object
+ */
+ setDate(element: InputElementOrString, date: Date): void {
+ element = getElement(element);
+ const data = _data.get(element) as DatePickerData;
+
+ element.dataset.value = date.getTime().toString();
+
+ let format = "";
+ let value: string;
+ if (data.isDateTime) {
+ if (data.isTimeOnly) {
+ value = DateUtil.formatTime(date);
+ format = "H:i";
+ } else if (data.ignoreTimezone) {
+ value = DateUtil.formatDateTime(date);
+ format = "Y-m-dTH:i:s";
+ } else {
+ value = DateUtil.formatDateTime(date);
+ format = "c";
+ }
+ } else {
+ value = DateUtil.formatDate(date);
+ format = "Y-m-d";
+ }
+
+ element.value = value;
+ data.shadow.value = DateUtil.format(date, format);
+
+ // show clear button
+ if (!data.disableClear) {
+ data.clearButton!.style.removeProperty("visibility");
+ }
+ },
+
+ /**
+ * Returns the current value.
+ */
+ getValue(element: InputElementOrString): string {
+ element = getElement(element);
+ const data = _data.get(element);
+
+ if (data) {
+ return data.shadow.value;
+ }
+
+ return "";
+ },
+
+ /**
+ * Clears the date value of given element.
+ */
+ clear(element: InputElementOrString): void {
+ element = getElement(element);
+ const data = _data.get(element) as DatePickerData;
+
+ element.removeAttribute("data-value");
+ element.value = "";
+
+ if (!data.disableClear) {
+ data.clearButton!.style.setProperty("visibility", "hidden", "");
+ }
+
+ data.isEmpty = true;
+ data.shadow.value = "";
+ },
+
+ /**
+ * Reverts the date picker into a normal input field.
+ */
+ destroy(element: InputElementOrString): void {
+ element = getElement(element);
+ const data = _data.get(element) as DatePickerData;
+
+ const container = element.parentNode as HTMLElement;
+ container.parentNode!.insertBefore(element, container);
+ container.remove();
+
+ element.setAttribute("type", "date" + (data.isDateTime ? "time" : ""));
+ element.name = data.shadow.name;
+ element.value = data.shadow.value;
+
+ element.removeAttribute("data-value");
+ element.removeEventListener("click", open);
+ data.shadow.remove();
+
+ element.classList.remove("inputDatePicker");
+ element.readOnly = false;
+ _data.delete(element);
+ },
+
+ /**
+ * Sets the callback invoked on picker close.
+ */
+ setCloseCallback(element: InputElementOrString, callback: Callback): void {
+ element = getElement(element);
+ _data.get(element)!.onClose = callback;
+ },
+};
+
+// backward-compatibility for `$.ui.datepicker` shim
+window.__wcf_bc_datePicker = DatePicker;
+
+export = DatePicker;
+
+type InputElementOrString = HTMLInputElement | string;
+
+type Callback = () => void;
+
+interface DatePickerData {
+ clearButton: HTMLAnchorElement | null;
+ shadow: HTMLInputElement;
+
+ disableClear: boolean;
+ isDateTime: boolean;
+ isEmpty: boolean;
+ isTimeOnly: boolean;
+ ignoreTimezone: boolean;
+
+ onClose: Callback | null;
+}
--- /dev/null
+/**
+ * Transforms <time> elements to display the elapsed time relative to the current time.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Date/Time/Relative
+ */
+
+import * as Core from "../../Core";
+import * as DateUtil from "../Util";
+import DomChangeListener from "../../Dom/Change/Listener";
+import * as Language from "../../Language";
+import RepeatingTimer from "../../Timer/Repeating";
+
+let _isActive = true;
+let _isPending = false;
+let _offset: number;
+
+function onVisibilityChange(): void {
+ if (document.hidden) {
+ _isActive = false;
+ _isPending = false;
+ } else {
+ _isActive = true;
+
+ // force immediate refresh
+ if (_isPending) {
+ refresh();
+ _isPending = false;
+ }
+ }
+}
+
+function refresh() {
+ // activity is suspended while the tab is hidden, but force an
+ // immediate refresh once the page is active again
+ if (!_isActive) {
+ if (!_isPending) _isPending = true;
+ return;
+ }
+
+ const date = new Date();
+ const timestamp = (date.getTime() - date.getMilliseconds()) / 1_000;
+
+ document.querySelectorAll("time").forEach((element) => {
+ rebuild(element, date, timestamp);
+ });
+}
+
+function rebuild(element: HTMLTimeElement, date: Date, timestamp: number): void {
+ if (!element.classList.contains("datetime") || Core.stringToBool(element.dataset.isFutureDate || "")) {
+ return;
+ }
+
+ const elTimestamp = parseInt(element.dataset.timestamp!, 10) + _offset;
+ const elDate = element.dataset.date!;
+ const elTime = element.dataset.time!;
+ const elOffset = element.dataset.offset!;
+
+ if (!element.title) {
+ element.title = Language.get("wcf.date.dateTimeFormat")
+ .replace(/%date%/, elDate)
+ .replace(/%time%/, elTime);
+ }
+
+ // timestamp is less than 60 seconds ago
+ if (elTimestamp >= timestamp || timestamp < elTimestamp + 60) {
+ element.textContent = Language.get("wcf.date.relative.now");
+ }
+ // timestamp is less than 60 minutes ago (display 1 hour ago rather than 60 minutes ago)
+ else if (timestamp < elTimestamp + 3540) {
+ const minutes = Math.max(Math.round((timestamp - elTimestamp) / 60), 1);
+ element.textContent = Language.get("wcf.date.relative.minutes", { minutes: minutes });
+ }
+ // timestamp is less than 24 hours ago
+ else if (timestamp < elTimestamp + 86400) {
+ const hours = Math.round((timestamp - elTimestamp) / 3600);
+ element.textContent = Language.get("wcf.date.relative.hours", { hours: hours });
+ }
+ // timestamp is less than 6 days ago
+ else if (timestamp < elTimestamp + 518400) {
+ const midnight = new Date(date.getFullYear(), date.getMonth(), date.getDate());
+ const days = Math.ceil((midnight.getTime() / 1000 - elTimestamp) / 86400);
+
+ // get day of week
+ const dateObj = DateUtil.getTimezoneDate(elTimestamp * 1000, parseInt(elOffset, 10) * 1000);
+ const dow = dateObj.getDay();
+ const day = Language.get("__days")[dow];
+
+ element.textContent = Language.get("wcf.date.relative.pastDays", { days: days, day: day, time: elTime });
+ }
+ // timestamp is between ~700 million years BC and last week
+ else {
+ element.textContent = Language.get("wcf.date.shortDateTimeFormat")
+ .replace(/%date%/, elDate)
+ .replace(/%time%/, elTime);
+ }
+}
+
+/**
+ * Transforms <time> elements on init and binds event listeners.
+ */
+export function setup(): void {
+ _offset = Math.trunc(Date.now() / 1_000 - window.TIME_NOW);
+
+ new RepeatingTimer(refresh, 60_000);
+
+ DomChangeListener.add("WoltLabSuite/Core/Date/Time/Relative", refresh);
+
+ document.addEventListener("visibilitychange", onVisibilityChange);
+}
--- /dev/null
+/**
+ * Provides utility functions for date operations.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module DateUtil (alias)
+ * @module WoltLabSuite/Core/Date/Util
+ */
+
+import * as Language from "../Language";
+
+/**
+ * Returns the formatted date.
+ */
+export function formatDate(date: Date): string {
+ return format(date, Language.get("wcf.date.dateFormat"));
+}
+
+/**
+ * Returns the formatted time.
+ */
+export function formatTime(date: Date): string {
+ return format(date, Language.get("wcf.date.timeFormat"));
+}
+
+/**
+ * Returns the formatted date time.
+ */
+export function formatDateTime(date: Date): string {
+ const dateTimeFormat = Language.get("wcf.date.dateTimeFormat");
+ const dateFormat = Language.get("wcf.date.dateFormat");
+ const timeFormat = Language.get("wcf.date.timeFormat");
+
+ return format(date, dateTimeFormat.replace(/%date%/, dateFormat).replace(/%time%/, timeFormat));
+}
+
+/**
+ * Formats a date using PHP's `date()` modifiers.
+ */
+export function format(date: Date, format: string): string {
+ // ISO 8601 date, best recognition by PHP's strtotime()
+ if (format === "c") {
+ format = "Y-m-dTH:i:sP";
+ }
+
+ let out = "";
+ for (let i = 0, length = format.length; i < length; i++) {
+ let char: string;
+ switch (format[i]) {
+ // seconds
+ case "s":
+ // `00` through `59`
+ char = date.getSeconds().toString().padStart(2, "0");
+ break;
+
+ // minutes
+ case "i":
+ // `00` through `59`
+ char = date.getMinutes().toString().padStart(2, "0");
+ break;
+
+ // hours
+ case "a":
+ // `am` or `pm`
+ char = date.getHours() > 11 ? "pm" : "am";
+ break;
+ case "g": {
+ // `1` through `12`
+ const hours = date.getHours();
+ if (hours === 0) {
+ char = "12";
+ } else if (hours > 12) {
+ char = (hours - 12).toString();
+ } else {
+ char = hours.toString();
+ }
+
+ break;
+ }
+ case "h": {
+ // `01` through `12`
+ const hours = date.getHours();
+ if (hours === 0) {
+ char = "12";
+ } else if (hours > 12) {
+ char = (hours - 12).toString();
+ } else {
+ char = hours.toString();
+ }
+
+ char = char.padStart(2, "0");
+
+ break;
+ }
+ case "A":
+ // `AM` or `PM`
+ char = date.getHours() > 11 ? "PM" : "AM";
+ break;
+ case "G":
+ // `0` through `23`
+ char = date.getHours().toString();
+ break;
+ case "H":
+ // `00` through `23`
+ char = date.getHours().toString().padStart(2, "0");
+ break;
+
+ // day
+ case "d":
+ // `01` through `31`
+ char = date.getDate().toString().padStart(2, "0");
+ break;
+ case "j":
+ // `1` through `31`
+ char = date.getDate().toString();
+ break;
+ case "l":
+ // `Monday` through `Sunday` (localized)
+ char = Language.get("__days")[date.getDay()];
+ break;
+ case "D":
+ // `Mon` through `Sun` (localized)
+ char = Language.get("__daysShort")[date.getDay()];
+ break;
+ case "S":
+ // ignore english ordinal suffix
+ char = "";
+ break;
+
+ // month
+ case "m":
+ // `01` through `12`
+ char = (date.getMonth() + 1).toString().padStart(2, "0");
+ break;
+ case "n":
+ // `1` through `12`
+ char = (date.getMonth() + 1).toString();
+ break;
+ case "F":
+ // `January` through `December` (localized)
+ char = Language.get("__months")[date.getMonth()];
+ break;
+ case "M":
+ // `Jan` through `Dec` (localized)
+ char = Language.get("__monthsShort")[date.getMonth()];
+ break;
+
+ // year
+ case "y":
+ // `00` through `99`
+ char = date.getFullYear().toString().slice(-2);
+ break;
+ case "Y":
+ // Examples: `1988` or `2015`
+ char = date.getFullYear().toString();
+ break;
+
+ // timezone
+ case "P": {
+ let offset = date.getTimezoneOffset();
+ char = offset > 0 ? "-" : "+";
+
+ offset = Math.abs(offset);
+
+ char += (~~(offset / 60)).toString().padStart(2, "0");
+ char += ":";
+ char += (offset % 60).toString().padStart(2, "0");
+
+ break;
+ }
+
+ // specials
+ case "r":
+ char = date.toString();
+ break;
+ case "U":
+ char = Math.round(date.getTime() / 1000).toString();
+ break;
+
+ // escape sequence
+ case "\\":
+ char = "";
+ if (i + 1 < length) {
+ char = format[++i];
+ }
+ break;
+
+ default:
+ char = format[i];
+ break;
+ }
+
+ out += char;
+ }
+
+ return out;
+}
+
+/**
+ * Returns UTC timestamp, if date is not given, current time will be used.
+ */
+export function gmdate(date: Date): number {
+ if (!(date instanceof Date)) {
+ date = new Date();
+ }
+
+ return Math.round(
+ Date.UTC(
+ date.getUTCFullYear(),
+ date.getUTCMonth(),
+ date.getUTCDay(),
+ date.getUTCHours(),
+ date.getUTCMinutes(),
+ date.getUTCSeconds(),
+ ) / 1000,
+ );
+}
+
+/**
+ * Returns a `time` element based on the given date just like a `time`
+ * element created by `wcf\system\template\plugin\TimeModifierTemplatePlugin`.
+ *
+ * Note: The actual content of the element is empty and is expected
+ * to be automatically updated by `WoltLabSuite/Core/Date/Time/Relative`
+ * (for dates not in the future) after the DOM change listener has been triggered.
+ */
+export function getTimeElement(date: Date): HTMLElement {
+ const time = document.createElement("time");
+ time.className = "datetime";
+
+ const formattedDate = formatDate(date);
+ const formattedTime = formatTime(date);
+
+ time.setAttribute("datetime", format(date, "c"));
+ time.dataset.timestamp = ((date.getTime() - date.getMilliseconds()) / 1_000).toString();
+ time.dataset.date = formattedDate;
+ time.dataset.time = formattedTime;
+ time.dataset.offset = (date.getTimezoneOffset() * 60).toString(); // PHP returns minutes, JavaScript returns seconds
+
+ if (date.getTime() > Date.now()) {
+ time.dataset.isFutureDate = "true";
+
+ time.textContent = Language.get("wcf.date.dateTimeFormat")
+ .replace("%time%", formattedTime)
+ .replace("%date%", formattedDate);
+ }
+
+ return time;
+}
+
+/**
+ * Returns a Date object with precise offset (including timezone and local timezone).
+ */
+export function getTimezoneDate(timestamp: number, offset: number): Date {
+ const date = new Date(timestamp);
+ const localOffset = date.getTimezoneOffset() * 60_000;
+
+ return new Date(timestamp + localOffset + offset);
+}
--- /dev/null
+/**
+ * Developer tools for WoltLab Suite.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Devtools (alias)
+ * @module WoltLabSuite/Core/Devtools
+ */
+
+let _settings = {
+ editorAutosave: true,
+ eventLogging: false,
+};
+
+function _updateConfig() {
+ if (window.sessionStorage) {
+ window.sessionStorage.setItem("__wsc_devtools_config", JSON.stringify(_settings));
+ }
+}
+
+const Devtools = {
+ /**
+ * Prints the list of available commands.
+ */
+ help(): void {
+ window.console.log("");
+ window.console.log("%cAvailable commands:", "text-decoration: underline");
+
+ Object.keys(Devtools)
+ .filter((cmd) => cmd !== "_internal_")
+ .sort()
+ .forEach((cmd) => {
+ window.console.log(`\tDevtools.${cmd}()`);
+ });
+
+ window.console.log("");
+ },
+
+ /**
+ * Disables/re-enables the editor autosave feature.
+ */
+ toggleEditorAutosave(forceDisable: boolean): void {
+ _settings.editorAutosave = forceDisable ? false : !_settings.editorAutosave;
+ _updateConfig();
+
+ window.console.log(
+ "%c\tEditor autosave " + (_settings.editorAutosave ? "enabled" : "disabled"),
+ "font-style: italic",
+ );
+ },
+
+ /**
+ * Enables/disables logging for fired event listener events.
+ */
+ toggleEventLogging(forceEnable: boolean): void {
+ _settings.eventLogging = forceEnable ? true : !_settings.eventLogging;
+ _updateConfig();
+
+ window.console.log("%c\tEvent logging " + (_settings.eventLogging ? "enabled" : "disabled"), "font-style: italic");
+ },
+
+ /**
+ * Internal methods not meant to be called directly.
+ */
+ _internal_: {
+ enable(): void {
+ window.Devtools = Devtools;
+
+ window.console.log("%cDevtools for WoltLab Suite loaded", "font-weight: bold");
+
+ if (window.sessionStorage) {
+ const settings = window.sessionStorage.getItem("__wsc_devtools_config");
+ try {
+ if (settings !== null) {
+ _settings = JSON.parse(settings);
+ }
+ } catch (e) {
+ // Ignore JSON parsing failure.
+ }
+
+ if (!_settings.editorAutosave) {
+ Devtools.toggleEditorAutosave(true);
+ }
+ if (_settings.eventLogging) {
+ Devtools.toggleEventLogging(true);
+ }
+ }
+
+ window.console.log("Settings are saved per browser session, enter `Devtools.help()` to learn more.");
+ window.console.log("");
+ },
+
+ editorAutosave(): boolean {
+ return _settings.editorAutosave;
+ },
+
+ eventLog(identifier: string, action: string): void {
+ if (_settings.eventLogging) {
+ window.console.log("[Devtools.EventLogging] Firing event: " + action + " @ " + identifier);
+ }
+ },
+ },
+};
+
+export = Devtools;
--- /dev/null
+/**
+ * Dictionary implementation relying on an object or if supported on a Map to hold key => value data.
+ *
+ * If you're looking for a dictionary with object keys, please see `WoltLabSuite/Core/ObjectMap`.
+ *
+ * This is a legacy implementation, that does not implement all methods of `Map`, furthermore it has
+ * the side effect of converting all numeric keys to string values, treating 1 === "1".
+ *
+ * @author Tim Duesterhus, Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Dictionary (alias)
+ * @module WoltLabSuite/Core/Dictionary
+ */
+
+import * as Core from "./Core";
+
+/** @deprecated 5.4 Use a `Map` instead. */
+class Dictionary<T> {
+ private readonly _dictionary = new Map<number | string, T>();
+
+ /**
+ * Sets a new key with given value, will overwrite an existing key.
+ */
+ set(key: number | string, value: T): void {
+ this._dictionary.set(key.toString(), value);
+ }
+
+ /**
+ * Removes a key from the dictionary.
+ */
+ delete(key: number | string): boolean {
+ return this._dictionary.delete(key.toString());
+ }
+
+ /**
+ * Returns true if dictionary contains a value for given key and is not undefined.
+ */
+ has(key: number | string): boolean {
+ return this._dictionary.has(key.toString());
+ }
+
+ /**
+ * Retrieves a value by key, returns undefined if there is no match.
+ */
+ get(key: number | string): unknown {
+ return this._dictionary.get(key.toString());
+ }
+
+ /**
+ * Iterates over the dictionary keys and values, callback function should expect the
+ * value as first parameter and the key name second.
+ */
+ forEach(callback: (value: T, key: number | string) => void): void {
+ if (typeof callback !== "function") {
+ throw new TypeError("forEach() expects a callback as first parameter.");
+ }
+
+ this._dictionary.forEach(callback);
+ }
+
+ /**
+ * Merges one or more Dictionary instances into this one.
+ */
+ merge(...dictionaries: Dictionary<T>[]): void {
+ for (let i = 0, length = dictionaries.length; i < length; i++) {
+ const dictionary = dictionaries[i];
+
+ dictionary.forEach((value, key) => this.set(key, value));
+ }
+ }
+
+ /**
+ * Returns the object representation of the dictionary.
+ */
+ toObject(): object {
+ const object = {};
+ this._dictionary.forEach((value, key) => (object[key] = value));
+
+ return object;
+ }
+
+ /**
+ * Creates a new Dictionary based on the given object.
+ * All properties that are owned by the object will be added
+ * as keys to the resulting Dictionary.
+ */
+ static fromObject(object: object): Dictionary<any> {
+ const result = new Dictionary();
+
+ Object.keys(object).forEach((key) => {
+ result.set(key, object[key]);
+ });
+
+ return result;
+ }
+
+ get size(): number {
+ return this._dictionary.size;
+ }
+}
+
+Core.enableLegacyInheritance(Dictionary);
+
+export = Dictionary;
--- /dev/null
+/**
+ * Allows to be informed when the DOM may have changed and
+ * new elements that are relevant to you may have been added.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Dom/ChangeListener (alias)
+ * @module WoltLabSuite/Core/Dom/Change/Listener
+ */
+
+import CallbackList from "../../CallbackList";
+
+const _callbackList = new CallbackList();
+let _hot = false;
+
+const DomChangeListener = {
+ /**
+ * @see CallbackList.add
+ */
+ add: _callbackList.add.bind(_callbackList),
+
+ /**
+ * @see CallbackList.remove
+ */
+ remove: _callbackList.remove.bind(_callbackList),
+
+ /**
+ * Triggers the execution of all the listeners.
+ * Use this function when you added new elements to the DOM that might
+ * be relevant to others.
+ * While this function is in progress further calls to it will be ignored.
+ */
+ trigger(): void {
+ if (_hot) return;
+
+ try {
+ _hot = true;
+ _callbackList.forEach(null, (callback) => callback());
+ } finally {
+ _hot = false;
+ }
+ },
+};
+
+export = DomChangeListener;
--- /dev/null
+/**
+ * Provides helper functions to traverse the DOM.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Dom/Traverse (alias)
+ * @module WoltLabSuite/Core/Dom/Traverse
+ */
+
+const enum Type {
+ None,
+ Selector,
+ ClassName,
+ TagName,
+}
+
+type SiblingType = "nextElementSibling" | "previousElementSibling";
+
+const _test = new Map<Type, (...args: any[]) => boolean>([
+ [Type.None, () => true],
+ [Type.Selector, (element: Element, selector: string) => element.matches(selector)],
+ [Type.ClassName, (element: Element, className: string) => element.classList.contains(className)],
+ [Type.TagName, (element: Element, tagName: string) => element.nodeName === tagName],
+]);
+
+function _getChildren(element: Element, type: Type, value: string): Element[] {
+ if (!(element instanceof Element)) {
+ throw new TypeError("Expected a valid element as first argument.");
+ }
+
+ const children: Element[] = [];
+ for (let i = 0; i < element.childElementCount; i++) {
+ if (_test.get(type)!(element.children[i], value)) {
+ children.push(element.children[i]);
+ }
+ }
+
+ return children;
+}
+
+function _getParent(element: Element, type: Type, value: string, untilElement?: Element): Element | null {
+ if (!(element instanceof Element)) {
+ throw new TypeError("Expected a valid element as first argument.");
+ }
+
+ let target = element.parentNode;
+ while (target instanceof Element) {
+ if (target === untilElement) {
+ return null;
+ }
+
+ if (_test.get(type)!(target, value)) {
+ return target;
+ }
+
+ target = target.parentNode;
+ }
+
+ return null;
+}
+
+function _getSibling(element: Element, siblingType: SiblingType, type: Type, value: string): Element | null {
+ if (!(element instanceof Element)) {
+ throw new TypeError("Expected a valid element as first argument.");
+ }
+
+ if (element instanceof Element) {
+ if (element[siblingType] !== null && _test.get(type)!(element[siblingType], value)) {
+ return element[siblingType];
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Examines child elements and returns the first child matching the given selector.
+ */
+export function childBySel(element: Element, selector: string): Element | null {
+ return _getChildren(element, Type.Selector, selector)[0] || null;
+}
+
+/**
+ * Examines child elements and returns the first child that has the given CSS class set.
+ */
+export function childByClass(element: Element, className: string): Element | null {
+ return _getChildren(element, Type.ClassName, className)[0] || null;
+}
+
+/**
+ * Examines child elements and returns the first child which equals the given tag.
+ */
+export function childByTag<K extends Uppercase<keyof HTMLElementTagNameMap>>(
+ element: Element,
+ tagName: K,
+): HTMLElementTagNameMap[Lowercase<K>] | null;
+export function childByTag(element: Element, tagName: string): Element | null;
+export function childByTag(element: Element, tagName: string): Element | null {
+ return _getChildren(element, Type.TagName, tagName)[0] || null;
+}
+
+/**
+ * Examines child elements and returns all children matching the given selector.
+ */
+export function childrenBySel(element: Element, selector: string): Element[] {
+ return _getChildren(element, Type.Selector, selector);
+}
+
+/**
+ * Examines child elements and returns all children that have the given CSS class set.
+ */
+export function childrenByClass(element: Element, className: string): Element[] {
+ return _getChildren(element, Type.ClassName, className);
+}
+
+/**
+ * Examines child elements and returns all children which equal the given tag.
+ */
+export function childrenByTag<K extends Uppercase<keyof HTMLElementTagNameMap>>(
+ element: Element,
+ tagName: K,
+): HTMLElementTagNameMap[Lowercase<K>][];
+export function childrenByTag(element: Element, tagName: string): Element[];
+export function childrenByTag(element: Element, tagName: string): Element[] {
+ return _getChildren(element, Type.TagName, tagName);
+}
+
+/**
+ * Examines parent nodes and returns the first parent that matches the given selector.
+ */
+export function parentBySel(element: Element, selector: string, untilElement?: Element): Element | null {
+ return _getParent(element, Type.Selector, selector, untilElement);
+}
+
+/**
+ * Examines parent nodes and returns the first parent that has the given CSS class set.
+ */
+export function parentByClass(element: Element, className: string, untilElement?: Element): Element | null {
+ return _getParent(element, Type.ClassName, className, untilElement);
+}
+
+/**
+ * Examines parent nodes and returns the first parent which equals the given tag.
+ */
+export function parentByTag(element: Element, tagName: string, untilElement?: Element): Element | null {
+ return _getParent(element, Type.TagName, tagName, untilElement);
+}
+
+/**
+ * Returns the next element sibling.
+ *
+ * @deprecated 5.4 Use `element.nextElementSibling` instead.
+ */
+export function next(element: Element): Element | null {
+ return _getSibling(element, "nextElementSibling", Type.None, "");
+}
+
+/**
+ * Returns the next element sibling that matches the given selector.
+ */
+export function nextBySel(element: Element, selector: string): Element | null {
+ return _getSibling(element, "nextElementSibling", Type.Selector, selector);
+}
+
+/**
+ * Returns the next element sibling with given CSS class.
+ */
+export function nextByClass(element: Element, className: string): Element | null {
+ return _getSibling(element, "nextElementSibling", Type.ClassName, className);
+}
+
+/**
+ * Returns the next element sibling with given CSS class.
+ */
+export function nextByTag(element: Element, tagName: string): Element | null {
+ return _getSibling(element, "nextElementSibling", Type.TagName, tagName);
+}
+
+/**
+ * Returns the previous element sibling.
+ *
+ * @deprecated 5.4 Use `element.previousElementSibling` instead.
+ */
+export function prev(element: Element): Element | null {
+ return _getSibling(element, "previousElementSibling", Type.None, "");
+}
+
+/**
+ * Returns the previous element sibling that matches the given selector.
+ */
+export function prevBySel(element: Element, selector: string): Element | null {
+ return _getSibling(element, "previousElementSibling", Type.Selector, selector);
+}
+
+/**
+ * Returns the previous element sibling with given CSS class.
+ */
+export function prevByClass(element: Element, className: string): Element | null {
+ return _getSibling(element, "previousElementSibling", Type.ClassName, className);
+}
+
+/**
+ * Returns the previous element sibling with given CSS class.
+ */
+export function prevByTag(element: Element, tagName: string): Element | null {
+ return _getSibling(element, "previousElementSibling", Type.TagName, tagName);
+}
--- /dev/null
+/**
+ * Provides helper functions to work with DOM nodes.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Dom/Util (alias)
+ * @module WoltLabSuite/Core/Dom/Util
+ */
+
+import * as StringUtil from "../StringUtil";
+
+function _isBoundaryNode(element: Element, ancestor: Element, position: string): boolean {
+ if (!ancestor.contains(element)) {
+ throw new Error("Ancestor element does not contain target element.");
+ }
+
+ let node: Node;
+ let target: Node | null = element;
+ const whichSibling = position + "Sibling";
+ while (target !== null && target !== ancestor) {
+ if (target[position + "ElementSibling"] !== null) {
+ return false;
+ } else if (target[whichSibling]) {
+ node = target[whichSibling];
+ while (node) {
+ if (node.textContent!.trim() !== "") {
+ return false;
+ }
+
+ node = node[whichSibling];
+ }
+ }
+
+ target = target.parentNode;
+ }
+
+ return true;
+}
+
+let _idCounter = 0;
+
+const DomUtil = {
+ /**
+ * Returns a DocumentFragment containing the provided HTML string as DOM nodes.
+ */
+ createFragmentFromHtml(html: string): DocumentFragment {
+ const tmp = document.createElement("div");
+ this.setInnerHtml(tmp, html);
+
+ const fragment = document.createDocumentFragment();
+ while (tmp.childNodes.length) {
+ fragment.appendChild(tmp.childNodes[0]);
+ }
+
+ return fragment;
+ },
+
+ /**
+ * Returns a unique element id.
+ */
+ getUniqueId(): string {
+ let elementId: string;
+
+ do {
+ elementId = `wcf${_idCounter++}`;
+ } while (document.getElementById(elementId) !== null);
+
+ return elementId;
+ },
+
+ /**
+ * Returns the element's id. If there is no id set, a unique id will be
+ * created and assigned.
+ */
+ identify(element: Element): string {
+ if (!(element instanceof Element)) {
+ throw new TypeError("Expected a valid DOM element as argument.");
+ }
+
+ let id = element.id;
+ if (!id) {
+ id = this.getUniqueId();
+ element.id = id;
+ }
+
+ return id;
+ },
+
+ /**
+ * Returns the outer height of an element including margins.
+ */
+ outerHeight(element: HTMLElement, styles?: CSSStyleDeclaration): number {
+ styles = styles || window.getComputedStyle(element);
+
+ let height = element.offsetHeight;
+ height += ~~styles.marginTop + ~~styles.marginBottom;
+
+ return height;
+ },
+
+ /**
+ * Returns the outer width of an element including margins.
+ */
+ outerWidth(element: HTMLElement, styles?: CSSStyleDeclaration): number {
+ styles = styles || window.getComputedStyle(element);
+
+ let width = element.offsetWidth;
+ width += ~~styles.marginLeft + ~~styles.marginRight;
+
+ return width;
+ },
+
+ /**
+ * Returns the outer dimensions of an element including margins.
+ */
+ outerDimensions(element: HTMLElement): Dimensions {
+ const styles = window.getComputedStyle(element);
+
+ return {
+ height: this.outerHeight(element, styles),
+ width: this.outerWidth(element, styles),
+ };
+ },
+
+ /**
+ * Returns the element's offset relative to the document's top left corner.
+ *
+ * @param {Element} element element
+ * @return {{left: int, top: int}} offset relative to top left corner
+ */
+ offset(element: Element): Offset {
+ const rect = element.getBoundingClientRect();
+
+ return {
+ top: Math.round(rect.top + (window.scrollY || window.pageYOffset)),
+ left: Math.round(rect.left + (window.scrollX || window.pageXOffset)),
+ };
+ },
+
+ /**
+ * Prepends an element to a parent element.
+ *
+ * @deprecated 5.3 Use `parent.insertAdjacentElement('afterbegin', element)` instead.
+ */
+ prepend(element: Element, parent: Element): void {
+ parent.insertAdjacentElement("afterbegin", element);
+ },
+
+ /**
+ * Inserts an element after an existing element.
+ *
+ * @deprecated 5.3 Use `element.insertAdjacentElement('afterend', newElement)` instead.
+ */
+ insertAfter(newElement: Element, element: Element): void {
+ element.insertAdjacentElement("afterend", newElement);
+ },
+
+ /**
+ * Applies a list of CSS properties to an element.
+ */
+ setStyles(element: HTMLElement, styles: CssDeclarations): void {
+ let important = false;
+ Object.keys(styles).forEach((property) => {
+ if (/ !important$/.test(styles[property])) {
+ important = true;
+
+ styles[property] = styles[property].replace(/ !important$/, "");
+ } else {
+ important = false;
+ }
+
+ // for a set style property with priority = important, some browsers are
+ // not able to overwrite it with a property != important; removing the
+ // property first solves this issue
+ if (element.style.getPropertyPriority(property) === "important" && !important) {
+ element.style.removeProperty(property);
+ }
+
+ element.style.setProperty(property, styles[property], important ? "important" : "");
+ });
+ },
+
+ /**
+ * Returns a style property value as integer.
+ *
+ * The behavior of this method is undefined for properties that are not considered
+ * to have a "numeric" value, e.g. "background-image".
+ */
+ styleAsInt(styles: CSSStyleDeclaration, propertyName: string): number {
+ const value = styles.getPropertyValue(propertyName);
+ if (value === null) {
+ return 0;
+ }
+
+ return parseInt(value, 10);
+ },
+
+ /**
+ * Sets the inner HTML of given element and reinjects <script> elements to be properly executed.
+ *
+ * @see http://www.w3.org/TR/2008/WD-html5-20080610/dom.html#innerhtml0
+ * @param {Element} element target element
+ * @param {string} innerHtml HTML string
+ */
+ setInnerHtml(element: Element, innerHtml: string): void {
+ element.innerHTML = innerHtml;
+
+ const scripts = element.querySelectorAll<HTMLScriptElement>("script");
+ for (let i = 0, length = scripts.length; i < length; i++) {
+ const script = scripts[i];
+ const newScript = document.createElement("script");
+ if (script.src) {
+ newScript.src = script.src;
+ } else {
+ newScript.textContent = script.textContent;
+ }
+
+ element.appendChild(newScript);
+ script.remove();
+ }
+ },
+
+ /**
+ *
+ * @param html
+ * @param {Element} referenceElement
+ * @param insertMethod
+ */
+ insertHtml(html: string, referenceElement: Element, insertMethod: string): void {
+ const element = document.createElement("div");
+ this.setInnerHtml(element, html);
+
+ if (!element.childNodes.length) {
+ return;
+ }
+
+ let node = element.childNodes[0] as Element;
+ switch (insertMethod) {
+ case "append":
+ referenceElement.appendChild(node);
+ break;
+
+ case "after":
+ this.insertAfter(node, referenceElement);
+ break;
+
+ case "prepend":
+ this.prepend(node, referenceElement);
+ break;
+
+ case "before":
+ if (referenceElement.parentNode === null) {
+ throw new Error("The reference element has no parent, but the insert position was set to 'before'.");
+ }
+
+ referenceElement.parentNode.insertBefore(node, referenceElement);
+ break;
+
+ default:
+ throw new Error("Unknown insert method '" + insertMethod + "'.");
+ }
+
+ let tmp;
+ while (element.childNodes.length) {
+ tmp = element.childNodes[0];
+
+ this.insertAfter(tmp, node);
+ node = tmp;
+ }
+ },
+
+ /**
+ * Returns true if `element` contains the `child` element.
+ *
+ * @deprecated 5.4 Use `element.contains(child)` instead.
+ */
+ contains(element: Element, child: Element): boolean {
+ return element.contains(child);
+ },
+
+ /**
+ * Retrieves all data attributes from target element, optionally allowing for
+ * a custom prefix that serves two purposes: First it will restrict the results
+ * for items starting with it and second it will remove that prefix.
+ *
+ * @deprecated 5.4 Use `element.dataset` instead.
+ */
+ getDataAttributes(
+ element: Element,
+ prefix?: string,
+ camelCaseName?: boolean,
+ idToUpperCase?: boolean,
+ ): DataAttributes {
+ prefix = prefix || "";
+ if (prefix.indexOf("data-") !== 0) {
+ prefix = "data-" + prefix;
+ }
+ camelCaseName = camelCaseName === true;
+ idToUpperCase = idToUpperCase === true;
+
+ const attributes = {};
+ for (let i = 0, length = element.attributes.length; i < length; i++) {
+ const attribute = element.attributes[i];
+
+ if (attribute.name.indexOf(prefix) === 0) {
+ let name = attribute.name.replace(new RegExp("^" + prefix), "");
+ if (camelCaseName) {
+ const tmp = name.split("-");
+ name = "";
+ for (let j = 0, innerLength = tmp.length; j < innerLength; j++) {
+ if (name.length) {
+ if (idToUpperCase && tmp[j] === "id") {
+ tmp[j] = "ID";
+ } else {
+ tmp[j] = StringUtil.ucfirst(tmp[j]);
+ }
+ }
+
+ name += tmp[j];
+ }
+ }
+
+ attributes[name] = attribute.value;
+ }
+ }
+
+ return attributes;
+ },
+
+ /**
+ * Unwraps contained nodes by moving them out of `element` while
+ * preserving their previous order. Target element will be removed
+ * at the end of the operation.
+ */
+ unwrapChildNodes(element: Element): void {
+ if (element.parentNode === null) {
+ throw new Error("The element has no parent.");
+ }
+
+ const parent = element.parentNode;
+ while (element.childNodes.length) {
+ parent.insertBefore(element.childNodes[0], element);
+ }
+
+ element.remove();
+ },
+
+ /**
+ * Replaces an element by moving all child nodes into the new element
+ * while preserving their previous order. The old element will be removed
+ * at the end of the operation.
+ */
+ replaceElement(oldElement: Element, newElement: Element): void {
+ if (oldElement.parentNode === null) {
+ throw new Error("The old element has no parent.");
+ }
+
+ while (oldElement.childNodes.length) {
+ newElement.appendChild(oldElement.childNodes[0]);
+ }
+
+ oldElement.parentNode.insertBefore(newElement, oldElement);
+ oldElement.remove();
+ },
+
+ /**
+ * Returns true if given element is the most left node of the ancestor, that is
+ * a node without any content nor elements before it or its parent nodes.
+ */
+ isAtNodeStart(element: Element, ancestor: Element): boolean {
+ return _isBoundaryNode(element, ancestor, "previous");
+ },
+
+ /**
+ * Returns true if given element is the most right node of the ancestor, that is
+ * a node without any content nor elements after it or its parent nodes.
+ */
+ isAtNodeEnd(element: Element, ancestor: Element): boolean {
+ return _isBoundaryNode(element, ancestor, "next");
+ },
+
+ /**
+ * Returns the first ancestor element with position fixed or null.
+ *
+ * @param {Element} element target element
+ * @returns {(Element|null)} first ancestor with position fixed or null
+ */
+ getFixedParent(element: HTMLElement): Element | null {
+ while (element && element !== document.body) {
+ if (window.getComputedStyle(element).getPropertyValue("position") === "fixed") {
+ return element;
+ }
+
+ element = element.offsetParent as HTMLElement;
+ }
+
+ return null;
+ },
+
+ /**
+ * Shorthand function to hide an element by setting its 'display' value to 'none'.
+ */
+ hide(element: HTMLElement): void {
+ element.style.setProperty("display", "none", "");
+ },
+
+ /**
+ * Shorthand function to show an element previously hidden by using `hide()`.
+ */
+ show(element: HTMLElement): void {
+ element.style.removeProperty("display");
+ },
+
+ /**
+ * Shorthand function to check if given element is hidden by setting its 'display'
+ * value to 'none'.
+ */
+ isHidden(element: HTMLElement): boolean {
+ return element.style.getPropertyValue("display") === "none";
+ },
+
+ /**
+ * Shorthand function to toggle the element visibility using either `hide()` or `show()`.
+ */
+ toggle(element: HTMLElement): void {
+ if (this.isHidden(element)) {
+ this.show(element);
+ } else {
+ this.hide(element);
+ }
+ },
+
+ /**
+ * Displays or removes an error message below the provided element.
+ */
+ innerError(element: HTMLElement, errorMessage?: string | false | null, isHtml?: boolean): HTMLElement | null {
+ const parent = element.parentNode;
+ if (parent === null) {
+ throw new Error("Only elements that have a parent element or document are valid.");
+ }
+
+ if (typeof errorMessage !== "string") {
+ if (!errorMessage) {
+ errorMessage = "";
+ } else {
+ throw new TypeError(
+ "The error message must be a string; `false`, `null` or `undefined` can be used as a substitute for an empty string.",
+ );
+ }
+ }
+
+ let innerError = element.nextElementSibling;
+ if (innerError === null || innerError.nodeName !== "SMALL" || !innerError.classList.contains("innerError")) {
+ if (errorMessage === "") {
+ innerError = null;
+ } else {
+ innerError = document.createElement("small");
+ innerError.className = "innerError";
+ parent.insertBefore(innerError, element.nextSibling);
+ }
+ }
+
+ if (errorMessage === "") {
+ if (innerError !== null) {
+ innerError.remove();
+ innerError = null;
+ }
+ } else {
+ innerError![isHtml ? "innerHTML" : "textContent"] = errorMessage;
+ }
+
+ return innerError as HTMLElement | null;
+ },
+
+ /**
+ * Finds the closest element that matches the provided selector. This is a helper
+ * function because `closest()` does exist on elements only, for example, it is
+ * missing on text nodes.
+ */
+ closest(node: Node, selector: string): HTMLElement | null {
+ const element = node instanceof HTMLElement ? node : node.parentElement!;
+ return element.closest(selector);
+ },
+
+ /**
+ * Returns the `node` if it is an element or its parent. This is useful when working
+ * with the range of a text selection.
+ */
+ getClosestElement(node: Node): HTMLElement {
+ return node instanceof HTMLElement ? node : node.parentElement!;
+ },
+};
+
+interface Dimensions {
+ height: number;
+ width: number;
+}
+
+interface Offset {
+ top: number;
+ left: number;
+}
+
+interface CssDeclarations {
+ [key: string]: string;
+}
+
+interface DataAttributes {
+ [key: string]: string;
+}
+
+// expose on window object for backward compatibility
+window.bc_wcfDomUtil = DomUtil;
+
+export = DomUtil;
--- /dev/null
+/**
+ * Provides basic details on the JavaScript environment.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Environment (alias)
+ * @module WoltLabSuite/Core/Environment
+ */
+
+let _browser = "other";
+let _editor = "none";
+let _platform = "desktop";
+let _touch = false;
+
+/**
+ * Determines environment variables.
+ */
+export function setup(): void {
+ if (typeof (window as any).chrome === "object") {
+ // this detects Opera as well, we could check for window.opr if we need to
+ _browser = "chrome";
+ } else {
+ const styles = window.getComputedStyle(document.documentElement);
+ for (let i = 0, length = styles.length; i < length; i++) {
+ const property = styles[i];
+
+ if (property.indexOf("-ms-") === 0) {
+ // it is tempting to use 'msie', but it wouldn't really represent 'Edge'
+ _browser = "microsoft";
+ } else if (property.indexOf("-moz-") === 0) {
+ _browser = "firefox";
+ } else if (_browser !== "firefox" && property.indexOf("-webkit-") === 0) {
+ _browser = "safari";
+ }
+ }
+ }
+
+ const ua = window.navigator.userAgent.toLowerCase();
+ if (ua.indexOf("crios") !== -1) {
+ _browser = "chrome";
+ _platform = "ios";
+ } else if (/(?:iphone|ipad|ipod)/.test(ua)) {
+ _browser = "safari";
+ _platform = "ios";
+ } else if (ua.indexOf("android") !== -1) {
+ _platform = "android";
+ } else if (ua.indexOf("iemobile") !== -1) {
+ _browser = "microsoft";
+ _platform = "windows";
+ }
+
+ if (_platform === "desktop" && (ua.indexOf("mobile") !== -1 || ua.indexOf("tablet") !== -1)) {
+ _platform = "mobile";
+ }
+
+ _editor = "redactor";
+ _touch =
+ "ontouchstart" in window ||
+ ("msMaxTouchPoints" in window.navigator && window.navigator.msMaxTouchPoints > 0) ||
+ ((window as any).DocumentTouch && document instanceof (window as any).DocumentTouch);
+
+ // The iPad Pro 12.9" masquerades as a desktop browser.
+ if (window.navigator.platform === "MacIntel" && window.navigator.maxTouchPoints > 1) {
+ _browser = "safari";
+ _platform = "ios";
+ }
+}
+
+/**
+ * Returns the lower-case browser identifier.
+ *
+ * Possible values:
+ * - chrome: Chrome and Opera
+ * - firefox
+ * - microsoft: Internet Explorer and Microsoft Edge
+ * - safari
+ */
+export function browser(): string {
+ return _browser;
+}
+
+/**
+ * Returns the available editor's name or an empty string.
+ */
+export function editor(): string {
+ return _editor;
+}
+
+/**
+ * Returns the browser platform.
+ *
+ * Possible values:
+ * - desktop
+ * - android
+ * - ios: iPhone, iPad and iPod
+ * - windows: Windows on phones/tablets
+ */
+export function platform(): string {
+ return _platform;
+}
+
+/**
+ * Returns true if browser is potentially used with a touchscreen.
+ *
+ * Warning: Detecting touch is unreliable and should be avoided at all cost.
+ *
+ * @deprecated 3.0 - exists for backward-compatibility only, will be removed in the future
+ */
+export function touch(): boolean {
+ return _touch;
+}
--- /dev/null
+/**
+ * Versatile event system similar to the WCF-PHP counter part.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module EventHandler (alias)
+ * @module WoltLabSuite/Core/Event/Handler
+ */
+
+import * as Core from "../Core";
+import Devtools from "../Devtools";
+
+type Identifier = string;
+type Action = string;
+type Uuid = string;
+const _listeners = new Map<Identifier, Map<Action, Map<Uuid, Callback>>>();
+
+/**
+ * Registers an event listener.
+ */
+export function add(identifier: Identifier, action: Action, callback: Callback): Uuid {
+ if (typeof callback !== "function") {
+ throw new TypeError(`Expected a valid callback for '${action}'@'${identifier}'.`);
+ }
+
+ let actions = _listeners.get(identifier);
+ if (actions === undefined) {
+ actions = new Map<Action, Map<Uuid, Callback>>();
+ _listeners.set(identifier, actions);
+ }
+
+ let callbacks = actions.get(action);
+ if (callbacks === undefined) {
+ callbacks = new Map<Uuid, Callback>();
+ actions.set(action, callbacks);
+ }
+
+ const uuid = Core.getUuid();
+ callbacks.set(uuid, callback);
+
+ return uuid;
+}
+
+/**
+ * Fires an event and notifies all listeners.
+ */
+export function fire(identifier: Identifier, action: Action, data?: object): void {
+ Devtools._internal_.eventLog(identifier, action);
+
+ data = data || {};
+
+ _listeners
+ .get(identifier)
+ ?.get(action)
+ ?.forEach((callback) => callback(data));
+}
+
+/**
+ * Removes an event listener, requires the uuid returned by add().
+ */
+export function remove(identifier: Identifier, action: Action, uuid: Uuid): void {
+ _listeners.get(identifier)?.get(action)?.delete(uuid);
+}
+
+/**
+ * Removes all event listeners for given action. Omitting the second parameter will
+ * remove all listeners for this identifier.
+ */
+export function removeAll(identifier: Identifier, action?: Action): void {
+ if (typeof action !== "string") action = undefined;
+
+ const actions = _listeners.get(identifier);
+ if (actions === undefined) {
+ return;
+ }
+
+ if (action === undefined) {
+ _listeners.delete(identifier);
+ } else {
+ actions.delete(action);
+ }
+}
+
+/**
+ * Removes all listeners registered for an identifier and ending with a special suffix.
+ * This is commonly used to unbound event handlers for the editor.
+ */
+export function removeAllBySuffix(identifier: Identifier, suffix: string): void {
+ const actions = _listeners.get(identifier);
+ if (actions === undefined) {
+ return;
+ }
+
+ suffix = "_" + suffix;
+ const length = suffix.length * -1;
+ actions.forEach((callbacks, action) => {
+ if (action.substr(length) === suffix) {
+ removeAll(identifier, action);
+ }
+ });
+}
+
+type Callback = (...args: any[]) => void;
--- /dev/null
+/**
+ * Provides reliable checks for common key presses, uses `Event.key` on supported browsers
+ * or the deprecated `Event.which`.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module EventKey (alias)
+ * @module WoltLabSuite/Core/Event/Key
+ */
+
+function _test(event: KeyboardEvent, key: string, which: number) {
+ if (!(event instanceof Event)) {
+ throw new TypeError("Expected a valid event when testing for key '" + key + "'.");
+ }
+
+ return event.key === key || event.which === which;
+}
+
+/**
+ * Returns true if the pressed key equals 'ArrowDown'.
+ *
+ * @deprecated 5.4 Use `event.key === "ArrowDown"` instead.
+ */
+export function ArrowDown(event: KeyboardEvent): boolean {
+ return _test(event, "ArrowDown", 40);
+}
+
+/**
+ * Returns true if the pressed key equals 'ArrowLeft'.
+ *
+ * @deprecated 5.4 Use `event.key === "ArrowLeft"` instead.
+ */
+export function ArrowLeft(event: KeyboardEvent): boolean {
+ return _test(event, "ArrowLeft", 37);
+}
+
+/**
+ * Returns true if the pressed key equals 'ArrowRight'.
+ *
+ * @deprecated 5.4 Use `event.key === "ArrowRight"` instead.
+ */
+export function ArrowRight(event: KeyboardEvent): boolean {
+ return _test(event, "ArrowRight", 39);
+}
+
+/**
+ * Returns true if the pressed key equals 'ArrowUp'.
+ *
+ * @deprecated 5.4 Use `event.key === "ArrowUp"` instead.
+ */
+export function ArrowUp(event: KeyboardEvent): boolean {
+ return _test(event, "ArrowUp", 38);
+}
+
+/**
+ * Returns true if the pressed key equals 'Comma'.
+ *
+ * @deprecated 5.4 Use `event.key === ","` instead.
+ */
+export function Comma(event: KeyboardEvent): boolean {
+ return _test(event, ",", 44);
+}
+
+/**
+ * Returns true if the pressed key equals 'End'.
+ *
+ * @deprecated 5.4 Use `event.key === "End"` instead.
+ */
+export function End(event: KeyboardEvent): boolean {
+ return _test(event, "End", 35);
+}
+
+/**
+ * Returns true if the pressed key equals 'Enter'.
+ *
+ * @deprecated 5.4 Use `event.key === "Enter"` instead.
+ */
+export function Enter(event: KeyboardEvent): boolean {
+ return _test(event, "Enter", 13);
+}
+
+/**
+ * Returns true if the pressed key equals 'Escape'.
+ *
+ * @deprecated 5.4 Use `event.key === "Escape"` instead.
+ */
+export function Escape(event: KeyboardEvent): boolean {
+ return _test(event, "Escape", 27);
+}
+
+/**
+ * Returns true if the pressed key equals 'Home'.
+ *
+ * @deprecated 5.4 Use `event.key === "Home"` instead.
+ */
+export function Home(event: KeyboardEvent): boolean {
+ return _test(event, "Home", 36);
+}
+
+/**
+ * Returns true if the pressed key equals 'Space'.
+ *
+ * @deprecated 5.4 Use `event.key === "Space"` instead.
+ */
+export function Space(event: KeyboardEvent): boolean {
+ return _test(event, "Space", 32);
+}
+
+/**
+ * Returns true if the pressed key equals 'Tab'.
+ *
+ * @deprecated 5.4 Use `event.key === "Tab"` instead.
+ */
+export function Tab(event: KeyboardEvent): boolean {
+ return _test(event, "Tab", 9);
+}
--- /dev/null
+/**
+ * Provides helper functions for file handling.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/FileUtil
+ */
+
+import * as StringUtil from "./StringUtil";
+
+const _fileExtensionIconMapping = new Map<string, string>(
+ Object.entries({
+ // archive
+ zip: "archive",
+ rar: "archive",
+ tar: "archive",
+ gz: "archive",
+
+ // audio
+ mp3: "audio",
+ ogg: "audio",
+ wav: "audio",
+
+ // code
+ php: "code",
+ html: "code",
+ htm: "code",
+ tpl: "code",
+ js: "code",
+
+ // excel
+ xls: "excel",
+ ods: "excel",
+ xlsx: "excel",
+
+ // image
+ gif: "image",
+ jpg: "image",
+ jpeg: "image",
+ png: "image",
+ bmp: "image",
+ webp: "image",
+
+ // video
+ avi: "video",
+ wmv: "video",
+ mov: "video",
+ mp4: "video",
+ mpg: "video",
+ mpeg: "video",
+ flv: "video",
+
+ // pdf
+ pdf: "pdf",
+
+ // powerpoint
+ ppt: "powerpoint",
+ pptx: "powerpoint",
+
+ // text
+ txt: "text",
+
+ // word
+ doc: "word",
+ docx: "word",
+ odt: "word",
+ }),
+);
+
+const _mimeTypeExtensionMapping = new Map<string, string>(
+ Object.entries({
+ // archive
+ "application/zip": "zip",
+ "application/x-zip-compressed": "zip",
+ "application/rar": "rar",
+ "application/vnd.rar": "rar",
+ "application/x-rar-compressed": "rar",
+ "application/x-tar": "tar",
+ "application/x-gzip": "gz",
+ "application/gzip": "gz",
+
+ // audio
+ "audio/mpeg": "mp3",
+ "audio/mp3": "mp3",
+ "audio/ogg": "ogg",
+ "audio/x-wav": "wav",
+
+ // code
+ "application/x-php": "php",
+ "text/html": "html",
+ "application/javascript": "js",
+
+ // excel
+ "application/vnd.ms-excel": "xls",
+ "application/vnd.oasis.opendocument.spreadsheet": "ods",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
+
+ // image
+ "image/gif": "gif",
+ "image/jpeg": "jpg",
+ "image/png": "png",
+ "image/x-ms-bmp": "bmp",
+ "image/bmp": "bmp",
+ "image/webp": "webp",
+
+ // video
+ "video/x-msvideo": "avi",
+ "video/x-ms-wmv": "wmv",
+ "video/quicktime": "mov",
+ "video/mp4": "mp4",
+ "video/mpeg": "mpg",
+ "video/x-flv": "flv",
+
+ // pdf
+ "application/pdf": "pdf",
+
+ // powerpoint
+ "application/vnd.ms-powerpoint": "ppt",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
+
+ // text
+ "text/plain": "txt",
+
+ // word
+ "application/msword": "doc",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
+ "application/vnd.oasis.opendocument.text": "odt",
+ }),
+);
+
+/**
+ * Formats the given filesize.
+ */
+export function formatFilesize(byte: number, precision = 2): string {
+ let symbol = "Byte";
+ if (byte >= 1000) {
+ byte /= 1000;
+ symbol = "kB";
+ }
+ if (byte >= 1000) {
+ byte /= 1000;
+ symbol = "MB";
+ }
+ if (byte >= 1000) {
+ byte /= 1000;
+ symbol = "GB";
+ }
+ if (byte >= 1000) {
+ byte /= 1000;
+ symbol = "TB";
+ }
+
+ return StringUtil.formatNumeric(byte, -precision) + " " + symbol;
+}
+
+/**
+ * Returns the icon name for given filename.
+ *
+ * Note: For any file icon name like `fa-file-word`, only `word`
+ * will be returned by this method.
+ */
+export function getIconNameByFilename(filename: string): string {
+ const lastDotPosition = filename.lastIndexOf(".");
+ if (lastDotPosition !== -1) {
+ const extension = filename.substr(lastDotPosition + 1);
+
+ if (_fileExtensionIconMapping.has(extension)) {
+ return _fileExtensionIconMapping.get(extension) as string;
+ }
+ }
+
+ return "";
+}
+
+/**
+ * Returns a known file extension including a leading dot or an empty string.
+ */
+export function getExtensionByMimeType(mimetype: string): string {
+ if (_mimeTypeExtensionMapping.has(mimetype)) {
+ return "." + _mimeTypeExtensionMapping.get(mimetype)!;
+ }
+
+ return "";
+}
+
+/**
+ * Constructs a File object from a Blob
+ *
+ * @param blob the blob to convert
+ * @param filename the filename
+ * @returns {File} the File object
+ */
+export function blobToFile(blob: Blob, filename: string): File {
+ const ext = getExtensionByMimeType(blob.type);
+
+ return new File([blob], filename + ext, { type: blob.type });
+}
--- /dev/null
+/**
+ * Handles the dropdowns of form fields with a suffix.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Container/SuffixFormField
+ * @since 5.2
+ */
+
+import UiSimpleDropdown from "../../../Ui/Dropdown/Simple";
+import * as EventHandler from "../../../Event/Handler";
+import * as Core from "../../../Core";
+
+type DestroyDropdownData = {
+ formId: string;
+};
+
+class SuffixFormField {
+ protected readonly _formId: string;
+ protected readonly _suffixField: HTMLInputElement;
+ protected readonly _suffixDropdownMenu: HTMLElement;
+ protected readonly _suffixDropdownToggle: HTMLElement;
+
+ constructor(formId: string, suffixFieldId: string) {
+ this._formId = formId;
+
+ this._suffixField = document.getElementById(suffixFieldId)! as HTMLInputElement;
+ this._suffixDropdownMenu = UiSimpleDropdown.getDropdownMenu(suffixFieldId + "_dropdown")!;
+ this._suffixDropdownToggle = UiSimpleDropdown.getDropdown(suffixFieldId + "_dropdown")!.getElementsByClassName(
+ "dropdownToggle",
+ )[0] as HTMLInputElement;
+ Array.from(this._suffixDropdownMenu.children).forEach((listItem: HTMLLIElement) => {
+ listItem.addEventListener("click", (ev) => this._changeSuffixSelection(ev));
+ });
+
+ EventHandler.add("WoltLabSuite/Core/Form/Builder/Manager", "afterUnregisterForm", (data) =>
+ this._destroyDropdown(data),
+ );
+ }
+
+ /**
+ * Handles changing the suffix selection.
+ */
+ protected _changeSuffixSelection(event: MouseEvent): void {
+ const target = event.currentTarget! as HTMLElement;
+ if (target.classList.contains("disabled")) {
+ return;
+ }
+
+ Array.from(this._suffixDropdownMenu.children).forEach((listItem: HTMLLIElement) => {
+ if (listItem === target) {
+ listItem.classList.add("active");
+ } else {
+ listItem.classList.remove("active");
+ }
+ });
+
+ this._suffixField.value = target.dataset.value!;
+ this._suffixDropdownToggle.innerHTML =
+ target.dataset.label! + ' <span class="icon icon16 fa-caret-down pointer"></span>';
+ }
+
+ /**
+ * Destroys the suffix dropdown if the parent form is unregistered.
+ */
+ protected _destroyDropdown(data: DestroyDropdownData): void {
+ if (data.formId === this._formId) {
+ UiSimpleDropdown.destroy(this._suffixDropdownMenu.id);
+ }
+ }
+}
+
+Core.enableLegacyInheritance(SuffixFormField);
+
+export = SuffixFormField;
--- /dev/null
+import { DialogOptions } from "../../Ui/Dialog/Data";
+
+interface InternalFormBuilderData {
+ [key: string]: any;
+}
+
+export interface AjaxResponseReturnValues {
+ dialog: string;
+ formId: string;
+}
+
+export type FormBuilderData = InternalFormBuilderData | Promise<InternalFormBuilderData>;
+
+export interface FormBuilderDialogOptions {
+ actionParameters: {
+ [key: string]: any;
+ };
+ closeCallback: () => void;
+ destroyOnClose: boolean;
+ dialog: DialogOptions;
+ onSubmit: (formData: FormBuilderData, submitButton: HTMLButtonElement) => void;
+ submitActionName?: string;
+ successCallback: (returnValues: AjaxResponseReturnValues) => void;
+ usesDboAction: boolean;
+}
+
+export interface LabelFormFieldOptions {
+ forceSelection: boolean;
+ showWithoutSelection: boolean;
+}
--- /dev/null
+/**
+ * Provides API to create a dialog form created by form builder.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Dialog
+ * @since 5.2
+ */
+
+import * as Core from "../../Core";
+import UiDialog from "../../Ui/Dialog";
+import { DialogCallbackObject, DialogCallbackSetup, DialogData } from "../../Ui/Dialog/Data";
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse, RequestOptions } from "../../Ajax/Data";
+import * as FormBuilderManager from "./Manager";
+import { AjaxResponseReturnValues, FormBuilderData, FormBuilderDialogOptions } from "./Data";
+
+interface AjaxResponse extends DatabaseObjectActionResponse {
+ returnValues: AjaxResponseReturnValues;
+}
+
+class FormBuilderDialog implements AjaxCallbackObject, DialogCallbackObject {
+ protected _actionName: string;
+ protected _className: string;
+ protected _dialogContent: string;
+ protected _dialogId: string;
+ protected _formId: string;
+ protected _options: FormBuilderDialogOptions;
+ protected _additionalSubmitButtons: HTMLButtonElement[];
+
+ constructor(dialogId: string, className: string, actionName: string, options: FormBuilderDialogOptions) {
+ this.init(dialogId, className, actionName, options);
+ }
+
+ protected init(dialogId: string, className: string, actionName: string, options: FormBuilderDialogOptions): void {
+ this._dialogId = dialogId;
+ this._className = className;
+ this._actionName = actionName;
+ this._options = Core.extend(
+ {
+ actionParameters: {},
+ destroyOnClose: false,
+ usesDboAction: /\w+\\data\\/.test(this._className),
+ },
+ options,
+ ) as FormBuilderDialogOptions;
+ this._options.dialog = Core.extend(this._options.dialog || {}, {
+ onClose: () => this._dialogOnClose(),
+ });
+
+ this._formId = "";
+ this._dialogContent = "";
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ const options = {
+ data: {
+ actionName: this._actionName,
+ className: this._className,
+ parameters: this._options.actionParameters,
+ },
+ } as RequestOptions;
+
+ // By default, `AJAXProxyAction` is used which relies on an `IDatabaseObjectAction` object; if
+ // no such object is used but an `IAJAXInvokeAction` object, `AJAXInvokeAction` has to be used.
+ if (!this._options.usesDboAction) {
+ options.url = "index.php?ajax-invoke/&t=" + window.SECURITY_TOKEN;
+ options.withCredentials = true;
+ }
+
+ return options;
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ switch (data.actionName) {
+ case this._actionName:
+ if (data.returnValues === undefined) {
+ throw new Error("Missing return data.");
+ } else if (data.returnValues.dialog === undefined) {
+ throw new Error("Missing dialog template in return data.");
+ } else if (data.returnValues.formId === undefined) {
+ throw new Error("Missing form id in return data.");
+ }
+
+ this._openDialogContent(data.returnValues.formId, data.returnValues.dialog);
+
+ break;
+
+ case this._options.submitActionName:
+ // If the validation failed, the dialog is shown again.
+ if (data.returnValues && data.returnValues.formId && data.returnValues.dialog) {
+ if (data.returnValues.formId !== this._formId) {
+ throw new Error(
+ "Mismatch between form ids: expected '" + this._formId + "' but got '" + data.returnValues.formId + "'.",
+ );
+ }
+
+ this._openDialogContent(data.returnValues.formId, data.returnValues.dialog);
+ } else {
+ this.destroy();
+
+ if (typeof this._options.successCallback === "function") {
+ this._options.successCallback(data.returnValues || {});
+ }
+ }
+
+ break;
+
+ default:
+ throw new Error("Cannot handle action '" + data.actionName + "'.");
+ }
+ }
+
+ protected _closeDialog(): void {
+ UiDialog.close(this);
+
+ if (typeof this._options.closeCallback === "function") {
+ this._options.closeCallback();
+ }
+ }
+
+ protected _dialogOnClose(): void {
+ if (this._options.destroyOnClose) {
+ this.destroy();
+ }
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: this._dialogId,
+ options: this._options.dialog,
+ source: this._dialogContent,
+ };
+ }
+
+ _dialogSubmit(): void {
+ void this.getData().then((formData: FormBuilderData) => this._submitForm(formData));
+ }
+
+ /**
+ * Opens the form dialog with the given form content.
+ */
+ protected _openDialogContent(formId: string, dialogContent: string): void {
+ this.destroy(true);
+
+ this._formId = formId;
+ this._dialogContent = dialogContent;
+
+ const dialogData = UiDialog.open(this, this._dialogContent) as DialogData;
+
+ const cancelButton = dialogData.content.querySelector("button[data-type=cancel]") as HTMLButtonElement;
+ if (cancelButton !== null && !Core.stringToBool(cancelButton.dataset.hasEventListener || "")) {
+ cancelButton.addEventListener("click", () => this._closeDialog());
+ cancelButton.dataset.hasEventListener = "1";
+ }
+
+ this._additionalSubmitButtons = Array.from(
+ dialogData.content.querySelectorAll(':not(.formSubmit) button[type="submit"]'),
+ );
+ this._additionalSubmitButtons.forEach((submit) => {
+ submit.addEventListener("click", () => {
+ // Mark the button that was clicked so that the button data handlers know
+ // which data needs to be submitted.
+ this._additionalSubmitButtons.forEach((button) => {
+ button.dataset.isClicked = button === submit ? "1" : "0";
+ });
+
+ // Enable other `click` event listeners to be executed first before the form
+ // is submitted.
+ setTimeout(() => UiDialog.submit(this._dialogId), 0);
+ });
+ });
+ }
+
+ /**
+ * Submits the form with the given form data.
+ */
+ protected _submitForm(formData: FormBuilderData): void {
+ const dialogData = UiDialog.getDialog(this)!;
+
+ const submitButton = dialogData.content.querySelector("button[data-type=submit]") as HTMLButtonElement;
+
+ if (typeof this._options.onSubmit === "function") {
+ this._options.onSubmit(formData, submitButton);
+ } else if (typeof this._options.submitActionName === "string") {
+ submitButton.disabled = true;
+ this._additionalSubmitButtons.forEach((submit) => (submit.disabled = true));
+
+ Ajax.api(this, {
+ actionName: this._options.submitActionName,
+ parameters: {
+ data: formData,
+ formId: this._formId,
+ },
+ });
+ }
+ }
+
+ /**
+ * Destroys the dialog form.
+ */
+ public destroy(ignoreDialog = false): void {
+ if (this._formId !== "") {
+ if (FormBuilderManager.hasForm(this._formId)) {
+ FormBuilderManager.unregisterForm(this._formId);
+ }
+
+ if (ignoreDialog !== true) {
+ UiDialog.destroy(this);
+ }
+ }
+ }
+
+ /**
+ * Returns a promise that provides all of the dialog form's data.
+ */
+ public getData(): Promise<FormBuilderData> {
+ if (this._formId === "") {
+ throw new Error("Form has not been requested yet.");
+ }
+
+ return FormBuilderManager.getData(this._formId);
+ }
+
+ /**
+ * Opens the dialog form.
+ */
+ public open(): void {
+ if (UiDialog.getDialog(this._dialogId)) {
+ UiDialog.open(this);
+ } else {
+ Ajax.api(this);
+ }
+ }
+}
+
+Core.enableLegacyInheritance(FormBuilderDialog);
+
+export = FormBuilderDialog;
--- /dev/null
+/**
+ * Data handler for a acl form builder field in an Ajax form.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Acl
+ * @since 5.2.3
+ */
+
+import Field from "./Field";
+import { FormBuilderData } from "../Data";
+import * as Core from "../../../Core";
+
+interface AclList {
+ getData: () => object;
+}
+
+class Acl extends Field {
+ protected _aclList: AclList;
+
+ protected _getData(): FormBuilderData {
+ return {
+ [this._fieldId]: this._aclList.getData(),
+ };
+ }
+
+ protected _readField(): void {
+ // does nothing
+ }
+
+ public setAclList(aclList: AclList): Acl {
+ this._aclList = aclList;
+
+ return this;
+ }
+}
+
+Core.enableLegacyInheritance(Acl);
+
+export = Acl;
--- /dev/null
+/**
+ * Data handler for a button form builder field in an Ajax form.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Value
+ * @since 5.4
+ */
+
+import Field from "./Field";
+import { FormBuilderData } from "../Data";
+
+export class Button extends Field {
+ protected _getData(): FormBuilderData {
+ const data = {};
+
+ if (this._field!.dataset.isClicked === "1") {
+ data[this._fieldId] = (this._field! as HTMLInputElement).value;
+ }
+
+ return data;
+ }
+}
+
+export default Button;
--- /dev/null
+/**
+ * Data handler for a captcha form builder field in an Ajax form.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Captcha
+ * @since 5.2
+ */
+
+import Field from "./Field";
+import ControllerCaptcha from "../../../Controller/Captcha";
+import { FormBuilderData } from "../Data";
+import * as Core from "../../../Core";
+
+class Captcha extends Field {
+ protected _getData(): FormBuilderData {
+ if (ControllerCaptcha.has(this._fieldId)) {
+ return ControllerCaptcha.getData(this._fieldId) as FormBuilderData;
+ }
+
+ return {};
+ }
+
+ protected _readField(): void {
+ // does nothing
+ }
+
+ destroy(): void {
+ if (ControllerCaptcha.has(this._fieldId)) {
+ ControllerCaptcha.delete(this._fieldId);
+ }
+ }
+}
+
+Core.enableLegacyInheritance(Captcha);
+
+export = Captcha;
--- /dev/null
+/**
+ * Data handler for a form builder field in an Ajax form represented by checkboxes.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Checkboxes
+ * @since 5.2
+ */
+
+import Field from "./Field";
+import { FormBuilderData } from "../Data";
+import * as Core from "../../../Core";
+
+class Checkboxes extends Field {
+ protected _fields: HTMLInputElement[];
+
+ protected _getData(): FormBuilderData {
+ const values = this._fields
+ .map((input) => {
+ if (input.checked) {
+ return input.value;
+ }
+
+ return null;
+ })
+ .filter((v) => v !== null) as string[];
+
+ return {
+ [this._fieldId]: values,
+ };
+ }
+
+ protected _readField(): void {
+ this._fields = Array.from(document.querySelectorAll("input[name=" + this._fieldId + "]"));
+ }
+}
+
+Core.enableLegacyInheritance(Checkboxes);
+
+export = Checkboxes;
--- /dev/null
+/**
+ * Data handler for a form builder field in an Ajax form that stores its value via a checkbox being
+ * checked or not.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Checked
+ * @since 5.2
+ */
+
+import Field from "./Field";
+import { FormBuilderData } from "../Data";
+import * as Core from "../../../Core";
+
+class Checked extends Field {
+ protected _getData(): FormBuilderData {
+ return {
+ [this._fieldId]: (this._field as HTMLInputElement).checked ? 1 : 0,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(Checked);
+
+export = Checked;
--- /dev/null
+/**
+ * Handles the JavaScript part of the label form field.
+ *
+ * @author Alexander Ebert, Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Controller/Label
+ * @since 5.2
+ */
+
+import * as Core from "../../../../Core";
+import * as DomUtil from "../../../../Dom/Util";
+import * as Language from "../../../../Language";
+import UiDropdownSimple from "../../../../Ui/Dropdown/Simple";
+import { LabelFormFieldOptions } from "../../Data";
+
+class Label {
+ protected readonly _formFieldContainer: HTMLElement;
+ protected readonly _input: HTMLInputElement;
+ protected readonly _labelChooser: HTMLElement;
+ protected readonly _options: LabelFormFieldOptions;
+
+ constructor(fieldId: string, labelId: string, options: Partial<LabelFormFieldOptions>) {
+ this._formFieldContainer = document.getElementById(fieldId + "Container")!;
+ this._labelChooser = this._formFieldContainer.getElementsByClassName("labelChooser")[0] as HTMLElement;
+ this._options = Core.extend(
+ {
+ forceSelection: false,
+ showWithoutSelection: false,
+ },
+ options,
+ ) as LabelFormFieldOptions;
+
+ this._input = document.createElement("input");
+ this._input.type = "hidden";
+ this._input.id = fieldId;
+ this._input.name = fieldId;
+ this._input.value = labelId;
+ this._formFieldContainer.appendChild(this._input);
+
+ const labelChooserId = DomUtil.identify(this._labelChooser);
+
+ // init dropdown
+ let dropdownMenu = UiDropdownSimple.getDropdownMenu(labelChooserId)!;
+ if (dropdownMenu === null) {
+ UiDropdownSimple.init(this._labelChooser.getElementsByClassName("dropdownToggle")[0] as HTMLElement);
+ dropdownMenu = UiDropdownSimple.getDropdownMenu(labelChooserId)!;
+ }
+
+ let additionalOptionList: HTMLUListElement | null = null;
+ if (this._options.showWithoutSelection || !this._options.forceSelection) {
+ additionalOptionList = document.createElement("ul");
+ dropdownMenu.appendChild(additionalOptionList);
+
+ const dropdownDivider = document.createElement("li");
+ dropdownDivider.classList.add("dropdownDivider");
+ additionalOptionList.appendChild(dropdownDivider);
+ }
+
+ if (this._options.showWithoutSelection) {
+ const listItem = document.createElement("li");
+ listItem.dataset.labelId = "-1";
+ this._blockScroll(listItem);
+ additionalOptionList!.appendChild(listItem);
+
+ const span = document.createElement("span");
+ listItem.appendChild(span);
+
+ const label = document.createElement("span");
+ label.classList.add("badge", "label");
+ label.innerHTML = Language.get("wcf.label.withoutSelection");
+ span.appendChild(label);
+ }
+
+ if (!this._options.forceSelection) {
+ const listItem = document.createElement("li");
+ listItem.dataset.labelId = "0";
+ this._blockScroll(listItem);
+ additionalOptionList!.appendChild(listItem);
+
+ const span = document.createElement("span");
+ listItem.appendChild(span);
+
+ const label = document.createElement("span");
+ label.classList.add("badge", "label");
+ label.innerHTML = Language.get("wcf.label.none");
+ span.appendChild(label);
+ }
+
+ dropdownMenu.querySelectorAll("li:not(.dropdownDivider)").forEach((listItem: HTMLElement) => {
+ listItem.addEventListener("click", (ev) => this._click(ev));
+
+ if (labelId) {
+ if (listItem.dataset.labelId === labelId) {
+ this._selectLabel(listItem);
+ }
+ }
+ });
+ }
+
+ _blockScroll(element: HTMLElement): void {
+ element.addEventListener("wheel", (ev) => ev.preventDefault(), {
+ passive: false,
+ });
+ }
+
+ _click(event: Event): void {
+ event.preventDefault();
+
+ this._selectLabel(event.currentTarget as HTMLElement);
+ }
+
+ _selectLabel(label: HTMLElement): void {
+ // save label
+ let labelId = label.dataset.labelId;
+ if (!labelId) {
+ labelId = "0";
+ }
+
+ // replace button with currently selected label
+ const displayLabel = label.querySelector("span > span")!;
+ const button = this._labelChooser.querySelector(".dropdownToggle > span")!;
+ button.className = displayLabel.className;
+ button.textContent = displayLabel.textContent;
+
+ this._input.value = labelId;
+ }
+}
+
+Core.enableLegacyInheritance(Label);
+
+export = Label;
--- /dev/null
+/**
+ * Handles the JavaScript part of the rating form field.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Controller/Rating
+ * @since 5.2
+ */
+
+import * as Core from "../../../../Core";
+import * as Environment from "../../../../Environment";
+
+class Rating {
+ protected readonly _activeCssClasses: string[];
+ protected readonly _defaultCssClasses: string[];
+ protected readonly _field: HTMLElement;
+ protected readonly _input: HTMLInputElement;
+ protected readonly _ratingElements: Map<string, HTMLElement>;
+
+ constructor(fieldId: string, value: string, activeCssClasses: string[], defaultCssClasses: string[]) {
+ this._field = document.getElementById(fieldId + "Container")!;
+ if (this._field === null) {
+ throw new Error("Unknown field with id '" + fieldId + "'");
+ }
+
+ this._input = document.createElement("input");
+ this._input.id = fieldId;
+ this._input.name = fieldId;
+ this._input.type = "hidden";
+ this._input.value = value;
+ this._field.appendChild(this._input);
+
+ this._activeCssClasses = activeCssClasses;
+ this._defaultCssClasses = defaultCssClasses;
+
+ this._ratingElements = new Map();
+
+ const ratingList = this._field.querySelector(".ratingList")!;
+ ratingList.addEventListener("mouseleave", () => this._restoreRating());
+
+ ratingList.querySelectorAll("li").forEach((listItem) => {
+ if (listItem.classList.contains("ratingMetaButton")) {
+ listItem.addEventListener("click", (ev) => this._metaButtonClick(ev));
+ listItem.addEventListener("mouseenter", () => this._restoreRating());
+ } else {
+ this._ratingElements.set(listItem.dataset.rating!, listItem);
+
+ listItem.addEventListener("click", (ev) => this._listItemClick(ev));
+ listItem.addEventListener("mouseenter", (ev) => this._listItemMouseEnter(ev));
+ listItem.addEventListener("mouseleave", () => this._listItemMouseLeave());
+ }
+ });
+ }
+
+ /**
+ * Saves the rating associated with the clicked rating element.
+ */
+ protected _listItemClick(event: Event): void {
+ const target = event.currentTarget as HTMLElement;
+ this._input.value = target.dataset.rating!;
+
+ if (Environment.platform() !== "desktop") {
+ this._restoreRating();
+ }
+ }
+
+ /**
+ * Updates the rating UI when hovering over a rating element.
+ */
+ protected _listItemMouseEnter(event: Event): void {
+ const target = event.currentTarget as HTMLElement;
+ const currentRating = target.dataset.rating!;
+
+ this._ratingElements.forEach((ratingElement, rating) => {
+ const icon = ratingElement.getElementsByClassName("icon")[0]! as HTMLElement;
+
+ this._toggleIcon(icon, ~~rating <= ~~currentRating);
+ });
+ }
+
+ /**
+ * Updates the rating UI when leaving a rating element by changing all rating elements
+ * to their default state.
+ */
+ protected _listItemMouseLeave(): void {
+ this._ratingElements.forEach((ratingElement) => {
+ const icon = ratingElement.getElementsByClassName("icon")[0]! as HTMLElement;
+
+ this._toggleIcon(icon, false);
+ });
+ }
+
+ /**
+ * Handles clicks on meta buttons.
+ */
+ protected _metaButtonClick(event: Event): void {
+ const target = event.currentTarget as HTMLElement;
+ if (target.dataset.action === "removeRating") {
+ this._input.value = "";
+
+ this._listItemMouseLeave();
+ }
+ }
+
+ /**
+ * Updates the rating UI by changing the rating elements to the stored rating state.
+ */
+ protected _restoreRating(): void {
+ this._ratingElements.forEach((ratingElement, rating) => {
+ const icon = ratingElement.getElementsByClassName("icon")[0]! as HTMLElement;
+
+ this._toggleIcon(icon, ~~rating <= ~~this._input.value);
+ });
+ }
+
+ /**
+ * Toggles the state of the given icon based on the given state parameter.
+ */
+ protected _toggleIcon(icon: HTMLElement, active = false): void {
+ if (active) {
+ icon.classList.remove(...this._defaultCssClasses);
+ icon.classList.add(...this._activeCssClasses);
+ } else {
+ icon.classList.remove(...this._activeCssClasses);
+ icon.classList.add(...this._defaultCssClasses);
+ }
+ }
+}
+
+Core.enableLegacyInheritance(Rating);
+
+export = Rating;
--- /dev/null
+/**
+ * Data handler for a date form builder field in an Ajax form.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Date
+ * @since 5.2
+ */
+import Field from "./Field";
+import { FormBuilderData } from "../Data";
+import DatePicker from "../../../Date/Picker";
+import * as Core from "../../../Core";
+
+class Date extends Field {
+ protected _getData(): FormBuilderData {
+ return {
+ [this._fieldId]: DatePicker.getValue(this._field as HTMLInputElement),
+ };
+ }
+}
+
+Core.enableLegacyInheritance(Date);
+
+export = Date;
--- /dev/null
+/**
+ * Abstract implementation of a form field dependency.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
+ * @since 5.2
+ */
+
+import * as DependencyManager from "./Manager";
+import * as Core from "../../../../Core";
+
+abstract class FormBuilderFormFieldDependency {
+ protected _dependentElement: HTMLElement;
+ protected _field: HTMLElement;
+ protected _fields: HTMLElement[];
+ protected _noField?: HTMLInputElement;
+
+ constructor(dependentElementId: string, fieldId: string) {
+ this.init(dependentElementId, fieldId);
+ }
+
+ /**
+ * Returns `true` if the dependency is met.
+ */
+ public checkDependency(): boolean {
+ throw new Error(
+ "Missing implementation of WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract.checkDependency!",
+ );
+ }
+
+ /**
+ * Return the node whose availability depends on the value of a field.
+ */
+ public getDependentNode(): HTMLElement {
+ return this._dependentElement;
+ }
+
+ /**
+ * Returns the field the availability of the element dependents on.
+ */
+ public getField(): HTMLElement {
+ return this._field;
+ }
+
+ /**
+ * Returns all fields requiring event listeners for this dependency to be properly resolved.
+ */
+ public getFields(): HTMLElement[] {
+ return this._fields;
+ }
+
+ /**
+ * Initializes the new dependency object.
+ */
+ protected init(dependentElementId: string, fieldId: string): void {
+ this._dependentElement = document.getElementById(dependentElementId)!;
+ if (this._dependentElement === null) {
+ throw new Error("Unknown dependent element with container id '" + dependentElementId + "Container'.");
+ }
+
+ this._field = document.getElementById(fieldId)!;
+ if (this._field === null) {
+ this._fields = [];
+ document.querySelectorAll("input[type=radio][name=" + fieldId + "]").forEach((field: HTMLInputElement) => {
+ this._fields.push(field);
+ });
+
+ if (!this._fields.length) {
+ document
+ .querySelectorAll('input[type=checkbox][name="' + fieldId + '[]"]')
+ .forEach((field: HTMLInputElement) => {
+ this._fields.push(field);
+ });
+
+ if (!this._fields.length) {
+ throw new Error("Unknown field with id '" + fieldId + "'.");
+ }
+ }
+ } else {
+ this._fields = [this._field];
+
+ // Handle special case of boolean form fields that have two form fields.
+ if (
+ this._field.tagName === "INPUT" &&
+ (this._field as HTMLInputElement).type === "radio" &&
+ this._field.dataset.noInputId !== ""
+ ) {
+ this._noField = document.getElementById(this._field.dataset.noInputId!)! as HTMLInputElement;
+ if (this._noField === null) {
+ throw new Error("Cannot find 'no' input field for input field '" + fieldId + "'");
+ }
+
+ this._fields.push(this._noField);
+ }
+ }
+
+ DependencyManager.addDependency(this);
+ }
+}
+
+Core.enableLegacyInheritance(FormBuilderFormFieldDependency);
+
+export = FormBuilderFormFieldDependency;
--- /dev/null
+/**
+ * Abstract implementation of a handler for the visibility of container due the dependencies
+ * of its children.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Abstract
+ * @since 5.2
+ */
+
+import * as DependencyManager from "../Manager";
+import * as Core from "../../../../../Core";
+
+abstract class Abstract {
+ protected _container: HTMLElement;
+
+ constructor(containerId: string) {
+ this.init(containerId);
+ }
+
+ /**
+ * Returns `true` if the dependency is met and thus if the container should be shown.
+ */
+ public checkContainer(): void {
+ throw new Error(
+ "Missing implementation of WoltLabSuite/Core/Form/Builder/Field/Dependency/Container.checkContainer!",
+ );
+ }
+
+ /**
+ * Initializes a new container dependency handler for the container with the given id.
+ */
+ protected init(containerId: string): void {
+ if (typeof containerId !== "string") {
+ throw new TypeError("Container id has to be a string.");
+ }
+
+ this._container = document.getElementById(containerId)!;
+ if (this._container === null) {
+ throw new Error("Unknown container with id '" + containerId + "'.");
+ }
+
+ DependencyManager.addContainerCheckCallback(() => this.checkContainer());
+ }
+}
+
+Core.enableLegacyInheritance(Abstract);
+
+export = Abstract;
--- /dev/null
+/**
+ * Default implementation for a container visibility handler due to the dependencies of its
+ * children that only considers the visibility of all of its children.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Default
+ * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
+ * @since 5.2
+ */
+
+import Abstract from "./Abstract";
+import * as Core from "../../../../../Core";
+import * as DependencyManager from "../Manager";
+import DomUtil from "../../../../../Dom/Util";
+
+class Default extends Abstract {
+ public checkContainer(): void {
+ if (Core.stringToBool(this._container.dataset.ignoreDependencies || "")) {
+ return;
+ }
+
+ // only consider containers that have not been hidden by their own dependencies
+ if (DependencyManager.isHiddenByDependencies(this._container)) {
+ return;
+ }
+
+ const containerIsVisible = !DomUtil.isHidden(this._container);
+ const containerShouldBeVisible = Array.from(this._container.children).some((child: HTMLElement, index) => {
+ // ignore container header for visibility considerations
+ if (index === 0 && (child.tagName === "H2" || child.tagName === "HEADER")) {
+ return false;
+ }
+
+ return !DomUtil.isHidden(child);
+ });
+
+ if (containerIsVisible !== containerShouldBeVisible) {
+ if (containerShouldBeVisible) {
+ DomUtil.show(this._container);
+ } else {
+ DomUtil.hide(this._container);
+ }
+
+ // check containers again to make sure parent containers can react to
+ // changing the visibility of this container
+ DependencyManager.checkContainers();
+ }
+ }
+}
+
+Core.enableLegacyInheritance(Default);
+
+export = Default;
--- /dev/null
+/**
+ * Container visibility handler implementation for a tab menu tab that, in addition to the
+ * tab itself, also handles the visibility of the tab menu list item.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Tab
+ * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
+ * @since 5.2
+ */
+
+import Abstract from "./Abstract";
+import * as DependencyManager from "../Manager";
+import * as DomUtil from "../../../../../Dom/Util";
+import * as UiTabMenu from "../../../../../Ui/TabMenu";
+import * as Core from "../../../../../Core";
+
+class Tab 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 = !DomUtil.isHidden(this._container);
+ const containerShouldBeVisible = Array.from(this._container.children).some(
+ (child: HTMLElement) => !DomUtil.isHidden(child),
+ );
+
+ if (containerIsVisible !== containerShouldBeVisible) {
+ const tabMenuListItem = this._container.parentNode!.parentNode!.querySelector(
+ "#" +
+ DomUtil.identify(this._container.parentNode! as HTMLElement) +
+ " > nav > ul > li[data-name=" +
+ this._container.id +
+ "]",
+ )! as HTMLElement;
+ if (tabMenuListItem === null) {
+ throw new Error("Cannot find tab menu entry for tab '" + this._container.id + "'.");
+ }
+
+ if (containerShouldBeVisible) {
+ DomUtil.show(this._container);
+ DomUtil.show(tabMenuListItem);
+ } else {
+ DomUtil.hide(this._container);
+ DomUtil.hide(tabMenuListItem);
+
+ const tabMenu = UiTabMenu.getTabMenu(
+ DomUtil.identify(tabMenuListItem.closest(".tabMenuContainer") as HTMLElement),
+ )!;
+
+ // check if currently active tab will be hidden
+ if (tabMenu.getActiveTab() === tabMenuListItem) {
+ tabMenu.selectFirstVisible();
+ }
+ }
+
+ // Check containers again to make sure parent containers can react to changing the visibility
+ // of this container.
+ DependencyManager.checkContainers();
+ }
+ }
+}
+
+Core.enableLegacyInheritance(Tab);
+
+export = Tab;
--- /dev/null
+/**
+ * Container visibility handler implementation for a tab menu that checks visibility
+ * based on the visibility of its tab menu list items.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/TabMenu
+ * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
+ * @since 5.2
+ */
+
+import Abstract from "./Abstract";
+import * as DependencyManager from "../Manager";
+import * as DomUtil from "../../../../../Dom/Util";
+import * as UiTabMenu from "../../../../../Ui/TabMenu";
+import * as Core from "../../../../../Core";
+
+class TabMenu 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 = !DomUtil.isHidden(this._container);
+ const listItems = this._container.parentNode!.querySelectorAll(
+ "#" + DomUtil.identify(this._container) + " > nav > ul > li",
+ );
+ const containerShouldBeVisible = Array.from(listItems).some((child: HTMLElement) => !DomUtil.isHidden(child));
+
+ if (containerIsVisible !== containerShouldBeVisible) {
+ if (containerShouldBeVisible) {
+ DomUtil.show(this._container);
+
+ UiTabMenu.getTabMenu(DomUtil.identify(this._container))!.selectFirstVisible();
+ } else {
+ DomUtil.hide(this._container);
+ }
+
+ // check containers again to make sure parent containers can react to
+ // changing the visibility of this container
+ DependencyManager.checkContainers();
+ }
+ }
+}
+
+Core.enableLegacyInheritance(TabMenu);
+
+export = TabMenu;
--- /dev/null
+/**
+ * Form field dependency implementation that requires the value of a field to be empty.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Empty
+ * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
+ * @since 5.2
+ */
+
+import Abstract from "./Abstract";
+import * as Core from "../../../../Core";
+
+class Empty extends Abstract {
+ public checkDependency(): boolean {
+ if (this._field !== null) {
+ switch (this._field.tagName) {
+ case "INPUT": {
+ const field = this._field as HTMLInputElement;
+ switch (field.type) {
+ case "checkbox":
+ return !field.checked;
+
+ case "radio":
+ if (this._noField && this._noField.checked) {
+ return true;
+ }
+
+ return !field.checked;
+
+ default:
+ return field.value.trim().length === 0;
+ }
+ }
+
+ case "SELECT": {
+ const field = this._field as HTMLSelectElement;
+ if (field.multiple) {
+ return this._field.querySelectorAll("option:checked").length === 0;
+ }
+
+ return field.value == "0" || field.value.length === 0;
+ }
+
+ case "TEXTAREA": {
+ return (this._field as HTMLTextAreaElement).value.trim().length === 0;
+ }
+ }
+ }
+
+ // Check that none of the fields are checked.
+ return this._fields.every((field: HTMLInputElement) => !field.checked);
+ }
+}
+
+Core.enableLegacyInheritance(Empty);
+
+export = Empty;
--- /dev/null
+/**
+ * Form field dependency implementation that requires that a button has not been clicked.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/IsNotClicked
+ * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
+ * @since 5.4
+ */
+
+import Abstract from "./Abstract";
+import * as DependencyManager from "./Manager";
+
+export class IsNotClicked extends Abstract {
+ constructor(dependentElementId: string, fieldId: string) {
+ super(dependentElementId, fieldId);
+
+ // To check for clicks after they occured, set `isClicked` in the field's data set and then
+ // explicitly check the dependencies as the dependency manager itself does to listen to click
+ // events.
+ this._field.addEventListener("click", () => {
+ this._field.dataset.isClicked = "1";
+
+ DependencyManager.checkDependencies();
+ });
+ }
+
+ checkDependency(): boolean {
+ return this._field.dataset.isClicked !== "1";
+ }
+}
+
+export default IsNotClicked;
--- /dev/null
+/**
+ * Manages form field dependencies.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Manager
+ * @since 5.2
+ */
+
+import DomUtil from "../../../../Dom/Util";
+import * as EventHandler from "../../../../Event/Handler";
+import FormBuilderFormFieldDependency from "./Abstract";
+
+type PropertiesMap = Map<string, string>;
+
+const _dependencyHiddenNodes = new Set<HTMLElement>();
+const _fields = new Map<string, HTMLElement>();
+const _forms = new WeakSet<HTMLElement>();
+const _nodeDependencies = new Map<string, FormBuilderFormFieldDependency[]>();
+const _validatedFieldProperties = new WeakMap<HTMLElement, PropertiesMap>();
+
+let _checkingContainers = false;
+let _checkContainersAgain = true;
+
+type Callback = (...args: any[]) => void;
+
+/**
+ * Hides the given node because of its own dependencies.
+ */
+function _hide(node: HTMLElement): void {
+ DomUtil.hide(node);
+ _dependencyHiddenNodes.add(node);
+
+ // also hide tab menu entry
+ if (node.classList.contains("tabMenuContent")) {
+ node
+ .parentNode!.querySelector(".tabMenu")!
+ .querySelectorAll("li")
+ .forEach((tabLink) => {
+ if (tabLink.dataset.name === node.dataset.name) {
+ DomUtil.hide(tabLink);
+ }
+ });
+ }
+
+ node.querySelectorAll("[max], [maxlength], [min], [required]").forEach((validatedField: HTMLInputElement) => {
+ const properties = new Map<string, string>();
+
+ const max = validatedField.getAttribute("max");
+ if (max) {
+ properties.set("max", max);
+ validatedField.removeAttribute("max");
+ }
+
+ const maxlength = validatedField.getAttribute("maxlength");
+ if (maxlength) {
+ properties.set("maxlength", maxlength);
+ validatedField.removeAttribute("maxlength");
+ }
+
+ const min = validatedField.getAttribute("min");
+ if (min) {
+ properties.set("min", min);
+ validatedField.removeAttribute("min");
+ }
+
+ if (validatedField.required) {
+ properties.set("required", "true");
+ validatedField.removeAttribute("required");
+ }
+
+ _validatedFieldProperties.set(validatedField, properties);
+ });
+}
+
+/**
+ * Shows the given node because of its own dependencies.
+ */
+function _show(node: HTMLElement): void {
+ DomUtil.show(node);
+ _dependencyHiddenNodes.delete(node);
+
+ // also show tab menu entry
+ if (node.classList.contains("tabMenuContent")) {
+ node
+ .parentNode!.querySelector(".tabMenu")!
+ .querySelectorAll("li")
+ .forEach((tabLink) => {
+ if (tabLink.dataset.name === node.dataset.name) {
+ DomUtil.show(tabLink);
+ }
+ });
+ }
+
+ node.querySelectorAll("input, select").forEach((validatedField: HTMLInputElement | HTMLSelectElement) => {
+ // if a container is shown, ignore all fields that
+ // have a hidden parent element within the container
+ let parentNode = validatedField.parentNode! as HTMLElement;
+ while (parentNode !== node && !DomUtil.isHidden(parentNode)) {
+ parentNode = parentNode.parentNode! as HTMLElement;
+ }
+
+ if (parentNode === node && _validatedFieldProperties.has(validatedField)) {
+ const properties = _validatedFieldProperties.get(validatedField)!;
+
+ if (properties.has("max")) {
+ validatedField.setAttribute("max", properties.get("max")!);
+ }
+ if (properties.has("maxlength")) {
+ validatedField.setAttribute("maxlength", properties.get("maxlength")!);
+ }
+ if (properties.has("min")) {
+ validatedField.setAttribute("min", properties.get("min")!);
+ }
+ if (properties.has("required")) {
+ validatedField.setAttribute("required", "");
+ }
+
+ _validatedFieldProperties.delete(validatedField);
+ }
+ });
+}
+
+/**
+ * Adds the given callback to the list of callbacks called when checking containers.
+ */
+export function addContainerCheckCallback(callback: Callback): void {
+ if (typeof callback !== "function") {
+ throw new TypeError("Expected a valid callback for parameter 'callback'.");
+ }
+
+ EventHandler.add("com.woltlab.wcf.form.builder.dependency", "checkContainers", callback);
+}
+
+/**
+ * Registers a new form field dependency.
+ */
+export function addDependency(dependency: FormBuilderFormFieldDependency): void {
+ const dependentNode = dependency.getDependentNode();
+ if (!_nodeDependencies.has(dependentNode.id)) {
+ _nodeDependencies.set(dependentNode.id, [dependency]);
+ } else {
+ _nodeDependencies.get(dependentNode.id)!.push(dependency);
+ }
+
+ dependency.getFields().forEach((field) => {
+ const id = DomUtil.identify(field);
+
+ if (!_fields.has(id)) {
+ _fields.set(id, field);
+
+ if (
+ field.tagName === "INPUT" &&
+ ((field as HTMLInputElement).type === "checkbox" ||
+ (field as HTMLInputElement).type === "radio" ||
+ (field as HTMLInputElement).type === "hidden")
+ ) {
+ field.addEventListener("change", () => checkDependencies());
+ } else {
+ field.addEventListener("input", () => checkDependencies());
+ }
+ }
+ });
+}
+
+/**
+ * Checks the containers for their availability.
+ *
+ * If this function is called while containers are currently checked, the containers
+ * will be checked after the current check has been finished completely.
+ */
+export function checkContainers(): void {
+ // check if containers are currently being checked
+ if (_checkingContainers === true) {
+ // and if that is the case, calling this method indicates, that after the current round,
+ // containters should be checked to properly propagate changes in children to their parents
+ _checkContainersAgain = true;
+
+ return;
+ }
+
+ // starting to check containers also resets the flag to check containers again after the current check
+ _checkingContainers = true;
+ _checkContainersAgain = false;
+
+ EventHandler.fire("com.woltlab.wcf.form.builder.dependency", "checkContainers");
+
+ // finish checking containers and check if containters should be checked again
+ _checkingContainers = false;
+ if (_checkContainersAgain) {
+ checkContainers();
+ }
+}
+
+/**
+ * Checks if all dependencies are met.
+ */
+export function checkDependencies(): void {
+ const obsoleteNodeIds: string[] = [];
+
+ _nodeDependencies.forEach((nodeDependencies, nodeId) => {
+ const dependentNode = document.getElementById(nodeId);
+ if (dependentNode === null) {
+ obsoleteNodeIds.push(nodeId);
+
+ return;
+ }
+
+ let dependenciesMet = true;
+ nodeDependencies.forEach((dependency) => {
+ if (!dependency.checkDependency()) {
+ _hide(dependentNode);
+ dependenciesMet = false;
+ }
+ });
+
+ if (dependenciesMet) {
+ _show(dependentNode);
+ }
+ });
+
+ obsoleteNodeIds.forEach((id) => _nodeDependencies.delete(id));
+
+ checkContainers();
+}
+
+/**
+ * Returns `true` if the given node has been hidden because of its own dependencies.
+ */
+export function isHiddenByDependencies(node: HTMLElement): boolean {
+ if (_dependencyHiddenNodes.has(node)) {
+ return true;
+ }
+
+ let returnValue = false;
+ _dependencyHiddenNodes.forEach((hiddenNode) => {
+ if (node.contains(hiddenNode)) {
+ returnValue = true;
+ }
+ });
+
+ return returnValue;
+}
+
+/**
+ * Registers the form with the given id with the dependency manager.
+ */
+export function register(formId: string): void {
+ const form = document.getElementById(formId);
+
+ if (form === null) {
+ throw new Error("Unknown element with id '" + formId + "'");
+ }
+
+ if (_forms.has(form)) {
+ throw new Error("Form with id '" + formId + "' has already been registered.");
+ }
+
+ _forms.add(form);
+}
+
+/**
+ * Unregisters the form with the given id and all of its dependencies.
+ */
+export function unregister(formId: string): void {
+ const form = document.getElementById(formId);
+
+ if (form === null) {
+ throw new Error("Unknown element with id '" + formId + "'");
+ }
+
+ if (!_forms.has(form)) {
+ throw new Error("Form with id '" + formId + "' has not been registered.");
+ }
+
+ _forms.delete(form);
+
+ _dependencyHiddenNodes.forEach((hiddenNode) => {
+ if (form.contains(hiddenNode)) {
+ _dependencyHiddenNodes.delete(hiddenNode);
+ }
+ });
+ _nodeDependencies.forEach((dependencies, nodeId) => {
+ if (form.contains(document.getElementById(nodeId))) {
+ _nodeDependencies.delete(nodeId);
+ }
+
+ dependencies.forEach((dependency) => {
+ dependency.getFields().forEach((field) => {
+ _fields.delete(field.id);
+
+ _validatedFieldProperties.delete(field);
+ });
+ });
+ });
+}
--- /dev/null
+/**
+ * Form field dependency implementation that requires the value of a field not to be empty.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/NonEmpty
+ * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
+ * @since 5.2
+ */
+
+import Abstract from "./Abstract";
+import * as Core from "../../../../Core";
+
+class NonEmpty extends Abstract {
+ public checkDependency(): boolean {
+ if (this._field !== null) {
+ switch (this._field.tagName) {
+ case "INPUT": {
+ const field = this._field as HTMLInputElement;
+ switch (field.type) {
+ case "checkbox":
+ return field.checked;
+
+ case "radio":
+ if (this._noField && this._noField.checked) {
+ return false;
+ }
+
+ return field.checked;
+
+ default:
+ return field.value.trim().length !== 0;
+ }
+ }
+
+ case "SELECT": {
+ const field = this._field as HTMLSelectElement;
+ if (field.multiple) {
+ return field.querySelectorAll("option:checked").length !== 0;
+ }
+
+ return field.value != "0" && field.value.length !== 0;
+ }
+
+ case "TEXTAREA": {
+ return (this._field as HTMLTextAreaElement).value.trim().length !== 0;
+ }
+ }
+ }
+
+ // Check if any of the fields if checked.
+ return this._fields.some((field: HTMLInputElement) => field.checked);
+ }
+}
+
+Core.enableLegacyInheritance(NonEmpty);
+
+export = NonEmpty;
--- /dev/null
+/**
+ * Form field dependency implementation that requires a field to have a certain value.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Value
+ * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
+ * @since 5.2
+ */
+
+import Abstract from "./Abstract";
+import * as DependencyManager from "./Manager";
+import * as Core from "../../../../Core";
+
+class Value extends Abstract {
+ protected _isNegated = false;
+ protected _values?: string[];
+
+ checkDependency(): boolean {
+ if (!this._values) {
+ throw new Error("Values have not been set.");
+ }
+
+ const values: string[] = [];
+ if (this._field) {
+ if (DependencyManager.isHiddenByDependencies(this._field)) {
+ return false;
+ }
+
+ values.push((this._field as HTMLInputElement).value);
+ } else {
+ let hasCheckedField = true;
+ this._fields.forEach((field: HTMLInputElement) => {
+ if (field.checked) {
+ if (DependencyManager.isHiddenByDependencies(field)) {
+ hasCheckedField = false;
+ return false;
+ }
+
+ values.push(field.value);
+ }
+ });
+
+ if (!hasCheckedField) {
+ return false;
+ }
+ }
+
+ let foundMatch = false;
+ this._values.forEach((value) => {
+ values.forEach((selectedValue) => {
+ if (value == selectedValue) {
+ foundMatch = true;
+ }
+ });
+ });
+
+ if (foundMatch) {
+ return !this._isNegated;
+ }
+
+ return this._isNegated;
+ }
+
+ /**
+ * Sets if the field value may not have any of the set values.
+ */
+ negate(negate: boolean): Value {
+ this._isNegated = negate;
+
+ return this;
+ }
+
+ /**
+ * Sets the possible values the field may have for the dependency to be met.
+ */
+ values(values: string[]): Value {
+ this._values = values;
+
+ return this;
+ }
+}
+
+Core.enableLegacyInheritance(Value);
+
+export = Value;
--- /dev/null
+/**
+ * Data handler for a form builder field in an Ajax form.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Field
+ * @since 5.2
+ */
+
+import * as Core from "../../../Core";
+import { FormBuilderData } from "../Data";
+
+class Field {
+ protected _fieldId: string;
+ protected _field: HTMLElement | null;
+
+ constructor(fieldId: string) {
+ this.init(fieldId);
+ }
+
+ /**
+ * Initializes the field.
+ */
+ protected init(fieldId: string): void {
+ this._fieldId = fieldId;
+
+ this._readField();
+ }
+
+ /**
+ * Returns the current data of the field or a promise returning the current data
+ * of the field.
+ *
+ * @return {Promise|data}
+ */
+ protected _getData(): FormBuilderData {
+ throw new Error("Missing implementation of WoltLabSuite/Core/Form/Builder/Field/Field._getData!");
+ }
+
+ /**
+ * Reads the field's HTML element.
+ */
+ protected _readField(): void {
+ this._field = document.getElementById(this._fieldId);
+
+ if (this._field === null) {
+ throw new Error("Unknown field with id '" + this._fieldId + "'.");
+ }
+ }
+
+ /**
+ * Destroys the field.
+ *
+ * This function is useful for remove registered elements from other APIs like dialogs.
+ */
+ public destroy(): void {
+ // does nothinbg
+ }
+
+ /**
+ * Returns a promise providing the current data of the field.
+ */
+ public getData(): Promise<FormBuilderData> {
+ return Promise.resolve(this._getData());
+ }
+
+ /**
+ * Returns the id of the field.
+ */
+ public getId(): string {
+ return this._fieldId;
+ }
+}
+
+Core.enableLegacyInheritance(Field);
+
+export = Field;
--- /dev/null
+/**
+ * Data handler for an item list form builder field in an Ajax form.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/ItemList
+ * @since 5.2
+ */
+
+import Field from "./Field";
+import * as UiItemListStatic from "../../../Ui/ItemList/Static";
+import { FormBuilderData } from "../Data";
+import * as Core from "../../../Core";
+
+class ItemList extends Field {
+ protected _getData(): FormBuilderData {
+ const values: string[] = [];
+ UiItemListStatic.getValues(this._fieldId).forEach((item) => {
+ if (item.objectId) {
+ values[item.objectId] = item.value;
+ } else {
+ values.push(item.value);
+ }
+ });
+
+ return {
+ [this._fieldId]: values,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(ItemList);
+
+export = ItemList;
--- /dev/null
+/**
+ * Data handler for a content language form builder field in an Ajax form.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Language/ContentLanguage
+ * @since 5.2
+ */
+
+import Value from "../Value";
+import * as LanguageChooser from "../../../../Language/Chooser";
+import * as Core from "../../../../Core";
+
+class ContentLanguage extends Value {
+ public destroy(): void {
+ LanguageChooser.removeChooser(this._fieldId);
+ }
+}
+
+Core.enableLegacyInheritance(ContentLanguage);
+
+export = ContentLanguage;
--- /dev/null
+/**
+ * Data handler for a radio button form builder field in an Ajax form.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/RadioButton
+ * @since 5.2
+ */
+
+import Field from "./Field";
+import { FormBuilderData } from "../Data";
+import * as Core from "../../../Core";
+
+class RadioButton extends Field {
+ protected _fields: HTMLInputElement[];
+
+ protected _getData(): FormBuilderData {
+ const data = {};
+
+ this._fields.some((input) => {
+ if (input.checked) {
+ data[this._fieldId] = input.value;
+ return true;
+ }
+
+ return false;
+ });
+
+ return data;
+ }
+
+ protected _readField(): void {
+ this._fields = Array.from(document.querySelectorAll("input[name=" + this._fieldId + "]"));
+ }
+}
+
+Core.enableLegacyInheritance(RadioButton);
+
+export = RadioButton;
--- /dev/null
+import Field from "./Field";
+import { FormBuilderData } from "../Data";
+import * as Core from "../../../Core";
+
+class SimpleAcl extends Field {
+ protected _getData(): FormBuilderData {
+ const groupIds = Array.from(document.querySelectorAll('input[name="' + this._fieldId + '[group][]"]')).map(
+ (input: HTMLInputElement) => input.value,
+ );
+
+ const usersIds = Array.from(document.querySelectorAll('input[name="' + this._fieldId + '[user][]"]')).map(
+ (input: HTMLInputElement) => input.value,
+ );
+
+ return {
+ [this._fieldId]: {
+ group: groupIds,
+ user: usersIds,
+ },
+ };
+ }
+
+ protected _readField(): void {
+ // does nothing
+ }
+}
+
+Core.enableLegacyInheritance(SimpleAcl);
+
+export = SimpleAcl;
--- /dev/null
+/**
+ * Data handler for a tag form builder field in an Ajax form.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Tag
+ * @since 5.2
+ */
+
+import Field from "./Field";
+import * as UiItemList from "../../../Ui/ItemList";
+import { FormBuilderData } from "../Data";
+import * as Core from "../../../Core";
+
+class Tag extends Field {
+ protected _getData(): FormBuilderData {
+ const values: string[] = UiItemList.getValues(this._fieldId).map((item) => item.value);
+
+ return {
+ [this._fieldId]: values,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(Tag);
+
+export = Tag;
--- /dev/null
+/**
+ * Data handler for a user form builder field in an Ajax form.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/User
+ * @since 5.2
+ */
+
+import Field from "./Field";
+import { FormBuilderData } from "../Data";
+import * as Core from "../../../Core";
+import * as UiItemList from "../../../Ui/ItemList/Static";
+
+class User extends Field {
+ protected _getData(): FormBuilderData {
+ const usernames = UiItemList.getValues(this._fieldId)
+ .map((item) => {
+ if (item.objectId) {
+ return item.value;
+ }
+
+ return null;
+ })
+ .filter((v) => v !== null) as string[];
+
+ return {
+ [this._fieldId]: usernames.join(","),
+ };
+ }
+}
+
+Core.enableLegacyInheritance(User);
+
+export = User;
--- /dev/null
+/**
+ * Data handler for a form builder field in an Ajax form that stores its value in an input's value
+ * attribute.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Value
+ * @since 5.2
+ */
+
+import Field from "./Field";
+import { FormBuilderData } from "../Data";
+import * as Core from "../../../Core";
+
+class Value extends Field {
+ protected _getData(): FormBuilderData {
+ return {
+ [this._fieldId]: (this._field as HTMLInputElement).value,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(Value);
+
+export = Value;
--- /dev/null
+/**
+ * Data handler for an i18n form builder field in an Ajax form that stores its value in an input's
+ * value attribute.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/ValueI18n
+ * @since 5.2
+ */
+
+import Field from "./Field";
+import { FormBuilderData } from "../Data";
+import * as LanguageInput from "../../../Language/Input";
+import * as Core from "../../../Core";
+
+class ValueI18n extends Field {
+ protected _getData(): FormBuilderData {
+ const data = {};
+
+ const values = LanguageInput.getValues(this._fieldId);
+ if (values.size > 1) {
+ values.forEach((value, key) => {
+ data[this._fieldId + "_i18n"][key] = value;
+ });
+ } else {
+ data[this._fieldId] = values.get(0);
+ }
+
+ return data;
+ }
+
+ destroy(): void {
+ LanguageInput.unregister(this._fieldId);
+ }
+}
+
+Core.enableLegacyInheritance(ValueI18n);
+
+export = ValueI18n;
--- /dev/null
+/**
+ * Data handler for a wysiwyg attachment form builder field that stores the temporary hash.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Field/Wysiwyg/Attachment
+ * @since 5.2
+ */
+
+import Value from "../Value";
+import * as Core from "../../../../Core";
+
+class Attachment extends Value {
+ constructor(fieldId: string) {
+ super(fieldId + "_tmpHash");
+ }
+}
+
+Core.enableLegacyInheritance(Attachment);
+
+export = Attachment;
--- /dev/null
+/**
+ * Data handler for the poll options.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Poll
+ * @since 5.2
+ */
+
+import Field from "../Field";
+import * as Core from "../../../../Core";
+import { FormBuilderData } from "../../Data";
+import UiPollEditor from "../../../../Ui/Poll/Editor";
+
+class Poll extends Field {
+ protected _pollEditor: UiPollEditor;
+
+ protected _getData(): FormBuilderData {
+ return this._pollEditor.getData();
+ }
+
+ protected _readField(): void {
+ // does nothing
+ }
+
+ public setPollEditor(pollEditor: UiPollEditor): void {
+ this._pollEditor = pollEditor;
+ }
+}
+
+Core.enableLegacyInheritance(Poll);
+
+export = Poll;
--- /dev/null
+/**
+ * Manager for registered Ajax forms and its fields that can be used to retrieve the current data
+ * of the registered forms.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Form/Builder/Manager
+ * @since 5.2
+ */
+
+import * as Core from "../../Core";
+import * as EventHandler from "../../Event/Handler";
+import Field from "./Field/Field";
+import * as DependencyManager from "./Field/Dependency/Manager";
+import { FormBuilderData } from "./Data";
+
+type FormId = string;
+type FieldId = string;
+
+const _fields = new Map<FormId, Map<FieldId, Field>>();
+const _forms = new Map<FormId, HTMLElement>();
+
+/**
+ * Returns a promise returning the data of the form with the given id.
+ */
+export function getData(formId: FieldId): Promise<FormBuilderData> {
+ if (!hasForm(formId)) {
+ throw new Error("Unknown form with id '" + formId + "'.");
+ }
+
+ const promises: Promise<FormBuilderData>[] = [];
+
+ _fields.get(formId)!.forEach((field) => {
+ const fieldData = field.getData();
+
+ if (!(fieldData instanceof Promise)) {
+ throw new TypeError("Data for field with id '" + field.getId() + "' is no promise.");
+ }
+
+ promises.push(fieldData);
+ });
+
+ return Promise.all(promises).then((promiseData: FormBuilderData[]) => {
+ return promiseData.reduce((carry, current) => Core.extend(carry, current), {});
+ });
+}
+
+/**
+ * Returns the registered form field with given.
+ *
+ * @since 5.2.3
+ */
+export function getField(formId: FieldId, fieldId: FieldId): Field {
+ if (!hasField(formId, fieldId)) {
+ throw new Error("Unknown field with id '" + formId + "' for form with id '" + fieldId + "'.");
+ }
+
+ return _fields.get(formId)!.get(fieldId)!;
+}
+
+/**
+ * Returns the registered form with given id.
+ */
+export function getForm(formId: FieldId): HTMLElement {
+ if (!hasForm(formId)) {
+ throw new Error("Unknown form with id '" + formId + "'.");
+ }
+
+ return _forms.get(formId)!;
+}
+
+/**
+ * Returns `true` if a field with the given id has been registered for the form with the given id
+ * and `false` otherwise.
+ */
+export function hasField(formId: FieldId, fieldId: FieldId): boolean {
+ if (!hasForm(formId)) {
+ throw new Error("Unknown form with id '" + formId + "'.");
+ }
+
+ return _fields.get(formId)!.has(fieldId);
+}
+
+/**
+ * Returns `true` if a form with the given id has been registered and `false` otherwise.
+ */
+export function hasForm(formId: FieldId): boolean {
+ return _forms.has(formId);
+}
+
+/**
+ * Registers the given field for the form with the given id.
+ */
+export function registerField(formId: FieldId, field: Field): void {
+ if (!hasForm(formId)) {
+ throw new Error("Unknown form with id '" + formId + "'.");
+ }
+
+ if (!(field instanceof Field)) {
+ throw new Error("Add field is no instance of 'WoltLabSuite/Core/Form/Builder/Field/Field'.");
+ }
+
+ const fieldId = field.getId();
+
+ if (hasField(formId, fieldId)) {
+ throw new Error(
+ "Form field with id '" + fieldId + "' has already been registered for form with id '" + formId + "'.",
+ );
+ }
+
+ _fields.get(formId)!.set(fieldId, field);
+
+ EventHandler.fire("WoltLabSuite/Core/Form/Builder/Manager", "registerField", {
+ field: field,
+ formId: formId,
+ });
+}
+
+/**
+ * Registers the form with the given id.
+ */
+export function registerForm(formId: FieldId): void {
+ if (hasForm(formId)) {
+ throw new Error("Form with id '" + formId + "' has already been registered.");
+ }
+
+ const form = document.getElementById(formId);
+ if (form === null) {
+ throw new Error("Unknown form with id '" + formId + "'.");
+ }
+
+ _forms.set(formId, form);
+ _fields.set(formId, new Map<FieldId, Field>());
+
+ EventHandler.fire("WoltLabSuite/Core/Form/Builder/Manager", "registerForm", {
+ formId: formId,
+ });
+}
+
+/**
+ * Unregisters the form with the given id.
+ */
+export function unregisterForm(formId: FieldId): void {
+ if (!hasForm(formId)) {
+ throw new Error("Unknown form with id '" + formId + "'.");
+ }
+
+ EventHandler.fire("WoltLabSuite/Core/Form/Builder/Manager", "beforeUnregisterForm", {
+ formId: formId,
+ });
+
+ _forms.delete(formId);
+
+ _fields.get(formId)!.forEach(function (field) {
+ field.destroy();
+ });
+
+ _fields.delete(formId);
+
+ DependencyManager.unregister(formId);
+
+ EventHandler.fire("WoltLabSuite/Core/Form/Builder/Manager", "afterUnregisterForm", {
+ formId: formId,
+ });
+}
--- /dev/null
+/**
+ * Generates plural phrases for the `plural` template plugin.
+ *
+ * @author Matthias Schmidt, Marcel Werk
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/I18n/Plural
+ */
+
+import * as StringUtil from "../StringUtil";
+
+const enum Category {
+ Few = "few",
+ Many = "many",
+ One = "one",
+ Other = "other",
+ Two = "two",
+ Zero = "zero",
+}
+
+const Languages = {
+ // Afrikaans
+ af(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Amharic
+ am(n: number): Category | undefined {
+ const i = Math.floor(Math.abs(n));
+ if (n == 1 || i === 0) {
+ return Category.One;
+ }
+ },
+
+ // Arabic
+ ar(n: number): Category | undefined {
+ if (n == 0) {
+ return Category.Zero;
+ }
+ if (n == 1) {
+ return Category.One;
+ }
+ if (n == 2) {
+ return Category.Two;
+ }
+
+ const mod100 = n % 100;
+ if (mod100 >= 3 && mod100 <= 10) {
+ return Category.Few;
+ }
+ if (mod100 >= 11 && mod100 <= 99) {
+ return Category.Many;
+ }
+ },
+
+ // Assamese
+ as(n: number): Category | undefined {
+ const i = Math.floor(Math.abs(n));
+ if (n == 1 || i === 0) {
+ return Category.One;
+ }
+ },
+
+ // Azerbaijani
+ az(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Belarusian
+ be(n: number): Category | undefined {
+ const mod10 = n % 10;
+ const mod100 = n % 100;
+
+ if (mod10 == 1 && mod100 != 11) {
+ return Category.One;
+ }
+ if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) {
+ return Category.Few;
+ }
+ if (mod10 == 0 || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 11 && mod100 <= 14)) {
+ return Category.Many;
+ }
+ },
+
+ // Bulgarian
+ bg(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Bengali
+ bn(n: number): Category | undefined {
+ const i = Math.floor(Math.abs(n));
+ if (n == 1 || i === 0) {
+ return Category.One;
+ }
+ },
+
+ // Tibetan
+ bo(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Bosnian
+ bs(n: number): Category | undefined {
+ const v = Plural.getV(n);
+ const f = Plural.getF(n);
+ const mod10 = n % 10;
+ const mod100 = n % 100;
+ const fMod10 = f % 10;
+ const fMod100 = f % 100;
+
+ if ((v == 0 && mod10 == 1 && mod100 != 11) || (fMod10 == 1 && fMod100 != 11)) {
+ return Category.One;
+ }
+ if (
+ (v == 0 && mod10 >= 2 && mod10 <= 4 && mod100 >= 12 && mod100 <= 14) ||
+ (fMod10 >= 2 && fMod10 <= 4 && fMod100 >= 12 && fMod100 <= 14)
+ ) {
+ return Category.Few;
+ }
+ },
+
+ // Czech
+ cs(n: number): Category | undefined {
+ const v = Plural.getV(n);
+
+ if (n == 1 && v === 0) {
+ return Category.One;
+ }
+ if (n >= 2 && n <= 4 && v === 0) {
+ return Category.Few;
+ }
+ if (v === 0) {
+ return Category.Many;
+ }
+ },
+
+ // Welsh
+ cy(n: number): Category | undefined {
+ if (n == 0) {
+ return Category.Zero;
+ }
+ if (n == 1) {
+ return Category.One;
+ }
+ if (n == 2) {
+ return Category.Two;
+ }
+ if (n == 3) {
+ return Category.Few;
+ }
+ if (n == 6) {
+ return Category.Many;
+ }
+ },
+
+ // Danish
+ da(n: number): Category | undefined {
+ if (n > 0 && n < 2) {
+ return Category.One;
+ }
+ },
+
+ // Greek
+ el(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Catalan (ca)
+ // German (de)
+ // English (en)
+ // Estonian (et)
+ // Finnish (fi)
+ // Italian (it)
+ // Dutch (nl)
+ // Swedish (sv)
+ // Swahili (sw)
+ // Urdu (ur)
+ en(n: number): Category | undefined {
+ if (n == 1 && Plural.getV(n) === 0) {
+ return Category.One;
+ }
+ },
+
+ // Spanish
+ es(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Basque
+ eu(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Persian
+ fa(n: number): Category | undefined {
+ if (n >= 0 && n <= 1) {
+ return Category.One;
+ }
+ },
+
+ // French
+ fr(n: number): Category | undefined {
+ if (n >= 0 && n < 2) {
+ return Category.One;
+ }
+ },
+
+ // Irish
+ ga(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ if (n == 2) {
+ return Category.Two;
+ }
+ if (n == 3 || n == 4 || n == 5 || n == 6) {
+ return Category.Few;
+ }
+ if (n == 7 || n == 8 || n == 9 || n == 10) {
+ return Category.Many;
+ }
+ },
+
+ // Gujarati
+ gu(n: number): Category | undefined {
+ if (n >= 0 && n <= 1) {
+ return Category.One;
+ }
+ },
+
+ // Hebrew
+ he(n: number): Category | undefined {
+ const v = Plural.getV(n);
+
+ if (n == 1 && v === 0) {
+ return Category.One;
+ }
+ if (n == 2 && v === 0) {
+ return Category.Two;
+ }
+ if (n > 10 && v === 0 && n % 10 == 0) {
+ return Category.Many;
+ }
+ },
+
+ // Hindi
+ hi(n: number): Category | undefined {
+ if (n >= 0 && n <= 1) {
+ return Category.One;
+ }
+ },
+
+ // Croatian
+ hr(n: number): Category | undefined {
+ // same as Bosnian
+ return Plural.bs(n);
+ },
+
+ // Hungarian
+ hu(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Armenian
+ hy(n: number): Category | undefined {
+ if (n >= 0 && n < 2) {
+ return Category.One;
+ }
+ },
+
+ // Indonesian
+ id(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Icelandic
+ is(n: number): Category | undefined {
+ const f = Plural.getF(n);
+
+ if ((f === 0 && n % 10 === 1 && !(n % 100 === 11)) || !(f === 0)) {
+ return Category.One;
+ }
+ },
+
+ // Japanese
+ ja(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Javanese
+ jv(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Georgian
+ ka(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Kazakh
+ kk(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Khmer
+ km(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Kannada
+ kn(n: number): Category | undefined {
+ if (n >= 0 && n <= 1) {
+ return Category.One;
+ }
+ },
+
+ // Korean
+ ko(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Kurdish
+ ku(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Kyrgyz
+ ky(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Luxembourgish
+ lb(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Lao
+ lo(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Lithuanian
+ lt(n: number): Category | undefined {
+ const mod10 = n % 10;
+ const mod100 = n % 100;
+
+ if (mod10 == 1 && !(mod100 >= 11 && mod100 <= 19)) {
+ return Category.One;
+ }
+ if (mod10 >= 2 && mod10 <= 9 && !(mod100 >= 11 && mod100 <= 19)) {
+ return Category.Few;
+ }
+ if (Plural.getF(n) != 0) {
+ return Category.Many;
+ }
+ },
+
+ // Latvian
+ lv(n: number): Category | undefined {
+ const mod10 = n % 10;
+ const mod100 = n % 100;
+ const v = Plural.getV(n);
+ const f = Plural.getF(n);
+ const fMod10 = f % 10;
+ const fMod100 = f % 100;
+
+ if (mod10 == 0 || (mod100 >= 11 && mod100 <= 19) || (v == 2 && fMod100 >= 11 && fMod100 <= 19)) {
+ return Category.Zero;
+ }
+ if ((mod10 == 1 && mod100 != 11) || (v == 2 && fMod10 == 1 && fMod100 != 11) || (v != 2 && fMod10 == 1)) {
+ return Category.One;
+ }
+ },
+
+ // Macedonian
+ mk(n: number): Category | undefined {
+ return Plural.bs(n);
+ },
+
+ // Malayalam
+ ml(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Mongolian
+ mn(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Marathi
+ mr(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Malay
+ ms(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Maltese
+ mt(n: number): Category | undefined {
+ const mod100 = n % 100;
+
+ if (n == 1) {
+ return Category.One;
+ }
+ if (n == 0 || (mod100 >= 2 && mod100 <= 10)) {
+ return Category.Few;
+ }
+ if (mod100 >= 11 && mod100 <= 19) {
+ return Category.Many;
+ }
+ },
+
+ // Burmese
+ my(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Norwegian
+ no(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Nepali
+ ne(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Odia
+ or(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Punjabi
+ pa(n: number): Category | undefined {
+ if (n == 1 || n == 0) {
+ return Category.One;
+ }
+ },
+
+ // Polish
+ pl(n: number): Category | undefined {
+ const v = Plural.getV(n);
+ const mod10 = n % 10;
+ const mod100 = n % 100;
+
+ if (n == 1 && v == 0) {
+ return Category.One;
+ }
+ if (v == 0 && mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) {
+ return Category.Few;
+ }
+ if (
+ v == 0 &&
+ ((n != 1 && mod10 >= 0 && mod10 <= 1) || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 12 && mod100 <= 14))
+ ) {
+ return Category.Many;
+ }
+ },
+
+ // Pashto
+ ps(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Portuguese
+ pt(n: number): Category | undefined {
+ if (n >= 0 && n < 2) {
+ return Category.One;
+ }
+ },
+
+ // Romanian
+ ro(n: number): Category | undefined {
+ const v = Plural.getV(n);
+ const mod100 = n % 100;
+
+ if (n == 1 && v === 0) {
+ return Category.One;
+ }
+ if (v != 0 || n == 0 || (mod100 >= 2 && mod100 <= 19)) {
+ return Category.Few;
+ }
+ },
+
+ // Russian
+ ru(n: number): Category | undefined {
+ const mod10 = n % 10;
+ const mod100 = n % 100;
+
+ if (Plural.getV(n) == 0) {
+ if (mod10 == 1 && mod100 != 11) {
+ return Category.One;
+ }
+ if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) {
+ return Category.Few;
+ }
+ if (mod10 == 0 || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 11 && mod100 <= 14)) {
+ return Category.Many;
+ }
+ }
+ },
+
+ // Sindhi
+ sd(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Sinhala
+ si(n: number): Category | undefined {
+ if (n == 0 || n == 1 || (Math.floor(n) == 0 && Plural.getF(n) == 1)) {
+ return Category.One;
+ }
+ },
+
+ // Slovak
+ sk(n: number): Category | undefined {
+ // same as Czech
+ return Plural.cs(n);
+ },
+
+ // Slovenian
+ sl(n: number): Category | undefined {
+ const v = Plural.getV(n);
+ const mod100 = n % 100;
+
+ if (v == 0 && mod100 == 1) {
+ return Category.One;
+ }
+ if (v == 0 && mod100 == 2) {
+ return Category.Two;
+ }
+ if ((v == 0 && (mod100 == 3 || mod100 == 4)) || v != 0) {
+ return Category.Few;
+ }
+ },
+
+ // Albanian
+ sq(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Serbian
+ sr(n: number): Category | undefined {
+ // same as Bosnian
+ return Plural.bs(n);
+ },
+
+ // Tamil
+ ta(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Telugu
+ te(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Tajik
+ tg(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Thai
+ th(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Turkmen
+ tk(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Turkish
+ tr(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Uyghur
+ ug(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Ukrainian
+ uk(n: number): Category | undefined {
+ // same as Russian
+ return Plural.ru(n);
+ },
+
+ // Uzbek
+ uz(n: number): Category | undefined {
+ if (n == 1) {
+ return Category.One;
+ }
+ },
+
+ // Vietnamese
+ vi(_n: number): Category | undefined {
+ return undefined;
+ },
+
+ // Chinese
+ zh(_n: number): Category | undefined {
+ return undefined;
+ },
+};
+
+type ValidLanguage = keyof typeof Languages;
+
+// Note: This cannot be an interface due to the computed property.
+type Parameters = {
+ value: number;
+ other: string;
+} & {
+ [category in Category]?: string;
+} & {
+ [number: number]: string;
+ };
+
+const Plural = {
+ /**
+ * Returns the plural category for the given value.
+ */
+ getCategory(value: number, languageCode?: ValidLanguage): Category {
+ if (!languageCode) {
+ languageCode = document.documentElement.lang as ValidLanguage;
+ }
+
+ // Fallback: handle unknown languages as English
+ if (typeof Plural[languageCode] !== "function") {
+ languageCode = "en";
+ }
+
+ const category = Plural[languageCode](value);
+ if (category) {
+ return category;
+ }
+
+ return Category.Other;
+ },
+
+ /**
+ * Returns the value for a `plural` element used in the template.
+ *
+ * @see wcf\system\template\plugin\PluralFunctionTemplatePlugin::execute()
+ */
+ getCategoryFromTemplateParameters(parameters: Parameters): string {
+ if (!parameters["value"]) {
+ throw new Error("Missing parameter value");
+ }
+ if (!parameters["other"]) {
+ throw new Error("Missing parameter other");
+ }
+
+ let value = parameters["value"];
+ if (Array.isArray(value)) {
+ value = value.length;
+ }
+
+ // handle numeric attributes
+ const numericAttribute = Object.keys(parameters).find((key) => {
+ return key.toString() === (~~key).toString() && key.toString() === value.toString();
+ });
+
+ if (numericAttribute) {
+ return numericAttribute;
+ }
+
+ let category = Plural.getCategory(value);
+ if (!parameters[category]) {
+ category = Category.Other;
+ }
+
+ const string = parameters[category]!;
+ if (string.indexOf("#") !== -1) {
+ return string.replace("#", StringUtil.formatNumeric(value));
+ }
+
+ return string;
+ },
+
+ /**
+ * `f` is the fractional number as a whole number (1.234 yields 234)
+ */
+ getF(n: number): number {
+ const tmp = n.toString();
+ const pos = tmp.indexOf(".");
+ if (pos === -1) {
+ return 0;
+ }
+
+ return parseInt(tmp.substr(pos + 1), 10);
+ },
+
+ /**
+ * `v` represents the number of digits of the fractional part (1.234 yields 3)
+ */
+ getV(n: number): number {
+ return n.toString().replace(/^[^.]*\.?/, "").length;
+ },
+
+ ...Languages,
+};
+
+export = Plural;
--- /dev/null
+/**
+ * Provides helper functions for Exif metadata handling.
+ *
+ * @author Tim Duesterhus, Maximilian Mader
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Image/ExifUtil
+ */
+
+enum Tag {
+ SOI = 0xd8, // Start of image
+ APP0 = 0xe0, // JFIF tag
+ APP1 = 0xe1, // EXIF / XMP
+ APP2 = 0xe2, // General purpose tag
+ APP3 = 0xe3, // General purpose tag
+ APP4 = 0xe4, // General purpose tag
+ APP5 = 0xe5, // General purpose tag
+ APP6 = 0xe6, // General purpose tag
+ APP7 = 0xe7, // General purpose tag
+ APP8 = 0xe8, // General purpose tag
+ APP9 = 0xe9, // General purpose tag
+ APP10 = 0xea, // General purpose tag
+ APP11 = 0xeb, // General purpose tag
+ APP12 = 0xec, // General purpose tag
+ APP13 = 0xed, // General purpose tag
+ APP14 = 0xee, // Often used to store copyright information
+ COM = 0xfe, // Comments
+}
+
+// Known sequence signatures
+const _signatureEXIF = "Exif";
+const _signatureXMP = "http://ns.adobe.com/xap/1.0/";
+const _signatureXMPExtension = "http://ns.adobe.com/xmp/extension/";
+
+function isExifSignature(signature: string): boolean {
+ return signature === _signatureEXIF || signature === _signatureXMP || signature === _signatureXMPExtension;
+}
+
+function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array {
+ let offset = 0;
+ const length = arrays.reduce((sum, array) => sum + array.length, 0);
+
+ const result = new Uint8Array(length);
+ arrays.forEach((array) => {
+ result.set(array, offset);
+ offset += array.length;
+ });
+
+ return result;
+}
+
+async function blobToUint8(blob: Blob | File): Promise<Uint8Array> {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+
+ reader.addEventListener("error", () => {
+ reader.abort();
+ reject(reader.error);
+ });
+
+ reader.addEventListener("load", () => {
+ resolve(new Uint8Array(reader.result! as ArrayBuffer));
+ });
+
+ reader.readAsArrayBuffer(blob);
+ });
+}
+
+/**
+ * Extracts the EXIF / XMP sections of a JPEG blob.
+ */
+export async function getExifBytesFromJpeg(blob: Blob | File): Promise<Exif> {
+ if (!((blob as any) instanceof Blob) && !(blob instanceof File)) {
+ throw new TypeError("The argument must be a Blob or a File");
+ }
+
+ const bytes = await blobToUint8(blob);
+
+ let exif = new Uint8Array(0);
+
+ if (bytes[0] !== 0xff && bytes[1] !== Tag.SOI) {
+ throw new Error("Not a JPEG");
+ }
+
+ for (let i = 2; i < bytes.length; ) {
+ // each sequence starts with 0xFF
+ if (bytes[i] !== 0xff) break;
+
+ const length = 2 + ((bytes[i + 2] << 8) | bytes[i + 3]);
+
+ // Check if the next byte indicates an EXIF sequence
+ if (bytes[i + 1] === Tag.APP1) {
+ let signature = "";
+ for (let j = i + 4; bytes[j] !== 0 && j < bytes.length; j++) {
+ signature += String.fromCharCode(bytes[j]);
+ }
+
+ // Only copy Exif and XMP data
+ if (isExifSignature(signature)) {
+ // append the found EXIF sequence, usually only a single EXIF (APP1) sequence should be defined
+ const sequence = bytes.slice(i, length + i);
+ exif = concatUint8Arrays(exif, sequence);
+ }
+ }
+
+ i += length;
+ }
+
+ return exif;
+}
+
+/**
+ * Removes all EXIF and XMP sections of a JPEG blob.
+ */
+export async function removeExifData(blob: Blob | File): Promise<Blob> {
+ if (!((blob as any) instanceof Blob) && !(blob instanceof File)) {
+ throw new TypeError("The argument must be a Blob or a File");
+ }
+
+ const bytes = await blobToUint8(blob);
+
+ if (bytes[0] !== 0xff && bytes[1] !== Tag.SOI) {
+ throw new Error("Not a JPEG");
+ }
+
+ let result = bytes;
+ for (let i = 2; i < result.length; ) {
+ // each sequence starts with 0xFF
+ if (result[i] !== 0xff) break;
+
+ const length = 2 + ((result[i + 2] << 8) | result[i + 3]);
+
+ // Check if the next byte indicates an EXIF sequence
+ if (result[i + 1] === Tag.APP1) {
+ let signature = "";
+ for (let j = i + 4; result[j] !== 0 && j < result.length; j++) {
+ signature += String.fromCharCode(result[j]);
+ }
+
+ // Only remove known signatures
+ if (isExifSignature(signature)) {
+ const start = result.slice(0, i);
+ const end = result.slice(i + length);
+ result = concatUint8Arrays(start, end);
+ } else {
+ i += length;
+ }
+ } else {
+ i += length;
+ }
+ }
+
+ return new Blob([result], { type: blob.type });
+}
+
+/**
+ * Overrides the APP1 (EXIF / XMP) sections of a JPEG blob with the given data.
+ */
+export async function setExifData(blob: Blob, exif: Exif): Promise<Blob> {
+ blob = await removeExifData(blob);
+
+ const bytes = await blobToUint8(blob);
+
+ let offset = 2;
+
+ // check if the second tag is the JFIF tag
+ if (bytes[2] === 0xff && bytes[3] === Tag.APP0) {
+ offset += 2 + ((bytes[4] << 8) | bytes[5]);
+ }
+
+ const start = bytes.slice(0, offset);
+ const end = bytes.slice(offset);
+
+ const result = concatUint8Arrays(start, exif, end);
+
+ return new Blob([result], { type: blob.type });
+}
+
+export type Exif = Uint8Array;
--- /dev/null
+/**
+ * Provides helper functions for Image metadata handling.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Image/ImageUtil
+ */
+
+/**
+ * Returns whether the given canvas contains transparent pixels.
+ */
+export function containsTransparentPixels(canvas: HTMLCanvasElement): boolean {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("Unable to get canvas context.");
+ }
+
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+
+ for (let i = 3, max = imageData.data.length; i < max; i += 4) {
+ if (imageData.data[i] !== 255) return true;
+ }
+
+ return false;
+}
--- /dev/null
+/**
+ * This module allows resizing and conversion of HTMLImageElements to Blob and File objects
+ *
+ * @author Tim Duesterhus, Maximilian Mader
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Image/Resizer
+ */
+
+import * as Core from "../Core";
+import * as FileUtil from "../FileUtil";
+import * as ExifUtil from "./ExifUtil";
+import Pica from "pica";
+
+const pica = new Pica({ features: ["js", "wasm", "ww"] });
+
+const DEFAULT_WIDTH = 800;
+const DEFAULT_HEIGHT = 600;
+const DEFAULT_QUALITY = 0.8;
+const DEFAULT_FILETYPE = "image/jpeg";
+
+class ImageResizer {
+ maxWidth = DEFAULT_WIDTH;
+ maxHeight = DEFAULT_HEIGHT;
+ quality = DEFAULT_QUALITY;
+ fileType = DEFAULT_FILETYPE;
+
+ /**
+ * Sets the default maximum width for this instance
+ */
+ setMaxWidth(value: number): ImageResizer {
+ if (value == null) {
+ value = DEFAULT_WIDTH;
+ }
+
+ this.maxWidth = value;
+ return this;
+ }
+
+ /**
+ * Sets the default maximum height for this instance
+ */
+ setMaxHeight(value: number): ImageResizer {
+ if (value == null) {
+ value = DEFAULT_HEIGHT;
+ }
+
+ this.maxHeight = value;
+ return this;
+ }
+
+ /**
+ * Sets the default quality for this instance
+ */
+ setQuality(value: number): ImageResizer {
+ if (value == null) {
+ value = DEFAULT_QUALITY;
+ }
+
+ this.quality = value;
+ return this;
+ }
+
+ /**
+ * Sets the default file type for this instance
+ */
+ setFileType(value: string): ImageResizer {
+ if (value == null) {
+ value = DEFAULT_FILETYPE;
+ }
+
+ this.fileType = value;
+ return this;
+ }
+
+ /**
+ * Converts the given object of exif data and image data into a File.
+ */
+ async saveFile(
+ data: CanvasPlusExif,
+ fileName: string,
+ fileType: string = this.fileType,
+ quality: number = this.quality,
+ ): Promise<File> {
+ const basename = /(.+)(\..+?)$/.exec(fileName);
+
+ let blob = await pica.toBlob(data.image, fileType, quality);
+
+ if (fileType === "image/jpeg" && typeof data.exif !== "undefined") {
+ blob = await ExifUtil.setExifData(blob, data.exif);
+ }
+
+ return FileUtil.blobToFile(blob, basename![1]);
+ }
+
+ /**
+ * Loads the given file into an image object and parses Exif information.
+ */
+ async loadFile(file: File): Promise<ImagePlusExif> {
+ let exifBytes: Promise<ExifUtil.Exif | undefined> = Promise.resolve(undefined);
+
+ let fileData: Blob | File = file;
+ if (file.type === "image/jpeg") {
+ // Extract EXIF data
+ exifBytes = ExifUtil.getExifBytesFromJpeg(file);
+
+ // Strip EXIF data
+ fileData = await ExifUtil.removeExifData(fileData);
+ }
+
+ const imageLoader: Promise<HTMLImageElement> = new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ const image = new Image();
+
+ reader.addEventListener("load", () => {
+ image.src = reader.result! as string;
+ });
+
+ reader.addEventListener("error", () => {
+ reader.abort();
+ reject(reader.error);
+ });
+
+ image.addEventListener("error", reject);
+
+ image.addEventListener("load", () => {
+ resolve(image);
+ });
+
+ reader.readAsDataURL(fileData);
+ });
+
+ const [exif, image] = await Promise.all([exifBytes, imageLoader]);
+
+ return { exif, image };
+ }
+
+ /**
+ * Downscales an image given as File object.
+ */
+ async resize(
+ image: HTMLImageElement,
+ maxWidth: number = this.maxWidth,
+ maxHeight: number = this.maxHeight,
+ quality: number = this.quality,
+ force = false,
+ cancelPromise?: Promise<unknown>,
+ ): Promise<HTMLCanvasElement | undefined> {
+ const canvas = document.createElement("canvas");
+
+ if (window.createImageBitmap as any) {
+ const bitmap = await createImageBitmap(image);
+
+ if (bitmap.height != image.height) {
+ throw new Error("Chrome Bug #1069965");
+ }
+ }
+
+ // Prevent upscaling
+ const newWidth = Math.min(maxWidth, image.width);
+ const newHeight = Math.min(maxHeight, image.height);
+
+ if (image.width <= newWidth && image.height <= newHeight && !force) {
+ return undefined;
+ }
+
+ // Keep image ratio
+ const ratio = Math.min(newWidth / image.width, newHeight / image.height);
+
+ canvas.width = Math.floor(image.width * ratio);
+ canvas.height = Math.floor(image.height * ratio);
+
+ // Map to Pica's quality
+ let resizeQuality = 1;
+ if (quality >= 0.8) {
+ resizeQuality = 3;
+ } else if (quality >= 0.4) {
+ resizeQuality = 2;
+ }
+
+ const options = {
+ quality: resizeQuality,
+ cancelToken: cancelPromise,
+ alpha: true,
+ };
+
+ return pica.resize(image, canvas, options);
+ }
+}
+
+interface ImagePlusExif {
+ image: HTMLImageElement;
+ exif?: ExifUtil.Exif;
+}
+
+interface CanvasPlusExif {
+ image: HTMLCanvasElement;
+ exif?: ExifUtil.Exif;
+}
+
+Core.enableLegacyInheritance(ImageResizer);
+
+export = ImageResizer;
--- /dev/null
+/**
+ * Manages language items.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Language (alias)
+ * @module WoltLabSuite/Core/Language
+ */
+
+import Template from "./Template";
+
+const _languageItems = new Map<string, string | Template>();
+
+/**
+ * Adds all the language items in the given object to the store.
+ */
+export function addObject(object: LanguageItems): void {
+ Object.keys(object).forEach((key) => {
+ _languageItems.set(key, object[key]);
+ });
+}
+
+/**
+ * Adds a single language item to the store.
+ */
+export function add(key: string, value: string): void {
+ _languageItems.set(key, value);
+}
+
+/**
+ * Fetches the language item specified by the given key.
+ * If the language item is a string it will be evaluated as
+ * WoltLabSuite/Core/Template with the given parameters.
+ *
+ * @param {string} key Language item to return.
+ * @param {Object=} parameters Parameters to provide to WoltLabSuite/Core/Template.
+ * @return {string}
+ */
+export function get(key: string, parameters?: object): string {
+ let value = _languageItems.get(key);
+ if (value === undefined) {
+ return key;
+ }
+
+ if (Template === undefined) {
+ // @ts-expect-error: This is required due to a circular dependency.
+ Template = require("./Template");
+ }
+
+ if (typeof value === "string") {
+ // lazily convert to WCF.Template
+ try {
+ _languageItems.set(key, new Template(value));
+ } catch (e) {
+ _languageItems.set(
+ key,
+ new Template(
+ "{literal}" + value.replace(/{\/literal}/g, "{/literal}{ldelim}/literal}{literal}") + "{/literal}",
+ ),
+ );
+ }
+ value = _languageItems.get(key);
+ }
+
+ if (value instanceof Template) {
+ value = value.fetch(parameters || {});
+ }
+
+ return value as string;
+}
+
+interface LanguageItems {
+ [key: string]: string;
+}
--- /dev/null
+/**
+ * Dropdown language chooser.
+ *
+ * @author Alexander Ebert, Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Language/Chooser
+ */
+
+import * as Core from "../Core";
+import * as Language from "../Language";
+import DomUtil from "../Dom/Util";
+import UiDropdownSimple from "../Ui/Dropdown/Simple";
+
+type ChooserId = string;
+type CallbackSelect = (listItem: HTMLElement) => void;
+type SelectFieldOrHiddenInput = HTMLInputElement | HTMLSelectElement;
+
+interface LanguageData {
+ iconPath: string;
+ languageCode?: string;
+ languageName: string;
+}
+
+interface Languages {
+ [key: string]: LanguageData;
+}
+
+interface ChooserData {
+ callback: CallbackSelect;
+ dropdownMenu: HTMLUListElement;
+ dropdownToggle: HTMLAnchorElement;
+ element: SelectFieldOrHiddenInput;
+}
+
+const _choosers = new Map<ChooserId, ChooserData>();
+const _forms = new WeakMap<HTMLFormElement, ChooserId[]>();
+
+/**
+ * Sets up DOM and event listeners for a language chooser.
+ */
+function initElement(
+ chooserId: string,
+ element: SelectFieldOrHiddenInput,
+ languageId: number,
+ languages: Languages,
+ callback: CallbackSelect,
+ allowEmptyValue: boolean,
+) {
+ let container: HTMLElement;
+
+ const parent = element.parentElement!;
+ if (parent.nodeName === "DD") {
+ container = document.createElement("div");
+ container.className = "dropdown";
+
+ // language chooser is the first child so that descriptions and error messages
+ // are always shown below the language chooser
+ parent.insertAdjacentElement("afterbegin", container);
+ } else {
+ container = parent;
+ container.classList.add("dropdown");
+ }
+
+ DomUtil.hide(element);
+
+ const dropdownToggle = document.createElement("a");
+ dropdownToggle.className = "dropdownToggle dropdownIndicator boxFlag box24 inputPrefix";
+ if (parent.nodeName === "DD") {
+ dropdownToggle.classList.add("button");
+ }
+ container.appendChild(dropdownToggle);
+
+ const dropdownMenu = document.createElement("ul");
+ dropdownMenu.className = "dropdownMenu";
+ container.appendChild(dropdownMenu);
+
+ function callbackClick(event: MouseEvent): void {
+ const target = event.currentTarget as HTMLElement;
+ const languageId = ~~target.dataset.languageId!;
+
+ const activeItem = dropdownMenu.querySelector(".active");
+ if (activeItem !== null) {
+ activeItem.classList.remove("active");
+ }
+
+ if (languageId) {
+ target.classList.add("active");
+ }
+
+ select(chooserId, languageId, target);
+ }
+
+ // add language dropdown items
+ Object.entries(languages).forEach(([langId, language]) => {
+ const listItem = document.createElement("li");
+ listItem.className = "boxFlag";
+ listItem.addEventListener("click", callbackClick);
+ listItem.dataset.languageId = langId;
+ if (language.languageCode !== undefined) {
+ listItem.dataset.languageCode = language.languageCode;
+ }
+ dropdownMenu.appendChild(listItem);
+
+ const link = document.createElement("a");
+ link.className = "box24";
+ listItem.appendChild(link);
+
+ const img = document.createElement("img");
+ img.src = language.iconPath;
+ img.alt = "";
+ img.className = "iconFlag";
+ link.appendChild(img);
+
+ const span = document.createElement("span");
+ span.textContent = language.languageName;
+ link.appendChild(span);
+
+ if (+langId === languageId) {
+ dropdownToggle.innerHTML = link.innerHTML;
+ }
+ });
+
+ // add dropdown item for "no selection"
+ if (allowEmptyValue) {
+ const divider = document.createElement("li");
+ divider.className = "dropdownDivider";
+ dropdownMenu.appendChild(divider);
+
+ const listItem = document.createElement("li");
+ listItem.dataset.languageId = "0";
+ listItem.addEventListener("click", callbackClick);
+ dropdownMenu.appendChild(listItem);
+
+ const link = document.createElement("a");
+ link.textContent = Language.get("wcf.global.language.noSelection");
+ listItem.appendChild(link);
+
+ if (languageId === 0) {
+ dropdownToggle.innerHTML = link.innerHTML;
+ }
+
+ listItem.addEventListener("click", callbackClick);
+ } else if (languageId === 0) {
+ dropdownToggle.innerHTML = "";
+
+ const div = document.createElement("div");
+ dropdownToggle.appendChild(div);
+
+ const icon = document.createElement("span");
+ icon.className = "icon icon24 fa-question pointer";
+ div.appendChild(icon);
+
+ const span = document.createElement("span");
+ span.textContent = Language.get("wcf.global.language.noSelection");
+ div.appendChild(span);
+ }
+
+ UiDropdownSimple.init(dropdownToggle);
+
+ _choosers.set(chooserId, {
+ callback: callback,
+ dropdownMenu: dropdownMenu,
+ dropdownToggle: dropdownToggle,
+ element: element,
+ });
+
+ // bind to submit event
+ const form = element.closest("form") as HTMLFormElement;
+ if (form !== null) {
+ form.addEventListener("submit", onSubmit);
+
+ let chooserIds = _forms.get(form);
+ if (chooserIds === undefined) {
+ chooserIds = [];
+ _forms.set(form, chooserIds);
+ }
+
+ chooserIds.push(chooserId);
+ }
+}
+
+/**
+ * Selects a language from the dropdown list.
+ */
+function select(chooserId: string, languageId: number, listItem?: HTMLElement): void {
+ const chooser = _choosers.get(chooserId)!;
+
+ if (listItem === undefined) {
+ listItem = Array.from(chooser.dropdownMenu.children).find((element: HTMLElement) => {
+ return ~~element.dataset.languageId! === languageId;
+ }) as HTMLElement;
+
+ if (listItem === undefined) {
+ throw new Error(`The language id '${languageId}' is unknown`);
+ }
+ }
+
+ chooser.element.value = languageId.toString();
+ Core.triggerEvent(chooser.element, "change");
+
+ chooser.dropdownToggle.innerHTML = listItem.children[0].innerHTML;
+
+ _choosers.set(chooserId, chooser);
+
+ // execute callback
+ if (typeof chooser.callback === "function") {
+ chooser.callback(listItem);
+ }
+}
+
+/**
+ * Inserts hidden fields for the language chooser value on submit.
+ */
+function onSubmit(event: Event): void {
+ const form = event.currentTarget as HTMLFormElement;
+ const elementIds = _forms.get(form)!;
+
+ elementIds.forEach((elementId) => {
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = elementId;
+ input.value = getLanguageId(elementId).toString();
+
+ form.appendChild(input);
+ });
+}
+
+/**
+ * Initializes a language chooser.
+ */
+export function init(
+ containerId: string,
+ chooserId: string,
+ languageId: number,
+ languages: Languages,
+ callback: CallbackSelect,
+ allowEmptyValue: boolean,
+): void {
+ if (_choosers.has(chooserId)) {
+ return;
+ }
+
+ const container = document.getElementById(containerId);
+ if (container === null) {
+ throw new Error(`Expected a valid container id, cannot find '${chooserId}'.`);
+ }
+
+ let element = document.getElementById(chooserId) as SelectFieldOrHiddenInput;
+ if (element === null) {
+ element = document.createElement("input");
+ element.type = "hidden";
+ element.id = chooserId;
+ element.name = chooserId;
+ element.value = languageId.toString();
+
+ container.appendChild(element);
+ }
+
+ initElement(chooserId, element, languageId, languages, callback, allowEmptyValue);
+}
+
+/**
+ * Returns the chooser for an input field.
+ */
+export function getChooser(chooserId: string): ChooserData {
+ const chooser = _choosers.get(chooserId);
+ if (chooser === undefined) {
+ throw new Error(`Expected a valid language chooser input element, '${chooserId}' is not i18n input field.`);
+ }
+
+ return chooser;
+}
+
+/**
+ * Returns the selected language for a certain chooser.
+ */
+export function getLanguageId(chooserId: string): number {
+ return ~~getChooser(chooserId).element.value;
+}
+
+/**
+ * Removes the chooser with given id.
+ */
+export function removeChooser(chooserId: string): void {
+ _choosers.delete(chooserId);
+}
+
+/**
+ * Sets the language for a certain chooser.
+ */
+export function setLanguageId(chooserId: string, languageId: number): void {
+ if (_choosers.get(chooserId) === undefined) {
+ throw new Error(`Expected a valid input element, '${chooserId}' is not i18n input field.`);
+ }
+
+ select(chooserId, languageId);
+}
--- /dev/null
+/**
+ * I18n interface for input and textarea fields.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Language/Input
+ */
+
+import DomUtil from "../Dom/Util";
+import * as Language from "../Language";
+import { NotificationAction } from "../Ui/Dropdown/Data";
+import UiDropdownSimple from "../Ui/Dropdown/Simple";
+import * as StringUtil from "../StringUtil";
+
+type LanguageId = number;
+
+export interface I18nValues {
+ // languageID => value
+ [key: string]: string;
+}
+
+export interface Languages {
+ // languageID => languageName
+ [key: string]: string;
+}
+
+type Values = Map<LanguageId, string>;
+
+export type InputOrTextarea = HTMLInputElement | HTMLTextAreaElement;
+
+type CallbackEvent = "select" | "submit";
+type Callback = (element: InputOrTextarea) => void;
+
+interface ElementData {
+ buttonLabel: HTMLElement;
+ callbacks: Map<CallbackEvent, Callback>;
+ element: InputOrTextarea;
+ languageId: number;
+ isEnabled: boolean;
+ forceSelection: boolean;
+}
+
+const _elements = new Map<string, ElementData>();
+const _forms = new WeakMap<HTMLFormElement, string[]>();
+const _values = new Map<string, Values>();
+
+/**
+ * Sets up DOM and event listeners for an input field.
+ */
+function initElement(
+ elementId: string,
+ element: InputOrTextarea,
+ values: Values,
+ availableLanguages: Languages,
+ forceSelection: boolean,
+): void {
+ let container = element.parentElement!;
+ if (!container.classList.contains("inputAddon")) {
+ container = document.createElement("div");
+ container.className = "inputAddon";
+ if (element.nodeName === "TEXTAREA") {
+ container.classList.add("inputAddonTextarea");
+ }
+ container.dataset.inputId = elementId;
+
+ const hasFocus = document.activeElement === element;
+
+ // DOM manipulation causes focused element to lose focus
+ element.insertAdjacentElement("beforebegin", container);
+ container.appendChild(element);
+
+ if (hasFocus) {
+ element.focus();
+ }
+ }
+
+ container.classList.add("dropdown");
+ const button = document.createElement("span");
+ button.className = "button dropdownToggle inputPrefix";
+
+ const buttonLabel = document.createElement("span");
+ buttonLabel.textContent = Language.get("wcf.global.button.disabledI18n");
+
+ button.appendChild(buttonLabel);
+ container.insertBefore(button, element);
+
+ const dropdownMenu = document.createElement("ul");
+ dropdownMenu.className = "dropdownMenu";
+ button.insertAdjacentElement("afterend", dropdownMenu);
+
+ const callbackClick = (event: MouseEvent | HTMLElement): void => {
+ let target: HTMLElement;
+ if (event instanceof HTMLElement) {
+ target = event;
+ } else {
+ target = event.currentTarget as HTMLElement;
+ }
+
+ const languageId = ~~target.dataset.languageId!;
+
+ const activeItem = dropdownMenu.querySelector(".active");
+ if (activeItem !== null) {
+ activeItem.classList.remove("active");
+ }
+
+ if (languageId) {
+ target.classList.add("active");
+ }
+
+ const isInit = event instanceof HTMLElement;
+ select(elementId, languageId, isInit);
+ };
+
+ // build language dropdown
+ Object.entries(availableLanguages).forEach(([languageId, languageName]) => {
+ const listItem = document.createElement("li");
+ listItem.dataset.languageId = languageId;
+
+ const span = document.createElement("span");
+ span.textContent = languageName;
+
+ listItem.appendChild(span);
+ listItem.addEventListener("click", callbackClick);
+ dropdownMenu.appendChild(listItem);
+ });
+
+ if (!forceSelection) {
+ const divider = document.createElement("li");
+ divider.className = "dropdownDivider";
+ dropdownMenu.appendChild(divider);
+
+ const listItem = document.createElement("li");
+ listItem.dataset.languageId = "0";
+ listItem.addEventListener("click", callbackClick);
+
+ const span = document.createElement("span");
+ span.textContent = Language.get("wcf.global.button.disabledI18n");
+ listItem.appendChild(span);
+
+ dropdownMenu.appendChild(listItem);
+ }
+
+ let activeItem: HTMLElement | undefined = undefined;
+ if (forceSelection || values.size) {
+ activeItem = Array.from(dropdownMenu.children).find((element: HTMLElement) => {
+ return +element.dataset.languageId! === window.LANGUAGE_ID;
+ }) as HTMLElement;
+ }
+
+ UiDropdownSimple.init(button);
+ UiDropdownSimple.registerCallback(container.id, dropdownToggle);
+
+ _elements.set(elementId, {
+ buttonLabel,
+ callbacks: new Map<CallbackEvent, Callback>(),
+ element,
+ languageId: 0,
+ isEnabled: true,
+ forceSelection,
+ });
+
+ // bind to submit event
+ const form = element.closest("form");
+ if (form !== null) {
+ form.addEventListener("submit", submit);
+
+ let elementIds = _forms.get(form);
+ if (elementIds === undefined) {
+ elementIds = [];
+ _forms.set(form, elementIds);
+ }
+
+ elementIds.push(elementId);
+ }
+
+ if (activeItem) {
+ callbackClick(activeItem);
+ }
+}
+
+/**
+ * Selects a language or non-i18n from the dropdown list.
+ */
+function select(elementId: string, languageId: number, isInit: boolean): void {
+ const data = _elements.get(elementId)!;
+
+ const dropdownMenu = UiDropdownSimple.getDropdownMenu(data.element.closest(".inputAddon")!.id)!;
+
+ const item = dropdownMenu.querySelector(`[data-language-id="${languageId}"]`);
+ const label = item ? item.textContent! : "";
+
+ // save current value
+ if (data.languageId !== languageId) {
+ const values = _values.get(elementId)!;
+
+ if (data.languageId) {
+ values.set(data.languageId, data.element.value);
+ }
+
+ if (languageId === 0) {
+ _values.set(elementId, new Map<LanguageId, string>());
+ } else if (data.buttonLabel.classList.contains("active") || isInit) {
+ data.element.value = values.get(languageId) || "";
+ }
+
+ // update label
+ data.buttonLabel.textContent = label;
+ data.buttonLabel.classList[languageId ? "add" : "remove"]("active");
+
+ data.languageId = languageId;
+ }
+
+ if (!isInit) {
+ data.element.blur();
+ data.element.focus();
+ }
+
+ if (data.callbacks.has("select")) {
+ data.callbacks.get("select")!(data.element);
+ }
+}
+
+/**
+ * Callback for dropdowns being opened, flags items with a missing value for one or more languages.
+ */
+function dropdownToggle(containerId: string, action: NotificationAction): void {
+ if (action !== "open") {
+ return;
+ }
+
+ const dropdownMenu = UiDropdownSimple.getDropdownMenu(containerId)!;
+ const container = document.getElementById(containerId)!;
+ const elementId = container.dataset.inputId!;
+ const data = _elements.get(elementId)!;
+ const values = _values.get(elementId)!;
+
+ Array.from(dropdownMenu.children).forEach((item: HTMLElement) => {
+ const languageId = ~~(item.dataset.languageId || "");
+
+ if (languageId) {
+ let hasMissingValue = false;
+ if (data.languageId) {
+ if (languageId === data.languageId) {
+ hasMissingValue = data.element.value.trim() === "";
+ } else {
+ hasMissingValue = !values.get(languageId);
+ }
+ }
+
+ if (hasMissingValue) {
+ item.classList.add("missingValue");
+ } else {
+ item.classList.remove("missingValue");
+ }
+ }
+ });
+}
+
+/**
+ * Inserts hidden fields for i18n input on submit.
+ */
+function submit(event: Event): void {
+ const form = event.currentTarget as HTMLFormElement;
+ const elementIds = _forms.get(form)!;
+
+ elementIds.forEach((elementId) => {
+ const data = _elements.get(elementId)!;
+ if (!data.isEnabled) {
+ return;
+ }
+
+ const values = _values.get(elementId)!;
+
+ if (data.callbacks.has("submit")) {
+ data.callbacks.get("submit")!(data.element);
+ }
+
+ // update with current value
+ if (data.languageId) {
+ values.set(data.languageId, data.element.value);
+ }
+
+ if (values.size) {
+ values.forEach(function (value, languageId) {
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = `${elementId}_i18n[${languageId}]`;
+ input.value = value;
+
+ form.appendChild(input);
+ });
+
+ // remove name attribute to enforce i18n values
+ data.element.removeAttribute("name");
+ }
+ });
+}
+
+/**
+ * Initializes an input field.
+ */
+export function init(
+ elementId: string,
+ values: I18nValues,
+ availableLanguages: Languages,
+ forceSelection: boolean,
+): void {
+ if (_values.has(elementId)) {
+ return;
+ }
+
+ const element = document.getElementById(elementId) as InputOrTextarea;
+ if (element === null) {
+ throw new Error(`Expected a valid element id, cannot find '${elementId}'.`);
+ }
+
+ // unescape values
+ const unescapedValues = new Map<LanguageId, string>();
+ Object.entries(values).forEach(([languageId, value]) => {
+ unescapedValues.set(+languageId, StringUtil.unescapeHTML(value));
+ });
+
+ _values.set(elementId, unescapedValues);
+
+ initElement(elementId, element, unescapedValues, availableLanguages, forceSelection);
+}
+
+/**
+ * Registers a callback for an element.
+ */
+export function registerCallback(elementId: string, eventName: CallbackEvent, callback: Callback): void {
+ if (!_values.has(elementId)) {
+ throw new Error(`Unknown element id '${elementId}'.`);
+ }
+
+ _elements.get(elementId)!.callbacks.set(eventName, callback);
+}
+
+/**
+ * Unregisters the element with the given id.
+ *
+ * @since 5.2
+ */
+export function unregister(elementId: string): void {
+ if (!_values.has(elementId)) {
+ throw new Error(`Unknown element id '${elementId}'.`);
+ }
+
+ _values.delete(elementId);
+ _elements.delete(elementId);
+}
+
+/**
+ * Returns the values of an input field.
+ */
+export function getValues(elementId: string): Values {
+ const element = _elements.get(elementId)!;
+ if (element === undefined) {
+ throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+ }
+
+ const values = _values.get(elementId)!;
+
+ // update with current value
+ values.set(element.languageId, element.element.value);
+
+ return values;
+}
+
+/**
+ * Sets the values of an input field.
+ */
+export function setValues(elementId: string, newValues: Values | I18nValues): void {
+ const element = _elements.get(elementId);
+ if (element === undefined) {
+ throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+ }
+
+ element.element.value = "";
+
+ const values = new Map<LanguageId, string>(
+ Object.entries(newValues).map(([languageId, value]) => {
+ return [+languageId, value];
+ }),
+ );
+
+ if (values.has(0)) {
+ element.element.value = values.get(0)!;
+ values.delete(0);
+
+ _values.set(elementId, values);
+ select(elementId, 0, true);
+
+ return;
+ }
+
+ _values.set(elementId, values);
+
+ element.languageId = 0;
+ select(elementId, window.LANGUAGE_ID, true);
+}
+
+/**
+ * Disables the i18n interface for an input field.
+ */
+export function disable(elementId: string): void {
+ const element = _elements.get(elementId);
+ if (element === undefined) {
+ throw new Error(`Expected a valid element, '${elementId}' is not an i18n input field.`);
+ }
+
+ if (!element.isEnabled) {
+ return;
+ }
+
+ element.isEnabled = false;
+
+ // hide language dropdown
+ const buttonContainer = element.buttonLabel.parentElement!;
+ DomUtil.hide(buttonContainer);
+ const dropdownContainer = buttonContainer.parentElement!;
+ dropdownContainer.classList.remove("inputAddon", "dropdown");
+}
+
+/**
+ * Enables the i18n interface for an input field.
+ */
+export function enable(elementId: string): void {
+ const element = _elements.get(elementId);
+ if (element === undefined) {
+ throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+ }
+
+ if (element.isEnabled) {
+ return;
+ }
+
+ element.isEnabled = true;
+
+ // show language dropdown
+ const buttonContainer = element.buttonLabel.parentElement!;
+ DomUtil.show(buttonContainer);
+ const dropdownContainer = buttonContainer.parentElement!;
+ dropdownContainer.classList.add("inputAddon", "dropdown");
+}
+
+/**
+ * Returns true if i18n input is enabled for an input field.
+ */
+export function isEnabled(elementId: string): boolean {
+ const element = _elements.get(elementId);
+ if (element === undefined) {
+ throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+ }
+
+ return element.isEnabled;
+}
+
+/**
+ * Returns true if the value of an i18n input field is valid.
+ *
+ * If the element is disabled, true is returned.
+ */
+export function validate(elementId: string, permitEmptyValue: boolean): boolean {
+ const element = _elements.get(elementId)!;
+ if (element === undefined) {
+ throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
+ }
+
+ if (!element.isEnabled) {
+ return true;
+ }
+
+ const values = _values.get(elementId)!;
+
+ const dropdownMenu = UiDropdownSimple.getDropdownMenu(element.element.parentElement!.id)!;
+
+ if (element.languageId) {
+ values.set(element.languageId, element.element.value);
+ }
+
+ let hasEmptyValue = false;
+ let hasNonEmptyValue = false;
+ Array.from(dropdownMenu.children).forEach((item: HTMLElement) => {
+ const languageId = ~~item.dataset.languageId!;
+
+ if (languageId) {
+ if (!values.has(languageId) || values.get(languageId)!.length === 0) {
+ // input has non-empty value for previously checked language
+ if (hasNonEmptyValue) {
+ return false;
+ }
+
+ hasEmptyValue = true;
+ } else {
+ // input has empty value for previously checked language
+ if (hasEmptyValue) {
+ return false;
+ }
+
+ hasNonEmptyValue = true;
+ }
+ }
+ });
+
+ return !hasEmptyValue || permitEmptyValue;
+}
--- /dev/null
+/**
+ * I18n interface for wysiwyg input fields.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Language/Text
+ */
+
+import { I18nValues, InputOrTextarea, Languages } from "./Input";
+import * as LanguageInput from "./Input";
+
+/**
+ * Refreshes the editor content on language switch.
+ */
+function callbackSelect(element: InputOrTextarea): void {
+ if (window.jQuery !== undefined) {
+ window.jQuery(element).redactor("code.set", element.value);
+ }
+}
+
+/**
+ * Refreshes the input element value on submit.
+ */
+function callbackSubmit(element: InputOrTextarea): void {
+ if (window.jQuery !== undefined) {
+ element.value = window.jQuery(element).redactor("code.get") as string;
+ }
+}
+
+/**
+ * Initializes an WYSIWYG input field.
+ */
+export function init(
+ elementId: string,
+ values: I18nValues,
+ availableLanguages: Languages,
+ forceSelection: boolean,
+): void {
+ const element = document.getElementById(elementId);
+ if (!element || element.nodeName !== "TEXTAREA" || !element.classList.contains("wysiwygTextarea")) {
+ throw new Error(`Expected <textarea class="wysiwygTextarea" /> for id '${elementId}'.`);
+ }
+
+ LanguageInput.init(elementId, values, availableLanguages, forceSelection);
+
+ LanguageInput.registerCallback(elementId, "select", callbackSelect);
+ LanguageInput.registerCallback(elementId, "submit", callbackSubmit);
+}
--- /dev/null
+/**
+ * List implementation relying on an array or if supported on a Set to hold values.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module List (alias)
+ * @module WoltLabSuite/Core/List
+ */
+
+import * as Core from "./Core";
+
+/** @deprecated 5.4 Use a `Set` instead. */
+class List<T = any> {
+ private _set = new Set<T>();
+
+ /**
+ * Appends an element to the list, silently rejects adding an already existing value.
+ */
+ add(value: T): void {
+ this._set.add(value);
+ }
+
+ /**
+ * Removes all elements from the list.
+ */
+ clear(): void {
+ this._set.clear();
+ }
+
+ /**
+ * Removes an element from the list, returns true if the element was in the list.
+ */
+ delete(value: T): boolean {
+ return this._set.delete(value);
+ }
+
+ /**
+ * Invokes the `callback` for each element in the list.
+ */
+ forEach(callback: (value: T) => void): void {
+ this._set.forEach(callback);
+ }
+
+ /**
+ * Returns true if the list contains the element.
+ */
+ has(value: T): boolean {
+ return this._set.has(value);
+ }
+
+ get size(): number {
+ return this._set.size;
+ }
+}
+
+Core.enableLegacyInheritance(List);
+
+export = List;
--- /dev/null
+/**
+ * Initializes modules required for media clipboard.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Media/Clipboard
+ */
+
+import MediaManager from "./Manager/Base";
+import MediaManagerEditor from "./Manager/Editor";
+import * as Clipboard from "../Controller/Clipboard";
+import * as UiNotification from "../Ui/Notification";
+import * as UiDialog from "../Ui/Dialog";
+import * as EventHandler from "../Event/Handler";
+import * as Language from "../Language";
+import * as Ajax from "../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../Ajax/Data";
+import { DialogCallbackObject, DialogCallbackSetup } from "../Ui/Dialog/Data";
+
+let _mediaManager: MediaManager;
+
+class MediaClipboard implements AjaxCallbackObject, DialogCallbackObject {
+ public _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ className: "wcf\\data\\media\\MediaAction",
+ },
+ };
+ }
+
+ public _ajaxSuccess(data): void {
+ switch (data.actionName) {
+ case "getSetCategoryDialog":
+ UiDialog.open(this, data.returnValues.template);
+
+ break;
+
+ case "setCategory":
+ UiDialog.close(this);
+
+ UiNotification.show();
+
+ Clipboard.reload();
+
+ break;
+ }
+ }
+
+ public _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "mediaSetCategoryDialog",
+ options: {
+ onSetup: (content) => {
+ content.querySelector("button")!.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ const category = content.querySelector('select[name="categoryID"]') as HTMLSelectElement;
+ setCategory(~~category.value);
+
+ const target = event.currentTarget as HTMLButtonElement;
+ target.disabled = true;
+ });
+ },
+ title: Language.get("wcf.media.setCategory"),
+ },
+ source: null,
+ };
+ }
+}
+
+const ajax = new MediaClipboard();
+
+let clipboardObjectIds: number[] = [];
+
+interface ClipboardActionData {
+ data: {
+ actionName: "com.woltlab.wcf.media.delete" | "com.woltlab.wcf.media.insert" | "com.woltlab.wcf.media.setCategory";
+ parameters: {
+ objectIDs: number[];
+ };
+ };
+ responseData: null;
+}
+
+/**
+ * Handles successful clipboard actions.
+ */
+function clipboardAction(actionData: ClipboardActionData): void {
+ const mediaIds = actionData.data.parameters.objectIDs;
+
+ switch (actionData.data.actionName) {
+ case "com.woltlab.wcf.media.delete":
+ // only consider events if the action has been executed
+ if (actionData.responseData !== null) {
+ _mediaManager.clipboardDeleteMedia(mediaIds);
+ }
+
+ break;
+
+ case "com.woltlab.wcf.media.insert": {
+ const mediaManagerEditor = _mediaManager as MediaManagerEditor;
+ mediaManagerEditor.clipboardInsertMedia(mediaIds);
+
+ break;
+ }
+
+ case "com.woltlab.wcf.media.setCategory":
+ clipboardObjectIds = mediaIds;
+
+ Ajax.api(ajax, {
+ actionName: "getSetCategoryDialog",
+ });
+
+ break;
+ }
+}
+
+/**
+ * Sets the category of the marked media files.
+ */
+function setCategory(categoryID: number) {
+ Ajax.api(ajax, {
+ actionName: "setCategory",
+ objectIDs: clipboardObjectIds,
+ parameters: {
+ categoryID: categoryID,
+ },
+ });
+}
+
+export function init(pageClassName: string, hasMarkedItems: boolean, mediaManager: MediaManager): void {
+ Clipboard.setup({
+ hasMarkedItems: hasMarkedItems,
+ pageClassName: pageClassName,
+ });
+
+ _mediaManager = mediaManager;
+
+ EventHandler.add("com.woltlab.wcf.clipboard", "com.woltlab.wcf.media", (data) => clipboardAction(data));
+}
--- /dev/null
+/**
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Media/Data
+ */
+
+import MediaUpload from "./Upload";
+import { FileElements, UploadOptions } from "../Upload/Data";
+import MediaEditor from "./Editor";
+import MediaManager from "./Manager/Base";
+import { RedactorEditor } from "../Ui/Redactor/Editor";
+import { I18nValues } from "../Language/Input";
+
+export interface Media {
+ altText: I18nValues | string;
+ caption: I18nValues | string;
+ categoryID: number;
+ elementTag: string;
+ captionEnableHtml: number;
+ filename: string;
+ formattedFilesize: string;
+ languageID: number | null;
+ isImage: number;
+ isMultilingual: number;
+ link: string;
+ mediaID: number;
+ smallThumbnailLink: string;
+ smallThumbnailType: string;
+ tinyThumbnailLink: string;
+ tinyThumbnailType: string;
+ title: I18nValues | string;
+}
+
+export interface MediaManagerOptions {
+ dialogTitle: string;
+ imagesOnly: boolean;
+ minSearchLength: number;
+}
+
+export const enum MediaInsertType {
+ Separate = "separate",
+}
+
+export interface MediaManagerEditorOptions extends MediaManagerOptions {
+ buttonClass?: string;
+ callbackInsert: (media: Map<number, Media>, insertType: MediaInsertType, thumbnailSize: string) => void;
+ editor?: RedactorEditor;
+}
+
+export interface MediaManagerSelectOptions extends MediaManagerOptions {
+ buttonClass?: string;
+}
+
+export interface MediaEditorCallbackObject {
+ _editorClose?: () => void;
+ _editorSuccess?: (Media, number?) => void;
+}
+
+export interface MediaUploadSuccessEventData {
+ files: FileElements;
+ isMultiFileUpload: boolean;
+ media: Media[];
+ upload: MediaUpload;
+ uploadId: number;
+}
+
+export interface MediaUploadOptions extends UploadOptions {
+ elementTagSize: number;
+ mediaEditor?: MediaEditor;
+ mediaManager?: MediaManager;
+}
+
+export interface MediaListUploadOptions extends MediaUploadOptions {
+ categoryId?: number;
+}
+
+export interface MediaUploadAjaxResponseData {
+ returnValues: {
+ errors: MediaUploadError[];
+ media: Media[];
+ };
+}
+
+export interface MediaUploadError {
+ errorType: string;
+ filename: string;
+}
--- /dev/null
+/**
+ * Handles editing media files via dialog.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Media/Editor
+ */
+
+import * as Core from "../Core";
+import { Media, MediaEditorCallbackObject } from "./Data";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../Ajax/Data";
+import * as UiNotification from "../Ui/Notification";
+import * as UiDialog from "../Ui/Dialog";
+import { DialogCallbackObject } from "../Ui/Dialog/Data";
+import * as LanguageChooser from "../Language/Chooser";
+import * as LanguageInput from "../Language/Input";
+import * as DomUtil from "../Dom/Util";
+import * as DomTraverse from "../Dom/Traverse";
+import DomChangeListener from "../Dom/Change/Listener";
+import * as Language from "../Language";
+import * as Ajax from "../Ajax";
+import MediaReplace from "./Replace";
+import { I18nValues } from "../Language/Input";
+
+interface InitEditorData {
+ returnValues: {
+ availableLanguageCount: number;
+ categoryIDs: number[];
+ mediaData?: Media;
+ };
+}
+
+class MediaEditor implements AjaxCallbackObject {
+ protected _availableLanguageCount = 1;
+ protected _categoryIds: number[] = [];
+ protected _dialogs = new Map<string, DialogCallbackObject>();
+ protected readonly _callbackObject: MediaEditorCallbackObject;
+ protected _media: Media | null = null;
+ protected _oldCategoryId = 0;
+
+ constructor(callbackObject: MediaEditorCallbackObject) {
+ this._callbackObject = callbackObject || {};
+
+ if (this._callbackObject._editorClose && typeof this._callbackObject._editorClose !== "function") {
+ throw new TypeError("Callback object has no function '_editorClose'.");
+ }
+ if (this._callbackObject._editorSuccess && typeof this._callbackObject._editorSuccess !== "function") {
+ throw new TypeError("Callback object has no function '_editorSuccess'.");
+ }
+ }
+
+ public _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "update",
+ className: "wcf\\data\\media\\MediaAction",
+ },
+ };
+ }
+
+ public _ajaxSuccess(): void {
+ UiNotification.show();
+
+ if (this._callbackObject._editorSuccess) {
+ this._callbackObject._editorSuccess(this._media, this._oldCategoryId);
+ this._oldCategoryId = 0;
+ }
+
+ UiDialog.close(`mediaEditor_${this._media!.mediaID}`);
+
+ this._media = null;
+ }
+
+ /**
+ * Is called if an editor is manually closed by the user.
+ */
+ protected _close(): void {
+ this._media = null;
+
+ if (this._callbackObject._editorClose) {
+ this._callbackObject._editorClose();
+ }
+ }
+
+ /**
+ * Initializes the editor dialog.
+ *
+ * @since 5.3
+ */
+ protected _initEditor(content: HTMLElement, data: InitEditorData): void {
+ this._availableLanguageCount = ~~data.returnValues.availableLanguageCount;
+ this._categoryIds = data.returnValues.categoryIDs.map((number) => ~~number);
+
+ if (data.returnValues.mediaData) {
+ this._media = data.returnValues.mediaData;
+ }
+ const mediaId = this._media!.mediaID;
+
+ // make sure that the language chooser is initialized first
+ setTimeout(() => {
+ if (this._availableLanguageCount > 1) {
+ LanguageChooser.setLanguageId(
+ `mediaEditor_${mediaId}_languageID`,
+ this._media!.languageID || window.LANGUAGE_ID,
+ );
+ }
+
+ if (this._categoryIds.length) {
+ const categoryID = content.querySelector("select[name=categoryID]") as HTMLSelectElement;
+ if (this._media!.categoryID) {
+ categoryID.value = this._media!.categoryID.toString();
+ } else {
+ categoryID.value = "0";
+ }
+ }
+
+ const title = content.querySelector("input[name=title]") as HTMLInputElement;
+ const altText = content.querySelector("input[name=altText]") as HTMLInputElement;
+ const caption = content.querySelector("textarea[name=caption]") as HTMLInputElement;
+
+ if (this._availableLanguageCount > 1 && this._media!.isMultilingual) {
+ if (document.getElementById(`altText_${mediaId}`)) {
+ LanguageInput.setValues(`altText_${mediaId}`, (this._media!.altText || {}) as I18nValues);
+ }
+
+ if (document.getElementById(`caption_${mediaId}`)) {
+ LanguageInput.setValues(`caption_${mediaId}`, (this._media!.caption || {}) as I18nValues);
+ }
+
+ LanguageInput.setValues(`title_${mediaId}`, (this._media!.title || {}) as I18nValues);
+ } else {
+ title.value = this._media?.title[this._media.languageID || window.LANGUAGE_ID] || "";
+ if (altText) {
+ altText.value = this._media?.altText[this._media.languageID || window.LANGUAGE_ID] || "";
+ }
+ if (caption) {
+ caption.value = this._media?.caption[this._media.languageID || window.LANGUAGE_ID] || "";
+ }
+ }
+
+ if (this._availableLanguageCount > 1) {
+ const isMultilingual = content.querySelector("input[name=isMultilingual]") as HTMLInputElement;
+ isMultilingual.addEventListener("change", (ev) => this._updateLanguageFields(ev));
+
+ this._updateLanguageFields(null, isMultilingual);
+ }
+
+ if (altText) {
+ altText.addEventListener("keypress", (ev) => this._keyPress(ev));
+ }
+ title.addEventListener("keypress", (ev) => this._keyPress(ev));
+
+ content.querySelector("button[data-type=submit]")!.addEventListener("click", () => this._saveData());
+
+ // remove focus from input elements and scroll dialog to top
+ (document.activeElement! as HTMLElement).blur();
+ (document.getElementById(`mediaEditor_${mediaId}`)!.parentNode as HTMLElement).scrollTop = 0;
+
+ // Initialize button to replace media file.
+ const uploadButton = content.querySelector(".mediaManagerMediaReplaceButton")!;
+ let target = content.querySelector(".mediaThumbnail");
+ if (!target) {
+ target = document.createElement("div");
+ content.appendChild(target);
+ }
+ new MediaReplace(
+ mediaId,
+ DomUtil.identify(uploadButton),
+ // Pass an anonymous element for non-images which is required internally
+ // but not needed in this case.
+ DomUtil.identify(target),
+ {
+ mediaEditor: this,
+ },
+ );
+
+ DomChangeListener.trigger();
+ }, 200);
+ }
+
+ /**
+ * Handles the `[ENTER]` key to submit the form.
+ */
+ protected _keyPress(event: KeyboardEvent): void {
+ if (event.key === "Enter") {
+ event.preventDefault();
+
+ this._saveData();
+ }
+ }
+
+ /**
+ * Saves the data of the currently edited media.
+ */
+ protected _saveData(): void {
+ const content = UiDialog.getDialog(`mediaEditor_${this._media!.mediaID}`)!.content;
+
+ const categoryId = content.querySelector("select[name=categoryID]") as HTMLSelectElement;
+ const altText = content.querySelector("input[name=altText]") as HTMLInputElement;
+ const caption = content.querySelector("textarea[name=caption]") as HTMLTextAreaElement;
+ const captionEnableHtml = content.querySelector("input[name=captionEnableHtml]") as HTMLInputElement;
+ const title = content.querySelector("input[name=title]") as HTMLInputElement;
+
+ let hasError = false;
+ const altTextError = altText ? DomTraverse.childByClass(altText.parentNode! as HTMLElement, "innerError") : false;
+ const captionError = caption ? DomTraverse.childByClass(caption.parentNode! as HTMLElement, "innerError") : false;
+ const titleError = DomTraverse.childByClass(title.parentNode! as HTMLElement, "innerError");
+
+ // category
+ this._oldCategoryId = this._media!.categoryID;
+ if (this._categoryIds.length) {
+ this._media!.categoryID = ~~categoryId.value;
+
+ // if the selected category id not valid (manipulated DOM), ignore
+ if (this._categoryIds.indexOf(this._media!.categoryID) === -1) {
+ this._media!.categoryID = 0;
+ }
+ }
+
+ // language and multilingualism
+ if (this._availableLanguageCount > 1) {
+ const isMultilingual = content.querySelector("input[name=isMultilingual]") as HTMLInputElement;
+ this._media!.isMultilingual = ~~isMultilingual.checked;
+ this._media!.languageID = this._media!.isMultilingual
+ ? null
+ : LanguageChooser.getLanguageId(`mediaEditor_${this._media!.mediaID}_languageID`);
+ } else {
+ this._media!.languageID = window.LANGUAGE_ID;
+ }
+
+ // altText, caption and title
+ this._media!.altText = {};
+ this._media!.caption = {};
+ this._media!.title = {};
+ if (this._availableLanguageCount > 1 && this._media!.isMultilingual) {
+ if (altText && !LanguageInput.validate(altText.id, true)) {
+ hasError = true;
+ if (!altTextError) {
+ DomUtil.innerError(altText, Language.get("wcf.global.form.error.multilingual"));
+ }
+ }
+ if (caption && !LanguageInput.validate(caption.id, true)) {
+ hasError = true;
+ if (!captionError) {
+ DomUtil.innerError(caption, Language.get("wcf.global.form.error.multilingual"));
+ }
+ }
+ if (!LanguageInput.validate(title.id, true)) {
+ hasError = true;
+ if (!titleError) {
+ DomUtil.innerError(title, Language.get("wcf.global.form.error.multilingual"));
+ }
+ }
+
+ this._media!.altText = altText ? this.mapToI18nValues(LanguageInput.getValues(altText.id)) : "";
+ this._media!.caption = caption ? this.mapToI18nValues(LanguageInput.getValues(caption.id)) : "";
+ this._media!.title = this.mapToI18nValues(LanguageInput.getValues(title.id));
+ } else {
+ this._media!.altText[this._media!.languageID!] = altText ? altText.value : "";
+ this._media!.caption[this._media!.languageID!] = caption ? caption.value : "";
+ this._media!.title[this._media!.languageID!] = title.value;
+ }
+
+ // captionEnableHtml
+ if (captionEnableHtml) {
+ this._media!.captionEnableHtml = ~~captionEnableHtml.checked;
+ } else {
+ this._media!.captionEnableHtml = 0;
+ }
+
+ const aclValues = {
+ allowAll: ~~(document.getElementById(`mediaEditor_${this._media!.mediaID}_aclAllowAll`)! as HTMLInputElement)
+ .checked,
+ group: Array.from(
+ content.querySelectorAll(`input[name="mediaEditor_${this._media!.mediaID}_aclValues[group][]"]`),
+ ).map((aclGroup: HTMLInputElement) => ~~aclGroup.value),
+ user: Array.from(
+ content.querySelectorAll(`input[name="mediaEditor_${this._media!.mediaID}_aclValues[user][]"]`),
+ ).map((aclUser: HTMLInputElement) => ~~aclUser.value),
+ };
+
+ if (!hasError) {
+ if (altTextError) {
+ altTextError.remove();
+ }
+ if (captionError) {
+ captionError.remove();
+ }
+ if (titleError) {
+ titleError.remove();
+ }
+
+ Ajax.api(this, {
+ actionName: "update",
+ objectIDs: [this._media!.mediaID],
+ parameters: {
+ aclValues: aclValues,
+ altText: this._media!.altText,
+ caption: this._media!.caption,
+ data: {
+ captionEnableHtml: this._media!.captionEnableHtml,
+ categoryID: this._media!.categoryID,
+ isMultilingual: this._media!.isMultilingual,
+ languageID: this._media!.languageID,
+ },
+ title: this._media!.title,
+ },
+ });
+ }
+ }
+
+ private mapToI18nValues(values: Map<number, string>): I18nValues {
+ const obj = {};
+ values.forEach((value, key) => (obj[key] = value));
+
+ return obj;
+ }
+
+ /**
+ * Updates language-related input fields depending on whether multilingualis is enabled.
+ */
+ protected _updateLanguageFields(event: Event | null, element?: HTMLInputElement): void {
+ if (event) {
+ element = event.currentTarget as HTMLInputElement;
+ }
+
+ const mediaId = this._media!.mediaID;
+ const languageChooserContainer = document.getElementById(`mediaEditor_${mediaId}_languageIDContainer`)!
+ .parentNode! as HTMLElement;
+
+ if (element!.checked) {
+ LanguageInput.enable(`title_${mediaId}`);
+ if (document.getElementById(`caption_${mediaId}`)) {
+ LanguageInput.enable(`caption_${mediaId}`);
+ }
+ if (document.getElementById(`altText_${mediaId}`)) {
+ LanguageInput.enable(`altText_${mediaId}`);
+ }
+
+ DomUtil.hide(languageChooserContainer);
+ } else {
+ LanguageInput.disable(`title_${mediaId}`);
+ if (document.getElementById(`caption_${mediaId}`)) {
+ LanguageInput.disable(`caption_${mediaId}`);
+ }
+ if (document.getElementById(`altText_${mediaId}`)) {
+ LanguageInput.disable(`altText_${mediaId}`);
+ }
+
+ DomUtil.show(languageChooserContainer);
+ }
+ }
+
+ /**
+ * Edits the media with the given data or id.
+ */
+ public edit(editedMedia: Media | number): void {
+ let media: Media;
+ let mediaId = 0;
+ if (typeof editedMedia === "object") {
+ media = editedMedia;
+ mediaId = media.mediaID;
+ } else {
+ media = {
+ mediaID: editedMedia,
+ } as Media;
+ mediaId = editedMedia;
+ }
+
+ if (this._media !== null) {
+ throw new Error(`Cannot edit media with id ${mediaId} while editing media with id '${this._media.mediaID}'.`);
+ }
+
+ this._media = media;
+
+ if (!this._dialogs.has(`mediaEditor_${mediaId}`)) {
+ this._dialogs.set(`mediaEditor_${mediaId}`, {
+ _dialogSetup: () => {
+ return {
+ id: `mediaEditor_${mediaId}`,
+ options: {
+ backdropCloseOnClick: false,
+ onClose: () => this._close(),
+ title: Language.get("wcf.media.edit"),
+ },
+ source: {
+ after: (content: HTMLElement, responseData: InitEditorData) => this._initEditor(content, responseData),
+ data: {
+ actionName: "getEditorDialog",
+ className: "wcf\\data\\media\\MediaAction",
+ objectIDs: [mediaId],
+ },
+ },
+ };
+ },
+ });
+ }
+
+ UiDialog.open(this._dialogs.get(`mediaEditor_${mediaId}`)!);
+ }
+
+ /**
+ * Updates the data of the currently edited media file.
+ */
+ public updateData(media: Media): void {
+ if (this._callbackObject._editorSuccess) {
+ this._callbackObject._editorSuccess(media);
+ }
+ }
+}
+
+Core.enableLegacyInheritance(MediaEditor);
+
+export = MediaEditor;
--- /dev/null
+/**
+ * Uploads media files.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Media/List/Upload
+ */
+
+import MediaUpload from "../Upload";
+import { MediaListUploadOptions } from "../Data";
+import * as Core from "../../Core";
+
+class MediaListUpload extends MediaUpload<MediaListUploadOptions> {
+ protected _createButton(): void {
+ super._createButton();
+
+ const span = this._button.querySelector("span") as HTMLSpanElement;
+
+ const space = document.createTextNode(" ");
+ span.insertBefore(space, span.childNodes[0]);
+
+ const icon = document.createElement("span");
+ icon.className = "icon icon16 fa-upload";
+ span.insertBefore(icon, span.childNodes[0]);
+ }
+
+ protected _getParameters(): ArbitraryObject {
+ if (this._options.categoryId) {
+ return Core.extend(
+ super._getParameters() as object,
+ {
+ categoryID: this._options.categoryId,
+ } as object,
+ ) as ArbitraryObject;
+ }
+
+ return super._getParameters();
+ }
+}
+
+Core.enableLegacyInheritance(MediaListUpload);
+
+export = MediaListUpload;
--- /dev/null
+/**
+ * Provides the media manager dialog.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Media/Manager/Base
+ */
+
+import * as Core from "../../Core";
+import { Media, MediaManagerOptions, MediaEditorCallbackObject, MediaUploadSuccessEventData } from "../Data";
+import * as Language from "../../Language";
+import * as Permission from "../../Permission";
+import * as DomChangeListener from "../../Dom/Change/Listener";
+import * as EventHandler from "../../Event/Handler";
+import * as DomTraverse from "../../Dom/Traverse";
+import * as DomUtil from "../../Dom/Util";
+import * as UiDialog from "../../Ui/Dialog";
+import { DialogCallbackSetup, DialogCallbackObject } from "../../Ui/Dialog/Data";
+import * as Clipboard from "../../Controller/Clipboard";
+import UiPagination from "../../Ui/Pagination";
+import * as UiNotification from "../../Ui/Notification";
+import * as StringUtil from "../../StringUtil";
+import MediaManagerSearch from "./Search";
+import MediaUpload from "../Upload";
+import MediaEditor from "../Editor";
+import * as MediaClipboard from "../Clipboard";
+
+let mediaManagerCounter = 0;
+
+interface DialogInitAjaxResponseData {
+ returnValues: {
+ hasMarkedItems: number;
+ media: object;
+ pageCount: number;
+ };
+}
+
+interface SetMediaAdditionalData {
+ pageCount: number;
+ pageNo: number;
+}
+
+abstract class MediaManager<TOptions extends MediaManagerOptions = MediaManagerOptions>
+ implements DialogCallbackObject, MediaEditorCallbackObject {
+ protected _forceClipboard = false;
+ protected _hadInitiallyMarkedItems = false;
+ protected readonly _id;
+ protected readonly _listItems = new Map<number, HTMLLIElement>();
+ protected _media = new Map<number, Media>();
+ protected _mediaCategorySelect: HTMLSelectElement | null;
+ protected readonly _mediaEditor: MediaEditor | null = null;
+ protected _mediaManagerMediaList: HTMLElement | null = null;
+ protected _pagination: UiPagination | null = null;
+ protected _search: MediaManagerSearch | null = null;
+ protected _upload: any = null;
+ protected readonly _options: TOptions;
+
+ constructor(options: Partial<TOptions>) {
+ this._options = Core.extend(
+ {
+ dialogTitle: Language.get("wcf.media.manager"),
+ imagesOnly: false,
+ minSearchLength: 3,
+ },
+ options,
+ ) as TOptions;
+
+ this._id = `mediaManager${mediaManagerCounter++}`;
+
+ if (Permission.get("admin.content.cms.canManageMedia")) {
+ this._mediaEditor = new MediaEditor(this);
+ }
+
+ DomChangeListener.add("WoltLabSuite/Core/Media/Manager", () => this._addButtonEventListeners());
+
+ EventHandler.add("com.woltlab.wcf.media.upload", "success", (data: MediaUploadSuccessEventData) =>
+ this._openEditorAfterUpload(data),
+ );
+ }
+
+ /**
+ * Adds click event listeners to media buttons.
+ */
+ protected _addButtonEventListeners(): void {
+ if (!this._mediaManagerMediaList || !Permission.get("admin.content.cms.canManageMedia")) return;
+
+ DomTraverse.childrenByTag(this._mediaManagerMediaList, "LI").forEach((listItem) => {
+ const editIcon = listItem.querySelector(".jsMediaEditButton");
+ if (editIcon) {
+ editIcon.classList.remove("jsMediaEditButton");
+ editIcon.addEventListener("click", (ev) => this._editMedia(ev));
+ }
+ });
+ }
+
+ /**
+ * Is called when a new category is selected.
+ */
+ protected _categoryChange(): void {
+ this._search!.search();
+ }
+
+ /**
+ * Handles clicks on the media manager button.
+ */
+ protected _click(event: Event): void {
+ event.preventDefault();
+
+ UiDialog.open(this);
+ }
+
+ /**
+ * Is called if the media manager dialog is closed.
+ */
+ protected _dialogClose(): void {
+ // only show media clipboard if editor is open
+ if (Permission.get("admin.content.cms.canManageMedia") || this._forceClipboard) {
+ Clipboard.hideEditor("com.woltlab.wcf.media");
+ }
+ }
+
+ /**
+ * Initializes the dialog when first loaded.
+ */
+ protected _dialogInit(content: HTMLElement, data: DialogInitAjaxResponseData): void {
+ // store media data locally
+ Object.entries(data.returnValues.media || {}).forEach(([mediaId, media]) => {
+ this._media.set(~~mediaId, media);
+ });
+
+ this._initPagination(~~data.returnValues.pageCount);
+
+ this._hadInitiallyMarkedItems = data.returnValues.hasMarkedItems > 0;
+ }
+
+ /**
+ * Returns all data to setup the media manager dialog.
+ */
+ public _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: this._id,
+ options: {
+ onClose: () => this._dialogClose(),
+ onShow: () => this._dialogShow(),
+ title: this._options.dialogTitle,
+ },
+ source: {
+ after: (content: HTMLElement, data: DialogInitAjaxResponseData) => this._dialogInit(content, data),
+ data: {
+ actionName: "getManagementDialog",
+ className: "wcf\\data\\media\\MediaAction",
+ parameters: {
+ mode: this.getMode(),
+ imagesOnly: this._options.imagesOnly,
+ },
+ },
+ },
+ };
+ }
+
+ /**
+ * Is called if the media manager dialog is shown.
+ */
+ protected _dialogShow(): void {
+ if (!this._mediaManagerMediaList) {
+ const dialog = this.getDialog();
+
+ this._mediaManagerMediaList = dialog.querySelector(".mediaManagerMediaList");
+
+ this._mediaCategorySelect = dialog.querySelector(".mediaManagerCategoryList > select");
+ if (this._mediaCategorySelect) {
+ this._mediaCategorySelect.addEventListener("change", () => this._categoryChange());
+ }
+
+ // store list items locally
+ const listItems = DomTraverse.childrenByTag(this._mediaManagerMediaList!, "LI");
+ listItems.forEach((listItem: HTMLLIElement) => {
+ this._listItems.set(~~listItem.dataset.objectId!, listItem);
+ });
+
+ if (Permission.get("admin.content.cms.canManageMedia")) {
+ const uploadButton = UiDialog.getDialog(this)!.dialog.querySelector(".mediaManagerMediaUploadButton")!;
+ this._upload = new MediaUpload(DomUtil.identify(uploadButton), DomUtil.identify(this._mediaManagerMediaList!), {
+ mediaManager: this,
+ });
+
+ // eslint-disable-next-line
+ //@ts-ignore
+ const deleteAction = new WCF.Action.Delete("wcf\\data\\media\\MediaAction", ".mediaFile");
+ deleteAction._didTriggerEffect = (element) => this.removeMedia(element[0].dataset.objectId);
+ }
+
+ if (Permission.get("admin.content.cms.canManageMedia") || this._forceClipboard) {
+ MediaClipboard.init("menuManagerDialog-" + this.getMode(), this._hadInitiallyMarkedItems ? true : false, this);
+ } else {
+ this._removeClipboardCheckboxes();
+ }
+
+ this._search = new MediaManagerSearch(this);
+
+ if (!listItems.length) {
+ this._search.hideSearch();
+ }
+ }
+
+ // only show media clipboard if editor is open
+ if (Permission.get("admin.content.cms.canManageMedia") || this._forceClipboard) {
+ Clipboard.showEditor();
+ }
+ }
+
+ /**
+ * Opens the media editor for a media file.
+ */
+ protected _editMedia(event: Event): void {
+ if (!Permission.get("admin.content.cms.canManageMedia")) {
+ throw new Error("You are not allowed to edit media files.");
+ }
+
+ UiDialog.close(this);
+
+ const target = event.currentTarget as HTMLElement;
+
+ this._mediaEditor!.edit(this._media.get(~~target.dataset.objectId!)!);
+ }
+
+ /**
+ * Re-opens the manager dialog after closing the editor dialog.
+ */
+ _editorClose(): void {
+ UiDialog.open(this);
+ }
+
+ /**
+ * Re-opens the manager dialog and updates the media data after successfully editing a media file.
+ */
+ _editorSuccess(media: Media, oldCategoryId?: number): void {
+ // if the category changed of media changed and category
+ // is selected, check if media list needs to be refreshed
+ if (this._mediaCategorySelect) {
+ const selectedCategoryId = ~~this._mediaCategorySelect.value;
+
+ if (selectedCategoryId) {
+ const newCategoryId = ~~media.categoryID;
+
+ if (
+ oldCategoryId != newCategoryId &&
+ (oldCategoryId == selectedCategoryId || newCategoryId == selectedCategoryId)
+ ) {
+ this._search!.search();
+ }
+ }
+ }
+
+ UiDialog.open(this);
+
+ this._media.set(~~media.mediaID, media);
+
+ const listItem = this._listItems.get(~~media.mediaID)!;
+ const p = listItem.querySelector(".mediaTitle")!;
+ if (media.isMultilingual) {
+ if (media.title && media.title[window.LANGUAGE_ID]) {
+ p.textContent = media.title[window.LANGUAGE_ID];
+ } else {
+ p.textContent = media.filename;
+ }
+ } else {
+ if (media.title && media.title[media.languageID!]) {
+ p.textContent = media.title[media.languageID!];
+ } else {
+ p.textContent = media.filename;
+ }
+ }
+
+ const thumbnail = listItem.querySelector(".mediaThumbnail")!;
+ thumbnail.innerHTML = media.elementTag;
+ // Bust browser cache by adding additional parameter.
+ const img = thumbnail.querySelector("img");
+ if (img) {
+ img.src += `&refresh=${Date.now()}`;
+ }
+ }
+
+ /**
+ * Initializes the dialog pagination.
+ */
+ protected _initPagination(pageCount: number, pageNo?: number): void {
+ if (pageNo === undefined) pageNo = 1;
+
+ if (pageCount > 1) {
+ const newPagination = document.createElement("div");
+ newPagination.className = "paginationBottom jsPagination";
+ DomUtil.replaceElement(
+ UiDialog.getDialog(this)!.content.querySelector(".jsPagination") as HTMLElement,
+ newPagination,
+ );
+
+ this._pagination = new UiPagination(newPagination, {
+ activePage: pageNo,
+ callbackSwitch: (pageNo: number) => this._search!.search(pageNo),
+ maxPage: pageCount,
+ });
+ } else if (this._pagination) {
+ DomUtil.hide(this._pagination.getElement());
+ }
+ }
+
+ /**
+ * Removes all media clipboard checkboxes.
+ */
+ _removeClipboardCheckboxes(): void {
+ this._mediaManagerMediaList!.querySelectorAll(".mediaCheckbox").forEach((el) => el.remove());
+ }
+
+ /**
+ * Opens the media editor after uploading a single file.
+ *
+ * @since 5.2
+ */
+ _openEditorAfterUpload(data: MediaUploadSuccessEventData): void {
+ if (data.upload === this._upload && !data.isMultiFileUpload && !this._upload.hasPendingUploads()) {
+ const keys = Object.keys(data.media);
+
+ if (keys.length) {
+ UiDialog.close(this);
+
+ this._mediaEditor!.edit(this._media.get(~~data.media[keys[0]].mediaID)!);
+ }
+ }
+ }
+
+ /**
+ * Sets the displayed media (after a search).
+ */
+ _setMedia(media: object): void {
+ this._media = new Map<number, Media>(Object.entries(media).map(([mediaId, media]) => [~~mediaId, media]));
+
+ let info = DomTraverse.nextByClass(this._mediaManagerMediaList!, "info") as HTMLElement;
+
+ if (this._media.size) {
+ if (info) {
+ DomUtil.hide(info);
+ }
+ } else {
+ if (info === null) {
+ info = document.createElement("p");
+ info.className = "info";
+ info.textContent = Language.get("wcf.media.search.noResults");
+ }
+
+ DomUtil.show(info);
+ DomUtil.insertAfter(info, this._mediaManagerMediaList!);
+ }
+
+ DomTraverse.childrenByTag(this._mediaManagerMediaList!, "LI").forEach((listItem) => {
+ if (!this._media.has(~~listItem.dataset.objectId!)) {
+ DomUtil.hide(listItem);
+ } else {
+ DomUtil.show(listItem);
+ }
+ });
+
+ DomChangeListener.trigger();
+
+ if (Permission.get("admin.content.cms.canManageMedia") || this._forceClipboard) {
+ Clipboard.reload();
+ } else {
+ this._removeClipboardCheckboxes();
+ }
+ }
+
+ /**
+ * Adds a media file to the manager.
+ */
+ public addMedia(media: Media, listItem: HTMLLIElement): void {
+ if (!media.languageID) media.isMultilingual = 1;
+
+ this._media.set(~~media.mediaID, media);
+ this._listItems.set(~~media.mediaID, listItem);
+
+ if (this._listItems.size === 1) {
+ this._search!.showSearch();
+ }
+ }
+
+ /**
+ * Is called after the media files with the given ids have been deleted via clipboard.
+ */
+ public clipboardDeleteMedia(mediaIds: number[]): void {
+ mediaIds.forEach((mediaId) => {
+ this.removeMedia(~~mediaId);
+ });
+
+ UiNotification.show();
+ }
+
+ /**
+ * Returns the id of the currently selected category or `0` if no category is selected.
+ */
+ public getCategoryId(): number {
+ if (this._mediaCategorySelect) {
+ return ~~this._mediaCategorySelect.value;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Returns the media manager dialog element.
+ */
+ getDialog(): HTMLElement {
+ return UiDialog.getDialog(this)!.dialog;
+ }
+
+ /**
+ * Returns the mode of the media manager.
+ */
+ public getMode(): string {
+ return "";
+ }
+
+ /**
+ * Returns the media manager option with the given name.
+ */
+ public getOption(name: string): any {
+ if (this._options[name]) {
+ return this._options[name];
+ }
+
+ return null;
+ }
+
+ /**
+ * Removes a media file.
+ */
+ public removeMedia(mediaId: number): void {
+ if (this._listItems.has(mediaId)) {
+ // remove list item
+ try {
+ this._listItems.get(mediaId)!.remove();
+ } catch (e) {
+ // ignore errors if item has already been removed like by WCF.Action.Delete
+ }
+
+ this._listItems.delete(mediaId);
+ this._media.delete(mediaId);
+ }
+ }
+
+ /**
+ * Changes the displayed media to the previously displayed media.
+ */
+ public resetMedia(): void {
+ // calling WoltLabSuite/Core/Media/Manager/Search.search() reloads the first page of the dialog
+ this._search!.search();
+ }
+
+ /**
+ * Sets the media files currently displayed.
+ */
+ setMedia(media: object, template: string, additionalData: SetMediaAdditionalData): void {
+ const hasMedia = Object.entries(media).length > 0;
+
+ if (hasMedia) {
+ const ul = document.createElement("ul");
+ ul.innerHTML = template;
+
+ DomTraverse.childrenByTag(ul, "LI").forEach((listItem) => {
+ if (!this._listItems.has(~~listItem.dataset.objectId!)) {
+ this._listItems.set(~~listItem.dataset.objectId!, listItem);
+
+ this._mediaManagerMediaList!.appendChild(listItem);
+ }
+ });
+ }
+
+ this._initPagination(additionalData.pageCount, additionalData.pageNo);
+
+ this._setMedia(media);
+ }
+
+ /**
+ * Sets up a new media element.
+ */
+ public setupMediaElement(media: Media, mediaElement: HTMLElement): void {
+ const mediaInformation = DomTraverse.childByClass(mediaElement, "mediaInformation")!;
+
+ const buttonGroupNavigation = document.createElement("nav");
+ buttonGroupNavigation.className = "jsMobileNavigation buttonGroupNavigation";
+ mediaInformation.parentNode!.appendChild(buttonGroupNavigation);
+
+ const buttons = document.createElement("ul");
+ buttons.className = "buttonList iconList";
+ buttonGroupNavigation.appendChild(buttons);
+
+ const listItem = document.createElement("li");
+ listItem.className = "mediaCheckbox";
+ buttons.appendChild(listItem);
+
+ const a = document.createElement("a");
+ listItem.appendChild(a);
+
+ const label = document.createElement("label");
+ a.appendChild(label);
+
+ const checkbox = document.createElement("input");
+ checkbox.className = "jsClipboardItem";
+ checkbox.type = "checkbox";
+ checkbox.dataset.objectId = media.mediaID.toString();
+ label.appendChild(checkbox);
+
+ if (Permission.get("admin.content.cms.canManageMedia")) {
+ const editButton = document.createElement("li");
+ editButton.className = "jsMediaEditButton";
+ editButton.dataset.objectId = media.mediaID.toString();
+ buttons.appendChild(editButton);
+
+ editButton.innerHTML = `
+ <a>
+ <span class="icon icon16 fa-pencil jsTooltip" title="${Language.get("wcf.global.button.edit")}"></span>
+ <span class="invisible">${Language.get("wcf.global.button.edit")}</span>
+ </a>`;
+
+ const deleteButton = document.createElement("li");
+ deleteButton.className = "jsDeleteButton";
+ deleteButton.dataset.objectId = media.mediaID.toString();
+
+ // use temporary title to not unescape html in filename
+ const uuid = Core.getUuid();
+ deleteButton.dataset.confirmMessageHtml = StringUtil.unescapeHTML(
+ Language.get("wcf.media.delete.confirmMessage", {
+ title: uuid,
+ }),
+ ).replace(uuid, StringUtil.escapeHTML(media.filename));
+ buttons.appendChild(deleteButton);
+
+ deleteButton.innerHTML = `
+ <a>
+ <span class="icon icon16 fa-times jsTooltip" title="${Language.get("wcf.global.button.delete")}"></span>
+ <span class="invisible">${Language.get("wcf.global.button.delete")}</span>
+ </a>`;
+ }
+ }
+}
+
+Core.enableLegacyInheritance(MediaManager);
+
+export = MediaManager;
--- /dev/null
+/**
+ * Provides the media manager dialog for selecting media for Redactor editors.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Media/Manager/Editor
+ */
+
+import MediaManager from "./Base";
+import * as Core from "../../Core";
+import { Media, MediaInsertType, MediaManagerEditorOptions, MediaUploadSuccessEventData } from "../Data";
+import * as EventHandler from "../../Event/Handler";
+import * as DomTraverse from "../../Dom/Traverse";
+import * as Language from "../../Language";
+import * as UiDialog from "../../Ui/Dialog";
+import * as Clipboard from "../../Controller/Clipboard";
+import { OnDropPayload } from "../../Ui/Redactor/DragAndDrop";
+import DomUtil from "../../Dom/Util";
+
+interface PasteFromClipboard {
+ blob: Blob;
+}
+
+class MediaManagerEditor extends MediaManager<MediaManagerEditorOptions> {
+ protected _activeButton;
+ protected readonly _buttons: HTMLCollectionOf<HTMLElement>;
+ protected _mediaToInsert: Map<number, Media>;
+ protected _mediaToInsertByClipboard: boolean;
+ protected _uploadData: OnDropPayload | PasteFromClipboard | null;
+ protected _uploadId: number | null;
+
+ constructor(options: Partial<MediaManagerEditorOptions>) {
+ options = Core.extend(
+ {
+ callbackInsert: null,
+ },
+ options,
+ );
+
+ super(options);
+
+ this._forceClipboard = true;
+ this._activeButton = null;
+ const context = this._options.editor ? this._options.editor.core.toolbar()[0] : undefined;
+ this._buttons = (context || window.document).getElementsByClassName(
+ this._options.buttonClass || "jsMediaEditorButton",
+ ) as HTMLCollectionOf<HTMLElement>;
+ Array.from(this._buttons).forEach((button) => {
+ button.addEventListener("click", (ev) => this._click(ev));
+ });
+ this._mediaToInsert = new Map<number, Media>();
+ this._mediaToInsertByClipboard = false;
+ this._uploadData = null;
+ this._uploadId = null;
+
+ if (this._options.editor && !this._options.editor.opts.woltlab.attachments) {
+ const editorId = this._options.editor.$editor[0].dataset.elementId as string;
+
+ const uuid1 = EventHandler.add("com.woltlab.wcf.redactor2", `dragAndDrop_${editorId}`, (data: OnDropPayload) =>
+ this._editorUpload(data),
+ );
+ const uuid2 = EventHandler.add(
+ "com.woltlab.wcf.redactor2",
+ `pasteFromClipboard_${editorId}`,
+ (data: OnDropPayload) => this._editorUpload(data),
+ );
+
+ EventHandler.add("com.woltlab.wcf.redactor2", `destroy_${editorId}`, () => {
+ EventHandler.remove("com.woltlab.wcf.redactor2", `dragAndDrop_${editorId}`, uuid1);
+ EventHandler.remove("com.woltlab.wcf.redactor2", `dragAndDrop_${editorId}`, uuid2);
+ });
+
+ EventHandler.add("com.woltlab.wcf.media.upload", "success", (data) => this._mediaUploaded(data));
+ }
+ }
+
+ protected _addButtonEventListeners(): void {
+ super._addButtonEventListeners();
+
+ if (!this._mediaManagerMediaList) {
+ return;
+ }
+
+ DomTraverse.childrenByTag(this._mediaManagerMediaList, "LI").forEach((listItem) => {
+ const insertIcon = listItem.querySelector(".jsMediaInsertButton");
+ if (insertIcon) {
+ insertIcon.classList.remove("jsMediaInsertButton");
+ insertIcon.addEventListener("click", (ev) => this._openInsertDialog(ev));
+ }
+ });
+ }
+
+ /**
+ * Builds the dialog to setup inserting media files.
+ */
+ protected _buildInsertDialog(): void {
+ let thumbnailOptions = "";
+
+ this._getThumbnailSizes().forEach((thumbnailSize) => {
+ thumbnailOptions +=
+ '<option value="' +
+ thumbnailSize +
+ '">' +
+ Language.get("wcf.media.insert.imageSize." + thumbnailSize) +
+ "</option>";
+ });
+ thumbnailOptions += '<option value="original">' + Language.get("wcf.media.insert.imageSize.original") + "</option>";
+
+ const dialog = `
+ <div class="section">
+ <dl class="thumbnailSizeSelection">
+ <dt>${Language.get("wcf.media.insert.imageSize")}</dt>
+ <dd>
+ <select name="thumbnailSize">
+ ${thumbnailOptions}
+ </select>
+ </dd>
+ </dl>
+ </div>
+ <div class="formSubmit">
+ <button class="buttonPrimary">${Language.get("wcf.global.button.insert")}</button>
+ </div>`;
+
+ UiDialog.open({
+ _dialogSetup: () => {
+ return {
+ id: this._getInsertDialogId(),
+ options: {
+ onClose: () => this._editorClose(),
+ onSetup: (content) => {
+ content.querySelector(".buttonPrimary")!.addEventListener("click", (ev) => this._insertMedia(ev));
+
+ DomUtil.show(content.querySelector(".thumbnailSizeSelection") as HTMLElement);
+ },
+ title: Language.get("wcf.media.insert"),
+ },
+ source: dialog,
+ };
+ },
+ });
+ }
+
+ protected _click(event: Event): void {
+ this._activeButton = event.currentTarget;
+
+ super._click(event);
+ }
+
+ protected _dialogShow(): void {
+ super._dialogShow();
+
+ // check if data needs to be uploaded
+ if (this._uploadData) {
+ const fileUploadData = this._uploadData as OnDropPayload;
+ if (fileUploadData.file) {
+ this._upload.uploadFile(fileUploadData.file);
+ } else {
+ const blobUploadData = this._uploadData as PasteFromClipboard;
+ this._uploadId = this._upload.uploadBlob(blobUploadData.blob);
+ }
+
+ this._uploadData = null;
+ }
+ }
+
+ /**
+ * Handles pasting and dragging and dropping files into the editor.
+ */
+ protected _editorUpload(data: OnDropPayload): void {
+ this._uploadData = data;
+
+ UiDialog.open(this);
+ }
+
+ /**
+ * Returns the id of the insert dialog based on the media files to be inserted.
+ */
+ protected _getInsertDialogId(): string {
+ return ["mediaInsert", ...this._mediaToInsert.keys()].join("-");
+ }
+
+ /**
+ * Returns the supported thumbnail sizes (excluding `original`) for all media images to be inserted.
+ */
+ protected _getThumbnailSizes(): string[] {
+ return ["small", "medium", "large"]
+ .map((size) => {
+ const sizeSupported = Array.from(this._mediaToInsert.values()).every((media) => {
+ return media[size + "ThumbnailType"] !== null;
+ });
+
+ if (sizeSupported) {
+ return size;
+ }
+
+ return null;
+ })
+ .filter((s) => s !== null) as string[];
+ }
+
+ /**
+ * Inserts media files into the editor.
+ */
+ protected _insertMedia(event?: Event | null, thumbnailSize?: string, closeEditor = false): void {
+ if (closeEditor === undefined) closeEditor = true;
+
+ // update insert options with selected values if method is called by clicking on 'insert' button
+ // in dialog
+ if (event) {
+ UiDialog.close(this._getInsertDialogId());
+
+ const dialogContent = (event.currentTarget as HTMLElement).closest(".dialogContent")!;
+ const thumbnailSizeSelect = dialogContent.querySelector("select[name=thumbnailSize]") as HTMLSelectElement;
+ thumbnailSize = thumbnailSizeSelect.value;
+ }
+
+ if (this._options.callbackInsert !== null) {
+ this._options.callbackInsert(this._mediaToInsert, MediaInsertType.Separate, thumbnailSize!);
+ } else {
+ this._options.editor!.buffer.set();
+ }
+
+ if (this._mediaToInsertByClipboard) {
+ Clipboard.unmark("com.woltlab.wcf.media", Array.from(this._mediaToInsert.keys()));
+ }
+
+ this._mediaToInsert = new Map<number, Media>();
+ this._mediaToInsertByClipboard = false;
+
+ // close manager dialog
+ if (closeEditor) {
+ UiDialog.close(this);
+ }
+ }
+
+ /**
+ * Inserts a single media item into the editor.
+ */
+ protected _insertMediaItem(thumbnailSize: string, media: Media): void {
+ if (media.isImage) {
+ let available = "";
+ ["small", "medium", "large", "original"].some((size) => {
+ if (media[size + "ThumbnailHeight"] != 0) {
+ available = size;
+
+ if (thumbnailSize == size) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+
+ thumbnailSize = available;
+
+ if (!thumbnailSize) {
+ thumbnailSize = "original";
+ }
+
+ let link = media.link;
+ if (thumbnailSize !== "original") {
+ link = media[thumbnailSize + "ThumbnailLink"];
+ }
+
+ this._options.editor!.insert.html(
+ `<img src="${link}" class="woltlabSuiteMedia" data-media-id="${media.mediaID}" data-media-size="${thumbnailSize}">`,
+ );
+ } else {
+ this._options.editor!.insert.text(`[wsm='${media.mediaID}'][/wsm]`);
+ }
+ }
+
+ /**
+ * Is called after media files are successfully uploaded to insert copied media.
+ */
+ protected _mediaUploaded(data: MediaUploadSuccessEventData): void {
+ if (this._uploadId !== null && this._upload === data.upload) {
+ if (
+ this._uploadId === data.uploadId ||
+ (Array.isArray(this._uploadId) && this._uploadId.indexOf(data.uploadId) !== -1)
+ ) {
+ this._mediaToInsert = new Map<number, Media>(data.media.entries());
+ this._insertMedia(null, "medium", false);
+
+ this._uploadId = null;
+ }
+ }
+ }
+
+ /**
+ * Handles clicking on the insert button.
+ */
+ protected _openInsertDialog(event: Event): void {
+ const target = event.currentTarget as HTMLElement;
+
+ this.insertMedia([~~target.dataset.objectId!]);
+ }
+
+ /**
+ * Is called to insert the media files with the given ids into an editor.
+ */
+ public clipboardInsertMedia(mediaIds: number[]): void {
+ this.insertMedia(mediaIds, true);
+ }
+
+ /**
+ * Prepares insertion of the media files with the given ids.
+ */
+ public insertMedia(mediaIds: number[], insertedByClipboard?: boolean): void {
+ this._mediaToInsert = new Map<number, Media>();
+ this._mediaToInsertByClipboard = insertedByClipboard || false;
+
+ // open the insert dialog if all media files are images
+ let imagesOnly = true;
+ mediaIds.forEach((mediaId) => {
+ const media = this._media.get(mediaId)!;
+ this._mediaToInsert.set(media.mediaID, media);
+
+ if (!media.isImage) {
+ imagesOnly = false;
+ }
+ });
+
+ if (imagesOnly) {
+ const thumbnailSizes = this._getThumbnailSizes();
+ if (thumbnailSizes.length) {
+ UiDialog.close(this);
+ const dialogId = this._getInsertDialogId();
+ if (UiDialog.getDialog(dialogId)) {
+ UiDialog.openStatic(dialogId, null);
+ } else {
+ this._buildInsertDialog();
+ }
+ } else {
+ this._insertMedia(undefined, "original");
+ }
+ } else {
+ this._insertMedia();
+ }
+ }
+
+ public getMode(): string {
+ return "editor";
+ }
+
+ public setupMediaElement(media: Media, mediaElement: HTMLElement): void {
+ super.setupMediaElement(media, mediaElement);
+
+ // add media insertion icon
+ const buttons = mediaElement.querySelector("nav.buttonGroupNavigation > ul")!;
+
+ const listItem = document.createElement("li");
+ listItem.className = "jsMediaInsertButton";
+ listItem.dataset.objectId = media.mediaID.toString();
+ buttons.appendChild(listItem);
+
+ listItem.innerHTML = `
+ <a>
+ <span class="icon icon16 fa-plus jsTooltip" title="${Language.get("wcf.global.button.insert")}"></span>
+ <span class="invisible">${Language.get("wcf.global.button.insert")}</span>
+ </a>`;
+ }
+}
+
+Core.enableLegacyInheritance(MediaManagerEditor);
+
+export = MediaManagerEditor;
--- /dev/null
+/**
+ * Provides the media search for the media manager.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Media/Manager/Search
+ */
+
+import MediaManager from "./Base";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../../Ajax/Data";
+import { Media } from "../Data";
+import * as DomTraverse from "../../Dom/Traverse";
+import * as Language from "../../Language";
+import * as Ajax from "../../Ajax";
+import * as Core from "../../Core";
+import DomUtil from "../../Dom/Util";
+
+interface AjaxResponseData {
+ returnValues: {
+ media?: Media;
+ pageCount?: number;
+ pageNo?: number;
+ template?: string;
+ };
+}
+
+class MediaManagerSearch implements AjaxCallbackObject {
+ protected readonly _cancelButton: HTMLSpanElement;
+ protected readonly _input: HTMLInputElement;
+ protected readonly _mediaManager: MediaManager;
+ protected readonly _searchContainer: HTMLDivElement;
+ protected _searchMode = false;
+
+ constructor(mediaManager: MediaManager) {
+ this._mediaManager = mediaManager;
+
+ const dialog = mediaManager.getDialog();
+
+ this._searchContainer = dialog.querySelector(".mediaManagerSearch") as HTMLDivElement;
+ this._input = dialog.querySelector(".mediaManagerSearchField") as HTMLInputElement;
+ this._input.addEventListener("keypress", (ev) => this._keyPress(ev));
+
+ this._cancelButton = dialog.querySelector(".mediaManagerSearchCancelButton") as HTMLSpanElement;
+ this._cancelButton.addEventListener("click", () => this._cancelSearch());
+ }
+
+ public _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "getSearchResultList",
+ className: "wcf\\data\\media\\MediaAction",
+ interfaceName: "wcf\\data\\ISearchAction",
+ },
+ };
+ }
+
+ public _ajaxSuccess(data: AjaxResponseData): void {
+ this._mediaManager.setMedia(data.returnValues.media || ({} as Media), data.returnValues.template || "", {
+ pageCount: data.returnValues.pageCount || 0,
+ pageNo: data.returnValues.pageNo || 0,
+ });
+
+ this._mediaManager.getDialog().querySelector(".dialogContent")!.scrollTop = 0;
+ }
+
+ /**
+ * Cancels the search after clicking on the cancel search button.
+ */
+ protected _cancelSearch(): void {
+ if (this._searchMode) {
+ this._searchMode = false;
+
+ this.resetSearch();
+ this._mediaManager.resetMedia();
+ }
+ }
+
+ /**
+ * Hides the search string threshold error.
+ */
+ protected _hideStringThresholdError(): void {
+ const innerInfo = DomTraverse.childByClass(
+ this._input.parentNode!.parentNode as HTMLElement,
+ "innerInfo",
+ ) as HTMLElement;
+ if (innerInfo) {
+ DomUtil.hide(innerInfo);
+ }
+ }
+
+ /**
+ * Handles the `[ENTER]` key to submit the form.
+ */
+ protected _keyPress(event: KeyboardEvent): void {
+ if (event.key === "Enter") {
+ event.preventDefault();
+
+ if (this._input.value.length >= this._mediaManager.getOption("minSearchLength")) {
+ this._hideStringThresholdError();
+
+ this.search();
+ } else {
+ this._showStringThresholdError();
+ }
+ }
+ }
+
+ /**
+ * Shows the search string threshold error.
+ */
+ protected _showStringThresholdError(): void {
+ let innerInfo = DomTraverse.childByClass(
+ this._input.parentNode!.parentNode as HTMLElement,
+ "innerInfo",
+ ) as HTMLParagraphElement;
+ if (innerInfo) {
+ DomUtil.show(innerInfo);
+ } else {
+ innerInfo = document.createElement("p");
+ innerInfo.className = "innerInfo";
+ innerInfo.textContent = Language.get("wcf.media.search.info.searchStringThreshold", {
+ minSearchLength: this._mediaManager.getOption("minSearchLength"),
+ });
+
+ (this._input.parentNode! as HTMLElement).insertAdjacentElement("afterend", innerInfo);
+ }
+ }
+
+ /**
+ * Hides the media search.
+ */
+ public hideSearch(): void {
+ DomUtil.hide(this._searchContainer);
+ }
+
+ /**
+ * Resets the media search.
+ */
+ public resetSearch(): void {
+ this._input.value = "";
+ }
+
+ /**
+ * Shows the media search.
+ */
+ public showSearch(): void {
+ DomUtil.show(this._searchContainer);
+ }
+
+ /**
+ * Sends an AJAX request to fetch search results.
+ */
+ public search(pageNo?: number): void {
+ if (typeof pageNo !== "number") {
+ pageNo = 1;
+ }
+
+ let searchString = this._input.value;
+ if (searchString && this._input.value.length < this._mediaManager.getOption("minSearchLength")) {
+ this._showStringThresholdError();
+
+ searchString = "";
+ } else {
+ this._hideStringThresholdError();
+ }
+
+ this._searchMode = true;
+
+ Ajax.api(this, {
+ parameters: {
+ categoryID: this._mediaManager.getCategoryId(),
+ imagesOnly: this._mediaManager.getOption("imagesOnly"),
+ mode: this._mediaManager.getMode(),
+ pageNo: pageNo,
+ searchString: searchString,
+ },
+ });
+ }
+}
+
+Core.enableLegacyInheritance(MediaManagerSearch);
+
+export = MediaManagerSearch;
--- /dev/null
+/**
+ * Provides the media manager dialog for selecting media for input elements.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Media/Manager/Select
+ */
+
+import MediaManager from "./Base";
+import * as Core from "../../Core";
+import { Media, MediaManagerSelectOptions } from "../Data";
+import * as DomTraverse from "../../Dom/Traverse";
+import * as FileUtil from "../../FileUtil";
+import * as Language from "../../Language";
+import * as UiDialog from "../../Ui/Dialog";
+import DomUtil from "../../Dom/Util";
+
+class MediaManagerSelect extends MediaManager<MediaManagerSelectOptions> {
+ protected _activeButton: HTMLElement | null = null;
+ protected readonly _buttons: HTMLCollectionOf<HTMLInputElement>;
+ protected readonly _storeElements = new WeakMap<HTMLElement, HTMLInputElement>();
+
+ constructor(options: Partial<MediaManagerSelectOptions>) {
+ super(options);
+
+ this._buttons = document.getElementsByClassName(
+ this._options.buttonClass || "jsMediaSelectButton",
+ ) as HTMLCollectionOf<HTMLInputElement>;
+ Array.from(this._buttons).forEach((button) => {
+ // only consider buttons with a proper store specified
+ const store = button.dataset.store;
+ if (store) {
+ const storeElement = document.getElementById(store) as HTMLInputElement;
+ if (storeElement && storeElement.tagName === "INPUT") {
+ button.addEventListener("click", (ev) => this._click(ev));
+
+ this._storeElements.set(button, storeElement);
+
+ // add remove button
+ const removeButton = document.createElement("p");
+ removeButton.className = "button";
+ button.insertAdjacentElement("afterend", removeButton);
+
+ const icon = document.createElement("span");
+ icon.className = "icon icon16 fa-times";
+ removeButton.appendChild(icon);
+
+ if (!storeElement.value) {
+ DomUtil.hide(removeButton);
+ }
+ removeButton.addEventListener("click", (ev) => this._removeMedia(ev));
+ }
+ }
+ });
+ }
+
+ protected _addButtonEventListeners(): void {
+ super._addButtonEventListeners();
+
+ if (!this._mediaManagerMediaList) return;
+
+ DomTraverse.childrenByTag(this._mediaManagerMediaList, "LI").forEach((listItem) => {
+ const chooseIcon = listItem.querySelector(".jsMediaSelectButton");
+ if (chooseIcon) {
+ chooseIcon.classList.remove("jsMediaSelectButton");
+ chooseIcon.addEventListener("click", (ev) => this._chooseMedia(ev));
+ }
+ });
+ }
+
+ /**
+ * Handles clicking on a media choose icon.
+ */
+ protected _chooseMedia(event: Event): void {
+ if (this._activeButton === null) {
+ throw new Error("Media cannot be chosen if no button is active.");
+ }
+
+ const target = event.currentTarget as HTMLElement;
+
+ const media = this._media.get(~~target.dataset.objectId!)!;
+
+ // save selected media in store element
+ const input = document.getElementById(this._activeButton.dataset.store!) as HTMLInputElement;
+ input.value = media.mediaID.toString();
+ Core.triggerEvent(input, "change");
+
+ // display selected media
+ const display = this._activeButton.dataset.display;
+ if (display) {
+ const displayElement = document.getElementById(display);
+ if (displayElement) {
+ if (media.isImage) {
+ const thumbnailLink: string = media.smallThumbnailLink ? media.smallThumbnailLink : media.link;
+ const altText: string =
+ media.altText && media.altText[window.LANGUAGE_ID] ? media.altText[window.LANGUAGE_ID] : "";
+ displayElement.innerHTML = `<img src="${thumbnailLink}" alt="${altText}" />`;
+ } else {
+ let fileIcon = FileUtil.getIconNameByFilename(media.filename);
+ if (fileIcon) {
+ fileIcon = "-" + fileIcon;
+ }
+
+ displayElement.innerHTML = `
+ <div class="box48" style="margin-bottom: 10px;">
+ <span class="icon icon48 fa-file${fileIcon}-o"></span>
+ <div class="containerHeadline">
+ <h3>${media.filename}</h3>
+ <p>${media.formattedFilesize}</p>
+ </div>
+ </div>`;
+ }
+ }
+ }
+
+ // show remove button
+ (this._activeButton.nextElementSibling as HTMLElement).style.removeProperty("display");
+
+ UiDialog.close(this);
+ }
+
+ protected _click(event: Event): void {
+ event.preventDefault();
+ this._activeButton = event.currentTarget as HTMLInputElement;
+
+ super._click(event);
+
+ if (!this._mediaManagerMediaList) {
+ return;
+ }
+
+ const storeElement = this._storeElements.get(this._activeButton)!;
+ DomTraverse.childrenByTag(this._mediaManagerMediaList, "LI").forEach((listItem) => {
+ if (storeElement.value && storeElement.value == listItem.dataset.objectId) {
+ listItem.classList.add("jsSelected");
+ } else {
+ listItem.classList.remove("jsSelected");
+ }
+ });
+ }
+
+ public getMode(): string {
+ return "select";
+ }
+
+ public setupMediaElement(media: Media, mediaElement: HTMLElement): void {
+ super.setupMediaElement(media, mediaElement);
+
+ // add media insertion icon
+ const buttons = mediaElement.querySelector("nav.buttonGroupNavigation > ul") as HTMLUListElement;
+
+ const listItem = document.createElement("li");
+ listItem.className = "jsMediaSelectButton";
+ listItem.dataset.objectId = media.mediaID.toString();
+ buttons.appendChild(listItem);
+
+ listItem.innerHTML =
+ '<a><span class="icon icon16 fa-check jsTooltip" title="' +
+ Language.get("wcf.media.button.select") +
+ '"></span> <span class="invisible">' +
+ Language.get("wcf.media.button.select") +
+ "</span></a>";
+ }
+
+ /**
+ * Handles clicking on the remove button.
+ */
+ protected _removeMedia(event: Event): void {
+ event.preventDefault();
+
+ const removeButton = event.currentTarget as HTMLSpanElement;
+ const button = removeButton.previousElementSibling as HTMLElement;
+
+ removeButton.remove();
+
+ const input = document.getElementById(button.dataset.store!) as HTMLInputElement;
+ input.value = "";
+ Core.triggerEvent(input, "change");
+ const display = button.dataset.display;
+ if (display) {
+ const displayElement = document.getElementById(display);
+ if (displayElement) {
+ displayElement.innerHTML = "";
+ }
+ }
+ }
+}
+
+Core.enableLegacyInheritance(MediaManagerSelect);
+
+export = MediaManagerSelect;
--- /dev/null
+/**
+ * Uploads replacemnts for media files.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Media/Replace
+ * @since 5.3
+ */
+
+import * as Core from "../Core";
+import { MediaUploadAjaxResponseData, MediaUploadError, MediaUploadOptions } from "./Data";
+import MediaUpload from "./Upload";
+import * as Language from "../Language";
+import DomUtil from "../Dom/Util";
+import * as UiNotification from "../Ui/Notification";
+import * as DomChangeListener from "../Dom/Change/Listener";
+
+class MediaReplace extends MediaUpload {
+ protected readonly _mediaID: number;
+
+ constructor(mediaID: number, buttonContainerId: string, targetId: string, options: Partial<MediaUploadOptions>) {
+ super(
+ buttonContainerId,
+ targetId,
+ Core.extend(options, {
+ action: "replaceFile",
+ }),
+ );
+
+ this._mediaID = mediaID;
+ }
+
+ protected _createButton(): void {
+ super._createButton();
+
+ this._button.classList.add("small");
+
+ this._button.querySelector("span")!.textContent = Language.get("wcf.media.button.replaceFile");
+ }
+
+ protected _createFileElement(): HTMLElement {
+ return this._target;
+ }
+
+ protected _getFormData(): ArbitraryObject {
+ return {
+ objectIDs: [this._mediaID],
+ };
+ }
+
+ protected _success(uploadId: number, data: MediaUploadAjaxResponseData): void {
+ this._fileElements[uploadId].forEach((file) => {
+ const internalFileId = file.dataset.internalFileId!;
+ const media = data.returnValues.media[internalFileId];
+
+ if (media) {
+ if (media.isImage) {
+ this._target.innerHTML = media.smallThumbnailTag;
+ }
+
+ document.getElementById("mediaFilename")!.textContent = media.filename;
+ document.getElementById("mediaFilesize")!.textContent = media.formattedFilesize;
+ if (media.isImage) {
+ document.getElementById("mediaImageDimensions")!.textContent = media.imageDimensions;
+ }
+ document.getElementById("mediaUploader")!.innerHTML = media.userLinkElement;
+
+ this._options.mediaEditor!.updateData(media);
+
+ // Remove existing error messages.
+ DomUtil.innerError(this._buttonContainer, "");
+
+ UiNotification.show();
+ } else {
+ let error: MediaUploadError = data.returnValues.errors[internalFileId];
+ if (!error) {
+ error = {
+ errorType: "uploadFailed",
+ filename: file.dataset.filename!,
+ };
+ }
+
+ DomUtil.innerError(
+ this._buttonContainer,
+ Language.get("wcf.media.upload.error." + error.errorType, {
+ filename: error.filename,
+ }),
+ );
+ }
+
+ DomChangeListener.trigger();
+ });
+ }
+}
+
+Core.enableLegacyInheritance(MediaReplace);
+
+export = MediaReplace;
--- /dev/null
+/**
+ * Uploads media files.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Media/Upload
+ */
+
+import Upload from "../Upload";
+import * as Core from "../Core";
+import * as DomUtil from "../Dom/Util";
+import * as DomTraverse from "../Dom/Traverse";
+import * as Language from "../Language";
+import User from "../User";
+import * as DateUtil from "../Date/Util";
+import * as FileUtil from "../FileUtil";
+import * as DomChangeListener from "../Dom/Change/Listener";
+import {
+ Media,
+ MediaUploadOptions,
+ MediaUploadSuccessEventData,
+ MediaUploadError,
+ MediaUploadAjaxResponseData,
+} from "./Data";
+import * as EventHandler from "../Event/Handler";
+import MediaManager from "./Manager/Base";
+
+class MediaUpload<TOptions extends MediaUploadOptions = MediaUploadOptions> extends Upload<TOptions> {
+ protected _categoryId: number | null = null;
+ protected readonly _elementTagSize: number;
+ protected readonly _mediaManager: MediaManager | null;
+
+ constructor(buttonContainerId: string, targetId: string, options: Partial<TOptions>) {
+ super(
+ buttonContainerId,
+ targetId,
+ Core.extend(
+ {
+ className: "wcf\\data\\media\\MediaAction",
+ multiple: options.mediaManager ? true : false,
+ singleFileRequests: true,
+ },
+ options || {},
+ ),
+ );
+
+ options = options || {};
+
+ this._elementTagSize = 144;
+ if (this._options.elementTagSize) {
+ this._elementTagSize = this._options.elementTagSize;
+ }
+
+ this._mediaManager = null;
+ if (this._options.mediaManager) {
+ this._mediaManager = this._options.mediaManager;
+ delete this._options.mediaManager;
+ }
+ }
+
+ protected _createFileElement(file: File): HTMLElement {
+ let fileElement: HTMLElement;
+ if (this._target.nodeName === "OL" || this._target.nodeName === "UL") {
+ fileElement = document.createElement("li");
+ } else if (this._target.nodeName === "TBODY") {
+ const firstTr = this._target.getElementsByTagName("TR")[0] as HTMLTableRowElement;
+ const tableContainer = this._target.parentNode!.parentNode! as HTMLElement;
+ if (tableContainer.style.getPropertyValue("display") === "none") {
+ fileElement = firstTr;
+
+ tableContainer.style.removeProperty("display");
+
+ document.getElementById(this._target.dataset.noItemsInfo!)!.remove();
+ } else {
+ fileElement = firstTr.cloneNode(true) as HTMLTableRowElement;
+
+ // regenerate id of table row
+ fileElement.removeAttribute("id");
+ DomUtil.identify(fileElement);
+ }
+
+ Array.from(fileElement.getElementsByTagName("TD")).forEach((cell: HTMLTableDataCellElement) => {
+ if (cell.classList.contains("columnMark")) {
+ cell.querySelectorAll("[data-object-id]").forEach((el: HTMLElement) => DomUtil.hide(el));
+ } else if (cell.classList.contains("columnIcon")) {
+ cell.querySelectorAll("[data-object-id]").forEach((el: HTMLElement) => DomUtil.hide(el));
+
+ cell.querySelector(".mediaEditButton")!.classList.add("jsMediaEditButton");
+ (cell.querySelector(".jsDeleteButton") as HTMLElement).dataset.confirmMessageHtml = Language.get(
+ "wcf.media.delete.confirmMessage",
+ {
+ title: file.name,
+ },
+ );
+ } else if (cell.classList.contains("columnFilename")) {
+ // replace copied image with spinner
+ let image = cell.querySelector("img");
+ if (!image) {
+ image = cell.querySelector(".icon48");
+ }
+
+ const spinner = document.createElement("span");
+ spinner.className = "icon icon48 fa-spinner mediaThumbnail";
+
+ DomUtil.replaceElement(image!, spinner);
+
+ // replace title and uploading user
+ const ps = cell.querySelectorAll(".box48 > div > p");
+ ps[0].textContent = file.name;
+
+ let userLink = ps[1].getElementsByTagName("A")[0];
+ if (!userLink) {
+ userLink = document.createElement("a");
+ ps[1].getElementsByTagName("SMALL")[0].appendChild(userLink);
+ }
+
+ userLink.setAttribute("href", User.getLink());
+ userLink.textContent = User.username;
+ } else if (cell.classList.contains("columnUploadTime")) {
+ cell.innerHTML = "";
+ cell.appendChild(DateUtil.getTimeElement(new Date()));
+ } else if (cell.classList.contains("columnDigits")) {
+ cell.textContent = FileUtil.formatFilesize(file.size);
+ } else {
+ // empty the other cells
+ cell.innerHTML = "";
+ }
+ });
+
+ DomUtil.prepend(fileElement, this._target);
+
+ return fileElement;
+ } else {
+ fileElement = document.createElement("p");
+ }
+
+ const thumbnail = document.createElement("div");
+ thumbnail.className = "mediaThumbnail";
+ fileElement.appendChild(thumbnail);
+
+ const fileIcon = document.createElement("span");
+ fileIcon.className = "icon icon144 fa-spinner";
+ thumbnail.appendChild(fileIcon);
+
+ const mediaInformation = document.createElement("div");
+ mediaInformation.className = "mediaInformation";
+ fileElement.appendChild(mediaInformation);
+
+ const p = document.createElement("p");
+ p.className = "mediaTitle";
+ p.textContent = file.name;
+ mediaInformation.appendChild(p);
+
+ const progress = document.createElement("progress");
+ progress.max = 100;
+ mediaInformation.appendChild(progress);
+
+ DomUtil.prepend(fileElement, this._target);
+
+ DomChangeListener.trigger();
+
+ return fileElement;
+ }
+
+ protected _getParameters(): ArbitraryObject {
+ const parameters: ArbitraryObject = {
+ elementTagSize: this._elementTagSize,
+ };
+ if (this._mediaManager) {
+ parameters.imagesOnly = this._mediaManager.getOption("imagesOnly");
+
+ const categoryId = this._mediaManager.getCategoryId();
+ if (categoryId) {
+ parameters.categoryID = categoryId;
+ }
+ }
+
+ return Core.extend(super._getParameters() as object, parameters as object) as ArbitraryObject;
+ }
+
+ protected _replaceFileIcon(fileIcon: HTMLElement, media: Media, size: number): void {
+ if (media.elementTag) {
+ fileIcon.outerHTML = media.elementTag;
+ } else if (media.tinyThumbnailType) {
+ const img = document.createElement("img");
+ img.src = media.tinyThumbnailLink;
+ img.alt = "";
+ img.style.setProperty("width", `${size}px`);
+ img.style.setProperty("height", `${size}px`);
+
+ DomUtil.replaceElement(fileIcon, img);
+ } else {
+ fileIcon.classList.remove("fa-spinner");
+
+ let fileIconName = FileUtil.getIconNameByFilename(media.filename);
+ if (fileIconName) {
+ fileIconName = "-" + fileIconName;
+ }
+ fileIcon.classList.add(`fa-file${fileIconName}-o`);
+ }
+ }
+
+ protected _success(uploadId: number, data: MediaUploadAjaxResponseData): void {
+ const files = this._fileElements[uploadId];
+ files.forEach((file) => {
+ const internalFileId = file.dataset.internalFileId!;
+ const media: Media = data.returnValues.media[internalFileId];
+
+ if (file.tagName === "TR") {
+ if (media) {
+ // update object id
+ file.querySelectorAll("[data-object-id]").forEach((el: HTMLElement) => {
+ el.dataset.objectId = media.mediaID.toString();
+ el.style.removeProperty("display");
+ });
+
+ file.querySelector(".columnMediaID")!.textContent = media.mediaID.toString();
+
+ // update icon
+ this._replaceFileIcon(file.querySelector(".fa-spinner") as HTMLSpanElement, media, 48);
+ } else {
+ let error: MediaUploadError = data.returnValues.errors[internalFileId];
+ if (!error) {
+ error = {
+ errorType: "uploadFailed",
+ filename: file.dataset.filename!,
+ };
+ }
+
+ const fileIcon = file.querySelector(".fa-spinner") as HTMLSpanElement;
+ fileIcon.classList.remove("fa-spinner");
+ fileIcon.classList.add("fa-remove", "pointer", "jsTooltip");
+ fileIcon.title = Language.get("wcf.global.button.delete");
+ fileIcon.addEventListener("click", (event) => {
+ const target = event.currentTarget as HTMLSpanElement;
+ target.closest(".mediaFile")!.remove();
+
+ EventHandler.fire("com.woltlab.wcf.media.upload", "removedErroneousUploadRow");
+ });
+
+ file.classList.add("uploadFailed");
+
+ const p = file.querySelectorAll(".columnFilename .box48 > div > p")[1] as HTMLElement;
+
+ DomUtil.innerError(
+ p,
+ Language.get(`wcf.media.upload.error.${error.errorType}`, {
+ filename: error.filename,
+ }),
+ );
+
+ p.remove();
+ }
+ } else {
+ DomTraverse.childByTag(DomTraverse.childByClass(file, "mediaInformation")!, "PROGRESS")!.remove();
+
+ if (media) {
+ const fileIcon = DomTraverse.childByTag(DomTraverse.childByClass(file, "mediaThumbnail")!, "SPAN")!;
+ this._replaceFileIcon(fileIcon, media, 144);
+
+ file.className = "jsClipboardObject mediaFile";
+ file.dataset.objectId = media.mediaID.toString();
+
+ if (this._mediaManager) {
+ this._mediaManager.setupMediaElement(media, file);
+ this._mediaManager.addMedia(media, file as HTMLLIElement);
+ }
+ } else {
+ let error: MediaUploadError = data.returnValues.errors[internalFileId];
+ if (!error) {
+ error = {
+ errorType: "uploadFailed",
+ filename: file.dataset.filename!,
+ };
+ }
+
+ const fileIcon = DomTraverse.childByTag(DomTraverse.childByClass(file, "mediaThumbnail")!, "SPAN")!;
+ fileIcon.classList.remove("fa-spinner");
+ fileIcon.classList.add("fa-remove", "pointer");
+
+ file.classList.add("uploadFailed", "jsTooltip");
+ file.title = Language.get("wcf.global.button.delete");
+ file.addEventListener("click", () => file.remove());
+
+ const title = DomTraverse.childByClass(
+ DomTraverse.childByClass(file, "mediaInformation")!,
+ "mediaTitle",
+ ) as HTMLElement;
+ title.innerText = Language.get(`wcf.media.upload.error.${error.errorType}`, {
+ filename: error.filename,
+ });
+ }
+ }
+
+ DomChangeListener.trigger();
+ });
+
+ EventHandler.fire("com.woltlab.wcf.media.upload", "success", {
+ files: files,
+ isMultiFileUpload: this._multiFileUploadIds.indexOf(uploadId) !== -1,
+ media: data.returnValues.media,
+ upload: this,
+ uploadId: uploadId,
+ } as MediaUploadSuccessEventData);
+ }
+}
+
+Core.enableLegacyInheritance(MediaUpload);
+
+export = MediaUpload;
--- /dev/null
+/**
+ * Provides desktop notifications via periodic polling with an
+ * increasing request delay on inactivity.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Notification/Handler
+ */
+
+import * as Ajax from "../Ajax";
+import { AjaxCallbackSetup } from "../Ajax/Data";
+import * as Core from "../Core";
+import * as EventHandler from "../Event/Handler";
+import * as StringUtil from "../StringUtil";
+
+interface NotificationHandlerOptions {
+ enableNotifications: boolean;
+ icon: string;
+}
+
+interface PollingResult {
+ notification: {
+ link: string;
+ message?: string;
+ title: string;
+ };
+}
+
+interface AjaxResponse {
+ returnValues: {
+ keepAliveData: unknown;
+ lastRequestTimestamp: number;
+ pollData: PollingResult;
+ };
+}
+
+class NotificationHandler {
+ private allowNotification: boolean;
+ private readonly icon: string;
+ private inactiveSince = 0;
+ private lastRequestTimestamp = window.TIME_NOW;
+ private requestTimer?: number = undefined;
+
+ /**
+ * Initializes the desktop notification system.
+ */
+ constructor(options: NotificationHandlerOptions) {
+ options = Core.extend(
+ {
+ enableNotifications: false,
+ icon: "",
+ },
+ options,
+ ) as NotificationHandlerOptions;
+
+ this.icon = options.icon;
+
+ this.prepareNextRequest();
+
+ document.addEventListener("visibilitychange", (ev) => this.onVisibilityChange(ev));
+ window.addEventListener("storage", () => this.onStorage());
+
+ this.onVisibilityChange();
+
+ if (options.enableNotifications) {
+ void this.enableNotifications();
+ }
+ }
+
+ private async enableNotifications(): Promise<void> {
+ switch (window.Notification.permission) {
+ case "granted":
+ this.allowNotification = true;
+ break;
+
+ case "default": {
+ const result = await window.Notification.requestPermission();
+ if (result === "granted") {
+ this.allowNotification = true;
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Detects when this window is hidden or restored.
+ */
+ private onVisibilityChange(event?: Event) {
+ // document was hidden before
+ if (event && !document.hidden) {
+ const difference = (Date.now() - this.inactiveSince) / 60_000;
+ if (difference > 4) {
+ this.resetTimer();
+ this.dispatchRequest();
+ }
+ }
+
+ this.inactiveSince = document.hidden ? Date.now() : 0;
+ }
+
+ /**
+ * Returns the delay in minutes before the next request should be dispatched.
+ */
+ private getNextDelay(): number {
+ if (this.inactiveSince === 0) {
+ return 5;
+ }
+
+ // milliseconds -> minutes
+ const inactiveMinutes = ~~((Date.now() - this.inactiveSince) / 60_000);
+ if (inactiveMinutes < 15) {
+ return 5;
+ } else if (inactiveMinutes < 30) {
+ return 10;
+ }
+
+ return 15;
+ }
+
+ /**
+ * Resets the request delay timer.
+ */
+ private resetTimer(): void {
+ if (this.requestTimer) {
+ window.clearTimeout(this.requestTimer);
+ this.requestTimer = undefined;
+ }
+ }
+
+ /**
+ * Schedules the next request using a calculated delay.
+ */
+ private prepareNextRequest(): void {
+ this.resetTimer();
+
+ this.requestTimer = window.setTimeout(this.dispatchRequest.bind(this), this.getNextDelay() * 60_000);
+ }
+
+ /**
+ * Requests new data from the server.
+ */
+ private dispatchRequest(): void {
+ const parameters: ArbitraryObject = {};
+
+ EventHandler.fire("com.woltlab.wcf.notification", "beforePoll", parameters);
+
+ // this timestamp is used to determine new notifications and to avoid
+ // notifications being displayed multiple times due to different origins
+ // (=subdomains) used, because we cannot synchronize them in the client
+ parameters.lastRequestTimestamp = this.lastRequestTimestamp;
+
+ Ajax.api(this, {
+ parameters: parameters,
+ });
+ }
+
+ /**
+ * Notifies subscribers for updated data received by another tab.
+ */
+ private onStorage(): void {
+ // abort and re-schedule periodic request
+ this.prepareNextRequest();
+
+ let pollData;
+ let keepAliveData;
+ let abort = false;
+ try {
+ pollData = window.localStorage.getItem(Core.getStoragePrefix() + "notification");
+ keepAliveData = window.localStorage.getItem(Core.getStoragePrefix() + "keepAliveData");
+
+ pollData = JSON.parse(pollData);
+ keepAliveData = JSON.parse(keepAliveData);
+ } catch (e) {
+ abort = true;
+ }
+
+ if (!abort) {
+ EventHandler.fire("com.woltlab.wcf.notification", "onStorage", {
+ pollData: pollData,
+ keepAliveData: keepAliveData,
+ });
+ }
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ const keepAliveData = data.returnValues.keepAliveData;
+ const pollData = data.returnValues.pollData;
+
+ // forward keep alive data
+ window.WCF.System.PushNotification.executeCallbacks({ returnValues: keepAliveData });
+
+ // store response data in local storage
+ let abort = false;
+ try {
+ window.localStorage.setItem(Core.getStoragePrefix() + "notification", JSON.stringify(pollData));
+ window.localStorage.setItem(Core.getStoragePrefix() + "keepAliveData", JSON.stringify(keepAliveData));
+ } catch (e) {
+ // storage is unavailable, e.g. in private mode, log error and disable polling
+ abort = true;
+
+ window.console.log(e);
+ }
+
+ if (!abort) {
+ this.prepareNextRequest();
+ }
+
+ this.lastRequestTimestamp = data.returnValues.lastRequestTimestamp;
+
+ EventHandler.fire("com.woltlab.wcf.notification", "afterPoll", pollData);
+
+ this.showNotification(pollData);
+ }
+
+ /**
+ * Displays a desktop notification.
+ */
+ private showNotification(pollData: PollingResult): void {
+ if (!this.allowNotification) {
+ return;
+ }
+
+ if (typeof pollData.notification === "object" && typeof pollData.notification.message === "string") {
+ const notification = new window.Notification(pollData.notification.title, {
+ body: StringUtil.unescapeHTML(pollData.notification.message),
+ icon: this.icon,
+ });
+ notification.onclick = () => {
+ window.focus();
+ notification.close();
+
+ window.location.href = pollData.notification.link;
+ };
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "poll",
+ className: "wcf\\data\\session\\SessionAction",
+ },
+ ignoreError: !window.ENABLE_DEBUG_MODE,
+ silent: !window.ENABLE_DEBUG_MODE,
+ };
+ }
+}
+
+let notificationHandler: NotificationHandler;
+
+/**
+ * Initializes the desktop notification system.
+ */
+export function setup(options: NotificationHandlerOptions): void {
+ if (!notificationHandler) {
+ notificationHandler = new NotificationHandler(options);
+ }
+}
--- /dev/null
+/**
+ * Provides helper functions for Number handling.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/NumberUtil
+ */
+
+/**
+ * Decimal adjustment of a number.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round
+ */
+export function round(value: number, exp: number): number {
+ // If the exp is undefined or zero...
+ if (typeof exp === "undefined" || +exp === 0) {
+ return Math.round(value);
+ }
+ value = +value;
+ exp = +exp;
+
+ // If the value is not a number or the exp is not an integer...
+ if (isNaN(value) || !(typeof (exp as any) === "number" && exp % 1 === 0)) {
+ return NaN;
+ }
+
+ // Shift
+ let tmp = value.toString().split("e");
+ let exponent = tmp[1] ? +tmp[1] - exp : -exp;
+ value = Math.round(+`${tmp[0]}e${exponent}`);
+
+ // Shift back
+ tmp = value.toString().split("e");
+ exponent = tmp[1] ? +tmp[1] + exp : exp;
+ return +`${tmp[0]}e${exponent}`;
+}
--- /dev/null
+/**
+ * Simple `object` to `object` map using a WeakMap.
+ *
+ * If you're looking for a dictionary with string keys, please see `WoltLabSuite/Core/Dictionary`.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module ObjectMap (alias)
+ * @module WoltLabSuite/Core/ObjectMap
+ */
+
+import * as Core from "./Core";
+
+/** @deprecated 5.4 Use a `WeakMap` instead. */
+class ObjectMap {
+ private _map = new WeakMap<object, object>();
+
+ /**
+ * Sets a new key with given value, will overwrite an existing key.
+ */
+ set(key: object, value: object): void {
+ if (typeof key !== "object" || key === null) {
+ throw new TypeError("Only objects can be used as key");
+ }
+
+ if (typeof value !== "object" || value === null) {
+ throw new TypeError("Only objects can be used as value");
+ }
+
+ this._map.set(key, value);
+ }
+
+ /**
+ * Removes a key from the map.
+ */
+ delete(key: object): void {
+ this._map.delete(key);
+ }
+
+ /**
+ * Returns true if dictionary contains a value for given key.
+ */
+ has(key: object): boolean {
+ return this._map.has(key);
+ }
+
+ /**
+ * Retrieves a value by key, returns undefined if there is no match.
+ */
+ get(key: object): object | undefined {
+ return this._map.get(key);
+ }
+}
+
+Core.enableLegacyInheritance(ObjectMap);
+
+export = ObjectMap;
--- /dev/null
+/**
+ * Manages user permissions.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Permission (alias)
+ * @module WoltLabSuite/Core/Permission
+ */
+
+const _permissions = new Map<string, boolean>();
+
+/**
+ * Adds a single permission to the store.
+ */
+export function add(permission: string, value: boolean): void {
+ if (typeof (value as any) !== "boolean") {
+ throw new TypeError("The permission value has to be boolean.");
+ }
+
+ _permissions.set(permission, value);
+}
+
+/**
+ * Adds all the permissions in the given object to the store.
+ */
+export function addObject(object: PermissionObject): void {
+ Object.keys(object).forEach((key) => add(key, object[key]));
+}
+
+/**
+ * Returns the value of a permission.
+ *
+ * If the permission is unknown, false is returned.
+ */
+export function get(permission: string): boolean {
+ if (_permissions.has(permission)) {
+ return _permissions.get(permission)!;
+ }
+
+ return false;
+}
+
+interface PermissionObject {
+ [key: string]: boolean;
+}
--- /dev/null
+import Prism from "prismjs";
+
+export default Prism;
--- /dev/null
+/**
+ * Loads Prism while disabling automated highlighting.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Prism
+ */
+window.Prism = window.Prism || {};
+window.Prism.manual = true;
+define(['prism/prism'], function () {
+ /**
+ * @deprecated 5.4 - Use WoltLabSuite/Core/Prism/Helper#splitIntoLines.
+ */
+ Prism.wscSplitIntoLines = function (container) {
+ var frag = document.createDocumentFragment();
+ var lineNo = 1;
+ var it, node, line;
+ function newLine() {
+ var line = elCreate('span');
+ elData(line, 'number', lineNo++);
+ frag.appendChild(line);
+ return line;
+ }
+ // IE11 expects a fourth, non-standard, parameter (entityReferenceExpansion) and a valid function as third
+ it = document.createNodeIterator(container, NodeFilter.SHOW_TEXT, function () {
+ return NodeFilter.FILTER_ACCEPT;
+ }, false);
+ line = newLine(lineNo);
+ while (node = it.nextNode()) {
+ node.data.split(/\r?\n/).forEach(function (codeLine, index) {
+ var current, parent;
+ // We are behind a newline, insert \n and create new container.
+ if (index >= 1) {
+ line.appendChild(document.createTextNode("\n"));
+ line = newLine(lineNo);
+ }
+ current = document.createTextNode(codeLine);
+ // Copy hierarchy (to preserve CSS classes).
+ parent = node.parentNode;
+ while (parent !== container) {
+ var clone = parent.cloneNode(false);
+ clone.appendChild(current);
+ current = clone;
+ parent = parent.parentNode;
+ }
+ line.appendChild(current);
+ });
+ }
+ return frag;
+ };
+ return Prism;
+});
--- /dev/null
+/**
+ * Provide helper functions for prism processing.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2021 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Prism/Helper
+ */
+
+export function* splitIntoLines(container: Node): Generator<Element, void> {
+ const it = document.createNodeIterator(container, NodeFilter.SHOW_TEXT, {
+ acceptNode() {
+ return NodeFilter.FILTER_ACCEPT;
+ },
+ });
+
+ let line = document.createElement("span");
+ let node;
+ while ((node = it.nextNode())) {
+ const text = node as Text;
+ const lines = text.data.split(/\r?\n/);
+
+ for (let i = 0, max = lines.length; i < max; i++) {
+ const codeLine = lines[i];
+ // We are behind a newline, insert \n and create new container.
+ if (i >= 1) {
+ line.appendChild(document.createTextNode("\n"));
+ yield line;
+ line = document.createElement("span");
+ }
+
+ let current: Node = document.createTextNode(codeLine);
+ // Copy hierarchy (to preserve CSS classes).
+ let parent = text.parentNode;
+ while (parent && parent !== container) {
+ const clone = parent.cloneNode(false);
+ clone.appendChild(current);
+ current = clone;
+ parent = parent.parentNode;
+ }
+ line.appendChild(current);
+ }
+ }
+ yield line;
+}
--- /dev/null
+/**
+ * Provides helper functions for String handling.
+ *
+ * @author Tim Duesterhus, Joshua Ruesweg
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module StringUtil (alias)
+ * @module WoltLabSuite/Core/StringUtil
+ */
+
+import * as NumberUtil from "./NumberUtil";
+
+let _decimalPoint = ".";
+let _thousandsSeparator = ",";
+
+/**
+ * Adds thousands separators to a given number.
+ *
+ * @see http://stackoverflow.com/a/6502556/782822
+ */
+export function addThousandsSeparator(number: number): string {
+ return String(number).replace(/(^-?\d{1,3}|\d{3})(?=(?:\d{3})+(?:$|\.))/g, "$1" + _thousandsSeparator);
+}
+
+/**
+ * Escapes special HTML-characters within a string
+ */
+export function escapeHTML(string: string): string {
+ return String(string).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
+}
+
+/**
+ * Escapes a String to work with RegExp.
+ *
+ * @see https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/regexp.js#L25
+ */
+export function escapeRegExp(string: string): string {
+ return String(string).replace(/([.*+?^=!:${}()|[\]/\\])/g, "\\$1");
+}
+
+/**
+ * Rounds number to given count of floating point digits, localizes decimal-point and inserts thousands separators.
+ */
+export function formatNumeric(number: number, decimalPlaces?: number): string {
+ let tmp = NumberUtil.round(number, decimalPlaces || -2).toString();
+ const numberParts = tmp.split(".");
+
+ tmp = addThousandsSeparator(+numberParts[0]);
+ if (numberParts.length > 1) {
+ tmp += _decimalPoint + numberParts[1];
+ }
+
+ tmp = tmp.replace("-", "\u2212");
+
+ return tmp;
+}
+
+/**
+ * Makes a string's first character lowercase.
+ */
+export function lcfirst(string: string): string {
+ return String(string).substring(0, 1).toLowerCase() + string.substring(1);
+}
+
+/**
+ * Makes a string's first character uppercase.
+ */
+export function ucfirst(string: string): string {
+ return String(string).substring(0, 1).toUpperCase() + string.substring(1);
+}
+
+/**
+ * Unescapes special HTML-characters within a string.
+ */
+export function unescapeHTML(string: string): string {
+ return String(string)
+ .replace(/&/g, "&")
+ .replace(/"/g, '"')
+ .replace(/</g, "<")
+ .replace(/>/g, ">");
+}
+
+/**
+ * Shortens numbers larger than 1000 by using unit suffixes.
+ */
+export function shortUnit(number: number): string {
+ let unitSuffix = "";
+
+ if (number >= 1000000) {
+ number /= 1000000;
+
+ if (number > 10) {
+ number = Math.floor(number);
+ } else {
+ number = NumberUtil.round(number, -1);
+ }
+
+ unitSuffix = "M";
+ } else if (number >= 1000) {
+ number /= 1000;
+
+ if (number > 10) {
+ number = Math.floor(number);
+ } else {
+ number = NumberUtil.round(number, -1);
+ }
+
+ unitSuffix = "k";
+ }
+
+ return formatNumeric(number) + unitSuffix;
+}
+
+/**
+ * Converts a lower-case string containing dashed to camelCase for use
+ * with the `dataset` property.
+ */
+export function toCamelCase(value: string): string {
+ if (!value.includes("-")) {
+ return value;
+ }
+
+ return value
+ .split("-")
+ .map((part, index) => {
+ if (index > 0) {
+ part = ucfirst(part);
+ }
+
+ return part;
+ })
+ .join("");
+}
+
+interface I18nValues {
+ decimalPoint: string;
+ thousandsSeparator: string;
+}
+
+export function setupI18n(values: I18nValues): void {
+ _decimalPoint = values.decimalPoint;
+ _thousandsSeparator = values.thousandsSeparator;
+}
--- /dev/null
+export function parse(input: string): unknown;
--- /dev/null
+/**
+ * Grammar for WoltLabSuite/Core/Template.
+ *
+ * Recompile using:
+ * jison -m amd -o Template.grammar.js Template.grammar.jison
+ * after making changes to the grammar.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Template.grammar
+ */
+
+%lex
+%s command
+%%
+
+\{\*[\s\S]*?\*\} /* comment */
+\{literal\}[\s\S]*?\{\/literal\} { yytext = yytext.substring(9, yytext.length - 10); return 'T_LITERAL'; }
+<command>\"([^"]|\\\.)*\" return 'T_QUOTED_STRING';
+<command>\'([^']|\\\.)*\' return 'T_QUOTED_STRING';
+<command>\$ return 'T_VARIABLE';
+<command>[0-9]+ { return 'T_DIGITS'; }
+<command>[_a-zA-Z][_a-zA-Z0-9]* { return 'T_VARIABLE_NAME'; }
+<command>"." return '.';
+<command>"[" return '[';
+<command>"]" return ']';
+<command>"(" return '(';
+<command>")" return ')';
+<command>"=" return '=';
+"{ldelim}" return '{ldelim}';
+"{rdelim}" return '{rdelim}';
+"{#" { this.begin('command'); return '{#'; }
+"{@" { this.begin('command'); return '{@'; }
+"{if " { this.begin('command'); return '{if'; }
+"{else if " { this.begin('command'); return '{elseif'; }
+"{elseif " { this.begin('command'); return '{elseif'; }
+"{else}" return '{else}';
+"{/if}" return '{/if}';
+"{lang}" return '{lang}';
+"{/lang}" return '{/lang}';
+"{include " { this.begin('command'); return '{include'; }
+"{implode " { this.begin('command'); return '{implode'; }
+"{plural " { this.begin('command'); return '{plural'; }
+"{/implode}" return '{/implode}';
+"{foreach " { this.begin('command'); return '{foreach'; }
+"{foreachelse}" return '{foreachelse}';
+"{/foreach}" return '{/foreach}';
+\{(?!\s) { this.begin('command'); return '{'; }
+<command>"}" { this.popState(); return '}';}
+\s+ return 'T_WS';
+<<EOF>> return 'EOF';
+[^{] return 'T_ANY';
+
+/lex
+
+%start TEMPLATE
+%ebnf
+
+%%
+
+// A valid template is any number of CHUNKs.
+TEMPLATE: CHUNK_STAR EOF { return $1 + ";"; };
+
+CHUNK_STAR: CHUNK* {
+ var result = $1.reduce(function (carry, item) {
+ if (item.encode && !carry[1]) carry[0] += " + '" + item.value;
+ else if (item.encode && carry[1]) carry[0] += item.value;
+ else if (!item.encode && carry[1]) carry[0] += "' + " + item.value;
+ else if (!item.encode && !carry[1]) carry[0] += " + " + item.value;
+
+ carry[1] = item.encode;
+ return carry;
+ }, [ "''", false ]);
+ if (result[1]) result[0] += "'";
+
+ $$ = result[0];
+};
+
+CHUNK:
+ PLAIN_ANY -> { encode: true, value: $1.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/(\r\n|\n|\r)/g, '\\n') }
+| T_LITERAL -> { encode: true, value: $1.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/(\r\n|\n|\r)/g, '\\n') }
+| COMMAND -> { encode: false, value: $1 }
+;
+
+PLAIN_ANY: T_ANY | T_WS;
+
+COMMAND:
+ '{if' COMMAND_PARAMETERS '}' CHUNK_STAR (ELSE_IF)* ELSE? '{/if}' {
+ $$ = "(function() { if (" + $2 + ") { return " + $4 + "; } " + $5.join(' ') + " " + ($6 || '') + " return ''; })()";
+ }
+| '{include' COMMAND_PARAMETER_LIST '}' {
+ if (!$2['file']) throw new Error('Missing parameter file');
+
+ $$ = $2['file'] + ".fetch(v)";
+ }
+| '{implode' COMMAND_PARAMETER_LIST '}' CHUNK_STAR '{/implode}' {
+ if (!$2['from']) throw new Error('Missing parameter from');
+ if (!$2['item']) throw new Error('Missing parameter item');
+ if (!$2['glue']) $2['glue'] = "', '";
+
+ $$ = "(function() { return " + $2['from'] + ".map(function(item) { v[" + $2['item'] + "] = item; return " + $4 + "; }).join(" + $2['glue'] + "); })()";
+ }
+| '{foreach' COMMAND_PARAMETER_LIST '}' CHUNK_STAR FOREACH_ELSE? '{/foreach}' {
+ if (!$2['from']) throw new Error('Missing parameter from');
+ if (!$2['item']) throw new Error('Missing parameter item');
+
+ $$ = "(function() {"
+ + "var looped = false, result = '';"
+ + "if (" + $2['from'] + " instanceof Array) {"
+ + "for (var i = 0; i < " + $2['from'] + ".length; i++) { looped = true;"
+ + "v[" + $2['key'] + "] = i;"
+ + "v[" + $2['item'] + "] = " + $2['from'] + "[i];"
+ + "result += " + $4 + ";"
+ + "}"
+ + "} else {"
+ + "for (var key in " + $2['from'] + ") {"
+ + "if (!" + $2['from'] + ".hasOwnProperty(key)) continue;"
+ + "looped = true;"
+ + "v[" + $2['key'] + "] = key;"
+ + "v[" + $2['item'] + "] = " + $2['from'] + "[key];"
+ + "result += " + $4 + ";"
+ + "}"
+ + "}"
+ + "return (looped ? result : " + ($5 || "''") + "); })()"
+ }
+| '{plural' PLURAL_PARAMETER_LIST '}' {
+ $$ = "I18nPlural.getCategoryFromTemplateParameters({"
+ var needsComma = false;
+ for (var key in $2) {
+ if (objOwns($2, key)) {
+ $$ += (needsComma ? ',' : '') + key + ': ' + $2[key];
+ needsComma = true;
+ }
+ }
+ $$ += "})";
+ }
+| '{lang}' CHUNK_STAR '{/lang}' -> "Language.get(" + $2 + ", v)"
+| '{' VARIABLE '}' -> "StringUtil.escapeHTML(" + $2 + ")"
+| '{#' VARIABLE '}' -> "StringUtil.formatNumeric(" + $2 + ")"
+| '{@' VARIABLE '}' -> $2
+| '{ldelim}' -> "'{'"
+| '{rdelim}' -> "'}'"
+;
+
+ELSE: '{else}' CHUNK_STAR -> "else { return " + $2 + "; }"
+;
+
+ELSE_IF: '{elseif' COMMAND_PARAMETERS '}' CHUNK_STAR -> "else if (" + $2 + ") { return " + $4 + "; }"
+;
+
+FOREACH_ELSE: '{foreachelse}' CHUNK_STAR -> $2
+;
+
+// VARIABLE parses a valid variable access (with optional property access)
+VARIABLE: T_VARIABLE T_VARIABLE_NAME VARIABLE_SUFFIX* -> "v['" + $2 + "']" + $3.join('');
+;
+
+VARIABLE_SUFFIX:
+ '[' COMMAND_PARAMETERS ']' -> $1 + $2 + $3
+| '.' T_VARIABLE_NAME -> "['" + $2 + "']"
+| '(' COMMAND_PARAMETERS? ')' -> $1 + ($2 || '') + $3
+;
+
+COMMAND_PARAMETER_LIST:
+ T_VARIABLE_NAME '=' COMMAND_PARAMETER_VALUE T_WS COMMAND_PARAMETER_LIST { $$ = $5; $$[$1] = $3; }
+| T_VARIABLE_NAME '=' COMMAND_PARAMETER_VALUE { $$ = {}; $$[$1] = $3; }
+;
+
+COMMAND_PARAMETER_VALUE: T_QUOTED_STRING | T_DIGITS | VARIABLE;
+
+// COMMAND_PARAMETERS parses anything that is valid between a command name and the closing brace
+COMMAND_PARAMETERS: COMMAND_PARAMETER+ -> $1.join('')
+;
+COMMAND_PARAMETER: T_ANY | T_DIGITS | T_WS | '=' | T_QUOTED_STRING | VARIABLE | T_VARIABLE_NAME
+| '(' COMMAND_PARAMETERS ')' -> $1 + ($2 || '') + $3
+;
+
+PLURAL_PARAMETER_LIST:
+ T_PLURAL_PARAMETER_NAME '=' COMMAND_PARAMETER_VALUE T_WS PLURAL_PARAMETER_LIST { $$ = $5; $$[$1] = $3; }
+| T_PLURAL_PARAMETER_NAME '=' COMMAND_PARAMETER_VALUE { $$ = {}; $$[$1] = $3; }
+;
+
+T_PLURAL_PARAMETER_NAME: T_DIGITS | T_VARIABLE_NAME;
--- /dev/null
+define(function (require) {
+ var o = function (k, v, o, l) { for (o = o || {}, l = k.length; l--; o[k[l]] = v)
+ ; return o; }, $V0 = [2, 44], $V1 = [5, 9, 11, 12, 13, 18, 19, 21, 22, 23, 25, 26, 28, 29, 30, 32, 33, 34, 35, 37, 39, 41], $V2 = [1, 25], $V3 = [1, 27], $V4 = [1, 33], $V5 = [1, 31], $V6 = [1, 32], $V7 = [1, 28], $V8 = [1, 29], $V9 = [1, 26], $Va = [1, 35], $Vb = [1, 41], $Vc = [1, 40], $Vd = [11, 12, 15, 42, 43, 47, 49, 51, 52, 54, 55], $Ve = [9, 11, 12, 13, 18, 19, 21, 23, 26, 28, 30, 32, 33, 34, 35, 37, 39], $Vf = [11, 12, 15, 42, 43, 46, 47, 48, 49, 51, 52, 54, 55], $Vg = [1, 64], $Vh = [1, 65], $Vi = [18, 37, 39], $Vj = [12, 15];
+ var parser = { trace: function trace() { },
+ yy: {},
+ symbols_: { "error": 2, "TEMPLATE": 3, "CHUNK_STAR": 4, "EOF": 5, "CHUNK_STAR_repetition0": 6, "CHUNK": 7, "PLAIN_ANY": 8, "T_LITERAL": 9, "COMMAND": 10, "T_ANY": 11, "T_WS": 12, "{if": 13, "COMMAND_PARAMETERS": 14, "}": 15, "COMMAND_repetition0": 16, "COMMAND_option0": 17, "{/if}": 18, "{include": 19, "COMMAND_PARAMETER_LIST": 20, "{implode": 21, "{/implode}": 22, "{foreach": 23, "COMMAND_option1": 24, "{/foreach}": 25, "{plural": 26, "PLURAL_PARAMETER_LIST": 27, "{lang}": 28, "{/lang}": 29, "{": 30, "VARIABLE": 31, "{#": 32, "{@": 33, "{ldelim}": 34, "{rdelim}": 35, "ELSE": 36, "{else}": 37, "ELSE_IF": 38, "{elseif": 39, "FOREACH_ELSE": 40, "{foreachelse}": 41, "T_VARIABLE": 42, "T_VARIABLE_NAME": 43, "VARIABLE_repetition0": 44, "VARIABLE_SUFFIX": 45, "[": 46, "]": 47, ".": 48, "(": 49, "VARIABLE_SUFFIX_option0": 50, ")": 51, "=": 52, "COMMAND_PARAMETER_VALUE": 53, "T_QUOTED_STRING": 54, "T_DIGITS": 55, "COMMAND_PARAMETERS_repetition_plus0": 56, "COMMAND_PARAMETER": 57, "T_PLURAL_PARAMETER_NAME": 58, "$accept": 0, "$end": 1 },
+ terminals_: { 2: "error", 5: "EOF", 9: "T_LITERAL", 11: "T_ANY", 12: "T_WS", 13: "{if", 15: "}", 18: "{/if}", 19: "{include", 21: "{implode", 22: "{/implode}", 23: "{foreach", 25: "{/foreach}", 26: "{plural", 28: "{lang}", 29: "{/lang}", 30: "{", 32: "{#", 33: "{@", 34: "{ldelim}", 35: "{rdelim}", 37: "{else}", 39: "{elseif", 41: "{foreachelse}", 42: "T_VARIABLE", 43: "T_VARIABLE_NAME", 46: "[", 47: "]", 48: ".", 49: "(", 51: ")", 52: "=", 54: "T_QUOTED_STRING", 55: "T_DIGITS" },
+ productions_: [0, [3, 2], [4, 1], [7, 1], [7, 1], [7, 1], [8, 1], [8, 1], [10, 7], [10, 3], [10, 5], [10, 6], [10, 3], [10, 3], [10, 3], [10, 3], [10, 3], [10, 1], [10, 1], [36, 2], [38, 4], [40, 2], [31, 3], [45, 3], [45, 2], [45, 3], [20, 5], [20, 3], [53, 1], [53, 1], [53, 1], [14, 1], [57, 1], [57, 1], [57, 1], [57, 1], [57, 1], [57, 1], [57, 1], [57, 3], [27, 5], [27, 3], [58, 1], [58, 1], [6, 0], [6, 2], [16, 0], [16, 2], [17, 0], [17, 1], [24, 0], [24, 1], [44, 0], [44, 2], [50, 0], [50, 1], [56, 1], [56, 2]],
+ performAction: function anonymous(yytext, yyleng, yylineno, yy, yystate /* action[1] */, $$ /* vstack */, _$ /* lstack */) {
+ /* this == yyval */
+ var $0 = $$.length - 1;
+ switch (yystate) {
+ case 1:
+ return $$[$0 - 1] + ";";
+ break;
+ case 2:
+ var result = $$[$0].reduce(function (carry, item) {
+ if (item.encode && !carry[1])
+ carry[0] += " + '" + item.value;
+ else if (item.encode && carry[1])
+ carry[0] += item.value;
+ else if (!item.encode && carry[1])
+ carry[0] += "' + " + item.value;
+ else if (!item.encode && !carry[1])
+ carry[0] += " + " + item.value;
+ carry[1] = item.encode;
+ return carry;
+ }, ["''", false]);
+ if (result[1])
+ result[0] += "'";
+ this.$ = result[0];
+ break;
+ case 3:
+ case 4:
+ this.$ = { encode: true, value: $$[$0].replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/(\r\n|\n|\r)/g, '\\n') };
+ break;
+ case 5:
+ this.$ = { encode: false, value: $$[$0] };
+ break;
+ case 8:
+ this.$ = "(function() { if (" + $$[$0 - 5] + ") { return " + $$[$0 - 3] + "; } " + $$[$0 - 2].join(' ') + " " + ($$[$0 - 1] || '') + " return ''; })()";
+ break;
+ case 9:
+ if (!$$[$0 - 1]['file'])
+ throw new Error('Missing parameter file');
+ this.$ = $$[$0 - 1]['file'] + ".fetch(v)";
+ break;
+ case 10:
+ if (!$$[$0 - 3]['from'])
+ throw new Error('Missing parameter from');
+ if (!$$[$0 - 3]['item'])
+ throw new Error('Missing parameter item');
+ if (!$$[$0 - 3]['glue'])
+ $$[$0 - 3]['glue'] = "', '";
+ this.$ = "(function() { return " + $$[$0 - 3]['from'] + ".map(function(item) { v[" + $$[$0 - 3]['item'] + "] = item; return " + $$[$0 - 1] + "; }).join(" + $$[$0 - 3]['glue'] + "); })()";
+ break;
+ case 11:
+ if (!$$[$0 - 4]['from'])
+ throw new Error('Missing parameter from');
+ if (!$$[$0 - 4]['item'])
+ throw new Error('Missing parameter item');
+ this.$ = "(function() {"
+ + "var looped = false, result = '';"
+ + "if (" + $$[$0 - 4]['from'] + " instanceof Array) {"
+ + "for (var i = 0; i < " + $$[$0 - 4]['from'] + ".length; i++) { looped = true;"
+ + "v[" + $$[$0 - 4]['key'] + "] = i;"
+ + "v[" + $$[$0 - 4]['item'] + "] = " + $$[$0 - 4]['from'] + "[i];"
+ + "result += " + $$[$0 - 2] + ";"
+ + "}"
+ + "} else {"
+ + "for (var key in " + $$[$0 - 4]['from'] + ") {"
+ + "if (!" + $$[$0 - 4]['from'] + ".hasOwnProperty(key)) continue;"
+ + "looped = true;"
+ + "v[" + $$[$0 - 4]['key'] + "] = key;"
+ + "v[" + $$[$0 - 4]['item'] + "] = " + $$[$0 - 4]['from'] + "[key];"
+ + "result += " + $$[$0 - 2] + ";"
+ + "}"
+ + "}"
+ + "return (looped ? result : " + ($$[$0 - 1] || "''") + "); })()";
+ break;
+ case 12:
+ this.$ = "I18nPlural.getCategoryFromTemplateParameters({";
+ var needsComma = false;
+ for (var key in $$[$0 - 1]) {
+ if (objOwns($$[$0 - 1], key)) {
+ this.$ += (needsComma ? ',' : '') + key + ': ' + $$[$0 - 1][key];
+ needsComma = true;
+ }
+ }
+ this.$ += "})";
+ break;
+ case 13:
+ this.$ = "Language.get(" + $$[$0 - 1] + ", v)";
+ break;
+ case 14:
+ this.$ = "StringUtil.escapeHTML(" + $$[$0 - 1] + ")";
+ break;
+ case 15:
+ this.$ = "StringUtil.formatNumeric(" + $$[$0 - 1] + ")";
+ break;
+ case 16:
+ this.$ = $$[$0 - 1];
+ break;
+ case 17:
+ this.$ = "'{'";
+ break;
+ case 18:
+ this.$ = "'}'";
+ break;
+ case 19:
+ this.$ = "else { return " + $$[$0] + "; }";
+ break;
+ case 20:
+ this.$ = "else if (" + $$[$0 - 2] + ") { return " + $$[$0] + "; }";
+ break;
+ case 21:
+ this.$ = $$[$0];
+ break;
+ case 22:
+ this.$ = "v['" + $$[$0 - 1] + "']" + $$[$0].join('');
+ ;
+ break;
+ case 23:
+ this.$ = $$[$0 - 2] + $$[$0 - 1] + $$[$0];
+ break;
+ case 24:
+ this.$ = "['" + $$[$0] + "']";
+ break;
+ case 25:
+ case 39:
+ this.$ = $$[$0 - 2] + ($$[$0 - 1] || '') + $$[$0];
+ break;
+ case 26:
+ case 40:
+ this.$ = $$[$0];
+ this.$[$$[$0 - 4]] = $$[$0 - 2];
+ break;
+ case 27:
+ case 41:
+ this.$ = {};
+ this.$[$$[$0 - 2]] = $$[$0];
+ break;
+ case 31:
+ this.$ = $$[$0].join('');
+ break;
+ case 44:
+ case 46:
+ case 52:
+ this.$ = [];
+ break;
+ case 45:
+ case 47:
+ case 53:
+ case 57:
+ $$[$0 - 1].push($$[$0]);
+ break;
+ case 56:
+ this.$ = [$$[$0]];
+ break;
+ }
+ },
+ table: [o([5, 9, 11, 12, 13, 19, 21, 23, 26, 28, 30, 32, 33, 34, 35], $V0, { 3: 1, 4: 2, 6: 3 }), { 1: [3] }, { 5: [1, 4] }, o([5, 18, 22, 25, 29, 37, 39, 41], [2, 2], { 7: 5, 8: 6, 10: 8, 9: [1, 7], 11: [1, 9], 12: [1, 10], 13: [1, 11], 19: [1, 12], 21: [1, 13], 23: [1, 14], 26: [1, 15], 28: [1, 16], 30: [1, 17], 32: [1, 18], 33: [1, 19], 34: [1, 20], 35: [1, 21] }), { 1: [2, 1] }, o($V1, [2, 45]), o($V1, [2, 3]), o($V1, [2, 4]), o($V1, [2, 5]), o($V1, [2, 6]), o($V1, [2, 7]), { 11: $V2, 12: $V3, 14: 22, 31: 30, 42: $V4, 43: $V5, 49: $V6, 52: $V7, 54: $V8, 55: $V9, 56: 23, 57: 24 }, { 20: 34, 43: $Va }, { 20: 36, 43: $Va }, { 20: 37, 43: $Va }, { 27: 38, 43: $Vb, 55: $Vc, 58: 39 }, o([9, 11, 12, 13, 19, 21, 23, 26, 28, 29, 30, 32, 33, 34, 35], $V0, { 6: 3, 4: 42 }), { 31: 43, 42: $V4 }, { 31: 44, 42: $V4 }, { 31: 45, 42: $V4 }, o($V1, [2, 17]), o($V1, [2, 18]), { 15: [1, 46] }, o([15, 47, 51], [2, 31], { 31: 30, 57: 47, 11: $V2, 12: $V3, 42: $V4, 43: $V5, 49: $V6, 52: $V7, 54: $V8, 55: $V9 }), o($Vd, [2, 56]), o($Vd, [2, 32]), o($Vd, [2, 33]), o($Vd, [2, 34]), o($Vd, [2, 35]), o($Vd, [2, 36]), o($Vd, [2, 37]), o($Vd, [2, 38]), { 11: $V2, 12: $V3, 14: 48, 31: 30, 42: $V4, 43: $V5, 49: $V6, 52: $V7, 54: $V8, 55: $V9, 56: 23, 57: 24 }, { 43: [1, 49] }, { 15: [1, 50] }, { 52: [1, 51] }, { 15: [1, 52] }, { 15: [1, 53] }, { 15: [1, 54] }, { 52: [1, 55] }, { 52: [2, 42] }, { 52: [2, 43] }, { 29: [1, 56] }, { 15: [1, 57] }, { 15: [1, 58] }, { 15: [1, 59] }, o($Ve, $V0, { 6: 3, 4: 60 }), o($Vd, [2, 57]), { 51: [1, 61] }, o($Vf, [2, 52], { 44: 62 }), o($V1, [2, 9]), { 31: 66, 42: $V4, 53: 63, 54: $Vg, 55: $Vh }, o([9, 11, 12, 13, 19, 21, 22, 23, 26, 28, 30, 32, 33, 34, 35], $V0, { 6: 3, 4: 67 }), o([9, 11, 12, 13, 19, 21, 23, 25, 26, 28, 30, 32, 33, 34, 35, 41], $V0, { 6: 3, 4: 68 }), o($V1, [2, 12]), { 31: 66, 42: $V4, 53: 69, 54: $Vg, 55: $Vh }, o($V1, [2, 13]), o($V1, [2, 14]), o($V1, [2, 15]), o($V1, [2, 16]), o($Vi, [2, 46], { 16: 70 }), o($Vd, [2, 39]), o([11, 12, 15, 42, 43, 47, 51, 52, 54, 55], [2, 22], { 45: 71, 46: [1, 72], 48: [1, 73], 49: [1, 74] }), { 12: [1, 75], 15: [2, 27] }, o($Vj, [2, 28]), o($Vj, [2, 29]), o($Vj, [2, 30]), { 22: [1, 76] }, { 24: 77, 25: [2, 50], 40: 78, 41: [1, 79] }, { 12: [1, 80], 15: [2, 41] }, { 17: 81, 18: [2, 48], 36: 83, 37: [1, 85], 38: 82, 39: [1, 84] }, o($Vf, [2, 53]), { 11: $V2, 12: $V3, 14: 86, 31: 30, 42: $V4, 43: $V5, 49: $V6, 52: $V7, 54: $V8, 55: $V9, 56: 23, 57: 24 }, { 43: [1, 87] }, { 11: $V2, 12: $V3, 14: 89, 31: 30, 42: $V4, 43: $V5, 49: $V6, 50: 88, 51: [2, 54], 52: $V7, 54: $V8, 55: $V9, 56: 23, 57: 24 }, { 20: 90, 43: $Va }, o($V1, [2, 10]), { 25: [1, 91] }, { 25: [2, 51] }, o([9, 11, 12, 13, 19, 21, 23, 25, 26, 28, 30, 32, 33, 34, 35], $V0, { 6: 3, 4: 92 }), { 27: 93, 43: $Vb, 55: $Vc, 58: 39 }, { 18: [1, 94] }, o($Vi, [2, 47]), { 18: [2, 49] }, { 11: $V2, 12: $V3, 14: 95, 31: 30, 42: $V4, 43: $V5, 49: $V6, 52: $V7, 54: $V8, 55: $V9, 56: 23, 57: 24 }, o([9, 11, 12, 13, 18, 19, 21, 23, 26, 28, 30, 32, 33, 34, 35], $V0, { 6: 3, 4: 96 }), { 47: [1, 97] }, o($Vf, [2, 24]), { 51: [1, 98] }, { 51: [2, 55] }, { 15: [2, 26] }, o($V1, [2, 11]), { 25: [2, 21] }, { 15: [2, 40] }, o($V1, [2, 8]), { 15: [1, 99] }, { 18: [2, 19] }, o($Vf, [2, 23]), o($Vf, [2, 25]), o($Ve, $V0, { 6: 3, 4: 100 }), o($Vi, [2, 20])],
+ defaultActions: { 4: [2, 1], 40: [2, 42], 41: [2, 43], 78: [2, 51], 83: [2, 49], 89: [2, 55], 90: [2, 26], 92: [2, 21], 93: [2, 40], 96: [2, 19] },
+ parseError: function parseError(str, hash) {
+ if (hash.recoverable) {
+ this.trace(str);
+ }
+ else {
+ var error = new Error(str);
+ error.hash = hash;
+ throw error;
+ }
+ },
+ parse: function parse(input) {
+ var self = this, stack = [0], tstack = [], vstack = [null], lstack = [], table = this.table, yytext = '', yylineno = 0, yyleng = 0, recovering = 0, TERROR = 2, EOF = 1;
+ var args = lstack.slice.call(arguments, 1);
+ var lexer = Object.create(this.lexer);
+ var sharedState = { yy: {} };
+ for (var k in this.yy) {
+ if (Object.prototype.hasOwnProperty.call(this.yy, k)) {
+ sharedState.yy[k] = this.yy[k];
+ }
+ }
+ lexer.setInput(input, sharedState.yy);
+ sharedState.yy.lexer = lexer;
+ sharedState.yy.parser = this;
+ if (typeof lexer.yylloc == 'undefined') {
+ lexer.yylloc = {};
+ }
+ var yyloc = lexer.yylloc;
+ lstack.push(yyloc);
+ var ranges = lexer.options && lexer.options.ranges;
+ if (typeof sharedState.yy.parseError === 'function') {
+ this.parseError = sharedState.yy.parseError;
+ }
+ else {
+ this.parseError = Object.getPrototypeOf(this).parseError;
+ }
+ function popStack(n) {
+ stack.length = stack.length - 2 * n;
+ vstack.length = vstack.length - n;
+ lstack.length = lstack.length - n;
+ }
+ _token_stack: var lex = function () {
+ var token;
+ token = lexer.lex() || EOF;
+ if (typeof token !== 'number') {
+ token = self.symbols_[token] || token;
+ }
+ return token;
+ };
+ var symbol, preErrorSymbol, state, action, a, r, yyval = {}, p, len, newState, expected;
+ while (true) {
+ state = stack[stack.length - 1];
+ if (this.defaultActions[state]) {
+ action = this.defaultActions[state];
+ }
+ else {
+ if (symbol === null || typeof symbol == 'undefined') {
+ symbol = lex();
+ }
+ action = table[state] && table[state][symbol];
+ }
+ if (typeof action === 'undefined' || !action.length || !action[0]) {
+ var errStr = '';
+ expected = [];
+ for (p in table[state]) {
+ if (this.terminals_[p] && p > TERROR) {
+ expected.push('\'' + this.terminals_[p] + '\'');
+ }
+ }
+ if (lexer.showPosition) {
+ errStr = 'Parse error on line ' + (yylineno + 1) + ':\n' + lexer.showPosition() + '\nExpecting ' + expected.join(', ') + ', got \'' + (this.terminals_[symbol] || symbol) + '\'';
+ }
+ else {
+ errStr = 'Parse error on line ' + (yylineno + 1) + ': Unexpected ' + (symbol == EOF ? 'end of input' : '\'' + (this.terminals_[symbol] || symbol) + '\'');
+ }
+ this.parseError(errStr, {
+ text: lexer.match,
+ token: this.terminals_[symbol] || symbol,
+ line: lexer.yylineno,
+ loc: yyloc,
+ expected: expected
+ });
+ }
+ if (action[0] instanceof Array && action.length > 1) {
+ throw new Error('Parse Error: multiple actions possible at state: ' + state + ', token: ' + symbol);
+ }
+ switch (action[0]) {
+ case 1:
+ stack.push(symbol);
+ vstack.push(lexer.yytext);
+ lstack.push(lexer.yylloc);
+ stack.push(action[1]);
+ symbol = null;
+ if (!preErrorSymbol) {
+ yyleng = lexer.yyleng;
+ yytext = lexer.yytext;
+ yylineno = lexer.yylineno;
+ yyloc = lexer.yylloc;
+ if (recovering > 0) {
+ recovering--;
+ }
+ }
+ else {
+ symbol = preErrorSymbol;
+ preErrorSymbol = null;
+ }
+ break;
+ case 2:
+ len = this.productions_[action[1]][1];
+ yyval.$ = vstack[vstack.length - len];
+ yyval._$ = {
+ first_line: lstack[lstack.length - (len || 1)].first_line,
+ last_line: lstack[lstack.length - 1].last_line,
+ first_column: lstack[lstack.length - (len || 1)].first_column,
+ last_column: lstack[lstack.length - 1].last_column
+ };
+ if (ranges) {
+ yyval._$.range = [
+ lstack[lstack.length - (len || 1)].range[0],
+ lstack[lstack.length - 1].range[1]
+ ];
+ }
+ r = this.performAction.apply(yyval, [
+ yytext,
+ yyleng,
+ yylineno,
+ sharedState.yy,
+ action[1],
+ vstack,
+ lstack
+ ].concat(args));
+ if (typeof r !== 'undefined') {
+ return r;
+ }
+ if (len) {
+ stack = stack.slice(0, -1 * len * 2);
+ vstack = vstack.slice(0, -1 * len);
+ lstack = lstack.slice(0, -1 * len);
+ }
+ stack.push(this.productions_[action[1]][0]);
+ vstack.push(yyval.$);
+ lstack.push(yyval._$);
+ newState = table[stack[stack.length - 2]][stack[stack.length - 1]];
+ stack.push(newState);
+ break;
+ case 3:
+ return true;
+ }
+ }
+ return true;
+ } };
+ /* generated by jison-lex 0.3.4 */
+ var lexer = (function () {
+ var lexer = ({
+ EOF: 1,
+ parseError: function parseError(str, hash) {
+ if (this.yy.parser) {
+ this.yy.parser.parseError(str, hash);
+ }
+ else {
+ throw new Error(str);
+ }
+ },
+ // resets the lexer, sets new input
+ setInput: function (input, yy) {
+ this.yy = yy || this.yy || {};
+ this._input = input;
+ this._more = this._backtrack = this.done = false;
+ this.yylineno = this.yyleng = 0;
+ this.yytext = this.matched = this.match = '';
+ this.conditionStack = ['INITIAL'];
+ this.yylloc = {
+ first_line: 1,
+ first_column: 0,
+ last_line: 1,
+ last_column: 0
+ };
+ if (this.options.ranges) {
+ this.yylloc.range = [0, 0];
+ }
+ this.offset = 0;
+ return this;
+ },
+ // consumes and returns one char from the input
+ input: function () {
+ var ch = this._input[0];
+ this.yytext += ch;
+ this.yyleng++;
+ this.offset++;
+ this.match += ch;
+ this.matched += ch;
+ var lines = ch.match(/(?:\r\n?|\n).*/g);
+ if (lines) {
+ this.yylineno++;
+ this.yylloc.last_line++;
+ }
+ else {
+ this.yylloc.last_column++;
+ }
+ if (this.options.ranges) {
+ this.yylloc.range[1]++;
+ }
+ this._input = this._input.slice(1);
+ return ch;
+ },
+ // unshifts one char (or a string) into the input
+ unput: function (ch) {
+ var len = ch.length;
+ var lines = ch.split(/(?:\r\n?|\n)/g);
+ this._input = ch + this._input;
+ this.yytext = this.yytext.substr(0, this.yytext.length - len);
+ //this.yyleng -= len;
+ this.offset -= len;
+ var oldLines = this.match.split(/(?:\r\n?|\n)/g);
+ this.match = this.match.substr(0, this.match.length - 1);
+ this.matched = this.matched.substr(0, this.matched.length - 1);
+ if (lines.length - 1) {
+ this.yylineno -= lines.length - 1;
+ }
+ var r = this.yylloc.range;
+ this.yylloc = {
+ first_line: this.yylloc.first_line,
+ last_line: this.yylineno + 1,
+ first_column: this.yylloc.first_column,
+ last_column: lines ?
+ (lines.length === oldLines.length ? this.yylloc.first_column : 0)
+ + oldLines[oldLines.length - lines.length].length - lines[0].length :
+ this.yylloc.first_column - len
+ };
+ if (this.options.ranges) {
+ this.yylloc.range = [r[0], r[0] + this.yyleng - len];
+ }
+ this.yyleng = this.yytext.length;
+ return this;
+ },
+ // When called from action, caches matched text and appends it on next action
+ more: function () {
+ this._more = true;
+ return this;
+ },
+ // When called from action, signals the lexer that this rule fails to match the input, so the next matching rule (regex) should be tested instead.
+ reject: function () {
+ if (this.options.backtrack_lexer) {
+ this._backtrack = true;
+ }
+ else {
+ return this.parseError('Lexical error on line ' + (this.yylineno + 1) + '. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true).\n' + this.showPosition(), {
+ text: "",
+ token: null,
+ line: this.yylineno
+ });
+ }
+ return this;
+ },
+ // retain first n characters of the match
+ less: function (n) {
+ this.unput(this.match.slice(n));
+ },
+ // displays already matched input, i.e. for error messages
+ pastInput: function () {
+ var past = this.matched.substr(0, this.matched.length - this.match.length);
+ return (past.length > 20 ? '...' : '') + past.substr(-20).replace(/\n/g, "");
+ },
+ // displays upcoming input, i.e. for error messages
+ upcomingInput: function () {
+ var next = this.match;
+ if (next.length < 20) {
+ next += this._input.substr(0, 20 - next.length);
+ }
+ return (next.substr(0, 20) + (next.length > 20 ? '...' : '')).replace(/\n/g, "");
+ },
+ // displays the character position where the lexing error occurred, i.e. for error messages
+ showPosition: function () {
+ var pre = this.pastInput();
+ var c = new Array(pre.length + 1).join("-");
+ return pre + this.upcomingInput() + "\n" + c + "^";
+ },
+ // test the lexed token: return FALSE when not a match, otherwise return token
+ test_match: function (match, indexed_rule) {
+ var token, lines, backup;
+ if (this.options.backtrack_lexer) {
+ // save context
+ backup = {
+ yylineno: this.yylineno,
+ yylloc: {
+ first_line: this.yylloc.first_line,
+ last_line: this.last_line,
+ first_column: this.yylloc.first_column,
+ last_column: this.yylloc.last_column
+ },
+ yytext: this.yytext,
+ match: this.match,
+ matches: this.matches,
+ matched: this.matched,
+ yyleng: this.yyleng,
+ offset: this.offset,
+ _more: this._more,
+ _input: this._input,
+ yy: this.yy,
+ conditionStack: this.conditionStack.slice(0),
+ done: this.done
+ };
+ if (this.options.ranges) {
+ backup.yylloc.range = this.yylloc.range.slice(0);
+ }
+ }
+ lines = match[0].match(/(?:\r\n?|\n).*/g);
+ if (lines) {
+ this.yylineno += lines.length;
+ }
+ this.yylloc = {
+ first_line: this.yylloc.last_line,
+ last_line: this.yylineno + 1,
+ first_column: this.yylloc.last_column,
+ last_column: lines ?
+ lines[lines.length - 1].length - lines[lines.length - 1].match(/\r?\n?/)[0].length :
+ this.yylloc.last_column + match[0].length
+ };
+ this.yytext += match[0];
+ this.match += match[0];
+ this.matches = match;
+ this.yyleng = this.yytext.length;
+ if (this.options.ranges) {
+ this.yylloc.range = [this.offset, this.offset += this.yyleng];
+ }
+ this._more = false;
+ this._backtrack = false;
+ this._input = this._input.slice(match[0].length);
+ this.matched += match[0];
+ token = this.performAction.call(this, this.yy, this, indexed_rule, this.conditionStack[this.conditionStack.length - 1]);
+ if (this.done && this._input) {
+ this.done = false;
+ }
+ if (token) {
+ return token;
+ }
+ else if (this._backtrack) {
+ // recover context
+ for (var k in backup) {
+ this[k] = backup[k];
+ }
+ return false; // rule action called reject() implying the next rule should be tested instead.
+ }
+ return false;
+ },
+ // return next match in input
+ next: function () {
+ if (this.done) {
+ return this.EOF;
+ }
+ if (!this._input) {
+ this.done = true;
+ }
+ var token, match, tempMatch, index;
+ if (!this._more) {
+ this.yytext = '';
+ this.match = '';
+ }
+ var rules = this._currentRules();
+ for (var i = 0; i < rules.length; i++) {
+ tempMatch = this._input.match(this.rules[rules[i]]);
+ if (tempMatch && (!match || tempMatch[0].length > match[0].length)) {
+ match = tempMatch;
+ index = i;
+ if (this.options.backtrack_lexer) {
+ token = this.test_match(tempMatch, rules[i]);
+ if (token !== false) {
+ return token;
+ }
+ else if (this._backtrack) {
+ match = false;
+ continue; // rule action called reject() implying a rule MISmatch.
+ }
+ else {
+ // else: this is a lexer rule which consumes input without producing a token (e.g. whitespace)
+ return false;
+ }
+ }
+ else if (!this.options.flex) {
+ break;
+ }
+ }
+ }
+ if (match) {
+ token = this.test_match(match, rules[index]);
+ if (token !== false) {
+ return token;
+ }
+ // else: this is a lexer rule which consumes input without producing a token (e.g. whitespace)
+ return false;
+ }
+ if (this._input === "") {
+ return this.EOF;
+ }
+ else {
+ return this.parseError('Lexical error on line ' + (this.yylineno + 1) + '. Unrecognized text.\n' + this.showPosition(), {
+ text: "",
+ token: null,
+ line: this.yylineno
+ });
+ }
+ },
+ // return next match that has a token
+ lex: function lex() {
+ var r = this.next();
+ if (r) {
+ return r;
+ }
+ else {
+ return this.lex();
+ }
+ },
+ // activates a new lexer condition state (pushes the new lexer condition state onto the condition stack)
+ begin: function begin(condition) {
+ this.conditionStack.push(condition);
+ },
+ // pop the previously active lexer condition state off the condition stack
+ popState: function popState() {
+ var n = this.conditionStack.length - 1;
+ if (n > 0) {
+ return this.conditionStack.pop();
+ }
+ else {
+ return this.conditionStack[0];
+ }
+ },
+ // produce the lexer rule set which is active for the currently active lexer condition state
+ _currentRules: function _currentRules() {
+ if (this.conditionStack.length && this.conditionStack[this.conditionStack.length - 1]) {
+ return this.conditions[this.conditionStack[this.conditionStack.length - 1]].rules;
+ }
+ else {
+ return this.conditions["INITIAL"].rules;
+ }
+ },
+ // return the currently active lexer condition state; when an index argument is provided it produces the N-th previous condition state, if available
+ topState: function topState(n) {
+ n = this.conditionStack.length - 1 - Math.abs(n || 0);
+ if (n >= 0) {
+ return this.conditionStack[n];
+ }
+ else {
+ return "INITIAL";
+ }
+ },
+ // alias for begin(condition)
+ pushState: function pushState(condition) {
+ this.begin(condition);
+ },
+ // return the number of states currently on the stack
+ stateStackSize: function stateStackSize() {
+ return this.conditionStack.length;
+ },
+ options: {},
+ performAction: function anonymous(yy, yy_, $avoiding_name_collisions, YY_START) {
+ var YYSTATE = YY_START;
+ switch ($avoiding_name_collisions) {
+ case 0: /* comment */
+ break;
+ case 1:
+ yy_.yytext = yy_.yytext.substring(9, yy_.yytext.length - 10);
+ return 9;
+ break;
+ case 2:
+ return 54;
+ break;
+ case 3:
+ return 54;
+ break;
+ case 4:
+ return 42;
+ break;
+ case 5:
+ return 55;
+ break;
+ case 6:
+ return 43;
+ break;
+ case 7:
+ return 48;
+ break;
+ case 8:
+ return 46;
+ break;
+ case 9:
+ return 47;
+ break;
+ case 10:
+ return 49;
+ break;
+ case 11:
+ return 51;
+ break;
+ case 12:
+ return 52;
+ break;
+ case 13:
+ return 34;
+ break;
+ case 14:
+ return 35;
+ break;
+ case 15:
+ this.begin('command');
+ return 32;
+ break;
+ case 16:
+ this.begin('command');
+ return 33;
+ break;
+ case 17:
+ this.begin('command');
+ return 13;
+ break;
+ case 18:
+ this.begin('command');
+ return 39;
+ break;
+ case 19:
+ this.begin('command');
+ return 39;
+ break;
+ case 20:
+ return 37;
+ break;
+ case 21:
+ return 18;
+ break;
+ case 22:
+ return 28;
+ break;
+ case 23:
+ return 29;
+ break;
+ case 24:
+ this.begin('command');
+ return 19;
+ break;
+ case 25:
+ this.begin('command');
+ return 21;
+ break;
+ case 26:
+ this.begin('command');
+ return 26;
+ break;
+ case 27:
+ return 22;
+ break;
+ case 28:
+ this.begin('command');
+ return 23;
+ break;
+ case 29:
+ return 41;
+ break;
+ case 30:
+ return 25;
+ break;
+ case 31:
+ this.begin('command');
+ return 30;
+ break;
+ case 32:
+ this.popState();
+ return 15;
+ break;
+ case 33:
+ return 12;
+ break;
+ case 34:
+ return 5;
+ break;
+ case 35:
+ return 11;
+ break;
+ }
+ },
+ rules: [/^(?:\{\*[\s\S]*?\*\})/, /^(?:\{literal\}[\s\S]*?\{\/literal\})/, /^(?:"([^"]|\\\.)*")/, /^(?:'([^']|\\\.)*')/, /^(?:\$)/, /^(?:[0-9]+)/, /^(?:[_a-zA-Z][_a-zA-Z0-9]*)/, /^(?:\.)/, /^(?:\[)/, /^(?:\])/, /^(?:\()/, /^(?:\))/, /^(?:=)/, /^(?:\{ldelim\})/, /^(?:\{rdelim\})/, /^(?:\{#)/, /^(?:\{@)/, /^(?:\{if )/, /^(?:\{else if )/, /^(?:\{elseif )/, /^(?:\{else\})/, /^(?:\{\/if\})/, /^(?:\{lang\})/, /^(?:\{\/lang\})/, /^(?:\{include )/, /^(?:\{implode )/, /^(?:\{plural )/, /^(?:\{\/implode\})/, /^(?:\{foreach )/, /^(?:\{foreachelse\})/, /^(?:\{\/foreach\})/, /^(?:\{(?!\s))/, /^(?:\})/, /^(?:\s+)/, /^(?:$)/, /^(?:[^{])/],
+ conditions: { "command": { "rules": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35], "inclusive": true }, "INITIAL": { "rules": [0, 1, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 33, 34, 35], "inclusive": true } }
+ });
+ return lexer;
+ })();
+ parser.lexer = lexer;
+ return parser;
+});
--- /dev/null
+/**
+ * WoltLabSuite/Core/Template provides a template scripting compiler similar
+ * to the PHP one of WoltLab Suite Core. It supports a limited
+ * set of useful commands and compiles templates down to a pure
+ * JavaScript Function.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Template
+ */
+
+import * as Core from "./Core";
+import * as parser from "./Template.grammar";
+import * as StringUtil from "./StringUtil";
+import * as Language from "./Language";
+import * as I18nPlural from "./I18n/Plural";
+
+// @todo: still required?
+// work around bug in AMD module generation of Jison
+/*function Parser() {
+ this.yy = {};
+}
+
+Parser.prototype = parser;
+parser.Parser = Parser;
+parser = new Parser();*/
+
+class Template {
+ constructor(template: string) {
+ if (Language === undefined) {
+ // @ts-expect-error: This is required due to a circular dependency.
+ Language = require("./Language");
+ }
+ if (StringUtil === undefined) {
+ // @ts-expect-error: This is required due to a circular dependency.
+ StringUtil = require("./StringUtil");
+ }
+
+ try {
+ template = parser.parse(template) as string;
+ template =
+ "var tmp = {};\n" +
+ "for (var key in v) tmp[key] = v[key];\n" +
+ "v = tmp;\n" +
+ "v.__wcf = window.WCF; v.__window = window;\n" +
+ "return " +
+ template;
+
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
+ this.fetch = new Function("StringUtil", "Language", "I18nPlural", "v", template).bind(
+ undefined,
+ StringUtil,
+ Language,
+ I18nPlural,
+ );
+ } catch (e) {
+ console.debug(e.message);
+ throw e;
+ }
+ }
+
+ /**
+ * Evaluates the Template using the given parameters.
+ */
+ fetch(_v: object): string {
+ // this will be replaced in the init function
+ throw new Error("This Template is not initialized.");
+ }
+}
+
+Object.defineProperty(Template, "callbacks", {
+ enumerable: false,
+ configurable: false,
+ get: function () {
+ throw new Error("WCF.Template.callbacks is no longer supported");
+ },
+ set: function (_value) {
+ throw new Error("WCF.Template.callbacks is no longer supported");
+ },
+});
+
+Core.enableLegacyInheritance(Template);
+
+export = Template;
--- /dev/null
+/**
+ * Provides an object oriented API on top of `setInterval`.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Timer/Repeating
+ */
+
+import * as Core from "../Core";
+
+class RepeatingTimer {
+ private readonly _callback: (timer: RepeatingTimer) => void;
+ private _delta: number;
+ private _timer: number | undefined;
+
+ /**
+ * Creates a new timer that executes the given `callback` every `delta` milliseconds.
+ * It will be created in started mode. Call `stop()` if necessary.
+ * The `callback` will be passed the owning instance of `Repeating`.
+ */
+ constructor(callback: (timer: RepeatingTimer) => void, delta: number) {
+ if (typeof callback !== "function") {
+ throw new TypeError("Expected a valid callback as first argument.");
+ }
+ if (delta < 0 || delta > 86_400 * 1_000) {
+ throw new RangeError(`Invalid delta ${delta}. Delta must be in the interval [0, 86400000].`);
+ }
+
+ // curry callback with `this` as the first parameter
+ this._callback = callback.bind(undefined, this);
+ this._delta = delta;
+
+ this.restart();
+ }
+
+ /**
+ * Stops the timer and restarts it. The next call will occur in `delta` milliseconds.
+ */
+ restart(): void {
+ this.stop();
+
+ this._timer = setInterval(this._callback, this._delta);
+ }
+
+ /**
+ * Stops the timer. It will no longer be called until you call `restart`.
+ */
+ stop(): void {
+ if (this._timer !== undefined) {
+ clearInterval(this._timer);
+
+ this._timer = undefined;
+ }
+ }
+
+ /**
+ * Changes the `delta` of the timer and `restart`s it.
+ */
+ setDelta(delta: number): void {
+ this._delta = delta;
+
+ this.restart();
+ }
+}
+
+Core.enableLegacyInheritance(RepeatingTimer);
+
+export = RepeatingTimer;
--- /dev/null
+import * as Core from "../../Core";
+import * as Language from "../../Language";
+import * as StringUtil from "../../StringUtil";
+import DomChangeListener from "../../Dom/Change/Listener";
+import DomUtil from "../../Dom/Util";
+import UiUserSearchInput from "../User/Search/Input";
+
+class UiAclSimple {
+ private readonly aclListContainer: HTMLElement;
+ private readonly list: HTMLUListElement;
+ private readonly prefix: string;
+ private readonly inputName: string;
+ private readonly searchInput: UiUserSearchInput;
+
+ constructor(prefix?: string, inputName?: string) {
+ this.prefix = prefix || "";
+ this.inputName = inputName || "aclValues";
+
+ const container = document.getElementById(this.prefix + "aclInputContainer")!;
+
+ const allowAll = document.getElementById(this.prefix + "aclAllowAll") as HTMLInputElement;
+ allowAll.addEventListener("change", () => {
+ DomUtil.hide(container);
+ });
+
+ const denyAll = document.getElementById(this.prefix + "aclAllowAll_no")!;
+ denyAll.addEventListener("change", () => {
+ DomUtil.show(container);
+ });
+
+ this.list = document.getElementById(this.prefix + "aclAccessList") as HTMLUListElement;
+ this.list.addEventListener("click", this.removeItem.bind(this));
+
+ const excludedSearchValues: string[] = [];
+ this.list.querySelectorAll(".aclLabel").forEach((label) => {
+ excludedSearchValues.push(label.textContent!);
+ });
+
+ this.searchInput = new UiUserSearchInput(
+ document.getElementById(this.prefix + "aclSearchInput") as HTMLInputElement,
+ {
+ callbackSelect: this.select.bind(this),
+ includeUserGroups: true,
+ excludedSearchValues: excludedSearchValues,
+ preventSubmit: true,
+ },
+ );
+
+ this.aclListContainer = document.getElementById(this.prefix + "aclListContainer")!;
+
+ DomChangeListener.trigger();
+ }
+
+ private select(listItem: HTMLLIElement): boolean {
+ const type = listItem.dataset.type!;
+ const label = listItem.dataset.label!;
+ const objectId = listItem.dataset.objectId!;
+
+ const iconName = type === "group" ? "users" : "user";
+ const html = `<span class="icon icon16 fa-${iconName}"></span>
+ <span class="aclLabel">${StringUtil.escapeHTML(label)}</span>
+ <span class="icon icon16 fa-times pointer jsTooltip" title="${Language.get("wcf.global.button.delete")}"></span>
+ <input type="hidden" name="${this.inputName}[${type}][]" value="${objectId}">`;
+
+ const item = document.createElement("li");
+ item.innerHTML = html;
+
+ const firstUser = this.list.querySelector(".fa-user");
+ if (firstUser === null) {
+ this.list.appendChild(item);
+ } else {
+ this.list.insertBefore(item, firstUser.parentNode);
+ }
+
+ DomUtil.show(this.aclListContainer);
+
+ this.searchInput.addExcludedSearchValues(label);
+
+ DomChangeListener.trigger();
+
+ return false;
+ }
+
+ private removeItem(event: MouseEvent): void {
+ const target = event.target as HTMLElement;
+ if (target.classList.contains("fa-times")) {
+ const parent = target.parentElement!;
+ const label = parent.querySelector(".aclLabel")!;
+ this.searchInput.removeExcludedSearchValues(label.textContent!);
+
+ parent.remove();
+
+ if (this.list.childElementCount === 0) {
+ DomUtil.hide(this.aclListContainer);
+ }
+ }
+ }
+}
+
+Core.enableLegacyInheritance(UiAclSimple);
+
+export = UiAclSimple;
--- /dev/null
+/**
+ * Utility class to align elements relatively to another.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Ui/Alignment (alias)
+ * @module WoltLabSuite/Core/Ui/Alignment
+ */
+
+import * as Core from "../Core";
+import * as DomTraverse from "../Dom/Traverse";
+import DomUtil from "../Dom/Util";
+import * as Language from "../Language";
+
+type HorizontalAlignment = "center" | "left" | "right";
+type VerticalAlignment = "bottom" | "top";
+type Offset = number | "auto";
+
+interface HorizontalResult {
+ align: HorizontalAlignment;
+ left: Offset;
+ result: boolean;
+ right: Offset;
+}
+
+interface VerticalResult {
+ align: VerticalAlignment;
+ bottom: Offset;
+ result: boolean;
+ top: Offset;
+}
+
+const enum PointerClass {
+ Bottom = 0,
+ Right = 1,
+}
+
+interface ElementDimensions {
+ height: number;
+ width: number;
+}
+
+interface ElementOffset {
+ left: number;
+ top: number;
+}
+
+/**
+ * Calculates top/bottom position and verifies if the element would be still within the page's boundaries.
+ */
+function tryAlignmentVertical(
+ alignment: VerticalAlignment,
+ elDimensions: ElementDimensions,
+ refDimensions: ElementDimensions,
+ refOffsets: ElementOffset,
+ windowHeight: number,
+ verticalOffset: number,
+): VerticalResult {
+ let bottom: Offset = "auto";
+ let top: Offset = "auto";
+ let result = true;
+ let pageHeaderOffset = 50;
+
+ const pageHeaderPanel = document.getElementById("pageHeaderPanel");
+ if (pageHeaderPanel !== null) {
+ const position = window.getComputedStyle(pageHeaderPanel).position;
+ if (position === "fixed" || position === "static") {
+ pageHeaderOffset = pageHeaderPanel.offsetHeight;
+ } else {
+ pageHeaderOffset = 0;
+ }
+ }
+
+ if (alignment === "top") {
+ const bodyHeight = document.body.clientHeight;
+ bottom = bodyHeight - refOffsets.top + verticalOffset;
+ if (bodyHeight - (bottom + elDimensions.height) < (window.scrollY || window.pageYOffset) + pageHeaderOffset) {
+ result = false;
+ }
+ } else {
+ top = refOffsets.top + refDimensions.height + verticalOffset;
+ if (top + elDimensions.height - (window.scrollY || window.pageYOffset) > windowHeight) {
+ result = false;
+ }
+ }
+
+ return {
+ align: alignment,
+ bottom: bottom,
+ top: top,
+ result: result,
+ };
+}
+
+/**
+ * Calculates left/right position and verifies if the element would be still within the page's boundaries.
+ */
+function tryAlignmentHorizontal(
+ alignment: HorizontalAlignment,
+ elDimensions: ElementDimensions,
+ refDimensions: ElementDimensions,
+ refOffsets: ElementOffset,
+ windowWidth: number,
+): HorizontalResult {
+ let left: Offset = "auto";
+ let right: Offset = "auto";
+ let result = true;
+
+ if (alignment === "left") {
+ left = refOffsets.left;
+
+ if (left + elDimensions.width > windowWidth) {
+ result = false;
+ }
+ } else if (alignment === "right") {
+ if (refOffsets.left + refDimensions.width < elDimensions.width) {
+ result = false;
+ } else {
+ right = windowWidth - (refOffsets.left + refDimensions.width);
+
+ if (right < 0) {
+ result = false;
+ }
+ }
+ } else {
+ left = refOffsets.left + refDimensions.width / 2 - elDimensions.width / 2;
+ left = ~~left;
+
+ if (left < 0 || left + elDimensions.width > windowWidth) {
+ result = false;
+ }
+ }
+
+ return {
+ align: alignment,
+ left: left,
+ right: right,
+ result: result,
+ };
+}
+
+/**
+ * Sets the alignment for target element relatively to the reference element.
+ */
+export function set(element: HTMLElement, referenceElement: HTMLElement, options?: AlignmentOptions): void {
+ options = Core.extend(
+ {
+ // offset to reference element
+ verticalOffset: 0,
+ // align the pointer element, expects .elementPointer as a direct child of given element
+ pointer: false,
+ // use static pointer positions, expects two items: class to move it to the bottom and the second to move it to the right
+ pointerClassNames: [],
+ // alternate element used to calculate dimensions
+ refDimensionsElement: null,
+ // preferred alignment, possible values: left/right/center and top/bottom
+ horizontal: "left",
+ vertical: "bottom",
+ // allow flipping over axis, possible values: both, horizontal, vertical and none
+ allowFlip: "both",
+ },
+ options || {},
+ ) as AlignmentOptions;
+
+ if (!Array.isArray(options.pointerClassNames) || options.pointerClassNames.length !== (options.pointer ? 1 : 2)) {
+ options.pointerClassNames = [];
+ }
+ if (["left", "right", "center"].indexOf(options.horizontal!) === -1) {
+ options.horizontal = "left";
+ }
+ if (options.vertical !== "bottom") {
+ options.vertical = "top";
+ }
+ if (["both", "horizontal", "vertical", "none"].indexOf(options.allowFlip!) === -1) {
+ options.allowFlip = "both";
+ }
+
+ // Place the element in the upper left corner to prevent calculation issues due to possible scrollbars.
+ DomUtil.setStyles(element, {
+ bottom: "auto !important",
+ left: "0 !important",
+ right: "auto !important",
+ top: "0 !important",
+ visibility: "hidden !important",
+ });
+
+ const elDimensions = DomUtil.outerDimensions(element);
+ const refDimensions = DomUtil.outerDimensions(
+ options.refDimensionsElement instanceof HTMLElement ? options.refDimensionsElement : referenceElement,
+ );
+ const refOffsets = DomUtil.offset(referenceElement);
+ const windowHeight = window.innerHeight;
+ const windowWidth = document.body.clientWidth;
+
+ let horizontal: HorizontalResult | null = null;
+ let alignCenter = false;
+ if (options.horizontal === "center") {
+ alignCenter = true;
+ horizontal = tryAlignmentHorizontal(options.horizontal, elDimensions, refDimensions, refOffsets, windowWidth);
+ if (!horizontal.result) {
+ if (options.allowFlip === "both" || options.allowFlip === "horizontal") {
+ options.horizontal = "left";
+ } else {
+ horizontal.result = true;
+ }
+ }
+ }
+
+ // in rtl languages we simply swap the value for 'horizontal'
+ if (Language.get("wcf.global.pageDirection") === "rtl") {
+ options.horizontal = options.horizontal === "left" ? "right" : "left";
+ }
+
+ if (horizontal === null || !horizontal.result) {
+ const horizontalCenter = horizontal;
+ horizontal = tryAlignmentHorizontal(options.horizontal!, elDimensions, refDimensions, refOffsets, windowWidth);
+ if (!horizontal.result && (options.allowFlip === "both" || options.allowFlip === "horizontal")) {
+ const horizontalFlipped = tryAlignmentHorizontal(
+ options.horizontal === "left" ? "right" : "left",
+ elDimensions,
+ refDimensions,
+ refOffsets,
+ windowWidth,
+ );
+ // only use these results if it fits into the boundaries, otherwise both directions exceed and we honor the demanded direction
+ if (horizontalFlipped.result) {
+ horizontal = horizontalFlipped;
+ } else if (alignCenter) {
+ horizontal = horizontalCenter;
+ }
+ }
+ }
+
+ const left = horizontal!.left;
+ const right = horizontal!.right;
+ let vertical = tryAlignmentVertical(
+ options.vertical,
+ elDimensions,
+ refDimensions,
+ refOffsets,
+ windowHeight,
+ options.verticalOffset!,
+ );
+ if (!vertical.result && (options.allowFlip === "both" || options.allowFlip === "vertical")) {
+ const verticalFlipped = tryAlignmentVertical(
+ options.vertical === "top" ? "bottom" : "top",
+ elDimensions,
+ refDimensions,
+ refOffsets,
+ windowHeight,
+ options.verticalOffset!,
+ );
+ // only use these results if it fits into the boundaries, otherwise both directions exceed and we honor the demanded direction
+ if (verticalFlipped.result) {
+ vertical = verticalFlipped;
+ }
+ }
+
+ const bottom = vertical.bottom;
+ const top = vertical.top;
+ // set pointer position
+ if (options.pointer) {
+ const pointers = DomTraverse.childrenByClass(element, "elementPointer");
+ const pointer = pointers[0] || null;
+ if (pointer === null) {
+ throw new Error("Expected the .elementPointer element to be a direct children.");
+ }
+
+ if (horizontal!.align === "center") {
+ pointer.classList.add("center");
+ pointer.classList.remove("left", "right");
+ } else {
+ pointer.classList.add(horizontal!.align);
+ pointer.classList.remove("center");
+ pointer.classList.remove(horizontal!.align === "left" ? "right" : "left");
+ }
+
+ if (vertical.align === "top") {
+ pointer.classList.add("flipVertical");
+ } else {
+ pointer.classList.remove("flipVertical");
+ }
+ } else if (options.pointerClassNames.length === 2) {
+ element.classList[top === "auto" ? "add" : "remove"](options.pointerClassNames[PointerClass.Bottom]);
+ element.classList[left === "auto" ? "add" : "remove"](options.pointerClassNames[PointerClass.Right]);
+ }
+
+ DomUtil.setStyles(element, {
+ bottom: bottom === "auto" ? bottom : Math.round(bottom).toString() + "px",
+ left: left === "auto" ? left : Math.ceil(left).toString() + "px",
+ right: right === "auto" ? right : Math.floor(right).toString() + "px",
+ top: top === "auto" ? top : Math.round(top).toString() + "px",
+ });
+
+ DomUtil.show(element);
+ element.style.removeProperty("visibility");
+}
+
+export type AllowFlip = "both" | "horizontal" | "none" | "vertical";
+
+export interface AlignmentOptions {
+ // offset to reference element
+ verticalOffset?: number;
+ // align the pointer element, expects .elementPointer as a direct child of given element
+ pointer?: boolean;
+ // use static pointer positions, expects two items: class to move it to the bottom and the second to move it to the right
+ pointerClassNames?: string[];
+ // alternate element used to calculate dimensions
+ refDimensionsElement?: HTMLElement | null;
+ // preferred alignment, possible values: left/right/center and top/bottom
+ horizontal?: HorizontalAlignment;
+ vertical?: VerticalAlignment;
+ // allow flipping over axis, possible values: both, horizontal, vertical and none
+ allowFlip?: AllowFlip;
+}
--- /dev/null
+/**
+ * Handles the 'mark as read' action for articles.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Article/MarkAllAsRead
+ */
+
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../../Ajax/Data";
+
+class UiArticleMarkAllAsRead implements AjaxCallbackObject {
+ constructor() {
+ document.querySelectorAll(".markAllAsReadButton").forEach((button) => {
+ button.addEventListener("click", this.click.bind(this));
+ });
+ }
+
+ private click(event: MouseEvent): void {
+ event.preventDefault();
+
+ Ajax.api(this);
+ }
+
+ _ajaxSuccess(): void {
+ /* remove obsolete badges */
+ // main menu
+ const badge = document.querySelector(".mainMenu .active .badge");
+ if (badge) badge.remove();
+
+ // article list
+ document.querySelectorAll(".articleList .newMessageBadge").forEach((el) => el.remove());
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "markAllAsRead",
+ className: "wcf\\data\\article\\ArticleAction",
+ },
+ };
+ }
+}
+
+export function init(): void {
+ new UiArticleMarkAllAsRead();
+}
--- /dev/null
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../Ajax/Data";
+import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
+import DomUtil from "../../Dom/Util";
+import * as Language from "../../Language";
+import * as StringUtil from "../../StringUtil";
+import UiDialog from "../Dialog";
+
+type CallbackSelect = (articleId: number) => void;
+
+interface SearchResult {
+ articleID: number;
+ displayLink: string;
+ name: string;
+}
+
+interface AjaxResponse extends DatabaseObjectActionResponse {
+ returnValues: SearchResult[];
+}
+
+class UiArticleSearch implements AjaxCallbackObject, DialogCallbackObject {
+ private callbackSelect?: CallbackSelect = undefined;
+ private resultContainer?: HTMLElement = undefined;
+ private resultList?: HTMLOListElement = undefined;
+ private searchInput?: HTMLInputElement = undefined;
+
+ open(callbackSelect: CallbackSelect) {
+ this.callbackSelect = callbackSelect;
+
+ UiDialog.open(this);
+ }
+
+ private search(event: KeyboardEvent): void {
+ event.preventDefault();
+
+ const inputContainer = this.searchInput!.parentElement!;
+
+ const value = this.searchInput!.value.trim();
+ if (value.length < 3) {
+ DomUtil.innerError(inputContainer, Language.get("wcf.article.search.error.tooShort"));
+ return;
+ } else {
+ DomUtil.innerError(inputContainer, false);
+ }
+
+ Ajax.api(this, {
+ parameters: {
+ searchString: value,
+ },
+ });
+ }
+
+ private click(event: MouseEvent): void {
+ event.preventDefault();
+
+ const target = event.currentTarget as HTMLElement;
+ this.callbackSelect!(+target.dataset.articleId!);
+
+ UiDialog.close(this);
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ const html = data.returnValues
+ .map((article) => {
+ return `<li>
+ <div class="containerHeadline pointer" data-article-id="${article.articleID}">
+ <h3>${StringUtil.escapeHTML(article.name)}</h3>
+ <small>${StringUtil.escapeHTML(article.displayLink)}</small>
+ </div>
+ </li>`;
+ })
+ .join("");
+
+ this.resultList!.innerHTML = html;
+
+ if (html) {
+ DomUtil.show(this.resultList!);
+ } else {
+ DomUtil.hide(this.resultList!);
+ }
+
+ if (html) {
+ this.resultList!.querySelectorAll(".containerHeadline").forEach((item) => {
+ item.addEventListener("click", this.click.bind(this));
+ });
+ } else {
+ const parent = this.searchInput!.parentElement!;
+ DomUtil.innerError(parent, Language.get("wcf.article.search.error.noResults"));
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "search",
+ className: "wcf\\data\\article\\ArticleAction",
+ },
+ };
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "wcfUiArticleSearch",
+ options: {
+ onSetup: () => {
+ this.searchInput = document.getElementById("wcfUiArticleSearchInput") as HTMLInputElement;
+ this.searchInput.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ this.search(event);
+ }
+ });
+
+ const button = this.searchInput.nextElementSibling!;
+ button.addEventListener("click", this.search.bind(this));
+
+ this.resultContainer = document.getElementById("wcfUiArticleSearchResultContainer")!;
+ this.resultList = document.getElementById("wcfUiArticleSearchResultList") as HTMLOListElement;
+ },
+ onShow: () => {
+ this.searchInput!.focus();
+ },
+ title: Language.get("wcf.article.search"),
+ },
+ source: `<div class="section">
+ <dl>
+ <dt>
+ <label for="wcfUiArticleSearchInput">${Language.get("wcf.article.search.name")}</label>
+ </dt>
+ <dd>
+ <div class="inputAddon">
+ <input type="text" id="wcfUiArticleSearchInput" class="long">
+ <a href="#" class="inputSuffix"><span class="icon icon16 fa-search"></span></a>
+ </div>
+ </dd>
+ </dl>
+ </div>
+ <section id="wcfUiArticleSearchResultContainer" class="section" style="display: none;">
+ <header class="sectionHeader">
+ <h2 class="sectionTitle">${Language.get("wcf.article.search.results")}</h2>
+ </header>
+ <ol id="wcfUiArticleSearchResultList" class="containerList"></ol>
+ </section>`,
+ };
+ }
+}
+
+let uiArticleSearch: UiArticleSearch | undefined = undefined;
+
+function getUiArticleSearch() {
+ if (!uiArticleSearch) {
+ uiArticleSearch = new UiArticleSearch();
+ }
+
+ return uiArticleSearch;
+}
+
+export function open(callbackSelect: CallbackSelect): void {
+ getUiArticleSearch().open(callbackSelect);
+}
--- /dev/null
+/**
+ * Allows to be informed when a click event bubbled up to the document's body.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Ui/CloseOverlay (alias)
+ * @module WoltLabSuite/Core/Ui/CloseOverlay
+ */
+
+import CallbackList from "../CallbackList";
+
+const _callbackList = new CallbackList();
+
+const UiCloseOverlay = {
+ /**
+ * @see CallbackList.add
+ */
+ add: _callbackList.add.bind(_callbackList),
+
+ /**
+ * @see CallbackList.remove
+ */
+ remove: _callbackList.remove.bind(_callbackList),
+
+ /**
+ * Invokes all registered callbacks.
+ */
+ execute(): void {
+ _callbackList.forEach(null, (callback) => callback());
+ },
+};
+
+document.body.addEventListener("click", () => UiCloseOverlay.execute());
+
+export = UiCloseOverlay;
--- /dev/null
+/**
+ * Wrapper class to provide color picker support. Constructing a new object does not
+ * guarantee the picker to be ready at the time of call.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Color/Picker
+ */
+
+import * as Core from "../../Core";
+
+let _marshal = (element: HTMLElement, options: ColorPickerOptions) => {
+ if (typeof window.WCF === "object" && typeof window.WCF.ColorPicker === "function") {
+ _marshal = (element, options) => {
+ const picker = new window.WCF.ColorPicker(element);
+
+ if (typeof options.callbackSubmit === "function") {
+ picker.setCallbackSubmit(options.callbackSubmit);
+ }
+
+ return picker;
+ };
+
+ return _marshal(element, options);
+ } else {
+ if (_queue.length === 0) {
+ window.__wcf_bc_colorPickerInit = () => {
+ _queue.forEach((data) => {
+ _marshal(data[0], data[1]);
+ });
+
+ window.__wcf_bc_colorPickerInit = undefined;
+ _queue = [];
+ };
+ }
+
+ _queue.push([element, options]);
+ }
+};
+
+type QueueItem = [HTMLElement, ColorPickerOptions];
+
+let _queue: QueueItem[] = [];
+
+interface CallbackSubmitPayload {
+ r: number;
+ g: number;
+ b: number;
+ a: number;
+}
+
+interface ColorPickerOptions {
+ callbackSubmit: (data: CallbackSubmitPayload) => void;
+}
+
+class UiColorPicker {
+ /**
+ * Initializes a new color picker instance. This is actually just a wrapper that does
+ * not guarantee the picker to be ready at the time of call.
+ */
+ constructor(element: HTMLElement, options?: Partial<ColorPickerOptions>) {
+ if (!(element instanceof Element)) {
+ throw new TypeError(
+ "Expected a valid DOM element, use `UiColorPicker.fromSelector()` if you want to use a CSS selector.",
+ );
+ }
+
+ options = Core.extend(
+ {
+ callbackSubmit: null,
+ },
+ options || {},
+ );
+
+ _marshal(element, options as ColorPickerOptions);
+ }
+
+ /**
+ * Initializes a color picker for all input elements matching the given selector.
+ */
+ static fromSelector(selector: string): void {
+ document.querySelectorAll(selector).forEach((element: HTMLElement) => {
+ new UiColorPicker(element);
+ });
+ }
+}
+
+Core.enableLegacyInheritance(UiColorPicker);
+
+export = UiColorPicker;
--- /dev/null
+/**
+ * Handles the comment add feature.
+ *
+ * Warning: This implementation is also used for responses, but in a slightly
+ * modified version. Changes made to this class need to be verified
+ * against the response implementation.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Comment/Add
+ */
+
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
+import ControllerCaptcha from "../../Controller/Captcha";
+import * as Core from "../../Core";
+import DomChangeListener from "../../Dom/Change/Listener";
+import DomUtil from "../../Dom/Util";
+import * as EventHandler from "../../Event/Handler";
+import * as Language from "../../Language";
+import UiDialog from "../Dialog";
+import { RedactorEditor } from "../Redactor/Editor";
+import * as UiScroll from "../Scroll";
+import User from "../../User";
+import * as UiNotification from "../Notification";
+
+interface AjaxResponse {
+ returnValues: {
+ guestDialog?: string;
+ template: string;
+ };
+}
+
+class UiCommentAdd {
+ protected readonly _container: HTMLElement;
+ protected readonly _content: HTMLElement;
+ protected readonly _textarea: HTMLTextAreaElement;
+ protected _editor: RedactorEditor | null = null;
+ protected _loadingOverlay: HTMLElement | null = null;
+
+ /**
+ * Initializes a new quick reply field.
+ */
+ constructor(container: HTMLElement) {
+ this._container = container;
+ this._content = this._container.querySelector(".jsOuterEditorContainer") as HTMLElement;
+ this._textarea = this._container.querySelector(".wysiwygTextarea") as HTMLTextAreaElement;
+
+ this._content.addEventListener("click", (event) => {
+ if (this._content.classList.contains("collapsed")) {
+ event.preventDefault();
+
+ this._content.classList.remove("collapsed");
+
+ this._focusEditor();
+ }
+ });
+
+ // handle submit button
+ const submitButton = this._container.querySelector('button[data-type="save"]') as HTMLButtonElement;
+ submitButton.addEventListener("click", (ev) => this._submit(ev));
+ }
+
+ /**
+ * Scrolls the editor into view and sets the caret to the end of the editor.
+ */
+ protected _focusEditor(): void {
+ UiScroll.element(this._container, () => {
+ window.jQuery(this._textarea).redactor("WoltLabCaret.endOfEditor");
+ });
+ }
+
+ /**
+ * Submits the guest dialog.
+ */
+ protected _submitGuestDialog(event: MouseEvent | KeyboardEvent): void {
+ // only submit when enter key is pressed
+ if (event instanceof KeyboardEvent && event.key !== "Enter") {
+ return;
+ }
+
+ const target = event.currentTarget as HTMLInputElement;
+ const dialogContent = target.closest(".dialogContent") as HTMLElement;
+ const usernameInput = dialogContent.querySelector("input[name=username]") as HTMLInputElement;
+ if (usernameInput.value === "") {
+ DomUtil.innerError(usernameInput, Language.get("wcf.global.form.error.empty"));
+ usernameInput.closest("dl")!.classList.add("formError");
+
+ return;
+ }
+
+ let parameters: ArbitraryObject = {
+ parameters: {
+ data: {
+ username: usernameInput.value,
+ },
+ },
+ };
+
+ if (ControllerCaptcha.has("commentAdd")) {
+ const data = ControllerCaptcha.getData("commentAdd");
+ if (data instanceof Promise) {
+ void data.then((data) => {
+ parameters = Core.extend(parameters, data) as ArbitraryObject;
+ this._submit(undefined, parameters);
+ });
+ } else {
+ parameters = Core.extend(parameters, data as ArbitraryObject) as ArbitraryObject;
+ this._submit(undefined, parameters);
+ }
+ } else {
+ this._submit(undefined, parameters);
+ }
+ }
+
+ /**
+ * Validates the message and submits it to the server.
+ */
+ protected _submit(event: MouseEvent | undefined, additionalParameters?: ArbitraryObject): void {
+ if (event) {
+ event.preventDefault();
+ }
+
+ if (!this._validate()) {
+ // validation failed, bail out
+ return;
+ }
+
+ this._showLoadingOverlay();
+
+ // build parameters
+ const parameters = this._getParameters();
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", "submit_text", parameters.data as any);
+
+ if (!User.userId && !additionalParameters) {
+ parameters.requireGuestDialog = true;
+ }
+
+ Ajax.api(
+ this,
+ Core.extend(
+ {
+ parameters: parameters,
+ },
+ additionalParameters as ArbitraryObject,
+ ),
+ );
+ }
+
+ /**
+ * Returns the request parameters to add a comment.
+ */
+ protected _getParameters(): ArbitraryObject {
+ const commentList = this._container.closest(".commentList") as HTMLElement;
+
+ return {
+ data: {
+ message: this._getEditor().code.get(),
+ objectID: ~~commentList.dataset.objectId!,
+ objectTypeID: ~~commentList.dataset.objectTypeId!,
+ },
+ };
+ }
+
+ /**
+ * Validates the message and invokes listeners to perform additional validation.
+ */
+ protected _validate(): boolean {
+ // remove all existing error elements
+ this._container.querySelectorAll(".innerError").forEach((el) => el.remove());
+
+ // check if editor contains actual content
+ if (this._getEditor().utils.isEmpty()) {
+ this.throwError(this._textarea, Language.get("wcf.global.form.error.empty"));
+ return false;
+ }
+
+ const data = {
+ api: this,
+ editor: this._getEditor(),
+ message: this._getEditor().code.get(),
+ valid: true,
+ };
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", "validate_text", data);
+
+ return data.valid;
+ }
+
+ /**
+ * Throws an error by adding an inline error to target element.
+ */
+ throwError(element: HTMLElement, message: string): void {
+ DomUtil.innerError(element, message === "empty" ? Language.get("wcf.global.form.error.empty") : message);
+ }
+
+ /**
+ * Displays a loading spinner while the request is processed by the server.
+ */
+ protected _showLoadingOverlay(): void {
+ if (this._loadingOverlay === null) {
+ this._loadingOverlay = document.createElement("div");
+ this._loadingOverlay.className = "commentLoadingOverlay";
+ this._loadingOverlay.innerHTML = '<span class="icon icon96 fa-spinner"></span>';
+ }
+
+ this._content.classList.add("loading");
+ this._content.appendChild(this._loadingOverlay);
+ }
+
+ /**
+ * Hides the loading spinner.
+ */
+ protected _hideLoadingOverlay(): void {
+ this._content.classList.remove("loading");
+
+ const loadingOverlay = this._content.querySelector(".commentLoadingOverlay");
+ if (loadingOverlay !== null) {
+ loadingOverlay.remove();
+ }
+ }
+
+ /**
+ * Resets the editor contents and notifies event listeners.
+ */
+ protected _reset(): void {
+ this._getEditor().code.set("<p>\u200b</p>");
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", "reset_text");
+
+ if (document.activeElement instanceof HTMLElement) {
+ document.activeElement.blur();
+ }
+
+ this._content.classList.add("collapsed");
+ }
+
+ /**
+ * Handles errors occurred during server processing.
+ */
+ protected _handleError(data: ResponseData): void {
+ this.throwError(this._textarea, data.returnValues.errorType);
+ }
+
+ /**
+ * Returns the current editor instance.
+ */
+ protected _getEditor(): RedactorEditor {
+ if (this._editor === null) {
+ if (typeof window.jQuery === "function") {
+ this._editor = window.jQuery(this._textarea).data("redactor") as RedactorEditor;
+ } else {
+ throw new Error("Unable to access editor, jQuery has not been loaded yet.");
+ }
+ }
+
+ return this._editor;
+ }
+
+ /**
+ * Inserts the rendered message.
+ */
+ protected _insertMessage(data: AjaxResponse): HTMLElement {
+ // insert HTML
+ DomUtil.insertHtml(data.returnValues.template, this._container, "after");
+
+ UiNotification.show(Language.get("wcf.global.success.add"));
+
+ DomChangeListener.trigger();
+
+ return this._container.nextElementSibling as HTMLElement;
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ if (!User.userId && data.returnValues.guestDialog) {
+ UiDialog.openStatic("jsDialogGuestComment", data.returnValues.guestDialog, {
+ closable: false,
+ onClose: () => {
+ if (ControllerCaptcha.has("commentAdd")) {
+ ControllerCaptcha.delete("commentAdd");
+ }
+ },
+ title: Language.get("wcf.global.confirmation.title"),
+ });
+
+ const dialog = UiDialog.getDialog("jsDialogGuestComment")!;
+
+ const submitButton = dialog.content.querySelector("input[type=submit]") as HTMLButtonElement;
+ submitButton.addEventListener("click", (ev) => this._submitGuestDialog(ev));
+ const cancelButton = dialog.content.querySelector('button[data-type="cancel"]') as HTMLButtonElement;
+ cancelButton.addEventListener("click", () => this._cancelGuestDialog());
+
+ const input = dialog.content.querySelector("input[type=text]") as HTMLInputElement;
+ input.addEventListener("keypress", (ev) => this._submitGuestDialog(ev));
+ } else {
+ const scrollTarget = this._insertMessage(data);
+
+ if (!User.userId) {
+ UiDialog.close("jsDialogGuestComment");
+ }
+
+ this._reset();
+
+ this._hideLoadingOverlay();
+
+ window.setTimeout(() => {
+ UiScroll.element(scrollTarget);
+ }, 100);
+ }
+ }
+
+ _ajaxFailure(data: ResponseData): boolean {
+ this._hideLoadingOverlay();
+
+ if (data === null || data.returnValues === undefined || data.returnValues.errorType === undefined) {
+ return true;
+ }
+
+ this._handleError(data);
+
+ return false;
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "addComment",
+ className: "wcf\\data\\comment\\CommentAction",
+ },
+ silent: true,
+ };
+ }
+
+ /**
+ * Cancels the guest dialog and restores the comment editor.
+ */
+ protected _cancelGuestDialog(): void {
+ UiDialog.close("jsDialogGuestComment");
+
+ this._hideLoadingOverlay();
+ }
+}
+
+Core.enableLegacyInheritance(UiCommentAdd);
+
+export = UiCommentAdd;
--- /dev/null
+/**
+ * Provides editing support for comments.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Comment/Edit
+ */
+
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
+import * as Core from "../../Core";
+import DomChangeListener from "../../Dom/Change/Listener";
+import DomUtil from "../../Dom/Util";
+import * as Environment from "../../Environment";
+import * as EventHandler from "../../Event/Handler";
+import * as Language from "../../Language";
+import { RedactorEditor } from "../Redactor/Editor";
+import * as UiScroll from "../Scroll";
+import * as UiNotification from "../Notification";
+
+interface AjaxResponse {
+ actionName: string;
+ returnValues: {
+ message: string;
+ template: string;
+ };
+}
+
+class UiCommentEdit {
+ protected _activeElement: HTMLElement | null = null;
+ protected readonly _comments = new WeakSet<HTMLElement>();
+ protected readonly _container: HTMLElement;
+ protected _editorContainer: HTMLElement | null = null;
+
+ /**
+ * Initializes the comment edit manager.
+ */
+ constructor(container: HTMLElement) {
+ this._container = container;
+
+ this.rebuild();
+
+ DomChangeListener.add("Ui/Comment/Edit_" + DomUtil.identify(this._container), this.rebuild.bind(this));
+ }
+
+ /**
+ * Initializes each applicable message, should be called whenever new
+ * messages are being displayed.
+ */
+ rebuild(): void {
+ this._container.querySelectorAll(".comment").forEach((comment: HTMLElement) => {
+ if (this._comments.has(comment)) {
+ return;
+ }
+
+ if (Core.stringToBool(comment.dataset.canEdit || "")) {
+ const button = comment.querySelector(".jsCommentEditButton") as HTMLAnchorElement;
+ if (button !== null) {
+ button.addEventListener("click", (ev) => this._click(ev));
+ }
+ }
+
+ this._comments.add(comment);
+ });
+ }
+
+ /**
+ * Handles clicks on the edit button.
+ */
+ protected _click(event: MouseEvent): void {
+ event.preventDefault();
+
+ if (this._activeElement === null) {
+ const target = event.currentTarget as HTMLElement;
+ this._activeElement = target.closest(".comment") as HTMLElement;
+
+ this._prepare();
+
+ Ajax.api(this, {
+ actionName: "beginEdit",
+ objectIDs: [this._getObjectId(this._activeElement)],
+ });
+ } else {
+ UiNotification.show("wcf.message.error.editorAlreadyInUse", null, "warning");
+ }
+ }
+
+ /**
+ * Prepares the message for editor display.
+ */
+ protected _prepare(): void {
+ this._editorContainer = document.createElement("div");
+ this._editorContainer.className = "commentEditorContainer";
+ this._editorContainer.innerHTML = '<span class="icon icon48 fa-spinner"></span>';
+
+ const content = this._activeElement!.querySelector(".commentContentContainer")!;
+ content.insertBefore(this._editorContainer, content.firstChild);
+ }
+
+ /**
+ * Shows the message editor.
+ */
+ protected _showEditor(data: AjaxResponse): void {
+ const id = this._getEditorId();
+ const editorContainer = this._editorContainer!;
+
+ const icon = editorContainer.querySelector(".icon")!;
+ icon.remove();
+
+ const editor = document.createElement("div");
+ editor.className = "editorContainer";
+ DomUtil.setInnerHtml(editor, data.returnValues.template);
+ editorContainer.appendChild(editor);
+
+ // bind buttons
+ const formSubmit = editorContainer.querySelector(".formSubmit") as HTMLElement;
+
+ const buttonSave = formSubmit.querySelector('button[data-type="save"]') as HTMLButtonElement;
+ buttonSave.addEventListener("click", () => this._save());
+
+ const buttonCancel = formSubmit.querySelector('button[data-type="cancel"]') as HTMLButtonElement;
+ buttonCancel.addEventListener("click", () => this._restoreMessage());
+
+ EventHandler.add("com.woltlab.wcf.redactor", `submitEditor_${id}`, (data) => {
+ data.cancel = true;
+
+ this._save();
+ });
+
+ const editorElement = document.getElementById(id) as HTMLElement;
+ if (Environment.editor() === "redactor") {
+ window.setTimeout(() => {
+ UiScroll.element(this._activeElement!);
+ }, 250);
+ } else {
+ editorElement.focus();
+ }
+ }
+
+ /**
+ * Restores the message view.
+ */
+ protected _restoreMessage(): void {
+ this._destroyEditor();
+
+ this._editorContainer!.remove();
+
+ this._activeElement = null;
+ }
+
+ /**
+ * Saves the editor message.
+ */
+ protected _save(): void {
+ const parameters = {
+ data: {
+ message: "",
+ },
+ };
+
+ const id = this._getEditorId();
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", `getText_${id}`, parameters.data);
+
+ if (!this._validate(parameters)) {
+ // validation failed
+ return;
+ }
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", `submit_${id}`, parameters);
+
+ Ajax.api(this, {
+ actionName: "save",
+ objectIDs: [this._getObjectId(this._activeElement!)],
+ parameters: parameters,
+ });
+
+ this._hideEditor();
+ }
+
+ /**
+ * Validates the message and invokes listeners to perform additional validation.
+ */
+ protected _validate(parameters: ArbitraryObject): boolean {
+ // remove all existing error elements
+ this._activeElement!.querySelectorAll(".innerError").forEach((el) => el.remove());
+
+ // check if editor contains actual content
+ const editorElement = document.getElementById(this._getEditorId())!;
+ const redactor: RedactorEditor = window.jQuery(editorElement).data("redactor");
+ if (redactor.utils.isEmpty()) {
+ this.throwError(editorElement, Language.get("wcf.global.form.error.empty"));
+ return false;
+ }
+
+ const data = {
+ api: this,
+ parameters: parameters,
+ valid: true,
+ };
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", "validate_" + this._getEditorId(), data);
+
+ return data.valid;
+ }
+
+ /**
+ * Throws an error by adding an inline error to target element.
+ */
+ throwError(element: HTMLElement, message: string): void {
+ DomUtil.innerError(element, message);
+ }
+
+ /**
+ * Shows the update message.
+ */
+ protected _showMessage(data: AjaxResponse): void {
+ // set new content
+ const container = this._editorContainer!.parentElement!.querySelector(
+ ".commentContent .userMessage",
+ ) as HTMLElement;
+ DomUtil.setInnerHtml(container, data.returnValues.message);
+
+ this._restoreMessage();
+
+ UiNotification.show();
+ }
+
+ /**
+ * Hides the editor from view.
+ */
+ protected _hideEditor(): void {
+ const editorContainer = this._editorContainer!.querySelector(".editorContainer") as HTMLElement;
+ DomUtil.hide(editorContainer);
+
+ const icon = document.createElement("span");
+ icon.className = "icon icon48 fa-spinner";
+ this._editorContainer!.appendChild(icon);
+ }
+
+ /**
+ * Restores the previously hidden editor.
+ */
+ protected _restoreEditor(): void {
+ const icon = this._editorContainer!.querySelector(".fa-spinner")!;
+ icon.remove();
+
+ const editorContainer = this._editorContainer!.querySelector(".editorContainer") as HTMLElement;
+ if (editorContainer !== null) {
+ DomUtil.show(editorContainer);
+ }
+ }
+
+ /**
+ * Destroys the editor instance.
+ */
+ protected _destroyEditor(): void {
+ EventHandler.fire("com.woltlab.wcf.redactor2", `autosaveDestroy_${this._getEditorId()}`);
+ EventHandler.fire("com.woltlab.wcf.redactor2", `destroy_${this._getEditorId()}`);
+ }
+
+ /**
+ * Returns the unique editor id.
+ */
+ protected _getEditorId(): string {
+ return `commentEditor${this._getObjectId(this._activeElement!)}`;
+ }
+
+ /**
+ * Returns the element's `data-object-id` value.
+ */
+ protected _getObjectId(element: HTMLElement): number {
+ return ~~element.dataset.objectId!;
+ }
+
+ _ajaxFailure(data: ResponseData): boolean {
+ const editor = this._editorContainer!.querySelector(".redactor-layer") as HTMLElement;
+
+ // handle errors occurring on editor load
+ if (editor === null) {
+ this._restoreMessage();
+
+ return true;
+ }
+
+ this._restoreEditor();
+
+ if (!data || data.returnValues === undefined || data.returnValues.errorType === undefined) {
+ return true;
+ }
+
+ DomUtil.innerError(editor, data.returnValues.errorType);
+
+ return false;
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ switch (data.actionName) {
+ case "beginEdit":
+ this._showEditor(data);
+ break;
+
+ case "save":
+ this._showMessage(data);
+ break;
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ const objectTypeId = ~~this._container.dataset.objectTypeId!;
+
+ return {
+ data: {
+ className: "wcf\\data\\comment\\CommentAction",
+ parameters: {
+ data: {
+ objectTypeID: objectTypeId,
+ },
+ },
+ },
+ silent: true,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiCommentEdit);
+
+export = UiCommentEdit;
--- /dev/null
+/**
+ * Handles the comment response add feature.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Comment/Add
+ */
+
+import { AjaxCallbackSetup } from "../../../Ajax/Data";
+import * as Core from "../../../Core";
+import DomChangeListener from "../../../Dom/Change/Listener";
+import DomUtil from "../../../Dom/Util";
+import * as Language from "../../../Language";
+import UiCommentAdd from "../Add";
+import * as UiNotification from "../../Notification";
+
+type CallbackInsert = () => void;
+
+interface ResponseAddOptions {
+ callbackInsert: CallbackInsert | null;
+}
+
+interface AjaxResponse {
+ returnValues: {
+ guestDialog?: string;
+ template: string;
+ };
+}
+
+class UiCommentResponseAdd extends UiCommentAdd {
+ protected _options: ResponseAddOptions;
+
+ constructor(container: HTMLElement, options: Partial<ResponseAddOptions>) {
+ super(container);
+
+ this._options = Core.extend(
+ {
+ callbackInsert: null,
+ },
+ options,
+ ) as ResponseAddOptions;
+ }
+
+ /**
+ * Returns the editor container for placement.
+ */
+ getContainer(): HTMLElement {
+ return this._container;
+ }
+
+ /**
+ * Retrieves the current content from the editor.
+ */
+ getContent(): string {
+ return window.jQuery(this._textarea).redactor("code.get") as string;
+ }
+
+ /**
+ * Sets the content and places the caret at the end of the editor.
+ */
+ setContent(html: string): void {
+ window.jQuery(this._textarea).redactor("code.set", html);
+ window.jQuery(this._textarea).redactor("WoltLabCaret.endOfEditor");
+
+ // the error message can appear anywhere in the container, not exclusively after the textarea
+ const innerError = this._textarea.parentElement!.querySelector(".innerError");
+ if (innerError !== null) {
+ innerError.remove();
+ }
+
+ this._content.classList.remove("collapsed");
+ this._focusEditor();
+ }
+
+ protected _getParameters(): ArbitraryObject {
+ const parameters = super._getParameters();
+
+ const comment = this._container.closest(".comment") as HTMLElement;
+ (parameters.data as ArbitraryObject).commentID = ~~comment.dataset.objectId!;
+
+ return parameters;
+ }
+
+ protected _insertMessage(data: AjaxResponse): HTMLElement {
+ const commentContent = this._container.parentElement!.querySelector(".commentContent")!;
+ let responseList = commentContent.nextElementSibling as HTMLElement;
+ if (responseList === null || !responseList.classList.contains("commentResponseList")) {
+ responseList = document.createElement("ul");
+ responseList.className = "containerList commentResponseList";
+ responseList.dataset.responses = "0";
+
+ commentContent.insertAdjacentElement("afterend", responseList);
+ }
+
+ // insert HTML
+ DomUtil.insertHtml(data.returnValues.template, responseList, "append");
+
+ UiNotification.show(Language.get("wcf.global.success.add"));
+
+ DomChangeListener.trigger();
+
+ // reset editor
+ window.jQuery(this._textarea).redactor("code.set", "");
+
+ if (this._options.callbackInsert !== null) {
+ this._options.callbackInsert();
+ }
+
+ // update counter
+ responseList.dataset.responses = responseList.children.length.toString();
+
+ return responseList.lastElementChild as HTMLElement;
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ const data = super._ajaxSetup();
+ (data.data as ArbitraryObject).actionName = "addResponse";
+
+ return data;
+ }
+}
+
+Core.enableLegacyInheritance(UiCommentResponseAdd);
+
+export = UiCommentResponseAdd;
--- /dev/null
+/**
+ * Provides editing support for comment responses.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Comment/Response/Edit
+ */
+
+import * as Ajax from "../../../Ajax";
+import { AjaxCallbackSetup } from "../../../Ajax/Data";
+import * as Core from "../../../Core";
+import DomChangeListener from "../../../Dom/Change/Listener";
+import DomUtil from "../../../Dom/Util";
+import UiCommentEdit from "../Edit";
+import * as UiNotification from "../../Notification";
+
+interface AjaxResponse {
+ actionName: string;
+ returnValues: {
+ message: string;
+ template: string;
+ };
+}
+
+class UiCommentResponseEdit extends UiCommentEdit {
+ protected readonly _responses = new WeakSet<HTMLElement>();
+
+ /**
+ * Initializes the comment edit manager.
+ *
+ * @param {Element} container container element
+ */
+ constructor(container: HTMLElement) {
+ super(container);
+
+ this.rebuildResponses();
+
+ DomChangeListener.add("Ui/Comment/Response/Edit_" + DomUtil.identify(this._container), () =>
+ this.rebuildResponses(),
+ );
+ }
+
+ rebuild(): void {
+ // Do nothing, we want to avoid implicitly invoking `UiCommentEdit.rebuild()`.
+ }
+
+ /**
+ * Initializes each applicable message, should be called whenever new
+ * messages are being displayed.
+ */
+ rebuildResponses(): void {
+ this._container.querySelectorAll(".commentResponse").forEach((response: HTMLElement) => {
+ if (this._responses.has(response)) {
+ return;
+ }
+
+ if (Core.stringToBool(response.dataset.canEdit || "")) {
+ const button = response.querySelector(".jsCommentResponseEditButton") as HTMLAnchorElement;
+ if (button !== null) {
+ button.addEventListener("click", (ev) => this._click(ev));
+ }
+ }
+
+ this._responses.add(response);
+ });
+ }
+
+ /**
+ * Handles clicks on the edit button.
+ */
+ protected _click(event: MouseEvent): void {
+ event.preventDefault();
+
+ if (this._activeElement === null) {
+ const target = event.currentTarget as HTMLElement;
+ this._activeElement = target.closest(".commentResponse") as HTMLElement;
+
+ this._prepare();
+
+ Ajax.api(this, {
+ actionName: "beginEdit",
+ objectIDs: [this._getObjectId(this._activeElement)],
+ });
+ } else {
+ UiNotification.show("wcf.message.error.editorAlreadyInUse", null, "warning");
+ }
+ }
+
+ /**
+ * Prepares the message for editor display.
+ *
+ * @protected
+ */
+ protected _prepare(): void {
+ this._editorContainer = document.createElement("div");
+ this._editorContainer.className = "commentEditorContainer";
+ this._editorContainer.innerHTML = '<span class="icon icon48 fa-spinner"></span>';
+
+ const content = this._activeElement!.querySelector(".commentResponseContent")!;
+ content.insertBefore(this._editorContainer, content.firstChild);
+ }
+
+ /**
+ * Shows the update message.
+ */
+ protected _showMessage(data: AjaxResponse): void {
+ // set new content
+ const parent = this._editorContainer!.parentElement!;
+ DomUtil.setInnerHtml(parent.querySelector(".commentResponseContent .userMessage")!, data.returnValues.message);
+
+ this._restoreMessage();
+
+ UiNotification.show();
+ }
+
+ /**
+ * Returns the unique editor id.
+ */
+ protected _getEditorId(): string {
+ return `commentResponseEditor${this._getObjectId(this._activeElement!)}`;
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ const objectTypeId = ~~this._container.dataset.objectTypeId!;
+
+ return {
+ data: {
+ className: "wcf\\data\\comment\\response\\CommentResponseAction",
+ parameters: {
+ data: {
+ objectTypeID: objectTypeId,
+ },
+ },
+ },
+ silent: true,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiCommentResponseEdit);
+
+export = UiCommentResponseEdit;
--- /dev/null
+/**
+ * Provides the confirmation dialog overlay.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Ui/Confirmation (alias)
+ * @module WoltLabSuite/Core/Ui/Confirmation
+ */
+
+import * as Core from "../Core";
+import * as Language from "../Language";
+import UiDialog from "./Dialog";
+import { DialogCallbackObject, DialogCallbackSetup } from "./Dialog/Data";
+
+class UiConfirmation implements DialogCallbackObject {
+ private _active = false;
+ private parameters: ConfirmationCallbackParameters;
+
+ private readonly confirmButton: HTMLElement;
+ private readonly _content: HTMLElement;
+ private readonly dialog: HTMLElement;
+ private readonly text: HTMLElement;
+
+ private callbackCancel: CallbackCancel;
+ private callbackConfirm: CallbackConfirm;
+
+ constructor() {
+ this.dialog = document.createElement("div");
+ this.dialog.id = "wcfSystemConfirmation";
+ this.dialog.classList.add("systemConfirmation");
+
+ this.text = document.createElement("p");
+ this.dialog.appendChild(this.text);
+
+ this._content = document.createElement("div");
+ this._content.id = "wcfSystemConfirmationContent";
+ this.dialog.appendChild(this._content);
+
+ const formSubmit = document.createElement("div");
+ formSubmit.classList.add("formSubmit");
+ this.dialog.appendChild(formSubmit);
+
+ this.confirmButton = document.createElement("button");
+ this.confirmButton.classList.add("buttonPrimary");
+ this.confirmButton.textContent = Language.get("wcf.global.confirmation.confirm");
+ this.confirmButton.addEventListener("click", (_ev) => this._confirm());
+ formSubmit.appendChild(this.confirmButton);
+
+ const cancelButton = document.createElement("button");
+ cancelButton.textContent = Language.get("wcf.global.confirmation.cancel");
+ cancelButton.addEventListener("click", () => {
+ UiDialog.close(this);
+ });
+ formSubmit.appendChild(cancelButton);
+
+ document.body.appendChild(this.dialog);
+ }
+
+ public open(options: ConfirmationOptions): void {
+ this.parameters = options.parameters || {};
+
+ this._content.innerHTML = typeof options.template === "string" ? options.template.trim() : "";
+ this.text[options.messageIsHtml ? "innerHTML" : "textContent"] = options.message;
+
+ if (typeof options.legacyCallback === "function") {
+ this.callbackCancel = (parameters) => {
+ options.legacyCallback!("cancel", parameters, this.content);
+ };
+ this.callbackConfirm = (parameters) => {
+ options.legacyCallback!("confirm", parameters, this.content);
+ };
+ } else {
+ if (typeof options.cancel !== "function") {
+ options.cancel = () => {
+ // Do nothing
+ };
+ }
+
+ this.callbackCancel = options.cancel;
+ this.callbackConfirm = options.confirm!;
+ }
+
+ this._active = true;
+
+ UiDialog.open(this);
+ }
+
+ get active(): boolean {
+ return this._active;
+ }
+
+ get content(): HTMLElement {
+ return this._content;
+ }
+
+ /**
+ * Invoked if the user confirms the dialog.
+ */
+ _confirm(): void {
+ this.callbackConfirm(this.parameters, this.content);
+
+ this._active = false;
+
+ UiDialog.close("wcfSystemConfirmation");
+ }
+
+ /**
+ * Invoked on dialog close or if user cancels the dialog.
+ */
+ _onClose(): void {
+ if (this.active) {
+ this.confirmButton.blur();
+
+ this._active = false;
+
+ this.callbackCancel(this.parameters);
+ }
+ }
+
+ /**
+ * Sets the focus on the confirm button on dialog open for proper keyboard support.
+ */
+ _onShow(): void {
+ this.confirmButton.blur();
+ this.confirmButton.focus();
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "wcfSystemConfirmation",
+ options: {
+ onClose: this._onClose.bind(this),
+ onShow: this._onShow.bind(this),
+ title: Language.get("wcf.global.confirmation.title"),
+ },
+ };
+ }
+}
+
+let confirmation: UiConfirmation;
+
+function getConfirmation(): UiConfirmation {
+ if (!confirmation) {
+ confirmation = new UiConfirmation();
+ }
+ return confirmation;
+}
+
+type LegacyResult = "cancel" | "confirm";
+
+export type ConfirmationCallbackParameters = {
+ [key: string]: any;
+};
+
+interface BasicConfirmationOptions {
+ message: string;
+ messageIsHtml?: boolean;
+ parameters?: ConfirmationCallbackParameters;
+ template?: string;
+}
+
+interface LegacyConfirmationOptions extends BasicConfirmationOptions {
+ cancel?: never;
+ confirm?: never;
+ legacyCallback: (result: LegacyResult, parameters: ConfirmationCallbackParameters, element: HTMLElement) => void;
+}
+
+type CallbackCancel = (parameters: ConfirmationCallbackParameters) => void;
+type CallbackConfirm = (parameters: ConfirmationCallbackParameters, content: HTMLElement) => void;
+
+interface NewConfirmationOptions extends BasicConfirmationOptions {
+ cancel?: CallbackCancel;
+ confirm: CallbackConfirm;
+ legacyCallback?: never;
+}
+
+export type ConfirmationOptions = LegacyConfirmationOptions | NewConfirmationOptions;
+
+/**
+ * Shows the confirmation dialog.
+ */
+export function show(options: ConfirmationOptions): void {
+ if (getConfirmation().active) {
+ return;
+ }
+
+ options = Core.extend(
+ {
+ cancel: null,
+ confirm: null,
+ legacyCallback: null,
+ message: "",
+ messageIsHtml: false,
+ parameters: {},
+ template: "",
+ },
+ options,
+ ) as ConfirmationOptions;
+ options.message = typeof (options.message as any) === "string" ? options.message.trim() : "";
+ if (!options.message) {
+ throw new Error("Expected a non-empty string for option 'message'.");
+ }
+ if (typeof options.confirm !== "function" && typeof options.legacyCallback !== "function") {
+ throw new TypeError("Expected a valid callback for option 'confirm'.");
+ }
+
+ getConfirmation().open(options);
+}
+
+/**
+ * Returns content container element.
+ */
+export function getContentElement(): HTMLElement {
+ return getConfirmation().content;
+}
--- /dev/null
+/**
+ * Modal dialog handler.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Ui/Dialog (alias)
+ * @module WoltLabSuite/Core/Ui/Dialog
+ */
+
+import * as Core from "../Core";
+import DomChangeListener from "../Dom/Change/Listener";
+import * as UiScreen from "./Screen";
+import DomUtil from "../Dom/Util";
+import {
+ DialogCallbackObject,
+ DialogData,
+ DialogId,
+ DialogOptions,
+ DialogHtml,
+ AjaxInitialization,
+} from "./Dialog/Data";
+import * as Language from "../Language";
+import * as Environment from "../Environment";
+import * as EventHandler from "../Event/Handler";
+import UiDropdownSimple from "./Dropdown/Simple";
+import { AjaxCallbackSetup } from "../Ajax/Data";
+
+let _activeDialog: string | null = null;
+let _callbackFocus: (event: FocusEvent) => void;
+let _container: HTMLElement;
+const _dialogs = new Map<ElementId, DialogData>();
+let _dialogFullHeight = false;
+const _dialogObjects = new WeakMap<DialogCallbackObject, DialogInternalData>();
+const _dialogToObject = new Map<ElementId, DialogCallbackObject>();
+let _keyupListener: (event: KeyboardEvent) => boolean;
+const _validCallbacks = ["onBeforeClose", "onClose", "onShow"];
+
+// list of supported `input[type]` values for dialog submit
+const _validInputTypes = ["number", "password", "search", "tel", "text", "url"];
+
+const _focusableElements = [
+ 'a[href]:not([tabindex^="-"]):not([inert])',
+ 'area[href]:not([tabindex^="-"]):not([inert])',
+ "input:not([disabled]):not([inert])",
+ "select:not([disabled]):not([inert])",
+ "textarea:not([disabled]):not([inert])",
+ "button:not([disabled]):not([inert])",
+ 'iframe:not([tabindex^="-"]):not([inert])',
+ 'audio:not([tabindex^="-"]):not([inert])',
+ 'video:not([tabindex^="-"]):not([inert])',
+ '[contenteditable]:not([tabindex^="-"]):not([inert])',
+ '[tabindex]:not([tabindex^="-"]):not([inert])',
+];
+
+/**
+ * @exports WoltLabSuite/Core/Ui/Dialog
+ */
+const UiDialog = {
+ /**
+ * Sets up global container and internal variables.
+ */
+ setup(): void {
+ _container = document.createElement("div");
+ _container.classList.add("dialogOverlay");
+ _container.setAttribute("aria-hidden", "true");
+ _container.addEventListener("mousedown", (ev) => this._closeOnBackdrop(ev));
+ _container.addEventListener(
+ "wheel",
+ (event) => {
+ if (event.target === _container) {
+ event.preventDefault();
+ }
+ },
+ { passive: false },
+ );
+
+ document.getElementById("content")!.appendChild(_container);
+
+ _keyupListener = (event: KeyboardEvent): boolean => {
+ if (event.key === "Escape") {
+ const target = event.target as HTMLElement;
+ if (target.nodeName !== "INPUT" && target.nodeName !== "TEXTAREA") {
+ this.close(_activeDialog!);
+
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ UiScreen.on("screen-xs", {
+ match() {
+ _dialogFullHeight = true;
+ },
+ unmatch() {
+ _dialogFullHeight = false;
+ },
+ setup() {
+ _dialogFullHeight = true;
+ },
+ });
+
+ this._initStaticDialogs();
+ DomChangeListener.add("Ui/Dialog", () => {
+ this._initStaticDialogs();
+ });
+
+ window.addEventListener("resize", () => {
+ _dialogs.forEach((dialog) => {
+ if (!Core.stringToBool(dialog.dialog.getAttribute("aria-hidden"))) {
+ this.rebuild(dialog.dialog.dataset.id || "");
+ }
+ });
+ });
+ },
+
+ _initStaticDialogs(): void {
+ document.querySelectorAll(".jsStaticDialog").forEach((button: HTMLElement) => {
+ button.classList.remove("jsStaticDialog");
+
+ const id = button.dataset.dialogId || "";
+ if (id) {
+ const container = document.getElementById(id);
+ if (container !== null) {
+ container.classList.remove("jsStaticDialogContent");
+ container.dataset.isStaticDialog = "true";
+ DomUtil.hide(container);
+
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ this.openStatic(container.id, null, { title: container.dataset.title || "" });
+ });
+ }
+ }
+ });
+ },
+
+ /**
+ * Opens the dialog and implicitly creates it on first usage.
+ */
+ open(callbackObject: DialogCallbackObject, html?: DialogHtml): DialogData | object {
+ let dialogData = _dialogObjects.get(callbackObject);
+ if (dialogData && Core.isPlainObject(dialogData)) {
+ // dialog already exists
+ return this.openStatic(dialogData.id, typeof html === "undefined" ? null : html);
+ }
+
+ // initialize a new dialog
+ if (typeof callbackObject._dialogSetup !== "function") {
+ throw new Error("Callback object does not implement the method '_dialogSetup()'.");
+ }
+
+ const setupData = callbackObject._dialogSetup();
+ if (!Core.isPlainObject(setupData)) {
+ throw new Error("Expected an object literal as return value of '_dialogSetup()'.");
+ }
+
+ const id = setupData.id;
+ dialogData = { id };
+
+ let dialogElement: HTMLElement | null;
+ if (setupData.source === undefined) {
+ dialogElement = document.getElementById(id);
+ if (dialogElement === null) {
+ throw new Error(
+ "Element id '" +
+ id +
+ "' is invalid and no source attribute was given. If you want to use the `html` argument instead, please add `source: null` to your dialog configuration.",
+ );
+ }
+
+ setupData.source = document.createDocumentFragment();
+ setupData.source.appendChild(dialogElement);
+
+ dialogElement.removeAttribute("id");
+ DomUtil.show(dialogElement);
+ } else if (setupData.source === null) {
+ // `null` means there is no static markup and `html` should be used instead
+ setupData.source = html;
+ } else if (typeof setupData.source === "function") {
+ setupData.source();
+ } else if (Core.isPlainObject(setupData.source)) {
+ if (typeof html === "string" && html.trim() !== "") {
+ setupData.source = html;
+ } else {
+ void import("../Ajax").then((Ajax) => {
+ const source = setupData.source as AjaxInitialization;
+ Ajax.api(this as any, source.data, (data) => {
+ if (data.returnValues && typeof data.returnValues.template === "string") {
+ this.open(callbackObject, data.returnValues.template);
+
+ if (typeof source.after === "function") {
+ source.after(_dialogs.get(id)!.content, data);
+ }
+ }
+ });
+ });
+
+ return {};
+ }
+ } else {
+ if (typeof setupData.source === "string") {
+ dialogElement = document.createElement("div");
+ dialogElement.id = id;
+ DomUtil.setInnerHtml(dialogElement, setupData.source);
+
+ setupData.source = document.createDocumentFragment();
+ setupData.source.appendChild(dialogElement);
+ }
+
+ if (!setupData.source.nodeType || setupData.source.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
+ throw new Error("Expected at least a document fragment as 'source' attribute.");
+ }
+ }
+
+ _dialogObjects.set(callbackObject, dialogData);
+ _dialogToObject.set(id, callbackObject);
+
+ return this.openStatic(id, setupData.source as DialogHtml, setupData.options);
+ },
+
+ /**
+ * Opens an dialog, if the dialog is already open the content container
+ * will be replaced by the HTML string contained in the parameter html.
+ *
+ * If id is an existing element id, html will be ignored and the referenced
+ * element will be appended to the content element instead.
+ */
+ openStatic(id: string, html: DialogHtml, options?: DialogOptions): DialogData {
+ UiScreen.pageOverlayOpen();
+
+ if (Environment.platform() !== "desktop") {
+ if (!this.isOpen(id)) {
+ UiScreen.scrollDisable();
+ }
+ }
+
+ if (_dialogs.has(id)) {
+ this._updateDialog(id, html as string);
+ } else {
+ options = Core.extend(
+ {
+ backdropCloseOnClick: true,
+ closable: true,
+ closeButtonLabel: Language.get("wcf.global.button.close"),
+ closeConfirmMessage: "",
+ disableContentPadding: false,
+ title: "",
+
+ onBeforeClose: null,
+ onClose: null,
+ onShow: null,
+ },
+ options || {},
+ ) as InternalDialogOptions;
+
+ if (!options.closable) options.backdropCloseOnClick = false;
+ if (options.closeConfirmMessage) {
+ options.onBeforeClose = (id) => {
+ void import("./Confirmation").then((UiConfirmation) => {
+ UiConfirmation.show({
+ confirm: this.close.bind(this, id),
+ message: options!.closeConfirmMessage || "",
+ });
+ });
+ };
+ }
+
+ this._createDialog(id, html, options as InternalDialogOptions);
+ }
+
+ const data = _dialogs.get(id)!;
+
+ // iOS breaks `position: fixed` when input elements or `contenteditable`
+ // are focused, this will freeze the screen and force Safari to scroll
+ // to the input field
+ if (Environment.platform() === "ios") {
+ window.setTimeout(() => {
+ data.content.querySelector<HTMLElement>("input, textarea")?.focus();
+ }, 200);
+ }
+
+ return data;
+ },
+
+ /**
+ * Sets the dialog title.
+ */
+ setTitle(id: ElementIdOrCallbackObject, title: string): void {
+ id = this._getDialogId(id);
+
+ const data = _dialogs.get(id);
+ if (data === undefined) {
+ throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+ }
+
+ const dialogTitle = data.dialog.querySelector(".dialogTitle");
+ if (dialogTitle) {
+ dialogTitle.textContent = title;
+ }
+ },
+
+ /**
+ * Sets a callback function on runtime.
+ */
+ setCallback(id: ElementIdOrCallbackObject, key: string, value: (...args: any[]) => void | null): void {
+ if (typeof id === "object") {
+ const dialogData = _dialogObjects.get(id);
+ if (dialogData !== undefined) {
+ id = dialogData.id;
+ }
+ }
+
+ const data = _dialogs.get(id as string);
+ if (data === undefined) {
+ throw new Error(`Expected a valid dialog id, '${id as string}' does not match any active dialog.`);
+ }
+
+ if (_validCallbacks.indexOf(key) === -1) {
+ throw new Error("Invalid callback identifier, '" + key + "' is not recognized.");
+ }
+
+ if (typeof value !== "function" && value !== null) {
+ throw new Error(
+ "Only functions or the 'null' value are acceptable callback values ('" + typeof value + "' given).",
+ );
+ }
+
+ data[key] = value;
+ },
+
+ /**
+ * Creates the DOM for a new dialog and opens it.
+ */
+ _createDialog(id: string, html: DialogHtml, options: InternalDialogOptions): void {
+ let element: HTMLElement | null = null;
+ if (html === null) {
+ element = document.getElementById(id);
+ if (element === null) {
+ throw new Error("Expected either a HTML string or an existing element id.");
+ }
+ }
+
+ const dialog = document.createElement("div");
+ dialog.classList.add("dialogContainer");
+ dialog.setAttribute("aria-hidden", "true");
+ dialog.setAttribute("role", "dialog");
+ dialog.dataset.id = id;
+
+ const header = document.createElement("header");
+ dialog.appendChild(header);
+
+ const titleId = DomUtil.getUniqueId();
+ dialog.setAttribute("aria-labelledby", titleId);
+
+ const title = document.createElement("span");
+ title.classList.add("dialogTitle");
+ title.textContent = options.title!;
+ title.id = titleId;
+ header.appendChild(title);
+
+ if (options.closable) {
+ const closeButton = document.createElement("a");
+ closeButton.className = "dialogCloseButton jsTooltip";
+ closeButton.href = "#";
+ closeButton.setAttribute("role", "button");
+ closeButton.tabIndex = 0;
+ closeButton.title = options.closeButtonLabel;
+ closeButton.setAttribute("aria-label", options.closeButtonLabel);
+ closeButton.addEventListener("click", (ev) => this._close(ev));
+ header.appendChild(closeButton);
+
+ const span = document.createElement("span");
+ span.className = "icon icon24 fa-times";
+ closeButton.appendChild(span);
+ }
+
+ const contentContainer = document.createElement("div");
+ contentContainer.classList.add("dialogContent");
+ if (options.disableContentPadding) contentContainer.classList.add("dialogContentNoPadding");
+ dialog.appendChild(contentContainer);
+
+ contentContainer.addEventListener(
+ "wheel",
+ (event) => {
+ let allowScroll = false;
+ let element: HTMLElement | null = event.target as HTMLElement;
+ let clientHeight: number;
+ let scrollHeight: number;
+ let scrollTop: number;
+ for (;;) {
+ clientHeight = element.clientHeight;
+ scrollHeight = element.scrollHeight;
+
+ if (clientHeight < scrollHeight) {
+ scrollTop = element.scrollTop;
+
+ // negative value: scrolling up
+ if (event.deltaY < 0 && scrollTop > 0) {
+ allowScroll = true;
+ break;
+ } else if (event.deltaY > 0 && scrollTop + clientHeight < scrollHeight) {
+ allowScroll = true;
+ break;
+ }
+ }
+
+ if (!element || element === contentContainer) {
+ break;
+ }
+
+ element = element.parentNode as HTMLElement;
+ }
+
+ if (!allowScroll) {
+ event.preventDefault();
+ }
+ },
+ { passive: false },
+ );
+
+ let content: HTMLElement;
+ if (element === null) {
+ if (typeof html === "string") {
+ content = document.createElement("div");
+ content.id = id;
+ DomUtil.setInnerHtml(content, html);
+ } else if (html instanceof DocumentFragment) {
+ const children: HTMLElement[] = [];
+ let node: Node;
+ for (let i = 0, length = html.childNodes.length; i < length; i++) {
+ node = html.childNodes[i];
+
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ children.push(node as HTMLElement);
+ }
+ }
+
+ if (children[0].nodeName !== "DIV" || children.length > 1) {
+ content = document.createElement("div");
+ content.id = id;
+ content.appendChild(html);
+ } else {
+ content = children[0];
+ }
+ } else {
+ throw new TypeError("'html' must either be a string or a DocumentFragment");
+ }
+ } else {
+ content = element;
+ }
+
+ contentContainer.appendChild(content);
+
+ if (content.style.getPropertyValue("display") === "none") {
+ DomUtil.show(content);
+ }
+
+ _dialogs.set(id, {
+ backdropCloseOnClick: options.backdropCloseOnClick,
+ closable: options.closable,
+ content: content,
+ dialog: dialog,
+ header: header,
+ onBeforeClose: options.onBeforeClose!,
+ onClose: options.onClose!,
+ onShow: options.onShow!,
+
+ submitButton: null,
+ inputFields: new Set<HTMLInputElement>(),
+ });
+
+ _container.insertBefore(dialog, _container.firstChild);
+
+ if (typeof options.onSetup === "function") {
+ options.onSetup(content);
+ }
+
+ this._updateDialog(id, null);
+ },
+
+ /**
+ * Updates the dialog's content element.
+ */
+ _updateDialog(id: ElementId, html: string | null): void {
+ const data = _dialogs.get(id);
+ if (data === undefined) {
+ throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+ }
+
+ if (typeof html === "string") {
+ DomUtil.setInnerHtml(data.content, html);
+ }
+
+ if (Core.stringToBool(data.dialog.getAttribute("aria-hidden"))) {
+ // close existing dropdowns
+ UiDropdownSimple.closeAll();
+ window.WCF.Dropdown.Interactive.Handler.closeAll();
+
+ if (_callbackFocus === null) {
+ _callbackFocus = this._maintainFocus.bind(this);
+ document.body.addEventListener("focus", _callbackFocus, { capture: true });
+ }
+
+ if (data.closable && Core.stringToBool(_container.getAttribute("aria-hidden"))) {
+ window.addEventListener("keyup", _keyupListener);
+ }
+
+ // Move the dialog to the front to prevent it being hidden behind already open dialogs
+ // if it was previously visible.
+ data.dialog.parentNode!.insertBefore(data.dialog, data.dialog.parentNode!.firstChild);
+
+ data.dialog.setAttribute("aria-hidden", "false");
+ _container.setAttribute("aria-hidden", "false");
+ _container.setAttribute("close-on-click", data.backdropCloseOnClick ? "true" : "false");
+ _activeDialog = id;
+
+ // Set the focus to the first focusable child of the dialog element.
+ const closeButton = data.header.querySelector(".dialogCloseButton");
+ if (closeButton) closeButton.setAttribute("inert", "true");
+ this._setFocusToFirstItem(data.dialog, false);
+ if (closeButton) closeButton.removeAttribute("inert");
+
+ if (typeof data.onShow === "function") {
+ data.onShow(data.content);
+ }
+
+ if (Core.stringToBool(data.content.dataset.isStaticDialog || "")) {
+ EventHandler.fire("com.woltlab.wcf.dialog", "openStatic", {
+ content: data.content,
+ id: id,
+ });
+ }
+ }
+
+ this.rebuild(id);
+
+ DomChangeListener.trigger();
+ },
+
+ _maintainFocus(event: FocusEvent): void {
+ if (_activeDialog) {
+ const data = _dialogs.get(_activeDialog) as DialogData;
+ const target = event.target as HTMLElement;
+ if (
+ !data.dialog.contains(target) &&
+ !target.closest(".dropdownMenuContainer") &&
+ !target.closest(".datePicker")
+ ) {
+ this._setFocusToFirstItem(data.dialog, true);
+ }
+ }
+ },
+
+ _setFocusToFirstItem(dialog: HTMLElement, maintain: boolean): void {
+ let focusElement = this._getFirstFocusableChild(dialog);
+ if (focusElement !== null) {
+ if (maintain) {
+ if (focusElement.id === "username" || (focusElement as HTMLInputElement).name === "username") {
+ if (Environment.browser() === "safari" && Environment.platform() === "ios") {
+ // iOS Safari's username/password autofill breaks if the input field is focused
+ focusElement = null;
+ }
+ }
+ }
+
+ if (focusElement) {
+ // Setting the focus to a select element in iOS is pretty strange, because
+ // it focuses it, but also displays the keyboard for a fraction of a second,
+ // causing it to pop out from below and immediately vanish.
+ //
+ // iOS will only show the keyboard if an input element is focused *and* the
+ // focus is an immediate result of a user interaction. This method must be
+ // assumed to be called from within a click event, but we want to set the
+ // focus without triggering the keyboard.
+ //
+ // We can break the condition by wrapping it in a setTimeout() call,
+ // effectively tricking iOS into focusing the element without showing the
+ // keyboard.
+ setTimeout(() => {
+ focusElement!.focus();
+ }, 1);
+ }
+ }
+ },
+
+ _getFirstFocusableChild(element: HTMLElement): HTMLElement | null {
+ const nodeList = element.querySelectorAll<HTMLElement>(_focusableElements.join(","));
+ for (let i = 0, length = nodeList.length; i < length; i++) {
+ if (nodeList[i].offsetWidth && nodeList[i].offsetHeight && nodeList[i].getClientRects().length) {
+ return nodeList[i];
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Rebuilds dialog identified by given id.
+ */
+ rebuild(elementId: ElementIdOrCallbackObject): void {
+ const id = this._getDialogId(elementId);
+
+ const data = _dialogs.get(id);
+ if (data === undefined) {
+ throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+ }
+
+ // ignore non-active dialogs
+ if (Core.stringToBool(data.dialog.getAttribute("aria-hidden"))) {
+ return;
+ }
+
+ const contentContainer = data.content.parentNode as HTMLElement;
+
+ const formSubmit = data.content.querySelector(".formSubmit") as HTMLElement;
+ let unavailableHeight = 0;
+ if (formSubmit !== null) {
+ contentContainer.classList.add("dialogForm");
+ formSubmit.classList.add("dialogFormSubmit");
+
+ unavailableHeight += DomUtil.outerHeight(formSubmit);
+
+ // Calculated height can be a fractional value and depending on the
+ // browser the results can vary. By subtracting a single pixel we're
+ // working around fractional values, without visually changing anything.
+ unavailableHeight -= 1;
+
+ contentContainer.style.setProperty("margin-bottom", `${unavailableHeight}px`, "");
+ } else {
+ contentContainer.classList.remove("dialogForm");
+ contentContainer.style.removeProperty("margin-bottom");
+ }
+
+ unavailableHeight += DomUtil.outerHeight(data.header);
+
+ const maximumHeight = window.innerHeight * (_dialogFullHeight ? 1 : 0.8) - unavailableHeight;
+ contentContainer.style.setProperty("max-height", `${~~maximumHeight}px`, "");
+
+ // fix for a calculation bug in Chrome causing the scrollbar to overlap the border
+ if (Environment.browser() === "chrome") {
+ if (data.content.scrollHeight > maximumHeight) {
+ data.content.style.setProperty("margin-right", "-1px", "");
+ } else {
+ data.content.style.removeProperty("margin-right");
+ }
+ }
+
+ // Chrome and Safari use heavy anti-aliasing when the dialog's width
+ // cannot be evenly divided, causing the whole text to become blurry
+ if (Environment.browser() === "chrome" || Environment.browser() === "safari") {
+ // The new Microsoft Edge is detected as "chrome", because effectively we're detecting
+ // Chromium rather than Chrome specifically. The workaround for fractional pixels does
+ // not work well in Edge, there seems to be a different logic for fractional positions,
+ // causing the text to be blurry.
+ //
+ // We can use `backface-visibility: hidden` to prevent the anti aliasing artifacts in
+ // WebKit/Blink, which will also prevent some weird font rendering issues when resizing.
+ contentContainer.classList.add("jsWebKitFractionalPixelFix");
+ }
+
+ const callbackObject = _dialogToObject.get(id);
+ //noinspection JSUnresolvedVariable
+ if (callbackObject !== undefined && typeof callbackObject._dialogSubmit === "function") {
+ const inputFields = data.content.querySelectorAll<HTMLInputElement>('input[data-dialog-submit-on-enter="true"]');
+
+ const submitButton = data.content.querySelector(
+ '.formSubmit > input[type="submit"], .formSubmit > button[data-type="submit"]',
+ );
+ if (submitButton === null) {
+ // check if there is at least one input field with submit handling,
+ // otherwise we'll assume the dialog has not been populated yet
+ if (inputFields.length === 0) {
+ console.warn("Broken dialog, expected a submit button.", data.content);
+ }
+
+ return;
+ }
+
+ if (data.submitButton !== submitButton) {
+ data.submitButton = submitButton as HTMLElement;
+
+ submitButton.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ this._submit(id);
+ });
+
+ const _callbackKeydown = (event: KeyboardEvent): void => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+
+ this._submit(id);
+ }
+ };
+
+ // bind input fields
+ let inputField: HTMLInputElement;
+ for (let i = 0, length = inputFields.length; i < length; i++) {
+ inputField = inputFields[i];
+
+ if (data.inputFields.has(inputField)) continue;
+
+ if (_validInputTypes.indexOf(inputField.type) === -1) {
+ console.warn("Unsupported input type.", inputField);
+ continue;
+ }
+
+ data.inputFields.add(inputField);
+
+ inputField.addEventListener("keydown", _callbackKeydown);
+ }
+ }
+ }
+ },
+
+ /**
+ * Submits the dialog with the given id.
+ */
+ _submit(id: string): void {
+ const data = _dialogs.get(id);
+
+ let isValid = true;
+ data!.inputFields.forEach((inputField) => {
+ if (inputField.required) {
+ if (inputField.value.trim() === "") {
+ DomUtil.innerError(inputField, Language.get("wcf.global.form.error.empty"));
+
+ isValid = false;
+ } else {
+ DomUtil.innerError(inputField, false);
+ }
+ }
+ });
+
+ if (isValid) {
+ const callbackObject = _dialogToObject.get(id) as DialogCallbackObject;
+ if (typeof callbackObject._dialogSubmit === "function") {
+ callbackObject._dialogSubmit();
+ }
+ }
+ },
+
+ /**
+ * Submits the dialog with the given id.
+ */
+ submit(id: string): void {
+ this._submit(id);
+ },
+
+ /**
+ * Handles clicks on the close button or the backdrop if enabled.
+ */
+ _close(event: MouseEvent): boolean {
+ event.preventDefault();
+
+ const data = _dialogs.get(_activeDialog!) as DialogData;
+ if (typeof data.onBeforeClose === "function") {
+ data.onBeforeClose(_activeDialog!);
+
+ return false;
+ }
+
+ this.close(_activeDialog!);
+
+ return true;
+ },
+
+ /**
+ * Closes the current active dialog by clicks on the backdrop.
+ */
+ _closeOnBackdrop(event: MouseEvent): void {
+ if (event.target !== _container) {
+ return;
+ }
+
+ if (Core.stringToBool(_container.getAttribute("close-on-click"))) {
+ this._close(event);
+ } else {
+ event.preventDefault();
+ }
+ },
+
+ /**
+ * Closes a dialog identified by given id.
+ */
+ close(id: ElementIdOrCallbackObject): void {
+ id = this._getDialogId(id);
+
+ let data = _dialogs.get(id);
+ if (data === undefined) {
+ throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
+ }
+
+ data.dialog.setAttribute("aria-hidden", "true");
+
+ // Move the keyboard focus away from a now hidden element.
+ const activeElement = document.activeElement as HTMLElement;
+ if (activeElement.closest(".dialogContainer") === data.dialog) {
+ activeElement.blur();
+ }
+
+ if (typeof data.onClose === "function") {
+ data.onClose(id);
+ }
+
+ // get next active dialog
+ _activeDialog = null;
+ for (let i = 0; i < _container.childElementCount; i++) {
+ const child = _container.children[i] as HTMLElement;
+ if (!Core.stringToBool(child.getAttribute("aria-hidden"))) {
+ _activeDialog = child.dataset.id || "";
+ break;
+ }
+ }
+
+ UiScreen.pageOverlayClose();
+
+ if (_activeDialog === null) {
+ _container.setAttribute("aria-hidden", "true");
+ _container.dataset.closeOnClick = "false";
+
+ if (data.closable) {
+ window.removeEventListener("keyup", _keyupListener);
+ }
+ } else {
+ data = _dialogs.get(_activeDialog) as DialogData;
+ _container.dataset.closeOnClick = data.backdropCloseOnClick ? "true" : "false";
+ }
+
+ if (Environment.platform() !== "desktop") {
+ UiScreen.scrollEnable();
+ }
+ },
+
+ /**
+ * Returns the dialog data for given element id.
+ */
+ getDialog(id: ElementIdOrCallbackObject): DialogData | undefined {
+ return _dialogs.get(this._getDialogId(id));
+ },
+
+ /**
+ * Returns true for open dialogs.
+ */
+ isOpen(id: ElementIdOrCallbackObject): boolean {
+ const data = this.getDialog(id);
+ return data !== undefined && data.dialog.getAttribute("aria-hidden") === "false";
+ },
+
+ /**
+ * Destroys a dialog instance.
+ *
+ * @param {Object} callbackObject the same object that was used to invoke `_dialogSetup()` on first call
+ */
+ destroy(callbackObject: DialogCallbackObject): void {
+ if (typeof callbackObject !== "object") {
+ throw new TypeError("Expected the callback object as parameter.");
+ }
+
+ if (_dialogObjects.has(callbackObject)) {
+ const id = _dialogObjects.get(callbackObject)!.id;
+ if (this.isOpen(id)) {
+ this.close(id);
+ }
+
+ // If the dialog is destroyed in the close callback, this method is
+ // called twice resulting in `_dialogs.get(id)` being undefined for
+ // the initial call.
+ if (_dialogs.has(id)) {
+ _dialogs.get(id)!.dialog.remove();
+ _dialogs.delete(id);
+ }
+ _dialogObjects.delete(callbackObject);
+ }
+ },
+
+ /**
+ * Returns a dialog's id.
+ *
+ * @param {(string|object)} id element id or callback object
+ * @return {string}
+ * @protected
+ */
+ _getDialogId(id: ElementIdOrCallbackObject): DialogId {
+ if (typeof id === "object") {
+ const dialogData = _dialogObjects.get(id);
+ if (dialogData !== undefined) {
+ return dialogData.id;
+ }
+ }
+
+ return id.toString();
+ },
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {};
+ },
+};
+
+export = UiDialog;
+
+interface DialogInternalData {
+ id: string;
+}
+
+type ElementId = string;
+
+type ElementIdOrCallbackObject = DialogCallbackObject | ElementId;
+
+interface InternalDialogOptions extends DialogOptions {
+ backdropCloseOnClick: boolean;
+ closable: boolean;
+ closeButtonLabel: string;
+ closeConfirmMessage: string;
+ disableContentPadding: boolean;
+}
--- /dev/null
+import { RequestPayload, ResponseData } from "../../Ajax/Data";
+
+export type DialogHtml = DocumentFragment | string | null;
+
+export type DialogCallbackSetup = () => DialogSettings;
+export type CallbackSubmit = () => void;
+
+export interface DialogCallbackObject {
+ _dialogSetup: DialogCallbackSetup;
+ _dialogSubmit?: CallbackSubmit;
+}
+
+export interface AjaxInitialization extends RequestPayload {
+ after?: (content: HTMLElement, responseData: ResponseData) => void;
+}
+
+export type ExternalInitialization = () => void;
+
+export type DialogId = string;
+
+export interface DialogSettings {
+ id: DialogId;
+ source?: AjaxInitialization | DocumentFragment | ExternalInitialization | string | null;
+ options?: DialogOptions;
+}
+
+type CallbackOnBeforeClose = (id: string) => void;
+type CallbackOnClose = (id: string) => void;
+type CallbackOnSetup = (content: HTMLElement) => void;
+type CallbackOnShow = (content: HTMLElement) => void;
+
+export interface DialogOptions {
+ backdropCloseOnClick?: boolean;
+ closable?: boolean;
+ closeButtonLabel?: string;
+ closeConfirmMessage?: string;
+ disableContentPadding?: boolean;
+ title?: string;
+
+ onBeforeClose?: CallbackOnBeforeClose | null;
+ onClose?: CallbackOnClose | null;
+ onSetup?: CallbackOnSetup | null;
+ onShow?: CallbackOnShow | null;
+}
+
+export interface DialogData {
+ backdropCloseOnClick: boolean;
+ closable: boolean;
+ content: HTMLElement;
+ dialog: HTMLElement;
+ header: HTMLElement;
+
+ onBeforeClose: CallbackOnBeforeClose;
+ onClose: CallbackOnClose;
+ onShow: CallbackOnShow;
+
+ submitButton: HTMLElement | null;
+ inputFields: Set<HTMLInputElement>;
+}
--- /dev/null
+/**
+ * Generic interface for drag and Drop file uploads.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/DragAndDrop
+ */
+
+import * as Core from "../Core";
+import * as EventHandler from "../Event/Handler";
+import { init, OnDropPayload, OnGlobalDropPayload, RedactorEditorLike } from "./Redactor/DragAndDrop";
+
+interface DragAndDropOptions {
+ element: HTMLElement;
+ elementId: string;
+ onDrop: (data: OnDropPayload) => void;
+ onGlobalDrop: (data: OnGlobalDropPayload) => void;
+}
+
+export function register(options: DragAndDropOptions): void {
+ const uuid = Core.getUuid();
+ options = Core.extend({
+ element: null,
+ elementId: "",
+ onDrop: function (_data: OnDropPayload) {
+ /* data: { file: File } */
+ },
+ onGlobalDrop: function (_data: OnGlobalDropPayload) {
+ /* data: { cancelDrop: boolean, event: DragEvent } */
+ },
+ }) as DragAndDropOptions;
+
+ EventHandler.add("com.woltlab.wcf.redactor2", `dragAndDrop_${options.elementId}`, options.onDrop);
+ EventHandler.add("com.woltlab.wcf.redactor2", `dragAndDrop_globalDrop_${options.elementId}`, options.onGlobalDrop);
+
+ init({
+ uuid: uuid,
+ $editor: [options.element],
+ $element: [{ id: options.elementId }],
+ } as RedactorEditorLike);
+}
--- /dev/null
+/**
+ * Simplified and consistent dropdown creation.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Dropdown/Builder
+ */
+
+import * as Core from "../../Core";
+import UiDropdownSimple from "./Simple";
+
+const _validIconSizes = [16, 24, 32, 48, 64, 96, 144];
+
+function validateList(list: HTMLUListElement): void {
+ if (!(list instanceof HTMLUListElement)) {
+ throw new TypeError("Expected a reference to an <ul> element.");
+ }
+
+ if (!list.classList.contains("dropdownMenu")) {
+ throw new Error("List does not appear to be a dropdown menu.");
+ }
+}
+
+function buildItemFromData(data: DropdownBuilderItemData): HTMLLIElement {
+ const item = document.createElement("li");
+
+ // handle special `divider` type
+ if (data === "divider") {
+ item.className = "dropdownDivider";
+ return item;
+ }
+
+ if (typeof data.identifier === "string") {
+ item.dataset.identifier = data.identifier;
+ }
+
+ const link = document.createElement("a");
+ link.href = typeof data.href === "string" ? data.href : "#";
+ if (typeof data.callback === "function") {
+ link.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ data.callback!(link);
+ });
+ } else if (link.href === "#") {
+ throw new Error("Expected either a `href` value or a `callback`.");
+ }
+
+ if (data.attributes && Core.isPlainObject(data.attributes)) {
+ Object.keys(data.attributes).forEach((key) => {
+ const value = data.attributes![key];
+ if (typeof (value as any) !== "string") {
+ throw new Error("Expected only string values.");
+ }
+
+ // Support the dash notation for backwards compatibility.
+ if (key.indexOf("-") !== -1) {
+ link.setAttribute(`data-${key}`, value);
+ } else {
+ link.dataset[key] = value;
+ }
+ });
+ }
+
+ item.appendChild(link);
+
+ if (typeof data.icon !== "undefined" && Core.isPlainObject(data.icon)) {
+ if (typeof (data.icon.name as any) !== "string") {
+ throw new TypeError("Expected a valid icon name.");
+ }
+
+ let size = 16;
+ if (typeof data.icon.size === "number" && _validIconSizes.indexOf(~~data.icon.size) !== -1) {
+ size = ~~data.icon.size;
+ }
+
+ const icon = document.createElement("span");
+ icon.className = `icon icon${size} fa-${data.icon.name}`;
+
+ link.appendChild(icon);
+ }
+
+ const label = typeof (data.label as any) === "string" ? data.label!.trim() : "";
+ const labelHtml = typeof (data.labelHtml as any) === "string" ? data.labelHtml!.trim() : "";
+ if (label === "" && labelHtml === "") {
+ throw new TypeError("Expected either a label or a `labelHtml`.");
+ }
+
+ const span = document.createElement("span");
+ span[label ? "textContent" : "innerHTML"] = label ? label : labelHtml;
+ link.appendChild(document.createTextNode(" "));
+ link.appendChild(span);
+
+ return item;
+}
+
+/**
+ * Creates a new dropdown menu, optionally pre-populated with the supplied list of
+ * dropdown items. The list element will be returned and must be manually injected
+ * into the DOM by the callee.
+ */
+export function create(items: DropdownBuilderItemData[], identifier?: string): HTMLUListElement {
+ const list = document.createElement("ul");
+ list.className = "dropdownMenu";
+ if (typeof identifier === "string") {
+ list.dataset.identifier = identifier;
+ }
+
+ if (Array.isArray(items) && items.length > 0) {
+ appendItems(list, items);
+ }
+
+ return list;
+}
+
+/**
+ * Creates a new dropdown item that can be inserted into lists using regular DOM operations.
+ */
+export function buildItem(item: DropdownBuilderItemData): HTMLLIElement {
+ return buildItemFromData(item);
+}
+
+/**
+ * Appends a single item to the target list.
+ */
+export function appendItem(list: HTMLUListElement, item: DropdownBuilderItemData): void {
+ validateList(list);
+
+ list.appendChild(buildItemFromData(item));
+}
+
+/**
+ * Appends a list of items to the target list.
+ */
+export function appendItems(list: HTMLUListElement, items: DropdownBuilderItemData[]): void {
+ validateList(list);
+
+ if (!Array.isArray(items)) {
+ throw new TypeError("Expected an array of items.");
+ }
+
+ const length = items.length;
+ if (length === 0) {
+ throw new Error("Expected a non-empty list of items.");
+ }
+
+ if (length === 1) {
+ appendItem(list, items[0]);
+ } else {
+ const fragment = document.createDocumentFragment();
+ items.forEach((item) => {
+ fragment.appendChild(buildItemFromData(item));
+ });
+ list.appendChild(fragment);
+ }
+}
+
+/**
+ * Replaces the existing list items with the provided list of new items.
+ */
+export function setItems(list: HTMLUListElement, items: DropdownBuilderItemData[]): void {
+ validateList(list);
+
+ list.innerHTML = "";
+
+ appendItems(list, items);
+}
+
+/**
+ * Attaches the list to a button, visibility is from then on controlled through clicks
+ * on the provided button element. Internally calls `Ui/SimpleDropdown.initFragment()`
+ * to delegate the DOM management.
+ */
+export function attach(list: HTMLUListElement, button: HTMLElement): void {
+ validateList(list);
+
+ UiDropdownSimple.initFragment(button, list);
+
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ UiDropdownSimple.toggleDropdown(button.id);
+ });
+}
+
+/**
+ * Helper method that returns the special string `"divider"` that causes a divider to
+ * be created.
+ */
+export function divider(): string {
+ return "divider";
+}
+
+interface BaseItemData {
+ attributes?: {
+ [key: string]: string;
+ };
+ callback?: (link: HTMLAnchorElement) => void;
+ href?: string;
+ icon?: {
+ name: string;
+ size?: 16 | 24 | 32 | 48 | 64 | 96 | 144;
+ };
+ identifier?: string;
+ label?: string;
+ labelHtml?: string;
+}
+
+interface TextItemData extends BaseItemData {
+ label: string;
+ labelHtml?: never;
+}
+
+interface HtmlItemData extends BaseItemData {
+ label?: never;
+ labelHtml: string;
+}
+
+export type DropdownBuilderItemData = "divider" | HtmlItemData | TextItemData;
--- /dev/null
+export type NotificationAction = "close" | "open";
+export type NotificationCallback = (containerId: string, action: NotificationAction) => void;
--- /dev/null
+/**
+ * Simple interface to work with reusable dropdowns that are not bound to a specific item.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Ui/ReusableDropdown (alias)
+ * @module WoltLabSuite/Core/Ui/Dropdown/Reusable
+ */
+
+import UiDropdownSimple from "./Simple";
+import { NotificationCallback } from "./Data";
+
+const _dropdowns = new Map<string, string>();
+let _ghostElementId = 0;
+
+/**
+ * Returns dropdown name by internal identifier.
+ */
+function getDropdownName(identifier: string): string {
+ if (!_dropdowns.has(identifier)) {
+ throw new Error("Unknown dropdown identifier '" + identifier + "'");
+ }
+
+ return _dropdowns.get(identifier)!;
+}
+
+/**
+ * Initializes a new reusable dropdown.
+ */
+export function init(identifier: string, menu: HTMLElement): void {
+ if (_dropdowns.has(identifier)) {
+ return;
+ }
+
+ const ghostElement = document.createElement("div");
+ ghostElement.id = `reusableDropdownGhost${_ghostElementId++}`;
+
+ UiDropdownSimple.initFragment(ghostElement, menu);
+
+ _dropdowns.set(identifier, ghostElement.id);
+}
+
+/**
+ * Returns the dropdown menu element.
+ */
+export function getDropdownMenu(identifier: string): HTMLElement {
+ return UiDropdownSimple.getDropdownMenu(getDropdownName(identifier))!;
+}
+
+/**
+ * Registers a callback invoked upon open and close.
+ */
+export function registerCallback(identifier: string, callback: NotificationCallback): void {
+ UiDropdownSimple.registerCallback(getDropdownName(identifier), callback);
+}
+
+/**
+ * Toggles a dropdown.
+ */
+export function toggleDropdown(identifier: string, referenceElement: HTMLElement): void {
+ UiDropdownSimple.toggleDropdown(getDropdownName(identifier), referenceElement);
+}
--- /dev/null
+/**
+ * Simple drop-down implementation.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Ui/SimpleDropdown (alias)
+ * @module WoltLabSuite/Core/Ui/Dropdown/Simple
+ */
+
+import CallbackList from "../../CallbackList";
+import * as Core from "../../Core";
+import DomChangeListener from "../../Dom/Change/Listener";
+import * as DomTraverse from "../../Dom/Traverse";
+import DomUtil from "../../Dom/Util";
+import * as UiAlignment from "../Alignment";
+import UiCloseOverlay from "../CloseOverlay";
+import { AllowFlip } from "../Alignment";
+import { NotificationAction, NotificationCallback } from "./Data";
+
+let _availableDropdowns: HTMLCollectionOf<HTMLElement>;
+const _callbacks = new CallbackList();
+let _didInit = false;
+const _dropdowns = new Map<string, HTMLElement>();
+const _menus = new Map<string, HTMLElement>();
+let _menuContainer: HTMLElement;
+let _activeTargetId = "";
+
+/**
+ * Handles drop-down positions in overlays when scrolling in the overlay.
+ */
+function onDialogScroll(event: WheelEvent): void {
+ const dialogContent = event.currentTarget as HTMLElement;
+ const dropdowns = dialogContent.querySelectorAll(".dropdown.dropdownOpen");
+
+ for (let i = 0, length = dropdowns.length; i < length; i++) {
+ const dropdown = dropdowns[i];
+ const containerId = DomUtil.identify(dropdown);
+ const offset = DomUtil.offset(dropdown);
+ const dialogOffset = DomUtil.offset(dialogContent);
+
+ // check if dropdown toggle is still (partially) visible
+ if (offset.top + dropdown.clientHeight <= dialogOffset.top) {
+ // top check
+ UiDropdownSimple.toggleDropdown(containerId);
+ } else if (offset.top >= dialogOffset.top + dialogContent.offsetHeight) {
+ // bottom check
+ UiDropdownSimple.toggleDropdown(containerId);
+ } else if (offset.left <= dialogOffset.left) {
+ // left check
+ UiDropdownSimple.toggleDropdown(containerId);
+ } else if (offset.left >= dialogOffset.left + dialogContent.offsetWidth) {
+ // right check
+ UiDropdownSimple.toggleDropdown(containerId);
+ } else {
+ UiDropdownSimple.setAlignment(_dropdowns.get(containerId)!, _menus.get(containerId)!);
+ }
+ }
+}
+
+/**
+ * Recalculates drop-down positions on page scroll.
+ */
+function onScroll() {
+ _dropdowns.forEach((dropdown, containerId) => {
+ if (dropdown.classList.contains("dropdownOpen")) {
+ if (Core.stringToBool(dropdown.dataset.isOverlayDropdownButton || "")) {
+ UiDropdownSimple.setAlignment(dropdown, _menus.get(containerId)!);
+ } else {
+ const menu = _menus.get(dropdown.id) as HTMLElement;
+ if (!Core.stringToBool(menu.dataset.dropdownIgnorePageScroll || "")) {
+ UiDropdownSimple.close(containerId);
+ }
+ }
+ }
+ });
+}
+
+/**
+ * Notifies callbacks on status change.
+ */
+function notifyCallbacks(containerId: string, action: NotificationAction): void {
+ _callbacks.forEach(containerId, (callback) => {
+ callback(containerId, action);
+ });
+}
+
+/**
+ * Toggles the drop-down's state between open and close.
+ */
+function toggle(
+ event: KeyboardEvent | MouseEvent | null,
+ targetId?: string,
+ alternateElement?: HTMLElement,
+ disableAutoFocus?: boolean,
+): boolean {
+ if (event !== null) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const target = event.currentTarget as HTMLElement;
+ targetId = target.dataset.target;
+
+ if (disableAutoFocus === undefined && event instanceof MouseEvent) {
+ disableAutoFocus = true;
+ }
+ }
+
+ let dropdown = _dropdowns.get(targetId!) as HTMLElement;
+ let preventToggle = false;
+ if (dropdown !== undefined) {
+ let button, parent;
+
+ // check if the dropdown is still the same, as some components (e.g. page actions)
+ // re-create the parent of a button
+ if (event) {
+ button = event.currentTarget;
+ parent = button.parentNode;
+ if (parent !== dropdown) {
+ parent.classList.add("dropdown");
+ parent.id = dropdown.id;
+
+ // remove dropdown class and id from old parent
+ dropdown.classList.remove("dropdown");
+ dropdown.id = "";
+
+ dropdown = parent;
+ _dropdowns.set(targetId!, parent);
+ }
+ }
+
+ if (disableAutoFocus === undefined) {
+ button = dropdown.closest(".dropdownToggle");
+ if (!button) {
+ button = dropdown.querySelector(".dropdownToggle");
+
+ if (!button && dropdown.id) {
+ button = document.querySelector('[data-target="' + dropdown.id + '"]');
+ }
+ }
+
+ if (button && Core.stringToBool(button.dataset.dropdownLazyInit || "")) {
+ disableAutoFocus = true;
+ }
+ }
+
+ // Repeated clicks on the dropdown button will not cause it to close, the only way
+ // to close it is by clicking somewhere else in the document or on another dropdown
+ // toggle. This is used with the search bar to prevent the dropdown from closing by
+ // setting the caret position in the search input field.
+ if (
+ Core.stringToBool(dropdown.dataset.dropdownPreventToggle || "") &&
+ dropdown.classList.contains("dropdownOpen")
+ ) {
+ preventToggle = true;
+ }
+
+ // check if 'isOverlayDropdownButton' is set which indicates that the dropdown toggle is within an overlay
+ if (dropdown.dataset.isOverlayDropdownButton === "") {
+ const dialogContent = DomTraverse.parentByClass(dropdown, "dialogContent");
+ dropdown.dataset.isOverlayDropdownButton = dialogContent !== null ? "true" : "false";
+
+ if (dialogContent !== null) {
+ dialogContent.addEventListener("scroll", onDialogScroll);
+ }
+ }
+ }
+
+ // close all dropdowns
+ _activeTargetId = "";
+ _dropdowns.forEach((dropdown, containerId) => {
+ const menu = _menus.get(containerId) as HTMLElement;
+ let firstListItem: HTMLLIElement | null = null;
+
+ if (dropdown.classList.contains("dropdownOpen")) {
+ if (!preventToggle) {
+ dropdown.classList.remove("dropdownOpen");
+ menu.classList.remove("dropdownOpen");
+
+ const button = dropdown.querySelector(".dropdownToggle");
+ if (button) button.setAttribute("aria-expanded", "false");
+
+ notifyCallbacks(containerId, "close");
+ } else {
+ _activeTargetId = targetId!;
+ }
+ } else if (containerId === targetId && menu.childElementCount > 0) {
+ _activeTargetId = targetId;
+ dropdown.classList.add("dropdownOpen");
+ menu.classList.add("dropdownOpen");
+
+ const button = dropdown.querySelector(".dropdownToggle");
+ if (button) button.setAttribute("aria-expanded", "true");
+
+ const list: HTMLElement | null = menu.childElementCount > 0 ? (menu.children[0] as HTMLElement) : null;
+ if (list && Core.stringToBool(list.dataset.scrollToActive || "")) {
+ delete list.dataset.scrollToActive;
+
+ let active: HTMLElement | null = null;
+ for (let i = 0, length = list.childElementCount; i < length; i++) {
+ if (list.children[i].classList.contains("active")) {
+ active = list.children[i] as HTMLElement;
+ break;
+ }
+ }
+
+ if (active) {
+ list.scrollTop = Math.max(active.offsetTop + active.clientHeight - menu.clientHeight, 0);
+ }
+ }
+
+ const itemList = menu.querySelector(".scrollableDropdownMenu");
+ if (itemList !== null) {
+ itemList.classList[itemList.scrollHeight > itemList.clientHeight ? "add" : "remove"]("forceScrollbar");
+ }
+
+ notifyCallbacks(containerId, "open");
+
+ if (!disableAutoFocus) {
+ menu.setAttribute("role", "menu");
+ menu.tabIndex = -1;
+ menu.removeEventListener("keydown", dropdownMenuKeyDown);
+ menu.addEventListener("keydown", dropdownMenuKeyDown);
+ menu.querySelectorAll("li").forEach((listItem) => {
+ if (!listItem.clientHeight) return;
+ if (firstListItem === null) firstListItem = listItem;
+ else if (listItem.classList.contains("active")) firstListItem = listItem;
+
+ listItem.setAttribute("role", "menuitem");
+ listItem.tabIndex = -1;
+ });
+ }
+
+ UiDropdownSimple.setAlignment(dropdown, menu, alternateElement);
+
+ if (firstListItem !== null) {
+ firstListItem.focus();
+ }
+ }
+ });
+
+ window.WCF.Dropdown.Interactive.Handler.closeAll();
+
+ return event === null;
+}
+
+function handleKeyDown(event: KeyboardEvent): void {
+ // <input> elements are not valid targets for drop-down menus. However, some developers
+ // might still decide to combine them, in which case we try not to break things even more.
+ const target = event.currentTarget as HTMLElement;
+ if (target.nodeName === "INPUT") {
+ return;
+ }
+
+ if (event.key === "Enter" || event.key === "Space") {
+ event.preventDefault();
+ toggle(event);
+ }
+}
+
+function dropdownMenuKeyDown(event: KeyboardEvent): void {
+ const activeItem = document.activeElement as HTMLElement;
+ if (activeItem.nodeName !== "LI") {
+ return;
+ }
+
+ if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "End" || event.key === "Home") {
+ event.preventDefault();
+
+ const listItems: HTMLElement[] = Array.from(activeItem.closest(".dropdownMenu")!.querySelectorAll("li"));
+ if (event.key === "ArrowUp" || event.key === "End") {
+ listItems.reverse();
+ }
+
+ let newActiveItem: HTMLElement | null = null;
+ const isValidItem = (listItem) => {
+ return !listItem.classList.contains("dropdownDivider") && listItem.clientHeight > 0;
+ };
+
+ let activeIndex = listItems.indexOf(activeItem);
+ if (event.key === "End" || event.key === "Home") {
+ activeIndex = -1;
+ }
+
+ for (let i = activeIndex + 1; i < listItems.length; i++) {
+ if (isValidItem(listItems[i])) {
+ newActiveItem = listItems[i];
+ break;
+ }
+ }
+
+ if (newActiveItem === null) {
+ newActiveItem = listItems.find(isValidItem) || null;
+ }
+
+ if (newActiveItem !== null) {
+ newActiveItem.focus();
+ }
+ } else if (event.key === "Enter" || event.key === "Space") {
+ event.preventDefault();
+
+ let target = activeItem;
+ if (
+ target.childElementCount === 1 &&
+ (target.children[0].nodeName === "SPAN" || target.children[0].nodeName === "A")
+ ) {
+ target = target.children[0] as HTMLElement;
+ }
+
+ const dropdown = _dropdowns.get(_activeTargetId)!;
+ const button = dropdown.querySelector(".dropdownToggle") as HTMLElement;
+
+ const mouseEvent = dropdown.dataset.a11yMouseEvent || "click";
+ Core.triggerEvent(target, mouseEvent);
+
+ if (button) {
+ button.focus();
+ }
+ } else if (event.key === "Escape" || event.key === "Tab") {
+ event.preventDefault();
+
+ const dropdown = _dropdowns.get(_activeTargetId)!;
+ let button: HTMLElement | null = dropdown.querySelector(".dropdownToggle");
+
+ // Remote controlled drop-down menus may not have a dedicated toggle button, instead the
+ // `dropdown` element itself is the button.
+ if (button === null && !dropdown.classList.contains("dropdown")) {
+ button = dropdown;
+ }
+
+ toggle(null, _activeTargetId);
+ if (button) {
+ button.focus();
+ }
+ }
+}
+
+const UiDropdownSimple = {
+ /**
+ * Performs initial setup such as setting up dropdowns and binding listeners.
+ */
+ setup(): void {
+ if (_didInit) return;
+ _didInit = true;
+
+ _menuContainer = document.createElement("div");
+ _menuContainer.className = "dropdownMenuContainer";
+ document.body.appendChild(_menuContainer);
+
+ _availableDropdowns = document.getElementsByClassName("dropdownToggle") as HTMLCollectionOf<HTMLElement>;
+
+ UiDropdownSimple.initAll();
+
+ UiCloseOverlay.add("WoltLabSuite/Core/Ui/Dropdown/Simple", () => UiDropdownSimple.closeAll());
+ DomChangeListener.add("WoltLabSuite/Core/Ui/Dropdown/Simple", () => UiDropdownSimple.initAll());
+
+ document.addEventListener("scroll", onScroll);
+
+ // expose on window object for backward compatibility
+ window.bc_wcfSimpleDropdown = this;
+ },
+
+ /**
+ * Loops through all possible dropdowns and registers new ones.
+ */
+ initAll(): void {
+ for (let i = 0, length = _availableDropdowns.length; i < length; i++) {
+ UiDropdownSimple.init(_availableDropdowns[i], false);
+ }
+ },
+
+ /**
+ * Initializes a dropdown.
+ */
+ init(button: HTMLElement, isLazyInitialization?: boolean | MouseEvent): boolean {
+ UiDropdownSimple.setup();
+
+ button.setAttribute("role", "button");
+ button.tabIndex = 0;
+ button.setAttribute("aria-haspopup", "true");
+ button.setAttribute("aria-expanded", "false");
+
+ if (button.classList.contains("jsDropdownEnabled") || button.dataset.target) {
+ return false;
+ }
+
+ const dropdown = DomTraverse.parentByClass(button, "dropdown") as HTMLElement;
+ if (dropdown === null) {
+ throw new Error(
+ "Invalid dropdown passed, button '" + DomUtil.identify(button) + "' does not have a parent with .dropdown.",
+ );
+ }
+
+ const menu = DomTraverse.nextByClass(button, "dropdownMenu") as HTMLElement;
+ if (menu === null) {
+ throw new Error(
+ "Invalid dropdown passed, button '" + DomUtil.identify(button) + "' does not have a menu as next sibling.",
+ );
+ }
+
+ // move menu into global container
+ _menuContainer.appendChild(menu);
+
+ const containerId = DomUtil.identify(dropdown);
+ if (!_dropdowns.has(containerId)) {
+ button.classList.add("jsDropdownEnabled");
+ button.addEventListener("click", toggle);
+ button.addEventListener("keydown", handleKeyDown);
+
+ _dropdowns.set(containerId, dropdown);
+ _menus.set(containerId, menu);
+
+ if (!/^wcf\d+$/.test(containerId)) {
+ menu.dataset.source = containerId;
+ }
+
+ // prevent page scrolling
+ if (menu.childElementCount && menu.children[0].classList.contains("scrollableDropdownMenu")) {
+ const child = menu.children[0] as HTMLElement;
+ child.dataset.scrollToActive = "true";
+
+ let menuHeight: number | null = null;
+ let menuRealHeight: number | null = null;
+ child.addEventListener(
+ "wheel",
+ (event) => {
+ if (menuHeight === null) menuHeight = child.clientHeight;
+ if (menuRealHeight === null) menuRealHeight = child.scrollHeight;
+
+ // negative value: scrolling up
+ if (event.deltaY < 0 && child.scrollTop === 0) {
+ event.preventDefault();
+ } else if (event.deltaY > 0 && child.scrollTop + menuHeight === menuRealHeight) {
+ event.preventDefault();
+ }
+ },
+ { passive: false },
+ );
+ }
+ }
+
+ button.dataset.target = containerId;
+
+ if (isLazyInitialization) {
+ setTimeout(() => {
+ button.dataset.dropdownLazyInit = isLazyInitialization instanceof MouseEvent ? "true" : "false";
+
+ Core.triggerEvent(button, "click");
+
+ setTimeout(() => {
+ delete button.dataset.dropdownLazyInit;
+ }, 10);
+ }, 10);
+ }
+
+ return true;
+ },
+
+ /**
+ * Initializes a remote-controlled dropdown.
+ */
+ initFragment(dropdown: HTMLElement, menu: HTMLElement): void {
+ UiDropdownSimple.setup();
+
+ const containerId = DomUtil.identify(dropdown);
+ if (_dropdowns.has(containerId)) {
+ return;
+ }
+
+ _dropdowns.set(containerId, dropdown);
+ _menuContainer.appendChild(menu);
+
+ _menus.set(containerId, menu);
+ },
+
+ /**
+ * Registers a callback for open/close events.
+ */
+ registerCallback(containerId: string, callback: NotificationCallback): void {
+ _callbacks.add(containerId, callback);
+ },
+
+ /**
+ * Returns the requested dropdown wrapper element.
+ */
+ getDropdown(containerId: string): HTMLElement | undefined {
+ return _dropdowns.get(containerId);
+ },
+
+ /**
+ * Returns the requested dropdown menu list element.
+ */
+ getDropdownMenu(containerId: string): HTMLElement | undefined {
+ return _menus.get(containerId);
+ },
+
+ /**
+ * Toggles the requested dropdown between opened and closed.
+ */
+ toggleDropdown(containerId: string, referenceElement?: HTMLElement, disableAutoFocus?: boolean): void {
+ toggle(null, containerId, referenceElement, disableAutoFocus);
+ },
+
+ /**
+ * Calculates and sets the alignment of given dropdown.
+ */
+ setAlignment(dropdown: HTMLElement, dropdownMenu: HTMLElement, alternateElement?: HTMLElement): void {
+ // check if button belongs to an i18n textarea
+ const button = dropdown.querySelector(".dropdownToggle");
+ const parent = button !== null ? (button.parentNode as HTMLElement) : null;
+ let refDimensionsElement;
+ if (parent && parent.classList.contains("inputAddonTextarea")) {
+ refDimensionsElement = button;
+ }
+
+ UiAlignment.set(dropdownMenu, alternateElement || dropdown, {
+ pointerClassNames: ["dropdownArrowBottom", "dropdownArrowRight"],
+ refDimensionsElement: refDimensionsElement || null,
+
+ // alignment
+ horizontal: dropdownMenu.dataset.dropdownAlignmentHorizontal === "right" ? "right" : "left",
+ vertical: dropdownMenu.dataset.dropdownAlignmentVertical === "top" ? "top" : "bottom",
+
+ allowFlip: (dropdownMenu.dataset.dropdownAllowFlip as AllowFlip) || "both",
+ });
+ },
+
+ /**
+ * Calculates and sets the alignment of the dropdown identified by given id.
+ */
+ setAlignmentById(containerId: string): void {
+ const dropdown = _dropdowns.get(containerId);
+ if (dropdown === undefined) {
+ throw new Error("Unknown dropdown identifier '" + containerId + "'.");
+ }
+
+ const menu = _menus.get(containerId) as HTMLElement;
+
+ UiDropdownSimple.setAlignment(dropdown, menu);
+ },
+
+ /**
+ * Returns true if target dropdown exists and is open.
+ */
+ isOpen(containerId: string): boolean {
+ const menu = _menus.get(containerId);
+ return menu !== undefined && menu.classList.contains("dropdownOpen");
+ },
+
+ /**
+ * Opens the dropdown unless it is already open.
+ */
+ open(containerId: string, disableAutoFocus?: boolean): void {
+ const menu = _menus.get(containerId);
+ if (menu !== undefined && !menu.classList.contains("dropdownOpen")) {
+ UiDropdownSimple.toggleDropdown(containerId, undefined, disableAutoFocus);
+ }
+ },
+
+ /**
+ * Closes the dropdown identified by given id without notifying callbacks.
+ */
+ close(containerId: string): void {
+ const dropdown = _dropdowns.get(containerId);
+ if (dropdown !== undefined) {
+ dropdown.classList.remove("dropdownOpen");
+ _menus.get(containerId)!.classList.remove("dropdownOpen");
+ }
+ },
+
+ /**
+ * Closes all dropdowns.
+ */
+ closeAll(): void {
+ _dropdowns.forEach((dropdown, containerId) => {
+ if (dropdown.classList.contains("dropdownOpen")) {
+ dropdown.classList.remove("dropdownOpen");
+ _menus.get(containerId)!.classList.remove("dropdownOpen");
+
+ notifyCallbacks(containerId, "close");
+ }
+ });
+ },
+
+ /**
+ * Destroys a dropdown identified by given id.
+ */
+ destroy(containerId: string): boolean {
+ if (!_dropdowns.has(containerId)) {
+ return false;
+ }
+
+ try {
+ UiDropdownSimple.close(containerId);
+
+ _menus.get(containerId)?.remove();
+ } catch (e) {
+ // the elements might not exist anymore thus ignore all errors while cleaning up
+ }
+
+ _menus.delete(containerId);
+ _dropdowns.delete(containerId);
+
+ return true;
+ },
+
+ // Legacy call required for `WCF.Dropdown`
+ _toggle(
+ event: KeyboardEvent | MouseEvent | null,
+ targetId?: string,
+ alternateElement?: HTMLElement,
+ disableAutoFocus?: boolean,
+ ): boolean {
+ return toggle(event, targetId, alternateElement, disableAutoFocus);
+ },
+};
+
+export = UiDropdownSimple;
--- /dev/null
+// This helper interface exists to prevent a circular dependency
+// between `./Delete` and `./Upload`
+
+export interface FileUploadHandler {
+ checkMaxFiles(): void;
+}
--- /dev/null
+/**
+ * Delete files which are uploaded via AJAX.
+ *
+ * @author Joshua Ruesweg
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/File/Delete
+ * @since 5.2
+ */
+
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../Ajax/Data";
+import * as Core from "../../Core";
+import DomChangeListener from "../../Dom/Change/Listener";
+import * as Language from "../../Language";
+import { FileUploadHandler } from "./Data";
+
+interface AjaxResponse extends DatabaseObjectActionResponse {
+ uniqueFileId: string;
+}
+
+interface ElementData {
+ uniqueFileId: string;
+ element: HTMLElement;
+}
+
+class UiFileDelete implements AjaxCallbackObject {
+ private readonly buttonContainer: HTMLElement;
+ private readonly containers = new Map<string, ElementData>();
+ private deleteButton?: HTMLElement = undefined;
+ private readonly internalId: string;
+ private readonly isSingleImagePreview: boolean;
+ private readonly target: HTMLElement;
+ private readonly uploadHandler: FileUploadHandler;
+
+ constructor(
+ buttonContainerId: string,
+ targetId: string,
+ isSingleImagePreview: boolean,
+ uploadHandler: FileUploadHandler,
+ ) {
+ this.isSingleImagePreview = isSingleImagePreview;
+ this.uploadHandler = uploadHandler;
+
+ const buttonContainer = document.getElementById(buttonContainerId);
+ if (buttonContainer === null) {
+ throw new Error(`Element id '${buttonContainerId}' is unknown.`);
+ }
+ this.buttonContainer = buttonContainer;
+
+ const target = document.getElementById(targetId);
+ if (target === null) {
+ throw new Error(`Element id '${targetId}' is unknown.`);
+ }
+ this.target = target;
+
+ const internalId = this.target.dataset.internalId;
+ if (!internalId) {
+ throw new Error("InternalId is unknown.");
+ }
+ this.internalId = internalId;
+
+ this.rebuild();
+ }
+
+ /**
+ * Creates the upload button.
+ */
+ private createButtons(): void {
+ let triggerChange = false;
+ this.target.querySelectorAll("li.uploadedFile").forEach((element: HTMLElement) => {
+ const uniqueFileId = element.dataset.uniqueFileId!;
+ if (this.containers.has(uniqueFileId)) {
+ return;
+ }
+
+ const elementData: ElementData = {
+ uniqueFileId: uniqueFileId,
+ element: element,
+ };
+
+ this.containers.set(uniqueFileId, elementData);
+ this.initDeleteButton(element, elementData);
+
+ triggerChange = true;
+ });
+
+ if (triggerChange) {
+ DomChangeListener.trigger();
+ }
+ }
+
+ /**
+ * Init the delete button for a specific element.
+ */
+ private initDeleteButton(element: HTMLElement, elementData: ElementData): void {
+ const buttonGroup = element.querySelector(".buttonGroup");
+ if (buttonGroup === null) {
+ throw new Error(`Button group in '${this.target.id}' is unknown.`);
+ }
+
+ const li = document.createElement("li");
+ const span = document.createElement("span");
+ span.className = "button jsDeleteButton small";
+ span.textContent = Language.get("wcf.global.button.delete");
+ li.appendChild(span);
+ buttonGroup.appendChild(li);
+
+ li.addEventListener("click", this.deleteElement.bind(this, elementData.uniqueFileId));
+ }
+
+ /**
+ * Delete a specific file with the given uniqueFileId.
+ */
+ private deleteElement(uniqueFileId: string): void {
+ Ajax.api(this, {
+ uniqueFileId: uniqueFileId,
+ internalId: this.internalId,
+ });
+ }
+
+ /**
+ * Rebuilds the delete buttons for unknown files.
+ */
+ rebuild(): void {
+ if (!this.isSingleImagePreview) {
+ this.createButtons();
+ return;
+ }
+
+ const img = this.target.querySelector("img");
+ if (img !== null) {
+ const uniqueFileId = img.dataset.uniqueFileId!;
+
+ if (!this.containers.has(uniqueFileId)) {
+ const elementData = {
+ uniqueFileId: uniqueFileId,
+ element: img,
+ };
+
+ this.containers.set(uniqueFileId, elementData);
+
+ this.deleteButton = document.createElement("p");
+ this.deleteButton.className = "button deleteButton";
+
+ const span = document.createElement("span");
+ span.textContent = Language.get("wcf.global.button.delete");
+ this.deleteButton.appendChild(span);
+
+ this.buttonContainer.appendChild(this.deleteButton);
+
+ this.deleteButton.addEventListener("click", this.deleteElement.bind(this, elementData.uniqueFileId));
+ }
+ }
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ const elementData = this.containers.get(data.uniqueFileId)!;
+ elementData.element.remove();
+
+ if (this.isSingleImagePreview && this.deleteButton) {
+ this.deleteButton.remove();
+ this.deleteButton = undefined;
+ }
+
+ this.uploadHandler.checkMaxFiles();
+ Core.triggerEvent(this.target, "change");
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ url: "index.php?ajax-file-delete/&t=" + window.SECURITY_TOKEN,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiFileDelete);
+
+export = UiFileDelete;
--- /dev/null
+/**
+ * Uploads file via AJAX.
+ *
+ * @author Joshua Ruesweg, Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/File/Upload
+ * @since 5.2
+ */
+
+import { ResponseData } from "../../Ajax/Data";
+import * as Core from "../../Core";
+import { FileCollection, FileLikeObject, UploadId, UploadOptions } from "../../Upload/Data";
+import { default as DeleteHandler } from "./Delete";
+import DomUtil from "../../Dom/Util";
+import * as Language from "../../Language";
+import Upload from "../../Upload";
+import { FileUploadHandler } from "./Data";
+
+interface FileUploadOptions extends UploadOptions {
+ // image preview
+ imagePreview: boolean;
+ // max files
+ maxFiles: number | null;
+
+ internalId: string;
+}
+
+interface FileData {
+ filesize: number;
+ icon: string;
+ image: string | null;
+ uniqueFileId: string;
+}
+
+interface ErrorData {
+ errorMessage: string;
+}
+
+interface AjaxResponse {
+ error: ErrorData[];
+ files: FileData[];
+}
+
+class FileUpload extends Upload<FileUploadOptions> implements FileUploadHandler {
+ protected readonly _deleteHandler: DeleteHandler;
+
+ constructor(buttonContainerId: string, targetId: string, options: Partial<FileUploadOptions>) {
+ options = options || {};
+
+ if (options.internalId === undefined) {
+ throw new Error("Missing internal id.");
+ }
+
+ // set default options
+ options = Core.extend(
+ {
+ // image preview
+ imagePreview: false,
+ // max files
+ maxFiles: null,
+ // Dummy value, because it is checked in the base method, without using it with this upload handler.
+ className: "invalid",
+ // url
+ url: `index.php?ajax-file-upload/&t=${window.SECURITY_TOKEN}`,
+ },
+ options,
+ );
+
+ options.multiple = options.maxFiles === null || (options.maxFiles as number) > 1;
+
+ super(buttonContainerId, targetId, options);
+
+ this.checkMaxFiles();
+
+ this._deleteHandler = new DeleteHandler(buttonContainerId, targetId, this._options.imagePreview, this);
+ }
+
+ protected _createFileElement(file: File | FileLikeObject): HTMLElement {
+ const element = super._createFileElement(file);
+ element.classList.add("box64", "uploadedFile");
+
+ const progress = element.querySelector("progress") as HTMLProgressElement;
+
+ const icon = document.createElement("span");
+ icon.className = "icon icon64 fa-spinner";
+
+ const fileName = element.textContent;
+ element.textContent = "";
+ element.append(icon);
+
+ const innerDiv = document.createElement("div");
+ const fileNameP = document.createElement("p");
+ fileNameP.textContent = fileName; // file.name
+
+ const smallProgress = document.createElement("small");
+ smallProgress.appendChild(progress);
+
+ innerDiv.appendChild(fileNameP);
+ innerDiv.appendChild(smallProgress);
+
+ const div = document.createElement("div");
+ div.appendChild(innerDiv);
+
+ const ul = document.createElement("ul");
+ ul.className = "buttonGroup";
+ div.appendChild(ul);
+
+ // reset element textContent and replace with own element style
+ element.append(div);
+
+ return element;
+ }
+
+ protected _failure(uploadId: number, data: ResponseData): boolean {
+ this._fileElements[uploadId].forEach((fileElement) => {
+ fileElement.classList.add("uploadFailed");
+
+ const small = fileElement.querySelector("small") as HTMLElement;
+ small.innerHTML = "";
+
+ const icon = fileElement.querySelector(".icon") as HTMLElement;
+ icon.classList.remove("fa-spinner");
+ icon.classList.add("fa-ban");
+
+ const innerError = document.createElement("span");
+ innerError.className = "innerError";
+ innerError.textContent = Language.get("wcf.upload.error.uploadFailed");
+ small.insertAdjacentElement("afterend", innerError);
+ });
+
+ throw new Error(`Upload failed: ${data.message as string}`);
+ }
+
+ protected _upload(event: Event): UploadId;
+ protected _upload(event: null, file: File): UploadId;
+ protected _upload(event: null, file: null, blob: Blob): UploadId;
+ protected _upload(event: Event | null, file?: File | null, blob?: Blob | null): UploadId {
+ const parent = this._buttonContainer.parentElement!;
+ const innerError = parent.querySelector("small.innerError:not(.innerFileError)");
+ if (innerError) {
+ innerError.remove();
+ }
+
+ return super._upload(event, file, blob);
+ }
+
+ protected _success(uploadId: number, data: AjaxResponse): void {
+ this._fileElements[uploadId].forEach((fileElement, index) => {
+ if (data.files[index] !== undefined) {
+ const fileData = data.files[index];
+
+ if (this._options.imagePreview) {
+ if (fileData.image === null) {
+ throw new Error("Expect image for uploaded file. None given.");
+ }
+
+ fileElement.remove();
+
+ const previewImage = this._target.querySelector("img.previewImage") as HTMLImageElement;
+ if (previewImage !== null) {
+ previewImage.src = fileData.image;
+ } else {
+ const image = document.createElement("img");
+ image.classList.add("previewImage");
+ image.src = fileData.image;
+ image.style.setProperty("max-width", "100%", "");
+ image.dataset.uniqueFileId = fileData.uniqueFileId;
+ this._target.appendChild(image);
+ }
+ } else {
+ fileElement.dataset.uniqueFileId = fileData.uniqueFileId;
+ fileElement.querySelector("small")!.textContent = fileData.filesize.toString();
+
+ const icon = fileElement.querySelector(".icon") as HTMLElement;
+ icon.classList.remove("fa-spinner");
+ icon.classList.add(`fa-${fileData.icon}`);
+ }
+ } else if (data.error[index] !== undefined) {
+ const errorData = data["error"][index];
+
+ fileElement.classList.add("uploadFailed");
+
+ const small = fileElement.querySelector("small") as HTMLElement;
+ small.innerHTML = "";
+
+ const icon = fileElement.querySelector(".icon") as HTMLElement;
+ icon.classList.remove("fa-spinner");
+ icon.classList.add("fa-ban");
+
+ let innerError = fileElement.querySelector(".innerError") as HTMLElement;
+ if (innerError === null) {
+ innerError = document.createElement("span");
+ innerError.className = "innerError";
+ innerError.textContent = errorData.errorMessage;
+
+ small.insertAdjacentElement("afterend", innerError);
+ } else {
+ innerError.textContent = errorData.errorMessage;
+ }
+ } else {
+ throw new Error(`Unknown uploaded file for uploadId ${uploadId}.`);
+ }
+ });
+
+ // create delete buttons
+ this._deleteHandler.rebuild();
+ this.checkMaxFiles();
+ Core.triggerEvent(this._target, "change");
+ }
+
+ protected _getFormData(): ArbitraryObject {
+ return {
+ internalId: this._options.internalId,
+ };
+ }
+
+ validateUpload(files: FileCollection): boolean {
+ if (this._options.maxFiles === null || files.length + this.countFiles() <= this._options.maxFiles) {
+ return true;
+ } else {
+ const parent = this._buttonContainer.parentElement!;
+
+ let innerError = parent.querySelector("small.innerError:not(.innerFileError)");
+ if (innerError === null) {
+ innerError = document.createElement("small");
+ innerError.className = "innerError";
+ this._buttonContainer.insertAdjacentElement("afterend", innerError);
+ }
+
+ innerError.textContent = Language.get("wcf.upload.error.reachedRemainingLimit", {
+ maxFiles: this._options.maxFiles - this.countFiles(),
+ });
+
+ return false;
+ }
+ }
+
+ /**
+ * Returns the count of the uploaded images.
+ */
+ countFiles(): number {
+ if (this._options.imagePreview) {
+ return this._target.querySelector("img") !== null ? 1 : 0;
+ } else {
+ return this._target.childElementCount;
+ }
+ }
+
+ /**
+ * Checks the maximum number of files and enables or disables the upload button.
+ */
+ checkMaxFiles(): void {
+ if (this._options.maxFiles !== null && this.countFiles() >= this._options.maxFiles) {
+ DomUtil.hide(this._button);
+ } else {
+ DomUtil.show(this._button);
+ }
+ }
+}
+
+Core.enableLegacyInheritance(FileUpload);
+
+export = FileUpload;
--- /dev/null
+/**
+ * Dynamically transforms menu-like structures to handle items exceeding the available width
+ * by moving them into a separate dropdown.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/FlexibleMenu
+ */
+
+import DomChangeListener from "../Dom/Change/Listener";
+import DomUtil from "../Dom/Util";
+import * as DomTraverse from "../Dom/Traverse";
+import UiDropdownSimple from "./Dropdown/Simple";
+
+const _containers = new Map<string, HTMLElement>();
+const _dropdowns = new Map<string, HTMLLIElement>();
+const _dropdownMenus = new Map<string, HTMLUListElement>();
+const _itemLists = new Map<string, HTMLUListElement>();
+
+/**
+ * Register default menus and set up event listeners.
+ */
+export function setup(): void {
+ if (document.getElementById("mainMenu") !== null) {
+ register("mainMenu");
+ }
+
+ const navigationHeader = document.querySelector(".navigationHeader");
+ if (navigationHeader !== null) {
+ register(DomUtil.identify(navigationHeader));
+ }
+
+ window.addEventListener("resize", rebuildAll);
+ DomChangeListener.add("WoltLabSuite/Core/Ui/FlexibleMenu", registerTabMenus);
+}
+
+/**
+ * Registers a menu by element id.
+ */
+export function register(containerId: string): void {
+ const container = document.getElementById(containerId);
+ if (container === null) {
+ throw "Expected a valid element id, '" + containerId + "' does not exist.";
+ }
+
+ if (_containers.has(containerId)) {
+ return;
+ }
+
+ const list = DomTraverse.childByTag(container, "UL");
+ if (list === null) {
+ throw "Expected an <ul> element as child of container '" + containerId + "'.";
+ }
+
+ _containers.set(containerId, container);
+ _itemLists.set(containerId, list);
+
+ rebuild(containerId);
+}
+
+/**
+ * Registers tab menus.
+ */
+export function registerTabMenus(): void {
+ document
+ .querySelectorAll(".tabMenuContainer:not(.jsFlexibleMenuEnabled), .messageTabMenu:not(.jsFlexibleMenuEnabled)")
+ .forEach((tabMenu) => {
+ const nav = DomTraverse.childByTag(tabMenu, "NAV");
+ if (nav !== null) {
+ tabMenu.classList.add("jsFlexibleMenuEnabled");
+ register(DomUtil.identify(nav));
+ }
+ });
+}
+
+/**
+ * Rebuilds all menus, e.g. on window resize.
+ */
+export function rebuildAll(): void {
+ _containers.forEach((container, containerId) => {
+ rebuild(containerId);
+ });
+}
+
+/**
+ * Rebuild the menu identified by given element id.
+ */
+export function rebuild(containerId: string): void {
+ const container = _containers.get(containerId);
+ if (container === undefined) {
+ throw "Expected a valid element id, '" + containerId + "' is unknown.";
+ }
+
+ const styles = window.getComputedStyle(container);
+ const parent = container.parentNode as HTMLElement;
+ let availableWidth = parent.clientWidth;
+ availableWidth -= DomUtil.styleAsInt(styles, "margin-left");
+ availableWidth -= DomUtil.styleAsInt(styles, "margin-right");
+
+ const list = _itemLists.get(containerId)!;
+ const items = DomTraverse.childrenByTag(list, "LI");
+ let dropdown = _dropdowns.get(containerId);
+ let dropdownWidth = 0;
+ if (dropdown !== undefined) {
+ // show all items for calculation
+ for (let i = 0, length = items.length; i < length; i++) {
+ const item = items[i];
+ if (item.classList.contains("dropdown")) {
+ continue;
+ }
+
+ DomUtil.show(item);
+ }
+ if (dropdown.parentNode !== null) {
+ dropdownWidth = DomUtil.outerWidth(dropdown);
+ }
+ }
+
+ const currentWidth = list.scrollWidth - dropdownWidth;
+ const hiddenItems: HTMLLIElement[] = [];
+ if (currentWidth > availableWidth) {
+ // hide items starting with the last one
+ for (let i = items.length - 1; i >= 0; i--) {
+ const item = items[i];
+
+ // ignore dropdown and active item
+ if (
+ item.classList.contains("dropdown") ||
+ item.classList.contains("active") ||
+ item.classList.contains("ui-state-active")
+ ) {
+ continue;
+ }
+
+ hiddenItems.push(item);
+ DomUtil.hide(item);
+
+ if (list.scrollWidth < availableWidth) {
+ break;
+ }
+ }
+ }
+
+ if (hiddenItems.length) {
+ let dropdownMenu: HTMLUListElement;
+ if (dropdown === undefined) {
+ dropdown = document.createElement("li");
+ dropdown.className = "dropdown jsFlexibleMenuDropdown";
+
+ const icon = document.createElement("a");
+ icon.className = "icon icon16 fa-list";
+ dropdown.appendChild(icon);
+
+ dropdownMenu = document.createElement("ul");
+ dropdownMenu.classList.add("dropdownMenu");
+ dropdown.appendChild(dropdownMenu);
+
+ _dropdowns.set(containerId, dropdown);
+ _dropdownMenus.set(containerId, dropdownMenu);
+ UiDropdownSimple.init(icon);
+ } else {
+ dropdownMenu = _dropdownMenus.get(containerId)!;
+ }
+
+ if (dropdown.parentNode === null) {
+ list.appendChild(dropdown);
+ }
+
+ // build dropdown menu
+ const fragment = document.createDocumentFragment();
+ hiddenItems.forEach((hiddenItem) => {
+ const item = document.createElement("li");
+ item.innerHTML = hiddenItem.innerHTML;
+
+ item.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ hiddenItem.querySelector("a")?.click();
+
+ // force a rebuild to guarantee the active item being visible
+ setTimeout(() => {
+ rebuild(containerId);
+ }, 59);
+ });
+
+ fragment.appendChild(item);
+ });
+
+ dropdownMenu.innerHTML = "";
+ dropdownMenu.appendChild(fragment);
+ } else if (dropdown !== undefined && dropdown.parentNode !== null) {
+ dropdown.remove();
+ }
+}
--- /dev/null
+/**
+ * Flexible UI element featuring both a list of items and an input field with suggestion support.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/ItemList
+ */
+
+import * as Core from "../Core";
+import * as DomTraverse from "../Dom/Traverse";
+import * as Language from "../Language";
+import UiSuggestion from "./Suggestion";
+import UiDropdownSimple from "./Dropdown/Simple";
+import { DatabaseObjectActionPayload } from "../Ajax/Data";
+import DomUtil from "../Dom/Util";
+
+const _data = new Map<string, ElementData>();
+
+/**
+ * Creates the DOM structure for target element. If `element` is a `<textarea>`
+ * it will be automatically replaced with an `<input>` element.
+ */
+function createUI(element: ItemListInputElement, options: ItemListOptions): UiData {
+ const parentElement = element.parentElement!;
+
+ const list = document.createElement("ol");
+ list.className = "inputItemList" + (element.disabled ? " disabled" : "");
+ list.dataset.elementId = element.id;
+ list.addEventListener("click", (event) => {
+ if (event.target === list) {
+ element.focus();
+ }
+ });
+
+ const listItem = document.createElement("li");
+ listItem.className = "input";
+ list.appendChild(listItem);
+ element.addEventListener("keydown", keyDown);
+ element.addEventListener("keypress", keyPress);
+ element.addEventListener("keyup", keyUp);
+ element.addEventListener("paste", paste);
+
+ const hasFocus = element === document.activeElement;
+ if (hasFocus) {
+ element.blur();
+ }
+ element.addEventListener("blur", blur);
+ parentElement.insertBefore(list, element);
+ listItem.appendChild(element);
+
+ if (hasFocus) {
+ window.setTimeout(() => {
+ element.focus();
+ }, 1);
+ }
+
+ if (options.maxLength !== -1) {
+ element.maxLength = options.maxLength;
+ }
+
+ const limitReached = document.createElement("span");
+ limitReached.className = "inputItemListLimitReached";
+ limitReached.textContent = Language.get("wcf.global.form.input.maxItems");
+ DomUtil.hide(limitReached);
+ listItem.appendChild(limitReached);
+
+ let shadow: HTMLInputElement | null = null;
+ const values: string[] = [];
+ if (options.isCSV) {
+ shadow = document.createElement("input");
+ shadow.className = "itemListInputShadow";
+ shadow.type = "hidden";
+ shadow.name = element.name;
+ element.removeAttribute("name");
+ list.parentNode!.insertBefore(shadow, list);
+
+ element.value.split(",").forEach((value) => {
+ value = value.trim();
+ if (value) {
+ values.push(value);
+ }
+ });
+
+ if (element.nodeName === "TEXTAREA") {
+ const inputElement = document.createElement("input");
+ inputElement.type = "text";
+ parentElement.insertBefore(inputElement, element);
+ inputElement.id = element.id;
+
+ element.remove();
+ element = inputElement;
+ }
+ }
+
+ return {
+ element: element,
+ limitReached: limitReached,
+ list: list,
+ shadow: shadow,
+ values: values,
+ };
+}
+
+/**
+ * Returns true if the input accepts new items.
+ */
+function acceptsNewItems(elementId: string): boolean {
+ const data = _data.get(elementId)!;
+ if (data.options.maxItems === -1) {
+ return true;
+ }
+
+ return data.list.childElementCount - 1 < data.options.maxItems;
+}
+
+/**
+ * Enforces the maximum number of items.
+ */
+function handleLimit(elementId: string): void {
+ const data = _data.get(elementId)!;
+ if (acceptsNewItems(elementId)) {
+ DomUtil.show(data.element);
+ DomUtil.hide(data.limitReached);
+ } else {
+ DomUtil.hide(data.element);
+ DomUtil.show(data.limitReached);
+ }
+}
+
+/**
+ * Sets the active item list id and handles keyboard access to remove an existing item.
+ */
+function keyDown(event: KeyboardEvent): void {
+ const input = event.currentTarget as HTMLInputElement;
+
+ const lastItem = input.parentElement!.previousElementSibling as HTMLElement | null;
+ if (event.key === "Backspace") {
+ if (input.value.length === 0) {
+ if (lastItem !== null) {
+ if (lastItem.classList.contains("active")) {
+ removeItem(lastItem);
+ } else {
+ lastItem.classList.add("active");
+ }
+ }
+ }
+ } else if (event.key === "Escape") {
+ if (lastItem !== null && lastItem.classList.contains("active")) {
+ lastItem.classList.remove("active");
+ }
+ }
+}
+
+/**
+ * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
+ */
+function keyPress(event: KeyboardEvent): void {
+ if (event.key === "Enter" || event.key === ",") {
+ event.preventDefault();
+
+ const input = event.currentTarget as HTMLInputElement;
+ if (_data.get(input.id)!.options.restricted) {
+ // restricted item lists only allow results from the dropdown to be picked
+ return;
+ }
+ const value = input.value.trim();
+ if (value.length) {
+ addItem(input.id, { objectId: 0, value: value });
+ }
+ }
+}
+
+/**
+ * Splits comma-separated values being pasted into the input field.
+ */
+function paste(event: ClipboardEvent): void {
+ event.preventDefault();
+
+ const text = event.clipboardData!.getData("text/plain");
+
+ const element = event.currentTarget as HTMLInputElement;
+ const elementId = element.id;
+ const maxLength = +element.maxLength;
+ text.split(/,/).forEach((item) => {
+ item = item.trim();
+ if (maxLength && item.length > maxLength) {
+ // truncating items provides a better UX than throwing an error or silently discarding it
+ item = item.substr(0, maxLength);
+ }
+
+ if (item.length > 0 && acceptsNewItems(elementId)) {
+ addItem(elementId, { objectId: 0, value: item });
+ }
+ });
+}
+
+/**
+ * Handles the keyup event to unmark an item for deletion.
+ */
+function keyUp(event: KeyboardEvent): void {
+ const input = event.currentTarget as HTMLInputElement;
+ if (input.value.length > 0) {
+ const lastItem = input.parentElement!.previousElementSibling;
+ if (lastItem !== null) {
+ lastItem.classList.remove("active");
+ }
+ }
+}
+
+/**
+ * Adds an item to the list.
+ */
+function addItem(elementId: string, value: ItemData): void {
+ const data = _data.get(elementId)!;
+ const listItem = document.createElement("li");
+ listItem.className = "item";
+
+ const content = document.createElement("span");
+ content.className = "content";
+ content.dataset.objectId = value.objectId.toString();
+ if (value.type) {
+ content.dataset.type = value.type;
+ }
+ content.textContent = value.value;
+ listItem.appendChild(content);
+
+ if (!data.element.disabled) {
+ const button = document.createElement("a");
+ button.className = "icon icon16 fa-times";
+ button.addEventListener("click", removeItem);
+ listItem.appendChild(button);
+ }
+
+ data.list.insertBefore(listItem, data.listItem);
+ data.suggestion.addExcludedValue(value.value);
+ data.element.value = "";
+ if (!data.element.disabled) {
+ handleLimit(elementId);
+ }
+
+ let values = syncShadow(data);
+ if (typeof data.options.callbackChange === "function") {
+ if (values === null) {
+ values = getValues(elementId);
+ }
+
+ data.options.callbackChange(elementId, values);
+ }
+}
+
+/**
+ * Removes an item from the list.
+ */
+function removeItem(item: Event | HTMLElement, noFocus?: boolean): void {
+ if (item instanceof Event) {
+ const target = item.currentTarget as HTMLElement;
+ item = target.parentElement!;
+ }
+
+ const parent = item.parentElement!;
+ const elementId = parent.dataset.elementId || "";
+ const data = _data.get(elementId)!;
+ if (item.children[0].textContent) {
+ data.suggestion.removeExcludedValue(item.children[0].textContent);
+ }
+
+ item.remove();
+
+ if (!noFocus) {
+ data.element.focus();
+ }
+
+ handleLimit(elementId);
+
+ let values = syncShadow(data);
+ if (typeof data.options.callbackChange === "function") {
+ if (values === null) {
+ values = getValues(elementId);
+ }
+
+ data.options.callbackChange(elementId, values);
+ }
+}
+
+/**
+ * Synchronizes the shadow input field with the current list item values.
+ */
+function syncShadow(data: ElementData): ItemData[] | null {
+ if (!data.options.isCSV) {
+ return null;
+ }
+
+ if (typeof data.options.callbackSyncShadow === "function") {
+ return data.options.callbackSyncShadow(data);
+ }
+
+ const values = getValues(data.element.id);
+
+ data.shadow!.value = getValues(data.element.id)
+ .map((value) => value.value)
+ .join(",");
+
+ return values;
+}
+
+/**
+ * Handles the blur event.
+ */
+function blur(event: FocusEvent): void {
+ const input = event.currentTarget as HTMLInputElement;
+ const data = _data.get(input.id)!;
+
+ if (data.options.restricted) {
+ // restricted item lists only allow results from the dropdown to be picked
+ return;
+ }
+
+ const value = input.value.trim();
+ if (value.length) {
+ if (!data.suggestion || !data.suggestion.isActive()) {
+ addItem(input.id, { objectId: 0, value: value });
+ }
+ }
+}
+
+/**
+ * Initializes an item list.
+ *
+ * The `values` argument must be empty or contain a list of strings or object, e.g.
+ * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
+ */
+export function init(elementId: string, values: ItemDataOrPlainValue[], opts: Partial<ItemListOptions>): void {
+ const element = document.getElementById(elementId) as ItemListInputElement;
+ if (element === null) {
+ throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
+ }
+
+ // remove data from previous instance
+ if (_data.has(elementId)) {
+ const tmp = _data.get(elementId)!;
+ Object.keys(tmp).forEach((key) => {
+ const el = tmp[key];
+ if (el instanceof Element && el.parentNode) {
+ el.remove();
+ }
+ });
+
+ UiDropdownSimple.destroy(elementId);
+ _data.delete(elementId);
+ }
+
+ const options = Core.extend(
+ {
+ // search parameters for suggestions
+ ajax: {
+ actionName: "getSearchResultList",
+ className: "",
+ data: {},
+ },
+ // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
+ excludedSearchValues: [],
+ // maximum number of items this list may contain, `-1` for infinite
+ maxItems: -1,
+ // maximum length of an item value, `-1` for infinite
+ maxLength: -1,
+ // disallow custom values, only values offered by the suggestion dropdown are accepted
+ restricted: false,
+ // initial value will be interpreted as comma separated value and submitted as such
+ isCSV: false,
+ // will be invoked whenever the items change, receives the element id first and list of values second
+ callbackChange: null,
+ // callback once the form is about to be submitted
+ callbackSubmit: null,
+ // Callback for the custom shadow synchronization.
+ callbackSyncShadow: null,
+ // Callback to set values during the setup.
+ callbackSetupValues: null,
+ // value may contain the placeholder `{$objectId}`
+ submitFieldName: "",
+ },
+ opts,
+ ) as ItemListOptions;
+
+ const form = DomTraverse.parentByTag(element, "FORM") as HTMLFormElement;
+ if (form !== null) {
+ if (!options.isCSV) {
+ if (!options.submitFieldName.length && typeof options.callbackSubmit !== "function") {
+ throw new Error(
+ "Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.",
+ );
+ }
+
+ form.addEventListener("submit", () => {
+ if (acceptsNewItems(elementId)) {
+ const value = _data.get(elementId)!.element.value.trim();
+ if (value.length) {
+ addItem(elementId, { objectId: 0, value: value });
+ }
+ }
+
+ const values = getValues(elementId);
+ if (options.submitFieldName.length) {
+ values.forEach((value) => {
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = options.submitFieldName.replace("{$objectId}", value.objectId.toString());
+ input.value = value.value;
+ form.appendChild(input);
+ });
+ } else {
+ options.callbackSubmit!(form, values);
+ }
+ });
+ } else {
+ form.addEventListener("submit", () => {
+ if (acceptsNewItems(elementId)) {
+ const value = _data.get(elementId)!.element.value.trim();
+ if (value.length) {
+ addItem(elementId, { objectId: 0, value: value });
+ }
+ }
+ });
+ }
+ }
+
+ const data = createUI(element, options);
+
+ const suggestion = new UiSuggestion(elementId, {
+ ajax: options.ajax as DatabaseObjectActionPayload,
+ callbackSelect: addItem,
+ excludedSearchValues: options.excludedSearchValues,
+ });
+
+ _data.set(elementId, {
+ dropdownMenu: null,
+ element: data.element,
+ limitReached: data.limitReached,
+ list: data.list,
+ listItem: data.element.parentElement!,
+ options: options,
+ shadow: data.shadow,
+ suggestion: suggestion,
+ });
+
+ if (options.callbackSetupValues) {
+ values = options.callbackSetupValues();
+ } else {
+ values = data.values.length ? data.values : values;
+ }
+
+ if (Array.isArray(values)) {
+ values.forEach((value) => {
+ if (typeof value === "string") {
+ value = { objectId: 0, value: value };
+ }
+
+ addItem(elementId, value);
+ });
+ }
+}
+
+/**
+ * Returns the list of current values.
+ */
+export function getValues(elementId: string): ItemData[] {
+ const data = _data.get(elementId);
+ if (!data) {
+ throw new Error("Element id '" + elementId + "' is unknown.");
+ }
+
+ const values: ItemData[] = [];
+ data.list.querySelectorAll(".item > span").forEach((span: HTMLSpanElement) => {
+ values.push({
+ objectId: +(span.dataset.objectId || ""),
+ value: span.textContent!.trim(),
+ type: span.dataset.type,
+ });
+ });
+
+ return values;
+}
+
+/**
+ * Sets the list of current values.
+ */
+export function setValues(elementId: string, values: ItemData[]): void {
+ const data = _data.get(elementId);
+ if (!data) {
+ throw new Error("Element id '" + elementId + "' is unknown.");
+ }
+
+ // remove all existing items first
+ DomTraverse.childrenByClass(data.list, "item").forEach((item: HTMLElement) => {
+ removeItem(item, true);
+ });
+
+ // add new items
+ values.forEach((value) => {
+ addItem(elementId, value);
+ });
+}
+
+type ItemListInputElement = HTMLInputElement | HTMLTextAreaElement;
+
+export interface ItemData {
+ objectId: number;
+ value: string;
+ type?: string;
+}
+
+type PlainValue = string;
+
+type ItemDataOrPlainValue = ItemData | PlainValue;
+
+export type CallbackChange = (elementId: string, values: ItemData[]) => void;
+
+export type CallbackSetupValues = () => ItemDataOrPlainValue[];
+
+export type CallbackSubmit = (form: HTMLFormElement, values: ItemData[]) => void;
+
+export type CallbackSyncShadow = (data: ElementData) => ItemData[];
+
+export interface ItemListOptions {
+ // search parameters for suggestions
+ ajax: {
+ actionName?: string;
+ className: string;
+ parameters?: object;
+ };
+
+ // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
+ excludedSearchValues: string[];
+
+ // maximum number of items this list may contain, `-1` for infinite
+ maxItems: number;
+
+ // maximum length of an item value, `-1` for infinite
+ maxLength: number;
+
+ // disallow custom values, only values offered by the suggestion dropdown are accepted
+ restricted: boolean;
+
+ // initial value will be interpreted as comma separated value and submitted as such
+ isCSV: boolean;
+
+ // will be invoked whenever the items change, receives the element id first and list of values second
+ callbackChange: CallbackChange | null;
+
+ // callback once the form is about to be submitted
+ callbackSubmit: CallbackSubmit | null;
+
+ // Callback for the custom shadow synchronization.
+ callbackSyncShadow: CallbackSyncShadow | null;
+
+ // Callback to set values during the setup.
+ callbackSetupValues: CallbackSetupValues | null;
+
+ // value may contain the placeholder `{$objectId}`
+ submitFieldName: string;
+}
+
+export interface ElementData {
+ dropdownMenu: HTMLElement | null;
+ element: ItemListInputElement;
+ limitReached: HTMLSpanElement;
+ list: HTMLElement;
+ listItem: HTMLElement;
+ options: ItemListOptions;
+ shadow: HTMLInputElement | null;
+ suggestion: UiSuggestion;
+}
+
+interface UiData {
+ element: ItemListInputElement;
+ limitReached: HTMLSpanElement;
+ list: HTMLOListElement;
+ shadow: HTMLInputElement | null;
+ values: string[];
+}
--- /dev/null
+/**
+ * Provides a filter input for checkbox lists.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/ItemList/Filter
+ */
+
+import * as Core from "../../Core";
+import DomUtil from "../../Dom/Util";
+import * as Language from "../../Language";
+import * as StringUtil from "../../StringUtil";
+import UiDropdownSimple from "../Dropdown/Simple";
+
+interface ItemMetaData {
+ item: HTMLLIElement;
+ span: HTMLSpanElement;
+ text: string;
+}
+
+interface FilterOptions {
+ callbackPrepareItem: (listItem: HTMLLIElement) => ItemMetaData;
+ enableVisibilityFilter: boolean;
+ filterPosition: "bottom" | "top";
+}
+
+class UiItemListFilter {
+ protected readonly _container: HTMLDivElement;
+ protected _dropdownId = "";
+ protected _dropdown?: HTMLUListElement = undefined;
+ protected readonly _element: HTMLElement;
+ protected _fragment?: DocumentFragment = undefined;
+ protected readonly _input: HTMLInputElement;
+ protected readonly _items = new Set<ItemMetaData>();
+ protected readonly _options: FilterOptions;
+ protected _value = "";
+
+ /**
+ * Creates a new filter input.
+ *
+ * @param {string} elementId list element id
+ * @param {Object=} options options
+ */
+ constructor(elementId: string, options: Partial<FilterOptions>) {
+ this._options = Core.extend(
+ {
+ callbackPrepareItem: undefined,
+ enableVisibilityFilter: true,
+ filterPosition: "bottom",
+ },
+ options,
+ ) as FilterOptions;
+
+ if (this._options.filterPosition !== "top") {
+ this._options.filterPosition = "bottom";
+ }
+
+ const element = document.getElementById(elementId);
+ if (element === null) {
+ throw new Error("Expected a valid element id, '" + elementId + "' does not match anything.");
+ } else if (
+ !element.classList.contains("scrollableCheckboxList") &&
+ typeof this._options.callbackPrepareItem !== "function"
+ ) {
+ throw new Error("Filter only works with elements with the CSS class 'scrollableCheckboxList'.");
+ }
+
+ if (typeof this._options.callbackPrepareItem !== "function") {
+ this._options.callbackPrepareItem = (item) => this._prepareItem(item);
+ }
+
+ element.dataset.filter = "showAll";
+
+ const container = document.createElement("div");
+ container.className = "itemListFilter";
+
+ element.insertAdjacentElement("beforebegin", container);
+ container.appendChild(element);
+
+ const inputAddon = document.createElement("div");
+ inputAddon.className = "inputAddon";
+
+ const input = document.createElement("input");
+ input.className = "long";
+ input.type = "text";
+ input.placeholder = Language.get("wcf.global.filter.placeholder");
+ input.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ }
+ });
+ input.addEventListener("keyup", () => this._keyup());
+
+ const clearButton = document.createElement("a");
+ clearButton.href = "#";
+ clearButton.className = "button inputSuffix jsTooltip";
+ clearButton.title = Language.get("wcf.global.filter.button.clear");
+ clearButton.innerHTML = '<span class="icon icon16 fa-times"></span>';
+ clearButton.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ this.reset();
+ });
+
+ inputAddon.appendChild(input);
+ inputAddon.appendChild(clearButton);
+
+ if (this._options.enableVisibilityFilter) {
+ const visibilityButton = document.createElement("a");
+ visibilityButton.href = "#";
+ visibilityButton.className = "button inputSuffix jsTooltip";
+ visibilityButton.title = Language.get("wcf.global.filter.button.visibility");
+ visibilityButton.innerHTML = '<span class="icon icon16 fa-eye"></span>';
+ visibilityButton.addEventListener("click", (ev) => this._toggleVisibility(ev));
+ inputAddon.appendChild(visibilityButton);
+ }
+
+ if (this._options.filterPosition === "bottom") {
+ container.appendChild(inputAddon);
+ } else {
+ container.insertBefore(inputAddon, element);
+ }
+
+ this._container = container;
+ this._element = element;
+ this._input = input;
+ }
+
+ /**
+ * Resets the filter.
+ */
+ reset(): void {
+ this._input.value = "";
+ this._keyup();
+ }
+
+ /**
+ * Builds the item list and rebuilds the items' DOM for easier manipulation.
+ *
+ * @protected
+ */
+ protected _buildItems(): void {
+ this._items.clear();
+
+ Array.from(this._element.children).forEach((item: HTMLLIElement) => {
+ this._items.add(this._options.callbackPrepareItem(item));
+ });
+ }
+
+ /**
+ * Processes an item and returns the meta data.
+ */
+ protected _prepareItem(item: HTMLLIElement): ItemMetaData {
+ const label = item.children[0] as HTMLElement;
+ const text = label.textContent!.trim();
+
+ const checkbox = label.children[0];
+ while (checkbox.nextSibling) {
+ label.removeChild(checkbox.nextSibling);
+ }
+
+ label.appendChild(document.createTextNode(" "));
+
+ const span = document.createElement("span");
+ span.textContent = text;
+ label.appendChild(span);
+
+ return {
+ item,
+ span,
+ text,
+ };
+ }
+
+ /**
+ * Rebuilds the list on keyup, uses case-insensitive matching.
+ */
+ protected _keyup(): void {
+ const value = this._input.value.trim();
+ if (this._value === value) {
+ return;
+ }
+
+ if (!this._fragment) {
+ this._fragment = document.createDocumentFragment();
+
+ // set fixed height to avoid layout jumps
+ this._element.style.setProperty("height", `${this._element.offsetHeight}px`, "");
+ }
+
+ // move list into fragment before editing items, increases performance
+ // by avoiding the browser to perform repaint/layout over and over again
+ this._fragment.appendChild(this._element);
+
+ if (!this._items.size) {
+ this._buildItems();
+ }
+
+ const regexp = new RegExp("(" + StringUtil.escapeRegExp(value) + ")", "i");
+ let hasVisibleItems = value === "";
+ this._items.forEach((item) => {
+ if (value === "") {
+ item.span.textContent = item.text;
+
+ DomUtil.show(item.item);
+ } else {
+ if (regexp.test(item.text)) {
+ item.span.innerHTML = item.text.replace(regexp, "<u>$1</u>");
+
+ DomUtil.show(item.item);
+ hasVisibleItems = true;
+ } else {
+ DomUtil.hide(item.item);
+ }
+ }
+ });
+
+ if (this._options.filterPosition === "bottom") {
+ this._container.insertAdjacentElement("afterbegin", this._element);
+ } else {
+ this._container.insertAdjacentElement("beforeend", this._element);
+ }
+
+ this._value = value;
+
+ DomUtil.innerError(this._container, hasVisibleItems ? false : Language.get("wcf.global.filter.error.noMatches"));
+ }
+
+ /**
+ * Toggles the visibility mode for marked items.
+ */
+ protected _toggleVisibility(event: MouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const button = event.currentTarget as HTMLElement;
+ if (!this._dropdown) {
+ const dropdown = document.createElement("ul");
+ dropdown.className = "dropdownMenu";
+
+ ["activeOnly", "highlightActive", "showAll"].forEach((type) => {
+ const link = document.createElement("a");
+ link.dataset.type = type;
+ link.href = "#";
+ link.textContent = Language.get(`wcf.global.filter.visibility.${type}`);
+ link.addEventListener("click", (ev) => this._setVisibility(ev));
+
+ const li = document.createElement("li");
+ li.appendChild(link);
+
+ if (type === "showAll") {
+ li.className = "active";
+
+ const divider = document.createElement("li");
+ divider.className = "dropdownDivider";
+ dropdown.appendChild(divider);
+ }
+
+ dropdown.appendChild(li);
+ });
+
+ UiDropdownSimple.initFragment(button, dropdown);
+
+ // add `active` classes required for the visibility filter
+ this._setupVisibilityFilter();
+
+ this._dropdown = dropdown;
+ this._dropdownId = button.id;
+ }
+
+ UiDropdownSimple.toggleDropdown(button.id, button);
+ }
+
+ /**
+ * Set-ups the visibility filter by assigning an active class to the
+ * list items that hold the checkboxes and observing the checkboxes
+ * for any changes.
+ *
+ * This process involves quite a few DOM changes and new event listeners,
+ * therefore we'll delay this until the filter has been accessed for
+ * the first time, because none of these changes matter before that.
+ */
+ protected _setupVisibilityFilter(): void {
+ const nextSibling = this._element.nextSibling;
+ const parent = this._element.parentElement!;
+ const scrollTop = this._element.scrollTop;
+
+ // mass-editing of DOM elements is slow while they're part of the document
+ const fragment = document.createDocumentFragment();
+ fragment.appendChild(this._element);
+
+ this._element.querySelectorAll("li").forEach((li) => {
+ const checkbox = li.querySelector('input[type="checkbox"]') as HTMLInputElement;
+ if (checkbox) {
+ if (checkbox.checked) {
+ li.classList.add("active");
+ }
+
+ checkbox.addEventListener("change", () => {
+ if (checkbox.checked) {
+ li.classList.add("active");
+ } else {
+ li.classList.remove("active");
+ }
+ });
+ } else {
+ const radioButton = li.querySelector('input[type="radio"]') as HTMLInputElement;
+ if (radioButton) {
+ if (radioButton.checked) {
+ li.classList.add("active");
+ }
+
+ radioButton.addEventListener("change", () => {
+ this._element.querySelectorAll("li").forEach((el) => el.classList.remove("active"));
+
+ if (radioButton.checked) {
+ li.classList.add("active");
+ } else {
+ li.classList.remove("active");
+ }
+ });
+ }
+ }
+ });
+
+ // re-insert the modified DOM
+ parent.insertBefore(this._element, nextSibling);
+ this._element.scrollTop = scrollTop;
+ }
+
+ /**
+ * Sets the visibility of marked items.
+ */
+ protected _setVisibility(event: MouseEvent): void {
+ event.preventDefault();
+
+ const link = event.currentTarget as HTMLElement;
+ const type = link.dataset.type;
+
+ UiDropdownSimple.close(this._dropdownId);
+
+ if (this._element.dataset.filter === type) {
+ // filter did not change
+ return;
+ }
+
+ this._element.dataset.filter = type;
+
+ const activeElement = this._dropdown!.querySelector(".active")!;
+ activeElement.classList.remove("active");
+ link.parentElement!.classList.add("active");
+
+ const button = document.getElementById(this._dropdownId) as HTMLElement;
+ if (type === "showAll") {
+ button.classList.remove("active");
+ } else {
+ button.classList.add("active");
+ }
+
+ const icon = button.querySelector(".icon") as HTMLElement;
+ if (type === "showAll") {
+ icon.classList.add("fa-eye");
+ icon.classList.remove("fa-eye-slash");
+ } else {
+ icon.classList.remove("fa-eye");
+ icon.classList.add("fa-eye-slash");
+ }
+ }
+}
+
+Core.enableLegacyInheritance(UiItemListFilter);
+
+export = UiItemListFilter;
--- /dev/null
+/**
+ * Flexible UI element featuring both a list of items and an input field.
+ *
+ * @author Alexander Ebert, Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/ItemList/Static
+ */
+
+import * as Core from "../../Core";
+import * as DomTraverse from "../../Dom/Traverse";
+import * as Language from "../../Language";
+import UiDropdownSimple from "../Dropdown/Simple";
+
+export type CallbackChange = (elementId: string, values: ItemData[]) => void;
+export type CallbackSubmit = (form: HTMLFormElement, values: ItemData[]) => void;
+
+export interface ItemListStaticOptions {
+ maxItems: number;
+ maxLength: number;
+ isCSV: boolean;
+ callbackChange: CallbackChange | null;
+ callbackSubmit: CallbackSubmit | null;
+ submitFieldName: string;
+}
+
+type ItemListInputElement = HTMLInputElement | HTMLTextAreaElement;
+
+export interface ItemData {
+ objectId: number;
+ value: string;
+ type?: string;
+}
+
+type PlainValue = string;
+
+type ItemDataOrPlainValue = ItemData | PlainValue;
+
+interface UiData {
+ element: HTMLInputElement | HTMLTextAreaElement;
+ list: HTMLOListElement;
+ shadow?: HTMLInputElement;
+ values: string[];
+}
+
+interface ElementData {
+ dropdownMenu: HTMLElement | null;
+ element: ItemListInputElement;
+ list: HTMLOListElement;
+ listItem: HTMLElement;
+ options: ItemListStaticOptions;
+ shadow?: HTMLInputElement;
+}
+
+const _data = new Map<string, ElementData>();
+
+/**
+ * Creates the DOM structure for target element. If `element` is a `<textarea>`
+ * it will be automatically replaced with an `<input>` element.
+ */
+function createUI(element: ItemListInputElement, options: ItemListStaticOptions): UiData {
+ const list = document.createElement("ol");
+ list.className = "inputItemList" + (element.disabled ? " disabled" : "");
+ list.dataset.elementId = element.id;
+ list.addEventListener("click", (event) => {
+ if (event.target === list) {
+ element.focus();
+ }
+ });
+
+ const listItem = document.createElement("li");
+ listItem.className = "input";
+ list.appendChild(listItem);
+
+ element.addEventListener("keydown", (ev: KeyboardEvent) => keyDown(ev));
+ element.addEventListener("keypress", (ev: KeyboardEvent) => keyPress(ev));
+ element.addEventListener("keyup", (ev: KeyboardEvent) => keyUp(ev));
+ element.addEventListener("paste", (ev: ClipboardEvent) => paste(ev));
+ element.addEventListener("blur", (ev: FocusEvent) => blur(ev));
+
+ element.insertAdjacentElement("beforebegin", list);
+ listItem.appendChild(element);
+
+ if (options.maxLength !== -1) {
+ element.maxLength = options.maxLength;
+ }
+
+ let shadow: HTMLInputElement | undefined;
+ let values: string[] = [];
+ if (options.isCSV) {
+ shadow = document.createElement("input");
+ shadow.className = "itemListInputShadow";
+ shadow.type = "hidden";
+ shadow.name = element.name;
+ element.removeAttribute("name");
+
+ list.insertAdjacentElement("beforebegin", shadow);
+
+ values = element.value
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+
+ if (element.nodeName === "TEXTAREA") {
+ const inputElement = document.createElement("input");
+ inputElement.type = "text";
+ element.parentElement!.insertBefore(inputElement, element);
+ inputElement.id = element.id;
+
+ element.remove();
+ element = inputElement;
+ }
+ }
+
+ return {
+ element,
+ list,
+ shadow,
+ values,
+ };
+}
+
+/**
+ * Enforces the maximum number of items.
+ */
+function handleLimit(elementId: string): void {
+ const data = _data.get(elementId)!;
+ if (data.options.maxItems === -1) {
+ return;
+ }
+
+ if (data.list.childElementCount - 1 < data.options.maxItems) {
+ if (data.element.disabled) {
+ data.element.disabled = false;
+ data.element.removeAttribute("placeholder");
+ }
+ } else if (!data.element.disabled) {
+ data.element.disabled = true;
+ data.element.placeholder = Language.get("wcf.global.form.input.maxItems");
+ }
+}
+
+/**
+ * Sets the active item list id and handles keyboard access to remove an existing item.
+ */
+function keyDown(event: KeyboardEvent): void {
+ const input = event.currentTarget as HTMLInputElement;
+ const lastItem = input.parentElement!.previousElementSibling as HTMLElement;
+
+ if (event.key === "Backspace") {
+ if (input.value.length === 0) {
+ if (lastItem !== null) {
+ if (lastItem.classList.contains("active")) {
+ removeItem(lastItem);
+ } else {
+ lastItem.classList.add("active");
+ }
+ }
+ }
+ } else if (event.key === "Escape") {
+ if (lastItem !== null && lastItem.classList.contains("active")) {
+ lastItem.classList.remove("active");
+ }
+ }
+}
+
+/**
+ * Handles the `[ENTER]` and `[,]` key to add an item to the list.
+ */
+function keyPress(event: KeyboardEvent): void {
+ if (event.key === "Enter" || event.key === "Comma") {
+ event.preventDefault();
+
+ const input = event.currentTarget as HTMLInputElement;
+ const value = input.value.trim();
+ if (value.length) {
+ addItem(input.id, { objectId: 0, value: value });
+ }
+ }
+}
+
+/**
+ * Splits comma-separated values being pasted into the input field.
+ */
+function paste(event: ClipboardEvent): void {
+ const input = event.currentTarget as HTMLInputElement;
+
+ const text = event.clipboardData!.getData("text/plain");
+ text
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0)
+ .forEach((s) => {
+ addItem(input.id, { objectId: 0, value: s });
+ });
+
+ event.preventDefault();
+}
+
+/**
+ * Handles the keyup event to unmark an item for deletion.
+ */
+function keyUp(event: KeyboardEvent): void {
+ const input = event.currentTarget as HTMLInputElement;
+
+ if (input.value.length > 0) {
+ const lastItem = input.parentElement!.previousElementSibling;
+ if (lastItem !== null) {
+ lastItem.classList.remove("active");
+ }
+ }
+}
+
+/**
+ * Adds an item to the list.
+ */
+function addItem(elementId: string, value: ItemData, forceRemoveIcon?: boolean): void {
+ const data = _data.get(elementId)!;
+
+ const listItem = document.createElement("li");
+ listItem.className = "item";
+
+ const content = document.createElement("span");
+ content.className = "content";
+ content.dataset.objectId = value.objectId.toString();
+ content.textContent = value.value;
+ listItem.appendChild(content);
+
+ if (forceRemoveIcon || !data.element.disabled) {
+ const button = document.createElement("a");
+ button.className = "icon icon16 fa-times";
+ button.addEventListener("click", (ev) => removeItem(ev));
+ listItem.appendChild(button);
+ }
+
+ data.list.insertBefore(listItem, data.listItem);
+ data.element.value = "";
+
+ if (!data.element.disabled) {
+ handleLimit(elementId);
+ }
+ let values = syncShadow(data);
+
+ if (typeof data.options.callbackChange === "function") {
+ if (values === null) {
+ values = getValues(elementId);
+ }
+ data.options.callbackChange(elementId, values);
+ }
+}
+
+/**
+ * Removes an item from the list.
+ */
+function removeItem(item: MouseEvent | HTMLElement, noFocus?: boolean): void {
+ if (item instanceof Event) {
+ item = (item.currentTarget as HTMLElement).parentElement as HTMLElement;
+ }
+
+ const parent = item.parentElement!;
+ const elementId = parent.dataset.elementId!;
+ const data = _data.get(elementId)!;
+
+ item.remove();
+ if (!noFocus) {
+ data.element.focus();
+ }
+
+ handleLimit(elementId);
+ let values = syncShadow(data);
+
+ if (typeof data.options.callbackChange === "function") {
+ if (values === null) {
+ values = getValues(elementId);
+ }
+ data.options.callbackChange(elementId, values);
+ }
+}
+
+/**
+ * Synchronizes the shadow input field with the current list item values.
+ */
+function syncShadow(data: ElementData): ItemData[] | null {
+ if (!data.options.isCSV) {
+ return null;
+ }
+
+ const values = getValues(data.element.id);
+
+ data.shadow!.value = values.map((v) => v.value).join(",");
+
+ return values;
+}
+
+/**
+ * Handles the blur event.
+ */
+function blur(event: FocusEvent): void {
+ const input = event.currentTarget as HTMLInputElement;
+
+ window.setTimeout(() => {
+ const value = input.value.trim();
+ if (value.length) {
+ addItem(input.id, { objectId: 0, value: value });
+ }
+ }, 100);
+}
+
+/**
+ * Initializes an item list.
+ *
+ * The `values` argument must be empty or contain a list of strings or object, e.g.
+ * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
+ */
+export function init(elementId: string, values: ItemDataOrPlainValue[], opts: Partial<ItemListStaticOptions>): void {
+ const element = document.getElementById(elementId) as HTMLInputElement | HTMLTextAreaElement;
+ if (element === null) {
+ throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
+ }
+
+ // remove data from previous instance
+ if (_data.has(elementId)) {
+ const tmp = _data.get(elementId)!;
+
+ Object.values(tmp).forEach((value) => {
+ if (value instanceof HTMLElement && value.parentElement) {
+ value.remove();
+ }
+ });
+
+ UiDropdownSimple.destroy(elementId);
+ _data.delete(elementId);
+ }
+
+ const options = Core.extend(
+ {
+ // maximum number of items this list may contain, `-1` for infinite
+ maxItems: -1,
+ // maximum length of an item value, `-1` for infinite
+ maxLength: -1,
+
+ // initial value will be interpreted as comma separated value and submitted as such
+ isCSV: false,
+
+ // will be invoked whenever the items change, receives the element id first and list of values second
+ callbackChange: null,
+ // callback once the form is about to be submitted
+ callbackSubmit: null,
+ // value may contain the placeholder `{$objectId}`
+ submitFieldName: "",
+ },
+ opts,
+ ) as ItemListStaticOptions;
+
+ const form = DomTraverse.parentByTag(element, "FORM") as HTMLFormElement;
+ if (form !== null) {
+ if (!options.isCSV) {
+ if (!options.submitFieldName.length && typeof options.callbackSubmit !== "function") {
+ throw new Error(
+ "Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.",
+ );
+ }
+
+ form.addEventListener("submit", () => {
+ const values = getValues(elementId);
+ if (options.submitFieldName.length) {
+ values.forEach((value) => {
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = options.submitFieldName.replace("{$objectId}", value.objectId.toString());
+ input.value = value.value;
+
+ form.appendChild(input);
+ });
+ } else {
+ options.callbackSubmit!(form, values);
+ }
+ });
+ }
+ }
+
+ const data = createUI(element, options);
+ _data.set(elementId, {
+ dropdownMenu: null,
+ element: data.element,
+ list: data.list,
+ listItem: data.element.parentElement!,
+ options: options,
+ shadow: data.shadow,
+ });
+
+ values = data.values.length ? data.values : values;
+ if (Array.isArray(values)) {
+ const forceRemoveIcon = !data.element.disabled;
+
+ values.forEach((value) => {
+ if (typeof value === "string") {
+ value = { objectId: 0, value: value };
+ }
+
+ addItem(elementId, value, forceRemoveIcon);
+ });
+ }
+}
+
+/**
+ * Returns the list of current values.
+ */
+export function getValues(elementId: string): ItemData[] {
+ if (!_data.has(elementId)) {
+ throw new Error(`Element id '${elementId}' is unknown.`);
+ }
+
+ const data = _data.get(elementId)!;
+
+ const values: ItemData[] = [];
+ data.list.querySelectorAll(".item > span").forEach((span: HTMLElement) => {
+ values.push({
+ objectId: ~~span.dataset.objectId!,
+ value: span.textContent!,
+ });
+ });
+
+ return values;
+}
+
+/**
+ * Sets the list of current values.
+ */
+export function setValues(elementId: string, values: ItemData[]): void {
+ if (!_data.has(elementId)) {
+ throw new Error(`Element id '${elementId}' is unknown.`);
+ }
+
+ const data = _data.get(elementId)!;
+
+ // remove all existing items first
+ const items = DomTraverse.childrenByClass(data.list, "item");
+ items.forEach((item: HTMLElement) => removeItem(item, true));
+
+ // add new items
+ values.forEach((v) => addItem(elementId, v));
+}
--- /dev/null
+/**
+ * Provides an item list for users and groups.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/ItemList/User
+ */
+
+import { CallbackChange, CallbackSetupValues, CallbackSyncShadow, ElementData, ItemData } from "../ItemList";
+import * as UiItemList from "../ItemList";
+
+interface ItemListUserOptions {
+ callbackChange?: CallbackChange;
+ callbackSetupValues?: CallbackSetupValues;
+ csvPerType?: boolean;
+ excludedSearchValues?: string[];
+ includeUserGroups?: boolean;
+ maxItems?: number;
+ restrictUserGroupIDs?: number[];
+}
+
+interface UserElementData extends ElementData {
+ _shadowGroups?: HTMLInputElement;
+}
+
+function syncShadow(data: UserElementData): ReturnType<CallbackSyncShadow> {
+ const values = getValues(data.element.id);
+
+ const users: string[] = [];
+ const groups: number[] = [];
+ values.forEach((value) => {
+ if (value.type && value.type === "group") {
+ groups.push(value.objectId);
+ } else {
+ users.push(value.value);
+ }
+ });
+
+ const shadowElement = data.shadow!;
+ shadowElement.value = users.join(",");
+ if (!data._shadowGroups) {
+ data._shadowGroups = document.createElement("input");
+ data._shadowGroups.type = "hidden";
+ data._shadowGroups.name = `${shadowElement.name}GroupIDs`;
+ shadowElement.insertAdjacentElement("beforebegin", data._shadowGroups);
+ }
+ data._shadowGroups.value = groups.join(",");
+
+ return values;
+}
+
+/**
+ * Initializes user suggestion support for an element.
+ *
+ * @param {string} elementId input element id
+ * @param {object} options option list
+ */
+export function init(elementId: string, options: ItemListUserOptions): void {
+ UiItemList.init(elementId, [], {
+ ajax: {
+ className: "wcf\\data\\user\\UserAction",
+ parameters: {
+ data: {
+ includeUserGroups: options.includeUserGroups ? ~~options.includeUserGroups : 0,
+ restrictUserGroupIDs: Array.isArray(options.restrictUserGroupIDs) ? options.restrictUserGroupIDs : [],
+ },
+ },
+ },
+ callbackChange: typeof options.callbackChange === "function" ? options.callbackChange : null,
+ callbackSyncShadow: options.csvPerType ? syncShadow : null,
+ callbackSetupValues: typeof options.callbackSetupValues === "function" ? options.callbackSetupValues : null,
+ excludedSearchValues: Array.isArray(options.excludedSearchValues) ? options.excludedSearchValues : [],
+ isCSV: true,
+ maxItems: options.maxItems ? ~~options.maxItems : -1,
+ restricted: true,
+ });
+}
+
+/**
+ * @see WoltLabSuite/Core/Ui/ItemList::getValues()
+ */
+export function getValues(elementId: string): ItemData[] {
+ return UiItemList.getValues(elementId);
+}
--- /dev/null
+/**
+ * Provides interface elements to display and review likes.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Like/Handler
+ * @deprecated 5.2 use ReactionHandler instead
+ */
+
+import * as Core from "../../Core";
+import DomChangeListener from "../../Dom/Change/Listener";
+import * as Language from "../../Language";
+import * as StringUtil from "../../StringUtil";
+import UiReactionHandler from "../Reaction/Handler";
+import User from "../../User";
+
+interface LikeHandlerOptions {
+ // settings
+ badgeClassNames: string;
+ isSingleItem: boolean;
+ markListItemAsActive: boolean;
+ renderAsButton: boolean;
+ summaryPrepend: boolean;
+ summaryUseIcon: boolean;
+
+ // permissions
+ canDislike: boolean;
+ canLike: boolean;
+ canLikeOwnContent: boolean;
+ canViewSummary: boolean;
+
+ // selectors
+ badgeContainerSelector: string;
+ buttonAppendToSelector: string;
+ buttonBeforeSelector: string;
+ containerSelector: string;
+ summarySelector: string;
+}
+
+interface LikeUsers {
+ [key: string]: number;
+}
+
+interface ElementData {
+ badge: HTMLUListElement | null;
+ dislikeButton: null;
+ likeButton: HTMLAnchorElement | null;
+ summary: null;
+
+ dislikes: number;
+ liked: number;
+ likes: number;
+ objectId: number;
+ users: LikeUsers;
+}
+
+const availableReactions = new Map(Object.entries(window.REACTION_TYPES));
+
+class UiLikeHandler {
+ protected readonly _containers = new WeakMap<HTMLElement, ElementData>();
+ protected readonly _objectType: string;
+ protected readonly _options: LikeHandlerOptions;
+
+ /**
+ * Initializes the like handler.
+ */
+ constructor(objectType: string, opts: Partial<LikeHandlerOptions>) {
+ if (!opts.containerSelector) {
+ throw new Error(
+ "[WoltLabSuite/Core/Ui/Like/Handler] Expected a non-empty string for option 'containerSelector'.",
+ );
+ }
+
+ this._objectType = objectType;
+ this._options = Core.extend(
+ {
+ // settings
+ badgeClassNames: "",
+ isSingleItem: false,
+ markListItemAsActive: false,
+ renderAsButton: true,
+ summaryPrepend: true,
+ summaryUseIcon: true,
+
+ // permissions
+ canDislike: false,
+ canLike: false,
+ canLikeOwnContent: false,
+ canViewSummary: false,
+
+ // selectors
+ badgeContainerSelector: ".messageHeader .messageStatus",
+ buttonAppendToSelector: ".messageFooter .messageFooterButtons",
+ buttonBeforeSelector: "",
+ containerSelector: "",
+ summarySelector: ".messageFooterGroup",
+ },
+ opts,
+ ) as LikeHandlerOptions;
+
+ this.initContainers();
+
+ DomChangeListener.add(`WoltLabSuite/Core/Ui/Like/Handler-${objectType}`, () => this.initContainers());
+
+ new UiReactionHandler(this._objectType, {
+ containerSelector: this._options.containerSelector,
+ });
+ }
+
+ /**
+ * Initializes all applicable containers.
+ */
+ initContainers(): void {
+ let triggerChange = false;
+
+ document.querySelectorAll(this._options.containerSelector).forEach((element: HTMLElement) => {
+ if (this._containers.has(element)) {
+ return;
+ }
+
+ const elementData = {
+ badge: null,
+ dislikeButton: null,
+ likeButton: null,
+ summary: null,
+
+ dislikes: ~~element.dataset.likeDislikes!,
+ liked: ~~element.dataset.likeLiked!,
+ likes: ~~element.dataset.likeLikes!,
+ objectId: ~~element.dataset.objectId!,
+ users: JSON.parse(element.dataset.likeUsers!),
+ };
+
+ this._containers.set(element, elementData);
+ this._buildWidget(element, elementData);
+
+ triggerChange = true;
+ });
+
+ if (triggerChange) {
+ DomChangeListener.trigger();
+ }
+ }
+
+ /**
+ * Creates the interface elements.
+ */
+ protected _buildWidget(element: HTMLElement, elementData: ElementData): void {
+ let badgeContainer: HTMLElement | null;
+ let isSummaryPosition = true;
+
+ if (this._options.isSingleItem) {
+ badgeContainer = document.querySelector(this._options.summarySelector);
+ } else {
+ badgeContainer = element.querySelector(this._options.summarySelector);
+ }
+
+ if (badgeContainer === null) {
+ if (this._options.isSingleItem) {
+ badgeContainer = document.querySelector(this._options.badgeContainerSelector);
+ } else {
+ badgeContainer = element.querySelector(this._options.badgeContainerSelector);
+ }
+
+ isSummaryPosition = false;
+ }
+
+ if (badgeContainer !== null) {
+ const summaryList = document.createElement("ul");
+ summaryList.classList.add("reactionSummaryList");
+ if (isSummaryPosition) {
+ summaryList.classList.add("likesSummary");
+ } else {
+ summaryList.classList.add("reactionSummaryListTiny");
+ }
+
+ Object.entries(elementData.users).forEach(([reactionTypeId, count]) => {
+ const reaction = availableReactions.get(reactionTypeId);
+ if (reactionTypeId === "reactionTypeID" || !reaction) {
+ return;
+ }
+
+ // create element
+ const createdElement = document.createElement("li");
+ createdElement.className = "reactCountButton";
+ createdElement.setAttribute("reaction-type-id", reactionTypeId);
+
+ const countSpan = document.createElement("span");
+ countSpan.className = "reactionCount";
+ countSpan.innerHTML = StringUtil.shortUnit(~~count);
+ createdElement.appendChild(countSpan);
+
+ createdElement.innerHTML = reaction.renderedIcon + createdElement.innerHTML;
+
+ summaryList.appendChild(createdElement);
+ });
+
+ if (isSummaryPosition) {
+ if (this._options.summaryPrepend) {
+ badgeContainer.insertAdjacentElement("afterbegin", summaryList);
+ } else {
+ badgeContainer.insertAdjacentElement("beforeend", summaryList);
+ }
+ } else {
+ if (badgeContainer.nodeName === "OL" || badgeContainer.nodeName === "UL") {
+ const listItem = document.createElement("li");
+ listItem.appendChild(summaryList);
+ badgeContainer.appendChild(listItem);
+ } else {
+ badgeContainer.appendChild(summaryList);
+ }
+ }
+
+ elementData.badge = summaryList;
+ }
+
+ // build reaction button
+ if (this._options.canLike && (User.userId != ~~element.dataset.userId! || this._options.canLikeOwnContent)) {
+ let appendTo: HTMLElement | null = null;
+ if (this._options.buttonAppendToSelector) {
+ if (this._options.isSingleItem) {
+ appendTo = document.querySelector(this._options.buttonAppendToSelector);
+ } else {
+ appendTo = element.querySelector(this._options.buttonAppendToSelector);
+ }
+ }
+
+ let insertPosition: HTMLElement | null = null;
+ if (this._options.buttonBeforeSelector) {
+ if (this._options.isSingleItem) {
+ insertPosition = document.querySelector(this._options.buttonBeforeSelector);
+ } else {
+ insertPosition = element.querySelector(this._options.buttonBeforeSelector);
+ }
+ }
+
+ if (insertPosition === null && appendTo === null) {
+ throw new Error("Unable to find insert location for like/dislike buttons.");
+ } else {
+ elementData.likeButton = this._createButton(
+ element,
+ elementData.users.reactionTypeID,
+ insertPosition,
+ appendTo,
+ );
+ }
+ }
+ }
+
+ /**
+ * Creates a reaction button.
+ */
+ protected _createButton(
+ element: HTMLElement,
+ reactionTypeID: number,
+ insertBefore: HTMLElement | null,
+ appendTo: HTMLElement | null,
+ ): HTMLAnchorElement {
+ const title = Language.get("wcf.reactions.react");
+
+ const listItem = document.createElement("li");
+ listItem.className = "wcfReactButton";
+
+ const button = document.createElement("a");
+ button.className = "jsTooltip reactButton";
+ if (this._options.renderAsButton) {
+ button.classList.add("button");
+ }
+
+ button.href = "#";
+ button.title = title;
+
+ const icon = document.createElement("span");
+ icon.className = "icon icon16 fa-smile-o";
+
+ if (reactionTypeID === undefined || reactionTypeID == 0) {
+ icon.dataset.reactionTypeId = "0";
+ } else {
+ button.dataset.reactionTypeId = reactionTypeID.toString();
+ button.classList.add("active");
+ }
+
+ button.appendChild(icon);
+
+ const invisibleText = document.createElement("span");
+ invisibleText.className = "invisible";
+ invisibleText.innerHTML = title;
+
+ button.appendChild(document.createTextNode(" "));
+ button.appendChild(invisibleText);
+
+ listItem.appendChild(button);
+
+ if (insertBefore) {
+ insertBefore.insertAdjacentElement("beforebegin", listItem);
+ } else {
+ appendTo!.insertAdjacentElement("beforeend", listItem);
+ }
+
+ return button;
+ }
+}
+
+Core.enableLegacyInheritance(UiLikeHandler);
+
+export = UiLikeHandler;
--- /dev/null
+/**
+ * Flexible message inline editor.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Message/InlineEditor
+ */
+
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
+import * as Core from "../../Core";
+import DomChangeListener from "../../Dom/Change/Listener";
+import DomUtil from "../../Dom/Util";
+import * as Environment from "../../Environment";
+import * as EventHandler from "../../Event/Handler";
+import * as Language from "../../Language";
+import { NotificationAction } from "../Dropdown/Data";
+import * as UiDropdownReusable from "../Dropdown/Reusable";
+import * as UiNotification from "../Notification";
+import * as UiScroll from "../Scroll";
+
+interface MessageInlineEditorOptions {
+ canEditInline: boolean;
+
+ className: string;
+ containerId: string;
+ dropdownIdentifier: string;
+ editorPrefix: string;
+
+ messageSelector: string;
+
+ // This is the legacy jQuery based class.
+ quoteManager: any;
+}
+
+interface ElementData {
+ button: HTMLAnchorElement;
+ messageBody: HTMLElement;
+ messageBodyEditor: HTMLElement | null;
+ messageFooter: HTMLElement;
+ messageFooterButtons: HTMLUListElement;
+ messageHeader: HTMLElement;
+ messageText: HTMLElement;
+}
+
+interface ItemData {
+ item: "divider" | "editItem" | string;
+ label?: string;
+}
+
+interface ElementVisibility {
+ [key: string]: boolean;
+}
+
+interface ValidationData {
+ api: UiMessageInlineEditor;
+ parameters: ArbitraryObject;
+ valid: boolean;
+ promises: Promise<void>[];
+}
+
+interface AjaxResponseEditor extends ResponseData {
+ returnValues: {
+ template: string;
+ };
+}
+
+interface AjaxResponseMessage extends ResponseData {
+ returnValues: {
+ attachmentList?: string;
+ message: string;
+ poll?: string;
+ };
+}
+
+class UiMessageInlineEditor implements AjaxCallbackObject {
+ protected _activeDropdownElement: HTMLElement | null;
+ protected _activeElement: HTMLElement | null;
+ protected _dropdownMenu: HTMLUListElement | null;
+ protected _elements: WeakMap<HTMLElement, ElementData>;
+ protected _options: MessageInlineEditorOptions;
+
+ /**
+ * Initializes the message inline editor.
+ */
+ constructor(opts: Partial<MessageInlineEditorOptions>) {
+ this.init(opts);
+ }
+
+ /**
+ * Helper initialization method for legacy inheritance support.
+ */
+ protected init(opts: Partial<MessageInlineEditorOptions>): void {
+ // Define the properties again, the constructor might not be
+ // called in legacy implementations.
+ this._activeDropdownElement = null;
+ this._activeElement = null;
+ this._dropdownMenu = null;
+ this._elements = new WeakMap<HTMLElement, ElementData>();
+
+ this._options = Core.extend(
+ {
+ canEditInline: false,
+
+ className: "",
+ containerId: 0,
+ dropdownIdentifier: "",
+ editorPrefix: "messageEditor",
+
+ messageSelector: ".jsMessage",
+
+ quoteManager: null,
+ },
+ opts,
+ ) as MessageInlineEditorOptions;
+
+ this.rebuild();
+
+ DomChangeListener.add(`Ui/Message/InlineEdit_${this._options.className}`, () => this.rebuild());
+ }
+
+ /**
+ * Initializes each applicable message, should be called whenever new
+ * messages are being displayed.
+ */
+ rebuild(): void {
+ document.querySelectorAll(this._options.messageSelector).forEach((element: HTMLElement) => {
+ if (this._elements.has(element)) {
+ return;
+ }
+
+ const button = element.querySelector(".jsMessageEditButton") as HTMLAnchorElement;
+ if (button !== null) {
+ const canEdit = Core.stringToBool(element.dataset.canEdit || "");
+ const canEditInline = Core.stringToBool(element.dataset.canEditInline || "");
+
+ if (this._options.canEditInline || canEditInline) {
+ button.addEventListener("click", (ev) => this._clickDropdown(element, ev));
+ button.classList.add("jsDropdownEnabled");
+
+ if (canEdit) {
+ button.addEventListener("dblclick", (ev) => this._click(element, ev));
+ }
+ } else if (canEdit) {
+ button.addEventListener("click", (ev) => this._click(element, ev));
+ }
+ }
+
+ const messageBody = element.querySelector(".messageBody") as HTMLElement;
+ const messageFooter = element.querySelector(".messageFooter") as HTMLElement;
+ const messageFooterButtons = messageFooter.querySelector(".messageFooterButtons") as HTMLUListElement;
+ const messageHeader = element.querySelector(".messageHeader") as HTMLElement;
+ const messageText = messageBody.querySelector(".messageText") as HTMLElement;
+
+ this._elements.set(element, {
+ button,
+ messageBody,
+ messageBodyEditor: null,
+ messageFooter,
+ messageFooterButtons,
+ messageHeader,
+ messageText,
+ });
+ });
+ }
+
+ /**
+ * Handles clicks on the edit button or the edit dropdown item.
+ */
+ protected _click(element: HTMLElement | null, event: MouseEvent | null): void {
+ if (element === null) {
+ element = this._activeDropdownElement;
+ }
+ if (event) {
+ event.preventDefault();
+ }
+
+ if (this._activeElement === null) {
+ this._activeElement = element;
+
+ this._prepare();
+
+ Ajax.api(this, {
+ actionName: "beginEdit",
+ parameters: {
+ containerID: this._options.containerId,
+ objectID: this._getObjectId(element!),
+ },
+ });
+ } else {
+ UiNotification.show("wcf.message.error.editorAlreadyInUse", undefined, "warning");
+ }
+ }
+
+ /**
+ * Creates and opens the dropdown on first usage.
+ */
+ protected _clickDropdown(element: HTMLElement, event: MouseEvent): void {
+ event.preventDefault();
+
+ const button = event.currentTarget as HTMLElement;
+ if (button.classList.contains("dropdownToggle")) {
+ return;
+ }
+
+ button.classList.add("dropdownToggle");
+ button.parentElement!.classList.add("dropdown");
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ this._activeDropdownElement = element;
+ UiDropdownReusable.toggleDropdown(this._options.dropdownIdentifier, button);
+ });
+
+ // build dropdown
+ if (this._dropdownMenu === null) {
+ this._dropdownMenu = document.createElement("ul");
+ this._dropdownMenu.className = "dropdownMenu";
+
+ const items = this._dropdownGetItems();
+
+ EventHandler.fire("com.woltlab.wcf.inlineEditor", `dropdownInit_${this._options.dropdownIdentifier}`, {
+ items: items,
+ });
+
+ this._dropdownBuild(items);
+
+ UiDropdownReusable.init(this._options.dropdownIdentifier, this._dropdownMenu);
+ UiDropdownReusable.registerCallback(this._options.dropdownIdentifier, (containerId, action) =>
+ this._dropdownToggle(containerId, action),
+ );
+ }
+
+ setTimeout(() => button.click(), 10);
+ }
+
+ /**
+ * Creates the dropdown menu on first usage.
+ */
+ protected _dropdownBuild(items: ItemData[]): void {
+ items.forEach((item) => {
+ const listItem = document.createElement("li");
+ listItem.dataset.item = item.item;
+
+ if (item.item === "divider") {
+ listItem.className = "dropdownDivider";
+ } else {
+ const label = document.createElement("span");
+ label.textContent = Language.get(item.label!);
+ listItem.appendChild(label);
+
+ if (item.item === "editItem") {
+ listItem.addEventListener("click", (ev) => this._click(null, ev));
+ } else {
+ listItem.addEventListener("click", (ev) => this._clickDropdownItem(ev));
+ }
+ }
+
+ this._dropdownMenu!.appendChild(listItem);
+ });
+ }
+
+ /**
+ * Callback for dropdown toggle.
+ */
+ protected _dropdownToggle(containerId: string, action: NotificationAction): void {
+ const elementData = this._elements.get(this._activeDropdownElement!)!;
+ const buttonParent = elementData.button.parentElement!;
+
+ if (action === "close") {
+ buttonParent.classList.remove("dropdownOpen");
+ elementData.messageFooterButtons.classList.remove("forceVisible");
+
+ return;
+ }
+
+ buttonParent.classList.add("dropdownOpen");
+ elementData.messageFooterButtons.classList.add("forceVisible");
+
+ const visibility = new Map<string, boolean>(Object.entries(this._dropdownOpen()));
+
+ EventHandler.fire("com.woltlab.wcf.inlineEditor", `dropdownOpen_${this._options.dropdownIdentifier}`, {
+ element: this._activeDropdownElement,
+ visibility,
+ });
+
+ const dropdownMenu = this._dropdownMenu!;
+
+ let visiblePredecessor = false;
+ const children = Array.from(dropdownMenu.children);
+ children.forEach((listItem: HTMLElement, index) => {
+ const item = listItem.dataset.item!;
+
+ if (item === "divider") {
+ if (visiblePredecessor) {
+ DomUtil.show(listItem);
+
+ visiblePredecessor = false;
+ } else {
+ DomUtil.hide(listItem);
+ }
+ } else {
+ if (visibility.get(item) === false) {
+ DomUtil.hide(listItem);
+
+ // check if previous item was a divider
+ if (index > 0 && index + 1 === children.length) {
+ const previousElementSibling = listItem.previousElementSibling as HTMLElement;
+ if (previousElementSibling.dataset.item === "divider") {
+ DomUtil.hide(previousElementSibling);
+ }
+ }
+ } else {
+ DomUtil.show(listItem);
+
+ visiblePredecessor = true;
+ }
+ }
+ });
+ }
+
+ /**
+ * Returns the list of dropdown items for this type.
+ */
+ protected _dropdownGetItems(): ItemData[] {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+ return [];
+ }
+
+ /**
+ * Invoked once the dropdown for this type is shown, expects a list of type name and a boolean value
+ * to represent the visibility of each item. Items that do not appear in this list will be considered
+ * visible.
+ */
+ protected _dropdownOpen(): ElementVisibility {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+ return {};
+ }
+
+ /**
+ * Invoked whenever the user selects an item from the dropdown menu, the selected item is passed as argument.
+ */
+ protected _dropdownSelect(_item: string): void {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+ }
+
+ /**
+ * Handles clicks on a dropdown item.
+ */
+ protected _clickDropdownItem(event: MouseEvent): void {
+ event.preventDefault();
+
+ const target = event.currentTarget as HTMLElement;
+ const item = target.dataset.item!;
+ const data = {
+ cancel: false,
+ element: this._activeDropdownElement,
+ item,
+ };
+ EventHandler.fire("com.woltlab.wcf.inlineEditor", `dropdownItemClick_${this._options.dropdownIdentifier}`, data);
+
+ if (data.cancel) {
+ event.preventDefault();
+ } else {
+ this._dropdownSelect(item);
+ }
+ }
+
+ /**
+ * Prepares the message for editor display.
+ */
+ protected _prepare(): void {
+ const data = this._elements.get(this._activeElement!)!;
+
+ const messageBodyEditor = document.createElement("div");
+ messageBodyEditor.className = "messageBody editor";
+ data.messageBodyEditor = messageBodyEditor;
+
+ const icon = document.createElement("span");
+ icon.className = "icon icon48 fa-spinner";
+ messageBodyEditor.appendChild(icon);
+
+ data.messageBody.insertAdjacentElement("afterend", messageBodyEditor);
+
+ DomUtil.hide(data.messageBody);
+ }
+
+ /**
+ * Shows the message editor.
+ */
+ protected _showEditor(data: AjaxResponseEditor): void {
+ const id = this._getEditorId();
+ const activeElement = this._activeElement!;
+ const elementData = this._elements.get(activeElement)!;
+
+ activeElement.classList.add("jsInvalidQuoteTarget");
+ const icon = elementData.messageBodyEditor!.querySelector(".icon") as HTMLElement;
+ icon.remove();
+
+ const messageBody = elementData.messageBodyEditor!;
+ const editor = document.createElement("div");
+ editor.className = "editorContainer";
+ DomUtil.setInnerHtml(editor, data.returnValues.template);
+ messageBody.appendChild(editor);
+
+ // bind buttons
+ const formSubmit = editor.querySelector(".formSubmit") as HTMLElement;
+
+ const buttonSave = formSubmit.querySelector('button[data-type="save"]') as HTMLButtonElement;
+ buttonSave.addEventListener("click", () => this._save());
+
+ const buttonCancel = formSubmit.querySelector('button[data-type="cancel"]') as HTMLButtonElement;
+ buttonCancel.addEventListener("click", () => this._restoreMessage());
+
+ EventHandler.add("com.woltlab.wcf.redactor", `submitEditor_${id}`, (data: { cancel: boolean }) => {
+ data.cancel = true;
+
+ this._save();
+ });
+
+ // hide message header and footer
+ DomUtil.hide(elementData.messageHeader);
+ DomUtil.hide(elementData.messageFooter);
+
+ if (Environment.editor() === "redactor") {
+ window.setTimeout(() => {
+ if (this._options.quoteManager) {
+ this._options.quoteManager.setAlternativeEditor(id);
+ }
+
+ UiScroll.element(activeElement);
+ }, 250);
+ } else {
+ const editorElement = document.getElementById(id) as HTMLElement;
+ editorElement.focus();
+ }
+ }
+
+ /**
+ * Restores the message view.
+ */
+ protected _restoreMessage(): void {
+ const activeElement = this._activeElement!;
+ const elementData = this._elements.get(activeElement)!;
+
+ this._destroyEditor();
+
+ elementData.messageBodyEditor!.remove();
+ elementData.messageBodyEditor = null;
+
+ DomUtil.show(elementData.messageBody);
+ DomUtil.show(elementData.messageFooter);
+ DomUtil.show(elementData.messageHeader);
+ activeElement.classList.remove("jsInvalidQuoteTarget");
+
+ this._activeElement = null;
+
+ if (this._options.quoteManager) {
+ this._options.quoteManager.clearAlternativeEditor();
+ }
+ }
+
+ /**
+ * Saves the editor message.
+ */
+ protected _save(): void {
+ const parameters = {
+ containerID: this._options.containerId,
+ data: {
+ message: "",
+ },
+ objectID: this._getObjectId(this._activeElement!),
+ removeQuoteIDs: this._options.quoteManager ? this._options.quoteManager.getQuotesMarkedForRemoval() : [],
+ };
+
+ const id = this._getEditorId();
+
+ // add any available settings
+ const settingsContainer = document.getElementById(`settings_${id}`);
+ if (settingsContainer) {
+ settingsContainer
+ .querySelectorAll("input, select, textarea")
+ .forEach((element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement) => {
+ if (element.nodeName === "INPUT" && (element.type === "checkbox" || element.type === "radio")) {
+ if (!(element as HTMLInputElement).checked) {
+ return;
+ }
+ }
+
+ const name = element.name;
+ if (Object.prototype.hasOwnProperty.call(parameters, name)) {
+ throw new Error(`Variable overshadowing, key '${name}' is already present.`);
+ }
+
+ parameters[name] = element.value.trim();
+ });
+ }
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", `getText_${id}`, parameters.data);
+
+ let validateResult: unknown = this._validate(parameters);
+
+ // Legacy validation methods returned a plain boolean.
+ if (!(validateResult instanceof Promise)) {
+ if (validateResult === false) {
+ validateResult = Promise.reject();
+ } else {
+ validateResult = Promise.resolve();
+ }
+ }
+
+ (validateResult as Promise<void[]>).then(
+ () => {
+ EventHandler.fire("com.woltlab.wcf.redactor2", `submit_${id}`, parameters);
+
+ Ajax.api(this, {
+ actionName: "save",
+ parameters: parameters,
+ });
+
+ this._hideEditor();
+ },
+ (e) => {
+ const errorMessage = (e as Error).message;
+ console.log(`Validation of post edit failed: ${errorMessage}`);
+ },
+ );
+ }
+
+ /**
+ * Validates the message and invokes listeners to perform additional validation.
+ */
+ protected _validate(parameters: ArbitraryObject): Promise<void[]> {
+ // remove all existing error elements
+ this._activeElement!.querySelectorAll(".innerError").forEach((el) => el.remove());
+
+ const data: ValidationData = {
+ api: this,
+ parameters: parameters,
+ valid: true,
+ promises: [],
+ };
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", `validate_${this._getEditorId()}`, data);
+
+ if (data.valid) {
+ data.promises.push(Promise.resolve());
+ } else {
+ data.promises.push(Promise.reject());
+ }
+
+ return Promise.all(data.promises);
+ }
+
+ /**
+ * Throws an error by showing an inline error for the target element.
+ */
+ throwError(element: HTMLElement, message: string): void {
+ DomUtil.innerError(element, message);
+ }
+
+ /**
+ * Shows the update message.
+ */
+ protected _showMessage(data: AjaxResponseMessage): void {
+ const activeElement = this._activeElement!;
+ const editorId = this._getEditorId();
+ const elementData = this._elements.get(activeElement)!;
+
+ // set new content
+ DomUtil.setInnerHtml(elementData.messageBody.querySelector(".messageText")!, data.returnValues.message);
+
+ // handle attachment list
+ if (typeof data.returnValues.attachmentList === "string") {
+ elementData.messageFooter
+ .querySelectorAll(".attachmentThumbnailList, .attachmentFileList")
+ .forEach((el) => el.remove());
+
+ const element = document.createElement("div");
+ DomUtil.setInnerHtml(element, data.returnValues.attachmentList);
+
+ let node;
+ while (element.childNodes.length) {
+ node = element.childNodes[element.childNodes.length - 1];
+ elementData.messageFooter.insertBefore(node, elementData.messageFooter.firstChild);
+ }
+ }
+
+ if (typeof data.returnValues.poll === "string") {
+ const poll = elementData.messageBody.querySelector(".pollContainer");
+ if (poll !== null) {
+ // The poll container is wrapped inside `.jsInlineEditorHideContent`.
+ poll.parentElement!.remove();
+ }
+
+ const pollContainer = document.createElement("div");
+ pollContainer.className = "jsInlineEditorHideContent";
+ DomUtil.setInnerHtml(pollContainer, data.returnValues.poll);
+
+ elementData.messageBody.insertAdjacentElement("afterbegin", pollContainer);
+ }
+
+ this._restoreMessage();
+
+ this._updateHistory(this._getHash(this._getObjectId(activeElement)));
+
+ EventHandler.fire("com.woltlab.wcf.redactor", `autosaveDestroy_${editorId}`);
+
+ UiNotification.show();
+
+ if (this._options.quoteManager) {
+ this._options.quoteManager.clearAlternativeEditor();
+ this._options.quoteManager.countQuotes();
+ }
+ }
+
+ /**
+ * Hides the editor from view.
+ */
+ protected _hideEditor(): void {
+ const elementData = this._elements.get(this._activeElement!)!;
+ const editorContainer = elementData.messageBodyEditor!.querySelector(".editorContainer") as HTMLElement;
+ DomUtil.hide(editorContainer);
+
+ const icon = document.createElement("span");
+ icon.className = "icon icon48 fa-spinner";
+ elementData.messageBodyEditor!.appendChild(icon);
+ }
+
+ /**
+ * Restores the previously hidden editor.
+ */
+ protected _restoreEditor(): void {
+ const elementData = this._elements.get(this._activeElement!)!;
+ const messageBodyEditor = elementData.messageBodyEditor!;
+
+ const icon = messageBodyEditor.querySelector(".fa-spinner") as HTMLElement;
+ icon.remove();
+
+ const editorContainer = messageBodyEditor.querySelector(".editorContainer") as HTMLElement;
+ if (editorContainer !== null) {
+ DomUtil.show(editorContainer);
+ }
+ }
+
+ /**
+ * Destroys the editor instance.
+ */
+ protected _destroyEditor(): void {
+ EventHandler.fire("com.woltlab.wcf.redactor2", `autosaveDestroy_${this._getEditorId()}`);
+ EventHandler.fire("com.woltlab.wcf.redactor2", `destroy_${this._getEditorId()}`);
+ }
+
+ /**
+ * Returns the hash added to the url after successfully editing a message.
+ */
+ protected _getHash(objectId: string): string {
+ return `#message${objectId}`;
+ }
+
+ /**
+ * Updates the history to avoid old content when going back in the browser
+ * history.
+ */
+ protected _updateHistory(hash: string): void {
+ window.location.hash = hash;
+ }
+
+ /**
+ * Returns the unique editor id.
+ */
+ protected _getEditorId(): string {
+ return this._options.editorPrefix + this._getObjectId(this._activeElement!).toString();
+ }
+
+ /**
+ * Returns the element's `data-object-id` value.
+ */
+ protected _getObjectId(element: HTMLElement): string {
+ return element.dataset.objectId || "";
+ }
+
+ _ajaxFailure(data: ResponseData): boolean {
+ const elementData = this._elements.get(this._activeElement!)!;
+ const editor = elementData.messageBodyEditor!.querySelector(".redactor-layer") as HTMLElement;
+
+ // handle errors occurring on editor load
+ if (editor === null) {
+ this._restoreMessage();
+
+ return true;
+ }
+
+ this._restoreEditor();
+
+ if (!data || data.returnValues === undefined || data.returnValues.realErrorMessage === undefined) {
+ return true;
+ }
+
+ DomUtil.innerError(editor, data.returnValues.realErrorMessage);
+
+ return false;
+ }
+
+ _ajaxSuccess(data: ResponseData): void {
+ switch (data.actionName) {
+ case "beginEdit":
+ this._showEditor(data as AjaxResponseEditor);
+ break;
+
+ case "save":
+ this._showMessage(data as AjaxResponseMessage);
+ break;
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ className: this._options.className,
+ interfaceName: "wcf\\data\\IMessageInlineEditorAction",
+ },
+ silent: true,
+ };
+ }
+
+ /** @deprecated 3.0 - used only for backward compatibility with `WCF.Message.InlineEditor` */
+ legacyEdit(containerId: string): void {
+ this._click(document.getElementById(containerId), null);
+ }
+}
+
+Core.enableLegacyInheritance(UiMessageInlineEditor);
+
+export = UiMessageInlineEditor;
--- /dev/null
+/**
+ * Provides access and editing of message properties.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Message/Manager
+ */
+
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
+import * as Core from "../../Core";
+import DomChangeListener from "../../Dom/Change/Listener";
+import * as Language from "../../Language";
+import * as StringUtil from "../../StringUtil";
+
+interface MessageManagerOptions {
+ className: string;
+ selector: string;
+}
+
+type StringableValue = boolean | number | string;
+
+class UiMessageManager implements AjaxCallbackObject {
+ protected readonly _elements = new Map<string, HTMLElement>();
+ protected readonly _options: MessageManagerOptions;
+
+ /**
+ * Initializes a new manager instance.
+ */
+ constructor(options: MessageManagerOptions) {
+ this._options = Core.extend(
+ {
+ className: "",
+ selector: "",
+ },
+ options,
+ ) as MessageManagerOptions;
+
+ this.rebuild();
+
+ DomChangeListener.add(`Ui/Message/Manager${this._options.className}`, this.rebuild.bind(this));
+ }
+
+ /**
+ * Rebuilds the list of observed messages. You should call this method whenever a
+ * message has been either added or removed from the document.
+ */
+ rebuild(): void {
+ this._elements.clear();
+
+ document.querySelectorAll(this._options.selector).forEach((element: HTMLElement) => {
+ this._elements.set(element.dataset.objectId!, element);
+ });
+ }
+
+ /**
+ * Returns a boolean value for the given permission. The permission should not start
+ * with "can" or "can-" as this is automatically assumed by this method.
+ */
+ getPermission(objectId: string, permission: string): boolean {
+ permission = "can" + StringUtil.ucfirst(permission);
+ const element = this._elements.get(objectId);
+ if (element === undefined) {
+ throw new Error(`Unknown object id '${objectId}' for selector '${this._options.selector}'`);
+ }
+
+ return Core.stringToBool(element.dataset[permission] || "");
+ }
+
+ /**
+ * Returns the given property value from a message, optionally supporting a boolean return value.
+ */
+ getPropertyValue(objectId: string, propertyName: string, asBool: boolean): boolean | string {
+ const element = this._elements.get(objectId);
+ if (element === undefined) {
+ throw new Error(`Unknown object id '${objectId}' for selector '${this._options.selector}'`);
+ }
+
+ const value = element.dataset[StringUtil.toCamelCase(propertyName)] || "";
+
+ if (asBool) {
+ return Core.stringToBool(value);
+ }
+
+ return value;
+ }
+
+ /**
+ * Invokes a method for given message object id in order to alter its state or properties.
+ */
+ update(objectId: string, actionName: string, parameters?: ArbitraryObject): void {
+ Ajax.api(this, {
+ actionName: actionName,
+ parameters: parameters || {},
+ objectIDs: [objectId],
+ });
+ }
+
+ /**
+ * Updates properties and states for given object ids. Keep in mind that this method does
+ * not support setting individual properties per message, instead all property changes
+ * are applied to all matching message objects.
+ */
+ updateItems(objectIds: string | string[], data: ArbitraryObject): void {
+ if (!Array.isArray(objectIds)) {
+ objectIds = [objectIds];
+ }
+
+ objectIds.forEach((objectId) => {
+ const element = this._elements.get(objectId);
+ if (element === undefined) {
+ return;
+ }
+
+ Object.entries(data).forEach(([key, value]) => {
+ this._update(element, key, value as StringableValue);
+ });
+ });
+ }
+
+ /**
+ * Bulk updates the properties and states for all observed messages at once.
+ */
+ updateAllItems(data: ArbitraryObject): void {
+ const objectIds = Array.from(this._elements.keys());
+
+ this.updateItems(objectIds, data);
+ }
+
+ /**
+ * Sets or removes a message note identified by its unique CSS class.
+ */
+ setNote(objectId: string, className: string, htmlContent: string): void {
+ const element = this._elements.get(objectId);
+ if (element === undefined) {
+ throw new Error(`Unknown object id '${objectId}' for selector '${this._options.selector}'`);
+ }
+
+ const messageFooterNotes = element.querySelector(".messageFooterNotes") as HTMLElement;
+ let note = messageFooterNotes.querySelector(`.${className}`);
+ if (htmlContent) {
+ if (note === null) {
+ note = document.createElement("p");
+ note.className = "messageFooterNote " + className;
+
+ messageFooterNotes.appendChild(note);
+ }
+
+ note.innerHTML = htmlContent;
+ } else if (note !== null) {
+ note.remove();
+ }
+ }
+
+ /**
+ * Updates a single property of a message element.
+ */
+ protected _update(element: HTMLElement, propertyName: string, propertyValue: StringableValue): void {
+ element.dataset[propertyName] = propertyValue.toString();
+
+ // handle special properties
+ const propertyValueBoolean = propertyValue == 1 || propertyValue === true || propertyValue === "true";
+ this._updateState(element, propertyName, propertyValue, propertyValueBoolean);
+ }
+
+ /**
+ * Updates the message element's state based upon a property change.
+ */
+ protected _updateState(
+ element: HTMLElement,
+ propertyName: string,
+ propertyValue: StringableValue,
+ propertyValueBoolean: boolean,
+ ): void {
+ switch (propertyName) {
+ case "isDeleted":
+ if (propertyValueBoolean) {
+ element.classList.add("messageDeleted");
+ } else {
+ element.classList.remove("messageDeleted");
+ }
+
+ this._toggleMessageStatus(element, "jsIconDeleted", "wcf.message.status.deleted", "red", propertyValueBoolean);
+
+ break;
+
+ case "isDisabled":
+ if (propertyValueBoolean) {
+ element.classList.add("messageDisabled");
+ } else {
+ element.classList.remove("messageDisabled");
+ }
+
+ this._toggleMessageStatus(
+ element,
+ "jsIconDisabled",
+ "wcf.message.status.disabled",
+ "green",
+ propertyValueBoolean,
+ );
+
+ break;
+ }
+ }
+
+ /**
+ * Toggles the message status bade for provided element.
+ */
+ protected _toggleMessageStatus(
+ element: HTMLElement,
+ className: string,
+ phrase: string,
+ badgeColor: string,
+ addBadge: boolean,
+ ): void {
+ let messageStatus = element.querySelector(".messageStatus");
+ if (messageStatus === null) {
+ const messageHeaderMetaData = element.querySelector(".messageHeaderMetaData");
+ if (messageHeaderMetaData === null) {
+ // can't find appropriate location to insert badge
+ return;
+ }
+
+ messageStatus = document.createElement("ul");
+ messageStatus.className = "messageStatus";
+ messageHeaderMetaData.insertAdjacentElement("afterend", messageStatus);
+ }
+
+ let badge = messageStatus.querySelector(`.${className}`);
+ if (addBadge) {
+ if (badge !== null) {
+ // badge already exists
+ return;
+ }
+
+ badge = document.createElement("span");
+ badge.className = `badge label ${badgeColor} ${className}`;
+ badge.textContent = Language.get(phrase);
+
+ const listItem = document.createElement("li");
+ listItem.appendChild(badge);
+ messageStatus.appendChild(listItem);
+ } else {
+ if (badge === null) {
+ // badge does not exist
+ return;
+ }
+
+ badge.parentElement!.remove();
+ }
+ }
+
+ /**
+ * Transforms camel-cased property names into their attribute equivalent.
+ *
+ * @deprecated 5.4 Access the value via `element.dataset` which uses camel-case.
+ */
+ protected _getAttributeName(propertyName: string): string {
+ if (propertyName.indexOf("-") !== -1) {
+ return propertyName;
+ }
+
+ return propertyName
+ .split(/([A-Z][a-z]+)/)
+ .map((s) => s.trim().toLowerCase())
+ .filter((s) => s.length > 0)
+ .join("-");
+ }
+
+ _ajaxSuccess(_data: ResponseData): void {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+ throw new Error("Method _ajaxSuccess() must be implemented by deriving functions.");
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ className: this._options.className,
+ },
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiMessageManager);
+
+export = UiMessageManager;
--- /dev/null
+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;
+}
+
+export class UiMessageQuote implements AjaxCallbackObject {
+ private activeMessageId = "";
+
+ private readonly className: string;
+
+ private containers = new Map<string, HTMLElement>();
+
+ 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: any;
+
+ /**
+ * Initializes the quote handler for given object type.
+ */
+ constructor(
+ quoteManager: any, // TODO
+ className: string,
+ objectType: string,
+ containerSelector: string,
+ messageBodySelector: string,
+ 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) => {
+ if (this.copyQuote.classList.contains("active")) {
+ 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: 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)!;
+ 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 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 (!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 {
+ const copyQuote = document.getElementById("quoteManagerCopy");
+ copyQuote?.remove();
+
+ this.copyQuote.id = "quoteManagerCopy";
+ this.copyQuote.classList.add("balloonTooltip", "interactive");
+
+ const buttonSaveQuote = document.createElement("span");
+ buttonSaveQuote.classList.add("jsQuoteManagerStore");
+ buttonSaveQuote.textContent = Language.get("wcf.message.quote.quoteSelected");
+ buttonSaveQuote.addEventListener("click", (event) => this.saveQuote(event));
+ this.copyQuote.appendChild(buttonSaveQuote);
+
+ if (supportDirectInsert) {
+ const buttonSaveAndInsertQuote = document.createElement("span");
+ buttonSaveAndInsertQuote.classList.add("jsQuoteManagerQuoteAndInsert");
+ buttonSaveAndInsertQuote.textContent = Language.get("wcf.message.quote.quoteAndReply");
+ buttonSaveAndInsertQuote.addEventListener("click", (event) => this.saveAndInsertQuote(event));
+ this.copyQuote.appendChild(buttonSaveAndInsertQuote);
+ }
+
+ document.body.appendChild(this.copyQuote);
+ }
+
+ private getSelectedText(): string {
+ const selection = window.getSelection()!;
+ if (selection.rangeCount) {
+ return this.getNodeText(selection.getRangeAt(0).cloneContents());
+ }
+
+ return "";
+ }
+
+ private saveFullQuote(event: MouseEvent): void {
+ event.preventDefault();
+
+ const listItem = event.currentTarget as HTMLElement;
+
+ Ajax.api(this, {
+ actionName: "saveFullQuote",
+ objectIDs: [listItem.dataset.objectId],
+ });
+
+ // mark element as quoted
+ const quoteLink = listItem.querySelector("a")!;
+ if (Core.stringToBool(listItem.dataset.isQuoted || "")) {
+ listItem.dataset.isQuoted = "false";
+ quoteLink.classList.remove("active");
+ } else {
+ listItem.dataset.isQuoted = "true";
+ quoteLink.classList.add("active");
+ }
+
+ // close navigation on mobile
+ const navigationList = listItem.closest(".buttonGroupNavigation") as HTMLUListElement;
+ if (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<AjaxCallbackSetup> {
+ 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");
+ }
+ });
+ }
+}
+
+export default UiMessageQuote;
--- /dev/null
+/**
+ * Handles user interaction with the quick reply feature.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Message/Reply
+ */
+
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
+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 UiDialog from "../Dialog";
+import * as UiNotification from "../Notification";
+import User from "../../User";
+import ControllerCaptcha from "../../Controller/Captcha";
+import { RedactorEditor } from "../Redactor/Editor";
+import * as UiScroll from "../Scroll";
+
+interface MessageReplyOptions {
+ ajax: {
+ className: string;
+ };
+ quoteManager: any;
+ successMessage: string;
+}
+
+interface AjaxResponse {
+ returnValues: {
+ guestDialog?: string;
+ guestDialogID?: string;
+ lastPostTime: number;
+ template?: string;
+ url?: string;
+ };
+}
+
+class UiMessageReply {
+ protected readonly _container: HTMLElement;
+ protected readonly _content: HTMLElement;
+ protected _editor: RedactorEditor | null = null;
+ protected _guestDialogId = "";
+ protected _loadingOverlay: HTMLElement | null = null;
+ protected readonly _options: MessageReplyOptions;
+ protected readonly _textarea: HTMLTextAreaElement;
+
+ /**
+ * Initializes a new quick reply field.
+ */
+ constructor(opts: Partial<MessageReplyOptions>) {
+ this._options = Core.extend(
+ {
+ ajax: {
+ className: "",
+ },
+ quoteManager: null,
+ successMessage: "wcf.global.success.add",
+ },
+ opts,
+ ) as MessageReplyOptions;
+
+ this._container = document.getElementById("messageQuickReply") as HTMLElement;
+ this._content = this._container.querySelector(".messageContent") as HTMLElement;
+ this._textarea = document.getElementById("text") as HTMLTextAreaElement;
+
+ // prevent marking of text for quoting
+ this._container.querySelector(".message")!.classList.add("jsInvalidQuoteTarget");
+
+ // handle submit button
+ const submitButton = this._container.querySelector('button[data-type="save"]') as HTMLButtonElement;
+ submitButton.addEventListener("click", (ev) => this._submit(ev));
+
+ // bind reply button
+ document.querySelectorAll(".jsQuickReply").forEach((replyButton: HTMLAnchorElement) => {
+ replyButton.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ this._getEditor().WoltLabReply.showEditor();
+
+ UiScroll.element(this._container, () => {
+ this._getEditor().WoltLabCaret.endOfEditor();
+ });
+ });
+ });
+ }
+
+ /**
+ * Submits the guest dialog.
+ */
+ protected _submitGuestDialog(event: KeyboardEvent | MouseEvent): void {
+ // only submit when enter key is pressed
+ if (event instanceof KeyboardEvent && event.key !== "Enter") {
+ return;
+ }
+
+ const target = event.currentTarget as HTMLElement;
+ const dialogContent = target.closest(".dialogContent")!;
+ const usernameInput = dialogContent.querySelector("input[name=username]") as HTMLInputElement;
+ if (usernameInput.value === "") {
+ DomUtil.innerError(usernameInput, Language.get("wcf.global.form.error.empty"));
+ usernameInput.closest("dl")!.classList.add("formError");
+
+ return;
+ }
+
+ let parameters: ArbitraryObject = {
+ parameters: {
+ data: {
+ username: usernameInput.value,
+ },
+ },
+ };
+
+ const captchaId = target.dataset.captchaId!;
+ if (ControllerCaptcha.has(captchaId)) {
+ const data = ControllerCaptcha.getData(captchaId);
+ if (data instanceof Promise) {
+ void data.then((data) => {
+ parameters = Core.extend(parameters, data) as ArbitraryObject;
+ this._submit(undefined, parameters);
+ });
+ } else {
+ parameters = Core.extend(parameters, data as ArbitraryObject) as ArbitraryObject;
+ this._submit(undefined, parameters);
+ }
+ } else {
+ this._submit(undefined, parameters);
+ }
+ }
+
+ /**
+ * Validates the message and submits it to the server.
+ */
+ protected _submit(event: MouseEvent | undefined, additionalParameters?: ArbitraryObject): void {
+ if (event) {
+ event.preventDefault();
+ }
+
+ // Ignore requests to submit the message while a previous request is still pending.
+ if (this._content.classList.contains("loading")) {
+ if (!this._guestDialogId || !UiDialog.isOpen(this._guestDialogId)) {
+ return;
+ }
+ }
+
+ if (!this._validate()) {
+ // validation failed, bail out
+ return;
+ }
+
+ this._showLoadingOverlay();
+
+ // build parameters
+ const parameters: ArbitraryObject = {};
+ Object.entries(this._container.dataset).forEach(([key, value]) => {
+ parameters[key.replace(/Id$/, "ID")] = value;
+ });
+
+ parameters.data = { message: this._getEditor().code.get() };
+ parameters.removeQuoteIDs = this._options.quoteManager
+ ? this._options.quoteManager.getQuotesMarkedForRemoval()
+ : [];
+
+ // add any available settings
+ const settingsContainer = document.getElementById("settings_text");
+ if (settingsContainer) {
+ settingsContainer
+ .querySelectorAll("input, select, textarea")
+ .forEach((element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement) => {
+ if (element.nodeName === "INPUT" && (element.type === "checkbox" || element.type === "radio")) {
+ if (!(element as HTMLInputElement).checked) {
+ return;
+ }
+ }
+
+ const name = element.name;
+ if (Object.prototype.hasOwnProperty.call(parameters, name)) {
+ throw new Error(`Variable overshadowing, key '${name}' is already present.`);
+ }
+
+ parameters[name] = element.value.trim();
+ });
+ }
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", "submit_text", parameters.data as any);
+
+ if (!User.userId && !additionalParameters) {
+ parameters.requireGuestDialog = true;
+ }
+
+ Ajax.api(
+ this,
+ Core.extend(
+ {
+ parameters: parameters,
+ },
+ additionalParameters as ArbitraryObject,
+ ),
+ );
+ }
+
+ /**
+ * Validates the message and invokes listeners to perform additional validation.
+ */
+ protected _validate(): boolean {
+ // remove all existing error elements
+ this._container.querySelectorAll(".innerError").forEach((el) => el.remove());
+
+ // check if editor contains actual content
+ if (this._getEditor().utils.isEmpty()) {
+ this.throwError(this._textarea, Language.get("wcf.global.form.error.empty"));
+ return false;
+ }
+
+ const data = {
+ api: this,
+ editor: this._getEditor(),
+ message: this._getEditor().code.get(),
+ valid: true,
+ };
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", "validate_text", data);
+
+ return data.valid;
+ }
+
+ /**
+ * Throws an error by adding an inline error to target element.
+ *
+ * @param {Element} element erroneous element
+ * @param {string} message error message
+ */
+ throwError(element: HTMLElement, message: string): void {
+ DomUtil.innerError(element, message === "empty" ? Language.get("wcf.global.form.error.empty") : message);
+ }
+
+ /**
+ * Displays a loading spinner while the request is processed by the server.
+ */
+ protected _showLoadingOverlay(): void {
+ if (this._loadingOverlay === null) {
+ this._loadingOverlay = document.createElement("div");
+ this._loadingOverlay.className = "messageContentLoadingOverlay";
+ this._loadingOverlay.innerHTML = '<span class="icon icon96 fa-spinner"></span>';
+ }
+
+ this._content.classList.add("loading");
+ this._content.appendChild(this._loadingOverlay);
+ }
+
+ /**
+ * Hides the loading spinner.
+ */
+ protected _hideLoadingOverlay(): void {
+ this._content.classList.remove("loading");
+
+ const loadingOverlay = this._content.querySelector(".messageContentLoadingOverlay");
+ if (loadingOverlay !== null) {
+ loadingOverlay.remove();
+ }
+ }
+
+ /**
+ * Resets the editor contents and notifies event listeners.
+ */
+ protected _reset(): void {
+ this._getEditor().code.set("<p>\u200b</p>");
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", "reset_text");
+ }
+
+ /**
+ * Handles errors occurred during server processing.
+ */
+ protected _handleError(data: ResponseData): void {
+ const parameters = {
+ api: this,
+ cancel: false,
+ returnValues: data.returnValues,
+ };
+ EventHandler.fire("com.woltlab.wcf.redactor2", "handleError_text", parameters);
+
+ if (!parameters.cancel) {
+ this.throwError(this._textarea, data.returnValues.realErrorMessage);
+ }
+ }
+
+ /**
+ * Returns the current editor instance.
+ */
+ protected _getEditor(): RedactorEditor {
+ if (this._editor === null) {
+ if (typeof window.jQuery === "function") {
+ this._editor = window.jQuery(this._textarea).data("redactor") as RedactorEditor;
+ } else {
+ throw new Error("Unable to access editor, jQuery has not been loaded yet.");
+ }
+ }
+
+ return this._editor;
+ }
+
+ /**
+ * Inserts the rendered message into the post list, unless the post is on the next
+ * page in which case a redirect will be performed instead.
+ */
+ protected _insertMessage(data: AjaxResponse): void {
+ this._getEditor().WoltLabAutosave.reset();
+
+ // redirect to new page
+ if (data.returnValues.url) {
+ if (window.location.href == data.returnValues.url) {
+ window.location.reload();
+ }
+ window.location.href = data.returnValues.url;
+ } else {
+ if (data.returnValues.template) {
+ let elementId: string;
+
+ // insert HTML
+ if (this._container.dataset.sortOrder === "DESC") {
+ DomUtil.insertHtml(data.returnValues.template, this._container, "after");
+ elementId = DomUtil.identify(this._container.nextElementSibling!);
+ } else {
+ let insertBefore = this._container;
+ if (
+ insertBefore.previousElementSibling &&
+ insertBefore.previousElementSibling.classList.contains("messageListPagination")
+ ) {
+ insertBefore = insertBefore.previousElementSibling as HTMLElement;
+ }
+
+ DomUtil.insertHtml(data.returnValues.template, insertBefore, "before");
+ elementId = DomUtil.identify(insertBefore.previousElementSibling!);
+ }
+
+ // update last post time
+ this._container.dataset.lastPostTime = data.returnValues.lastPostTime.toString();
+
+ window.history.replaceState(undefined, "", `#${elementId}`);
+ UiScroll.element(document.getElementById(elementId)!);
+ }
+
+ UiNotification.show(Language.get(this._options.successMessage));
+
+ if (this._options.quoteManager) {
+ this._options.quoteManager.countQuotes();
+ }
+
+ DomChangeListener.trigger();
+ }
+ }
+
+ /**
+ * @param {{returnValues:{guestDialog:string,guestDialogID:string}}} data
+ * @protected
+ */
+ _ajaxSuccess(data: AjaxResponse): void {
+ if (!User.userId && !data.returnValues.guestDialogID) {
+ throw new Error("Missing 'guestDialogID' return value for guest.");
+ }
+
+ if (!User.userId && data.returnValues.guestDialog) {
+ const guestDialogId = data.returnValues.guestDialogID!;
+
+ UiDialog.openStatic(guestDialogId, data.returnValues.guestDialog, {
+ closable: false,
+ onClose: function () {
+ if (ControllerCaptcha.has(guestDialogId)) {
+ ControllerCaptcha.delete(guestDialogId);
+ }
+ },
+ title: Language.get("wcf.global.confirmation.title"),
+ });
+
+ const dialog = UiDialog.getDialog(guestDialogId)!;
+ const submit = dialog.content.querySelector("input[type=submit]") as HTMLInputElement;
+ submit.addEventListener("click", (ev) => this._submitGuestDialog(ev));
+ const input = dialog.content.querySelector("input[type=text]") as HTMLInputElement;
+ input.addEventListener("keypress", (ev) => this._submitGuestDialog(ev));
+
+ this._guestDialogId = guestDialogId;
+ } else {
+ this._insertMessage(data);
+
+ if (!User.userId) {
+ UiDialog.close(data.returnValues.guestDialogID!);
+ }
+
+ this._reset();
+
+ this._hideLoadingOverlay();
+ }
+ }
+
+ _ajaxFailure(data: ResponseData): boolean {
+ this._hideLoadingOverlay();
+
+ if (data === null || data.returnValues === undefined || data.returnValues.realErrorMessage === undefined) {
+ return true;
+ }
+
+ this._handleError(data);
+
+ return false;
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "quickReply",
+ className: this._options.ajax.className,
+ interfaceName: "wcf\\data\\IMessageQuickReplyAction",
+ },
+ silent: true,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiMessageReply);
+
+export = UiMessageReply;
--- /dev/null
+/**
+ * Provides buttons to share a page through multiple social community sites.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Message/Share
+ */
+
+import * as EventHandler from "../../Event/Handler";
+import * as StringUtil from "../../StringUtil";
+
+let _pageDescription = "";
+let _pageUrl = "";
+
+function share(objectName: string, url: string, appendUrl: boolean, pageUrl: string) {
+ // fallback for plugins
+ if (!pageUrl) {
+ pageUrl = _pageUrl;
+ }
+
+ window.open(
+ url.replace("{pageURL}", pageUrl).replace("{text}", _pageDescription + (appendUrl ? `%20${pageUrl}` : "")),
+ objectName,
+ "height=600,width=600",
+ );
+}
+
+interface Provider {
+ link: HTMLElement | null;
+
+ share(event: MouseEvent): void;
+}
+
+interface Providers {
+ [key: string]: Provider;
+}
+
+export function init(): void {
+ const title = document.querySelector('meta[property="og:title"]') as HTMLMetaElement;
+ if (title !== null) {
+ _pageDescription = encodeURIComponent(title.content);
+ }
+
+ const url = document.querySelector('meta[property="og:url"]') as HTMLMetaElement;
+ if (url !== null) {
+ _pageUrl = encodeURIComponent(url.content);
+ }
+
+ document.querySelectorAll(".jsMessageShareButtons").forEach((container: HTMLElement) => {
+ container.classList.remove("jsMessageShareButtons");
+
+ let pageUrl = encodeURIComponent(StringUtil.unescapeHTML(container.dataset.url || ""));
+ if (!pageUrl) {
+ pageUrl = _pageUrl;
+ }
+
+ const providers: Providers = {
+ facebook: {
+ link: container.querySelector(".jsShareFacebook"),
+ share(event: MouseEvent): void {
+ event.preventDefault();
+ share("facebook", "https://www.facebook.com/sharer.php?u={pageURL}&t={text}", true, pageUrl);
+ },
+ },
+ reddit: {
+ link: container.querySelector(".jsShareReddit"),
+ share(event: MouseEvent): void {
+ event.preventDefault();
+ share("reddit", "https://ssl.reddit.com/submit?url={pageURL}", false, pageUrl);
+ },
+ },
+ twitter: {
+ link: container.querySelector(".jsShareTwitter"),
+ share(event: MouseEvent): void {
+ event.preventDefault();
+ share("twitter", "https://twitter.com/share?url={pageURL}&text={text}", false, pageUrl);
+ },
+ },
+ linkedIn: {
+ link: container.querySelector(".jsShareLinkedIn"),
+ share(event: MouseEvent): void {
+ event.preventDefault();
+ share("linkedIn", "https://www.linkedin.com/cws/share?url={pageURL}", false, pageUrl);
+ },
+ },
+ pinterest: {
+ link: container.querySelector(".jsSharePinterest"),
+ share(event: MouseEvent): void {
+ event.preventDefault();
+ share(
+ "pinterest",
+ "https://www.pinterest.com/pin/create/link/?url={pageURL}&description={text}",
+ false,
+ pageUrl,
+ );
+ },
+ },
+ xing: {
+ link: container.querySelector(".jsShareXing"),
+ share(event: MouseEvent): void {
+ event.preventDefault();
+ share("xing", "https://www.xing.com/social_plugins/share?url={pageURL}", false, pageUrl);
+ },
+ },
+ whatsApp: {
+ link: container.querySelector(".jsShareWhatsApp"),
+ share(event: MouseEvent): void {
+ event.preventDefault();
+ window.location.href = "https://api.whatsapp.com/send?text=" + _pageDescription + "%20" + _pageUrl;
+ },
+ },
+ };
+
+ EventHandler.fire("com.woltlab.wcf.message.share", "shareProvider", {
+ container,
+ providers,
+ pageDescription: _pageDescription,
+ pageUrl: _pageUrl,
+ });
+
+ Object.values(providers).forEach((provider) => {
+ if (provider.link !== null) {
+ const link = provider.link as HTMLAnchorElement;
+ link.addEventListener("click", (ev) => provider.share(ev));
+ }
+ });
+ });
+}
--- /dev/null
+/**
+ * Wrapper around Twitter's createTweet API.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Message/TwitterEmbed
+ */
+
+import "https://platform.twitter.com/widgets.js";
+
+type CallbackReady = (twttr: Twitter) => void;
+
+const twitterReady = new Promise((resolve: CallbackReady) => {
+ twttr.ready(resolve);
+});
+
+/**
+ * Embed the tweet identified by the given tweetId into the given container.
+ *
+ * @param {HTMLElement} container
+ * @param {string} tweetId
+ * @param {boolean} removeChildren Whether to remove existing children of the given container after embedding the tweet.
+ * @return {HTMLElement} The Tweet element created by Twitter.
+ */
+export async function embedTweet(
+ container: HTMLElement,
+ tweetId: string,
+ removeChildren = false,
+): Promise<HTMLElement> {
+ const twitter = await twitterReady;
+
+ const tweet = await twitter.widgets.createTweet(tweetId, container, {
+ dnt: true,
+ lang: document.documentElement.lang,
+ });
+
+ if (tweet && removeChildren) {
+ while (container.lastChild) {
+ container.removeChild(container.lastChild);
+ }
+ container.appendChild(tweet);
+ }
+
+ return tweet;
+}
+
+/**
+ * Embeds tweets into all elements with a data-wsc-twitter-tweet attribute, removing
+ * existing children.
+ */
+export function embedAll(): void {
+ document.querySelectorAll("[data-wsc-twitter-tweet]").forEach((container: HTMLElement) => {
+ const tweetId = container.dataset.wscTwitterTweet;
+ if (tweetId) {
+ delete container.dataset.wscTwitterTweet;
+
+ void embedTweet(container, tweetId, true);
+ }
+ });
+}
--- /dev/null
+/**
+ * Prompts the user for their consent before displaying external media.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Message/UserConsent
+ */
+
+import * as Ajax from "../../Ajax";
+import * as Core from "../../Core";
+import DomChangeListener from "../../Dom/Change/Listener";
+import DomUtil from "../../Dom/Util";
+import User from "../../User";
+
+class UserConsent {
+ private enableAll = false;
+ private readonly knownButtons = new WeakSet();
+
+ constructor() {
+ if (window.sessionStorage.getItem(`${Core.getStoragePrefix()}user-consent`) === "all") {
+ this.enableAll = true;
+ }
+
+ this.registerEventListeners();
+
+ DomChangeListener.add("WoltLabSuite/Core/Ui/Message/UserConsent", () => this.registerEventListeners());
+ }
+
+ private registerEventListeners(): void {
+ if (this.enableAll) {
+ this.enableAllExternalMedia();
+ } else {
+ document.querySelectorAll(".jsButtonMessageUserConsentEnable").forEach((button: HTMLAnchorElement) => {
+ if (!this.knownButtons.has(button)) {
+ this.knownButtons.add(button);
+
+ button.addEventListener("click", (ev) => this.click(ev));
+ }
+ });
+ }
+ }
+
+ private click(event: MouseEvent): void {
+ event.preventDefault();
+
+ this.enableAll = true;
+
+ this.enableAllExternalMedia();
+
+ if (User.userId) {
+ Ajax.apiOnce({
+ data: {
+ actionName: "saveUserConsent",
+ className: "wcf\\data\\user\\UserAction",
+ },
+ silent: true,
+ });
+ } else {
+ window.sessionStorage.setItem(`${Core.getStoragePrefix()}user-consent`, "all");
+ }
+ }
+
+ private enableExternalMedia(container: HTMLElement): void {
+ const payload = atob(container.dataset.payload!);
+
+ DomUtil.insertHtml(payload, container, "before");
+ container.remove();
+ }
+
+ private enableAllExternalMedia(): void {
+ document.querySelectorAll(".messageUserConsent").forEach((el: HTMLElement) => this.enableExternalMedia(el));
+ }
+}
+
+let userConsent: UserConsent;
+
+export function init(): void {
+ if (!userConsent) {
+ userConsent = new UserConsent();
+ }
+}
--- /dev/null
+/**
+ * Modifies the interface to provide a better usability for mobile devices.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Mobile
+ */
+
+import * as Core from "../Core";
+import DomChangeListener from "../Dom/Change/Listener";
+import * as Environment from "../Environment";
+import * as EventHandler from "../Event/Handler";
+import * as UiAlignment from "./Alignment";
+import UiCloseOverlay from "./CloseOverlay";
+import * as UiDropdownReusable from "./Dropdown/Reusable";
+import UiPageMenuMain from "./Page/Menu/Main";
+import UiPageMenuUser from "./Page/Menu/User";
+import * as UiScreen from "./Screen";
+
+interface MainMenuMorePayload {
+ identifier: string;
+ handler: UiPageMenuMain;
+}
+
+let _dropdownMenu: HTMLUListElement | null = null;
+let _dropdownMenuMessage = null;
+let _enabled = false;
+let _enabledLGTouchNavigation = false;
+let _enableMobileMenu = false;
+const _knownMessages = new WeakSet<HTMLElement>();
+let _mobileSidebarEnabled = false;
+let _pageMenuMain: UiPageMenuMain;
+let _pageMenuUser: UiPageMenuUser;
+let _messageGroups: HTMLCollection | null = null;
+const _sidebars: HTMLElement[] = [];
+
+function _init(): void {
+ _enabled = true;
+
+ initSearchBar();
+ _initButtonGroupNavigation();
+ _initMessages();
+ _initMobileMenu();
+
+ UiCloseOverlay.add("WoltLabSuite/Core/Ui/Mobile", _closeAllMenus);
+ DomChangeListener.add("WoltLabSuite/Core/Ui/Mobile", () => {
+ _initButtonGroupNavigation();
+ _initMessages();
+ });
+}
+
+function initSearchBar(): void {
+ const searchBar = document.getElementById("pageHeaderSearch")!;
+ const searchInput = document.getElementById("pageHeaderSearchInput")!;
+
+ let scrollTop: number | null = null;
+ EventHandler.add("com.woltlab.wcf.MainMenuMobile", "more", (data: MainMenuMorePayload) => {
+ if (data.identifier === "com.woltlab.wcf.search") {
+ data.handler.close();
+
+ if (Environment.platform() === "ios") {
+ scrollTop = document.body.scrollTop;
+ UiScreen.scrollDisable();
+ }
+
+ const pageHeader = document.getElementById("pageHeader")!;
+ searchBar.style.setProperty("top", `${pageHeader.offsetHeight}px`, "");
+ searchBar.classList.add("open");
+ searchInput.focus();
+
+ if (Environment.platform() === "ios") {
+ document.body.scrollTop = 0;
+ }
+ }
+ });
+
+ document.getElementById("main")!.addEventListener("click", () => {
+ if (searchBar) {
+ searchBar.classList.remove("open");
+ }
+
+ if (Environment.platform() === "ios" && scrollTop) {
+ UiScreen.scrollEnable();
+ document.body.scrollTop = scrollTop;
+ scrollTop = null;
+ }
+ });
+}
+
+function _initButtonGroupNavigation(): void {
+ document.querySelectorAll(".buttonGroupNavigation").forEach((navigation) => {
+ if (navigation.classList.contains("jsMobileButtonGroupNavigation")) {
+ return;
+ } else {
+ navigation.classList.add("jsMobileButtonGroupNavigation");
+ }
+
+ const list = navigation.querySelector(".buttonList") as HTMLUListElement;
+ if (list.childElementCount === 0) {
+ // ignore objects without options
+ return;
+ }
+
+ navigation.parentElement!.classList.add("hasMobileNavigation");
+
+ const button = document.createElement("a");
+ button.className = "dropdownLabel";
+ const span = document.createElement("span");
+ span.className = "icon icon24 fa-ellipsis-v";
+ button.appendChild(span);
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ navigation.classList.toggle("open");
+ });
+
+ list.addEventListener("click", function (event) {
+ event.stopPropagation();
+ navigation.classList.remove("open");
+ });
+
+ navigation.insertBefore(button, navigation.firstChild);
+ });
+}
+
+function _initMessages(): void {
+ document.querySelectorAll(".message").forEach((message: HTMLElement) => {
+ if (_knownMessages.has(message)) {
+ return;
+ }
+
+ const navigation = message.querySelector(".jsMobileNavigation") as HTMLAnchorElement;
+ if (navigation) {
+ navigation.addEventListener("click", (event) => {
+ event.stopPropagation();
+
+ // mimic dropdown behavior
+ window.setTimeout(() => {
+ navigation.classList.remove("open");
+ }, 10);
+ });
+
+ const quickOptions = message.querySelector(".messageQuickOptions");
+ if (quickOptions && navigation.childElementCount) {
+ quickOptions.classList.add("active");
+ quickOptions.addEventListener("click", (event) => {
+ const target = event.target as HTMLElement;
+
+ if (_enabled && UiScreen.is("screen-sm-down") && target.nodeName !== "LABEL" && target.nodeName !== "INPUT") {
+ event.preventDefault();
+ event.stopPropagation();
+
+ _toggleMobileNavigation(message, quickOptions, navigation);
+ }
+ });
+ }
+ }
+ _knownMessages.add(message);
+ });
+}
+
+function _initMobileMenu(): void {
+ if (_enableMobileMenu) {
+ _pageMenuMain = new UiPageMenuMain();
+ _pageMenuUser = new UiPageMenuUser();
+ }
+}
+
+function _closeAllMenus(): void {
+ document.querySelectorAll(".jsMobileButtonGroupNavigation.open, .jsMobileNavigation.open").forEach((menu) => {
+ menu.classList.remove("open");
+ });
+
+ if (_enabled && _dropdownMenu) {
+ closeDropdown();
+ }
+}
+
+function _enableMobileSidebar(): void {
+ _mobileSidebarEnabled = true;
+}
+
+function _disableMobileSidebar(): void {
+ _mobileSidebarEnabled = false;
+ _sidebars.forEach(function (sidebar) {
+ sidebar.classList.remove("open");
+ });
+}
+
+function _setupMobileSidebar(): void {
+ _sidebars.forEach(function (sidebar) {
+ sidebar.addEventListener("mousedown", function (event) {
+ if (_mobileSidebarEnabled && event.target === sidebar) {
+ event.preventDefault();
+ sidebar.classList.toggle("open");
+ }
+ });
+ });
+ _mobileSidebarEnabled = true;
+}
+
+function closeDropdown(): void {
+ _dropdownMenu!.classList.remove("dropdownOpen");
+}
+
+function _toggleMobileNavigation(message, quickOptions, navigation): void {
+ if (_dropdownMenu === null) {
+ _dropdownMenu = document.createElement("ul");
+ _dropdownMenu.className = "dropdownMenu";
+ UiDropdownReusable.init("com.woltlab.wcf.jsMobileNavigation", _dropdownMenu);
+ } else if (_dropdownMenu.classList.contains("dropdownOpen")) {
+ closeDropdown();
+ if (_dropdownMenuMessage === message) {
+ // toggle behavior
+ return;
+ }
+ }
+ _dropdownMenu.innerHTML = "";
+ UiCloseOverlay.execute();
+ _rebuildMobileNavigation(navigation);
+ const previousNavigation = navigation.previousElementSibling;
+ if (previousNavigation && previousNavigation.classList.contains("messageFooterButtonsExtra")) {
+ const divider = document.createElement("li");
+ divider.className = "dropdownDivider";
+ _dropdownMenu.appendChild(divider);
+ _rebuildMobileNavigation(previousNavigation);
+ }
+ UiAlignment.set(_dropdownMenu, quickOptions, {
+ horizontal: "right",
+ allowFlip: "vertical",
+ });
+ _dropdownMenu.classList.add("dropdownOpen");
+ _dropdownMenuMessage = message;
+}
+
+function _setupLGTouchNavigation(): void {
+ _enabledLGTouchNavigation = true;
+ document.querySelectorAll(".boxMenuHasChildren > a").forEach((element: HTMLElement) => {
+ element.addEventListener("touchstart", function (event) {
+ if (_enabledLGTouchNavigation && element.getAttribute("aria-expanded") === "false") {
+ event.preventDefault();
+
+ element.setAttribute("aria-expanded", "true");
+
+ // Register an new event listener after the touch ended, which is triggered once when an
+ // element on the page is pressed. This allows us to reset the touch status of the navigation
+ // entry when the entry is no longer open, so that it does not redirect to the page when you
+ // click it again.
+ element.addEventListener(
+ "touchend",
+ () => {
+ document.body.addEventListener(
+ "touchstart",
+ () => {
+ document.body.addEventListener(
+ "touchend",
+ (event) => {
+ const parent = element.parentElement!;
+ const target = event.target as HTMLElement;
+ if (!parent.contains(target) && target !== parent) {
+ element.setAttribute("aria-expanded", "false");
+ }
+ },
+ {
+ once: true,
+ },
+ );
+ },
+ {
+ once: true,
+ },
+ );
+ },
+ { once: true },
+ );
+ }
+ });
+ });
+}
+
+function _enableLGTouchNavigation(): void {
+ _enabledLGTouchNavigation = true;
+}
+
+function _disableLGTouchNavigation(): void {
+ _enabledLGTouchNavigation = false;
+}
+
+function _rebuildMobileNavigation(navigation: HTMLElement): void {
+ navigation.querySelectorAll(".button").forEach((button: HTMLElement) => {
+ if (button.classList.contains("ignoreMobileNavigation")) {
+ // The reaction button was hidden up until 5.2.2, but was enabled again in 5.2.3. This check
+ // exists to make sure that there is no unexpected behavior in 3rd party apps or plugins that
+ // used the same code and hid the reaction button via a CSS class in the template.
+ if (!button.classList.contains("reactButton")) {
+ return;
+ }
+ }
+
+ const item = document.createElement("li");
+ if (button.classList.contains("active")) {
+ item.className = "active";
+ }
+
+ const label = button.querySelector("span:not(.icon)")!;
+ item.innerHTML = `<a href="#">${label.textContent!}</a>`;
+ item.children[0].addEventListener("click", function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ if (button.nodeName === "A") {
+ button.click();
+ } else {
+ Core.triggerEvent(button, "click");
+ }
+ closeDropdown();
+ });
+ _dropdownMenu!.appendChild(item);
+ });
+}
+
+/**
+ * Initializes the mobile UI.
+ */
+export function setup(enableMobileMenu: boolean): void {
+ _enableMobileMenu = enableMobileMenu;
+ document.querySelectorAll(".sidebar").forEach((sidebar: HTMLElement) => {
+ _sidebars.push(sidebar);
+ });
+
+ if (Environment.touch()) {
+ document.documentElement.classList.add("touch");
+ }
+ if (Environment.platform() !== "desktop") {
+ document.documentElement.classList.add("mobile");
+ }
+
+ const messageGroupList = document.querySelector(".messageGroupList");
+ if (messageGroupList) {
+ _messageGroups = messageGroupList.getElementsByClassName("messageGroup");
+ }
+
+ UiScreen.on("screen-md-down", {
+ match: enable,
+ unmatch: disable,
+ setup: _init,
+ });
+ UiScreen.on("screen-sm-down", {
+ match: enableShadow,
+ unmatch: disableShadow,
+ setup: enableShadow,
+ });
+ UiScreen.on("screen-md-down", {
+ match: _enableMobileSidebar,
+ unmatch: _disableMobileSidebar,
+ setup: _setupMobileSidebar,
+ });
+
+ // On the large tablets (e.g. iPad Pro) the navigation is not usable, because there is not the mobile
+ // layout displayed, but the normal one for the desktop. The navigation reacts to a hover status if a
+ // menu item has several submenu items. Logically, this cannot be created with the tablet, so that we
+ // display the submenu here after a single click and only follow the link after another click.
+ if (Environment.touch() && (Environment.platform() === "ios" || Environment.platform() === "android")) {
+ UiScreen.on("screen-lg", {
+ match: _enableLGTouchNavigation,
+ unmatch: _disableLGTouchNavigation,
+ setup: _setupLGTouchNavigation,
+ });
+ }
+}
+
+/**
+ * Enables the mobile UI.
+ */
+export function enable(): void {
+ _enabled = true;
+ if (_enableMobileMenu) {
+ _pageMenuMain.enable();
+ _pageMenuUser.enable();
+ }
+}
+
+/**
+ * Enables shadow links for larger click areas on messages.
+ */
+export function enableShadow(): void {
+ if (_messageGroups) {
+ rebuildShadow(_messageGroups, ".messageGroupLink");
+ }
+}
+
+/**
+ * Disables the mobile UI.
+ */
+export function disable(): void {
+ _enabled = false;
+ if (_enableMobileMenu) {
+ _pageMenuMain.disable();
+ _pageMenuUser.disable();
+ }
+}
+
+/**
+ * Disables shadow links.
+ */
+export function disableShadow(): void {
+ if (_messageGroups) {
+ removeShadow(_messageGroups);
+ }
+ if (_dropdownMenu) {
+ closeDropdown();
+ }
+}
+
+export function rebuildShadow(elements: HTMLElement[] | HTMLCollection, linkSelector: string): void {
+ Array.from(elements).forEach((element) => {
+ const parent = element.parentElement as HTMLElement;
+
+ let shadow = parent.querySelector(".mobileLinkShadow") as HTMLAnchorElement;
+ if (shadow === null) {
+ const link = element.querySelector(linkSelector) as HTMLAnchorElement;
+ if (link.href) {
+ shadow = document.createElement("a");
+ shadow.className = "mobileLinkShadow";
+ shadow.href = link.href;
+ parent.appendChild(shadow);
+ parent.classList.add("mobileLinkShadowContainer");
+ }
+ }
+ });
+}
+
+export function removeShadow(elements: HTMLElement[] | HTMLCollection): void {
+ Array.from(elements).forEach((element) => {
+ const parent = element.parentElement!;
+ if (parent.classList.contains("mobileLinkShadowContainer")) {
+ const shadow = parent.querySelector(".mobileLinkShadow");
+ if (shadow !== null) {
+ shadow.remove();
+ }
+
+ parent.classList.remove("mobileLinkShadowContainer");
+ }
+ });
+}
--- /dev/null
+/**
+ * Simple notification overlay.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Ui/Notification (alias)
+ * @module WoltLabSuite/Core/Ui/Notification
+ */
+
+import * as Language from "../Language";
+
+type Callback = () => void;
+
+let _busy = false;
+let _callback: Callback | null = null;
+let _didInit = false;
+let _message: HTMLElement;
+let _notificationElement: HTMLElement;
+let _timeout: number;
+
+function init() {
+ if (_didInit) {
+ return;
+ }
+ _didInit = true;
+
+ _notificationElement = document.createElement("div");
+ _notificationElement.id = "systemNotification";
+
+ _message = document.createElement("p");
+ _message.addEventListener("click", hide);
+ _notificationElement.appendChild(_message);
+
+ document.body.appendChild(_notificationElement);
+}
+
+/**
+ * Hides the notification and invokes the callback if provided.
+ */
+function hide() {
+ clearTimeout(_timeout);
+
+ _notificationElement.classList.remove("active");
+
+ if (_callback !== null) {
+ _callback();
+ }
+
+ _busy = false;
+}
+
+/**
+ * Displays a notification.
+ */
+export function show(message?: string, callback?: Callback | null, cssClassName?: string): void {
+ if (_busy) {
+ return;
+ }
+ _busy = true;
+
+ init();
+
+ _callback = typeof callback === "function" ? callback : null;
+ _message.className = cssClassName || "success";
+ _message.textContent = Language.get(message || "wcf.global.success");
+
+ _notificationElement.classList.add("active");
+ _timeout = setTimeout(hide, 2000);
+}
--- /dev/null
+/**
+ * Provides page actions such as "jump to top" and clipboard actions.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Page/Action
+ */
+
+import * as Core from "../../Core";
+import * as Language from "../../Language";
+
+const _buttons = new Map<string, HTMLElement>();
+
+let _container: HTMLElement;
+let _didInit = false;
+let _lastPosition = -1;
+let _toTopButton: HTMLElement;
+let _wrapper: HTMLElement;
+
+const _resetLastPosition = Core.debounce(() => {
+ _lastPosition = -1;
+}, 50);
+
+function buildToTopButton(): HTMLAnchorElement {
+ const button = document.createElement("a");
+ button.className = "button buttonPrimary pageActionButtonToTop initiallyHidden jsTooltip";
+ button.href = "";
+ button.title = Language.get("wcf.global.scrollUp");
+ button.setAttribute("aria-hidden", "true");
+ button.innerHTML = '<span class="icon icon32 fa-angle-up"></span>';
+
+ button.addEventListener("click", scrollToTop);
+
+ return button;
+}
+
+function onScroll(): void {
+ if (document.documentElement.classList.contains("disableScrolling")) {
+ // Ignore any scroll events that take place while body scrolling is disabled,
+ // because it messes up the scroll offsets.
+ return;
+ }
+
+ const offset = window.pageYOffset;
+ if (offset === _lastPosition) {
+ // Ignore any scroll event that is fired but without a position change. This can
+ // happen after closing a dialog that prevented the body from being scrolled.
+ _resetLastPosition();
+ return;
+ }
+
+ if (offset >= 300) {
+ if (_toTopButton.classList.contains("initiallyHidden")) {
+ _toTopButton.classList.remove("initiallyHidden");
+ }
+
+ _toTopButton.setAttribute("aria-hidden", "false");
+ } else {
+ _toTopButton.setAttribute("aria-hidden", "true");
+ }
+
+ renderContainer();
+
+ if (_lastPosition !== -1) {
+ _wrapper.classList[offset < _lastPosition ? "remove" : "add"]("scrolledDown");
+ }
+
+ _lastPosition = -1;
+}
+
+function scrollToTop(event: MouseEvent): void {
+ event.preventDefault();
+
+ const topAnchor = document.getElementById("top")!;
+ topAnchor.scrollIntoView({ behavior: "smooth" });
+}
+
+/**
+ * Toggles the container's visibility.
+ */
+function renderContainer() {
+ const visibleChild = Array.from(_container.children).find((element) => {
+ return element.getAttribute("aria-hidden") === "false";
+ });
+
+ _container.classList[visibleChild ? "add" : "remove"]("active");
+}
+
+/**
+ * Initializes the page action container.
+ */
+export function setup(): void {
+ if (_didInit) {
+ return;
+ }
+
+ _didInit = true;
+
+ _wrapper = document.createElement("div");
+ _wrapper.className = "pageAction";
+
+ _container = document.createElement("div");
+ _container.className = "pageActionButtons";
+ _wrapper.appendChild(_container);
+
+ _toTopButton = buildToTopButton();
+ _wrapper.appendChild(_toTopButton);
+
+ document.body.appendChild(_wrapper);
+
+ const debounce = Core.debounce(onScroll, 100);
+ window.addEventListener(
+ "scroll",
+ () => {
+ if (_lastPosition === -1) {
+ _lastPosition = window.pageYOffset;
+
+ // Invoke the scroll handler once to immediately respond to
+ // the user action before debouncing all further calls.
+ window.setTimeout(() => {
+ onScroll();
+
+ _lastPosition = window.pageYOffset;
+ }, 60);
+ }
+
+ debounce();
+ },
+ { passive: true },
+ );
+
+ window.addEventListener(
+ "touchstart",
+ () => {
+ // Force a reset of the scroll position to trigger an immediate reaction
+ // when the user touches the display again.
+ if (_lastPosition !== -1) {
+ _lastPosition = -1;
+ }
+ },
+ { passive: true },
+ );
+
+ onScroll();
+}
+
+/**
+ * Adds a button to the page action list. You can optionally provide a button name to
+ * insert the button right before it. Unmatched button names or empty value will cause
+ * the button to be prepended to the list.
+ */
+export function add(buttonName: string, button: HTMLElement, insertBeforeButton?: string): void {
+ setup();
+
+ // The wrapper is required for backwards compatibility, because some implementations rely on a
+ // dedicated parent element to insert elements, for example, for drop-down menus.
+ const wrapper = document.createElement("div");
+ wrapper.className = "pageActionButton";
+ wrapper.dataset.name = buttonName;
+ wrapper.setAttribute("aria-hidden", "true");
+
+ button.classList.add("button");
+ button.classList.add("buttonPrimary");
+ wrapper.appendChild(button);
+
+ let insertBefore: HTMLElement | null = null;
+ if (insertBeforeButton) {
+ insertBefore = _buttons.get(insertBeforeButton) || null;
+ if (insertBefore) {
+ insertBefore = insertBefore.parentElement;
+ }
+ }
+
+ if (!insertBefore && _container.childElementCount) {
+ insertBefore = _container.children[0] as HTMLElement;
+ }
+ if (!insertBefore) {
+ insertBefore = _container.firstChild as HTMLElement;
+ }
+
+ _container.insertBefore(wrapper, insertBefore);
+ _wrapper.classList.remove("scrolledDown");
+
+ _buttons.set(buttonName, button);
+
+ // Query a layout related property to force a reflow, otherwise the transition is optimized away.
+ // noinspection BadExpressionStatementJS
+ wrapper.offsetParent;
+
+ // Toggle the visibility to force the transition to be applied.
+ wrapper.setAttribute("aria-hidden", "false");
+
+ renderContainer();
+}
+
+/**
+ * Returns true if there is a registered button with the provided name.
+ */
+export function has(buttonName: string): boolean {
+ return _buttons.has(buttonName);
+}
+
+/**
+ * Returns the stored button by name or undefined.
+ */
+export function get(buttonName: string): HTMLElement | undefined {
+ return _buttons.get(buttonName);
+}
+
+/**
+ * Removes a button by its button name.
+ */
+export function remove(buttonName: string): void {
+ const button = _buttons.get(buttonName);
+ if (button !== undefined) {
+ const listItem = button.parentElement!;
+ const callback = () => {
+ try {
+ if (Core.stringToBool(listItem.getAttribute("aria-hidden"))) {
+ _container.removeChild(listItem);
+ _buttons.delete(buttonName);
+ }
+
+ listItem.removeEventListener("transitionend", callback);
+ } catch (e) {
+ // ignore errors if the element has already been removed
+ }
+ };
+
+ listItem.addEventListener("transitionend", callback);
+
+ hide(buttonName);
+ }
+}
+
+/**
+ * Hides a button by its button name.
+ */
+export function hide(buttonName: string): void {
+ const button = _buttons.get(buttonName);
+ if (button) {
+ const parent = button.parentElement!;
+ parent.setAttribute("aria-hidden", "true");
+
+ renderContainer();
+ }
+}
+
+/**
+ * Shows a button by its button name.
+ */
+export function show(buttonName: string): void {
+ const button = _buttons.get(buttonName);
+ if (button) {
+ const parent = button.parentElement!;
+ if (parent.classList.contains("initiallyHidden")) {
+ parent.classList.remove("initiallyHidden");
+ }
+
+ parent.setAttribute("aria-hidden", "false");
+ _wrapper.classList.remove("scrolledDown");
+
+ renderContainer();
+ }
+}
--- /dev/null
+/**
+ * Manages the sticky page header.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Page/Header/Fixed
+ */
+
+import * as EventHandler from "../../../Event/Handler";
+import * as UiAlignment from "../../Alignment";
+import UiCloseOverlay from "../../CloseOverlay";
+import UiDropdownSimple from "../../Dropdown/Simple";
+import * as UiScreen from "../../Screen";
+
+let _isMobile = false;
+
+let _pageHeader: HTMLElement;
+let _pageHeaderPanel: HTMLElement;
+let _pageHeaderSearch: HTMLElement;
+let _searchInput: HTMLInputElement;
+let _topMenu: HTMLElement;
+let _userPanelSearchButton: HTMLElement;
+
+/**
+ * Provides the collapsible search bar.
+ */
+function initSearchBar(): void {
+ _pageHeaderSearch = document.getElementById("pageHeaderSearch")!;
+ _pageHeaderSearch.addEventListener("click", (ev) => ev.stopPropagation());
+
+ _pageHeaderPanel = document.getElementById("pageHeaderPanel")!;
+ _searchInput = document.getElementById("pageHeaderSearchInput") as HTMLInputElement;
+ _topMenu = document.getElementById("topMenu")!;
+
+ _userPanelSearchButton = document.getElementById("userPanelSearchButton")!;
+ _userPanelSearchButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (_pageHeader.classList.contains("searchBarOpen")) {
+ closeSearchBar();
+ } else {
+ openSearchBar();
+ }
+ });
+
+ UiCloseOverlay.add("WoltLabSuite/Core/Ui/Page/Header/Fixed", () => {
+ if (_pageHeader.classList.contains("searchBarForceOpen")) {
+ return;
+ }
+
+ closeSearchBar();
+ });
+
+ EventHandler.add("com.woltlab.wcf.MainMenuMobile", "more", (data) => {
+ if (data.identifier === "com.woltlab.wcf.search") {
+ data.handler.close(true);
+
+ _userPanelSearchButton.click();
+ }
+ });
+}
+
+/**
+ * Opens the search bar.
+ */
+function openSearchBar(): void {
+ window.WCF.Dropdown.Interactive.Handler.closeAll();
+
+ _pageHeader.classList.add("searchBarOpen");
+ _userPanelSearchButton.parentElement!.classList.add("open");
+
+ if (!_isMobile) {
+ // calculate value for `right` on desktop
+ UiAlignment.set(_pageHeaderSearch, _topMenu, {
+ horizontal: "right",
+ });
+ }
+
+ _pageHeaderSearch.style.setProperty("top", `${_pageHeaderPanel.clientHeight}px`, "");
+ _searchInput.focus();
+
+ window.setTimeout(() => {
+ _searchInput.selectionStart = _searchInput.selectionEnd = _searchInput.value.length;
+ }, 1);
+}
+
+/**
+ * Closes the search bar.
+ */
+function closeSearchBar(): void {
+ _pageHeader.classList.remove("searchBarOpen");
+ _userPanelSearchButton.parentElement!.classList.remove("open");
+
+ ["bottom", "left", "right", "top"].forEach((propertyName) => {
+ _pageHeaderSearch.style.removeProperty(propertyName);
+ });
+
+ _searchInput.blur();
+
+ // close the scope selection
+ const scope = _pageHeaderSearch.querySelector(".pageHeaderSearchType")!;
+ UiDropdownSimple.close(scope.id);
+}
+
+/**
+ * Initializes the sticky page header handler.
+ */
+export function init(): void {
+ _pageHeader = document.getElementById("pageHeader")!;
+
+ initSearchBar();
+
+ UiScreen.on("screen-md-down", {
+ match() {
+ _isMobile = true;
+ },
+ unmatch() {
+ _isMobile = false;
+ },
+ setup() {
+ _isMobile = true;
+ },
+ });
+
+ EventHandler.add("com.woltlab.wcf.Search", "close", closeSearchBar);
+}
--- /dev/null
+/**
+ * Handles main menu overflow and a11y.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Page/Header/Menu
+ */
+
+import * as Environment from "../../../Environment";
+import * as Language from "../../../Language";
+import * as UiScreen from "../../Screen";
+
+let _enabled = false;
+
+let _buttonShowNext: HTMLAnchorElement;
+let _buttonShowPrevious: HTMLAnchorElement;
+let _firstElement: HTMLElement;
+let _menu: HTMLElement;
+
+let _marginLeft = 0;
+let _invisibleLeft: HTMLElement[] = [];
+let _invisibleRight: HTMLElement[] = [];
+
+/**
+ * Enables the overflow handler.
+ */
+function enable(): void {
+ _enabled = true;
+
+ // Safari waits three seconds for a font to be loaded which causes the header menu items
+ // to be extremely wide while waiting for the font to be loaded. The extremely wide menu
+ // items in turn can cause the overflow controls to be shown even if the width of the header
+ // menu, after the font has been loaded successfully, does not require them. This width
+ // issue results in the next button being shown for a short time. To circumvent this issue,
+ // we wait a second before showing the obverflow controls in Safari.
+ // see https://webkit.org/blog/6643/improved-font-loading/
+ if (Environment.browser() === "safari") {
+ window.setTimeout(rebuildVisibility, 1000);
+ } else {
+ rebuildVisibility();
+
+ // IE11 sometimes suffers from a timing issue
+ window.setTimeout(rebuildVisibility, 1000);
+ }
+}
+
+/**
+ * Disables the overflow handler.
+ */
+function disable(): void {
+ _enabled = false;
+}
+
+/**
+ * Displays the next three menu items.
+ */
+function showNext(event: MouseEvent): void {
+ event.preventDefault();
+
+ if (_invisibleRight.length) {
+ const showItem = _invisibleRight.slice(0, 3).pop()!;
+ setMarginLeft(_menu.clientWidth - (showItem.offsetLeft + showItem.clientWidth));
+
+ if (_menu.lastElementChild === showItem) {
+ _buttonShowNext.classList.remove("active");
+ }
+
+ _buttonShowPrevious.classList.add("active");
+ }
+}
+
+/**
+ * Displays the previous three menu items.
+ */
+function showPrevious(event: MouseEvent): void {
+ event.preventDefault();
+
+ if (_invisibleLeft.length) {
+ const showItem = _invisibleLeft.slice(-3)[0];
+ setMarginLeft(showItem.offsetLeft * -1);
+
+ if (_menu.firstElementChild === showItem) {
+ _buttonShowPrevious.classList.remove("active");
+ }
+
+ _buttonShowNext.classList.add("active");
+ }
+}
+
+/**
+ * Sets the first item's margin-left value that is
+ * used to move the menu contents around.
+ */
+function setMarginLeft(offset: number): void {
+ _marginLeft = Math.min(_marginLeft + offset, 0);
+
+ _firstElement.style.setProperty("margin-left", `${_marginLeft}px`, "");
+}
+
+/**
+ * Toggles button overlays and rebuilds the list
+ * of invisible items from left to right.
+ */
+function rebuildVisibility(): void {
+ if (!_enabled) return;
+
+ _invisibleLeft = [];
+ _invisibleRight = [];
+
+ const menuWidth = _menu.clientWidth;
+ if (_menu.scrollWidth > menuWidth || _marginLeft < 0) {
+ Array.from(_menu.children).forEach((child: HTMLElement) => {
+ const offsetLeft = child.offsetLeft;
+ if (offsetLeft < 0) {
+ _invisibleLeft.push(child);
+ } else if (offsetLeft + child.clientWidth > menuWidth) {
+ _invisibleRight.push(child);
+ }
+ });
+ }
+
+ _buttonShowPrevious.classList[_invisibleLeft.length ? "add" : "remove"]("active");
+ _buttonShowNext.classList[_invisibleRight.length ? "add" : "remove"]("active");
+}
+
+/**
+ * Builds the UI and binds the event listeners.
+ */
+function setup(): void {
+ setupOverflow();
+ setupA11y();
+}
+
+/**
+ * Setups overflow handling.
+ */
+function setupOverflow(): void {
+ const menuParent = _menu.parentElement!;
+
+ _buttonShowNext = document.createElement("a");
+ _buttonShowNext.className = "mainMenuShowNext";
+ _buttonShowNext.href = "#";
+ _buttonShowNext.innerHTML = '<span class="icon icon32 fa-angle-right"></span>';
+ _buttonShowNext.setAttribute("aria-hidden", "true");
+ _buttonShowNext.addEventListener("click", showNext);
+
+ menuParent.appendChild(_buttonShowNext);
+
+ _buttonShowPrevious = document.createElement("a");
+ _buttonShowPrevious.className = "mainMenuShowPrevious";
+ _buttonShowPrevious.href = "#";
+ _buttonShowPrevious.innerHTML = '<span class="icon icon32 fa-angle-left"></span>';
+ _buttonShowPrevious.setAttribute("aria-hidden", "true");
+ _buttonShowPrevious.addEventListener("click", showPrevious);
+
+ menuParent.insertBefore(_buttonShowPrevious, menuParent.firstChild);
+
+ _firstElement.addEventListener("transitionend", rebuildVisibility);
+
+ window.addEventListener("resize", () => {
+ _firstElement.style.setProperty("margin-left", "0px", "");
+ _marginLeft = 0;
+
+ rebuildVisibility();
+ });
+
+ enable();
+}
+
+/**
+ * Setups a11y improvements.
+ */
+function setupA11y(): void {
+ _menu.querySelectorAll(".boxMenuHasChildren").forEach((element) => {
+ const link = element.querySelector(".boxMenuLink")!;
+ link.setAttribute("aria-haspopup", "true");
+ link.setAttribute("aria-expanded", "false");
+
+ const showMenuButton = document.createElement("button");
+ showMenuButton.className = "visuallyHidden";
+ showMenuButton.tabIndex = 0;
+ showMenuButton.setAttribute("role", "button");
+ showMenuButton.setAttribute("aria-label", Language.get("wcf.global.button.showMenu"));
+ element.insertBefore(showMenuButton, link.nextSibling);
+
+ let showMenu = false;
+ showMenuButton.addEventListener("click", () => {
+ showMenu = !showMenu;
+ link.setAttribute("aria-expanded", showMenu ? "true" : "false");
+ showMenuButton.setAttribute(
+ "aria-label",
+ Language.get(showMenu ? "wcf.global.button.hideMenu" : "wcf.global.button.showMenu"),
+ );
+ });
+ });
+}
+
+/**
+ * Initializes the main menu overflow handling.
+ */
+export function init(): void {
+ const menu = document.querySelector(".mainMenu .boxMenu") as HTMLElement;
+ const firstElement = menu && menu.childElementCount ? (menu.children[0] as HTMLElement) : null;
+ if (firstElement === null) {
+ throw new Error("Unable to find the main menu.");
+ }
+
+ _menu = menu;
+ _firstElement = firstElement;
+
+ UiScreen.on("screen-lg", {
+ match: enable,
+ unmatch: disable,
+ setup: setup,
+ });
+}
--- /dev/null
+/**
+ * Utility class to provide a 'Jump To' overlay.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Page/JumpTo
+ */
+
+import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
+import * as Language from "../../Language";
+import UiDialog from "../Dialog";
+
+class UiPageJumpTo implements DialogCallbackObject {
+ private activeElement: HTMLElement;
+ private description: HTMLElement;
+ private elements = new Map<HTMLElement, Callback>();
+ private input: HTMLInputElement;
+ private submitButton: HTMLButtonElement;
+
+ /**
+ * Initializes a 'Jump To' element.
+ */
+ init(element: HTMLElement, callback?: Callback | null): void {
+ if (!callback) {
+ const redirectUrl = element.dataset.link;
+ if (redirectUrl) {
+ callback = (pageNo) => {
+ window.location.href = redirectUrl.replace(/pageNo=%d/, `pageNo=${pageNo}`);
+ };
+ } else {
+ callback = () => {
+ // Do nothing.
+ };
+ }
+ } else if (typeof callback !== "function") {
+ throw new TypeError("Expected a valid function for parameter 'callback'.");
+ }
+
+ if (!this.elements.has(element)) {
+ element.querySelectorAll(".jumpTo").forEach((jumpTo: HTMLElement) => {
+ jumpTo.addEventListener("click", (ev) => this.click(element, ev));
+ this.elements.set(element, callback!);
+ });
+ }
+ }
+
+ /**
+ * Handles clicks on the trigger element.
+ */
+ private click(element: HTMLElement, event: MouseEvent): void {
+ event.preventDefault();
+
+ this.activeElement = element;
+
+ UiDialog.open(this);
+
+ const pages = element.dataset.pages || "0";
+ this.input.value = pages;
+ this.input.max = pages;
+ this.input.select();
+
+ this.description.textContent = Language.get("wcf.page.jumpTo.description").replace(/#pages#/, pages);
+ }
+
+ /**
+ * Handles changes to the page number input field.
+ *
+ * @param {object} event event object
+ */
+ _keyUp(event: KeyboardEvent): void {
+ if (event.key === "Enter" && !this.submitButton.disabled) {
+ this.submit();
+ return;
+ }
+
+ const pageNo = +this.input.value;
+ this.submitButton.disabled = pageNo < 1 || pageNo > +this.input.max;
+ }
+
+ /**
+ * Invokes the callback with the chosen page number as first argument.
+ */
+ private submit(): void {
+ const callback = this.elements.get(this.activeElement) as Callback;
+ callback(+this.input.value);
+
+ UiDialog.close(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ const source = `<dl>
+ <dt><label for="jsPaginationPageNo">${Language.get("wcf.page.jumpTo")}</label></dt>
+ <dd>
+ <input type="number" id="jsPaginationPageNo" value="1" min="1" max="1" class="tiny">
+ <small></small>
+ </dd>
+ </dl>
+ <div class="formSubmit">
+ <button class="buttonPrimary">${Language.get("wcf.global.button.submit")}</button>
+ </div>`;
+
+ return {
+ id: "paginationOverlay",
+ options: {
+ onSetup: (content) => {
+ this.input = content.querySelector("input")!;
+ this.input.addEventListener("keyup", (ev) => this._keyUp(ev));
+
+ this.description = content.querySelector("small")!;
+
+ this.submitButton = content.querySelector("button")!;
+ this.submitButton.addEventListener("click", () => this.submit());
+ },
+ title: Language.get("wcf.global.page.pagination"),
+ },
+ source: source,
+ };
+ }
+}
+
+let jumpTo: UiPageJumpTo | null = null;
+
+function getUiPageJumpTo(): UiPageJumpTo {
+ if (jumpTo === null) {
+ jumpTo = new UiPageJumpTo();
+ }
+
+ return jumpTo;
+}
+
+/**
+ * Initializes a 'Jump To' element.
+ */
+export function init(element: HTMLElement, callback?: Callback | null): void {
+ getUiPageJumpTo().init(element, callback);
+}
+
+type Callback = (pageNo: number) => void;
--- /dev/null
+/**
+ * Provides a touch-friendly fullscreen menu.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Page/Menu/Abstract
+ */
+
+import * as Core from "../../../Core";
+import * as Environment from "../../../Environment";
+import * as EventHandler from "../../../Event/Handler";
+import * as Language from "../../../Language";
+import * as DomTraverse from "../../../Dom/Traverse";
+import * as UiScreen from "../../Screen";
+
+const _pageContainer = document.getElementById("pageContainer")!;
+
+const enum TouchPosition {
+ AtEdge = 20,
+ MovedHorizontally = 5,
+ MovedVertically = 20,
+}
+
+/**
+ * Which edge of the menu is touched? Empty string
+ * if no menu is currently touched.
+ *
+ * One 'left', 'right' or ''.
+ */
+let _androidTouching = "";
+
+interface ItemData {
+ itemList: HTMLOListElement;
+ parentItemList: HTMLOListElement;
+}
+
+abstract class UiPageMenuAbstract {
+ private readonly activeList: HTMLOListElement[] = [];
+ protected readonly button: HTMLElement;
+ private depth = 0;
+ private enabled = true;
+ private readonly eventIdentifier: string;
+ private readonly items = new Map<HTMLAnchorElement, ItemData>();
+ protected readonly menu: HTMLElement;
+ private removeActiveList = false;
+
+ protected constructor(eventIdentifier: string, elementId: string, buttonSelector: string) {
+ if (document.body.dataset.template === "packageInstallationSetup") {
+ // work-around for WCFSetup on mobile
+ return;
+ }
+
+ this.eventIdentifier = eventIdentifier;
+ this.menu = document.getElementById(elementId)!;
+
+ const callbackOpen = this.open.bind(this);
+ this.button = document.querySelector(buttonSelector) as HTMLElement;
+ this.button.addEventListener("click", callbackOpen);
+
+ this.initItems();
+ this.initHeader();
+
+ EventHandler.add(this.eventIdentifier, "open", callbackOpen);
+ EventHandler.add(this.eventIdentifier, "close", this.close.bind(this));
+ EventHandler.add(this.eventIdentifier, "updateButtonState", this.updateButtonState.bind(this));
+
+ this.menu.addEventListener("animationend", () => {
+ if (!this.menu.classList.contains("open")) {
+ this.menu.querySelectorAll(".menuOverlayItemList").forEach((itemList) => {
+ // force the main list to be displayed
+ itemList.classList.remove("active", "hidden");
+ });
+ }
+ });
+
+ this.menu.children[0].addEventListener("transitionend", () => {
+ this.menu.classList.add("allowScroll");
+
+ if (this.removeActiveList) {
+ this.removeActiveList = false;
+
+ const list = this.activeList.pop();
+ if (list) {
+ list.classList.remove("activeList");
+ }
+ }
+ });
+
+ const backdrop = document.createElement("div");
+ backdrop.className = "menuOverlayMobileBackdrop";
+ backdrop.addEventListener("click", this.close.bind(this));
+
+ this.menu.insertAdjacentElement("afterend", backdrop);
+
+ this.menu.parentElement!.insertBefore(backdrop, this.menu.nextSibling);
+
+ this.updateButtonState();
+
+ if (Environment.platform() === "android") {
+ this.initializeAndroid();
+ }
+ }
+
+ /**
+ * Opens the menu.
+ */
+ open(event?: MouseEvent): boolean {
+ if (!this.enabled) {
+ return false;
+ }
+
+ if (event instanceof Event) {
+ event.preventDefault();
+ }
+
+ this.menu.classList.add("open");
+ this.menu.classList.add("allowScroll");
+ this.menu.children[0].classList.add("activeList");
+
+ UiScreen.scrollDisable();
+
+ _pageContainer.classList.add("menuOverlay-" + this.menu.id);
+
+ UiScreen.pageOverlayOpen();
+
+ return true;
+ }
+
+ /**
+ * Closes the menu.
+ */
+ close(event?: Event): boolean {
+ if (event instanceof Event) {
+ event.preventDefault();
+ }
+
+ if (this.menu.classList.contains("open")) {
+ this.menu.classList.remove("open");
+
+ UiScreen.scrollEnable();
+ UiScreen.pageOverlayClose();
+
+ _pageContainer.classList.remove("menuOverlay-" + this.menu.id);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Enables the touch menu.
+ */
+ enable(): void {
+ this.enabled = true;
+ }
+
+ /**
+ * Disables the touch menu.
+ */
+ disable(): void {
+ this.enabled = false;
+
+ this.close();
+ }
+
+ /**
+ * Initializes the Android Touch Menu.
+ */
+ private initializeAndroid(): void {
+ // specify on which side of the page the menu appears
+ let appearsAt: "left" | "right";
+ switch (this.menu.id) {
+ case "pageUserMenuMobile":
+ appearsAt = "right";
+ break;
+ case "pageMainMenuMobile":
+ appearsAt = "left";
+ break;
+ default:
+ return;
+ }
+
+ const backdrop = this.menu.nextElementSibling as HTMLElement;
+
+ // horizontal position of the touch start
+ let touchStart: { x: number; y: number } | undefined = undefined;
+
+ document.addEventListener("touchstart", (event) => {
+ const touches = event.touches;
+
+ let isLeftEdge: boolean;
+ let isRightEdge: boolean;
+
+ const isOpen = this.menu.classList.contains("open");
+
+ // check whether we touch the edges of the menu
+ if (appearsAt === "left") {
+ isLeftEdge = !isOpen && touches[0].clientX < TouchPosition.AtEdge;
+ isRightEdge = isOpen && Math.abs(this.menu.offsetWidth - touches[0].clientX) < TouchPosition.AtEdge;
+ } else {
+ isLeftEdge =
+ isOpen &&
+ Math.abs(document.body.clientWidth - this.menu.offsetWidth - touches[0].clientX) < TouchPosition.AtEdge;
+ isRightEdge = !isOpen && document.body.clientWidth - touches[0].clientX < TouchPosition.AtEdge;
+ }
+
+ // abort if more than one touch
+ if (touches.length > 1) {
+ if (_androidTouching) {
+ Core.triggerEvent(document, "touchend");
+ }
+ return;
+ }
+
+ // break if a touch is in progress
+ if (_androidTouching) {
+ return;
+ }
+
+ // break if no edge has been touched
+ if (!isLeftEdge && !isRightEdge) {
+ return;
+ }
+
+ // break if a different menu is open
+ if (UiScreen.pageOverlayIsActive()) {
+ const found = _pageContainer.classList.contains(`menuOverlay-${this.menu.id}`);
+ if (!found) {
+ return;
+ }
+ }
+ // break if redactor is in use
+ if (document.documentElement.classList.contains("redactorActive")) {
+ return;
+ }
+
+ touchStart = {
+ x: touches[0].clientX,
+ y: touches[0].clientY,
+ };
+
+ if (isLeftEdge) {
+ _androidTouching = "left";
+ }
+ if (isRightEdge) {
+ _androidTouching = "right";
+ }
+ });
+
+ document.addEventListener("touchend", (event) => {
+ // break if we did not start a touch
+ if (!_androidTouching || !touchStart) {
+ return;
+ }
+
+ // break if the menu did not even start opening
+ if (!this.menu.classList.contains("open")) {
+ // reset
+ touchStart = undefined;
+ _androidTouching = "";
+ return;
+ }
+
+ // last known position of the finger
+ let position: number;
+ if (event) {
+ position = event.changedTouches[0].clientX;
+ } else {
+ position = touchStart.x;
+ }
+
+ // clean up touch styles
+ this.menu.classList.add("androidMenuTouchEnd");
+ this.menu.style.removeProperty("transform");
+ backdrop.style.removeProperty(appearsAt);
+ this.menu.addEventListener(
+ "transitionend",
+ () => {
+ this.menu.classList.remove("androidMenuTouchEnd");
+ },
+ { once: true },
+ );
+
+ // check whether the user moved the finger far enough
+ if (appearsAt === "left") {
+ if (_androidTouching === "left" && position < touchStart.x + 100) {
+ this.close();
+ }
+ if (_androidTouching === "right" && position < touchStart.x - 100) {
+ this.close();
+ }
+ } else {
+ if (_androidTouching === "left" && position > touchStart.x + 100) {
+ this.close();
+ }
+ if (_androidTouching === "right" && position > touchStart.x - 100) {
+ this.close();
+ }
+ }
+
+ // reset
+ touchStart = undefined;
+ _androidTouching = "";
+ });
+
+ document.addEventListener("touchmove", (event) => {
+ // break if we did not start a touch
+ if (!_androidTouching || !touchStart) {
+ return;
+ }
+
+ const touches = event.touches;
+
+ // check whether the user started moving in the correct direction
+ // this avoids false positives, in case the user just wanted to tap
+ let movedFromEdge = false;
+ if (_androidTouching === "left") {
+ movedFromEdge = touches[0].clientX > touchStart.x + TouchPosition.MovedHorizontally;
+ }
+ if (_androidTouching === "right") {
+ movedFromEdge = touches[0].clientX < touchStart.x - TouchPosition.MovedHorizontally;
+ }
+
+ const movedVertically = Math.abs(touches[0].clientY - touchStart.y) > TouchPosition.MovedVertically;
+
+ let isOpen = this.menu.classList.contains("open");
+ if (!isOpen && movedFromEdge && !movedVertically) {
+ // the menu is not yet open, but the user moved into the right direction
+ this.open();
+ isOpen = true;
+ }
+
+ if (isOpen) {
+ // update CSS to the new finger position
+ let position = touches[0].clientX;
+ if (appearsAt === "right") {
+ position = document.body.clientWidth - position;
+ }
+ if (position > this.menu.offsetWidth) {
+ position = this.menu.offsetWidth;
+ }
+ if (position < 0) {
+ position = 0;
+ }
+
+ const offset = (appearsAt === "left" ? 1 : -1) * (position - this.menu.offsetWidth);
+ this.menu.style.setProperty("transform", `translateX(${offset}px)`);
+ backdrop.style.setProperty(appearsAt, Math.min(this.menu.offsetWidth, position).toString() + "px");
+ }
+ });
+ }
+
+ /**
+ * Initializes all menu items.
+ */
+ private initItems(): void {
+ this.menu.querySelectorAll(".menuOverlayItemLink").forEach((element: HTMLAnchorElement) => {
+ this.initItem(element);
+ });
+ }
+
+ /**
+ * Initializes a single menu item.
+ */
+ private initItem(item: HTMLAnchorElement): void {
+ // check if it should contain a 'more' link w/ an external callback
+ const parent = item.parentElement!;
+ const more = parent.dataset.more;
+ if (more) {
+ item.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ EventHandler.fire(this.eventIdentifier, "more", {
+ handler: this,
+ identifier: more,
+ item: item,
+ parent: parent,
+ });
+ });
+
+ return;
+ }
+
+ const itemList = item.nextElementSibling as HTMLOListElement;
+ if (itemList === null) {
+ return;
+ }
+
+ // handle static items with an icon-type button next to it (acp menu)
+ if (itemList.nodeName !== "OL" && itemList.classList.contains("menuOverlayItemLinkIcon")) {
+ // add wrapper
+ const wrapper = document.createElement("span");
+ wrapper.className = "menuOverlayItemWrapper";
+ parent.insertBefore(wrapper, item);
+ wrapper.appendChild(item);
+
+ while (wrapper.nextElementSibling) {
+ wrapper.appendChild(wrapper.nextElementSibling);
+ }
+
+ return;
+ }
+
+ const isLink = item.href !== "#";
+ const parentItemList = parent.parentElement as HTMLOListElement;
+ let itemTitle = itemList.dataset.title;
+
+ this.items.set(item, {
+ itemList: itemList,
+ parentItemList: parentItemList,
+ });
+
+ if (!itemTitle) {
+ itemTitle = DomTraverse.childByClass(item, "menuOverlayItemTitle")!.textContent!;
+ itemList.dataset.title = itemTitle;
+ }
+
+ const callbackLink = this.showItemList.bind(this, item);
+ if (isLink) {
+ const wrapper = document.createElement("span");
+ wrapper.className = "menuOverlayItemWrapper";
+ parent.insertBefore(wrapper, item);
+ wrapper.appendChild(item);
+
+ const moreLink = document.createElement("a");
+ moreLink.href = "#";
+ moreLink.className = "menuOverlayItemLinkIcon" + (item.classList.contains("active") ? " active" : "");
+ moreLink.innerHTML = '<span class="icon icon24 fa-angle-right"></span>';
+ moreLink.addEventListener("click", callbackLink);
+ wrapper.appendChild(moreLink);
+ } else {
+ item.classList.add("menuOverlayItemLinkMore");
+ item.addEventListener("click", callbackLink);
+ }
+
+ const backLinkItem = document.createElement("li");
+ backLinkItem.className = "menuOverlayHeader";
+
+ const wrapper = document.createElement("span");
+ wrapper.className = "menuOverlayItemWrapper";
+
+ const backLink = document.createElement("a");
+ backLink.href = "#";
+ backLink.className = "menuOverlayItemLink menuOverlayBackLink";
+ backLink.textContent = parentItemList.dataset.title || "";
+ backLink.addEventListener("click", this.hideItemList.bind(this, item));
+
+ const closeLink = document.createElement("a");
+ closeLink.href = "#";
+ closeLink.className = "menuOverlayItemLinkIcon";
+ closeLink.innerHTML = '<span class="icon icon24 fa-times"></span>';
+ closeLink.addEventListener("click", this.close.bind(this));
+
+ wrapper.appendChild(backLink);
+ wrapper.appendChild(closeLink);
+ backLinkItem.appendChild(wrapper);
+
+ itemList.insertBefore(backLinkItem, itemList.firstElementChild);
+
+ if (!backLinkItem.nextElementSibling!.classList.contains("menuOverlayTitle")) {
+ const titleItem = document.createElement("li");
+ titleItem.className = "menuOverlayTitle";
+ const title = document.createElement("span");
+ title.textContent = itemTitle;
+ titleItem.appendChild(title);
+
+ itemList.insertBefore(titleItem, backLinkItem.nextElementSibling);
+ }
+ }
+
+ /**
+ * Renders the menu item list header.
+ */
+ private initHeader(): void {
+ const listItem = document.createElement("li");
+ listItem.className = "menuOverlayHeader";
+
+ const wrapper = document.createElement("span");
+ wrapper.className = "menuOverlayItemWrapper";
+ listItem.appendChild(wrapper);
+
+ const logoWrapper = document.createElement("span");
+ logoWrapper.className = "menuOverlayLogoWrapper";
+ wrapper.appendChild(logoWrapper);
+
+ const logo = document.createElement("span");
+ logo.className = "menuOverlayLogo";
+ const pageLogo = this.menu.dataset.pageLogo!;
+ logo.style.setProperty("background-image", `url("${pageLogo}")`, "");
+ logoWrapper.appendChild(logo);
+
+ const closeLink = document.createElement("a");
+ closeLink.href = "#";
+ closeLink.className = "menuOverlayItemLinkIcon";
+ closeLink.innerHTML = '<span class="icon icon24 fa-times"></span>';
+ closeLink.addEventListener("click", this.close.bind(this));
+ wrapper.appendChild(closeLink);
+
+ const list = DomTraverse.childByClass(this.menu, "menuOverlayItemList")!;
+ list.insertBefore(listItem, list.firstElementChild);
+ }
+
+ /**
+ * Hides an item list, return to the parent item list.
+ */
+ private hideItemList(item: HTMLAnchorElement, event: MouseEvent): void {
+ if (event instanceof Event) {
+ event.preventDefault();
+ }
+
+ this.menu.classList.remove("allowScroll");
+ this.removeActiveList = true;
+
+ const data = this.items.get(item)!;
+ data.parentItemList.classList.remove("hidden");
+
+ this.updateDepth(false);
+ }
+
+ /**
+ * Shows the child item list.
+ */
+ private showItemList(item: HTMLAnchorElement, event: MouseEvent): void {
+ event.preventDefault();
+
+ const data = this.items.get(item)!;
+
+ const load = data.itemList.dataset.load;
+ if (load) {
+ if (!Core.stringToBool(item.dataset.loaded || "")) {
+ const target = event.currentTarget as HTMLElement;
+ const icon = target.firstElementChild!;
+ if (icon.classList.contains("fa-angle-right")) {
+ icon.classList.remove("fa-angle-right");
+ icon.classList.add("fa-spinner");
+ }
+
+ EventHandler.fire(this.eventIdentifier, "load_" + load);
+
+ return;
+ }
+ }
+
+ this.menu.classList.remove("allowScroll");
+
+ data.itemList.classList.add("activeList");
+ data.parentItemList.classList.add("hidden");
+
+ this.activeList.push(data.itemList);
+
+ this.updateDepth(true);
+ }
+
+ private updateDepth(increase: boolean): void {
+ this.depth += increase ? 1 : -1;
+
+ let offset = this.depth * -100;
+ if (Language.get("wcf.global.pageDirection") === "rtl") {
+ // reverse logic for RTL
+ offset *= -1;
+ }
+
+ const child = this.menu.children[0] as HTMLElement;
+ child.style.setProperty("transform", `translateX(${offset}%)`, "");
+ }
+
+ protected updateButtonState(): void {
+ let hasNewContent = false;
+ const itemList = this.menu.querySelector(".menuOverlayItemList");
+ this.menu.querySelectorAll(".badgeUpdate").forEach((badge) => {
+ const value = badge.textContent!;
+ if (~~value > 0 && badge.closest(".menuOverlayItemList") === itemList) {
+ hasNewContent = true;
+ }
+ });
+
+ this.button.classList[hasNewContent ? "add" : "remove"]("pageMenuMobileButtonHasContent");
+ }
+}
+
+Core.enableLegacyInheritance(UiPageMenuAbstract);
+
+export = UiPageMenuAbstract;
--- /dev/null
+/**
+ * Provides the touch-friendly fullscreen main menu.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Page/Menu/Main
+ */
+
+import * as Core from "../../../Core";
+import DomUtil from "../../../Dom/Util";
+import * as Language from "../../../Language";
+import UiPageMenuAbstract from "./Abstract";
+
+class UiPageMenuMain extends UiPageMenuAbstract {
+ private hasItems = false;
+ private readonly navigationList: HTMLOListElement;
+ private readonly title: HTMLElement;
+
+ /**
+ * Initializes the touch-friendly fullscreen main menu.
+ */
+ constructor() {
+ super("com.woltlab.wcf.MainMenuMobile", "pageMainMenuMobile", "#pageHeader .mainMenu");
+
+ this.title = document.getElementById("pageMainMenuMobilePageOptionsTitle") as HTMLElement;
+ if (this.title !== null) {
+ this.navigationList = document.querySelector(".jsPageNavigationIcons") as HTMLOListElement;
+ }
+
+ this.button.setAttribute("aria-label", Language.get("wcf.menu.page"));
+ this.button.setAttribute("role", "button");
+ }
+
+ open(event?: MouseEvent): boolean {
+ if (!super.open(event)) {
+ return false;
+ }
+
+ if (this.title === null) {
+ return true;
+ }
+
+ this.hasItems = this.navigationList && this.navigationList.childElementCount > 0;
+
+ if (this.hasItems) {
+ while (this.navigationList.childElementCount) {
+ const item = this.navigationList.children[0];
+
+ item.classList.add("menuOverlayItem", "menuOverlayItemOption");
+ item.addEventListener("click", (ev) => {
+ ev.stopPropagation();
+
+ this.close();
+ });
+
+ const link = item.children[0];
+ link.classList.add("menuOverlayItemLink");
+ link.classList.add("box24");
+
+ link.children[1].classList.remove("invisible");
+ link.children[1].classList.add("menuOverlayItemTitle");
+
+ this.title.insertAdjacentElement("afterend", item);
+ }
+
+ DomUtil.show(this.title);
+ } else {
+ DomUtil.hide(this.title);
+ }
+
+ return true;
+ }
+
+ close(event?: Event): boolean {
+ if (!super.close(event)) {
+ return false;
+ }
+
+ if (this.hasItems) {
+ DomUtil.hide(this.title);
+
+ let item = this.title.nextElementSibling;
+ while (item && item.classList.contains("menuOverlayItemOption")) {
+ item.classList.remove("menuOverlayItem", "menuOverlayItemOption");
+ item.removeEventListener("click", (ev) => {
+ ev.stopPropagation();
+
+ this.close();
+ });
+
+ const link = item.children[0];
+ link.classList.remove("menuOverlayItemLink");
+ link.classList.remove("box24");
+
+ link.children[1].classList.add("invisible");
+ link.children[1].classList.remove("menuOverlayItemTitle");
+
+ this.navigationList.appendChild(item);
+
+ item = item.nextElementSibling;
+ }
+ }
+
+ return true;
+ }
+}
+
+Core.enableLegacyInheritance(UiPageMenuMain);
+
+export = UiPageMenuMain;
--- /dev/null
+/**
+ * Provides the touch-friendly fullscreen user menu.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Page/Menu/User
+ */
+
+import * as Core from "../../../Core";
+import * as EventHandler from "../../../Event/Handler";
+import * as Language from "../../../Language";
+import UiPageMenuAbstract from "./Abstract";
+
+interface EventPayload {
+ count: number;
+ identifier: string;
+}
+
+class UiPageMenuUser extends UiPageMenuAbstract {
+ /**
+ * Initializes the touch-friendly fullscreen user menu.
+ */
+ constructor() {
+ // check if user menu is actually empty
+ const menu = document.querySelector("#pageUserMenuMobile > .menuOverlayItemList")!;
+ if (menu.childElementCount === 1 && menu.children[0].classList.contains("menuOverlayTitle")) {
+ const userPanel = document.querySelector("#pageHeader .userPanel")!;
+ userPanel.classList.add("hideUserPanel");
+ return;
+ }
+
+ super("com.woltlab.wcf.UserMenuMobile", "pageUserMenuMobile", "#pageHeader .userPanel");
+
+ EventHandler.add("com.woltlab.wcf.userMenu", "updateBadge", (data) => this.updateBadge(data));
+
+ this.button.setAttribute("aria-label", Language.get("wcf.menu.user"));
+ this.button.setAttribute("role", "button");
+ }
+
+ close(event?: Event): boolean {
+ // The user menu is not initialized if there are no items to display.
+ if (this.menu === undefined) {
+ return false;
+ }
+
+ const dropdown = window.WCF.Dropdown.Interactive.Handler.getOpenDropdown();
+ if (dropdown) {
+ if (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ dropdown.close();
+
+ return true;
+ }
+
+ return super.close(event);
+ }
+
+ private updateBadge(data: EventPayload): void {
+ this.menu.querySelectorAll(".menuOverlayItemBadge").forEach((item: HTMLElement) => {
+ if (item.dataset.badgeIdentifier === data.identifier) {
+ let badge = item.querySelector(".badge");
+ if (data.count) {
+ if (badge === null) {
+ badge = document.createElement("span");
+ badge.className = "badge badgeUpdate";
+ item.appendChild(badge);
+ }
+
+ badge.textContent = data.count.toString();
+ } else if (badge !== null) {
+ badge.remove();
+ }
+
+ this.updateButtonState();
+ }
+ });
+ }
+}
+
+Core.enableLegacyInheritance(UiPageMenuUser);
+
+export = UiPageMenuUser;
--- /dev/null
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../Ajax/Data";
+import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
+import DomUtil from "../../Dom/Util";
+import * as Language from "../../Language";
+import * as StringUtil from "../../StringUtil";
+import UiDialog from "../Dialog";
+
+type CallbackSelect = (value: string) => void;
+
+interface SearchResult {
+ displayLink: string;
+ name: string;
+ pageID: number;
+}
+
+interface AjaxResponse extends DatabaseObjectActionResponse {
+ returnValues: SearchResult[];
+}
+
+class UiPageSearch implements AjaxCallbackObject, DialogCallbackObject {
+ private callbackSelect?: CallbackSelect = undefined;
+ private resultContainer?: HTMLElement = undefined;
+ private resultList?: HTMLOListElement = undefined;
+ private searchInput?: HTMLInputElement = undefined;
+
+ open(callbackSelect: CallbackSelect): void {
+ this.callbackSelect = callbackSelect;
+
+ UiDialog.open(this);
+ }
+
+ private search(event: Event): void {
+ event.preventDefault();
+
+ const inputContainer = this.searchInput!.parentNode as HTMLElement;
+
+ const value = this.searchInput!.value.trim();
+ if (value.length < 3) {
+ DomUtil.innerError(inputContainer, Language.get("wcf.page.search.error.tooShort"));
+ return;
+ } else {
+ DomUtil.innerError(inputContainer, false);
+ }
+
+ Ajax.api(this, {
+ parameters: {
+ searchString: value,
+ },
+ });
+ }
+
+ private click(event: MouseEvent): void {
+ event.preventDefault();
+
+ const page = event.currentTarget as HTMLElement;
+ const pageTitle = page.querySelector("h3")!;
+
+ this.callbackSelect!(page.dataset.pageId! + "#" + pageTitle.textContent!.replace(/['"]/g, ""));
+
+ UiDialog.close(this);
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ const html = data.returnValues
+ .map((page) => {
+ const name = StringUtil.escapeHTML(page.name);
+ const displayLink = StringUtil.escapeHTML(page.displayLink);
+
+ return `<li>
+ <div class="containerHeadline pointer" data-page-id="${page.pageID}">
+ <h3>${name}</h3>
+ <small>${displayLink}</small>
+ </div>
+ </li>`;
+ })
+ .join("");
+
+ this.resultList!.innerHTML = html;
+
+ DomUtil[html ? "show" : "hide"](this.resultContainer!);
+
+ if (html) {
+ this.resultList!.querySelectorAll(".containerHeadline").forEach((item: HTMLElement) => {
+ item.addEventListener("click", (ev) => this.click(ev));
+ });
+ } else {
+ DomUtil.innerError(this.searchInput!.parentElement!, Language.get("wcf.page.search.error.noResults"));
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "search",
+ className: "wcf\\data\\page\\PageAction",
+ },
+ };
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "wcfUiPageSearch",
+ options: {
+ onSetup: () => {
+ this.searchInput = document.getElementById("wcfUiPageSearchInput") as HTMLInputElement;
+ this.searchInput.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ this.search(event);
+ }
+ });
+
+ this.searchInput.nextElementSibling!.addEventListener("click", (ev) => this.search(ev));
+
+ this.resultContainer = document.getElementById("wcfUiPageSearchResultContainer") as HTMLElement;
+ this.resultList = document.getElementById("wcfUiPageSearchResultList") as HTMLOListElement;
+ },
+ onShow: () => {
+ this.searchInput!.focus();
+ },
+ title: Language.get("wcf.page.search"),
+ },
+ source: `<div class="section">
+ <dl>
+ <dt><label for="wcfUiPageSearchInput">${Language.get("wcf.page.search.name")}</label></dt>
+ <dd>
+ <div class="inputAddon">
+ <input type="text" id="wcfUiPageSearchInput" class="long">
+ <a href="#" class="inputSuffix"><span class="icon icon16 fa-search"></span></a>
+ </div>
+ </dd>
+ </dl>
+ </div>
+ <section id="wcfUiPageSearchResultContainer" class="section" style="display: none;">
+ <header class="sectionHeader">
+ <h2 class="sectionTitle">${Language.get("wcf.page.search.results")}</h2>
+ </header>
+ <ol id="wcfUiPageSearchResultList" class="containerList"></ol>
+ </section>`,
+ };
+ }
+}
+
+let uiPageSearch: UiPageSearch | undefined = undefined;
+
+function getUiPageSearch(): UiPageSearch {
+ if (uiPageSearch === undefined) {
+ uiPageSearch = new UiPageSearch();
+ }
+
+ return uiPageSearch;
+}
+
+export function open(callbackSelect: CallbackSelect): void {
+ getUiPageSearch().open(callbackSelect);
+}
--- /dev/null
+/**
+ * Provides access to the lookup function of page handlers, allowing the user to search and
+ * select page object ids.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Page/Search/Handler
+ */
+
+import * as Language from "../../../Language";
+import * as StringUtil from "../../../StringUtil";
+import DomUtil from "../../../Dom/Util";
+import UiDialog from "../../Dialog";
+import { DialogCallbackObject, DialogCallbackSetup } from "../../Dialog/Data";
+import UiPageSearchInput from "./Input";
+import { DatabaseObjectActionResponse } from "../../../Ajax/Data";
+
+type CallbackSelect = (objectId: number) => void;
+
+interface ItemData {
+ description?: string;
+ image: string;
+ link: string;
+ objectID: number;
+ title: string;
+}
+
+interface AjaxResponse extends DatabaseObjectActionResponse {
+ returnValues: ItemData[];
+}
+
+class UiPageSearchHandler implements DialogCallbackObject {
+ private callbackSuccess?: CallbackSelect = undefined;
+ private resultList?: HTMLUListElement = undefined;
+ private resultListContainer?: HTMLElement = undefined;
+ private searchInput?: HTMLInputElement = undefined;
+ private searchInputHandler?: UiPageSearchInput = undefined;
+ private searchInputLabel?: HTMLLabelElement = undefined;
+
+ /**
+ * Opens the lookup overlay for provided page id.
+ */
+ open(pageId: number, title: string, callback: CallbackSelect, labelLanguageItem?: string): void {
+ this.callbackSuccess = callback;
+
+ UiDialog.open(this);
+ UiDialog.setTitle(this, title);
+
+ this.searchInputLabel!.textContent = Language.get(labelLanguageItem || "wcf.page.pageObjectID.search.terms");
+
+ this._getSearchInputHandler().setPageId(pageId);
+ }
+
+ /**
+ * Builds the result list.
+ */
+ private buildList(data: AjaxResponse): void {
+ this.resetList();
+
+ if (!Array.isArray(data.returnValues) || data.returnValues.length === 0) {
+ DomUtil.innerError(this.searchInput!, Language.get("wcf.page.pageObjectID.search.noResults"));
+ return;
+ }
+
+ data.returnValues.forEach((item) => {
+ let image = item.image;
+ if (/^fa-/.test(image)) {
+ image = `<span class="icon icon48 ${image} pointer jsTooltip" title="${Language.get(
+ "wcf.global.select",
+ )}"></span>`;
+ }
+
+ const listItem = document.createElement("li");
+ listItem.dataset.objectId = item.objectID.toString();
+
+ const description = item.description ? `<p>${item.description}</p>` : "";
+ listItem.innerHTML = `<div class="box48">
+ ${image}
+ <div>
+ <div class="containerHeadline">
+ <h3>
+ <a href="${StringUtil.escapeHTML(item.link)}">${StringUtil.escapeHTML(item.title)}</a>
+ </h3>
+ ${description}
+ </div>
+ </div>
+ </div>`;
+
+ listItem.addEventListener("click", this.click.bind(this));
+
+ this.resultList!.appendChild(listItem);
+ });
+
+ DomUtil.show(this.resultListContainer!);
+ }
+
+ /**
+ * Resets the list and removes any error elements.
+ */
+ private resetList(): void {
+ DomUtil.innerError(this.searchInput!, false);
+
+ this.resultList!.innerHTML = "";
+
+ DomUtil.hide(this.resultListContainer!);
+ }
+
+ /**
+ * Initializes the search input handler and returns the instance.
+ */
+ _getSearchInputHandler(): UiPageSearchInput {
+ if (!this.searchInputHandler) {
+ const input = document.getElementById("wcfUiPageSearchInput") as HTMLInputElement;
+ this.searchInputHandler = new UiPageSearchInput(input, {
+ callbackSuccess: this.buildList.bind(this),
+ });
+ }
+
+ return this.searchInputHandler;
+ }
+
+ /**
+ * Handles clicks on the item unless the click occurred directly on a link.
+ */
+ private click(event: MouseEvent): void {
+ const clickTarget = event.target as HTMLElement;
+ if (clickTarget.nodeName === "A") {
+ return;
+ }
+
+ event.stopPropagation();
+
+ const eventTarget = event.currentTarget as HTMLElement;
+ this.callbackSuccess!(+eventTarget.dataset.objectId!);
+
+ UiDialog.close(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "wcfUiPageSearchHandler",
+ options: {
+ onShow: (content: HTMLElement): void => {
+ if (!this.searchInput) {
+ this.searchInput = document.getElementById("wcfUiPageSearchInput") as HTMLInputElement;
+ this.searchInputLabel = content.querySelector('label[for="wcfUiPageSearchInput"]') as HTMLLabelElement;
+ this.resultList = document.getElementById("wcfUiPageSearchResultList") as HTMLUListElement;
+ this.resultListContainer = document.getElementById("wcfUiPageSearchResultListContainer") as HTMLElement;
+ }
+
+ // clear search input
+ this.searchInput.value = "";
+
+ // reset results
+ DomUtil.hide(this.resultListContainer!);
+ this.resultList!.innerHTML = "";
+
+ this.searchInput.focus();
+ },
+ title: "",
+ },
+ source: `<div class="section">
+ <dl>
+ <dt>
+ <label for="wcfUiPageSearchInput">${Language.get("wcf.page.pageObjectID.search.terms")}</label>
+ </dt>
+ <dd>
+ <input type="text" id="wcfUiPageSearchInput" class="long">
+ </dd>
+ </dl>
+ </div>
+ <section id="wcfUiPageSearchResultListContainer" class="section sectionContainerList">
+ <header class="sectionHeader">
+ <h2 class="sectionTitle">${Language.get("wcf.page.pageObjectID.search.results")}</h2>
+ </header>
+ <ul id="wcfUiPageSearchResultList" class="containerList wcfUiPageSearchResultList"></ul>
+ </section>`,
+ };
+ }
+}
+
+let uiPageSearchHandler: UiPageSearchHandler | undefined = undefined;
+
+function getUiPageSearchHandler(): UiPageSearchHandler {
+ if (!uiPageSearchHandler) {
+ uiPageSearchHandler = new UiPageSearchHandler();
+ }
+
+ return uiPageSearchHandler;
+}
+
+/**
+ * Opens the lookup overlay for provided page id.
+ */
+export function open(pageId: number, title: string, callback: CallbackSelect, labelLanguageItem?: string): void {
+ getUiPageSearchHandler().open(pageId, title, callback, labelLanguageItem);
+}
--- /dev/null
+/**
+ * Suggestions for page object ids with external response data processing.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Page/Search/Input
+ */
+
+import * as Core from "../../../Core";
+import UiSearchInput from "../../Search/Input";
+import { SearchInputOptions } from "../../Search/Data";
+import { DatabaseObjectActionPayload, DatabaseObjectActionResponse } from "../../../Ajax/Data";
+
+type CallbackSuccess = (data: DatabaseObjectActionResponse) => void;
+
+interface PageSearchOptions extends SearchInputOptions {
+ callbackSuccess: CallbackSuccess;
+}
+
+class UiPageSearchInput extends UiSearchInput {
+ private readonly callbackSuccess: CallbackSuccess;
+ private pageId: number;
+
+ constructor(element: HTMLInputElement, options: PageSearchOptions) {
+ if (typeof options.callbackSuccess !== "function") {
+ throw new Error("Expected a valid callback function for 'callbackSuccess'.");
+ }
+
+ options = Core.extend(
+ {
+ ajax: {
+ className: "wcf\\data\\page\\PageAction",
+ },
+ },
+ options,
+ ) as any;
+
+ super(element, options);
+
+ this.callbackSuccess = options.callbackSuccess;
+
+ this.pageId = 0;
+ }
+
+ /**
+ * Sets the target page id.
+ */
+ setPageId(pageId: number): void {
+ this.pageId = pageId;
+ }
+
+ protected getParameters(value: string): Partial<DatabaseObjectActionPayload> {
+ const data = super.getParameters(value);
+
+ data.objectIDs = [this.pageId];
+
+ return data;
+ }
+
+ _ajaxSuccess(data: DatabaseObjectActionResponse): void {
+ this.callbackSuccess(data);
+ }
+}
+
+Core.enableLegacyInheritance(UiPageSearchInput);
+
+export = UiPageSearchInput;
--- /dev/null
+/**
+ * Callback-based pagination.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Pagination
+ */
+
+import * as Core from "../Core";
+import * as Language from "../Language";
+import * as StringUtil from "../StringUtil";
+import * as UiPageJumpTo from "./Page/JumpTo";
+
+class UiPagination {
+ /**
+ * maximum number of displayed page links, should match the PHP implementation
+ */
+ static readonly showLinks = 11;
+
+ private activePage: number;
+ private readonly maxPage: number;
+
+ private readonly element: HTMLElement;
+
+ private readonly callbackSwitch: CallbackSwitch | null = null;
+ private readonly callbackShouldSwitch: CallbackShouldSwitch | null = null;
+
+ /**
+ * Initializes the pagination.
+ *
+ * @param {Element} element container element
+ * @param {object} options list of initialization options
+ */
+ constructor(element: HTMLElement, options: PaginationOptions) {
+ this.element = element;
+ this.activePage = options.activePage;
+ this.maxPage = options.maxPage;
+ if (typeof options.callbackSwitch === "function") {
+ this.callbackSwitch = options.callbackSwitch;
+ }
+ if (typeof options.callbackShouldSwitch === "function") {
+ this.callbackShouldSwitch = options.callbackShouldSwitch;
+ }
+
+ this.element.classList.add("pagination");
+ this.rebuild();
+ }
+
+ /**
+ * Rebuilds the entire pagination UI.
+ */
+ private rebuild() {
+ let hasHiddenPages = false;
+
+ // clear content
+ this.element.innerHTML = "";
+
+ const list = document.createElement("ul");
+ let listItem = document.createElement("li");
+ listItem.className = "skip";
+ list.appendChild(listItem);
+
+ let iconClassNames = "icon icon24 fa-chevron-left";
+ if (this.activePage > 1) {
+ const link = document.createElement("a");
+ link.className = iconClassNames + " jsTooltip";
+ link.href = "#";
+ link.title = Language.get("wcf.global.page.previous");
+ link.rel = "prev";
+ listItem.appendChild(link);
+ link.addEventListener("click", (ev) => this.switchPage(this.activePage - 1, ev));
+ } else {
+ listItem.innerHTML = '<span class="' + iconClassNames + '"></span>';
+ listItem.classList.add("disabled");
+ }
+
+ // add first page
+ list.appendChild(this.createLink(1));
+
+ // calculate page links
+ let maxLinks = UiPagination.showLinks - 4;
+ let linksBefore = this.activePage - 2;
+ if (linksBefore < 0) {
+ linksBefore = 0;
+ }
+
+ let linksAfter = this.maxPage - (this.activePage + 1);
+ if (linksAfter < 0) {
+ linksAfter = 0;
+ }
+ if (this.activePage > 1 && this.activePage < this.maxPage) {
+ maxLinks--;
+ }
+
+ const half = maxLinks / 2;
+ let left = this.activePage;
+ let right = this.activePage;
+ if (left < 1) {
+ left = 1;
+ }
+ if (right < 1) {
+ right = 1;
+ }
+ if (right > this.maxPage - 1) {
+ right = this.maxPage - 1;
+ }
+
+ if (linksBefore >= half) {
+ left -= half;
+ } else {
+ left -= linksBefore;
+ right += half - linksBefore;
+ }
+
+ if (linksAfter >= half) {
+ right += half;
+ } else {
+ right += linksAfter;
+ left -= half - linksAfter;
+ }
+
+ right = Math.ceil(right);
+ left = Math.ceil(left);
+ if (left < 1) {
+ left = 1;
+ }
+ if (right > this.maxPage) {
+ right = this.maxPage;
+ }
+
+ // left ... links
+ const jumpToHtml = '<a class="jsTooltip" title="' + Language.get("wcf.page.jumpTo") + '">…</a>';
+ if (left > 1) {
+ if (left - 1 < 2) {
+ list.appendChild(this.createLink(2));
+ } else {
+ listItem = document.createElement("li");
+ listItem.className = "jumpTo";
+ listItem.innerHTML = jumpToHtml;
+ list.appendChild(listItem);
+ hasHiddenPages = true;
+ }
+ }
+
+ // visible links
+ for (let i = left + 1; i < right; i++) {
+ list.appendChild(this.createLink(i));
+ }
+
+ // right ... links
+ if (right < this.maxPage) {
+ if (this.maxPage - right < 2) {
+ list.appendChild(this.createLink(this.maxPage - 1));
+ } else {
+ listItem = document.createElement("li");
+ listItem.className = "jumpTo";
+ listItem.innerHTML = jumpToHtml;
+ list.appendChild(listItem);
+ hasHiddenPages = true;
+ }
+ }
+
+ // add last page
+ list.appendChild(this.createLink(this.maxPage));
+
+ // add next button
+ listItem = document.createElement("li");
+ listItem.className = "skip";
+ list.appendChild(listItem);
+ iconClassNames = "icon icon24 fa-chevron-right";
+ if (this.activePage < this.maxPage) {
+ const link = document.createElement("a");
+ link.className = iconClassNames + " jsTooltip";
+ link.href = "#";
+ link.title = Language.get("wcf.global.page.next");
+ link.rel = "next";
+ listItem.appendChild(link);
+ link.addEventListener("click", (ev) => this.switchPage(this.activePage + 1, ev));
+ } else {
+ listItem.innerHTML = '<span class="' + iconClassNames + '"></span>';
+ listItem.classList.add("disabled");
+ }
+
+ if (hasHiddenPages) {
+ list.dataset.pages = this.maxPage.toString();
+ UiPageJumpTo.init(list, this.switchPage.bind(this));
+ }
+
+ this.element.appendChild(list);
+ }
+
+ /**
+ * Creates a link to a specific page.
+ */
+ private createLink(pageNo: number): HTMLElement {
+ const listItem = document.createElement("li");
+ if (pageNo !== this.activePage) {
+ const link = document.createElement("a");
+ link.textContent = StringUtil.addThousandsSeparator(pageNo);
+ link.addEventListener("click", (ev) => this.switchPage(pageNo, ev));
+ listItem.appendChild(link);
+ } else {
+ listItem.classList.add("active");
+ listItem.innerHTML =
+ "<span>" +
+ StringUtil.addThousandsSeparator(pageNo) +
+ '</span><span class="invisible">' +
+ Language.get("wcf.page.pagePosition", {
+ pageNo: pageNo,
+ pages: this.maxPage,
+ }) +
+ "</span>";
+ }
+ return listItem;
+ }
+
+ /**
+ * Returns the active page.
+ */
+ getActivePage(): number {
+ return this.activePage;
+ }
+
+ /**
+ * Returns the pagination Ui element.
+ */
+ getElement(): HTMLElement {
+ return this.element;
+ }
+
+ /**
+ * Returns the maximum page.
+ */
+ getMaxPage(): number {
+ return this.maxPage;
+ }
+
+ /**
+ * Switches to given page number.
+ */
+ switchPage(pageNo: number, event?: MouseEvent): void {
+ if (event instanceof MouseEvent) {
+ event.preventDefault();
+
+ const target = event.currentTarget as HTMLElement;
+ // force tooltip to vanish and strip positioning
+ if (target && target.dataset.tooltip) {
+ const tooltip = document.getElementById("balloonTooltip");
+ if (tooltip) {
+ Core.triggerEvent(target, "mouseleave");
+ tooltip.style.removeProperty("top");
+ tooltip.style.removeProperty("bottom");
+ }
+ }
+ }
+
+ pageNo = ~~pageNo;
+ if (pageNo > 0 && this.activePage !== pageNo && pageNo <= this.maxPage) {
+ if (this.callbackShouldSwitch !== null) {
+ if (!this.callbackShouldSwitch(pageNo)) {
+ return;
+ }
+ }
+
+ this.activePage = pageNo;
+ this.rebuild();
+
+ if (this.callbackSwitch !== null) {
+ this.callbackSwitch(pageNo);
+ }
+ }
+ }
+}
+
+Core.enableLegacyInheritance(UiPagination);
+
+export = UiPagination;
+
+type CallbackSwitch = (pageNo: number) => void;
+type CallbackShouldSwitch = (pageNo: number) => boolean;
+
+interface PaginationOptions {
+ activePage: number;
+ maxPage: number;
+ callbackShouldSwitch?: CallbackShouldSwitch | null;
+ callbackSwitch?: CallbackSwitch | null;
+}
--- /dev/null
+/**
+ * Handles the data to create and edit a poll in a form created via form builder.
+ *
+ * @author Alexander Ebert, Matthias Schmidt
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Poll/Editor
+ */
+
+import * as Core from "../../Core";
+import * as Language from "../../Language";
+import UiSortableList from "../Sortable/List";
+import * as EventHandler from "../../Event/Handler";
+import * as DatePicker from "../../Date/Picker";
+import { DatabaseObjectActionResponse } from "../../Ajax/Data";
+
+interface UiPollEditorOptions {
+ isAjax: boolean;
+ maxOptions: number;
+}
+
+interface PollOption {
+ optionID: string;
+ optionValue: string;
+}
+
+interface AjaxReturnValue {
+ errorType: string;
+ fieldName: string;
+}
+
+interface AjaxResponse extends DatabaseObjectActionResponse {
+ returnValues: AjaxReturnValue;
+}
+
+interface ValidationApi {
+ throwError: (container: HTMLElement, message: string) => void;
+}
+
+interface ValidationData {
+ api: ValidationApi;
+ valid: boolean;
+}
+
+class UiPollEditor {
+ private readonly container: HTMLElement;
+ private readonly endTimeField: HTMLInputElement;
+ private readonly isChangeableNoField: HTMLInputElement;
+ private readonly isChangeableYesField: HTMLInputElement;
+ private readonly isPublicNoField: HTMLInputElement;
+ private readonly isPublicYesField: HTMLInputElement;
+ private readonly maxVotesField: HTMLInputElement;
+ private optionCount: number;
+ private readonly options: UiPollEditorOptions;
+ private readonly optionList: HTMLOListElement;
+ private readonly questionField: HTMLInputElement;
+ private readonly resultsRequireVoteNoField: HTMLInputElement;
+ private readonly resultsRequireVoteYesField: HTMLInputElement;
+ private readonly sortByVotesNoField: HTMLInputElement;
+ private readonly sortByVotesYesField: HTMLInputElement;
+ private readonly wysiwygId: string;
+
+ constructor(containerId: string, pollOptions: PollOption[], wysiwygId: string, options: UiPollEditorOptions) {
+ const container = document.getElementById(containerId);
+ if (container === null) {
+ throw new Error("Unknown poll editor container with id '" + containerId + "'.");
+ }
+ this.container = container;
+
+ this.wysiwygId = wysiwygId;
+ if (wysiwygId !== "" && document.getElementById(wysiwygId) === null) {
+ throw new Error("Unknown wysiwyg field with id '" + wysiwygId + "'.");
+ }
+
+ this.questionField = document.getElementById(this.wysiwygId + "Poll_question") as HTMLInputElement;
+
+ const optionList = this.container.querySelector(".sortableList");
+ if (optionList === null) {
+ throw new Error("Cannot find poll options list for container with id '" + containerId + "'.");
+ }
+ this.optionList = optionList as HTMLOListElement;
+
+ this.endTimeField = document.getElementById(this.wysiwygId + "Poll_endTime") as HTMLInputElement;
+ this.maxVotesField = document.getElementById(this.wysiwygId + "Poll_maxVotes") as HTMLInputElement;
+ this.isChangeableYesField = document.getElementById(this.wysiwygId + "Poll_isChangeable") as HTMLInputElement;
+ this.isChangeableNoField = document.getElementById(this.wysiwygId + "Poll_isChangeable_no") as HTMLInputElement;
+ this.isPublicYesField = document.getElementById(this.wysiwygId + "Poll_isPublic") as HTMLInputElement;
+ this.isPublicNoField = document.getElementById(this.wysiwygId + "Poll_isPublic_no") as HTMLInputElement;
+ this.resultsRequireVoteYesField = document.getElementById(
+ this.wysiwygId + "Poll_resultsRequireVote",
+ ) as HTMLInputElement;
+ this.resultsRequireVoteNoField = document.getElementById(
+ this.wysiwygId + "Poll_resultsRequireVote_no",
+ ) as HTMLInputElement;
+ this.sortByVotesYesField = document.getElementById(this.wysiwygId + "Poll_sortByVotes") as HTMLInputElement;
+ this.sortByVotesNoField = document.getElementById(this.wysiwygId + "Poll_sortByVotes_no") as HTMLInputElement;
+
+ this.optionCount = 0;
+
+ this.options = Core.extend(
+ {
+ isAjax: false,
+ maxOptions: 20,
+ },
+ options,
+ ) as UiPollEditorOptions;
+
+ this.createOptionList(pollOptions || []);
+
+ new UiSortableList({
+ containerId: containerId,
+ options: {
+ toleranceElement: "> div",
+ },
+ });
+
+ if (this.options.isAjax) {
+ ["handleError", "reset", "submit", "validate"].forEach((event) => {
+ EventHandler.add("com.woltlab.wcf.redactor2", event + "_" + this.wysiwygId, (...args: unknown[]) =>
+ this[event](...args),
+ );
+ });
+ } else {
+ const form = this.container.closest("form");
+ if (form === null) {
+ throw new Error("Cannot find form for container with id '" + containerId + "'.");
+ }
+
+ form.addEventListener("submit", (ev) => this.submit(ev));
+ }
+ }
+
+ /**
+ * Creates a poll option with the given data or an empty poll option of no data is given.
+ */
+ private createOption(optionValue?: string, optionId?: string, insertAfter?: HTMLElement): void {
+ optionValue = optionValue || "";
+ optionId = optionId || "0";
+
+ const listItem = document.createElement("LI") as HTMLLIElement;
+ listItem.classList.add("sortableNode");
+ listItem.dataset.optionId = optionId;
+
+ if (insertAfter) {
+ insertAfter.insertAdjacentElement("afterend", listItem);
+ } else {
+ this.optionList.appendChild(listItem);
+ }
+
+ const pollOptionInput = document.createElement("div");
+ pollOptionInput.classList.add("pollOptionInput");
+ listItem.appendChild(pollOptionInput);
+
+ const sortHandle = document.createElement("span");
+ sortHandle.classList.add("icon", "icon16", "fa-arrows", "sortableNodeHandle");
+ pollOptionInput.appendChild(sortHandle);
+
+ // buttons
+ const addButton = document.createElement("a");
+ listItem.setAttribute("role", "button");
+ listItem.setAttribute("href", "#");
+ addButton.classList.add("icon", "icon16", "fa-plus", "jsTooltip", "jsAddOption", "pointer");
+ addButton.setAttribute("title", Language.get("wcf.poll.button.addOption"));
+ addButton.addEventListener("click", () => this.createOption());
+ pollOptionInput.appendChild(addButton);
+
+ const deleteButton = document.createElement("a");
+ deleteButton.setAttribute("role", "button");
+ deleteButton.setAttribute("href", "#");
+ deleteButton.classList.add("icon", "icon16", "fa-times", "jsTooltip", "jsDeleteOption", "pointer");
+ deleteButton.setAttribute("title", Language.get("wcf.poll.button.removeOption"));
+ deleteButton.addEventListener("click", (ev) => this.removeOption(ev));
+ pollOptionInput.appendChild(deleteButton);
+
+ // input field
+ const optionInput = document.createElement("input");
+ optionInput.type = "text";
+ optionInput.value = optionValue;
+ optionInput.maxLength = 255;
+ optionInput.addEventListener("keydown", (ev) => this.optionInputKeyDown(ev));
+ optionInput.addEventListener("click", () => {
+ // work-around for some weird focus issue on iOS/Android
+ if (document.activeElement !== optionInput) {
+ optionInput.focus();
+ }
+ });
+ pollOptionInput.appendChild(optionInput);
+
+ if (insertAfter !== null) {
+ optionInput.focus();
+ }
+
+ this.optionCount++;
+ if (this.optionCount === this.options.maxOptions) {
+ this.optionList.querySelectorAll(".jsAddOption").forEach((icon: HTMLSpanElement) => {
+ icon.classList.remove("pointer");
+ icon.classList.add("disabled");
+ });
+ }
+ }
+
+ /**
+ * Populates the option list with the current options.
+ */
+ private createOptionList(pollOptions: PollOption[]): void {
+ pollOptions.forEach((option) => {
+ this.createOption(option.optionValue, option.optionID);
+ });
+
+ if (this.optionCount < this.options.maxOptions) {
+ this.createOption();
+ }
+ }
+
+ /**
+ * Handles validation errors returned by Ajax request.
+ */
+ private handleError(data: AjaxResponse): void {
+ switch (data.returnValues.fieldName) {
+ case this.wysiwygId + "Poll_endTime":
+ case this.wysiwygId + "Poll_maxVotes": {
+ const fieldName = data.returnValues.fieldName.replace(this.wysiwygId + "Poll_", "");
+
+ const small = document.createElement("small");
+ small.classList.add("innerError");
+ small.innerHTML = Language.get("wcf.poll." + fieldName + ".error." + data.returnValues.errorType);
+
+ const field = document.getElementById(data.returnValues.fieldName)!;
+ (field.nextSibling! as HTMLElement).insertAdjacentElement("afterbegin", small);
+
+ data.cancel = true;
+ break;
+ }
+ }
+ }
+
+ /**
+ * Adds another option field below the current option field after pressing Enter.
+ */
+ private optionInputKeyDown(event: KeyboardEvent): void {
+ if (event.key !== "Enter") {
+ return;
+ }
+
+ const target = event.currentTarget as HTMLInputElement;
+ const addOption = target.parentElement!.querySelector(".jsAddOption") as HTMLSpanElement;
+ Core.triggerEvent(addOption, "click");
+
+ event.preventDefault();
+ }
+
+ /**
+ * Removes a poll option after clicking on its deletion button.
+ */
+ private removeOption(event: Event): void {
+ event.preventDefault();
+
+ const button = event.currentTarget as HTMLSpanElement;
+ button.closest("li")!.remove();
+
+ this.optionCount--;
+
+ if (this.optionList.childElementCount === 0) {
+ this.createOption();
+ } else {
+ this.optionList.querySelectorAll(".jsAddOption").forEach((icon) => {
+ icon.classList.add("pointer");
+ icon.classList.remove("disabled");
+ });
+ }
+ }
+
+ /**
+ * Resets all poll fields.
+ */
+ private reset(): void {
+ this.questionField.value = "";
+
+ this.optionCount = 0;
+ this.optionList.innerHTML = "";
+ this.createOption();
+
+ DatePicker.clear(this.endTimeField);
+
+ this.maxVotesField.value = "1";
+ this.isChangeableYesField.checked = false;
+ this.isChangeableNoField.checked = true;
+ this.isPublicYesField.checked = false;
+ this.isPublicNoField.checked = true;
+ this.resultsRequireVoteYesField.checked = false;
+ this.resultsRequireVoteNoField.checked = true;
+ this.sortByVotesYesField.checked = false;
+ this.sortByVotesNoField.checked = true;
+
+ EventHandler.fire("com.woltlab.wcf.poll.editor", "reset", {
+ pollEditor: this,
+ });
+ }
+
+ /**
+ * Handles the poll data if the form is submitted.
+ */
+ private submit(event: Event): void {
+ if (this.options.isAjax) {
+ EventHandler.fire("com.woltlab.wcf.poll.editor", "submit", {
+ event: event,
+ pollEditor: this,
+ });
+ } else {
+ const form = this.container.closest("form")!;
+
+ this.getOptions().forEach((option, i) => {
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = `${this.wysiwygId} + 'Poll_options[${i}}]`;
+ input.value = option;
+ form.appendChild(input);
+ });
+ }
+ }
+
+ /**
+ * Validates the poll data.
+ */
+ private validate(data: ValidationData): void {
+ if (this.questionField.value.trim() === "") {
+ return;
+ }
+
+ let nonEmptyOptionCount = 0;
+ Array.from(this.optionList.children).forEach((listItem: HTMLLIElement) => {
+ const optionInput = listItem.querySelector("input[type=text]") as HTMLInputElement;
+ if (optionInput.value.trim() !== "") {
+ nonEmptyOptionCount++;
+ }
+ });
+
+ if (nonEmptyOptionCount === 0) {
+ data.api.throwError(this.container, Language.get("wcf.global.form.error.empty"));
+ data.valid = false;
+ } else {
+ const maxVotes = ~~this.maxVotesField.value;
+
+ if (maxVotes && maxVotes > nonEmptyOptionCount) {
+ data.api.throwError(this.maxVotesField.parentElement!, Language.get("wcf.poll.maxVotes.error.invalid"));
+ data.valid = false;
+ } else {
+ EventHandler.fire("com.woltlab.wcf.poll.editor", "validate", {
+ data: data,
+ pollEditor: this,
+ });
+ }
+ }
+ }
+
+ /**
+ * Returns the data of the poll.
+ */
+ public getData(): object {
+ return {
+ [this.questionField.id]: this.questionField.value,
+ [this.wysiwygId + "Poll_options"]: this.getOptions(),
+ [this.endTimeField.id]: this.endTimeField.value,
+ [this.maxVotesField.id]: this.maxVotesField.value,
+ [this.isChangeableYesField.id]: !!this.isChangeableYesField.checked,
+ [this.isPublicYesField.id]: !!this.isPublicYesField.checked,
+ [this.resultsRequireVoteYesField.id]: !!this.resultsRequireVoteYesField.checked,
+ [this.sortByVotesYesField.id]: !!this.sortByVotesYesField.checked,
+ };
+ }
+
+ /**
+ * Returns the selectable options in the poll.
+ *
+ * Format: `{optionID}_{option}` with `optionID = 0` if it is a new option.
+ */
+ public getOptions(): string[] {
+ const options: string[] = [];
+ Array.from(this.optionList.children).forEach((listItem: HTMLLIElement) => {
+ const optionValue = (listItem.querySelector("input[type=text]")! as HTMLInputElement).value.trim();
+
+ if (optionValue !== "") {
+ options.push(`${listItem.dataset.optionId!}_${optionValue}`);
+ }
+ });
+
+ return options;
+ }
+}
+
+Core.enableLegacyInheritance(UiPollEditor);
+
+export = UiPollEditor;
--- /dev/null
+/**
+ * Provides interface elements to use reactions.
+ *
+ * @author Joshua Ruesweg
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Reaction/Handler
+ * @since 5.2
+ */
+
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
+import * as Core from "../../Core";
+import { DialogCallbackSetup } from "../Dialog/Data";
+import DomChangeListener from "../../Dom/Change/Listener";
+import DomUtil from "../../Dom/Util";
+import * as EventHandler from "../../Event/Handler";
+import { Reaction, ReactionStats } from "./Data";
+import * as StringUtil from "../../StringUtil";
+import UiDialog from "../Dialog";
+
+interface CountButtonsOptions {
+ // selectors
+ summaryListSelector: string;
+ containerSelector: string;
+ isSingleItem: boolean;
+
+ // optional parameters
+ parameters: {
+ data: {
+ [key: string]: unknown;
+ };
+ };
+}
+
+interface ElementData {
+ element: HTMLElement;
+ objectId: number;
+ reactButton: null;
+ summary: null;
+}
+
+interface AjaxResponse extends ResponseData {
+ returnValues: {
+ template: string;
+ title: string;
+ };
+}
+
+const availableReactions = new Map<string, Reaction>(Object.entries(window.REACTION_TYPES));
+
+class CountButtons {
+ protected readonly _containers = new Map<string, ElementData>();
+ protected _currentObjectId = 0;
+ protected readonly _objects = new Map<number, ElementData[]>();
+ protected readonly _objectType: string;
+ protected readonly _options: CountButtonsOptions;
+
+ /**
+ * Initializes the like handler.
+ */
+ constructor(objectType: string, opts: Partial<CountButtonsOptions>) {
+ if (!opts.containerSelector) {
+ throw new Error(
+ "[WoltLabSuite/Core/Ui/Reaction/CountButtons] Expected a non-empty string for option 'containerSelector'.",
+ );
+ }
+
+ this._objectType = objectType;
+
+ this._options = Core.extend(
+ {
+ // selectors
+ summaryListSelector: ".reactionSummaryList",
+ containerSelector: "",
+ isSingleItem: false,
+
+ // optional parameters
+ parameters: {
+ data: {},
+ },
+ },
+ opts,
+ ) as CountButtonsOptions;
+
+ this.initContainers();
+
+ DomChangeListener.add(`WoltLabSuite/Core/Ui/Reaction/CountButtons-${objectType}`, () => this.initContainers());
+ }
+
+ /**
+ * Initialises the containers.
+ */
+ initContainers(): void {
+ let triggerChange = false;
+ document.querySelectorAll(this._options.containerSelector).forEach((element: HTMLElement) => {
+ const elementId = DomUtil.identify(element);
+ if (this._containers.has(elementId)) {
+ return;
+ }
+
+ const objectId = ~~element.dataset.objectId!;
+ const elementData: ElementData = {
+ reactButton: null,
+ summary: null,
+
+ objectId: objectId,
+ element: element,
+ };
+
+ this._containers.set(elementId, elementData);
+ this._initReactionCountButtons(element, elementData);
+
+ const objects = this._objects.get(objectId) || [];
+
+ objects.push(elementData);
+
+ this._objects.set(objectId, objects);
+
+ triggerChange = true;
+ });
+
+ if (triggerChange) {
+ DomChangeListener.trigger();
+ }
+ }
+
+ /**
+ * Update the count buttons with the given data.
+ */
+ updateCountButtons(objectId: number, data: ReactionStats): void {
+ let triggerChange = false;
+ this._objects.get(objectId)!.forEach((elementData) => {
+ let summaryList: HTMLElement | null;
+ if (this._options.isSingleItem) {
+ summaryList = document.querySelector(this._options.summaryListSelector);
+ } else {
+ summaryList = elementData.element.querySelector(this._options.summaryListSelector);
+ }
+
+ // summary list for the object not found; abort
+ if (summaryList === null) {
+ return;
+ }
+
+ const existingReactions = new Map<string, number>(Object.entries(data));
+
+ const sortedElements = new Map<string, HTMLElement>();
+ summaryList.querySelectorAll(".reactCountButton").forEach((reaction: HTMLElement) => {
+ const reactionTypeId = reaction.dataset.reactionTypeId!;
+ if (existingReactions.has(reactionTypeId)) {
+ sortedElements.set(reactionTypeId, reaction);
+ } else {
+ // The reaction no longer has any reactions.
+ reaction.remove();
+ }
+ });
+
+ existingReactions.forEach((count, reactionTypeId) => {
+ if (sortedElements.has(reactionTypeId)) {
+ const reaction = sortedElements.get(reactionTypeId)!;
+ const reactionCount = reaction.querySelector(".reactionCount") as HTMLElement;
+ reactionCount.innerHTML = StringUtil.shortUnit(count);
+ } else if (availableReactions.has(reactionTypeId)) {
+ const createdElement = document.createElement("span");
+ createdElement.className = "reactCountButton";
+ createdElement.innerHTML = availableReactions.get(reactionTypeId)!.renderedIcon;
+ createdElement.dataset.reactionTypeId = reactionTypeId;
+
+ const countSpan = document.createElement("span");
+ countSpan.className = "reactionCount";
+ countSpan.innerHTML = StringUtil.shortUnit(count);
+ createdElement.appendChild(countSpan);
+
+ summaryList!.appendChild(createdElement);
+
+ triggerChange = true;
+ }
+ });
+
+ if (summaryList.childElementCount > 0) {
+ DomUtil.show(summaryList);
+ } else {
+ DomUtil.hide(summaryList);
+ }
+ });
+
+ if (triggerChange) {
+ DomChangeListener.trigger();
+ }
+ }
+
+ /**
+ * Initialized the reaction count buttons.
+ */
+ protected _initReactionCountButtons(element: HTMLElement, elementData: ElementData): void {
+ let summaryList: HTMLElement | null;
+ if (this._options.isSingleItem) {
+ summaryList = document.querySelector(this._options.summaryListSelector);
+ } else {
+ summaryList = element.querySelector(this._options.summaryListSelector);
+ }
+
+ if (summaryList !== null) {
+ summaryList.addEventListener("click", (ev) => this._showReactionOverlay(elementData.objectId, ev));
+ }
+ }
+
+ /**
+ * Shows the reaction overly for a specific object.
+ */
+ protected _showReactionOverlay(objectId: number, event: MouseEvent): void {
+ event.preventDefault();
+
+ this._currentObjectId = objectId;
+ this._showOverlay();
+ }
+
+ /**
+ * Shows a specific page of the current opened reaction overlay.
+ */
+ protected _showOverlay(): void {
+ this._options.parameters.data.containerID = `${this._objectType}-${this._currentObjectId}`;
+ this._options.parameters.data.objectID = this._currentObjectId;
+ this._options.parameters.data.objectType = this._objectType;
+
+ Ajax.api(this, {
+ parameters: this._options.parameters,
+ });
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ EventHandler.fire("com.woltlab.wcf.ReactionCountButtons", "openDialog", data);
+
+ UiDialog.open(this, data.returnValues.template);
+ UiDialog.setTitle("userReactionOverlay-" + this._objectType, data.returnValues.title);
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "getReactionDetails",
+ className: "\\wcf\\data\\reaction\\ReactionAction",
+ },
+ };
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: `userReactionOverlay-${this._objectType}`,
+ options: {
+ title: "",
+ },
+ source: null,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(CountButtons);
+
+export = CountButtons;
--- /dev/null
+export interface Reaction {
+ title: string;
+ renderedIcon: string;
+ iconPath: string;
+ showOrder: number;
+ reactionTypeID: number;
+ isAssignable: 1 | 0;
+}
+
+export interface ReactionStats {
+ [key: string]: number;
+}
--- /dev/null
+/**
+ * Provides interface elements to use reactions.
+ *
+ * @author Joshua Ruesweg
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Reaction/Handler
+ * @since 5.2
+ */
+
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackSetup } from "../../Ajax/Data";
+import * as Core from "../../Core";
+import DomChangeListener from "../../Dom/Change/Listener";
+import DomUtil from "../../Dom/Util";
+import * as UiAlignment from "../Alignment";
+import UiCloseOverlay from "../CloseOverlay";
+import * as UiScreen from "../Screen";
+import CountButtons from "./CountButtons";
+import { Reaction, ReactionStats } from "./Data";
+
+interface ReactionHandlerOptions {
+ // selectors
+ buttonSelector: string;
+ containerSelector: string;
+ isButtonGroupNavigation: boolean;
+ isSingleItem: boolean;
+
+ // other stuff
+ parameters: {
+ data: {
+ [key: string]: unknown;
+ };
+ reactionTypeID?: number;
+ };
+}
+
+interface ElementData {
+ reactButton: HTMLElement | null;
+ objectId: number;
+ element: HTMLElement;
+}
+
+interface AjaxResponse {
+ returnValues: {
+ objectID: number;
+ objectType: string;
+ reactions: ReactionStats;
+ reactionTypeID: number;
+ reputationCount: number;
+ };
+}
+
+const availableReactions = Object.values(window.REACTION_TYPES);
+
+class UiReactionHandler {
+ readonly countButtons: CountButtons;
+ protected readonly _cache = new Map<string, unknown>();
+ protected readonly _containers = new Map<string, ElementData>();
+ protected readonly _options: ReactionHandlerOptions;
+ protected readonly _objects = new Map<number, ElementData[]>();
+ protected readonly _objectType: string;
+ protected _popoverCurrentObjectId = 0;
+ protected _popover: HTMLElement | null;
+ protected _popoverContent: HTMLElement | null;
+
+ /**
+ * Initializes the reaction handler.
+ */
+ constructor(objectType: string, opts: Partial<ReactionHandlerOptions>) {
+ if (!opts.containerSelector) {
+ throw new Error(
+ "[WoltLabSuite/Core/Ui/Reaction/Handler] Expected a non-empty string for option 'containerSelector'.",
+ );
+ }
+
+ this._objectType = objectType;
+
+ this._popover = null;
+ this._popoverContent = null;
+
+ this._options = Core.extend(
+ {
+ // selectors
+ buttonSelector: ".reactButton",
+ containerSelector: "",
+ isButtonGroupNavigation: false,
+ isSingleItem: false,
+
+ // other stuff
+ parameters: {
+ data: {},
+ },
+ },
+ opts,
+ ) as ReactionHandlerOptions;
+
+ this.initReactButtons();
+
+ this.countButtons = new CountButtons(this._objectType, this._options);
+
+ DomChangeListener.add(`WoltLabSuite/Core/Ui/Reaction/Handler-${objectType}`, () => this.initReactButtons());
+ UiCloseOverlay.add("WoltLabSuite/Core/Ui/Reaction/Handler", () => this._closePopover());
+ }
+
+ /**
+ * Initializes all applicable react buttons with the given selector.
+ */
+ initReactButtons(): void {
+ let triggerChange = false;
+
+ document.querySelectorAll(this._options.containerSelector).forEach((element: HTMLElement) => {
+ const elementId = DomUtil.identify(element);
+ if (this._containers.has(elementId)) {
+ return;
+ }
+
+ const objectId = ~~element.dataset.objectId!;
+ const elementData: ElementData = {
+ reactButton: null,
+ objectId: objectId,
+ element: element,
+ };
+
+ this._containers.set(elementId, elementData);
+ this._initReactButton(element, elementData);
+
+ const objects = this._objects.get(objectId) || [];
+
+ objects.push(elementData);
+
+ this._objects.set(objectId, objects);
+
+ triggerChange = true;
+ });
+
+ if (triggerChange) {
+ DomChangeListener.trigger();
+ }
+ }
+
+ /**
+ * Initializes a specific react button.
+ */
+ _initReactButton(element: HTMLElement, elementData: ElementData): void {
+ if (this._options.isSingleItem) {
+ elementData.reactButton = document.querySelector(this._options.buttonSelector) as HTMLElement;
+ } else {
+ elementData.reactButton = element.querySelector(this._options.buttonSelector) as HTMLElement;
+ }
+
+ if (elementData.reactButton === null) {
+ // The element may have no react button.
+ return;
+ }
+
+ if (availableReactions.length === 1) {
+ const reaction = availableReactions[0];
+ elementData.reactButton.title = reaction.title;
+ const textSpan = elementData.reactButton.querySelector(".invisible")!;
+ textSpan.textContent = reaction.title;
+ }
+
+ elementData.reactButton.addEventListener("click", (ev) => {
+ this._toggleReactPopover(elementData.objectId, elementData.reactButton!, ev);
+ });
+ }
+
+ protected _updateReactButton(objectID: number, reactionTypeID: number): void {
+ this._objects.get(objectID)!.forEach((elementData) => {
+ if (elementData.reactButton !== null) {
+ if (reactionTypeID) {
+ elementData.reactButton.classList.add("active");
+ elementData.reactButton.dataset.reactionTypeId = reactionTypeID.toString();
+ } else {
+ elementData.reactButton.dataset.reactionTypeId = "0";
+ elementData.reactButton.classList.remove("active");
+ }
+ }
+ });
+ }
+
+ protected _markReactionAsActive(): void {
+ let reactionTypeID = 0;
+ this._objects.get(this._popoverCurrentObjectId)!.forEach((element) => {
+ if (element.reactButton !== null) {
+ reactionTypeID = ~~element.reactButton.dataset.reactionTypeId!;
+ }
+ });
+
+ if (!reactionTypeID) {
+ throw new Error("Unable to find react button for current popover.");
+ }
+
+ // Clear the old active state.
+ const popover = this._getPopover();
+ popover.querySelectorAll(".reactionTypeButton.active").forEach((el) => el.classList.remove("active"));
+
+ const scrollableContainer = popover.querySelector(".reactionPopoverContent") as HTMLElement;
+ if (reactionTypeID) {
+ const reactionTypeButton = popover.querySelector(
+ `.reactionTypeButton[data-reaction-type-id="${reactionTypeID}"]`,
+ ) as HTMLElement;
+ reactionTypeButton.classList.add("active");
+
+ if (~~reactionTypeButton.dataset.isAssignable! === 0) {
+ DomUtil.show(reactionTypeButton);
+ }
+
+ this._scrollReactionIntoView(scrollableContainer, reactionTypeButton);
+ } else {
+ // The "first" reaction is positioned as close as possible to the toggle button,
+ // which means that we need to scroll the list to the bottom if the popover is
+ // displayed above the toggle button.
+ if (UiScreen.is("screen-xs")) {
+ if (popover.classList.contains("inverseOrder")) {
+ scrollableContainer.scrollTop = 0;
+ } else {
+ scrollableContainer.scrollTop = scrollableContainer.scrollHeight - scrollableContainer.clientHeight;
+ }
+ }
+ }
+ }
+
+ protected _scrollReactionIntoView(scrollableContainer: HTMLElement, reactionTypeButton: HTMLElement): void {
+ // Do not scroll if the button is located in the upper 75%.
+ if (reactionTypeButton.offsetTop < scrollableContainer.clientHeight * 0.75) {
+ scrollableContainer.scrollTop = 0;
+ } else {
+ // `Element.scrollTop` permits arbitrary values and will always clamp them to
+ // the maximum possible offset value. We can abuse this behavior by calculating
+ // the values to place the selected reaction in the center of the popover,
+ // regardless of the offset being out of range.
+ scrollableContainer.scrollTop =
+ reactionTypeButton.offsetTop + reactionTypeButton.clientHeight / 2 - scrollableContainer.clientHeight / 2;
+ }
+ }
+
+ /**
+ * Toggle the visibility of the react popover.
+ */
+ protected _toggleReactPopover(objectId: number, element: HTMLElement, event: MouseEvent): void {
+ if (event !== null) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ if (availableReactions.length === 1) {
+ const reaction = availableReactions[0];
+ this._popoverCurrentObjectId = objectId;
+
+ this._react(reaction.reactionTypeID);
+ } else {
+ if (this._popoverCurrentObjectId === 0 || this._popoverCurrentObjectId !== objectId) {
+ this._openReactPopover(objectId, element);
+ } else {
+ this._closePopover();
+ }
+ }
+ }
+
+ /**
+ * Opens the react popover for a specific react button.
+ */
+ protected _openReactPopover(objectId: number, element: HTMLElement): void {
+ if (this._popoverCurrentObjectId !== 0) {
+ this._closePopover();
+ }
+
+ this._popoverCurrentObjectId = objectId;
+
+ UiAlignment.set(this._getPopover(), element, {
+ pointer: true,
+ horizontal: this._options.isButtonGroupNavigation ? "left" : "center",
+ vertical: UiScreen.is("screen-xs") ? "bottom" : "top",
+ });
+
+ if (this._options.isButtonGroupNavigation) {
+ element.closest("nav")!.style.setProperty("opacity", "1", "");
+ }
+
+ const popover = this._getPopover();
+
+ // The popover could be rendered below the input field on mobile, in which case
+ // the "first" button is displayed at the bottom and thus farthest away. Reversing
+ // the display order will restore the logic by placing the "first" button as close
+ // to the react button as possible.
+ const inverseOrder = popover.style.getPropertyValue("bottom") === "auto";
+ if (inverseOrder) {
+ popover.classList.add("inverseOrder");
+ } else {
+ popover.classList.remove("inverseOrder");
+ }
+
+ this._markReactionAsActive();
+
+ this._rebuildOverflowIndicator();
+
+ popover.classList.remove("forceHide");
+ popover.classList.add("active");
+ }
+
+ /**
+ * Returns the react popover element.
+ */
+ protected _getPopover(): HTMLElement {
+ if (this._popover == null) {
+ this._popover = document.createElement("div");
+ this._popover.className = "reactionPopover forceHide";
+
+ this._popoverContent = document.createElement("div");
+ this._popoverContent.className = "reactionPopoverContent";
+
+ const popoverContentHTML = document.createElement("ul");
+ popoverContentHTML.className = "reactionTypeButtonList";
+
+ this._getSortedReactionTypes().forEach((reactionType) => {
+ const reactionTypeItem = document.createElement("li");
+ reactionTypeItem.className = "reactionTypeButton jsTooltip";
+ reactionTypeItem.dataset.reactionTypeId = reactionType.reactionTypeID.toString();
+ reactionTypeItem.dataset.title = reactionType.title;
+ reactionTypeItem.dataset.isAssignable = reactionType.isAssignable.toString();
+
+ reactionTypeItem.title = reactionType.title;
+
+ const reactionTypeItemSpan = document.createElement("span");
+ reactionTypeItemSpan.className = "reactionTypeButtonTitle";
+ reactionTypeItemSpan.innerHTML = reactionType.title;
+
+ reactionTypeItem.innerHTML = reactionType.renderedIcon;
+
+ reactionTypeItem.appendChild(reactionTypeItemSpan);
+
+ reactionTypeItem.addEventListener("click", () => this._react(reactionType.reactionTypeID));
+
+ if (!reactionType.isAssignable) {
+ DomUtil.hide(reactionTypeItem);
+ }
+
+ popoverContentHTML.appendChild(reactionTypeItem);
+ });
+
+ this._popoverContent.appendChild(popoverContentHTML);
+ this._popoverContent.addEventListener("scroll", () => this._rebuildOverflowIndicator(), { passive: true });
+
+ this._popover.appendChild(this._popoverContent);
+
+ const pointer = document.createElement("span");
+ pointer.className = "elementPointer";
+ pointer.appendChild(document.createElement("span"));
+ this._popover.appendChild(pointer);
+
+ document.body.appendChild(this._popover);
+
+ DomChangeListener.trigger();
+ }
+
+ return this._popover;
+ }
+
+ protected _rebuildOverflowIndicator(): void {
+ const popoverContent = this._popoverContent!;
+ const hasTopOverflow = popoverContent.scrollTop > 0;
+ if (hasTopOverflow) {
+ popoverContent.classList.add("overflowTop");
+ } else {
+ popoverContent.classList.remove("overflowTop");
+ }
+
+ const hasBottomOverflow = popoverContent.scrollTop + popoverContent.clientHeight < popoverContent.scrollHeight;
+ if (hasBottomOverflow) {
+ popoverContent.classList.add("overflowBottom");
+ } else {
+ popoverContent.classList.remove("overflowBottom");
+ }
+ }
+
+ /**
+ * Sort the reaction types by the showOrder field.
+ */
+ protected _getSortedReactionTypes(): Reaction[] {
+ return availableReactions.sort((a, b) => a.showOrder - b.showOrder);
+ }
+
+ /**
+ * Closes the react popover.
+ */
+ protected _closePopover(): void {
+ if (this._popoverCurrentObjectId !== 0) {
+ const popover = this._getPopover();
+ popover.classList.remove("active");
+
+ popover
+ .querySelectorAll('.reactionTypeButton[data-is-assignable="0"]')
+ .forEach((el: HTMLElement) => DomUtil.hide(el));
+
+ if (this._options.isButtonGroupNavigation) {
+ this._objects.get(this._popoverCurrentObjectId)!.forEach((elementData) => {
+ elementData.reactButton!.closest("nav")!.style.cssText = "";
+ });
+ }
+
+ this._popoverCurrentObjectId = 0;
+ }
+ }
+
+ /**
+ * React with the given reactionTypeId on an object.
+ */
+ protected _react(reactionTypeId: number): void {
+ if (~~this._popoverCurrentObjectId === 0) {
+ // Double clicking the reaction will cause the first click to go through, but
+ // causes the second to fail because the overlay is already closing.
+ return;
+ }
+
+ this._options.parameters.reactionTypeID = reactionTypeId;
+ this._options.parameters.data.objectID = this._popoverCurrentObjectId;
+ this._options.parameters.data.objectType = this._objectType;
+
+ Ajax.api(this, {
+ parameters: this._options.parameters,
+ });
+
+ this._closePopover();
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ this.countButtons.updateCountButtons(data.returnValues.objectID, data.returnValues.reactions);
+
+ this._updateReactButton(data.returnValues.objectID, data.returnValues.reactionTypeID);
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "react",
+ className: "\\wcf\\data\\reaction\\ReactionAction",
+ },
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiReactionHandler);
+
+export = UiReactionHandler;
--- /dev/null
+/**
+ * Handles the reaction list in the user profile.
+ *
+ * @author Joshua Ruesweg
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Reaction/Profile/Loader
+ * @since 5.2
+ */
+
+import * as Ajax from "../../../Ajax";
+import { AjaxCallbackSetup, ResponseData } from "../../../Ajax/Data";
+import * as Core from "../../../Core";
+import DomUtil from "../../../Dom/Util";
+import * as Language from "../../../Language";
+
+interface AjaxParameters {
+ parameters: {
+ [key: string]: number | string;
+ };
+}
+
+interface AjaxResponse extends ResponseData {
+ returnValues: {
+ template?: string;
+ lastLikeTime: number;
+ };
+}
+
+class UiReactionProfileLoader {
+ protected readonly _container: HTMLElement;
+ protected readonly _loadButton: HTMLButtonElement;
+ protected readonly _noMoreEntries: HTMLElement;
+ protected readonly _options: AjaxParameters;
+ protected _reactionTypeID: number | null = null;
+ protected _targetType = "received";
+ protected readonly _userID: number;
+
+ /**
+ * Initializes a new ReactionListLoader object.
+ */
+ constructor(userID: number) {
+ this._container = document.getElementById("likeList")!;
+ this._userID = userID;
+ this._options = {
+ parameters: {},
+ };
+
+ if (!this._userID) {
+ throw new Error("[WoltLabSuite/Core/Ui/Reaction/Profile/Loader] Invalid parameter 'userID' given.");
+ }
+
+ const loadButtonList = document.createElement("li");
+ loadButtonList.className = "likeListMore showMore";
+ this._noMoreEntries = document.createElement("small");
+ this._noMoreEntries.innerHTML = Language.get("wcf.like.reaction.noMoreEntries");
+ this._noMoreEntries.style.display = "none";
+ loadButtonList.appendChild(this._noMoreEntries);
+
+ this._loadButton = document.createElement("button");
+ this._loadButton.className = "small";
+ this._loadButton.innerHTML = Language.get("wcf.like.reaction.more");
+ this._loadButton.addEventListener("click", () => this._loadReactions());
+ this._loadButton.style.display = "none";
+ loadButtonList.appendChild(this._loadButton);
+ this._container.appendChild(loadButtonList);
+
+ if (document.querySelectorAll("#likeList > li").length === 2) {
+ this._noMoreEntries.style.display = "";
+ } else {
+ this._loadButton.style.display = "";
+ }
+
+ this._setupReactionTypeButtons();
+ this._setupTargetTypeButtons();
+ }
+
+ /**
+ * Set up the reaction type buttons.
+ */
+ protected _setupReactionTypeButtons(): void {
+ document.querySelectorAll("#reactionType .button").forEach((element: HTMLElement) => {
+ element.addEventListener("click", () => this._changeReactionTypeValue(~~element.dataset.reactionTypeId!));
+ });
+ }
+
+ /**
+ * Set up the target type buttons.
+ */
+ protected _setupTargetTypeButtons(): void {
+ document.querySelectorAll("#likeType .button").forEach((element: HTMLElement) => {
+ element.addEventListener("click", () => this._changeTargetType(element.dataset.likeType!));
+ });
+ }
+
+ /**
+ * Changes the reaction target type (given or received) and reload the entire element.
+ */
+ protected _changeTargetType(targetType: string): void {
+ if (targetType !== "given" && targetType !== "received") {
+ throw new Error("[WoltLabSuite/Core/Ui/Reaction/Profile/Loader] Invalid parameter 'targetType' given.");
+ }
+
+ if (targetType !== this._targetType) {
+ // remove old active state
+ document.querySelector("#likeType .button.active")!.classList.remove("active");
+
+ // add active status to new button
+ document.querySelector(`#likeType .button[data-like-type="${targetType}"]`)!.classList.add("active");
+
+ this._targetType = targetType;
+ this._reload();
+ }
+ }
+
+ /**
+ * Changes the reaction type value and reload the entire element.
+ */
+ protected _changeReactionTypeValue(reactionTypeID: number): void {
+ // remove old active state
+ const activeButton = document.querySelector("#reactionType .button.active");
+ if (activeButton) {
+ activeButton.classList.remove("active");
+ }
+
+ if (this._reactionTypeID !== reactionTypeID) {
+ // add active status to new button
+ document
+ .querySelector(`#reactionType .button[data-reaction-type-id="${reactionTypeID}"]`)!
+ .classList.add("active");
+
+ this._reactionTypeID = reactionTypeID;
+ } else {
+ this._reactionTypeID = null;
+ }
+
+ this._reload();
+ }
+
+ /**
+ * Handles reload.
+ */
+ protected _reload(): void {
+ document.querySelectorAll("#likeList > li:not(:first-child):not(:last-child)").forEach((el) => el.remove());
+
+ this._container.dataset.lastLikeTime = "0";
+
+ this._loadReactions();
+ }
+
+ /**
+ * Load a list of reactions.
+ */
+ protected _loadReactions(): void {
+ this._options.parameters.userID = this._userID;
+ this._options.parameters.lastLikeTime = ~~this._container.dataset.lastLikeTime!;
+ this._options.parameters.targetType = this._targetType;
+ this._options.parameters.reactionTypeID = ~~this._reactionTypeID!;
+
+ Ajax.api(this, {
+ parameters: this._options.parameters,
+ });
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ if (data.returnValues.template) {
+ document
+ .querySelector("#likeList > li:nth-last-child(1)")!
+ .insertAdjacentHTML("beforebegin", data.returnValues.template);
+
+ this._container.dataset.lastLikeTime = data.returnValues.lastLikeTime.toString();
+ DomUtil.hide(this._noMoreEntries);
+ DomUtil.show(this._loadButton);
+ } else {
+ DomUtil.show(this._noMoreEntries);
+ DomUtil.hide(this._loadButton);
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "load",
+ className: "\\wcf\\data\\reaction\\ReactionAction",
+ },
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiReactionProfileLoader);
+
+export = UiReactionProfileLoader;
--- /dev/null
+/**
+ * Converts `<woltlab-metacode>` into the bbcode representation.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Redactor/Article
+ */
+
+import * as Core from "../../Core";
+import * as UiArticleSearch from "../Article/Search";
+import { RedactorEditor } from "./Editor";
+
+class UiRedactorArticle {
+ protected readonly _editor: RedactorEditor;
+
+ constructor(editor: RedactorEditor, button: HTMLAnchorElement) {
+ this._editor = editor;
+
+ button.addEventListener("click", (ev) => this._click(ev));
+ }
+
+ protected _click(event: MouseEvent): void {
+ event.preventDefault();
+
+ UiArticleSearch.open((articleId) => this._insert(articleId));
+ }
+
+ protected _insert(articleId: number): void {
+ this._editor.buffer.set();
+
+ this._editor.insert.text(`[wsa='${articleId}'][/wsa]`);
+ }
+}
+
+Core.enableLegacyInheritance(UiRedactorArticle);
+
+export = UiRedactorArticle;
--- /dev/null
+/**
+ * Manages the autosave process storing the current editor message in the local
+ * storage to recover it on browser crash or accidental navigation.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Redactor/Autosave
+ */
+
+import * as Core from "../../Core";
+import Devtools from "../../Devtools";
+import * as EventHandler from "../../Event/Handler";
+import * as Language from "../../Language";
+import { RedactorEditor } from "./Editor";
+import * as UiRedactorMetacode from "./Metacode";
+
+interface AutosaveMetaData {
+ [key: string]: unknown;
+}
+
+interface AutosaveContent {
+ content: string;
+ meta: AutosaveMetaData;
+ timestamp: number;
+}
+
+// time between save requests in seconds
+const _frequency = 15;
+
+class UiRedactorAutosave {
+ protected _container: HTMLElement | null = null;
+ protected _editor: RedactorEditor | null = null;
+ protected readonly _element: HTMLTextAreaElement;
+ protected _isActive = true;
+ protected _isPending = false;
+ protected readonly _key: string;
+ protected _lastMessage = "";
+ protected _metaData: AutosaveMetaData = {};
+ protected _originalMessage = "";
+ protected _restored = false;
+ protected _timer: number | null = null;
+
+ /**
+ * Initializes the autosave handler and removes outdated messages from storage.
+ *
+ * @param {Element} element textarea element
+ */
+ constructor(element: HTMLTextAreaElement) {
+ this._element = element;
+ this._key = Core.getStoragePrefix() + this._element.dataset.autosave!;
+
+ this._cleanup();
+
+ // remove attribute to prevent Redactor's built-in autosave to kick in
+ delete this._element.dataset.autosave;
+
+ const form = this._element.closest("form");
+ if (form !== null) {
+ form.addEventListener("submit", this.destroy.bind(this));
+ }
+
+ // export meta data
+ EventHandler.add("com.woltlab.wcf.redactor2", `getMetaData_${this._element.id}`, (data: AutosaveMetaData) => {
+ Object.entries(this._metaData).forEach(([key, value]) => {
+ data[key] = value;
+ });
+ });
+
+ // clear editor content on reset
+ EventHandler.add("com.woltlab.wcf.redactor2", `reset_${this._element.id}`, () => this.hideOverlay());
+
+ document.addEventListener("visibilitychange", () => this._onVisibilityChange());
+ }
+
+ protected _onVisibilityChange(): void {
+ this._isActive = !document.hidden;
+ this._isPending = document.hidden;
+ }
+
+ /**
+ * Returns the initial value for the textarea, used to inject message
+ * from storage into the editor before initialization.
+ *
+ * @return {string} message content
+ */
+ getInitialValue(): string {
+ if (window.ENABLE_DEVELOPER_TOOLS && !Devtools._internal_.editorAutosave()) {
+ return this._element.value;
+ }
+
+ let value = "";
+ try {
+ value = window.localStorage.getItem(this._key) || "";
+ } catch (e) {
+ const errorMessage = (e as Error).message;
+ window.console.warn(`Unable to access local storage: ${errorMessage}`);
+ }
+
+ let metaData: AutosaveContent | null = null;
+ try {
+ metaData = JSON.parse(value);
+ } catch (e) {
+ // We do not care for JSON errors.
+ }
+
+ // Check if the storage is outdated.
+ if (metaData !== null && typeof metaData === "object" && metaData.content) {
+ const lastEditTime = ~~this._element.dataset.autosaveLastEditTime!;
+ if (lastEditTime * 1_000 <= metaData.timestamp) {
+ // Compare the stored version with the editor content, but only use the `innerText` property
+ // in order to ignore differences in whitespace, e. g. caused by indentation of HTML tags.
+ const div1 = document.createElement("div");
+ div1.innerHTML = this._element.value;
+ const div2 = document.createElement("div");
+ div2.innerHTML = metaData.content;
+
+ if (div1.innerText.trim() !== div2.innerText.trim()) {
+ this._originalMessage = this._element.value;
+ this._restored = true;
+
+ this._metaData = metaData.meta || {};
+
+ return metaData.content;
+ }
+ }
+ }
+
+ return this._element.value;
+ }
+
+ /**
+ * Returns the stored meta data.
+ */
+ getMetaData(): AutosaveMetaData {
+ return this._metaData;
+ }
+
+ /**
+ * Enables periodical save of editor contents to local storage.
+ */
+ watch(editor: RedactorEditor): void {
+ this._editor = editor;
+
+ if (this._timer !== null) {
+ throw new Error("Autosave timer is already active.");
+ }
+
+ this._timer = window.setInterval(() => this._saveToStorage(), _frequency * 1_000);
+
+ this._saveToStorage();
+
+ this._isPending = false;
+ }
+
+ /**
+ * Disables autosave handler, for use on editor destruction.
+ */
+ destroy(): void {
+ this.clear();
+
+ this._editor = null;
+
+ if (this._timer) {
+ window.clearInterval(this._timer);
+ }
+
+ this._timer = null;
+ this._isPending = false;
+ }
+
+ /**
+ * Removed the stored message, for use after a message has been submitted.
+ */
+ clear(): void {
+ this._metaData = {};
+ this._lastMessage = "";
+
+ try {
+ window.localStorage.removeItem(this._key);
+ } catch (e) {
+ const errorMessage = (e as Error).message;
+ window.console.warn(`Unable to remove from local storage: ${errorMessage}`);
+ }
+ }
+
+ /**
+ * Creates the autosave controls, used to keep or discard the restored draft.
+ */
+ createOverlay(): void {
+ if (!this._restored) {
+ return;
+ }
+
+ const editor = this._editor!;
+
+ const container = document.createElement("div");
+ container.className = "redactorAutosaveRestored active";
+
+ const title = document.createElement("span");
+ title.textContent = Language.get("wcf.editor.autosave.restored");
+ container.appendChild(title);
+
+ const buttonKeep = document.createElement("a");
+ buttonKeep.className = "jsTooltip";
+ buttonKeep.href = "#";
+ buttonKeep.title = Language.get("wcf.editor.autosave.keep");
+ buttonKeep.innerHTML = '<span class="icon icon16 fa-check green"></span>';
+ buttonKeep.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ this.hideOverlay();
+ });
+ container.appendChild(buttonKeep);
+
+ const buttonDiscard = document.createElement("a");
+ buttonDiscard.className = "jsTooltip";
+ buttonDiscard.href = "#";
+ buttonDiscard.title = Language.get("wcf.editor.autosave.discard");
+ buttonDiscard.innerHTML = '<span class="icon icon16 fa-times red"></span>';
+ buttonDiscard.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ // remove from storage
+ this.clear();
+
+ // set code
+ const content = UiRedactorMetacode.convertFromHtml(editor.core.element()[0].id, this._originalMessage);
+ editor.code.start(content);
+
+ // set value
+ editor.core.textarea().val(editor.clean.onSync(editor.$editor.html()));
+
+ this.hideOverlay();
+ });
+ container.appendChild(buttonDiscard);
+
+ editor.core.box()[0].appendChild(container);
+
+ editor.core.editor()[0].addEventListener("click", () => this.hideOverlay(), { once: true });
+
+ this._container = container;
+ }
+
+ /**
+ * Hides the autosave controls.
+ */
+ hideOverlay(): void {
+ if (this._container !== null) {
+ this._container.classList.remove("active");
+
+ window.setTimeout(() => {
+ if (this._container !== null) {
+ this._container.remove();
+ }
+
+ this._container = null;
+ this._originalMessage = "";
+ }, 1_000);
+ }
+ }
+
+ /**
+ * Saves the current message to storage unless there was no change.
+ */
+ protected _saveToStorage(): void {
+ if (!this._isActive) {
+ if (!this._isPending) {
+ return;
+ }
+
+ // save one last time before suspending
+ this._isPending = false;
+ }
+
+ //noinspection JSUnresolvedVariable
+ if (window.ENABLE_DEVELOPER_TOOLS && !Devtools._internal_.editorAutosave()) {
+ return;
+ }
+
+ const editor = this._editor!;
+ let content = editor.code.get();
+ if (editor.utils.isEmpty(content)) {
+ content = "";
+ }
+
+ if (this._lastMessage === content) {
+ // break if content hasn't changed
+ return;
+ }
+
+ if (content === "") {
+ return this.clear();
+ }
+
+ try {
+ EventHandler.fire("com.woltlab.wcf.redactor2", `autosaveMetaData_${this._element.id}`, this._metaData);
+
+ window.localStorage.setItem(
+ this._key,
+ JSON.stringify({
+ content: content,
+ meta: this._metaData,
+ timestamp: Date.now(),
+ } as AutosaveContent),
+ );
+
+ this._lastMessage = content;
+ } catch (e) {
+ const errorMessage = (e as Error).message;
+ window.console.warn(`Unable to write to local storage: ${errorMessage}`);
+ }
+ }
+
+ /**
+ * Removes stored messages older than one week.
+ */
+ protected _cleanup(): void {
+ const oneWeekAgo = Date.now() - 7 * 24 * 3_600 * 1_000;
+
+ Object.keys(window.localStorage)
+ .filter((key) => key.startsWith(Core.getStoragePrefix()))
+ .forEach((key) => {
+ let value = "";
+ try {
+ value = window.localStorage.getItem(key) || "";
+ } catch (e) {
+ const errorMessage = (e as Error).message;
+ window.console.warn(`Unable to access local storage: ${errorMessage}`);
+ }
+
+ let timestamp = 0;
+ try {
+ const content: AutosaveContent = JSON.parse(value);
+ timestamp = content.timestamp;
+ } catch (e) {
+ // We do not care for JSON errors.
+ }
+
+ if (!value || timestamp < oneWeekAgo) {
+ try {
+ window.localStorage.removeItem(key);
+ } catch (e) {
+ const errorMessage = (e as Error).message;
+ window.console.warn(`Unable to remove from local storage: ${errorMessage}`);
+ }
+ }
+ });
+ }
+}
+
+Core.enableLegacyInheritance(UiRedactorAutosave);
+
+export = UiRedactorAutosave;
--- /dev/null
+/**
+ * Manages code blocks.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Redactor/Code
+ */
+
+import * as Core from "../../Core";
+import DomUtil from "../../Dom/Util";
+import * as EventHandler from "../../Event/Handler";
+import * as Language from "../../Language";
+import * as StringUtil from "../../StringUtil";
+import UiDialog from "../Dialog";
+import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
+import { RedactorEditor, WoltLabEventData } from "./Editor";
+import * as UiRedactorPseudoHeader from "./PseudoHeader";
+import PrismMeta from "../../prism-meta";
+
+type Highlighter = [string, string];
+
+let _headerHeight = 0;
+
+class UiRedactorCode implements DialogCallbackObject {
+ protected readonly _callbackEdit: (ev: MouseEvent) => void;
+ protected readonly _editor: RedactorEditor;
+ protected readonly _elementId: string;
+ protected _pre: HTMLElement | null = null;
+
+ /**
+ * Initializes the source code management.
+ */
+ constructor(editor: RedactorEditor) {
+ this._editor = editor;
+ this._elementId = this._editor.$element[0].id;
+
+ EventHandler.add("com.woltlab.wcf.redactor2", `bbcode_code_${this._elementId}`, (data) => this._bbcodeCode(data));
+ EventHandler.add("com.woltlab.wcf.redactor2", `observe_load_${this._elementId}`, () => this._observeLoad());
+
+ // support for active button marking
+ this._editor.opts.activeButtonsStates.pre = "code";
+
+ // static bind to ensure that removing works
+ this._callbackEdit = this._edit.bind(this);
+
+ // bind listeners on init
+ this._observeLoad();
+ }
+
+ /**
+ * Intercepts the insertion of `[code]` tags and uses a native `<pre>` instead.
+ */
+ protected _bbcodeCode(data: WoltLabEventData): void {
+ data.cancel = true;
+
+ let pre = this._editor.selection.block();
+ if (pre && pre.nodeName === "PRE" && pre.classList.contains("woltlabHtml")) {
+ return;
+ }
+
+ this._editor.button.toggle({}, "pre", "func", "block.format");
+
+ pre = this._editor.selection.block();
+ if (pre && pre.nodeName === "PRE" && !pre.classList.contains("woltlabHtml")) {
+ if (pre.childElementCount === 1 && pre.children[0].nodeName === "BR") {
+ // drop superfluous linebreak
+ pre.removeChild(pre.children[0]);
+ }
+
+ this._setTitle(pre);
+
+ pre.addEventListener("click", this._callbackEdit);
+
+ // work-around for Safari
+ this._editor.caret.end(pre);
+ }
+ }
+
+ /**
+ * Binds event listeners and sets quote title on both editor
+ * initialization and when switching back from code view.
+ */
+ protected _observeLoad(): void {
+ this._editor.$editor[0].querySelectorAll("pre:not(.woltlabHtml)").forEach((pre: HTMLElement) => {
+ pre.addEventListener("mousedown", this._callbackEdit);
+ this._setTitle(pre);
+ });
+ }
+
+ /**
+ * Opens the dialog overlay to edit the code's properties.
+ */
+ protected _edit(event: MouseEvent): void {
+ const pre = event.currentTarget as HTMLPreElement;
+
+ if (_headerHeight === 0) {
+ _headerHeight = UiRedactorPseudoHeader.getHeight(pre);
+ }
+
+ // check if the click hit the header
+ const offset = DomUtil.offset(pre);
+ if (event.pageY > offset.top && event.pageY < offset.top + _headerHeight) {
+ event.preventDefault();
+
+ this._editor.selection.save();
+ this._pre = pre;
+
+ UiDialog.open(this);
+ }
+ }
+
+ /**
+ * Saves the changes to the code's properties.
+ */
+ _dialogSubmit(): void {
+ const id = "redactor-code-" + this._elementId;
+ const pre = this._pre!;
+
+ ["file", "highlighter", "line"].forEach((attr) => {
+ const input = document.getElementById(`${id}-${attr}`) as HTMLInputElement;
+ pre.dataset[attr] = input.value;
+ });
+
+ this._setTitle(pre);
+ this._editor.caret.after(pre);
+
+ UiDialog.close(this);
+ }
+
+ /**
+ * Sets or updates the code's header title.
+ */
+ protected _setTitle(pre: HTMLElement): void {
+ const file = pre.dataset.file!;
+ let highlighter = pre.dataset.highlighter!;
+
+ highlighter =
+ this._editor.opts.woltlab.highlighters.indexOf(highlighter) !== -1 ? PrismMeta[highlighter].title : "";
+
+ const title = Language.get("wcf.editor.code.title", {
+ file,
+ highlighter,
+ });
+
+ if (pre.dataset.title !== title) {
+ pre.dataset.title = title;
+ }
+ }
+
+ protected _delete(event: MouseEvent): void {
+ event.preventDefault();
+
+ const pre = this._pre!;
+ let caretEnd = pre.nextElementSibling || pre.previousElementSibling;
+ if (caretEnd === null && pre.parentElement !== this._editor.core.editor()[0]) {
+ caretEnd = pre.parentElement;
+ }
+
+ if (caretEnd === null) {
+ this._editor.code.set("");
+ this._editor.focus.end();
+ } else {
+ pre.remove();
+ this._editor.caret.end(caretEnd);
+ }
+
+ UiDialog.close(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ const id = `redactor-code-${this._elementId}`;
+ const idButtonDelete = `${id}-button-delete`;
+ const idButtonSave = `${id}-button-save`;
+ const idFile = `${id}-file`;
+ const idHighlighter = `${id}-highlighter`;
+ const idLine = `${id}-line`;
+
+ return {
+ id: id,
+ options: {
+ onClose: () => {
+ this._editor.selection.restore();
+
+ UiDialog.destroy(this);
+ },
+
+ onSetup: () => {
+ document.getElementById(idButtonDelete)!.addEventListener("click", (ev) => this._delete(ev));
+
+ // set highlighters
+ let highlighters = `<option value="">${Language.get("wcf.editor.code.highlighter.detect")}</option>
+ <option value="plain">${Language.get("wcf.editor.code.highlighter.plain")}</option>`;
+
+ const values: Highlighter[] = this._editor.opts.woltlab.highlighters.map((highlighter: string) => {
+ return [highlighter, PrismMeta[highlighter].title];
+ });
+
+ // sort by label
+ values.sort((a, b) => a[1].localeCompare(b[1]));
+
+ highlighters += values
+ .map(([highlighter, title]) => {
+ return `<option value="${highlighter}">${StringUtil.escapeHTML(title)}</option>`;
+ })
+ .join("\n");
+
+ document.getElementById(idHighlighter)!.innerHTML = highlighters;
+ },
+
+ onShow: () => {
+ const pre = this._pre!;
+
+ const highlighter = document.getElementById(idHighlighter) as HTMLSelectElement;
+ highlighter.value = pre.dataset.highlighter || "";
+ const line = ~~(pre.dataset.line || 1);
+
+ const lineInput = document.getElementById(idLine) as HTMLInputElement;
+ lineInput.value = line.toString();
+
+ const filename = document.getElementById(idFile) as HTMLInputElement;
+ filename.value = pre.dataset.file || "";
+ },
+
+ title: Language.get("wcf.editor.code.edit"),
+ },
+ source: `<div class="section">
+ <dl>
+ <dt>
+ <label for="${idHighlighter}">${Language.get("wcf.editor.code.highlighter")}</label>
+ </dt>
+ <dd>
+ <select id="${idHighlighter}"></select>
+ <small>${Language.get("wcf.editor.code.highlighter.description")}</small>
+ </dd>
+ </dl>
+ <dl>
+ <dt>
+ <label for="${idLine}">${Language.get("wcf.editor.code.line")}</label>
+ </dt>
+ <dd>
+ <input type="number" id="${idLine}" min="0" value="1" class="long" data-dialog-submit-on-enter="true">
+ <small>${Language.get("wcf.editor.code.line.description")}</small>
+ </dd>
+ </dl>
+ <dl>
+ <dt>
+ <label for="${idFile}">${Language.get("wcf.editor.code.file")}</label>
+ </dt>
+ <dd>
+ <input type="text" id="${idFile}" class="long" data-dialog-submit-on-enter="true">
+ <small>${Language.get("wcf.editor.code.file.description")}</small>
+ </dd>
+ </dl>
+ </div>
+ <div class="formSubmit">
+ <button id="${idButtonSave}" class="buttonPrimary" data-type="submit">${Language.get(
+ "wcf.global.button.save",
+ )}</button>
+ <button id="${idButtonDelete}">${Language.get("wcf.global.button.delete")}</button>
+ </div>`,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiRedactorCode);
+
+export = UiRedactorCode;
--- /dev/null
+/**
+ * Drag and Drop file uploads.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Redactor/DragAndDrop
+ */
+
+import * as EventHandler from "../../Event/Handler";
+import * as Language from "../../Language";
+import { RedactorEditor } from "./Editor";
+
+type Uuid = string;
+
+interface EditorData {
+ editor: RedactorEditor | RedactorEditorLike;
+ element: HTMLElement | null;
+}
+
+let _didInit = false;
+const _dragArea = new Map<Uuid, EditorData>();
+let _isDragging = false;
+let _isFile = false;
+let _timerLeave: number | null = null;
+
+/**
+ * Handles items dragged into the browser window.
+ */
+function _dragOver(event: DragEvent): void {
+ event.preventDefault();
+
+ if (!event.dataTransfer || !event.dataTransfer.types) {
+ return;
+ }
+
+ const isFirefox = Object.keys(event.dataTransfer).some((property) => property.startsWith("moz"));
+
+ // IE and WebKit set 'Files', Firefox sets 'application/x-moz-file' for files being dragged
+ // and Safari just provides 'Files' along with a huge list of garbage
+ _isFile = false;
+ if (isFirefox) {
+ // Firefox sets the 'Files' type even if the user is just dragging an on-page element
+ if (event.dataTransfer.types[0] === "application/x-moz-file") {
+ _isFile = true;
+ }
+ } else {
+ _isFile = event.dataTransfer.types.some((type) => type === "Files");
+ }
+
+ if (!_isFile) {
+ // user is just dragging around some garbage, ignore it
+ return;
+ }
+
+ if (_isDragging) {
+ // user is still dragging the file around
+ return;
+ }
+
+ _isDragging = true;
+
+ _dragArea.forEach((data, uuid) => {
+ const editor = data.editor.$editor[0];
+ if (!editor.parentElement) {
+ _dragArea.delete(uuid);
+ return;
+ }
+
+ let element: HTMLElement | null = data.element;
+ if (element === null) {
+ element = document.createElement("div");
+ element.className = "redactorDropArea";
+ element.dataset.elementId = data.editor.$element[0].id;
+ element.dataset.dropHere = Language.get("wcf.attachment.dragAndDrop.dropHere");
+ element.dataset.dropNow = Language.get("wcf.attachment.dragAndDrop.dropNow");
+
+ element.addEventListener("dragover", () => {
+ element!.classList.add("active");
+ });
+ element.addEventListener("dragleave", () => {
+ element!.classList.remove("active");
+ });
+ element.addEventListener("drop", (ev) => drop(ev));
+
+ data.element = element;
+ }
+
+ editor.parentElement.insertBefore(element, editor);
+ element.style.setProperty("top", `${editor.offsetTop}px`, "");
+ });
+}
+
+/**
+ * Handles items dropped onto an editor's drop area
+ */
+function drop(event: DragEvent): void {
+ if (!_isFile) {
+ return;
+ }
+
+ if (!event.dataTransfer || !event.dataTransfer.files.length) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const target = event.currentTarget as HTMLElement;
+ const elementId = target.dataset.elementId!;
+
+ Array.from(event.dataTransfer.files).forEach((file) => {
+ const eventData: OnDropPayload = { file };
+ EventHandler.fire("com.woltlab.wcf.redactor2", `dragAndDrop_${elementId}`, eventData);
+ });
+
+ // this will reset all drop areas
+ dragLeave();
+}
+
+/**
+ * Invoked whenever the item is no longer dragged or was dropped.
+ *
+ * @protected
+ */
+function dragLeave() {
+ if (!_isDragging || !_isFile) {
+ return;
+ }
+
+ if (_timerLeave !== null) {
+ window.clearTimeout(_timerLeave);
+ }
+
+ _timerLeave = window.setTimeout(() => {
+ if (!_isDragging) {
+ _dragArea.forEach((data) => {
+ if (data.element && data.element.parentElement) {
+ data.element.classList.remove("active");
+ data.element.remove();
+ }
+ });
+ }
+
+ _timerLeave = null;
+ }, 100);
+
+ _isDragging = false;
+}
+
+/**
+ * Handles the global drop event.
+ */
+function globalDrop(event: DragEvent): void {
+ const target = event.target as HTMLElement;
+ if (target.closest(".redactor-layer") === null) {
+ const eventData: OnGlobalDropPayload = { cancelDrop: true, event: event };
+ _dragArea.forEach((data) => {
+ EventHandler.fire("com.woltlab.wcf.redactor2", `dragAndDrop_globalDrop_${data.editor.$element[0].id}`, eventData);
+ });
+
+ if (eventData.cancelDrop) {
+ event.preventDefault();
+ }
+ }
+
+ dragLeave();
+}
+
+/**
+ * Binds listeners to global events.
+ *
+ * @protected
+ */
+function setup() {
+ // discard garbage event
+ window.addEventListener("dragend", (ev) => ev.preventDefault());
+
+ window.addEventListener("dragover", (ev) => _dragOver(ev));
+ window.addEventListener("dragleave", () => dragLeave());
+ window.addEventListener("drop", (ev) => globalDrop(ev));
+
+ _didInit = true;
+}
+
+/**
+ * Initializes drag and drop support for provided editor instance.
+ */
+export function init(editor: RedactorEditor | RedactorEditorLike): void {
+ if (!_didInit) {
+ setup();
+ }
+
+ _dragArea.set(editor.uuid, {
+ editor: editor,
+ element: null,
+ });
+}
+
+export interface RedactorEditorLike {
+ uuid: string;
+ $editor: HTMLElement[];
+ $element: HTMLElement[];
+}
+
+export interface OnDropPayload {
+ file: File;
+}
+
+export interface OnGlobalDropPayload {
+ cancelDrop: boolean;
+ event: DragEvent;
+}
--- /dev/null
+export interface RedactorEditor {
+ uuid: string;
+ $editor: JQuery;
+ $element: JQuery;
+
+ opts: {
+ [key: string]: any;
+ };
+
+ buffer: {
+ set(): void;
+ };
+ button: {
+ addCallback(button: JQuery, callback: () => void): void;
+ toggle(event: MouseEvent | object, btnName: string, type: string, callback: string, args?: object): void;
+ };
+ caret: {
+ after(node: Node): void;
+ end(node: Node): void;
+ };
+ clean: {
+ onSync(html: string): string;
+ };
+ code: {
+ get(): string;
+ set(html: string): void;
+ start(html: string): void;
+ };
+ core: {
+ box(): JQuery;
+ editor(): JQuery;
+ element(): JQuery;
+ textarea(): JQuery;
+ toolbar(): JQuery;
+ };
+ focus: {
+ end(): void;
+ };
+ insert: {
+ html(html: string): void;
+ text(text: string): void;
+ };
+ selection: {
+ block(): HTMLElement | false;
+ restore(): void;
+ save(): void;
+ };
+ utils: {
+ isEmpty(html?: string): boolean;
+ };
+
+ WoltLabAutosave: {
+ reset(): void;
+ };
+ WoltLabCaret: {
+ endOfEditor(): void;
+ paragraphAfterBlock(quote: HTMLElement): void;
+ };
+ WoltLabEvent: {
+ register(event: string, callback: (data: WoltLabEventData) => void): void;
+ };
+ WoltLabReply: {
+ showEditor(): void;
+ };
+ WoltLabSource: {
+ isActive(): boolean;
+ };
+}
+
+export interface WoltLabEventData {
+ cancel: boolean;
+ event: Event;
+ redactor: RedactorEditor;
+}
--- /dev/null
+/**
+ * Provides helper methods to add and remove format elements. These methods should in
+ * theory work with non-editor elements but has not been tested and any usage outside
+ * the editor is not recommended.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Redactor/Format
+ */
+
+import DomUtil from "../../Dom/Util";
+
+type SelectionMarker = [string, string];
+
+function isValidSelection(editorElement: HTMLElement): boolean {
+ let element = window.getSelection()!.anchorNode;
+ while (element) {
+ if (element === editorElement) {
+ return true;
+ }
+
+ element = element.parentNode;
+ }
+
+ return false;
+}
+
+/**
+ * Slices relevant parent nodes and removes matching ancestors.
+ *
+ * @param {Element} strikeElement strike element representing the text selection
+ * @param {Element} lastMatchingParent last matching ancestor element
+ * @param {string} property CSS property that should be removed
+ */
+function handleParentNodes(strikeElement: HTMLElement, lastMatchingParent: HTMLElement, property: string): void {
+ const parent = lastMatchingParent.parentElement!;
+
+ // selection does not begin at parent node start, slice all relevant parent
+ // nodes to ensure that selection is then at the beginning while preserving
+ // all proper ancestor elements
+ //
+ // before: (the pipe represents the node boundary)
+ // |otherContent <-- selection -->
+ // after:
+ // |otherContent| |<-- selection -->
+ if (!DomUtil.isAtNodeStart(strikeElement, lastMatchingParent)) {
+ const range = document.createRange();
+ range.setStartBefore(lastMatchingParent);
+ range.setEndBefore(strikeElement);
+
+ const fragment = range.extractContents();
+ parent.insertBefore(fragment, lastMatchingParent);
+ }
+
+ // selection does not end at parent node end, slice all relevant parent nodes
+ // to ensure that selection is then at the end while preserving all proper
+ // ancestor elements
+ //
+ // before: (the pipe represents the node boundary)
+ // <-- selection --> otherContent|
+ // after:
+ // <-- selection -->| |otherContent|
+ if (!DomUtil.isAtNodeEnd(strikeElement, lastMatchingParent)) {
+ const range = document.createRange();
+ range.setStartAfter(strikeElement);
+ range.setEndAfter(lastMatchingParent);
+
+ const fragment = range.extractContents();
+ parent.insertBefore(fragment, lastMatchingParent.nextSibling);
+ }
+
+ // the strike element is now some kind of isolated, meaning we can now safely
+ // remove all offending parent nodes without influencing formatting of any content
+ // before or after the element
+ lastMatchingParent.querySelectorAll("span").forEach((span) => {
+ if (span.style.getPropertyValue(property)) {
+ DomUtil.unwrapChildNodes(span);
+ }
+ });
+
+ // finally remove the parent itself
+ DomUtil.unwrapChildNodes(lastMatchingParent);
+}
+
+/**
+ * Finds the last matching ancestor until it reaches the editor element.
+ */
+function getLastMatchingParent(
+ strikeElement: HTMLElement,
+ editorElement: HTMLElement,
+ property: string,
+): HTMLElement | null {
+ let parent = strikeElement.parentElement!;
+ let match: HTMLElement | null = null;
+ while (parent !== editorElement) {
+ if (parent.nodeName === "SPAN" && parent.style.getPropertyValue(property) !== "") {
+ match = parent;
+ }
+
+ parent = parent.parentElement!;
+ }
+
+ return match;
+}
+
+/**
+ * Returns true if provided element is the first or last element
+ * of its parent, ignoring empty text nodes appearing between the
+ * element and the boundary.
+ */
+function isBoundaryElement(
+ element: HTMLElement,
+ parent: HTMLElement,
+ type: "previousSibling" | "nextSibling",
+): boolean {
+ let node: Node | null = element;
+ while ((node = node[type])) {
+ if (node.nodeType !== Node.TEXT_NODE || node.textContent!.replace(/\u200B/, "") !== "") {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Returns a custom selection marker element, can be either `strike`, `sub` or `sup`. Using other kind
+ * of formattings is not possible due to the inconsistent behavior across browsers.
+ */
+function getSelectionMarker(editorElement: HTMLElement, selection: Selection): SelectionMarker {
+ const tags = ["DEL", "SUB", "SUP"];
+ const tag = tags.find((tagName) => {
+ const anchorNode = selection.anchorNode!;
+ let node: HTMLElement =
+ anchorNode.nodeType === Node.ELEMENT_NODE ? (anchorNode as HTMLElement) : anchorNode.parentElement!;
+ const hasNode = node.querySelector(tagName.toLowerCase()) !== null;
+
+ if (!hasNode) {
+ while (node && node !== editorElement) {
+ if (node.nodeName === tagName) {
+ return true;
+ }
+
+ node = node.parentElement!;
+ }
+ }
+
+ return false;
+ });
+
+ if (tag === "DEL" || tag === undefined) {
+ return ["strike", "strikethrough"];
+ }
+
+ return [tag.toLowerCase(), tag.toLowerCase() + "script"];
+}
+
+/**
+ * Slightly modified version of Redactor's `utils.isEmpty()`.
+ */
+function isEmpty(html: string): boolean {
+ html = html.replace(/[\u200B-\u200D\uFEFF]/g, "");
+ html = html.replace(/ /gi, "");
+ html = html.replace(/<\/?br\s?\/?>/g, "");
+ html = html.replace(/\s/g, "");
+ html = html.replace(/^<p>[^\W\w\D\d]*?<\/p>$/i, "");
+ html = html.replace(/<iframe(.*?[^>])>$/i, "iframe");
+ html = html.replace(/<source(.*?[^>])>$/i, "source");
+
+ // remove empty tags
+ html = html.replace(/<[^/>][^>]*><\/[^>]+>/gi, "");
+ html = html.replace(/<[^/>][^>]*><\/[^>]+>/gi, "");
+
+ return html.trim() === "";
+}
+
+/**
+ * Applies format elements to the selected text.
+ */
+export function format(editorElement: HTMLElement, property: string, value: string): void {
+ const selection = window.getSelection()!;
+ if (!selection.rangeCount) {
+ // no active selection
+ return;
+ }
+
+ if (!isValidSelection(editorElement)) {
+ console.error("Invalid selection, range exists outside of the editor:", selection.anchorNode);
+ return;
+ }
+
+ let range = selection.getRangeAt(0);
+ let markerStart: HTMLElement | null = null;
+ let markerEnd: HTMLElement | null = null;
+ let tmpElement: HTMLElement | null = null;
+ if (range.collapsed) {
+ tmpElement = document.createElement("strike");
+ tmpElement.textContent = "\u200B";
+ range.insertNode(tmpElement);
+
+ range = document.createRange();
+ range.selectNodeContents(tmpElement);
+
+ selection.removeAllRanges();
+ selection.addRange(range);
+ } else {
+ // removing existing format causes the selection to vanish,
+ // these markers are used to restore it afterwards
+ markerStart = document.createElement("mark");
+ markerEnd = document.createElement("mark");
+
+ let tmpRange = range.cloneRange();
+ tmpRange.collapse(true);
+ tmpRange.insertNode(markerStart);
+
+ tmpRange = range.cloneRange();
+ tmpRange.collapse(false);
+ tmpRange.insertNode(markerEnd);
+
+ range = document.createRange();
+ range.setStartAfter(markerStart);
+ range.setEndBefore(markerEnd);
+
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // remove existing format before applying new one
+ removeFormat(editorElement, property);
+
+ range = document.createRange();
+ range.setStartAfter(markerStart);
+ range.setEndBefore(markerEnd);
+
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }
+
+ let selectionMarker: SelectionMarker = ["strike", "strikethrough"];
+ if (tmpElement === null) {
+ selectionMarker = getSelectionMarker(editorElement, selection);
+
+ document.execCommand(selectionMarker[1]);
+ }
+
+ const selectElements: HTMLElement[] = [];
+ editorElement.querySelectorAll(selectionMarker[0]).forEach((strike) => {
+ const formatElement = document.createElement("span");
+
+ // we're bypassing `style.setPropertyValue()` on purpose here,
+ // as it prevents browsers from mangling the value
+ formatElement.setAttribute("style", `${property}: ${value}`);
+
+ DomUtil.replaceElement(strike, formatElement);
+ selectElements.push(formatElement);
+ });
+
+ const count = selectElements.length;
+ if (count) {
+ const firstSelectedElement = selectElements[0];
+ const lastSelectedElement = selectElements[count - 1];
+
+ // check if parent is of the same format
+ // and contains only the selected nodes
+ if (tmpElement === null && firstSelectedElement.parentElement === lastSelectedElement.parentElement) {
+ const parent = firstSelectedElement.parentElement!;
+ if (parent.nodeName === "SPAN" && parent.style.getPropertyValue(property) !== "") {
+ if (
+ isBoundaryElement(firstSelectedElement, parent, "previousSibling") &&
+ isBoundaryElement(lastSelectedElement, parent, "nextSibling")
+ ) {
+ DomUtil.unwrapChildNodes(parent);
+ }
+ }
+ }
+
+ range = document.createRange();
+ range.setStart(firstSelectedElement, 0);
+ range.setEnd(lastSelectedElement, lastSelectedElement.childNodes.length);
+
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }
+
+ if (markerStart !== null) {
+ markerStart.remove();
+ markerEnd!.remove();
+ }
+}
+
+/**
+ * Removes a format element from the current selection.
+ *
+ * The removal uses a few techniques to remove the target element(s) without harming
+ * nesting nor any other formatting present. The steps taken are described below:
+ *
+ * 1. The browser will wrap all parts of the selection into <strike> tags
+ *
+ * This isn't the most efficient way to isolate each selected node, but is the
+ * most reliable way to accomplish this because the browser will insert them
+ * exactly where the range spans without harming the node nesting.
+ *
+ * Basically it is a trade-off between efficiency and reliability, the performance
+ * is still excellent but could be better at the expense of an increased complexity,
+ * which simply doesn't exactly pay off.
+ *
+ * 2. Iterate over each inserted <strike> and isolate all relevant ancestors
+ *
+ * Format tags can appear both as a child of the <strike> as well as once or multiple
+ * times as an ancestor.
+ *
+ * It uses ranges to select the contents before the <strike> element up to the start
+ * of the last matching ancestor and cuts out the nodes. The browser will ensure that
+ * the resulting fragment will include all relevant ancestors that were present before.
+ *
+ * The example below will use the fictional <bar> elements as the tag to remove, the
+ * pipe ("|") is used to denote the outer node boundaries.
+ *
+ * Before:
+ * |<bar>This is <foo>a <strike>simple <bar>example</bar></strike></foo></bar>|
+ * After:
+ * |<bar>This is <foo>a </foo></bar>|<bar><foo>simple <bar>example</bar></strike></foo></bar>|
+ *
+ * As a result we can now remove <bar> both inside the <strike> element as well as
+ * the outer <bar> without harming the effect of <bar> for the preceding siblings.
+ *
+ * This process is repeated for siblings appearing after the <strike> element too, it
+ * works as described above but flipped. This is an expensive operation and will only
+ * take place if there are any matching ancestors that need to be considered.
+ *
+ * Inspired by http://stackoverflow.com/a/12899461
+ *
+ * 3. Remove all matching ancestors, child elements and last the <strike> element itself
+ *
+ * Depending on the amount of nested matching nodes, this process will move a lot of
+ * nodes around. Removing the <bar> element will require all its child nodes to be moved
+ * in front of <bar>, they will actually become a sibling of <bar>. Afterwards the
+ * (now empty) <bar> element can be safely removed without losing any nodes.
+ *
+ *
+ * One last hint: This method will not check if the selection at some point contains at
+ * least one target element, it assumes that the user will not take any action that invokes
+ * this method for no reason (unless they want to waste CPU cycles, in that case they're
+ * welcome).
+ *
+ * This is especially important for developers as this method shouldn't be called for
+ * no good reason. Even though it is super fast, it still comes with expensive DOM operations
+ * and especially low-end devices (such as cheap smartphones) might not exactly like executing
+ * this method on large documents.
+ *
+ * If you fell the need to invoke this method anyway, go ahead. I'm a comment, not a cop.
+ */
+export function removeFormat(editorElement: HTMLElement, property: string): void {
+ const selection = window.getSelection()!;
+ if (!selection.rangeCount) {
+ return;
+ } else if (!isValidSelection(editorElement)) {
+ console.error("Invalid selection, range exists outside of the editor:", selection.anchorNode);
+ return;
+ }
+
+ // Removing a span from an empty selection in an empty line containing a `<br>` causes a selection
+ // shift where the caret is moved into the span again. Unlike inline changes to the formatting, any
+ // removal of the format in an empty line should remove it from its entirely, instead of just around
+ // the caret position.
+ let range = selection.getRangeAt(0);
+ let helperTextNode: Text | null = null;
+ const rangeIsCollapsed = range.collapsed;
+ if (rangeIsCollapsed) {
+ let container = range.startContainer as HTMLElement;
+ const tree = [container];
+ for (;;) {
+ const parent = container.parentElement!;
+ if (parent === editorElement || parent.nodeName === "TD") {
+ break;
+ }
+
+ container = parent;
+ tree.push(container);
+ }
+
+ if (isEmpty(container.innerHTML)) {
+ const marker = document.createElement("woltlab-format-marker");
+ range.insertNode(marker);
+
+ // Find the offending span and remove it entirely.
+ tree.forEach((element) => {
+ if (element.nodeName === "SPAN") {
+ if (element.style.getPropertyValue(property)) {
+ DomUtil.unwrapChildNodes(element);
+ }
+ }
+ });
+
+ // Firefox messes up the selection if the ancestor element was removed and there is
+ // an adjacent `<br>` present. Instead of keeping the caret in front of the <br>, it
+ // is implicitly moved behind it.
+ range = document.createRange();
+ range.selectNode(marker);
+ range.collapse(true);
+
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ marker.remove();
+
+ return;
+ }
+
+ // Fill up the range with a zero length whitespace to give the browser
+ // something to strike through. If the range is completely empty, the
+ // "strike" is remembered by the browser, but not actually inserted into
+ // the DOM, causing the next keystroke to magically insert it.
+ helperTextNode = document.createTextNode("\u200B");
+ range.insertNode(helperTextNode);
+ }
+
+ let strikeElements = editorElement.querySelectorAll("strike");
+
+ // remove any <strike> element first, all though there shouldn't be any at all
+ strikeElements.forEach((el) => DomUtil.unwrapChildNodes(el));
+
+ const selectionMarker = getSelectionMarker(editorElement, selection);
+
+ document.execCommand(selectionMarker[1]);
+ if (selectionMarker[0] !== "strike") {
+ strikeElements = editorElement.querySelectorAll(selectionMarker[0]);
+ }
+
+ // Safari 13 sometimes refuses to execute the `strikeThrough` command.
+ if (rangeIsCollapsed && helperTextNode !== null && strikeElements.length === 0) {
+ // Executing the command again will toggle off the previous command that had no
+ // effect anyway, effectively cancelling out the previous call. Only works if the
+ // first call had no effect, otherwise it will enable it.
+ document.execCommand(selectionMarker[1]);
+
+ const tmp = document.createElement(selectionMarker[0]);
+ helperTextNode.parentElement!.insertBefore(tmp, helperTextNode);
+ tmp.appendChild(helperTextNode);
+ }
+
+ strikeElements.forEach((strikeElement: HTMLElement) => {
+ const lastMatchingParent = getLastMatchingParent(strikeElement, editorElement, property);
+
+ if (lastMatchingParent !== null) {
+ handleParentNodes(strikeElement, lastMatchingParent, property);
+ }
+
+ // remove offending elements from child nodes
+ strikeElement.querySelectorAll("span").forEach((span) => {
+ if (span.style.getPropertyValue(property)) {
+ DomUtil.unwrapChildNodes(span);
+ }
+ });
+
+ // remove strike element itself
+ DomUtil.unwrapChildNodes(strikeElement);
+ });
+
+ // search for tags that are still floating around, but are completely empty
+ editorElement.querySelectorAll("span").forEach((element) => {
+ if (element.parentNode && !element.textContent!.length && element.style.getPropertyValue(property) !== "") {
+ if (element.childElementCount === 1 && element.children[0].nodeName === "MARK") {
+ element.parentNode.insertBefore(element.children[0], element);
+ }
+
+ if (element.childElementCount === 0) {
+ element.remove();
+ }
+ }
+ });
+}
--- /dev/null
+/**
+ * Manages html code blocks.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Redactor/Html
+ */
+
+import * as Core from "../../Core";
+import * as EventHandler from "../../Event/Handler";
+import * as Language from "../../Language";
+import { RedactorEditor } from "./Editor";
+
+class UiRedactorHtml {
+ protected readonly _editor: RedactorEditor;
+ protected readonly _elementId: string;
+ protected _pre: HTMLElement | null = null;
+
+ /**
+ * Initializes the source code management.
+ */
+ constructor(editor: RedactorEditor) {
+ this._editor = editor;
+ this._elementId = this._editor.$element[0].id;
+
+ EventHandler.add("com.woltlab.wcf.redactor2", `bbcode_woltlabHtml_${this._elementId}`, (data) =>
+ this._bbcodeCode(data),
+ );
+ EventHandler.add("com.woltlab.wcf.redactor2", `observe_load_${this._elementId}`, () => this._observeLoad());
+
+ // support for active button marking
+ this._editor.opts.activeButtonsStates["woltlab-html"] = "woltlabHtml";
+
+ // bind listeners on init
+ this._observeLoad();
+ }
+
+ /**
+ * Intercepts the insertion of `[woltlabHtml]` tags and uses a native `<pre>` instead.
+ */
+ protected _bbcodeCode(data: { cancel: boolean }): void {
+ data.cancel = true;
+
+ let pre = this._editor.selection.block();
+ if (pre && pre.nodeName === "PRE" && !pre.classList.contains("woltlabHtml")) {
+ return;
+ }
+
+ this._editor.button.toggle({}, "pre", "func", "block.format");
+
+ pre = this._editor.selection.block();
+ if (pre && pre.nodeName === "PRE") {
+ pre.classList.add("woltlabHtml");
+
+ if (pre.childElementCount === 1 && pre.children[0].nodeName === "BR") {
+ // drop superfluous linebreak
+ pre.removeChild(pre.children[0]);
+ }
+
+ this._setTitle(pre);
+
+ // work-around for Safari
+ this._editor.caret.end(pre);
+ }
+ }
+
+ /**
+ * Binds event listeners and sets quote title on both editor
+ * initialization and when switching back from code view.
+ */
+ protected _observeLoad(): void {
+ this._editor.$editor[0].querySelectorAll("pre.woltlabHtml").forEach((pre: HTMLElement) => {
+ this._setTitle(pre);
+ });
+ }
+
+ /**
+ * Sets or updates the code's header title.
+ */
+ protected _setTitle(pre: HTMLElement): void {
+ ["title", "description"].forEach((title) => {
+ const phrase = Language.get(`wcf.editor.html.${title}`);
+
+ if (pre.dataset[title] !== phrase) {
+ pre.dataset[title] = phrase;
+ }
+ });
+ }
+}
+
+Core.enableLegacyInheritance(UiRedactorHtml);
+
+export = UiRedactorHtml;
--- /dev/null
+import DomUtil from "../../Dom/Util";
+import * as Language from "../../Language";
+import UiDialog from "../Dialog";
+import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
+
+type SubmitCallback = () => boolean;
+
+interface LinkOptions {
+ insert: boolean;
+ submitCallback: SubmitCallback;
+}
+
+class UiRedactorLink implements DialogCallbackObject {
+ private boundListener = false;
+ private submitCallback: SubmitCallback;
+
+ open(options: LinkOptions) {
+ UiDialog.open(this);
+
+ UiDialog.setTitle(this, Language.get("wcf.editor.link." + (options.insert ? "add" : "edit")));
+
+ const submitButton = document.getElementById("redactor-modal-button-action")!;
+ submitButton.textContent = Language.get("wcf.global.button." + (options.insert ? "insert" : "save"));
+
+ this.submitCallback = options.submitCallback;
+
+ // Redactor might modify the button, thus we cannot bind it in the dialog's `onSetup()` callback.
+ if (!this.boundListener) {
+ this.boundListener = true;
+
+ submitButton.addEventListener("click", () => this.submit());
+ }
+ }
+
+ private submit(): void {
+ if (this.submitCallback()) {
+ UiDialog.close(this);
+ } else {
+ const url = document.getElementById("redactor-link-url") as HTMLInputElement;
+
+ const errorMessage = url.value.trim() === "" ? "wcf.global.form.error.empty" : "wcf.editor.link.error.invalid";
+ DomUtil.innerError(url, Language.get(errorMessage));
+ }
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "redactorDialogLink",
+ options: {
+ onClose: () => {
+ const url = document.getElementById("redactor-link-url") as HTMLInputElement;
+ const small = url.nextElementSibling;
+ if (small && small.nodeName === "SMALL") {
+ small.remove();
+ }
+ },
+ onSetup: (content) => {
+ const submitButton = content.querySelector(".formSubmit > .buttonPrimary") as HTMLButtonElement;
+
+ if (submitButton !== null) {
+ content.querySelectorAll('input[type="url"], input[type="text"]').forEach((input: HTMLInputElement) => {
+ input.addEventListener("keyup", (event) => {
+ if (event.key === "Enter") {
+ submitButton.click();
+ }
+ });
+ });
+ }
+ },
+ onShow: () => {
+ const url = document.getElementById("redactor-link-url") as HTMLInputElement;
+ url.focus();
+ },
+ },
+ source: `<dl>
+ <dt>
+ <label for="redactor-link-url">${Language.get("wcf.editor.link.url")}</label>
+ </dt>
+ <dd>
+ <input type="url" id="redactor-link-url" class="long">
+ </dd>
+ </dl>
+ <dl>
+ <dt>
+ <label for="redactor-link-url-text">${Language.get("wcf.editor.link.text")}</label>
+ </dt>
+ <dd>
+ <input type="text" id="redactor-link-url-text" class="long">
+ </dd>
+ </dl>
+ <div class="formSubmit">
+ <button id="redactor-modal-button-action" class="buttonPrimary"></button>
+ </div>`,
+ };
+ }
+}
+
+let uiRedactorLink: UiRedactorLink;
+
+export function showDialog(options: LinkOptions): void {
+ if (!uiRedactorLink) {
+ uiRedactorLink = new UiRedactorLink();
+ }
+
+ uiRedactorLink.open(options);
+}
--- /dev/null
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
+import * as Core from "../../Core";
+import * as StringUtil from "../../StringUtil";
+import UiCloseOverlay from "../CloseOverlay";
+import { RedactorEditor, WoltLabEventData } from "./Editor";
+
+interface DropDownPosition {
+ top: number;
+ left: number;
+}
+
+interface Mention {
+ range: Range;
+ selection: Selection;
+}
+
+interface MentionItem {
+ icon: string;
+ label: string;
+ objectID: number;
+}
+
+interface AjaxResponse extends ResponseData {
+ returnValues: MentionItem[];
+}
+
+let _dropdownContainer: HTMLElement | null = null;
+
+const DropDownPixelOffset = 7;
+
+class UiRedactorMention {
+ protected _active = false;
+ protected _dropdownActive = false;
+ protected _dropdownMenu: HTMLOListElement | null = null;
+ protected _itemIndex = 0;
+ protected _lineHeight: number | null = null;
+ protected _mentionStart = "";
+ protected _redactor: RedactorEditor;
+ protected _timer: number | null = null;
+
+ constructor(redactor: RedactorEditor) {
+ this._redactor = redactor;
+
+ redactor.WoltLabEvent.register("keydown", (data) => this._keyDown(data));
+ redactor.WoltLabEvent.register("keyup", (data) => this._keyUp(data));
+
+ UiCloseOverlay.add(`UiRedactorMention-${redactor.core.element()[0].id}`, () => this._hideDropdown());
+ }
+
+ protected _keyDown(data: WoltLabEventData): void {
+ if (!this._dropdownActive) {
+ return;
+ }
+
+ const event = data.event as KeyboardEvent;
+
+ switch (event.key) {
+ case "Enter":
+ this._setUsername(null, this._dropdownMenu!.children[this._itemIndex].children[0] as HTMLElement);
+ break;
+
+ case "ArrowUp":
+ this._selectItem(-1);
+ break;
+
+ case "ArrowDown":
+ this._selectItem(1);
+ break;
+
+ default:
+ this._hideDropdown();
+ return;
+ }
+
+ event.preventDefault();
+ data.cancel = true;
+ }
+
+ protected _keyUp(data: WoltLabEventData): void {
+ const event = data.event as KeyboardEvent;
+
+ // ignore return key
+ if (event.key === "Enter") {
+ this._active = false;
+
+ return;
+ }
+
+ if (this._dropdownActive) {
+ data.cancel = true;
+
+ // ignore arrow up/down
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ return;
+ }
+ }
+
+ const text = this._getTextLineInFrontOfCaret();
+ if (text.length > 0 && text.length < 25) {
+ const match = /@([^,]{3,})$/.exec(text);
+ if (match) {
+ // if mentioning is at text begin or there's a whitespace character
+ // before the '@', everything is fine
+ if (!match.index || /\s/.test(text[match.index - 1])) {
+ this._mentionStart = match[1];
+
+ if (this._timer !== null) {
+ window.clearTimeout(this._timer);
+ this._timer = null;
+ }
+
+ this._timer = window.setTimeout(() => {
+ Ajax.api(this, {
+ parameters: {
+ data: {
+ searchString: this._mentionStart,
+ },
+ },
+ });
+
+ this._timer = null;
+ }, 500);
+ }
+ } else {
+ this._hideDropdown();
+ }
+ } else {
+ this._hideDropdown();
+ }
+ }
+
+ protected _getTextLineInFrontOfCaret(): string {
+ const data = this._selectMention(false);
+ if (data !== null) {
+ return data.range
+ .cloneContents()
+ .textContent!.replace(/\u200B/g, "")
+ .replace(/\u00A0/g, " ")
+ .trim();
+ }
+
+ return "";
+ }
+
+ protected _getDropdownMenuPosition(): DropDownPosition | null {
+ const data = this._selectMention();
+ if (data === null) {
+ return null;
+ }
+
+ this._redactor.selection.save();
+
+ data.selection.removeAllRanges();
+ data.selection.addRange(data.range);
+
+ // get the offsets of the bounding box of current text selection
+ const rect = data.selection.getRangeAt(0).getBoundingClientRect();
+ const offsets: DropDownPosition = {
+ top: Math.round(rect.bottom) + (window.scrollY || window.pageYOffset),
+ left: Math.round(rect.left) + document.body.scrollLeft,
+ };
+
+ if (this._lineHeight === null) {
+ this._lineHeight = Math.round(rect.bottom - rect.top);
+ }
+
+ // restore caret position
+ this._redactor.selection.restore();
+
+ return offsets;
+ }
+
+ protected _setUsername(event: MouseEvent | null, item?: HTMLElement): void {
+ if (event) {
+ event.preventDefault();
+ item = event.currentTarget as HTMLElement;
+ }
+
+ const data = this._selectMention();
+ if (data === null) {
+ this._hideDropdown();
+
+ return;
+ }
+
+ // allow redactor to undo this
+ this._redactor.buffer.set();
+
+ data.selection.removeAllRanges();
+ data.selection.addRange(data.range);
+
+ let range = window.getSelection()!.getRangeAt(0);
+ range.deleteContents();
+ range.collapse(true);
+
+ // Mentions only allow for one whitespace per match, putting the username in apostrophes
+ // will allow an arbitrary number of spaces.
+ let username = item!.dataset.username!.trim();
+ if (username.split(/\s/g).length > 2) {
+ username = "'" + username.replace(/'/g, "''") + "'";
+ }
+
+ const text = document.createTextNode("@" + username + "\u00A0");
+ range.insertNode(text);
+
+ range = document.createRange();
+ range.selectNode(text);
+ range.collapse(false);
+
+ data.selection.removeAllRanges();
+ data.selection.addRange(range);
+
+ this._hideDropdown();
+ }
+
+ protected _selectMention(skipCheck?: boolean): Mention | null {
+ const selection = window.getSelection()!;
+ if (!selection.rangeCount || !selection.isCollapsed) {
+ return null;
+ }
+
+ let container = selection.anchorNode as HTMLElement;
+ if (container.nodeType === Node.TEXT_NODE) {
+ // work-around for Firefox after suggestions have been presented
+ container = container.parentElement!;
+ }
+
+ // check if there is an '@' within the current range
+ if (container.textContent!.indexOf("@") === -1) {
+ return null;
+ }
+
+ // check if we're inside code or quote blocks
+ const editor = this._redactor.core.editor()[0];
+ while (container && container !== editor) {
+ if (["PRE", "WOLTLAB-QUOTE"].indexOf(container.nodeName) !== -1) {
+ return null;
+ }
+
+ container = container.parentElement!;
+ }
+
+ let range = selection.getRangeAt(0);
+ let endContainer = range.startContainer;
+ let endOffset = range.startOffset;
+
+ // find the appropriate end location
+ while (endContainer.nodeType === Node.ELEMENT_NODE) {
+ if (endOffset === 0 && endContainer.childNodes.length === 0) {
+ // invalid start location
+ return null;
+ }
+
+ // startOffset for elements will always be after a node index
+ // or at the very start, which means if there is only text node
+ // and the caret is after it, startOffset will equal `1`
+ endContainer = endContainer.childNodes[endOffset ? endOffset - 1 : 0];
+ if (endOffset > 0) {
+ if (endContainer.nodeType === Node.TEXT_NODE) {
+ endOffset = endContainer.textContent!.length;
+ } else {
+ endOffset = endContainer.childNodes.length;
+ }
+ }
+ }
+
+ let startContainer = endContainer;
+ let startOffset = -1;
+ while (startContainer !== null) {
+ if (startContainer.nodeType !== Node.TEXT_NODE) {
+ return null;
+ }
+
+ if (startContainer.textContent!.indexOf("@") !== -1) {
+ startOffset = startContainer.textContent!.lastIndexOf("@");
+
+ break;
+ }
+
+ startContainer = startContainer.previousSibling!;
+ }
+
+ if (startOffset === -1) {
+ // there was a non-text node that was in our way
+ return null;
+ }
+
+ try {
+ // mark the entire text, starting from the '@' to the current cursor position
+ range = document.createRange();
+ range.setStart(startContainer, startOffset);
+ range.setEnd(endContainer, endOffset);
+ } catch (e) {
+ window.console.debug(e);
+ return null;
+ }
+
+ if (skipCheck === false) {
+ // check if the `@` occurs at the very start of the container
+ // or at least has a whitespace in front of it
+ let text = "";
+ if (startOffset) {
+ text = startContainer.textContent!.substr(0, startOffset);
+ }
+
+ while ((startContainer = startContainer.previousSibling!)) {
+ if (startContainer.nodeType === Node.TEXT_NODE) {
+ text = startContainer.textContent! + text;
+ } else {
+ break;
+ }
+ }
+
+ if (/\S$/.test(text.replace(/\u200B/g, ""))) {
+ return null;
+ }
+ } else {
+ // check if new range includes the mention text
+ if (
+ range
+ .cloneContents()
+ .textContent!.replace(/\u200B/g, "")
+ .replace(/\u00A0/g, "")
+ .trim()
+ .replace(/^@/, "") !== this._mentionStart
+ ) {
+ // string mismatch
+ return null;
+ }
+ }
+
+ return {
+ range: range,
+ selection: selection,
+ };
+ }
+
+ protected _updateDropdownPosition(): void {
+ const offset = this._getDropdownMenuPosition();
+ if (offset === null) {
+ this._hideDropdown();
+
+ return;
+ }
+ offset.top += DropDownPixelOffset;
+
+ const dropdownMenu = this._dropdownMenu!;
+ dropdownMenu.style.setProperty("left", `${offset.left}px`, "");
+ dropdownMenu.style.setProperty("top", `${offset.top}px`, "");
+
+ this._selectItem(0);
+
+ if (offset.top + dropdownMenu.offsetHeight + 10 > window.innerHeight + (window.scrollY || window.pageYOffset)) {
+ const top = offset.top - dropdownMenu.offsetHeight - 2 * this._lineHeight! + DropDownPixelOffset;
+ dropdownMenu.style.setProperty("top", `${top}px`, "");
+ }
+ }
+
+ protected _selectItem(step: number): void {
+ const dropdownMenu = this._dropdownMenu!;
+
+ // find currently active item
+ const item = dropdownMenu.querySelector(".active");
+ if (item !== null) {
+ item.classList.remove("active");
+ }
+
+ this._itemIndex += step;
+ if (this._itemIndex < 0) {
+ this._itemIndex = dropdownMenu.childElementCount - 1;
+ } else if (this._itemIndex >= dropdownMenu.childElementCount) {
+ this._itemIndex = 0;
+ }
+
+ dropdownMenu.children[this._itemIndex].classList.add("active");
+ }
+
+ protected _hideDropdown(): void {
+ if (this._dropdownMenu !== null) {
+ this._dropdownMenu.classList.remove("dropdownOpen");
+ }
+ this._dropdownActive = false;
+ this._itemIndex = 0;
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "getSearchResultList",
+ className: "wcf\\data\\user\\UserAction",
+ interfaceName: "wcf\\data\\ISearchAction",
+ parameters: {
+ data: {
+ includeUserGroups: true,
+ scope: "mention",
+ },
+ },
+ },
+ silent: true,
+ };
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ if (!Array.isArray(data.returnValues) || !data.returnValues.length) {
+ this._hideDropdown();
+
+ return;
+ }
+
+ if (this._dropdownMenu === null) {
+ this._dropdownMenu = document.createElement("ol");
+ this._dropdownMenu.className = "dropdownMenu";
+
+ if (_dropdownContainer === null) {
+ _dropdownContainer = document.createElement("div");
+ _dropdownContainer.className = "dropdownMenuContainer";
+ document.body.appendChild(_dropdownContainer);
+ }
+
+ _dropdownContainer.appendChild(this._dropdownMenu);
+ }
+
+ this._dropdownMenu.innerHTML = "";
+
+ data.returnValues.forEach((item) => {
+ const listItem = document.createElement("li");
+ const link = document.createElement("a");
+ link.addEventListener("mousedown", (ev) => this._setUsername(ev));
+ link.className = "box16";
+ link.innerHTML = `<span>${item.icon}</span> <span>${StringUtil.escapeHTML(item.label)}</span>`;
+ link.dataset.userId = item.objectID.toString();
+ link.dataset.username = item.label;
+
+ listItem.appendChild(link);
+ this._dropdownMenu!.appendChild(listItem);
+ });
+
+ this._dropdownMenu.classList.add("dropdownOpen");
+ this._dropdownActive = true;
+
+ this._updateDropdownPosition();
+ }
+}
+
+Core.enableLegacyInheritance(UiRedactorMention);
+
+export = UiRedactorMention;
--- /dev/null
+/**
+ * Converts `<woltlab-metacode>` into the bbcode representation.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Redactor/Metacode
+ */
+
+import * as EventHandler from "../../Event/Handler";
+import DomUtil from "../../Dom/Util";
+
+type Attributes = string[];
+
+/**
+ * Returns a text node representing the opening bbcode tag.
+ */
+function getOpeningTag(name: string, attributes: Attributes): Text {
+ let buffer = "[" + name;
+ if (attributes.length) {
+ buffer += "=";
+ buffer += attributes.map((attribute) => `'${attribute}'`).join(",");
+ }
+
+ return document.createTextNode(buffer + "]");
+}
+
+/**
+ * Returns a text node representing the closing bbcode tag.
+ */
+function getClosingTag(name: string): Text {
+ return document.createTextNode(`[/${name}]`);
+}
+
+/**
+ * Returns the first paragraph of provided element. If there are no children or
+ * the first child is not a paragraph, a new paragraph is created and inserted
+ * as first child.
+ */
+function getFirstParagraph(element: HTMLElement): HTMLElement {
+ let paragraph: HTMLElement;
+ if (element.childElementCount === 0) {
+ paragraph = document.createElement("p");
+ element.appendChild(paragraph);
+ } else {
+ const firstChild = element.children[0] as HTMLElement;
+
+ if (firstChild.nodeName === "P") {
+ paragraph = firstChild;
+ } else {
+ paragraph = document.createElement("p");
+ element.insertBefore(paragraph, firstChild);
+ }
+ }
+
+ return paragraph;
+}
+
+/**
+ * Returns the last paragraph of provided element. If there are no children or
+ * the last child is not a paragraph, a new paragraph is created and inserted
+ * as last child.
+ */
+function getLastParagraph(element: HTMLElement): HTMLElement {
+ const count = element.childElementCount;
+
+ let paragraph: HTMLElement;
+ if (count === 0) {
+ paragraph = document.createElement("p");
+ element.appendChild(paragraph);
+ } else {
+ const lastChild = element.children[count - 1] as HTMLElement;
+
+ if (lastChild.nodeName === "P") {
+ paragraph = lastChild;
+ } else {
+ paragraph = document.createElement("p");
+ element.appendChild(paragraph);
+ }
+ }
+
+ return paragraph;
+}
+
+/**
+ * Parses the attributes string.
+ */
+function parseAttributes(attributes: string): Attributes {
+ try {
+ attributes = JSON.parse(atob(attributes));
+ } catch (e) {
+ /* invalid base64 data or invalid json */
+ }
+
+ if (!Array.isArray(attributes)) {
+ return [];
+ }
+
+ return attributes.map((attribute: string | number) => {
+ return attribute.toString().replace(/^'(.*)'$/, "$1");
+ });
+}
+
+export function convertFromHtml(editorId: string, html: string): string {
+ const div = document.createElement("div");
+ div.innerHTML = html;
+
+ div.querySelectorAll("woltlab-metacode").forEach((metacode: HTMLElement) => {
+ const name = metacode.dataset.name!;
+ const attributes = parseAttributes(metacode.dataset.attributes || "");
+
+ const data = {
+ attributes: attributes,
+ cancel: false,
+ metacode: metacode,
+ };
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", `metacode_${name}_${editorId}`, data);
+ if (data.cancel) {
+ return;
+ }
+
+ const tagOpen = getOpeningTag(name, attributes);
+ const tagClose = getClosingTag(name);
+
+ if (metacode.parentElement === div) {
+ const paragraph = getFirstParagraph(metacode);
+ paragraph.insertBefore(tagOpen, paragraph.firstChild);
+ getLastParagraph(metacode).appendChild(tagClose);
+ } else {
+ metacode.insertBefore(tagOpen, metacode.firstChild);
+ metacode.appendChild(tagClose);
+ }
+
+ DomUtil.unwrapChildNodes(metacode);
+ });
+
+ // convert `<kbd>…</kbd>` to `[tt]…[/tt]`
+ div.querySelectorAll("kbd").forEach((inlineCode) => {
+ inlineCode.insertBefore(document.createTextNode("[tt]"), inlineCode.firstChild);
+ inlineCode.appendChild(document.createTextNode("[/tt]"));
+
+ DomUtil.unwrapChildNodes(inlineCode);
+ });
+
+ return div.innerHTML;
+}
--- /dev/null
+/**
+ * Converts `<woltlab-metacode>` into the bbcode representation.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Redactor/Page
+ */
+
+import * as Core from "../../Core";
+import * as UiPageSearch from "../Page/Search";
+import { RedactorEditor } from "./Editor";
+
+class UiRedactorPage {
+ protected _editor: RedactorEditor;
+
+ constructor(editor: RedactorEditor, button: HTMLAnchorElement) {
+ this._editor = editor;
+
+ button.addEventListener("click", (ev) => this._click(ev));
+ }
+
+ protected _click(event: MouseEvent): void {
+ event.preventDefault();
+
+ UiPageSearch.open((pageId) => this._insert(pageId));
+ }
+
+ protected _insert(pageId: string): void {
+ this._editor.buffer.set();
+
+ this._editor.insert.text(`[wsp='${pageId}'][/wsp]`);
+ }
+}
+
+Core.enableLegacyInheritance(UiRedactorPage);
+
+export = UiRedactorPage;
--- /dev/null
+/**
+ * Helper class to deal with clickable block headers using the pseudo
+ * `::before` element.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Redactor/PseudoHeader
+ */
+
+/**
+ * Returns the height within a click should be treated as a click
+ * within the block element's title. This method expects that the
+ * `::before` element is used and that removing the attribute
+ * `data-title` does cause the title to collapse.
+ */
+export function getHeight(element: HTMLElement): number {
+ let height = ~~window.getComputedStyle(element).paddingTop.replace(/px$/, "");
+
+ const styles = window.getComputedStyle(element, "::before");
+ height += ~~styles.paddingTop.replace(/px$/, "");
+ height += ~~styles.paddingBottom.replace(/px$/, "");
+
+ let titleHeight = ~~styles.height.replace(/px$/, "");
+ if (titleHeight === 0) {
+ // firefox returns garbage for pseudo element height
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=925694
+
+ titleHeight = element.scrollHeight;
+ element.classList.add("redactorCalcHeight");
+ titleHeight -= element.scrollHeight;
+ element.classList.remove("redactorCalcHeight");
+ }
+
+ height += titleHeight;
+
+ return height;
+}
--- /dev/null
+/**
+ * Manages quotes.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Redactor/Quote
+ */
+
+import * as Core from "../../Core";
+import DomUtil from "../../Dom/Util";
+import * as EventHandler from "../../Event/Handler";
+import * as Language from "../../Language";
+import * as StringUtil from "../../StringUtil";
+import UiDialog from "../Dialog";
+import { DialogCallbackSetup } from "../Dialog/Data";
+import { RedactorEditor } from "./Editor";
+import * as UiRedactorMetacode from "./Metacode";
+import * as UiRedactorPseudoHeader from "./PseudoHeader";
+
+interface QuoteData {
+ author: string;
+ content: string;
+ isText: boolean;
+ link: string;
+}
+
+let _headerHeight = 0;
+
+class UiRedactorQuote {
+ protected readonly _editor: RedactorEditor;
+ protected readonly _elementId: string;
+ protected _quote: HTMLElement | null = null;
+
+ /**
+ * Initializes the quote management.
+ */
+ constructor(editor: RedactorEditor, button: JQuery) {
+ this._editor = editor;
+ this._elementId = this._editor.$element[0].id;
+
+ EventHandler.add("com.woltlab.wcf.redactor2", `observe_load_${this._elementId}`, () => this._observeLoad());
+
+ this._editor.button.addCallback(button, this._click.bind(this));
+
+ // bind listeners on init
+ this._observeLoad();
+
+ // quote manager
+ EventHandler.add("com.woltlab.wcf.redactor2", `insertQuote_${this._elementId}`, (data) => this._insertQuote(data));
+ }
+
+ /**
+ * Inserts a quote.
+ */
+ protected _insertQuote(data: QuoteData): void {
+ if (this._editor.WoltLabSource.isActive()) {
+ return;
+ }
+
+ EventHandler.fire("com.woltlab.wcf.redactor2", "showEditor");
+
+ const editor = this._editor.core.editor()[0];
+ this._editor.selection.restore();
+
+ this._editor.buffer.set();
+
+ // caret must be within a `<p>`, if it is not: move it
+ let block = this._editor.selection.block();
+ if (block === false) {
+ this._editor.focus.end();
+ block = this._editor.selection.block() as HTMLElement;
+ }
+
+ while (block && block.parentElement !== editor) {
+ block = block.parentElement!;
+ }
+
+ const quote = document.createElement("woltlab-quote");
+ quote.dataset.author = data.author;
+ quote.dataset.link = data.link;
+
+ let content = data.content;
+ if (data.isText) {
+ content = StringUtil.escapeHTML(content);
+ content = `<p>${content}</p>`;
+ content = content.replace(/\n\n/g, "</p><p>");
+ content = content.replace(/\n/g, "<br>");
+ } else {
+ content = UiRedactorMetacode.convertFromHtml(this._editor.$element[0].id, content);
+ }
+
+ // bypass the editor as `insert.html()` doesn't like us
+ quote.innerHTML = content;
+
+ const blockParent = block.parentElement!;
+ blockParent.insertBefore(quote, block.nextSibling);
+
+ if (block.nodeName === "P" && (block.innerHTML === "<br>" || block.innerHTML.replace(/\u200B/g, "") === "")) {
+ blockParent.removeChild(block);
+ }
+
+ // avoid adjacent blocks that are not paragraphs
+ let sibling = quote.previousElementSibling;
+ if (sibling && sibling.nodeName !== "P") {
+ sibling = document.createElement("p");
+ sibling.textContent = "\u200B";
+ quote.insertAdjacentElement("beforebegin", sibling);
+ }
+
+ this._editor.WoltLabCaret.paragraphAfterBlock(quote);
+
+ this._editor.buffer.set();
+ }
+
+ /**
+ * Toggles the quote block on button click.
+ */
+ protected _click(): void {
+ this._editor.button.toggle({}, "woltlab-quote", "func", "block.format");
+
+ const quote = this._editor.selection.block();
+ if (quote && quote.nodeName === "WOLTLAB-QUOTE") {
+ this._setTitle(quote);
+
+ quote.addEventListener("click", (ev) => this._edit(ev));
+
+ // work-around for Safari
+ this._editor.caret.end(quote);
+ }
+ }
+
+ /**
+ * Binds event listeners and sets quote title on both editor
+ * initialization and when switching back from code view.
+ */
+ protected _observeLoad(): void {
+ document.querySelectorAll("woltlab-quote").forEach((quote: HTMLElement) => {
+ quote.addEventListener("mousedown", (ev) => this._edit(ev));
+ this._setTitle(quote);
+ });
+ }
+
+ /**
+ * Opens the dialog overlay to edit the quote's properties.
+ */
+ protected _edit(event: MouseEvent): void {
+ const quote = event.currentTarget as HTMLElement;
+
+ if (_headerHeight === 0) {
+ _headerHeight = UiRedactorPseudoHeader.getHeight(quote);
+ }
+
+ // check if the click hit the header
+ const offset = DomUtil.offset(quote);
+ if (event.pageY > offset.top && event.pageY < offset.top + _headerHeight) {
+ event.preventDefault();
+
+ this._editor.selection.save();
+ this._quote = quote;
+
+ UiDialog.open(this);
+ }
+ }
+
+ /**
+ * Saves the changes to the quote's properties.
+ *
+ * @protected
+ */
+ _dialogSubmit(): void {
+ const id = `redactor-quote-${this._elementId}`;
+ const urlInput = document.getElementById(`${id}-url`) as HTMLInputElement;
+
+ const url = urlInput.value.replace(/\u200B/g, "").trim();
+ // simple test to check if it at least looks like it could be a valid url
+ if (url.length && !/^https?:\/\/[^/]+/.test(url)) {
+ DomUtil.innerError(urlInput, Language.get("wcf.editor.quote.url.error.invalid"));
+
+ return;
+ } else {
+ DomUtil.innerError(urlInput, false);
+ }
+
+ const quote = this._quote!;
+
+ // set author
+ const author = document.getElementById(id + "-author") as HTMLInputElement;
+ quote.dataset.author = author.value;
+
+ // set url
+ quote.dataset.link = url;
+
+ this._setTitle(quote);
+ this._editor.caret.after(quote);
+
+ UiDialog.close(this);
+ }
+
+ /**
+ * Sets or updates the quote's header title.
+ */
+ protected _setTitle(quote: HTMLElement): void {
+ const title = Language.get("wcf.editor.quote.title", {
+ author: quote.dataset.author!,
+ url: quote.dataset.url!,
+ });
+
+ if (quote.dataset.title !== title) {
+ quote.dataset.title = title;
+ }
+ }
+
+ protected _delete(event: MouseEvent): void {
+ event.preventDefault();
+
+ const quote = this._quote!;
+
+ let caretEnd = quote.nextElementSibling || quote.previousElementSibling;
+ if (caretEnd === null && quote.parentElement !== this._editor.core.editor()[0]) {
+ caretEnd = quote.parentElement;
+ }
+
+ if (caretEnd === null) {
+ this._editor.code.set("");
+ this._editor.focus.end();
+ } else {
+ quote.remove();
+ this._editor.caret.end(caretEnd);
+ }
+
+ UiDialog.close(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ const id = `redactor-quote-${this._elementId}`;
+ const idAuthor = `${id}-author`;
+ const idButtonDelete = `${id}-button-delete`;
+ const idButtonSave = `${id}-button-save`;
+ const idUrl = `${id}-url`;
+
+ return {
+ id: id,
+ options: {
+ onClose: () => {
+ this._editor.selection.restore();
+
+ UiDialog.destroy(this);
+ },
+
+ onSetup: () => {
+ const button = document.getElementById(idButtonDelete) as HTMLButtonElement;
+ button.addEventListener("click", (ev) => this._delete(ev));
+ },
+
+ onShow: () => {
+ const author = document.getElementById(idAuthor) as HTMLInputElement;
+ author.value = this._quote!.dataset.author || "";
+
+ const url = document.getElementById(idUrl) as HTMLInputElement;
+ url.value = this._quote!.dataset.link || "";
+ },
+
+ title: Language.get("wcf.editor.quote.edit"),
+ },
+ source: `<div class="section">
+ <dl>
+ <dt>
+ <label for="${idAuthor}">${Language.get("wcf.editor.quote.author")}</label>
+ </dt>
+ <dd>
+ <input type="text" id="${idAuthor}" class="long" data-dialog-submit-on-enter="true">
+ </dd>
+ </dl>
+ <dl>
+ <dt>
+ <label for="${idUrl}">${Language.get("wcf.editor.quote.url")}</label>
+ </dt>
+ <dd>
+ <input type="text" id="${idUrl}" class="long" data-dialog-submit-on-enter="true">
+ <small>${Language.get("wcf.editor.quote.url.description")}</small>
+ </dd>
+ </dl>
+ </div>
+ <div class="formSubmit">
+ <button id="${idButtonSave}" class="buttonPrimary" data-type="submit">${Language.get(
+ "wcf.global.button.save",
+ )}</button>
+ <button id="${idButtonDelete}">${Language.get("wcf.global.button.delete")}</button>
+ </div>`,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiRedactorQuote);
+
+export = UiRedactorQuote;
--- /dev/null
+/**
+ * Manages spoilers.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Redactor/Spoiler
+ */
+
+import * as Core from "../../Core";
+import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
+import DomUtil from "../../Dom/Util";
+import * as EventHandler from "../../Event/Handler";
+import * as Language from "../../Language";
+import UiDialog from "../Dialog";
+import { RedactorEditor, WoltLabEventData } from "./Editor";
+import * as UiRedactorPseudoHeader from "./PseudoHeader";
+
+let _headerHeight = 0;
+
+class UiRedactorSpoiler implements DialogCallbackObject {
+ protected readonly _editor: RedactorEditor;
+ protected readonly _elementId: string;
+ protected _spoiler: HTMLElement | null = null;
+
+ /**
+ * Initializes the spoiler management.
+ */
+ constructor(editor: RedactorEditor) {
+ this._editor = editor;
+ this._elementId = this._editor.$element[0].id;
+
+ EventHandler.add("com.woltlab.wcf.redactor2", `bbcode_spoiler_${this._elementId}`, (data) =>
+ this._bbcodeSpoiler(data),
+ );
+ EventHandler.add("com.woltlab.wcf.redactor2", `observe_load_${this._elementId}`, () => this._observeLoad());
+
+ // bind listeners on init
+ this._observeLoad();
+ }
+
+ /**
+ * Intercepts the insertion of `[spoiler]` tags and uses
+ * the custom `<woltlab-spoiler>` element instead.
+ */
+ protected _bbcodeSpoiler(data: WoltLabEventData): void {
+ data.cancel = true;
+
+ this._editor.button.toggle({}, "woltlab-spoiler", "func", "block.format");
+
+ let spoiler = this._editor.selection.block();
+ if (spoiler) {
+ // iOS Safari might set the caret inside the spoiler.
+ if (spoiler.nodeName === "P") {
+ spoiler = spoiler.parentElement!;
+ }
+
+ if (spoiler.nodeName === "WOLTLAB-SPOILER") {
+ this._setTitle(spoiler);
+
+ spoiler.addEventListener("click", (ev) => this._edit(ev));
+
+ // work-around for Safari
+ this._editor.caret.end(spoiler);
+ }
+ }
+ }
+
+ /**
+ * Binds event listeners and sets quote title on both editor
+ * initialization and when switching back from code view.
+ */
+ protected _observeLoad(): void {
+ this._editor.$editor[0].querySelectorAll("woltlab-spoiler").forEach((spoiler: HTMLElement) => {
+ spoiler.addEventListener("mousedown", (ev) => this._edit(ev));
+ this._setTitle(spoiler);
+ });
+ }
+
+ /**
+ * Opens the dialog overlay to edit the spoiler's properties.
+ */
+ protected _edit(event: MouseEvent): void {
+ const spoiler = event.currentTarget as HTMLElement;
+
+ if (_headerHeight === 0) {
+ _headerHeight = UiRedactorPseudoHeader.getHeight(spoiler);
+ }
+
+ // check if the click hit the header
+ const offset = DomUtil.offset(spoiler);
+ if (event.pageY > offset.top && event.pageY < offset.top + _headerHeight) {
+ event.preventDefault();
+
+ this._editor.selection.save();
+ this._spoiler = spoiler;
+
+ UiDialog.open(this);
+ }
+ }
+
+ /**
+ * Saves the changes to the spoiler's properties.
+ *
+ * @protected
+ */
+ _dialogSubmit(): void {
+ const spoiler = this._spoiler!;
+
+ const label = document.getElementById("redactor-spoiler-" + this._elementId + "-label") as HTMLInputElement;
+ spoiler.dataset.label = label.value;
+
+ this._setTitle(spoiler);
+ this._editor.caret.after(spoiler);
+
+ UiDialog.close(this);
+ }
+
+ /**
+ * Sets or updates the spoiler's header title.
+ */
+ protected _setTitle(spoiler: HTMLElement): void {
+ const title = Language.get("wcf.editor.spoiler.title", { label: spoiler.dataset.label || "" });
+
+ if (spoiler.dataset.title !== title) {
+ spoiler.dataset.title = title;
+ }
+ }
+
+ protected _delete(event: MouseEvent): void {
+ event.preventDefault();
+
+ const spoiler = this._spoiler!;
+
+ let caretEnd = spoiler.nextElementSibling || spoiler.previousElementSibling;
+ if (caretEnd === null && spoiler.parentElement !== this._editor.core.editor()[0]) {
+ caretEnd = spoiler.parentElement;
+ }
+
+ if (caretEnd === null) {
+ this._editor.code.set("");
+ this._editor.focus.end();
+ } else {
+ spoiler.remove();
+ this._editor.caret.end(caretEnd);
+ }
+
+ UiDialog.close(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ const id = `redactor-spoiler-${this._elementId}`;
+ const idButtonDelete = `${id}-button-delete`;
+ const idButtonSave = `${id}-button-save`;
+ const idLabel = `${id}-label`;
+
+ return {
+ id: id,
+ options: {
+ onClose: () => {
+ this._editor.selection.restore();
+
+ UiDialog.destroy(this);
+ },
+
+ onSetup: () => {
+ const button = document.getElementById(idButtonDelete) as HTMLButtonElement;
+ button.addEventListener("click", (ev) => this._delete(ev));
+ },
+
+ onShow: () => {
+ const label = document.getElementById(idLabel) as HTMLInputElement;
+ label.value = this._spoiler!.dataset.label || "";
+ },
+
+ title: Language.get("wcf.editor.spoiler.edit"),
+ },
+ source: `<div class="section">
+ <dl>
+ <dt>
+ <label for="${idLabel}">${Language.get("wcf.editor.spoiler.label")}</label>
+ </dt>
+ <dd>
+ <input type="text" id="${idLabel}" class="long" data-dialog-submit-on-enter="true">
+ <small>${Language.get("wcf.editor.spoiler.label.description")}</small>
+ </dd>
+ </dl>
+ </div>
+ <div class="formSubmit">
+ <button id="${idButtonSave}" class="buttonPrimary" data-type="submit">${Language.get(
+ "wcf.global.button.save",
+ )}</button>
+ <button id="${idButtonDelete}">${Language.get("wcf.global.button.delete")}</button>
+ </div>`,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiRedactorSpoiler);
+
+export = UiRedactorSpoiler;
--- /dev/null
+import * as Language from "../../Language";
+import UiDialog from "../Dialog";
+import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
+
+type CallbackSubmit = () => void;
+
+interface TableOptions {
+ submitCallback: CallbackSubmit;
+}
+
+class UiRedactorTable implements DialogCallbackObject {
+ protected callbackSubmit: CallbackSubmit;
+
+ open(options: TableOptions): void {
+ UiDialog.open(this);
+
+ this.callbackSubmit = options.submitCallback;
+ }
+
+ _dialogSubmit(): void {
+ // check if rows and cols are within the boundaries
+ let isValid = true;
+ ["rows", "cols"].forEach((type) => {
+ const input = document.getElementById("redactor-table-" + type) as HTMLInputElement;
+ if (+input.value < 1 || +input.value > 100) {
+ isValid = false;
+ }
+ });
+
+ if (!isValid) {
+ return;
+ }
+
+ this.callbackSubmit();
+
+ UiDialog.close(this);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "redactorDialogTable",
+ options: {
+ onShow: () => {
+ const rows = document.getElementById("redactor-table-rows") as HTMLInputElement;
+ rows.value = "2";
+
+ const cols = document.getElementById("redactor-table-cols") as HTMLInputElement;
+ cols.value = "3";
+ },
+
+ title: Language.get("wcf.editor.table.insertTable"),
+ },
+ source: `<dl>
+ <dt>
+ <label for="redactor-table-rows">${Language.get("wcf.editor.table.rows")}</label>
+ </dt>
+ <dd>
+ <input type="number" id="redactor-table-rows" class="small" min="1" max="100" value="2" data-dialog-submit-on-enter="true">
+ </dd>
+ </dl>
+ <dl>
+ <dt>
+ <label for="redactor-table-cols">${Language.get("wcf.editor.table.cols")}</label>
+ </dt>
+ <dd>
+ <input type="number" id="redactor-table-cols" class="small" min="1" max="100" value="3" data-dialog-submit-on-enter="true">
+ </dd>
+ </dl>
+ <div class="formSubmit">
+ <button id="redactor-modal-button-action" class="buttonPrimary" data-type="submit">${Language.get(
+ "wcf.global.button.insert",
+ )}</button>
+ </div>`,
+ };
+ }
+}
+
+let uiRedactorTable: UiRedactorTable;
+
+export function showDialog(options: TableOptions): void {
+ if (!uiRedactorTable) {
+ uiRedactorTable = new UiRedactorTable();
+ }
+
+ uiRedactorTable.open(options);
+}
--- /dev/null
+/**
+ * Provides consistent support for media queries and body scrolling.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Ui/Screen (alias)
+ * @module WoltLabSuite/Core/Ui/Screen
+ */
+
+import * as Core from "../Core";
+import * as Environment from "../Environment";
+
+const _mql = new Map<string, MediaQueryData>();
+
+let _scrollDisableCounter = 0;
+let _scrollOffsetFrom: "body" | "documentElement";
+let _scrollTop = 0;
+let _pageOverlayCounter = 0;
+
+const _mqMap = new Map<string, string>(
+ Object.entries({
+ "screen-xs": "(max-width: 544px)" /* smartphone */,
+ "screen-sm": "(min-width: 545px) and (max-width: 768px)" /* tablet (portrait) */,
+ "screen-sm-down": "(max-width: 768px)" /* smartphone + tablet (portrait) */,
+ "screen-sm-up": "(min-width: 545px)" /* tablet (portrait) + tablet (landscape) + desktop */,
+ "screen-sm-md": "(min-width: 545px) and (max-width: 1024px)" /* tablet (portrait) + tablet (landscape) */,
+ "screen-md": "(min-width: 769px) and (max-width: 1024px)" /* tablet (landscape) */,
+ "screen-md-down": "(max-width: 1024px)" /* smartphone + tablet (portrait) + tablet (landscape) */,
+ "screen-md-up": "(min-width: 769px)" /* tablet (landscape) + desktop */,
+ "screen-lg": "(min-width: 1025px)" /* desktop */,
+ "screen-lg-only": "(min-width: 1025px) and (max-width: 1280px)",
+ "screen-lg-down": "(max-width: 1280px)",
+ "screen-xl": "(min-width: 1281px)",
+ }),
+);
+
+// Microsoft Edge rewrites the media queries to whatever it
+// pleases, causing the input and output query to mismatch
+const _mqMapEdge = new Map<string, string>();
+
+/**
+ * Registers event listeners for media query match/unmatch.
+ *
+ * The `callbacks` object may contain the following keys:
+ * - `match`, triggered when media query matches
+ * - `unmatch`, triggered when media query no longer matches
+ * - `setup`, invoked when media query first matches
+ *
+ * Returns a UUID that is used to internal identify the callbacks, can be used
+ * to remove binding by calling the `remove` method.
+ */
+export function on(query: string, callbacks: Partial<Callbacks>): string {
+ const uuid = Core.getUuid(),
+ queryObject = _getQueryObject(query);
+
+ if (typeof callbacks.match === "function") {
+ queryObject.callbacksMatch.set(uuid, callbacks.match);
+ }
+
+ if (typeof callbacks.unmatch === "function") {
+ queryObject.callbacksUnmatch.set(uuid, callbacks.unmatch);
+ }
+
+ if (typeof callbacks.setup === "function") {
+ if (queryObject.mql.matches) {
+ callbacks.setup();
+ } else {
+ queryObject.callbacksSetup.set(uuid, callbacks.setup);
+ }
+ }
+
+ return uuid;
+}
+
+/**
+ * Removes all listeners identified by their common UUID.
+ */
+export function remove(query: string, uuid: string): void {
+ const queryObject = _getQueryObject(query);
+
+ queryObject.callbacksMatch.delete(uuid);
+ queryObject.callbacksUnmatch.delete(uuid);
+ queryObject.callbacksSetup.delete(uuid);
+}
+
+/**
+ * Returns a boolean value if a media query expression currently matches.
+ */
+export function is(query: string): boolean {
+ return _getQueryObject(query).mql.matches;
+}
+
+/**
+ * Disables scrolling of body element.
+ */
+export function scrollDisable(): void {
+ if (_scrollDisableCounter === 0) {
+ _scrollTop = document.body.scrollTop;
+ _scrollOffsetFrom = "body";
+ if (!_scrollTop) {
+ _scrollTop = document.documentElement.scrollTop;
+ _scrollOffsetFrom = "documentElement";
+ }
+
+ const pageContainer = document.getElementById("pageContainer")!;
+
+ // setting translateY causes Mobile Safari to snap
+ if (Environment.platform() === "ios") {
+ pageContainer.style.setProperty("position", "relative", "");
+ pageContainer.style.setProperty("top", `-${_scrollTop}px`, "");
+ } else {
+ pageContainer.style.setProperty("margin-top", `-${_scrollTop}px`, "");
+ }
+
+ document.documentElement.classList.add("disableScrolling");
+ }
+
+ _scrollDisableCounter++;
+}
+
+/**
+ * Re-enables scrolling of body element.
+ */
+export function scrollEnable(): void {
+ if (_scrollDisableCounter) {
+ _scrollDisableCounter--;
+
+ if (_scrollDisableCounter === 0) {
+ document.documentElement.classList.remove("disableScrolling");
+
+ const pageContainer = document.getElementById("pageContainer")!;
+ if (Environment.platform() === "ios") {
+ pageContainer.style.removeProperty("position");
+ pageContainer.style.removeProperty("top");
+ } else {
+ pageContainer.style.removeProperty("margin-top");
+ }
+
+ if (_scrollTop) {
+ document[_scrollOffsetFrom].scrollTop = ~~_scrollTop;
+ }
+ }
+ }
+}
+
+/**
+ * Indicates that at least one page overlay is currently open.
+ */
+export function pageOverlayOpen(): void {
+ if (_pageOverlayCounter === 0) {
+ document.documentElement.classList.add("pageOverlayActive");
+ }
+
+ _pageOverlayCounter++;
+}
+
+/**
+ * Marks one page overlay as closed.
+ */
+export function pageOverlayClose(): void {
+ if (_pageOverlayCounter) {
+ _pageOverlayCounter--;
+
+ if (_pageOverlayCounter === 0) {
+ document.documentElement.classList.remove("pageOverlayActive");
+ }
+ }
+}
+
+/**
+ * Returns true if at least one page overlay is currently open.
+ *
+ * @returns {boolean}
+ */
+export function pageOverlayIsActive(): boolean {
+ return _pageOverlayCounter > 0;
+}
+
+/**
+ * @deprecated 5.4 - This method is a noop.
+ */
+export function setDialogContainer(_container: Element): void {
+ // Do nothing.
+}
+
+function _getQueryObject(query: string): MediaQueryData {
+ if (typeof (query as any) !== "string" || query.trim() === "") {
+ throw new TypeError("Expected a non-empty string for parameter 'query'.");
+ }
+
+ // Microsoft Edge rewrites the media queries to whatever it
+ // pleases, causing the input and output query to mismatch
+ if (_mqMapEdge.has(query)) query = _mqMapEdge.get(query)!;
+
+ if (_mqMap.has(query)) query = _mqMap.get(query) as string;
+
+ let queryObject = _mql.get(query);
+ if (!queryObject) {
+ queryObject = {
+ callbacksMatch: new Map<string, Callback>(),
+ callbacksUnmatch: new Map<string, Callback>(),
+ callbacksSetup: new Map<string, Callback>(),
+ mql: window.matchMedia(query),
+ };
+ //noinspection JSDeprecatedSymbols
+ queryObject.mql.addListener(_mqlChange);
+
+ _mql.set(query, queryObject);
+
+ if (query !== queryObject.mql.media) {
+ _mqMapEdge.set(queryObject.mql.media, query);
+ }
+ }
+
+ return queryObject;
+}
+
+/**
+ * Triggered whenever a registered media query now matches or no longer matches.
+ */
+function _mqlChange(event: MediaQueryListEvent): void {
+ const queryObject = _getQueryObject(event.media);
+ if (event.matches) {
+ if (queryObject.callbacksSetup.size) {
+ queryObject.callbacksSetup.forEach((callback) => {
+ callback();
+ });
+
+ // discard all setup callbacks after execution
+ queryObject.callbacksSetup = new Map<string, Callback>();
+ } else {
+ queryObject.callbacksMatch.forEach((callback) => {
+ callback();
+ });
+ }
+ } else {
+ // Chromium based browsers running on Windows suffer from a bug when
+ // used with the responsive mode of the DevTools. Enabling and
+ // disabling it will trigger some media queries to report a change
+ // even when there isn't really one. This cause errors when invoking
+ // "unmatch" handlers that rely on the setup being executed before.
+ if (queryObject.callbacksSetup.size) {
+ return;
+ }
+
+ queryObject.callbacksUnmatch.forEach((callback) => {
+ callback();
+ });
+ }
+}
+
+type Callback = () => void;
+
+interface Callbacks {
+ match: Callback;
+ setup: Callback;
+ unmatch: Callback;
+}
+
+interface MediaQueryData {
+ callbacksMatch: Map<string, Callback>;
+ callbacksSetup: Map<string, Callback>;
+ callbacksUnmatch: Map<string, Callback>;
+ mql: MediaQueryList;
+}
--- /dev/null
+/**
+ * Smoothly scrolls to an element while accounting for potential sticky headers.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Ui/Scroll (alias)
+ * @module WoltLabSuite/Core/Ui/Scroll
+ */
+import DomUtil from "../Dom/Util";
+
+type Callback = () => void;
+
+let _callback: Callback | null = null;
+let _offset: number | null = null;
+let _timeoutScroll: number | null = null;
+
+/**
+ * Monitors scroll event to only execute the callback once scrolling has ended.
+ */
+function onScroll(): void {
+ if (_timeoutScroll !== null) {
+ window.clearTimeout(_timeoutScroll);
+ }
+
+ _timeoutScroll = window.setTimeout(() => {
+ if (_callback !== null) {
+ _callback();
+ }
+
+ window.removeEventListener("scroll", onScroll);
+ _callback = null;
+ _timeoutScroll = null;
+ }, 100);
+}
+
+/**
+ * Scrolls to target element, optionally invoking the provided callback once scrolling has ended.
+ *
+ * @param {Element} element target element
+ * @param {function=} callback callback invoked once scrolling has ended
+ */
+export function element(element: HTMLElement, callback?: Callback): void {
+ if (!(element instanceof HTMLElement)) {
+ throw new TypeError("Expected a valid DOM element.");
+ } else if (callback !== undefined && typeof callback !== "function") {
+ throw new TypeError("Expected a valid callback function.");
+ } else if (!document.body.contains(element)) {
+ throw new Error("Element must be part of the visible DOM.");
+ } else if (_callback !== null) {
+ throw new Error("Cannot scroll to element, a concurrent request is running.");
+ }
+
+ if (callback) {
+ _callback = callback;
+ window.addEventListener("scroll", onScroll);
+ }
+
+ let y = DomUtil.offset(element).top;
+ if (_offset === null) {
+ _offset = 50;
+ const pageHeader = document.getElementById("pageHeaderPanel");
+ if (pageHeader !== null) {
+ const position = window.getComputedStyle(pageHeader).position;
+ if (position === "fixed" || position === "static") {
+ _offset = pageHeader.offsetHeight;
+ } else {
+ _offset = 0;
+ }
+ }
+ }
+
+ if (_offset > 0) {
+ if (y <= _offset) {
+ y = 0;
+ } else {
+ // add an offset to account for a sticky header
+ y -= _offset;
+ }
+ }
+
+ const offset = window.pageYOffset;
+ window.scrollTo({
+ left: 0,
+ top: y,
+ behavior: "smooth",
+ });
+
+ window.setTimeout(() => {
+ // no scrolling took place
+ if (offset === window.pageYOffset) {
+ onScroll();
+ }
+ }, 100);
+}
--- /dev/null
+import { DatabaseObjectActionPayload } from "../../Ajax/Data";
+
+export type CallbackDropdownInit = (list: HTMLUListElement) => void;
+
+export type CallbackSelect = (item: HTMLElement) => boolean;
+
+export interface SearchInputOptions {
+ ajax?: Partial<DatabaseObjectActionPayload>;
+ autoFocus?: boolean;
+ callbackDropdownInit?: CallbackDropdownInit;
+ callbackSelect?: CallbackSelect;
+ delay?: number;
+ excludedSearchValues?: string[];
+ minLength?: number;
+ noResultPlaceholder?: string;
+ preventSubmit?: boolean;
+}
--- /dev/null
+/**
+ * Provides suggestions using an input field, designed to work with `wcf\data\ISearchAction`.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Search/Input
+ */
+
+import * as Ajax from "../../Ajax";
+import * as Core from "../../Core";
+import DomUtil from "../../Dom/Util";
+import UiDropdownSimple from "../Dropdown/Simple";
+import { AjaxCallbackSetup, DatabaseObjectActionPayload, DatabaseObjectActionResponse } from "../../Ajax/Data";
+import AjaxRequest from "../../Ajax/Request";
+import { CallbackDropdownInit, CallbackSelect, SearchInputOptions } from "./Data";
+
+class UiSearchInput {
+ private activeItem?: HTMLLIElement = undefined;
+ private readonly ajaxPayload: DatabaseObjectActionPayload;
+ private readonly autoFocus: boolean;
+ private readonly callbackDropdownInit?: CallbackDropdownInit = undefined;
+ private readonly callbackSelect?: CallbackSelect = undefined;
+ private readonly delay: number;
+ private dropdownContainerId = "";
+ private readonly element: HTMLInputElement;
+ private readonly excludedSearchValues = new Set<string>();
+ private list?: HTMLUListElement = undefined;
+ private lastValue = "";
+ private readonly minLength: number;
+ private readonly noResultPlaceholder: string;
+ private readonly preventSubmit: boolean;
+ private request?: AjaxRequest = undefined;
+ private timerDelay?: number = undefined;
+
+ /**
+ * Initializes the search input field.
+ *
+ * @param {Element} element target input[type="text"]
+ * @param {Object} options search options and settings
+ */
+ constructor(element: HTMLInputElement, options: SearchInputOptions) {
+ this.element = element;
+ if (!(this.element instanceof HTMLInputElement)) {
+ throw new TypeError("Expected a valid DOM element.");
+ } else if (this.element.nodeName !== "INPUT" || (this.element.type !== "search" && this.element.type !== "text")) {
+ throw new Error('Expected an input[type="text"].');
+ }
+
+ options = Core.extend(
+ {
+ ajax: {
+ actionName: "getSearchResultList",
+ className: "",
+ interfaceName: "wcf\\data\\ISearchAction",
+ },
+ autoFocus: true,
+ callbackDropdownInit: undefined,
+ callbackSelect: undefined,
+ delay: 500,
+ excludedSearchValues: [],
+ minLength: 3,
+ noResultPlaceholder: "",
+ preventSubmit: false,
+ },
+ options,
+ ) as SearchInputOptions;
+
+ this.ajaxPayload = options.ajax as DatabaseObjectActionPayload;
+ this.autoFocus = options.autoFocus!;
+ this.callbackDropdownInit = options.callbackDropdownInit;
+ this.callbackSelect = options.callbackSelect;
+ this.delay = options.delay!;
+ options.excludedSearchValues!.forEach((value) => {
+ this.addExcludedSearchValues(value);
+ });
+ this.minLength = options.minLength!;
+ this.noResultPlaceholder = options.noResultPlaceholder!;
+ this.preventSubmit = options.preventSubmit!;
+
+ // Disable auto-complete because it collides with the suggestion dropdown.
+ this.element.autocomplete = "off";
+
+ this.element.addEventListener("keydown", (ev) => this.keydown(ev));
+ this.element.addEventListener("keyup", (ev) => this.keyup(ev));
+ }
+
+ /**
+ * Adds an excluded search value.
+ */
+ addExcludedSearchValues(value: string): void {
+ this.excludedSearchValues.add(value);
+ }
+
+ /**
+ * Removes a value from the excluded search values.
+ */
+ removeExcludedSearchValues(value: string): void {
+ this.excludedSearchValues.delete(value);
+ }
+
+ /**
+ * Handles the 'keydown' event.
+ */
+ private keydown(event: KeyboardEvent): void {
+ if ((this.activeItem !== null && UiDropdownSimple.isOpen(this.dropdownContainerId)) || this.preventSubmit) {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ }
+ }
+
+ if (["ArrowUp", "ArrowDown", "Escape"].includes(event.key)) {
+ event.preventDefault();
+ }
+ }
+
+ /**
+ * Handles the 'keyup' event, provides keyboard navigation and executes search queries.
+ */
+ private keyup(event: KeyboardEvent): void {
+ // handle dropdown keyboard navigation
+ if (this.activeItem !== null || !this.autoFocus) {
+ if (UiDropdownSimple.isOpen(this.dropdownContainerId)) {
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+
+ return this.keyboardPreviousItem();
+ } else if (event.key === "ArrowDown") {
+ event.preventDefault();
+
+ return this.keyboardNextItem();
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+
+ return this.keyboardSelectItem();
+ }
+ } else {
+ this.activeItem = undefined;
+ }
+ }
+
+ // close list on escape
+ if (event.key === "Escape") {
+ UiDropdownSimple.close(this.dropdownContainerId);
+
+ return;
+ }
+
+ const value = this.element.value.trim();
+ if (this.lastValue === value) {
+ // value did not change, e.g. previously it was "Test" and now it is "Test ",
+ // but the trailing whitespace has been ignored
+ return;
+ }
+
+ this.lastValue = value;
+
+ if (value.length < this.minLength) {
+ if (this.dropdownContainerId) {
+ UiDropdownSimple.close(this.dropdownContainerId);
+ this.activeItem = undefined;
+ }
+
+ // value below threshold
+ return;
+ }
+
+ if (this.delay) {
+ if (this.timerDelay) {
+ window.clearTimeout(this.timerDelay);
+ }
+
+ this.timerDelay = window.setTimeout(() => {
+ this.search(value);
+ }, this.delay);
+ } else {
+ this.search(value);
+ }
+ }
+
+ /**
+ * Queries the server with the provided search string.
+ */
+ private search(value: string): void {
+ if (this.request) {
+ this.request.abortPrevious();
+ }
+
+ this.request = Ajax.api(this, this.getParameters(value));
+ }
+
+ /**
+ * Returns additional AJAX parameters.
+ */
+ protected getParameters(value: string): Partial<DatabaseObjectActionPayload> {
+ return {
+ parameters: {
+ data: {
+ excludedSearchValues: this.excludedSearchValues,
+ searchString: value,
+ },
+ },
+ };
+ }
+
+ /**
+ * Selects the next dropdown item.
+ */
+ private keyboardNextItem(): void {
+ let nextItem: HTMLLIElement | undefined = undefined;
+
+ if (this.activeItem) {
+ this.activeItem.classList.remove("active");
+
+ if (this.activeItem.nextElementSibling) {
+ nextItem = this.activeItem.nextElementSibling as HTMLLIElement;
+ }
+ }
+
+ this.activeItem = nextItem || (this.list!.children[0] as HTMLLIElement);
+ this.activeItem.classList.add("active");
+ }
+
+ /**
+ * Selects the previous dropdown item.
+ */
+ private keyboardPreviousItem(): void {
+ let nextItem: HTMLLIElement | undefined = undefined;
+
+ if (this.activeItem) {
+ this.activeItem.classList.remove("active");
+
+ if (this.activeItem.previousElementSibling) {
+ nextItem = this.activeItem.previousElementSibling as HTMLLIElement;
+ }
+ }
+
+ this.activeItem = nextItem || (this.list!.children[this.list!.childElementCount - 1] as HTMLLIElement);
+ this.activeItem.classList.add("active");
+ }
+
+ /**
+ * Selects the active item from the dropdown.
+ */
+ private keyboardSelectItem(): void {
+ this.selectItem(this.activeItem!);
+ }
+
+ /**
+ * Selects an item from the dropdown by clicking it.
+ */
+ private clickSelectItem(event: MouseEvent): void {
+ this.selectItem(event.currentTarget as HTMLLIElement);
+ }
+
+ /**
+ * Selects an item.
+ */
+ private selectItem(item: HTMLLIElement): void {
+ if (this.callbackSelect && !this.callbackSelect(item)) {
+ this.element.value = "";
+ } else {
+ this.element.value = item.dataset.label || "";
+ }
+
+ this.activeItem = undefined;
+ UiDropdownSimple.close(this.dropdownContainerId);
+ }
+
+ /**
+ * Handles successful AJAX requests.
+ */
+ _ajaxSuccess(data: DatabaseObjectActionResponse): void {
+ let createdList = false;
+ if (!this.list) {
+ this.list = document.createElement("ul");
+ this.list.className = "dropdownMenu";
+
+ createdList = true;
+
+ if (typeof this.callbackDropdownInit === "function") {
+ this.callbackDropdownInit(this.list);
+ }
+ } else {
+ // reset current list
+ this.list.innerHTML = "";
+ }
+
+ if (typeof data.returnValues === "object") {
+ const callbackClick = this.clickSelectItem.bind(this);
+ let listItem;
+
+ Object.keys(data.returnValues).forEach((key) => {
+ listItem = this.createListItem(data.returnValues[key]);
+
+ listItem.addEventListener("click", callbackClick);
+ this.list!.appendChild(listItem);
+ });
+ }
+
+ if (createdList) {
+ this.element.insertAdjacentElement("afterend", this.list);
+ const parent = this.element.parentElement!;
+ UiDropdownSimple.initFragment(parent, this.list);
+
+ this.dropdownContainerId = DomUtil.identify(parent);
+ }
+
+ if (this.dropdownContainerId) {
+ this.activeItem = undefined;
+
+ if (!this.list.childElementCount && !this.handleEmptyResult()) {
+ UiDropdownSimple.close(this.dropdownContainerId);
+ } else {
+ UiDropdownSimple.open(this.dropdownContainerId, true);
+
+ // mark first item as active
+ const firstChild = this.list.childElementCount ? (this.list.children[0] as HTMLLIElement) : undefined;
+ if (this.autoFocus && firstChild && ~~(firstChild.dataset.objectId || "")) {
+ this.activeItem = firstChild;
+ this.activeItem.classList.add("active");
+ }
+ }
+ }
+ }
+
+ /**
+ * Handles an empty result set, return a boolean false to hide the dropdown.
+ */
+ private handleEmptyResult(): boolean {
+ if (!this.noResultPlaceholder) {
+ return false;
+ }
+
+ const listItem = document.createElement("li");
+ listItem.className = "dropdownText";
+
+ const span = document.createElement("span");
+ span.textContent = this.noResultPlaceholder;
+ listItem.appendChild(span);
+
+ this.list!.appendChild(listItem);
+
+ return true;
+ }
+
+ /**
+ * Creates an list item from response data.
+ */
+ protected createListItem(item: ListItemData): HTMLLIElement {
+ const listItem = document.createElement("li");
+ listItem.dataset.objectId = item.objectID.toString();
+ listItem.dataset.label = item.label;
+
+ const span = document.createElement("span");
+ span.textContent = item.label;
+ listItem.appendChild(span);
+
+ return listItem;
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: this.ajaxPayload,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiSearchInput);
+
+export = UiSearchInput;
+
+interface ListItemData {
+ label: string;
+ objectID: number;
+}
--- /dev/null
+import * as Core from "../../Core";
+import * as DomTraverse from "../../Dom/Traverse";
+import DomUtil from "../../Dom/Util";
+import UiDropdownSimple from "../Dropdown/Simple";
+import * as UiScreen from "../Screen";
+import UiSearchInput from "./Input";
+
+function click(event: MouseEvent): void {
+ event.preventDefault();
+
+ const pageHeader = document.getElementById("pageHeader") as HTMLElement;
+ pageHeader.classList.add("searchBarForceOpen");
+ window.setTimeout(() => {
+ pageHeader.classList.remove("searchBarForceOpen");
+ }, 10);
+
+ const target = event.currentTarget as HTMLElement;
+ const objectType = target.dataset.objectType;
+
+ const container = document.getElementById("pageHeaderSearchParameters") as HTMLElement;
+ container.innerHTML = "";
+
+ const extendedLink = target.dataset.extendedLink;
+ if (extendedLink) {
+ const link = document.querySelector(".pageHeaderSearchExtendedLink") as HTMLAnchorElement;
+ link.href = extendedLink;
+ }
+
+ const parameters = new Map<string, string>();
+ try {
+ const data = JSON.parse(target.dataset.parameters || "");
+ if (Core.isPlainObject(data)) {
+ Object.keys(data).forEach((key) => {
+ parameters.set(key, data[key]);
+ });
+ }
+ } catch (e) {
+ // Ignore JSON parsing failure.
+ }
+
+ if (objectType) {
+ parameters.set("types[]", objectType);
+ }
+
+ parameters.forEach((value, key) => {
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = key;
+ input.value = value;
+ container.appendChild(input);
+ });
+
+ // update label
+ const inputContainer = document.getElementById("pageHeaderSearchInputContainer") as HTMLElement;
+ const button = inputContainer.querySelector(
+ ".pageHeaderSearchType > .button > .pageHeaderSearchTypeLabel",
+ ) as HTMLElement;
+ button.textContent = target.textContent;
+}
+
+export function init(objectType: string): void {
+ const searchInput = document.getElementById("pageHeaderSearchInput") as HTMLInputElement;
+
+ new UiSearchInput(searchInput, {
+ ajax: {
+ className: "wcf\\data\\search\\keyword\\SearchKeywordAction",
+ },
+ autoFocus: false,
+ callbackDropdownInit(dropdownMenu) {
+ dropdownMenu.classList.add("dropdownMenuPageSearch");
+
+ if (UiScreen.is("screen-lg")) {
+ dropdownMenu.dataset.dropdownAlignmentHorizontal = "right";
+
+ const minWidth = searchInput.clientWidth;
+ dropdownMenu.style.setProperty("min-width", `${minWidth}px`, "");
+
+ // calculate offset to ignore the width caused by the submit button
+ const parent = searchInput.parentElement!;
+ const offsetRight =
+ DomUtil.offset(parent).left + parent.clientWidth - (DomUtil.offset(searchInput).left + minWidth);
+ const offsetTop = DomUtil.styleAsInt(window.getComputedStyle(parent), "padding-bottom");
+ dropdownMenu.style.setProperty(
+ "transform",
+ `translateX(-${Math.ceil(offsetRight)}px) translateY(-${offsetTop}px)`,
+ "",
+ );
+ }
+ },
+ callbackSelect() {
+ setTimeout(() => {
+ const form = DomTraverse.parentByTag(searchInput, "FORM") as HTMLFormElement;
+ form.submit();
+ }, 1);
+
+ return true;
+ },
+ });
+
+ const searchType = document.querySelector(".pageHeaderSearchType") as HTMLElement;
+ const dropdownMenu = UiDropdownSimple.getDropdownMenu(DomUtil.identify(searchType))!;
+ dropdownMenu.querySelectorAll("a[data-object-type]").forEach((link) => {
+ link.addEventListener("click", click);
+ });
+
+ // trigger click on init
+ const link = dropdownMenu.querySelector('a[data-object-type="' + objectType + '"]') as HTMLAnchorElement;
+ link.click();
+}
--- /dev/null
+/**
+ * Inserts smilies into a WYSIWYG editor instance, with WAI-ARIA keyboard support.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Smiley/Insert
+ */
+
+import * as Core from "../../Core";
+import * as EventHandler from "../../Event/Handler";
+
+class UiSmileyInsert {
+ private readonly container: HTMLElement;
+ private readonly editorId: string;
+
+ constructor(editorId: string) {
+ this.editorId = editorId;
+
+ let container = document.getElementById("smilies-" + this.editorId);
+ if (!container) {
+ // form builder
+ container = document.getElementById(this.editorId + "SmiliesTabContainer");
+ if (!container) {
+ throw new Error("Unable to find the message tab menu container containing the smilies.");
+ }
+ }
+
+ this.container = container;
+
+ this.container.addEventListener("keydown", (ev) => this.keydown(ev));
+ this.container.addEventListener("mousedown", (ev) => this.mousedown(ev));
+ }
+
+ keydown(event: KeyboardEvent): void {
+ const activeButton = document.activeElement as HTMLAnchorElement;
+ if (!activeButton.classList.contains("jsSmiley")) {
+ return;
+ }
+
+ if (["ArrowLeft", "ArrowRight", "End", "Home"].includes(event.key)) {
+ event.preventDefault();
+
+ const target = event.currentTarget as HTMLAnchorElement;
+ const smilies: HTMLAnchorElement[] = Array.from(target.querySelectorAll(".jsSmiley"));
+ if (event.key === "ArrowLeft") {
+ smilies.reverse();
+ }
+
+ let index = smilies.indexOf(activeButton);
+ if (event.key === "Home") {
+ index = 0;
+ } else if (event.key === "End") {
+ index = smilies.length - 1;
+ } else {
+ index = index + 1;
+ if (index === smilies.length) {
+ index = 0;
+ }
+ }
+
+ smilies[index].focus();
+ } else if (event.key === "Enter" || event.key === "Space") {
+ event.preventDefault();
+
+ const image = activeButton.querySelector("img") as HTMLImageElement;
+ this.insert(image);
+ }
+ }
+
+ mousedown(event: MouseEvent): void {
+ const target = event.target as HTMLElement;
+
+ // Clicks may occur on a few different elements, but we are only looking for the image.
+ const listItem = target.closest("li");
+ if (listItem && this.container.contains(listItem)) {
+ event.preventDefault();
+
+ const img = listItem.querySelector("img");
+ if (img) {
+ this.insert(img);
+ }
+ }
+ }
+
+ insert(img: HTMLImageElement): void {
+ EventHandler.fire("com.woltlab.wcf.redactor2", "insertSmiley_" + this.editorId, {
+ img,
+ });
+ }
+}
+
+Core.enableLegacyInheritance(UiSmileyInsert);
+
+export = UiSmileyInsert;
--- /dev/null
+/**
+ * Sortable lists with optimized handling per device sizes.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Sortable/List
+ */
+
+import * as Core from "../../Core";
+import * as UiScreen from "../Screen";
+
+interface UnknownObject {
+ [key: string]: unknown;
+}
+
+interface SortableListOptions {
+ containerId: string;
+ className: string;
+ offset: number;
+ options: UnknownObject;
+ isSimpleSorting: boolean;
+ additionalParameters: UnknownObject;
+}
+
+class UiSortableList {
+ protected readonly _options: SortableListOptions;
+
+ /**
+ * Initializes the sortable list controller.
+ */
+ constructor(opts: Partial<SortableListOptions>) {
+ this._options = Core.extend(
+ {
+ containerId: "",
+ className: "",
+ offset: 0,
+ options: {},
+ isSimpleSorting: false,
+ additionalParameters: {},
+ },
+ opts,
+ ) as SortableListOptions;
+
+ UiScreen.on("screen-sm-md", {
+ match: () => this._enable(true),
+ unmatch: () => this._disable(),
+ setup: () => this._enable(true),
+ });
+
+ UiScreen.on("screen-lg", {
+ match: () => this._enable(false),
+ unmatch: () => this._disable(),
+ setup: () => this._enable(false),
+ });
+ }
+
+ /**
+ * Enables sorting with an optional sort handle.
+ */
+ protected _enable(hasHandle: boolean): void {
+ const options = this._options.options;
+ if (hasHandle) {
+ options.handle = ".sortableNodeHandle";
+ }
+
+ new window.WCF.Sortable.List(
+ this._options.containerId,
+ this._options.className,
+ this._options.offset,
+ options,
+ this._options.isSimpleSorting,
+ this._options.additionalParameters,
+ );
+ }
+
+ /**
+ * Disables sorting for registered containers.
+ */
+ protected _disable(): void {
+ window
+ .jQuery(`#${this._options.containerId} .sortableList`)
+ [this._options.isSimpleSorting ? "sortable" : "nestedSortable"]("destroy");
+ }
+}
+
+Core.enableLegacyInheritance(UiSortableList);
+
+export = UiSortableList;
--- /dev/null
+/**
+ * Provides a selection dialog for FontAwesome icons with filter capabilities.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Style/FontAwesome
+ */
+
+import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
+import * as Language from "../../Language";
+import UiDialog from "../Dialog";
+import UiItemListFilter from "../ItemList/Filter";
+
+type CallbackSelect = (icon: string) => void;
+
+class UiStyleFontAwesome implements DialogCallbackObject {
+ private callback?: CallbackSelect = undefined;
+ private iconList?: HTMLElement = undefined;
+ private itemListFilter?: UiItemListFilter = undefined;
+ private readonly icons: string[];
+
+ constructor(icons: string[]) {
+ this.icons = icons;
+ }
+
+ open(callback: CallbackSelect): void {
+ this.callback = callback;
+
+ UiDialog.open(this);
+ }
+
+ /**
+ * Selects an icon, notifies the callback and closes the dialog.
+ */
+ protected click(event: MouseEvent): void {
+ event.preventDefault();
+
+ const target = event.target as HTMLElement;
+ const item = target.closest("li") as HTMLLIElement;
+ const icon = item.querySelector("small")!.textContent!.trim();
+
+ UiDialog.close(this);
+
+ this.callback!(icon);
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "fontAwesomeSelection",
+ options: {
+ onSetup: () => {
+ this.iconList = document.getElementById("fontAwesomeIcons") as HTMLElement;
+
+ // build icons
+ this.iconList.innerHTML = this.icons
+ .map((icon) => `<li><span class="icon icon48 fa-${icon}"></span><small>${icon}</small></li>`)
+ .join("");
+
+ this.iconList.addEventListener("click", (ev) => this.click(ev));
+
+ this.itemListFilter = new UiItemListFilter("fontAwesomeIcons", {
+ callbackPrepareItem: (item) => {
+ const small = item.querySelector("small") as HTMLElement;
+ const text = small.textContent!.trim();
+
+ return {
+ item,
+ span: small,
+ text,
+ };
+ },
+ enableVisibilityFilter: false,
+ filterPosition: "top",
+ });
+ },
+ onShow: () => {
+ this.itemListFilter!.reset();
+ },
+ title: Language.get("wcf.global.fontAwesome.selectIcon"),
+ },
+ source: '<ul class="fontAwesomeIcons" id="fontAwesomeIcons"></ul>',
+ };
+ }
+}
+
+let uiStyleFontAwesome: UiStyleFontAwesome;
+
+/**
+ * Sets the list of available icons, must be invoked prior to any call
+ * to the `open()` method.
+ */
+export function setup(icons: string[]): void {
+ if (!uiStyleFontAwesome) {
+ uiStyleFontAwesome = new UiStyleFontAwesome(icons);
+ }
+}
+
+/**
+ * Shows the FontAwesome selection dialog, supplied callback will be
+ * invoked with the selection icon's name as the only argument.
+ */
+export function open(callback: CallbackSelect): void {
+ if (!uiStyleFontAwesome) {
+ throw new Error(
+ "Missing icon data, please include the template before calling this method using `{include file='fontAwesomeJavaScript'}`.",
+ );
+ }
+
+ uiStyleFontAwesome.open(callback);
+}
--- /dev/null
+/**
+ * Flexible UI element featuring both a list of items and an input field with suggestion support.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Suggestion
+ */
+
+import * as Ajax from "../Ajax";
+import * as Core from "../Core";
+import {
+ AjaxCallbackObject,
+ AjaxCallbackSetup,
+ DatabaseObjectActionPayload,
+ DatabaseObjectActionResponse,
+} from "../Ajax/Data";
+import UiDropdownSimple from "./Dropdown/Simple";
+
+interface ItemData {
+ icon?: string;
+ label: string;
+ objectID: number;
+ type?: string;
+}
+
+interface AjaxResponse extends DatabaseObjectActionResponse {
+ returnValues: ItemData[];
+}
+
+class UiSuggestion implements AjaxCallbackObject {
+ private readonly ajaxPayload: DatabaseObjectActionPayload;
+ private readonly callbackSelect: CallbackSelect;
+ private dropdownMenu: HTMLElement | null = null;
+ private readonly excludedSearchValues: Set<string>;
+ private readonly element: HTMLElement;
+ private readonly threshold: number;
+ private value = "";
+
+ /**
+ * Initializes a new suggestion input.
+ */
+ constructor(elementId: string, options: SuggestionOptions) {
+ const element = document.getElementById(elementId);
+ if (element === null) {
+ throw new Error("Expected a valid element id.");
+ }
+
+ this.element = element;
+
+ this.ajaxPayload = Core.extend(
+ {
+ actionName: "getSearchResultList",
+ className: "",
+ interfaceName: "wcf\\data\\ISearchAction",
+ parameters: {
+ data: {},
+ },
+ },
+ options.ajax,
+ ) as DatabaseObjectActionPayload;
+
+ if (typeof options.callbackSelect !== "function") {
+ throw new Error("Expected a valid callback for option 'callbackSelect'.");
+ }
+ this.callbackSelect = options.callbackSelect;
+
+ this.excludedSearchValues = new Set(
+ Array.isArray(options.excludedSearchValues) ? options.excludedSearchValues : [],
+ );
+ this.threshold = options.threshold === undefined ? 3 : options.threshold;
+
+ this.element.addEventListener("click", (ev) => ev.preventDefault());
+ this.element.addEventListener("keydown", (ev) => this.keyDown(ev));
+ this.element.addEventListener("keyup", (ev) => this.keyUp(ev));
+ }
+
+ /**
+ * Adds an excluded search value.
+ */
+ addExcludedValue(value: string): void {
+ this.excludedSearchValues.add(value);
+ }
+
+ /**
+ * Removes an excluded search value.
+ */
+ removeExcludedValue(value: string): void {
+ this.excludedSearchValues.delete(value);
+ }
+
+ /**
+ * Returns true if the suggestions are active.
+ */
+ isActive(): boolean {
+ return this.dropdownMenu !== null && UiDropdownSimple.isOpen(this.element.id);
+ }
+
+ /**
+ * Handles the keyboard navigation for interaction with the suggestion list.
+ */
+ private keyDown(event: KeyboardEvent): boolean {
+ if (!this.isActive()) {
+ return true;
+ }
+
+ if (["ArrowDown", "ArrowUp", "Enter", "Escape"].indexOf(event.key) === -1) {
+ return true;
+ }
+
+ let active!: HTMLElement;
+ let i = 0;
+ const length = this.dropdownMenu!.childElementCount;
+ while (i < length) {
+ active = this.dropdownMenu!.children[i] as HTMLElement;
+ if (active.classList.contains("active")) {
+ break;
+ }
+ i++;
+ }
+
+ if (event.key === "Enter") {
+ UiDropdownSimple.close(this.element.id);
+ this.select(undefined, active);
+ } else if (event.key === "Escape") {
+ if (UiDropdownSimple.isOpen(this.element.id)) {
+ UiDropdownSimple.close(this.element.id);
+ } else {
+ // let the event pass through
+ return true;
+ }
+ } else {
+ let index = 0;
+ if (event.key === "ArrowUp") {
+ index = (i === 0 ? length : i) - 1;
+ } else if (event.key === "ArrowDown") {
+ index = i + 1;
+ if (index === length) {
+ index = 0;
+ }
+ }
+ if (index !== i) {
+ active.classList.remove("active");
+ this.dropdownMenu!.children[index].classList.add("active");
+ }
+ }
+
+ event.preventDefault();
+ return false;
+ }
+
+ /**
+ * Selects an item from the list.
+ */
+ private select(event: MouseEvent): void;
+ private select(event: undefined, item: HTMLElement): void;
+ private select(event: MouseEvent | undefined, item?: HTMLElement): void {
+ if (event instanceof MouseEvent) {
+ const target = event.currentTarget as HTMLElement;
+ item = target.parentNode as HTMLElement;
+ }
+
+ const anchor = item!.children[0] as HTMLElement;
+ this.callbackSelect(this.element.id, {
+ objectId: +(anchor.dataset.objectId || 0),
+ value: item!.textContent || "",
+ type: anchor.dataset.type || "",
+ });
+
+ if (event instanceof MouseEvent) {
+ this.element.focus();
+ }
+ }
+
+ /**
+ * Performs a search for the input value unless it is below the threshold.
+ */
+ private keyUp(event: KeyboardEvent): void {
+ const target = event.currentTarget as HTMLInputElement;
+ const value = target.value.trim();
+ if (this.value === value) {
+ return;
+ } else if (value.length < this.threshold) {
+ if (this.dropdownMenu !== null) {
+ UiDropdownSimple.close(this.element.id);
+ }
+
+ this.value = value;
+ return;
+ }
+
+ this.value = value;
+ Ajax.api(this, {
+ parameters: {
+ data: {
+ excludedSearchValues: Array.from(this.excludedSearchValues),
+ searchString: value,
+ },
+ },
+ });
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: this.ajaxPayload,
+ };
+ }
+
+ /**
+ * Handles successful Ajax requests.
+ */
+ _ajaxSuccess(data: AjaxResponse): void {
+ if (this.dropdownMenu === null) {
+ this.dropdownMenu = document.createElement("div");
+ this.dropdownMenu.className = "dropdownMenu";
+ UiDropdownSimple.initFragment(this.element, this.dropdownMenu);
+ } else {
+ this.dropdownMenu.innerHTML = "";
+ }
+
+ if (Array.isArray(data.returnValues)) {
+ data.returnValues.forEach((item, index) => {
+ const anchor = document.createElement("a");
+ if (item.icon) {
+ anchor.className = "box16";
+ anchor.innerHTML = `${item.icon} <span></span>`;
+ anchor.children[1].textContent = item.label;
+ } else {
+ anchor.textContent = item.label;
+ }
+
+ anchor.dataset.objectId = item.objectID.toString();
+ if (item.type) {
+ anchor.dataset.type = item.type;
+ }
+ anchor.addEventListener("click", (ev) => this.select(ev));
+
+ const listItem = document.createElement("li");
+ if (index === 0) {
+ listItem.className = "active";
+ }
+ listItem.appendChild(anchor);
+ this.dropdownMenu!.appendChild(listItem);
+ });
+
+ UiDropdownSimple.open(this.element.id, true);
+ } else {
+ UiDropdownSimple.close(this.element.id);
+ }
+ }
+}
+
+Core.enableLegacyInheritance(UiSuggestion);
+
+export = UiSuggestion;
+
+interface CallbackSelectData {
+ objectId: number;
+ value: string;
+ type: string;
+}
+
+type CallbackSelect = (elementId: string, data: CallbackSelectData) => void;
+
+interface SuggestionOptions {
+ ajax: DatabaseObjectActionPayload;
+
+ // will be executed once a value from the dropdown has been selected
+ callbackSelect: CallbackSelect;
+
+ // list of excluded search values
+ excludedSearchValues?: string[];
+
+ // minimum number of characters required to trigger a search request
+ threshold?: number;
+}
--- /dev/null
+/**
+ * Common interface for tab menu access.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Ui/TabMenu (alias)
+ * @module WoltLabSuite/Core/Ui/TabMenu
+ */
+
+import DomChangeListener from "../Dom/Change/Listener";
+import DomUtil from "../Dom/Util";
+import TabMenuSimple from "./TabMenu/Simple";
+import UiCloseOverlay from "./CloseOverlay";
+import * as UiScreen from "./Screen";
+import * as UiScroll from "./Scroll";
+
+let _activeList: HTMLUListElement | null = null;
+let _enableTabScroll = false;
+const _tabMenus = new Map<string, TabMenuSimple>();
+
+/**
+ * Initializes available tab menus.
+ */
+function init() {
+ document.querySelectorAll(".tabMenuContainer:not(.staticTabMenuContainer)").forEach((container: HTMLElement) => {
+ const containerId = DomUtil.identify(container);
+ if (_tabMenus.has(containerId)) {
+ return;
+ }
+
+ let tabMenu = new TabMenuSimple(container);
+ if (!tabMenu.validate()) {
+ return;
+ }
+
+ const returnValue = tabMenu.init();
+ _tabMenus.set(containerId, tabMenu);
+ if (returnValue instanceof HTMLElement) {
+ const parent = returnValue.parentNode as HTMLElement;
+ const parentTabMenu = getTabMenu(parent.id);
+ if (parentTabMenu) {
+ tabMenu = parentTabMenu;
+ tabMenu.select(returnValue.id, undefined, true);
+ }
+ }
+
+ const list = document.querySelector("#" + containerId + " > nav > ul") as HTMLUListElement;
+ list.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (event.target === list) {
+ list.classList.add("active");
+ _activeList = list;
+ } else {
+ list.classList.remove("active");
+ _activeList = null;
+ }
+ });
+
+ // bind scroll listener
+ container.querySelectorAll(".tabMenu, .menu").forEach((menu: HTMLElement) => {
+ function callback() {
+ timeout = null;
+
+ rebuildMenuOverflow(menu);
+ }
+
+ let timeout: number | null = null;
+ menu.querySelector("ul")!.addEventListener(
+ "scroll",
+ () => {
+ if (timeout !== null) {
+ window.clearTimeout(timeout);
+ }
+
+ // slight delay to avoid calling this function too often
+ timeout = window.setTimeout(callback, 10);
+ },
+ { passive: true },
+ );
+ });
+
+ // The validation of input fields, e.g. [required], yields strange results when
+ // the erroneous element is hidden inside a tab. The submit button will appear
+ // to not work and a warning is displayed on the console. We can work around this
+ // by manually checking if the input fields validate on submit and display the
+ // parent tab ourselves.
+ const form = container.closest("form");
+ if (form !== null) {
+ const submitButton = form.querySelector('input[type="submit"]');
+ if (submitButton !== null) {
+ submitButton.addEventListener("click", (event) => {
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ container.querySelectorAll("input, select").forEach((element: HTMLInputElement | HTMLSelectElement) => {
+ if (!element.checkValidity()) {
+ event.preventDefault();
+
+ // Select the tab that contains the erroneous element.
+ const tabMenu = getTabMenu(element.closest(".tabMenuContainer")!.id)!;
+ const tabMenuContent = element.closest(".tabMenuContent") as HTMLElement;
+ tabMenu.select(tabMenuContent.dataset.name || "");
+ UiScroll.element(element, () => {
+ element.reportValidity();
+ });
+
+ return;
+ }
+ });
+ });
+ }
+ }
+ });
+}
+
+/**
+ * Selects the first tab containing an element with class `formError`.
+ */
+function selectErroneousTabs(): void {
+ _tabMenus.forEach((tabMenu) => {
+ let foundError = false;
+ tabMenu.getContainers().forEach((container) => {
+ if (!foundError && container.querySelector(".formError") !== null) {
+ foundError = true;
+ tabMenu.select(container.id);
+ }
+ });
+ });
+}
+
+function scrollEnable(isSetup: boolean) {
+ _enableTabScroll = true;
+ _tabMenus.forEach((tabMenu) => {
+ const activeTab = tabMenu.getActiveTab();
+ if (isSetup) {
+ rebuildMenuOverflow(activeTab.closest(".menu, .tabMenu") as HTMLElement);
+ } else {
+ scrollToTab(activeTab);
+ }
+ });
+}
+
+function scrollDisable() {
+ _enableTabScroll = false;
+}
+
+function scrollMenu(
+ list: HTMLElement,
+ left: number,
+ scrollLeft: number,
+ scrollWidth: number,
+ width: number,
+ paddingRight: boolean,
+) {
+ // allow some padding to indicate overflow
+ if (paddingRight) {
+ left -= 15;
+ } else if (left > 0) {
+ left -= 15;
+ }
+
+ if (left < 0) {
+ left = 0;
+ } else {
+ // ensure that our left value is always within the boundaries
+ left = Math.min(left, scrollWidth - width);
+ }
+
+ if (scrollLeft === left) {
+ return;
+ }
+
+ list.classList.add("enableAnimation");
+
+ // new value is larger, we're scrolling towards the end
+ if (scrollLeft < left) {
+ (list.firstElementChild as HTMLElement).style.setProperty("margin-left", `${scrollLeft - left}px`, "");
+ } else {
+ // new value is smaller, we're scrolling towards the start
+ list.style.setProperty("padding-left", `${scrollLeft - left}px`, "");
+ }
+
+ setTimeout(() => {
+ list.classList.remove("enableAnimation");
+ (list.firstElementChild as HTMLElement).style.removeProperty("margin-left");
+ list.style.removeProperty("padding-left");
+ list.scrollLeft = left;
+ }, 300);
+}
+
+function rebuildMenuOverflow(menu: HTMLElement): void {
+ if (!_enableTabScroll) {
+ return;
+ }
+
+ const width = menu.clientWidth;
+ const list = menu.querySelector("ul") as HTMLElement;
+ const scrollLeft = list.scrollLeft;
+ const scrollWidth = list.scrollWidth;
+ const overflowLeft = scrollLeft > 0;
+
+ let overlayLeft = menu.querySelector(".tabMenuOverlayLeft");
+ if (overflowLeft) {
+ if (overlayLeft === null) {
+ overlayLeft = document.createElement("span");
+ overlayLeft.className = "tabMenuOverlayLeft icon icon24 fa-angle-left";
+ overlayLeft.addEventListener("click", () => {
+ const listWidth = list.clientWidth;
+ scrollMenu(list, list.scrollLeft - ~~(listWidth / 2), list.scrollLeft, list.scrollWidth, listWidth, false);
+ });
+ menu.insertBefore(overlayLeft, menu.firstChild);
+ }
+
+ overlayLeft.classList.add("active");
+ } else if (overlayLeft !== null) {
+ overlayLeft.classList.remove("active");
+ }
+
+ const overflowRight = width + scrollLeft < scrollWidth;
+ let overlayRight = menu.querySelector(".tabMenuOverlayRight");
+ if (overflowRight) {
+ if (overlayRight === null) {
+ overlayRight = document.createElement("span");
+ overlayRight.className = "tabMenuOverlayRight icon icon24 fa-angle-right";
+ overlayRight.addEventListener("click", () => {
+ const listWidth = list.clientWidth;
+ scrollMenu(list, list.scrollLeft + ~~(listWidth / 2), list.scrollLeft, list.scrollWidth, listWidth, false);
+ });
+
+ menu.appendChild(overlayRight);
+ }
+ overlayRight.classList.add("active");
+ } else if (overlayRight !== null) {
+ overlayRight.classList.remove("active");
+ }
+}
+
+/**
+ * Sets up tab menus and binds listeners.
+ */
+export function setup(): void {
+ init();
+ selectErroneousTabs();
+
+ DomChangeListener.add("WoltLabSuite/Core/Ui/TabMenu", init);
+ UiCloseOverlay.add("WoltLabSuite/Core/Ui/TabMenu", () => {
+ if (_activeList) {
+ _activeList.classList.remove("active");
+ _activeList = null;
+ }
+ });
+
+ UiScreen.on("screen-sm-down", {
+ match() {
+ scrollEnable(false);
+ },
+ unmatch: scrollDisable,
+ setup() {
+ scrollEnable(true);
+ },
+ });
+
+ window.addEventListener("hashchange", () => {
+ const hash = TabMenuSimple.getIdentifierFromHash();
+ const element = hash ? document.getElementById(hash) : null;
+ if (element !== null && element.classList.contains("tabMenuContent")) {
+ _tabMenus.forEach((tabMenu) => {
+ if (tabMenu.hasTab(hash)) {
+ tabMenu.select(hash);
+ }
+ });
+ }
+ });
+
+ const hash = TabMenuSimple.getIdentifierFromHash();
+ if (hash) {
+ window.setTimeout(() => {
+ // check if page was initially scrolled using a tab id
+ const tabMenuContent = document.getElementById(hash);
+ if (tabMenuContent && tabMenuContent.classList.contains("tabMenuContent")) {
+ const scrollY = window.scrollY || window.pageYOffset;
+ if (scrollY > 0) {
+ const parent = tabMenuContent.parentNode as HTMLElement;
+
+ let offsetTop = parent.offsetTop - 50;
+ if (offsetTop < 0) {
+ offsetTop = 0;
+ }
+
+ if (scrollY > offsetTop) {
+ let y = DomUtil.offset(parent).top;
+ if (y <= 50) {
+ y = 0;
+ } else {
+ y -= 50;
+ }
+
+ window.scrollTo(0, y);
+ }
+ }
+ }
+ }, 100);
+ }
+}
+
+/**
+ * Returns a TabMenuSimple instance for given container id.
+ */
+export function getTabMenu(containerId: string): TabMenuSimple | undefined {
+ return _tabMenus.get(containerId);
+}
+
+export function scrollToTab(tab: HTMLElement): void {
+ if (!_enableTabScroll) {
+ return;
+ }
+
+ const list = tab.closest("ul")!;
+ const width = list.clientWidth;
+ const scrollLeft = list.scrollLeft;
+ const scrollWidth = list.scrollWidth;
+ if (width === scrollWidth) {
+ // no overflow, ignore
+ return;
+ }
+
+ // check if tab is currently visible
+ const left = tab.offsetLeft;
+ let shouldScroll = false;
+ if (left < scrollLeft) {
+ shouldScroll = true;
+ }
+
+ let paddingRight = false;
+ if (!shouldScroll) {
+ const visibleWidth = width - (left - scrollLeft);
+ let virtualWidth = tab.clientWidth;
+ if (tab.nextElementSibling !== null) {
+ paddingRight = true;
+ virtualWidth += 20;
+ }
+
+ if (visibleWidth < virtualWidth) {
+ shouldScroll = true;
+ }
+ }
+
+ if (shouldScroll) {
+ scrollMenu(list, left, scrollLeft, scrollWidth, width, paddingRight);
+ }
+}
--- /dev/null
+/**
+ * Simple tab menu implementation with a straight-forward logic.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/TabMenu/Simple
+ */
+
+import * as Core from "../../Core";
+import * as DomTraverse from "../../Dom/Traverse";
+import DomUtil from "../../Dom/Util";
+import * as Environment from "../../Environment";
+import * as EventHandler from "../../Event/Handler";
+
+class TabMenuSimple {
+ private readonly container: HTMLElement;
+ private readonly containers = new Map<string, HTMLElement>();
+ private isLegacy = false;
+ private store: HTMLInputElement | null = null;
+ private readonly tabs = new Map<string, HTMLLIElement>();
+
+ constructor(container: HTMLElement) {
+ this.container = container;
+ }
+
+ /**
+ * Validates the properties and DOM structure of this container.
+ *
+ * Expected DOM:
+ * <div class="tabMenuContainer">
+ * <nav>
+ * <ul>
+ * <li data-name="foo"><a>bar</a></li>
+ * </ul>
+ * </nav>
+ *
+ * <div id="foo">baz</div>
+ * </div>
+ */
+ validate(): boolean {
+ if (!this.container.classList.contains("tabMenuContainer")) {
+ return false;
+ }
+
+ const nav = DomTraverse.childByTag(this.container, "NAV");
+ if (nav === null) {
+ return false;
+ }
+
+ // get children
+ const tabs = nav.querySelectorAll("li");
+ if (tabs.length === 0) {
+ return false;
+ }
+
+ DomTraverse.childrenByTag(this.container, "DIV").forEach((container) => {
+ let name = container.dataset.name;
+ if (!name) {
+ name = DomUtil.identify(container);
+ container.dataset.name = name;
+ }
+
+ this.containers.set(name, container);
+ });
+
+ const containerId = this.container.id;
+ tabs.forEach((tab) => {
+ const name = this._getTabName(tab);
+ if (!name) {
+ return;
+ }
+
+ if (this.tabs.has(name)) {
+ throw new Error(
+ "Tab names must be unique, li[data-name='" +
+ name +
+ "'] (tab menu id: '" +
+ containerId +
+ "') exists more than once.",
+ );
+ }
+
+ const container = this.containers.get(name);
+ if (container === undefined) {
+ throw new Error(
+ "Expected content element for li[data-name='" + name + "'] (tab menu id: '" + containerId + "').",
+ );
+ } else if (container.parentNode !== this.container) {
+ throw new Error(
+ "Expected content element '" + name + "' (tab menu id: '" + containerId + "') to be a direct children.",
+ );
+ }
+
+ // check if tab holds exactly one children which is an anchor element
+ if (tab.childElementCount !== 1 || tab.children[0].nodeName !== "A") {
+ throw new Error(
+ "Expected exactly one <a> as children for li[data-name='" + name + "'] (tab menu id: '" + containerId + "').",
+ );
+ }
+
+ this.tabs.set(name, tab);
+ });
+
+ if (!this.tabs.size) {
+ throw new Error("Expected at least one tab (tab menu id: '" + containerId + "').");
+ }
+
+ if (this.isLegacy) {
+ this.container.dataset.isLegacy = "true";
+
+ this.tabs.forEach(function (tab, name) {
+ tab.setAttribute("aria-controls", name);
+ });
+ }
+
+ return true;
+ }
+
+ /**
+ * Initializes this tab menu.
+ */
+ init(oldTabs?: Map<string, HTMLLIElement> | null): HTMLElement | null {
+ // bind listeners
+ this.tabs.forEach((tab) => {
+ if (!oldTabs || oldTabs.get(tab.dataset.name || "") !== tab) {
+ const firstChild = tab.children[0] as HTMLElement;
+ firstChild.addEventListener("click", (ev) => this._onClick(ev));
+
+ // iOS 13 changed the behavior for click events after scrolling the menu. It prevents
+ // the synthetic mouse events like "click" from triggering for a short duration after
+ // a scrolling has occurred. If the user scrolls to the end of the list and immediately
+ // attempts to click the tab, nothing will happen. However, if the user waits for some
+ // time, the tap will trigger a "click" event again.
+ //
+ // A "click" event is basically the result of a touch without any (significant) finger
+ // movement indicated by a "touchmove" event. This changes allows the user to scroll
+ // both the menu and the page normally, but still benefit from snappy reactions when
+ // tapping a menu item.
+ if (Environment.platform() === "ios") {
+ let isClick = false;
+ firstChild.addEventListener("touchstart", () => {
+ isClick = true;
+ });
+ firstChild.addEventListener("touchmove", () => {
+ isClick = false;
+ });
+ firstChild.addEventListener("touchend", (event) => {
+ if (isClick) {
+ isClick = false;
+
+ // This will block the regular click event from firing.
+ event.preventDefault();
+
+ // Invoke the click callback manually.
+ this._onClick(event);
+ }
+ });
+ }
+ }
+ });
+
+ let returnValue: HTMLElement | null = null;
+ if (!oldTabs) {
+ const hash = TabMenuSimple.getIdentifierFromHash();
+ let selectTab: HTMLLIElement | undefined = undefined;
+ if (hash !== "") {
+ selectTab = this.tabs.get(hash);
+
+ // check for parent tab menu
+ if (selectTab) {
+ const item = this.container.parentNode as HTMLElement;
+ if (item.classList.contains("tabMenuContainer")) {
+ returnValue = item;
+ }
+ }
+ }
+
+ if (!selectTab) {
+ let preselect: unknown = this.container.dataset.preselect || this.container.dataset.active;
+ if (preselect === "true" || !preselect) {
+ preselect = true;
+ }
+
+ if (preselect === true) {
+ this.tabs.forEach(function (tab) {
+ if (
+ !selectTab &&
+ !DomUtil.isHidden(tab) &&
+ (!tab.previousElementSibling || DomUtil.isHidden(tab.previousElementSibling as HTMLElement))
+ ) {
+ selectTab = tab;
+ }
+ });
+ } else if (typeof preselect === "string" && preselect !== "false") {
+ selectTab = this.tabs.get(preselect);
+ }
+ }
+
+ if (selectTab) {
+ this.containers.forEach((container) => {
+ container.classList.add("hidden");
+ });
+
+ this.select(null, selectTab, true);
+ }
+
+ const store = this.container.dataset.store;
+ if (store) {
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = store;
+ input.value = this.getActiveTab().dataset.name || "";
+
+ this.container.appendChild(input);
+
+ this.store = input;
+ }
+ }
+
+ return returnValue;
+ }
+
+ /**
+ * Selects a tab.
+ *
+ * @param {?(string|int)} name tab name or sequence no
+ * @param {Element=} tab tab element
+ * @param {boolean=} disableEvent suppress event handling
+ */
+ select(name: number | string | null, tab?: HTMLLIElement, disableEvent?: boolean): void {
+ name = name ? name.toString() : "";
+ tab = tab || this.tabs.get(name);
+
+ if (!tab) {
+ // check if name is an integer
+ if (~~name === +name) {
+ name = ~~name;
+
+ let i = 0;
+ this.tabs.forEach((item) => {
+ if (i === name) {
+ tab = item;
+ }
+
+ i++;
+ });
+ }
+
+ if (!tab) {
+ throw new Error(`Expected a valid tab name, '${name}' given (tab menu id: '${this.container.id}').`);
+ }
+ }
+
+ name = (name || tab.dataset.name || "") as string;
+
+ // unmark active tab
+ const oldTab = this.getActiveTab();
+ let oldContent: HTMLElement | null = null;
+ if (oldTab) {
+ const oldTabName = oldTab.dataset.name;
+ if (oldTabName === name) {
+ // same tab
+ return;
+ }
+
+ if (!disableEvent) {
+ EventHandler.fire("com.woltlab.wcf.simpleTabMenu_" + this.container.id, "beforeSelect", {
+ tab: oldTab,
+ tabName: oldTabName,
+ });
+ }
+
+ oldTab.classList.remove("active");
+ oldContent = this.containers.get(oldTab.dataset.name || "")!;
+ oldContent.classList.remove("active");
+ oldContent.classList.add("hidden");
+
+ if (this.isLegacy) {
+ oldTab.classList.remove("ui-state-active");
+ oldContent.classList.remove("ui-state-active");
+ }
+ }
+
+ tab.classList.add("active");
+ const newContent = this.containers.get(name)!;
+ newContent.classList.add("active");
+ newContent.classList.remove("hidden");
+
+ if (this.isLegacy) {
+ tab.classList.add("ui-state-active");
+ newContent.classList.add("ui-state-active");
+ }
+
+ if (this.store) {
+ this.store.value = name;
+ }
+
+ if (!disableEvent) {
+ EventHandler.fire("com.woltlab.wcf.simpleTabMenu_" + this.container.id, "select", {
+ active: tab,
+ activeName: name,
+ previous: oldTab,
+ previousName: oldTab ? oldTab.dataset.name : null,
+ });
+
+ const jQuery = this.isLegacy && typeof window.jQuery === "function" ? window.jQuery : null;
+ if (jQuery) {
+ // simulate jQuery UI Tabs event
+ jQuery(this.container).trigger("wcftabsbeforeactivate", {
+ newTab: jQuery(tab),
+ oldTab: jQuery(oldTab),
+ newPanel: jQuery(newContent),
+ oldPanel: jQuery(oldContent!),
+ });
+ }
+
+ let location = window.location.href.replace(/#+[^#]*$/, "");
+ if (TabMenuSimple.getIdentifierFromHash() === name) {
+ location += window.location.hash;
+ } else {
+ location += "#" + name;
+ }
+
+ // update history
+ window.history.replaceState(undefined, "", location);
+ }
+
+ void import("../TabMenu").then((UiTabMenu) => {
+ UiTabMenu.scrollToTab(tab!);
+ });
+ }
+
+ /**
+ * Selects the first visible tab of the tab menu and return `true`. If there is no
+ * visible tab, `false` is returned.
+ *
+ * The visibility of a tab is determined by calling `elIsHidden` with the tab menu
+ * item as the parameter.
+ */
+ selectFirstVisible(): boolean {
+ let selectTab: HTMLLIElement | null = null;
+ this.tabs.forEach((tab) => {
+ if (!selectTab && !DomUtil.isHidden(tab)) {
+ selectTab = tab;
+ }
+ });
+
+ if (selectTab) {
+ this.select(null, selectTab, false);
+ }
+
+ return selectTab !== null;
+ }
+
+ /**
+ * Rebuilds all tabs, must be invoked after adding or removing of tabs.
+ *
+ * Warning: Do not remove tabs if you plan to add these later again or at least clone the nodes
+ * to prevent issues with already bound event listeners. Consider hiding them via CSS.
+ */
+ rebuild(): void {
+ const oldTabs = new Map<string, HTMLLIElement>(this.tabs);
+
+ this.validate();
+ this.init(oldTabs);
+ }
+
+ /**
+ * Returns true if this tab menu has a tab with provided name.
+ */
+ hasTab(name: string): boolean {
+ return this.tabs.has(name);
+ }
+
+ /**
+ * Handles clicks on a tab.
+ */
+ _onClick(event: MouseEvent | TouchEvent): void {
+ event.preventDefault();
+
+ const target = event.currentTarget as HTMLElement;
+ this.select(null, target.parentNode as HTMLLIElement);
+ }
+
+ /**
+ * Returns the tab name.
+ */
+ _getTabName(tab: HTMLLIElement): string | null {
+ let name = tab.dataset.name || null;
+
+ // handle legacy tab menus
+ if (!name) {
+ if (tab.childElementCount === 1 && tab.children[0].nodeName === "A") {
+ const link = tab.children[0] as HTMLAnchorElement;
+ if (/#([^#]+)$/.exec(link.href)) {
+ name = RegExp.$1;
+
+ if (document.getElementById(name) === null) {
+ name = null;
+ } else {
+ this.isLegacy = true;
+ tab.dataset.name = name;
+ }
+ }
+ }
+ }
+
+ return name;
+ }
+
+ /**
+ * Returns the currently active tab.
+ */
+ getActiveTab(): HTMLLIElement {
+ return document.querySelector("#" + this.container.id + " > nav > ul > li.active") as HTMLLIElement;
+ }
+
+ /**
+ * Returns the list of registered content containers.
+ */
+ getContainers(): Map<string, HTMLElement> {
+ return this.containers;
+ }
+
+ /**
+ * Returns the list of registered tabs.
+ */
+ getTabs(): Map<string, HTMLLIElement> {
+ return this.tabs;
+ }
+
+ static getIdentifierFromHash(): string {
+ if (/^#+([^/]+)+(?:\/.+)?/.exec(window.location.hash)) {
+ return RegExp.$1;
+ }
+
+ return "";
+ }
+}
+
+Core.enableLegacyInheritance(TabMenuSimple);
+
+export = TabMenuSimple;
--- /dev/null
+/**
+ * Provides a simple toggle to show or hide certain elements when the
+ * target element is checked.
+ *
+ * Be aware that the list of elements to show or hide accepts selectors
+ * which will be passed to `elBySel()`, causing only the first matched
+ * element to be used. If you require a whole list of elements identified
+ * by a single selector to be handled, please provide the actual list of
+ * elements instead.
+ *
+ * Usage:
+ *
+ * new UiToggleInput('input[name="foo"][value="bar"]', {
+ * show: ['#showThisContainer', '.makeThisVisibleToo'],
+ * hide: ['.notRelevantStuff', document.getElementById('fooBar')]
+ * });
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Toggle/Input
+ */
+
+import * as Core from "../../Core";
+import DomUtil from "../../Dom/Util";
+
+class UiToggleInput {
+ private readonly element: HTMLInputElement;
+ private readonly hide: HTMLElement[];
+ private readonly show: HTMLElement[];
+
+ /**
+ * Initializes a new input toggle.
+ */
+ constructor(elementSelector: string, options: Partial<ToggleOptions>) {
+ const element = document.querySelector(elementSelector) as HTMLInputElement;
+ if (element === null) {
+ throw new Error("Unable to find element by selector '" + elementSelector + "'.");
+ }
+
+ const type = element.nodeName === "INPUT" ? element.type : "";
+ if (type !== "checkbox" && type !== "radio") {
+ throw new Error("Illegal element, expected input[type='checkbox'] or input[type='radio'].");
+ }
+
+ this.element = element;
+
+ this.hide = this.getElements("hide", Array.isArray(options.hide) ? options.hide : []);
+ this.hide = this.getElements("show", Array.isArray(options.show) ? options.show : []);
+
+ this.element.addEventListener("change", (ev) => this.change(ev));
+
+ this.updateVisibility(this.show, this.element.checked);
+ this.updateVisibility(this.hide, !this.element.checked);
+ }
+
+ private getElements(type: string, items: ElementOrSelector[]): HTMLElement[] {
+ const elements: HTMLElement[] = [];
+ items.forEach((item) => {
+ let element: HTMLElement | null = null;
+ if (typeof item === "string") {
+ element = document.querySelector(item);
+ if (element === null) {
+ throw new Error(`Unable to find an element with the selector '${item}'.`);
+ }
+ } else if (item instanceof HTMLElement) {
+ element = item;
+ } else {
+ throw new TypeError(`The array '${type}' may only contain string selectors or DOM elements.`);
+ }
+
+ elements.push(element);
+ });
+
+ return elements;
+ }
+
+ /**
+ * Triggered when element is checked / unchecked.
+ */
+ private change(event: Event): void {
+ const target = event.currentTarget as HTMLInputElement;
+ const showElements = target.checked;
+
+ this.updateVisibility(this.show, showElements);
+ this.updateVisibility(this.hide, !showElements);
+ }
+
+ /**
+ * Loops through the target elements and shows / hides them.
+ */
+ private updateVisibility(elements: HTMLElement[], showElement: boolean) {
+ elements.forEach((element) => {
+ DomUtil[showElement ? "show" : "hide"](element);
+ });
+ }
+}
+
+Core.enableLegacyInheritance(UiToggleInput);
+
+export = UiToggleInput;
+
+type ElementOrSelector = Element | string;
+
+interface ToggleOptions {
+ show: ElementOrSelector[];
+ hide: ElementOrSelector[];
+}
--- /dev/null
+/**
+ * Provides enhanced tooltips.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/Tooltip
+ */
+
+import DomChangeListener from "../Dom/Change/Listener";
+import * as Environment from "../Environment";
+import * as UiAlignment from "./Alignment";
+
+let _pointer: HTMLElement;
+let _text: HTMLElement;
+let _tooltip: HTMLElement;
+
+/**
+ * Displays the tooltip on mouse enter.
+ */
+function mouseEnter(event: MouseEvent): void {
+ const element = event.currentTarget as HTMLElement;
+
+ let title = element.title.trim();
+ if (title !== "") {
+ element.dataset.tooltip = title;
+ element.setAttribute("aria-label", title);
+ element.removeAttribute("title");
+ }
+
+ title = element.dataset.tooltip || "";
+
+ // reset tooltip position
+ _tooltip.style.removeProperty("top");
+ _tooltip.style.removeProperty("left");
+
+ // ignore empty tooltip
+ if (!title.length) {
+ _tooltip.classList.remove("active");
+ return;
+ } else {
+ _tooltip.classList.add("active");
+ }
+
+ _text.textContent = title;
+ UiAlignment.set(_tooltip, element, {
+ horizontal: "center",
+ verticalOffset: 4,
+ pointer: true,
+ pointerClassNames: ["inverse"],
+ vertical: "top",
+ });
+}
+
+/**
+ * Hides the tooltip once the mouse leaves the element.
+ */
+function mouseLeave(): void {
+ _tooltip.classList.remove("active");
+}
+
+/**
+ * Initializes the tooltip element and binds event listener.
+ */
+export function setup(): void {
+ if (Environment.platform() !== "desktop") {
+ return;
+ }
+
+ _tooltip = document.createElement("div");
+ _tooltip.id = "balloonTooltip";
+ _tooltip.classList.add("balloonTooltip");
+ _tooltip.addEventListener("transitionend", () => {
+ if (!_tooltip.classList.contains("active")) {
+ // reset back to the upper left corner, prevent it from staying outside
+ // the viewport if the body overflow was previously hidden
+ ["bottom", "left", "right", "top"].forEach((property) => {
+ _tooltip.style.removeProperty(property);
+ });
+ }
+ });
+
+ _text = document.createElement("span");
+ _text.id = "balloonTooltipText";
+ _tooltip.appendChild(_text);
+
+ _pointer = document.createElement("span");
+ _pointer.classList.add("elementPointer");
+ _pointer.appendChild(document.createElement("span"));
+ _tooltip.appendChild(_pointer);
+
+ document.body.appendChild(_tooltip);
+
+ init();
+
+ DomChangeListener.add("WoltLabSuite/Core/Ui/Tooltip", init);
+ window.addEventListener("scroll", mouseLeave);
+}
+
+/**
+ * Initializes tooltip elements.
+ */
+export function init(): void {
+ document.querySelectorAll(".jsTooltip").forEach((element: HTMLElement) => {
+ element.classList.remove("jsTooltip");
+
+ const title = element.title.trim();
+ if (title.length) {
+ element.dataset.tooltip = title;
+ element.removeAttribute("title");
+ element.setAttribute("aria-label", title);
+
+ element.addEventListener("mouseenter", mouseEnter);
+ element.addEventListener("mouseleave", mouseLeave);
+ element.addEventListener("click", mouseLeave);
+ }
+ });
+}
--- /dev/null
+import * as Ajax from "../../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../Ajax/Data";
+import * as Core from "../../../Core";
+import * as Language from "../../../Language";
+import DomUtil from "../../../Dom/Util";
+
+interface AjaxResponse {
+ returnValues: {
+ lastEventID: number;
+ lastEventTime: number;
+ template?: string;
+ };
+}
+
+class UiUserActivityRecent implements AjaxCallbackObject {
+ private readonly containerId: string;
+ private readonly list: HTMLUListElement;
+ private readonly showMoreItem: HTMLLIElement;
+
+ constructor(containerId: string) {
+ this.containerId = containerId;
+ const container = document.getElementById(this.containerId)!;
+ this.list = container.querySelector(".recentActivityList") as HTMLUListElement;
+
+ const showMoreItem = document.createElement("li");
+ showMoreItem.className = "showMore";
+ if (this.list.childElementCount) {
+ showMoreItem.innerHTML = '<button class="small">' + Language.get("wcf.user.recentActivity.more") + "</button>";
+
+ const button = showMoreItem.children[0] as HTMLButtonElement;
+ button.addEventListener("click", (ev) => this.showMore(ev));
+ } else {
+ showMoreItem.innerHTML = "<small>" + Language.get("wcf.user.recentActivity.noMoreEntries") + "</small>";
+ }
+
+ this.list.appendChild(showMoreItem);
+ this.showMoreItem = showMoreItem;
+
+ container.querySelectorAll(".jsRecentActivitySwitchContext .button").forEach((button) => {
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ if (!button.classList.contains("active")) {
+ this.switchContext();
+ }
+ });
+ });
+ }
+
+ private showMore(event: MouseEvent): void {
+ event.preventDefault();
+
+ const button = this.showMoreItem.children[0] as HTMLButtonElement;
+ button.disabled = true;
+
+ Ajax.api(this, {
+ actionName: "load",
+ parameters: {
+ boxID: ~~this.list.dataset.boxId!,
+ filteredByFollowedUsers: Core.stringToBool(this.list.dataset.filteredByFollowedUsers || ""),
+ lastEventId: this.list.dataset.lastEventId!,
+ lastEventTime: this.list.dataset.lastEventTime!,
+ userID: ~~this.list.dataset.userId!,
+ },
+ });
+ }
+
+ private switchContext(): void {
+ Ajax.api(
+ this,
+ {
+ actionName: "switchContext",
+ },
+ () => {
+ window.location.hash = `#${this.containerId}`;
+ window.location.reload();
+ },
+ );
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ if (data.returnValues.template) {
+ DomUtil.insertHtml(data.returnValues.template, this.showMoreItem, "before");
+
+ this.list.dataset.lastEventTime = data.returnValues.lastEventTime.toString();
+ this.list.dataset.lastEventId = data.returnValues.lastEventID.toString();
+
+ const button = this.showMoreItem.children[0] as HTMLButtonElement;
+ button.disabled = false;
+ } else {
+ this.showMoreItem.innerHTML = "<small>" + Language.get("wcf.user.recentActivity.noMoreEntries") + "</small>";
+ }
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ className: "wcf\\data\\user\\activity\\event\\UserActivityEventAction",
+ },
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiUserActivityRecent);
+
+export = UiUserActivityRecent;
--- /dev/null
+/**
+ * Deletes the current user cover photo.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/User/CoverPhoto/Delete
+ */
+
+import * as Ajax from "../../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "../../../Ajax/Data";
+import DomUtil from "../../../Dom/Util";
+import * as EventHandler from "../../../Event/Handler";
+import * as Language from "../../../Language";
+import * as UiConfirmation from "../../Confirmation";
+import * as UiNotification from "../../Notification";
+
+interface AjaxResponse extends ResponseData {
+ returnValues: {
+ url: string;
+ };
+}
+
+class UiUserCoverPhotoDelete implements AjaxCallbackObject {
+ private readonly button: HTMLAnchorElement;
+ private readonly userId: number;
+
+ /**
+ * Initializes the delete handler and enables the delete button on upload.
+ */
+ constructor(userId: number) {
+ this.button = document.querySelector(".jsButtonDeleteCoverPhoto") as HTMLAnchorElement;
+ this.button.addEventListener("click", (ev) => this._click(ev));
+ this.userId = userId;
+
+ EventHandler.add("com.woltlab.wcf.user", "coverPhoto", (data) => {
+ if (typeof data.url === "string" && data.url.length > 0) {
+ DomUtil.show(this.button.parentElement!);
+ }
+ });
+ }
+
+ /**
+ * Handles clicks on the delete button.
+ */
+ _click(event: MouseEvent): void {
+ event.preventDefault();
+
+ UiConfirmation.show({
+ confirm: () => Ajax.api(this),
+ message: Language.get("wcf.user.coverPhoto.delete.confirmMessage"),
+ });
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ const photo = document.querySelector(".userProfileCoverPhoto") as HTMLElement;
+ photo.style.setProperty("background-image", `url(${data.returnValues.url})`, "");
+
+ DomUtil.hide(this.button.parentElement!);
+
+ UiNotification.show();
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "deleteCoverPhoto",
+ className: "wcf\\data\\user\\UserProfileAction",
+ parameters: {
+ userID: this.userId,
+ },
+ },
+ };
+ }
+}
+
+let uiUserCoverPhotoDelete: UiUserCoverPhotoDelete | undefined;
+
+/**
+ * Initializes the delete handler and enables the delete button on upload.
+ */
+export function init(userId: number): void {
+ if (!uiUserCoverPhotoDelete) {
+ uiUserCoverPhotoDelete = new UiUserCoverPhotoDelete(userId);
+ }
+}
--- /dev/null
+/**
+ * Uploads the user cover photo via AJAX.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/User/CoverPhoto/Upload
+ */
+
+import * as Core from "../../../Core";
+import DomUtil from "../../../Dom/Util";
+import * as EventHandler from "../../../Event/Handler";
+import { ResponseData } from "../../../Ajax/Data";
+import * as UiDialog from "../../Dialog";
+import * as UiNotification from "../../Notification";
+import Upload from "../../../Upload";
+
+interface AjaxResponse extends ResponseData {
+ returnValues: {
+ errorMessage?: string;
+ url?: string;
+ };
+}
+
+/**
+ * @constructor
+ */
+class UiUserCoverPhotoUpload extends Upload {
+ private readonly userId: number;
+
+ constructor(userId: number) {
+ super("coverPhotoUploadButtonContainer", "coverPhotoUploadPreview", {
+ action: "uploadCoverPhoto",
+ className: "wcf\\data\\user\\UserProfileAction",
+ });
+
+ this.userId = userId;
+ }
+
+ protected _getParameters(): ArbitraryObject {
+ return {
+ userID: this.userId,
+ };
+ }
+
+ protected _success(uploadId: number, data: AjaxResponse): void {
+ // remove or display the error message
+ DomUtil.innerError(this._button, data.returnValues.errorMessage);
+
+ // remove the upload progress
+ this._target.innerHTML = "";
+
+ if (data.returnValues.url) {
+ const photo = document.querySelector(".userProfileCoverPhoto") as HTMLElement;
+ photo.style.setProperty("background-image", `url(${data.returnValues.url})`, "");
+
+ UiDialog.close("userProfileCoverPhotoUpload");
+ UiNotification.show();
+
+ EventHandler.fire("com.woltlab.wcf.user", "coverPhoto", {
+ url: data.returnValues.url,
+ });
+ }
+ }
+}
+
+Core.enableLegacyInheritance(UiUserCoverPhotoUpload);
+
+export = UiUserCoverPhotoUpload;
--- /dev/null
+/**
+ * Simple notification overlay.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/User/Editor
+ */
+
+import * as Ajax from "../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup } from "../../Ajax/Data";
+import * as Core from "../../Core";
+import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
+import DomUtil from "../../Dom/Util";
+import * as Language from "../../Language";
+import * as StringUtil from "../../StringUtil";
+import UiDialog from "../Dialog";
+import * as UiNotification from "../Notification";
+
+class UserEditor implements AjaxCallbackObject, DialogCallbackObject {
+ private actionName = "";
+ private readonly header: HTMLElement;
+
+ constructor() {
+ this.header = document.querySelector(".userProfileUser") as HTMLElement;
+
+ ["ban", "disableAvatar", "disableCoverPhoto", "disableSignature", "enable"].forEach((action) => {
+ const button = document.querySelector(
+ ".userProfileButtonMenu .jsButtonUser" + StringUtil.ucfirst(action),
+ ) as HTMLElement;
+
+ // The button is missing if the current user lacks the permission.
+ if (button) {
+ button.dataset.action = action;
+ button.addEventListener("click", (ev) => this._click(ev));
+ }
+ });
+ }
+
+ /**
+ * Handles clicks on action buttons.
+ */
+ _click(event: MouseEvent): void {
+ event.preventDefault();
+
+ const target = event.currentTarget as HTMLElement;
+ const action = target.dataset.action || "";
+ let actionName = "";
+ switch (action) {
+ case "ban":
+ if (Core.stringToBool(this.header.dataset.banned || "")) {
+ actionName = "unban";
+ }
+ break;
+
+ case "disableAvatar":
+ if (Core.stringToBool(this.header.dataset.disableAvatar || "")) {
+ actionName = "enableAvatar";
+ }
+ break;
+
+ case "disableCoverPhoto":
+ if (Core.stringToBool(this.header.dataset.disableCoverPhoto || "")) {
+ actionName = "enableCoverPhoto";
+ }
+ break;
+
+ case "disableSignature":
+ if (Core.stringToBool(this.header.dataset.disableSignature || "")) {
+ actionName = "enableSignature";
+ }
+ break;
+
+ case "enable":
+ actionName = Core.stringToBool(this.header.dataset.isDisabled || "") ? "enable" : "disable";
+ break;
+ }
+
+ if (actionName === "") {
+ this.actionName = action;
+
+ UiDialog.open(this);
+ } else {
+ Ajax.api(this, {
+ actionName: actionName,
+ });
+ }
+ }
+
+ /**
+ * Handles form submit and input validation.
+ */
+ _submit(event: Event): void {
+ event.preventDefault();
+
+ const label = document.getElementById("wcfUiUserEditorExpiresLabel") as HTMLElement;
+
+ let expires = "";
+ let errorMessage = "";
+ const neverExpires = document.getElementById("wcfUiUserEditorNeverExpires") as HTMLInputElement;
+ if (!neverExpires.checked) {
+ const expireValue = document.getElementById("wcfUiUserEditorExpiresDatePicker") as HTMLInputElement;
+ expires = expireValue.value;
+ if (expires === "") {
+ errorMessage = Language.get("wcf.global.form.error.empty");
+ }
+ }
+
+ DomUtil.innerError(label, errorMessage);
+
+ const parameters = {};
+ parameters[this.actionName + "Expires"] = expires;
+ const reason = document.getElementById("wcfUiUserEditorReason") as HTMLTextAreaElement;
+ parameters[this.actionName + "Reason"] = reason.value.trim();
+
+ Ajax.api(this, {
+ actionName: this.actionName,
+ parameters: parameters,
+ });
+ }
+
+ _ajaxSuccess(data): void {
+ let button: HTMLElement;
+ switch (data.actionName) {
+ case "ban":
+ case "unban": {
+ this.header.dataset.banned = data.actionName === "ban" ? "true" : "false";
+ button = document.querySelector(".userProfileButtonMenu .jsButtonUserBan") as HTMLElement;
+ button.textContent = Language.get("wcf.user." + (data.actionName === "ban" ? "unban" : "ban"));
+
+ const contentTitle = this.header.querySelector(".contentTitle") as HTMLElement;
+ let banIcon = contentTitle.querySelector(".jsUserBanned") as HTMLElement;
+ if (data.actionName === "ban") {
+ banIcon = document.createElement("span");
+ banIcon.className = "icon icon24 fa-lock jsUserBanned jsTooltip";
+ banIcon.title = data.returnValues;
+ contentTitle.appendChild(banIcon);
+ } else if (banIcon) {
+ banIcon.remove();
+ }
+ break;
+ }
+
+ case "disableAvatar":
+ case "enableAvatar":
+ this.header.dataset.disableAvatar = data.actionName === "disableAvatar" ? "true" : "false";
+ button = document.querySelector(".userProfileButtonMenu .jsButtonUserDisableAvatar") as HTMLElement;
+ button.textContent = Language.get(
+ "wcf.user." + (data.actionName === "disableAvatar" ? "enable" : "disable") + "Avatar",
+ );
+ break;
+
+ case "disableCoverPhoto":
+ case "enableCoverPhoto":
+ this.header.dataset.disableCoverPhoto = data.actionName === "disableCoverPhoto" ? "true" : "false";
+ button = document.querySelector(".userProfileButtonMenu .jsButtonUserDisableCoverPhoto") as HTMLElement;
+ button.textContent = Language.get(
+ "wcf.user." + (data.actionName === "disableCoverPhoto" ? "enable" : "disable") + "CoverPhoto",
+ );
+ break;
+
+ case "disableSignature":
+ case "enableSignature":
+ this.header.dataset.disableSignature = data.actionName === "disableSignature" ? "true" : "false";
+ button = document.querySelector(".userProfileButtonMenu .jsButtonUserDisableSignature") as HTMLElement;
+ button.textContent = Language.get(
+ "wcf.user." + (data.actionName === "disableSignature" ? "enable" : "disable") + "Signature",
+ );
+ break;
+
+ case "enable":
+ case "disable":
+ this.header.dataset.isDisabled = data.actionName === "disable" ? "true" : "false";
+ button = document.querySelector(".userProfileButtonMenu .jsButtonUserEnable") as HTMLElement;
+ button.textContent = Language.get("wcf.acp.user." + (data.actionName === "enable" ? "disable" : "enable"));
+ break;
+ }
+
+ if (["ban", "disableAvatar", "disableCoverPhoto", "disableSignature"].indexOf(data.actionName) !== -1) {
+ UiDialog.close(this);
+ }
+
+ UiNotification.show();
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ className: "wcf\\data\\user\\UserAction",
+ objectIDs: [+this.header.dataset.objectId!],
+ },
+ };
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "wcfUiUserEditor",
+ options: {
+ onSetup: (content) => {
+ const checkbox = document.getElementById("wcfUiUserEditorNeverExpires") as HTMLInputElement;
+ checkbox.addEventListener("change", () => {
+ const settings = document.getElementById("wcfUiUserEditorExpiresSettings") as HTMLElement;
+ DomUtil[checkbox.checked ? "hide" : "show"](settings);
+ });
+
+ const submitButton = content.querySelector("button.buttonPrimary") as HTMLButtonElement;
+ submitButton.addEventListener("click", this._submit.bind(this));
+ },
+ onShow: (content) => {
+ UiDialog.setTitle("wcfUiUserEditor", Language.get("wcf.user." + this.actionName + ".confirmMessage"));
+
+ const reason = document.getElementById("wcfUiUserEditorReason") as HTMLElement;
+ let label = reason.nextElementSibling as HTMLElement;
+ const phrase = "wcf.user." + this.actionName + ".reason.description";
+ label.textContent = Language.get(phrase);
+ if (label.textContent === phrase) {
+ DomUtil.hide(label);
+ } else {
+ DomUtil.show(label);
+ }
+
+ label = document.getElementById("wcfUiUserEditorNeverExpires")!.nextElementSibling as HTMLElement;
+ label.textContent = Language.get("wcf.user." + this.actionName + ".neverExpires");
+
+ label = content.querySelector('label[for="wcfUiUserEditorExpires"]') as HTMLElement;
+ label.textContent = Language.get("wcf.user." + this.actionName + ".expires");
+
+ label = document.getElementById("wcfUiUserEditorExpiresLabel") as HTMLElement;
+ label.textContent = Language.get("wcf.user." + this.actionName + ".expires.description");
+ },
+ },
+ source: `<div class="section">
+ <dl>
+ <dt><label for="wcfUiUserEditorReason">${Language.get("wcf.global.reason")}</label></dt>
+ <dd><textarea id="wcfUiUserEditorReason" cols="40" rows="3"></textarea><small></small></dd>
+ </dl>
+ <dl>
+ <dt></dt>
+ <dd><label><input type="checkbox" id="wcfUiUserEditorNeverExpires" checked> <span></span></label></dd>
+ </dl>
+ <dl id="wcfUiUserEditorExpiresSettings" style="display: none">
+ <dt><label for="wcfUiUserEditorExpires"></label></dt>
+ <dd>
+ <input type="date" name="wcfUiUserEditorExpires" id="wcfUiUserEditorExpires" class="medium" min="${new Date(
+ window.TIME_NOW * 1000,
+ ).toISOString()}" data-ignore-timezone="true">
+ <small id="wcfUiUserEditorExpiresLabel"></small>
+ </dd>
+ </dl>
+ </div>
+ <div class="formSubmit">
+ <button class="buttonPrimary">${Language.get("wcf.global.button.submit")}</button>
+ </div>`,
+ };
+ }
+}
+
+/**
+ * Initializes the user editor.
+ */
+export function init(): void {
+ new UserEditor();
+}
--- /dev/null
+/**
+ * Provides global helper methods to interact with ignored content.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/User/Ignore
+ */
+
+import DomChangeListener from "../../Dom/Change/Listener";
+
+const _availableMessages = document.getElementsByClassName("ignoredUserMessage");
+const _knownMessages = new Set<HTMLElement>();
+
+/**
+ * Adds ignored messages to the collection.
+ *
+ * @protected
+ */
+function rebuild() {
+ for (let i = 0, length = _availableMessages.length; i < length; i++) {
+ const message = _availableMessages[i] as HTMLElement;
+
+ if (!_knownMessages.has(message)) {
+ message.addEventListener("click", showMessage, { once: true });
+
+ _knownMessages.add(message);
+ }
+ }
+}
+
+/**
+ * Reveals a message on click/tap and disables the listener.
+ */
+function showMessage(event: MouseEvent): void {
+ event.preventDefault();
+
+ const message = event.currentTarget as HTMLElement;
+ message.classList.remove("ignoredUserMessage");
+ _knownMessages.delete(message);
+
+ // Firefox selects the entire message on click for no reason
+ window.getSelection()!.removeAllRanges();
+}
+
+/**
+ * Initializes the click handler for each ignored message and listens for
+ * newly inserted messages.
+ */
+export function init(): void {
+ rebuild();
+
+ DomChangeListener.add("WoltLabSuite/Core/Ui/User/Ignore", rebuild);
+}
--- /dev/null
+/**
+ * Object-based user list.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/User/List
+ */
+
+import * as Ajax from "../../Ajax";
+import * as Core from "../../Core";
+import DomUtil from "../../Dom/Util";
+import UiDialog from "../Dialog";
+import UiPagination from "../Pagination";
+import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../Ajax/Data";
+import { DialogCallbackObject, DialogData, DialogCallbackSetup } from "../Dialog/Data";
+
+/**
+ * @constructor
+ */
+class UiUserList implements AjaxCallbackObject, DialogCallbackObject {
+ private readonly cache = new Map<number, string>();
+ private readonly options: AjaxRequestOptions;
+ private pageCount = 0;
+ private pageNo = 1;
+
+ /**
+ * Initializes the user list.
+ *
+ * @param {object} options list of initialization options
+ */
+ constructor(options: AjaxRequestOptions) {
+ this.options = Core.extend(
+ {
+ className: "",
+ dialogTitle: "",
+ parameters: {},
+ },
+ options,
+ ) as AjaxRequestOptions;
+ }
+
+ /**
+ * Opens the user list.
+ */
+ open(): void {
+ this.pageNo = 1;
+ this.showPage();
+ }
+
+ /**
+ * Shows the current or given page.
+ */
+ private showPage(pageNo?: number): void {
+ if (typeof pageNo === "number") {
+ this.pageNo = +pageNo;
+ }
+
+ if (this.pageCount !== 0 && (this.pageNo < 1 || this.pageNo > this.pageCount)) {
+ throw new RangeError(`pageNo must be between 1 and ${this.pageCount} (${this.pageNo} given).`);
+ }
+
+ if (this.cache.has(this.pageNo)) {
+ const dialog = UiDialog.open(this, this.cache.get(this.pageNo)) as DialogData;
+
+ if (this.pageCount > 1) {
+ const element = dialog.content.querySelector(".jsPagination") as HTMLElement;
+ if (element !== null) {
+ new UiPagination(element, {
+ activePage: this.pageNo,
+ maxPage: this.pageCount,
+
+ callbackSwitch: this.showPage.bind(this),
+ });
+ }
+
+ // scroll to the list start
+ const container = dialog.content.parentElement!;
+ if (container.scrollTop > 0) {
+ container.scrollTop = 0;
+ }
+ }
+ } else {
+ this.options.parameters.pageNo = this.pageNo;
+
+ Ajax.api(this, {
+ parameters: this.options.parameters,
+ });
+ }
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ if (data.returnValues.pageCount !== undefined) {
+ this.pageCount = ~~data.returnValues.pageCount;
+ }
+
+ this.cache.set(this.pageNo, data.returnValues.template);
+ this.showPage();
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "getGroupedUserList",
+ className: this.options.className,
+ interfaceName: "wcf\\data\\IGroupedUserListAction",
+ },
+ };
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: DomUtil.getUniqueId(),
+ options: {
+ title: this.options.dialogTitle,
+ },
+ source: null,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiUserList);
+
+export = UiUserList;
+
+interface AjaxRequestOptions {
+ className: string;
+ dialogTitle: string;
+ parameters: {
+ [key: string]: any;
+ };
+}
+
+interface AjaxResponse extends DatabaseObjectActionResponse {
+ returnValues: {
+ pageCount?: number;
+ template: string;
+ };
+}
--- /dev/null
+import QrCreator from "qr-creator";
+
+export function render(container: HTMLElement): void {
+ const secret: HTMLElement | null = container.querySelector(".totpSecret");
+ if (!secret) {
+ return;
+ }
+
+ const accountName = secret.dataset.accountname;
+ if (!accountName) {
+ return;
+ }
+
+ const issuer = secret.dataset.issuer;
+ const label = (issuer ? `${issuer}:` : "") + accountName;
+
+ const canvas = container.querySelector("canvas");
+ QrCreator.render(
+ {
+ text: `otpauth://totp/${encodeURIComponent(label)}?secret=${encodeURIComponent(secret.textContent!)}${
+ issuer ? `&issuer=${encodeURIComponent(issuer)}` : ""
+ }`,
+ size: canvas && canvas.clientWidth ? canvas.clientWidth : 200,
+ },
+ canvas || container,
+ );
+}
+
+export default render;
+
+export function renderAll(): void {
+ document.querySelectorAll(".totpSecretContainer").forEach((el: HTMLElement) => render(el));
+}
--- /dev/null
+/**
+ * Adds a password strength meter to a password input and exposes
+ * zxcbn's verdict as sibling input.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/User/PasswordStrength
+ */
+
+import * as Language from "../../Language";
+import DomUtil from "../../Dom/Util";
+
+// zxcvbn is imported for the types only. It is loaded on demand, due to its size.
+import type zxcvbn from "zxcvbn";
+
+type StaticDictionary = string[];
+
+const STATIC_DICTIONARY: StaticDictionary = [];
+
+const siteName = document.querySelector('meta[property="og:site_name"]')?.getAttribute("content");
+if (siteName) {
+ STATIC_DICTIONARY.push(siteName);
+}
+
+function flatMap<T, U>(array: T[], callback: (x: T) => U[]): U[] {
+ return array.map(callback).reduce((carry, item) => {
+ return carry.concat(item);
+ }, [] as U[]);
+}
+
+function splitIntoWords(value: string): string[] {
+ return ([] as string[]).concat(value, value.split(/\W+/));
+}
+
+function initializeFeedbacker(Feedback: typeof zxcvbn.Feedback): zxcvbn.Feedback {
+ const localizedPhrases: typeof Feedback.default_phrases = {} as typeof Feedback.default_phrases;
+
+ Object.entries(Feedback.default_phrases).forEach(([type, phrases]) => {
+ localizedPhrases[type] = {};
+ Object.entries(phrases).forEach(([identifier, phrase]) => {
+ const languageItem = `wcf.user.password.zxcvbn.${type}.${identifier}`;
+ const localizedValue = Language.get(languageItem);
+ localizedPhrases[type][identifier] = localizedValue !== languageItem ? localizedValue : phrase;
+ });
+ });
+
+ return new Feedback(localizedPhrases);
+}
+
+class PasswordStrength {
+ private zxcvbn: typeof zxcvbn;
+ private relatedInputs: HTMLInputElement[];
+ private staticDictionary: StaticDictionary;
+ private feedbacker: zxcvbn.Feedback;
+
+ private readonly wrapper = document.createElement("div");
+ private readonly score = document.createElement("span");
+ private readonly verdictResult = document.createElement("input");
+
+ constructor(private readonly input: HTMLInputElement, options: Partial<Options>) {
+ void import("zxcvbn").then(({ default: zxcvbn }) => {
+ this.zxcvbn = zxcvbn;
+
+ if (options.relatedInputs) {
+ this.relatedInputs = options.relatedInputs;
+ }
+ if (options.staticDictionary) {
+ this.staticDictionary = options.staticDictionary;
+ }
+
+ this.feedbacker = initializeFeedbacker(zxcvbn.Feedback);
+
+ this.wrapper.className = "inputAddon inputAddonPasswordStrength";
+ this.input.parentNode!.insertBefore(this.wrapper, this.input);
+ this.wrapper.appendChild(this.input);
+
+ const rating = document.createElement("div");
+ rating.className = "passwordStrengthRating";
+
+ const ratingLabel = document.createElement("small");
+ ratingLabel.textContent = Language.get("wcf.user.password.strength");
+ rating.appendChild(ratingLabel);
+
+ this.score.className = "passwordStrengthScore";
+ this.score.dataset.score = "-1";
+ rating.appendChild(this.score);
+
+ this.wrapper.appendChild(rating);
+
+ this.verdictResult.type = "hidden";
+ this.verdictResult.name = `${this.input.name}_passwordStrengthVerdict`;
+ this.wrapper.parentNode!.insertBefore(this.verdictResult, this.wrapper);
+
+ this.input.addEventListener("input", (ev) => this.evaluate(ev));
+ this.relatedInputs.forEach((input) => input.addEventListener("input", (ev) => this.evaluate(ev)));
+ if (this.input.value.trim() !== "") {
+ this.evaluate();
+ }
+ });
+ }
+
+ private evaluate(event?: Event) {
+ const dictionary = flatMap(
+ STATIC_DICTIONARY.concat(
+ this.staticDictionary,
+ this.relatedInputs.map((input) => input.value.trim()),
+ ),
+ splitIntoWords,
+ ).filter((value) => value.length > 0);
+
+ const value = this.input.value.trim();
+
+ // To bound runtime latency for really long passwords, consider sending zxcvbn() only
+ // the first 100 characters or so of user input.
+ const verdict = this.zxcvbn(value.substr(0, 100), dictionary);
+ verdict.feedback = this.feedbacker.from_result(verdict);
+
+ this.score.dataset.score = value.length === 0 ? "-1" : verdict.score.toString();
+
+ if (event !== undefined) {
+ // Do not overwrite the value on page load.
+ DomUtil.innerError(this.wrapper, verdict.feedback.warning);
+ }
+
+ this.verdictResult.value = JSON.stringify(verdict);
+ }
+}
+
+export = PasswordStrength;
+
+interface Options {
+ relatedInputs: PasswordStrength["relatedInputs"];
+ staticDictionary: PasswordStrength["staticDictionary"];
+ feedbacker: PasswordStrength["feedbacker"];
+}
--- /dev/null
+/**
+ * Default implementation for user interaction menu items used in the user profile.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Abstract
+ */
+
+import * as Ajax from "../../../../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "../../../../../Ajax/Data";
+import * as Core from "../../../../../Core";
+
+abstract class UiUserProfileMenuItemAbstract implements AjaxCallbackObject {
+ protected readonly _button = document.createElement("a");
+ protected _isActive: boolean;
+ protected readonly _listItem = document.createElement("li");
+ protected readonly _userId: number;
+
+ /**
+ * Creates a new user profile menu item.
+ */
+ protected constructor(userId: number, isActive: boolean) {
+ this._userId = userId;
+ this._isActive = isActive;
+
+ this._initButton();
+ this._updateButton();
+ }
+
+ /**
+ * Initializes the menu item.
+ */
+ protected _initButton(): void {
+ this._button.href = "#";
+ this._button.addEventListener("click", (ev) => this._toggle(ev));
+ this._listItem.appendChild(this._button);
+
+ const menu = document.querySelector(`.userProfileButtonMenu[data-menu="interaction"]`) as HTMLElement;
+ menu.insertAdjacentElement("afterbegin", this._listItem);
+ }
+
+ /**
+ * Handles clicks on the menu item button.
+ */
+ protected _toggle(event: MouseEvent): void {
+ event.preventDefault();
+
+ Ajax.api(this, {
+ actionName: this._getAjaxActionName(),
+ parameters: {
+ data: {
+ userID: this._userId,
+ },
+ },
+ });
+ }
+
+ /**
+ * Updates the button state and label.
+ *
+ * @protected
+ */
+ protected _updateButton(): void {
+ this._button.textContent = this._getLabel();
+ if (this._isActive) {
+ this._listItem.classList.add("active");
+ } else {
+ this._listItem.classList.remove("active");
+ }
+ }
+
+ /**
+ * Returns the button label.
+ */
+ protected _getLabel(): string {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+
+ throw new Error("Implement me!");
+ }
+
+ /**
+ * Returns the Ajax action name.
+ */
+ protected _getAjaxActionName(): string {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+
+ throw new Error("Implement me!");
+ }
+
+ /**
+ * Handles successful Ajax requests.
+ */
+ _ajaxSuccess(_data: ResponseData): void {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+
+ throw new Error("Implement me!");
+ }
+
+ /**
+ * Returns the default Ajax request data
+ */
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+
+ throw new Error("Implement me!");
+ }
+}
+
+Core.enableLegacyInheritance(UiUserProfileMenuItemAbstract);
+
+export = UiUserProfileMenuItemAbstract;
--- /dev/null
+import * as Core from "../../../../../Core";
+import * as Language from "../../../../../Language";
+import { AjaxCallbackSetup, ResponseData } from "../../../../../Ajax/Data";
+import * as UiNotification from "../../../../Notification";
+import UiUserProfileMenuItemAbstract from "./Abstract";
+
+interface AjaxResponse extends ResponseData {
+ returnValues: {
+ following: 1 | 0;
+ };
+}
+
+class UiUserProfileMenuItemFollow extends UiUserProfileMenuItemAbstract {
+ constructor(userId: number, isActive: boolean) {
+ super(userId, isActive);
+ }
+
+ protected _getLabel(): string {
+ return Language.get("wcf.user.button." + (this._isActive ? "un" : "") + "follow");
+ }
+
+ protected _getAjaxActionName(): string {
+ return this._isActive ? "unfollow" : "follow";
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ this._isActive = !!data.returnValues.following;
+ this._updateButton();
+
+ UiNotification.show();
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ className: "wcf\\data\\user\\follow\\UserFollowAction",
+ },
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiUserProfileMenuItemFollow);
+
+export = UiUserProfileMenuItemFollow;
--- /dev/null
+import * as Core from "../../../../../Core";
+import * as Language from "../../../../../Language";
+import { AjaxCallbackSetup, ResponseData } from "../../../../../Ajax/Data";
+import * as UiNotification from "../../../../Notification";
+import UiUserProfileMenuItemAbstract from "./Abstract";
+
+interface AjaxResponse extends ResponseData {
+ returnValues: {
+ isIgnoredUser: 1 | 0;
+ };
+}
+
+class UiUserProfileMenuItemIgnore extends UiUserProfileMenuItemAbstract {
+ constructor(userId: number, isActive: boolean) {
+ super(userId, isActive);
+ }
+
+ _getLabel(): string {
+ return Language.get("wcf.user.button." + (this._isActive ? "un" : "") + "ignore");
+ }
+
+ _getAjaxActionName(): string {
+ return this._isActive ? "unignore" : "ignore";
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ this._isActive = !!data.returnValues.isIgnoredUser;
+ this._updateButton();
+
+ UiNotification.show();
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ className: "wcf\\data\\user\\ignore\\UserIgnoreAction",
+ },
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiUserProfileMenuItemIgnore);
+
+export = UiUserProfileMenuItemIgnore;
--- /dev/null
+/**
+ * Provides suggestions for users, optionally supporting groups.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/User/Search/Input
+ * @see module:WoltLabSuite/Core/Ui/Search/Input
+ */
+
+import * as Core from "../../../Core";
+import { SearchInputOptions } from "../../Search/Data";
+import UiSearchInput from "../../Search/Input";
+
+class UiUserSearchInput extends UiSearchInput {
+ constructor(element: HTMLInputElement, options: UserSearchInputOptions) {
+ const includeUserGroups = Core.isPlainObject(options) && options.includeUserGroups === true;
+
+ options = Core.extend(
+ {
+ ajax: {
+ className: "wcf\\data\\user\\UserAction",
+ parameters: {
+ data: {
+ includeUserGroups: includeUserGroups ? 1 : 0,
+ },
+ },
+ },
+ },
+ options,
+ );
+
+ super(element, options);
+ }
+
+ protected createListItem(item: UserListItemData): HTMLLIElement {
+ const listItem = super.createListItem(item);
+ listItem.dataset.type = item.type;
+
+ const box = document.createElement("div");
+ box.className = "box16";
+ box.innerHTML = item.type === "group" ? `<span class="icon icon16 fa-users"></span>` : item.icon;
+ box.appendChild(listItem.children[0]);
+ listItem.appendChild(box);
+
+ return listItem;
+ }
+}
+
+Core.enableLegacyInheritance(UiUserSearchInput);
+
+export = UiUserSearchInput;
+
+// https://stackoverflow.com/a/50677584/782822
+// This is a dirty hack, because the ListItemData cannot be exported for compatibility reasons.
+type FirstArgument<T> = T extends (arg1: infer U, ...args: any[]) => any ? U : never;
+
+interface UserListItemData extends FirstArgument<UiSearchInput["createListItem"]> {
+ type: "user" | "group";
+ icon: string;
+}
+
+interface UserSearchInputOptions extends SearchInputOptions {
+ includeUserGroups?: boolean;
+}
--- /dev/null
+/**
+ * Handles the deletion of a user session.
+ *
+ * @author Joshua Ruesweg
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/User/Session/Delete
+ */
+
+import * as Ajax from "../../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../../Ajax/Data";
+import * as UiNotification from "../../Notification";
+import * as UiConfirmation from "../../Confirmation";
+import * as Language from "../../../Language";
+
+export class UiUserSessionDelete implements AjaxCallbackObject {
+ private readonly knownElements = new Map<string, HTMLElement>();
+
+ /**
+ * Initializes the session delete buttons.
+ */
+ constructor() {
+ document.querySelectorAll(".sessionDeleteButton").forEach((element: HTMLElement) => {
+ if (!element.dataset.sessionId) {
+ throw new Error(`No sessionId for session delete button given.`);
+ }
+
+ if (!this.knownElements.has(element.dataset.sessionId)) {
+ element.addEventListener("click", (ev) => this.delete(element, ev));
+
+ this.knownElements.set(element.dataset.sessionId, element);
+ }
+ });
+ }
+
+ /**
+ * Opens the user trophy list for a specific user.
+ */
+ private delete(element: HTMLElement, event: MouseEvent): void {
+ event.preventDefault();
+
+ UiConfirmation.show({
+ message: Language.get("wcf.user.security.deleteSession.confirmMessage"),
+ confirm: (_parameters) => {
+ Ajax.api(this, {
+ sessionID: element.dataset.sessionId,
+ });
+ },
+ });
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ const element = this.knownElements.get(data.sessionID);
+
+ if (element !== undefined) {
+ const sessionItem = element.closest("li");
+
+ if (sessionItem !== null) {
+ sessionItem.remove();
+ }
+ }
+
+ UiNotification.show();
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ url: "index.php?delete-session/&t=" + window.SECURITY_TOKEN,
+ };
+ }
+}
+
+export default UiUserSessionDelete;
+
+interface AjaxResponse extends DatabaseObjectActionResponse {
+ sessionID: string;
+}
--- /dev/null
+/**
+ * Handles the user trophy dialog.
+ *
+ * @author Joshua Ruesweg
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Ui/User/Trophy/List
+ */
+
+import * as Ajax from "../../../Ajax";
+import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../../Ajax/Data";
+import * as Core from "../../../Core";
+import { DialogCallbackObject, DialogData, DialogCallbackSetup } from "../../Dialog/Data";
+import DomChangeListener from "../../../Dom/Change/Listener";
+import UiDialog from "../../Dialog";
+import UiPagination from "../../Pagination";
+
+class CacheData {
+ private readonly cache = new Map<number, string>();
+
+ constructor(readonly pageCount: number, readonly title: string) {}
+
+ has(pageNo: number): boolean {
+ return this.cache.has(pageNo);
+ }
+
+ get(pageNo: number): string | undefined {
+ return this.cache.get(pageNo);
+ }
+
+ set(pageNo: number, template: string): void {
+ this.cache.set(pageNo, template);
+ }
+}
+
+class UiUserTrophyList implements AjaxCallbackObject, DialogCallbackObject {
+ private readonly cache = new Map<number, CacheData>();
+ private currentPageNo = 0;
+ private currentUser = 0;
+ private readonly knownElements = new WeakSet<HTMLElement>();
+
+ /**
+ * Initializes the user trophy list.
+ */
+ constructor() {
+ DomChangeListener.add("WoltLabSuite/Core/Ui/User/Trophy/List", () => this.rebuild());
+
+ this.rebuild();
+ }
+
+ /**
+ * Adds event userTrophyOverlayList elements.
+ */
+ private rebuild(): void {
+ document.querySelectorAll(".userTrophyOverlayList").forEach((element: HTMLElement) => {
+ if (!this.knownElements.has(element)) {
+ element.addEventListener("click", (ev) => this.open(element, ev));
+
+ this.knownElements.add(element);
+ }
+ });
+ }
+
+ /**
+ * Opens the user trophy list for a specific user.
+ */
+ private open(element: HTMLElement, event: MouseEvent): void {
+ event.preventDefault();
+
+ this.currentPageNo = 1;
+ this.currentUser = +element.dataset.userId!;
+ this.showPage();
+ }
+
+ /**
+ * Shows the current or given page.
+ */
+ private showPage(pageNo?: number): void {
+ if (pageNo !== undefined) {
+ this.currentPageNo = pageNo;
+ }
+
+ const data = this.cache.get(this.currentUser);
+ if (data) {
+ // validate pageNo
+ if (data.pageCount !== 0 && (this.currentPageNo < 1 || this.currentPageNo > data.pageCount)) {
+ throw new RangeError(`pageNo must be between 1 and ${data.pageCount} (${this.currentPageNo} given).`);
+ }
+ }
+
+ if (data && data.has(this.currentPageNo)) {
+ const dialog = UiDialog.open(this, data.get(this.currentPageNo)) as DialogData;
+ UiDialog.setTitle("userTrophyListOverlay", data.title);
+
+ if (data.pageCount > 1) {
+ const element = dialog.content.querySelector(".jsPagination") as HTMLElement;
+ if (element !== null) {
+ new UiPagination(element, {
+ activePage: this.currentPageNo,
+ maxPage: data.pageCount,
+ callbackSwitch: this.showPage.bind(this),
+ });
+ }
+ }
+ } else {
+ Ajax.api(this, {
+ parameters: {
+ pageNo: this.currentPageNo,
+ userID: this.currentUser,
+ },
+ });
+ }
+ }
+
+ _ajaxSuccess(data: AjaxResponse): void {
+ let cache: CacheData;
+ if (data.returnValues.pageCount !== undefined) {
+ cache = new CacheData(+data.returnValues.pageCount, data.returnValues.title!);
+ this.cache.set(this.currentUser, cache);
+ } else {
+ cache = this.cache.get(this.currentUser)!;
+ }
+
+ cache.set(this.currentPageNo, data.returnValues.template);
+ this.showPage();
+ }
+
+ _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
+ return {
+ data: {
+ actionName: "getGroupedUserTrophyList",
+ className: "wcf\\data\\user\\trophy\\UserTrophyAction",
+ },
+ };
+ }
+
+ _dialogSetup(): ReturnType<DialogCallbackSetup> {
+ return {
+ id: "userTrophyListOverlay",
+ options: {
+ title: "",
+ },
+ source: null,
+ };
+ }
+}
+
+Core.enableLegacyInheritance(UiUserTrophyList);
+
+export = UiUserTrophyList;
+
+interface AjaxResponse extends DatabaseObjectActionResponse {
+ returnValues: {
+ pageCount?: number;
+ template: string;
+ title?: string;
+ };
+}
--- /dev/null
+/**
+ * Uploads file via AJAX.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module Upload (alias)
+ * @module WoltLabSuite/Core/Upload
+ */
+
+import { RequestOptions, ResponseData } from "./Ajax/Data";
+import AjaxRequest from "./Ajax/Request";
+import * as Core from "./Core";
+import DomChangeListener from "./Dom/Change/Listener";
+import * as Language from "./Language";
+import { FileCollection, FileElements, FileLikeObject, UploadId, UploadOptions } from "./Upload/Data";
+
+abstract class Upload<TOptions extends UploadOptions = UploadOptions> {
+ protected _button = document.createElement("p");
+ protected readonly _buttonContainer: HTMLElement;
+ protected readonly _fileElements: FileElements[] = [];
+ protected _fileUpload = document.createElement("input");
+ protected _internalFileId = 0;
+ protected readonly _multiFileUploadIds: unknown[] = [];
+ protected readonly _options: TOptions;
+ protected readonly _target: HTMLElement;
+
+ protected constructor(buttonContainerId: string, targetId: string, options: Partial<TOptions>) {
+ options = options || {};
+ if (!options.className) {
+ throw new Error("Missing class name.");
+ }
+
+ // set default options
+ this._options = Core.extend(
+ {
+ // name of the PHP action
+ action: "upload",
+ // is true if multiple files can be uploaded at once
+ multiple: false,
+ // array of acceptable file types, null if any file type is acceptable
+ acceptableFiles: null,
+ // name of the upload field
+ name: "__files[]",
+ // is true if every file from a multi-file selection is uploaded in its own request
+ singleFileRequests: false,
+ // url for uploading file
+ url: `index.php?ajax-upload/&t=${window.SECURITY_TOKEN}`,
+ },
+ options,
+ ) as TOptions;
+
+ this._options.url = Core.convertLegacyUrl(this._options.url);
+ if (this._options.url.indexOf("index.php") === 0) {
+ this._options.url = window.WSC_API_URL + this._options.url;
+ }
+
+ const buttonContainer = document.getElementById(buttonContainerId);
+ if (buttonContainer === null) {
+ throw new Error(`Element id '${buttonContainerId}' is unknown.`);
+ }
+ this._buttonContainer = buttonContainer;
+
+ const target = document.getElementById(targetId);
+ if (target === null) {
+ throw new Error(`Element id '${targetId}' is unknown.`);
+ }
+ this._target = target;
+
+ if (
+ options.multiple &&
+ this._target.nodeName !== "UL" &&
+ this._target.nodeName !== "OL" &&
+ this._target.nodeName !== "TBODY"
+ ) {
+ throw new Error("Target element has to be list or table body if uploading multiple files is supported.");
+ }
+
+ this._createButton();
+ }
+
+ /**
+ * Creates the upload button.
+ */
+ protected _createButton(): void {
+ this._fileUpload = document.createElement("input");
+ this._fileUpload.type = "file";
+ this._fileUpload.name = this._options.name;
+ if (this._options.multiple) {
+ this._fileUpload.multiple = true;
+ }
+ if (this._options.acceptableFiles !== null) {
+ this._fileUpload.accept = this._options.acceptableFiles.join(",");
+ }
+ this._fileUpload.addEventListener("change", (ev) => this._upload(ev));
+
+ this._button = document.createElement("p");
+ this._button.className = "button uploadButton";
+ this._button.setAttribute("role", "button");
+ this._fileUpload.addEventListener("focus", () => {
+ if (this._fileUpload.classList.contains("focus-visible")) {
+ this._button.classList.add("active");
+ }
+ });
+ this._fileUpload.addEventListener("blur", () => {
+ this._button.classList.remove("active");
+ });
+
+ const span = document.createElement("span");
+ span.textContent = Language.get("wcf.global.button.upload");
+ this._button.appendChild(span);
+
+ this._button.insertAdjacentElement("afterbegin", this._fileUpload);
+
+ this._insertButton();
+
+ DomChangeListener.trigger();
+ }
+
+ /**
+ * Creates the document element for an uploaded file.
+ */
+ protected _createFileElement(file: File | FileLikeObject): HTMLElement {
+ const progress = document.createElement("progress");
+ progress.max = 100;
+
+ let element: HTMLElement;
+ switch (this._target.nodeName) {
+ case "OL":
+ case "UL":
+ element = document.createElement("li");
+ element.innerText = file.name;
+ element.appendChild(progress);
+ this._target.appendChild(element);
+
+ return element;
+
+ case "TBODY":
+ return this._createFileTableRow(file);
+
+ default:
+ element = document.createElement("p");
+ element.appendChild(progress);
+ this._target.appendChild(element);
+
+ return element;
+ }
+ }
+
+ /**
+ * Creates the document elements for uploaded files.
+ */
+ protected _createFileElements(files: FileCollection): number | null {
+ if (!files.length) {
+ return null;
+ }
+
+ const elements: FileElements = [];
+ Array.from(files).forEach((file) => {
+ const fileElement = this._createFileElement(file);
+ if (!fileElement.classList.contains("uploadFailed")) {
+ fileElement.dataset.filename = file.name;
+ fileElement.dataset.internalFileId = (this._internalFileId++).toString();
+ elements.push(fileElement);
+ }
+ });
+
+ const uploadId = this._fileElements.length;
+ this._fileElements.push(elements);
+
+ DomChangeListener.trigger();
+ return uploadId;
+ }
+
+ protected _createFileTableRow(_file: File | FileLikeObject): HTMLTableRowElement {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+
+ throw new Error("Has to be implemented in subclass.");
+ }
+
+ /**
+ * Handles a failed file upload.
+ */
+ protected _failure(
+ _uploadId: number,
+ _data: ResponseData,
+ _responseText: string,
+ _xhr: XMLHttpRequest,
+ _requestOptions: RequestOptions,
+ ): boolean {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+
+ return true;
+ }
+
+ /**
+ * Return additional parameters for upload requests.
+ */
+ protected _getParameters(): ArbitraryObject {
+ return {};
+ }
+
+ /**
+ * Return additional form data for upload requests.
+ *
+ * @since 5.2
+ */
+ protected _getFormData(): ArbitraryObject {
+ return {};
+ }
+
+ /**
+ * Inserts the created button to upload files into the button container.
+ */
+ protected _insertButton(): void {
+ this._buttonContainer.insertAdjacentElement("afterbegin", this._button);
+ }
+
+ /**
+ * Updates the progress of an upload.
+ */
+ protected _progress(uploadId: number, event: ProgressEvent): void {
+ const percentComplete = Math.round((event.loaded / event.total) * 100);
+ this._fileElements[uploadId].forEach((element) => {
+ const progress = element.querySelector("progress");
+ if (progress) {
+ progress.value = percentComplete;
+ }
+ });
+ }
+
+ /**
+ * Removes the button to upload files.
+ */
+ protected _removeButton(): void {
+ this._button.remove();
+ DomChangeListener.trigger();
+ }
+
+ /**
+ * Handles a successful file upload.
+ */
+ protected _success(
+ _uploadId: number,
+ _data: ResponseData,
+ _responseText: string,
+ _xhr: XMLHttpRequestEventTarget,
+ _requestOptions: RequestOptions,
+ ): void {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+ }
+
+ /**
+ * File input change callback to upload files.
+ */
+ protected _upload(event: Event): UploadId;
+ protected _upload(event: null, file: File): UploadId;
+ protected _upload(event: null, file: null, blob: Blob): UploadId;
+ protected _upload(event: Event | null, file?: File | null, blob?: Blob | null): UploadId;
+ // This duplication is on purpose, the signature below is implementation private.
+ protected _upload(event: Event | null, file?: File | null, blob?: Blob | null): UploadId {
+ // remove failed upload elements first
+ this._target.querySelectorAll(".uploadFailed").forEach((el) => el.remove());
+
+ let uploadId: UploadId = null;
+ let files: (File | FileLikeObject)[] = [];
+ if (file) {
+ files.push(file);
+ } else if (blob) {
+ let fileExtension = "";
+ switch (blob.type) {
+ case "image/jpeg":
+ fileExtension = "jpg";
+ break;
+ case "image/gif":
+ fileExtension = "gif";
+ break;
+ case "image/png":
+ fileExtension = "png";
+ break;
+ case "image/webp":
+ fileExtension = "webp";
+ break;
+ }
+ files.push({
+ name: `pasted-from-clipboard.${fileExtension}`,
+ });
+ } else {
+ files = Array.from(this._fileUpload.files!);
+ }
+
+ if (files.length && this.validateUpload(files)) {
+ if (this._options.singleFileRequests) {
+ uploadId = [];
+ files.forEach((file) => {
+ const localUploadId = this._uploadFiles([file], blob) as number;
+ if (files.length !== 1) {
+ this._multiFileUploadIds.push(localUploadId);
+ }
+
+ (uploadId as number[]).push(localUploadId);
+ });
+ } else {
+ uploadId = this._uploadFiles(files, blob);
+ }
+ }
+ // re-create upload button to effectively reset the 'files'
+ // property of the input element
+ this._removeButton();
+ this._createButton();
+
+ return uploadId;
+ }
+
+ /**
+ * Validates the upload before uploading them.
+ *
+ * @since 5.2
+ */
+ validateUpload(_files: FileCollection): boolean {
+ // This should be an abstract method, but cannot be marked as such for backwards compatibility.
+
+ return true;
+ }
+
+ /**
+ * Sends the request to upload files.
+ */
+ protected _uploadFiles(files: FileCollection, blob?: Blob | null): number | null {
+ const uploadId = this._createFileElements(files)!;
+
+ // no more files left, abort
+ if (!this._fileElements[uploadId].length) {
+ return null;
+ }
+
+ const formData = new FormData();
+ for (let i = 0, length = files.length; i < length; i++) {
+ if (this._fileElements[uploadId][i]) {
+ const internalFileId = this._fileElements[uploadId][i].dataset.internalFileId!;
+ if (blob) {
+ formData.append(`__files[${internalFileId}]`, blob, files[i].name);
+ } else {
+ formData.append(`__files[${internalFileId}]`, files[i] as File);
+ }
+ }
+ }
+ formData.append("actionName", this._options.action);
+ formData.append("className", this._options.className);
+ if (this._options.action === "upload") {
+ formData.append("interfaceName", "wcf\\data\\IUploadAction");
+ }
+
+ // recursively append additional parameters to form data
+ function appendFormData(parameters: object | null, prefix?: string): void {
+ if (parameters === null) {
+ return;
+ }
+
+ prefix = prefix || "";
+
+ Object.entries(parameters).forEach(([key, value]) => {
+ if (typeof value === "object") {
+ const newPrefix = prefix!.length === 0 ? key : `${prefix!}[${key}]`;
+ appendFormData(value, newPrefix);
+ } else {
+ const dataName = prefix!.length === 0 ? key : `${prefix!}[${key}]`;
+ formData.append(dataName, value);
+ }
+ });
+ }
+
+ appendFormData(this._getParameters(), "parameters");
+ appendFormData(this._getFormData());
+
+ const request = new AjaxRequest({
+ data: formData,
+ contentType: false,
+ failure: this._failure.bind(this, uploadId),
+ silent: true,
+ success: this._success.bind(this, uploadId),
+ uploadProgress: this._progress.bind(this, uploadId),
+ url: this._options.url,
+ withCredentials: true,
+ });
+ request.sendRequest();
+
+ return uploadId;
+ }
+
+ /**
+ * Returns true if there are any pending uploads handled by this
+ * upload manager.
+ *
+ * @since 5.2
+ */
+ public hasPendingUploads(): boolean {
+ return (
+ this._fileElements.find((elements) => {
+ return elements.find((el) => el.querySelector("progress") !== null);
+ }) !== undefined
+ );
+ }
+
+ /**
+ * Uploads the given file blob.
+ */
+ uploadBlob(blob: Blob): number {
+ return this._upload(null, null, blob) as number;
+ }
+
+ /**
+ * Uploads the given file.
+ */
+ uploadFile(file: File): number {
+ return this._upload(null, file) as number;
+ }
+}
+
+Core.enableLegacyInheritance(Upload);
+
+export = Upload;
--- /dev/null
+export interface UploadOptions {
+ // name of the PHP action
+ action: string;
+ className: string;
+ // is true if multiple files can be uploaded at once
+ multiple: boolean;
+ // array of acceptable file types, null if any file type is acceptable
+ acceptableFiles: string[] | null;
+ // name of the upload field
+ name: string;
+ // is true if every file from a multi-file selection is uploaded in its own request
+ singleFileRequests: boolean;
+ // url for uploading file
+ url: string;
+}
+
+export type FileElements = HTMLElement[];
+
+export type FileLikeObject = { name: string };
+
+export type FileCollection = File[] | FileLikeObject[] | FileList;
+
+export type UploadId = number | number[] | null;
--- /dev/null
+/**
+ * Provides data of the active user.
+ *
+ * @author Matthias Schmidt
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module User (alias)
+ * @module WoltLabSuite/Core/User
+ */
+
+class User {
+ constructor(readonly userId: number, readonly username: string, readonly link: string) {}
+}
+
+let user: User;
+
+export = {
+ /**
+ * Returns the link to the active user's profile or an empty string
+ * if the active user is a guest.
+ */
+ getLink(): string {
+ return user.link;
+ },
+
+ /**
+ * Initializes the user object.
+ */
+ init(userId: number, username: string, link: string): void {
+ if (user) {
+ throw new Error("User has already been initialized.");
+ }
+
+ user = new User(userId, username, link);
+ },
+
+ get userId(): number {
+ return user.userId;
+ },
+
+ get username(): string {
+ return user.username;
+ },
+};
--- /dev/null
+/**
+ * Handles loading and initialization of Facebook's JavaScript SDK.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2020 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module WoltLabSuite/Core/Wrapper/FacebookSdk
+ */
+
+import "https://connect.facebook.net/en_US/sdk.js";
+
+// see: https://developers.facebook.com/docs/javascript/reference/FB.init/v7.0
+FB.init({
+ version: "v7.0",
+});
+
+export = FB;
--- /dev/null
+export interface LanguageData {
+ title: string;
+ file: string;
+}
+export type LanguageIdentifier = string;
+export type PrismMeta = Record<LanguageIdentifier, LanguageData>;
+// prettier-ignore
+/*!START*/ const metadata: PrismMeta = {"markup":{"title":"Markup","file":"markup"},"html":{"title":"HTML","file":"markup"},"xml":{"title":"XML","file":"markup"},"svg":{"title":"SVG","file":"markup"},"mathml":{"title":"MathML","file":"markup"},"ssml":{"title":"SSML","file":"markup"},"atom":{"title":"Atom","file":"markup"},"rss":{"title":"RSS","file":"markup"},"css":{"title":"CSS","file":"css"},"clike":{"title":"C-like","file":"clike"},"javascript":{"title":"JavaScript","file":"javascript"},"abap":{"title":"ABAP","file":"abap"},"abnf":{"title":"ABNF","file":"abnf"},"actionscript":{"title":"ActionScript","file":"actionscript"},"ada":{"title":"Ada","file":"ada"},"agda":{"title":"Agda","file":"agda"},"al":{"title":"AL","file":"al"},"antlr4":{"title":"ANTLR4","file":"antlr4"},"apacheconf":{"title":"Apache Configuration","file":"apacheconf"},"apl":{"title":"APL","file":"apl"},"applescript":{"title":"AppleScript","file":"applescript"},"aql":{"title":"AQL","file":"aql"},"arduino":{"title":"Arduino","file":"arduino"},"arff":{"title":"ARFF","file":"arff"},"asciidoc":{"title":"AsciiDoc","file":"asciidoc"},"aspnet":{"title":"ASP.NET (C#)","file":"aspnet"},"asm6502":{"title":"6502 Assembly","file":"asm6502"},"autohotkey":{"title":"AutoHotkey","file":"autohotkey"},"autoit":{"title":"AutoIt","file":"autoit"},"bash":{"title":"Bash","file":"bash"},"basic":{"title":"BASIC","file":"basic"},"batch":{"title":"Batch","file":"batch"},"bbcode":{"title":"BBcode","file":"bbcode"},"bison":{"title":"Bison","file":"bison"},"bnf":{"title":"BNF","file":"bnf"},"brainfuck":{"title":"Brainfuck","file":"brainfuck"},"brightscript":{"title":"BrightScript","file":"brightscript"},"bro":{"title":"Bro","file":"bro"},"c":{"title":"C","file":"c"},"csharp":{"title":"C#","file":"csharp"},"cpp":{"title":"C++","file":"cpp"},"cil":{"title":"CIL","file":"cil"},"clojure":{"title":"Clojure","file":"clojure"},"cmake":{"title":"CMake","file":"cmake"},"coffeescript":{"title":"CoffeeScript","file":"coffeescript"},"concurnas":{"title":"Concurnas","file":"concurnas"},"csp":{"title":"Content-Security-Policy","file":"csp"},"crystal":{"title":"Crystal","file":"crystal"},"css-extras":{"title":"CSS Extras","file":"css-extras"},"cypher":{"title":"Cypher","file":"cypher"},"d":{"title":"D","file":"d"},"dart":{"title":"Dart","file":"dart"},"dax":{"title":"DAX","file":"dax"},"dhall":{"title":"Dhall","file":"dhall"},"diff":{"title":"Diff","file":"diff"},"django":{"title":"Django/Jinja2","file":"django"},"dns-zone-file":{"title":"DNS zone file","file":"dns-zone-file"},"docker":{"title":"Docker","file":"docker"},"ebnf":{"title":"EBNF","file":"ebnf"},"editorconfig":{"title":"EditorConfig","file":"editorconfig"},"eiffel":{"title":"Eiffel","file":"eiffel"},"ejs":{"title":"EJS","file":"ejs"},"elixir":{"title":"Elixir","file":"elixir"},"elm":{"title":"Elm","file":"elm"},"etlua":{"title":"Embedded Lua templating","file":"etlua"},"erb":{"title":"ERB","file":"erb"},"erlang":{"title":"Erlang","file":"erlang"},"excel-formula":{"title":"Excel Formula","file":"excel-formula"},"fsharp":{"title":"F#","file":"fsharp"},"factor":{"title":"Factor","file":"factor"},"firestore-security-rules":{"title":"Firestore security rules","file":"firestore-security-rules"},"flow":{"title":"Flow","file":"flow"},"fortran":{"title":"Fortran","file":"fortran"},"ftl":{"title":"FreeMarker Template Language","file":"ftl"},"gml":{"title":"GameMaker Language","file":"gml"},"gcode":{"title":"G-code","file":"gcode"},"gdscript":{"title":"GDScript","file":"gdscript"},"gedcom":{"title":"GEDCOM","file":"gedcom"},"gherkin":{"title":"Gherkin","file":"gherkin"},"git":{"title":"Git","file":"git"},"glsl":{"title":"GLSL","file":"glsl"},"go":{"title":"Go","file":"go"},"graphql":{"title":"GraphQL","file":"graphql"},"groovy":{"title":"Groovy","file":"groovy"},"haml":{"title":"Haml","file":"haml"},"handlebars":{"title":"Handlebars","file":"handlebars"},"haskell":{"title":"Haskell","file":"haskell"},"haxe":{"title":"Haxe","file":"haxe"},"hcl":{"title":"HCL","file":"hcl"},"hlsl":{"title":"HLSL","file":"hlsl"},"http":{"title":"HTTP","file":"http"},"hpkp":{"title":"HTTP Public-Key-Pins","file":"hpkp"},"hsts":{"title":"HTTP Strict-Transport-Security","file":"hsts"},"ichigojam":{"title":"IchigoJam","file":"ichigojam"},"icon":{"title":"Icon","file":"icon"},"ignore":{"title":".ignore","file":"ignore"},"gitignore":{"title":".gitignore","file":"ignore"},"hgignore":{"title":".hgignore","file":"ignore"},"npmignore":{"title":".npmignore","file":"ignore"},"inform7":{"title":"Inform 7","file":"inform7"},"ini":{"title":"Ini","file":"ini"},"io":{"title":"Io","file":"io"},"j":{"title":"J","file":"j"},"java":{"title":"Java","file":"java"},"javadoc":{"title":"JavaDoc","file":"javadoc"},"javadoclike":{"title":"JavaDoc-like","file":"javadoclike"},"javastacktrace":{"title":"Java stack trace","file":"javastacktrace"},"jolie":{"title":"Jolie","file":"jolie"},"jq":{"title":"JQ","file":"jq"},"jsdoc":{"title":"JSDoc","file":"jsdoc"},"js-extras":{"title":"JS Extras","file":"js-extras"},"json":{"title":"JSON","file":"json"},"json5":{"title":"JSON5","file":"json5"},"jsonp":{"title":"JSONP","file":"jsonp"},"jsstacktrace":{"title":"JS stack trace","file":"jsstacktrace"},"js-templates":{"title":"JS Templates","file":"js-templates"},"julia":{"title":"Julia","file":"julia"},"keyman":{"title":"Keyman","file":"keyman"},"kotlin":{"title":"Kotlin","file":"kotlin"},"kts":{"title":"Kotlin Script","file":"kotlin"},"latex":{"title":"LaTeX","file":"latex"},"tex":{"title":"TeX","file":"latex"},"context":{"title":"ConTeXt","file":"latex"},"latte":{"title":"Latte","file":"latte"},"less":{"title":"Less","file":"less"},"lilypond":{"title":"LilyPond","file":"lilypond"},"liquid":{"title":"Liquid","file":"liquid"},"lisp":{"title":"Lisp","file":"lisp"},"livescript":{"title":"LiveScript","file":"livescript"},"llvm":{"title":"LLVM IR","file":"llvm"},"lolcode":{"title":"LOLCODE","file":"lolcode"},"lua":{"title":"Lua","file":"lua"},"makefile":{"title":"Makefile","file":"makefile"},"markdown":{"title":"Markdown","file":"markdown"},"markup-templating":{"title":"Markup templating","file":"markup-templating"},"matlab":{"title":"MATLAB","file":"matlab"},"mel":{"title":"MEL","file":"mel"},"mizar":{"title":"Mizar","file":"mizar"},"monkey":{"title":"Monkey","file":"monkey"},"moonscript":{"title":"MoonScript","file":"moonscript"},"n1ql":{"title":"N1QL","file":"n1ql"},"n4js":{"title":"N4JS","file":"n4js"},"nand2tetris-hdl":{"title":"Nand To Tetris HDL","file":"nand2tetris-hdl"},"nasm":{"title":"NASM","file":"nasm"},"neon":{"title":"NEON","file":"neon"},"nginx":{"title":"nginx","file":"nginx"},"nim":{"title":"Nim","file":"nim"},"nix":{"title":"Nix","file":"nix"},"nsis":{"title":"NSIS","file":"nsis"},"objectivec":{"title":"Objective-C","file":"objectivec"},"ocaml":{"title":"OCaml","file":"ocaml"},"opencl":{"title":"OpenCL","file":"opencl"},"oz":{"title":"Oz","file":"oz"},"parigp":{"title":"PARI/GP","file":"parigp"},"parser":{"title":"Parser","file":"parser"},"pascal":{"title":"Pascal","file":"pascal"},"pascaligo":{"title":"Pascaligo","file":"pascaligo"},"pcaxis":{"title":"PC-Axis","file":"pcaxis"},"peoplecode":{"title":"PeopleCode","file":"peoplecode"},"perl":{"title":"Perl","file":"perl"},"php":{"title":"PHP","file":"php"},"phpdoc":{"title":"PHPDoc","file":"phpdoc"},"php-extras":{"title":"PHP Extras","file":"php-extras"},"plsql":{"title":"PL/SQL","file":"plsql"},"powerquery":{"title":"PowerQuery","file":"powerquery"},"powershell":{"title":"PowerShell","file":"powershell"},"processing":{"title":"Processing","file":"processing"},"prolog":{"title":"Prolog","file":"prolog"},"properties":{"title":".properties","file":"properties"},"protobuf":{"title":"Protocol Buffers","file":"protobuf"},"pug":{"title":"Pug","file":"pug"},"puppet":{"title":"Puppet","file":"puppet"},"pure":{"title":"Pure","file":"pure"},"purebasic":{"title":"PureBasic","file":"purebasic"},"python":{"title":"Python","file":"python"},"q":{"title":"Q (kdb+ database)","file":"q"},"qml":{"title":"QML","file":"qml"},"qore":{"title":"Qore","file":"qore"},"r":{"title":"R","file":"r"},"racket":{"title":"Racket","file":"racket"},"jsx":{"title":"React JSX","file":"jsx"},"tsx":{"title":"React TSX","file":"tsx"},"reason":{"title":"Reason","file":"reason"},"regex":{"title":"Regex","file":"regex"},"renpy":{"title":"Ren'py","file":"renpy"},"rest":{"title":"reST (reStructuredText)","file":"rest"},"rip":{"title":"Rip","file":"rip"},"roboconf":{"title":"Roboconf","file":"roboconf"},"robotframework":{"title":"Robot Framework","file":"robotframework"},"ruby":{"title":"Ruby","file":"ruby"},"rust":{"title":"Rust","file":"rust"},"sas":{"title":"SAS","file":"sas"},"sass":{"title":"Sass (Sass)","file":"sass"},"scss":{"title":"Sass (Scss)","file":"scss"},"scala":{"title":"Scala","file":"scala"},"scheme":{"title":"Scheme","file":"scheme"},"shell-session":{"title":"Shell session","file":"shell-session"},"smali":{"title":"Smali","file":"smali"},"smalltalk":{"title":"Smalltalk","file":"smalltalk"},"smarty":{"title":"Smarty","file":"smarty"},"solidity":{"title":"Solidity (Ethereum)","file":"solidity"},"solution-file":{"title":"Solution file","file":"solution-file"},"soy":{"title":"Soy (Closure Template)","file":"soy"},"sparql":{"title":"SPARQL","file":"sparql"},"splunk-spl":{"title":"Splunk SPL","file":"splunk-spl"},"sqf":{"title":"SQF: Status Quo Function (Arma 3)","file":"sqf"},"sql":{"title":"SQL","file":"sql"},"iecst":{"title":"Structured Text (IEC 61131-3)","file":"iecst"},"stylus":{"title":"Stylus","file":"stylus"},"swift":{"title":"Swift","file":"swift"},"t4-templating":{"title":"T4 templating","file":"t4-templating"},"t4-cs":{"title":"T4 Text Templates (C#)","file":"t4-cs"},"t4-vb":{"title":"T4 Text Templates (VB)","file":"t4-vb"},"tap":{"title":"TAP","file":"tap"},"tcl":{"title":"Tcl","file":"tcl"},"tt2":{"title":"Template Toolkit 2","file":"tt2"},"textile":{"title":"Textile","file":"textile"},"toml":{"title":"TOML","file":"toml"},"turtle":{"title":"Turtle","file":"turtle"},"twig":{"title":"Twig","file":"twig"},"typescript":{"title":"TypeScript","file":"typescript"},"unrealscript":{"title":"UnrealScript","file":"unrealscript"},"vala":{"title":"Vala","file":"vala"},"vbnet":{"title":"VB.Net","file":"vbnet"},"velocity":{"title":"Velocity","file":"velocity"},"verilog":{"title":"Verilog","file":"verilog"},"vhdl":{"title":"VHDL","file":"vhdl"},"vim":{"title":"vim","file":"vim"},"visual-basic":{"title":"Visual Basic","file":"visual-basic"},"vba":{"title":"VBA","file":"visual-basic"},"warpscript":{"title":"WarpScript","file":"warpscript"},"wasm":{"title":"WebAssembly","file":"wasm"},"wiki":{"title":"Wiki markup","file":"wiki"},"xeora":{"title":"Xeora","file":"xeora"},"xml-doc":{"title":"XML doc (.net)","file":"xml-doc"},"xojo":{"title":"Xojo (REALbasic)","file":"xojo"},"xquery":{"title":"XQuery","file":"xquery"},"yaml":{"title":"YAML","file":"yaml"},"yang":{"title":"YANG","file":"yang"},"zig":{"title":"Zig","file":"zig"}} /*!END*/
+export default metadata;
{
"include": [
"global.d.ts",
- "wcfsetup/install/files/ts/**/*"
+ "ts/**/*"
],
"compilerOptions": {
"allowJs": true,
"target": "es2017",
"module": "amd",
- "rootDir": "wcfsetup/install/files/ts/",
+ "rootDir": "ts/",
"outDir": "wcfsetup/install/files/js/",
"lib": [
"dom",
)} /*!END*/
export default metadata;
`;
- fs.writeFileSync("../../../ts/WoltLabSuite/Core/prism-meta.ts", contents, "utf8");
+ fs.writeFileSync("../../../../../../ts/WoltLabSuite/Core/prism-meta.ts", contents, "utf8");
}
+++ /dev/null
-/**
- * Bootstraps WCF's JavaScript with additions for the ACP usage.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Bootstrap
- */
-
-import * as Core from "../Core";
-import { BoostrapOptions, setup as bootstrapSetup } from "../Bootstrap";
-import * as UiPageMenu from "./Ui/Page/Menu";
-
-interface AcpBootstrapOptions {
- bootstrap: BoostrapOptions;
-}
-
-/**
- * Bootstraps general modules and frontend exclusive ones.
- *
- * @param {Object=} options bootstrap options
- */
-export function setup(options: AcpBootstrapOptions): void {
- options = Core.extend(
- {
- bootstrap: {
- enableMobileMenu: true,
- },
- },
- options,
- ) as AcpBootstrapOptions;
-
- bootstrapSetup(options.bootstrap);
- UiPageMenu.init();
-}
+++ /dev/null
-/**
- * Abstract implementation of the JavaScript component of a form field handling a list of packages.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList
- * @since 5.2
- */
-
-import * as Core from "../../../../../../Core";
-import * as Language from "../../../../../../Language";
-import * as DomTraverse from "../../../../../../Dom/Traverse";
-import DomChangeListener from "../../../../../../Dom/Change/Listener";
-import DomUtil from "../../../../../../Dom/Util";
-import { PackageData } from "./Data";
-
-abstract class AbstractPackageList<TPackageData extends PackageData = PackageData> {
- protected readonly addButton: HTMLAnchorElement;
- protected readonly form: HTMLFormElement;
- protected readonly formFieldId: string;
- protected readonly packageList: HTMLOListElement;
- protected readonly packageIdentifier: HTMLInputElement;
-
- // see `wcf\data\package\Package::isValidPackageName()`
- protected static readonly packageIdentifierRegExp = new RegExp(/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/);
-
- // see `wcf\data\package\Package::isValidVersion()`
- protected static readonly versionRegExp = new RegExp(
- /^([0-9]+).([0-9]+)\.([0-9]+)( (a|alpha|b|beta|d|dev|rc|pl) ([0-9]+))?$/i,
- );
-
- constructor(formFieldId: string, existingPackages: TPackageData[]) {
- this.formFieldId = formFieldId;
-
- this.packageList = document.getElementById(`${this.formFieldId}_packageList`) as HTMLOListElement;
- if (this.packageList === null) {
- throw new Error(`Cannot find package list for packages field with id '${this.formFieldId}'.`);
- }
-
- this.packageIdentifier = document.getElementById(`${this.formFieldId}_packageIdentifier`) as HTMLInputElement;
- if (this.packageIdentifier === null) {
- throw new Error(`Cannot find package identifier form field for packages field with id '${this.formFieldId}'.`);
- }
- this.packageIdentifier.addEventListener("keypress", (ev) => this.keyPress(ev));
-
- this.addButton = document.getElementById(`${this.formFieldId}_addButton`) as HTMLAnchorElement;
- if (this.addButton === null) {
- throw new Error(`Cannot find add button for packages field with id '${this.formFieldId}'.`);
- }
- this.addButton.addEventListener("click", (ev) => this.addPackage(ev));
-
- this.form = this.packageList.closest("form") as HTMLFormElement;
- if (this.form === null) {
- throw new Error(`Cannot find form element for packages field with id '${this.formFieldId}'.`);
- }
- this.form.addEventListener("submit", () => this.submit());
-
- existingPackages.forEach((data) => this.addPackageByData(data));
- }
-
- /**
- * Adds a package to the package list as a consequence of the given event.
- *
- * If the package data is invalid, an error message is shown and no package is added.
- */
- protected addPackage(event: Event): void {
- event.preventDefault();
- event.stopPropagation();
-
- // validate data
- if (!this.validateInput()) {
- return;
- }
-
- this.addPackageByData(this.getInputData());
-
- // empty fields
- this.emptyInput();
-
- this.packageIdentifier.focus();
- }
-
- /**
- * Adds a package to the package list using the given package data.
- */
- protected addPackageByData(packageData: TPackageData): void {
- // add package to list
- const listItem = document.createElement("li");
- this.populateListItem(listItem, packageData);
-
- // add delete button
- const deleteButton = document.createElement("span");
- deleteButton.className = "icon icon16 fa-times pointer jsTooltip";
- deleteButton.title = Language.get("wcf.global.button.delete");
- deleteButton.addEventListener("click", (ev) => this.removePackage(ev));
- listItem.insertAdjacentElement("afterbegin", deleteButton);
-
- this.packageList.appendChild(listItem);
-
- DomChangeListener.trigger();
- }
-
- /**
- * Creates the hidden fields when the form is submitted.
- */
- protected createSubmitFields(listElement: HTMLLIElement, index: number): void {
- const packageIdentifier = document.createElement("input");
- packageIdentifier.type = "hidden";
- packageIdentifier.name = `${this.formFieldId}[${index}][packageIdentifier]`;
- packageIdentifier.value = listElement.dataset.packageIdentifier!;
- this.form.appendChild(packageIdentifier);
- }
-
- /**
- * Empties the input fields.
- */
- protected emptyInput(): void {
- this.packageIdentifier.value = "";
- }
-
- /**
- * Returns the current data of the input fields to add a new package.
- */
- protected getInputData(): TPackageData {
- return {
- packageIdentifier: this.packageIdentifier.value,
- } as TPackageData;
- }
-
- /**
- * Adds a package to the package list after pressing ENTER in a text field.
- */
- protected keyPress(event: KeyboardEvent): void {
- if (event.key === "Enter") {
- this.addPackage(event);
- }
- }
-
- /**
- * Adds all necessary package-relavant data to the given list item.
- */
- protected populateListItem(listItem: HTMLLIElement, packageData: TPackageData): void {
- listItem.dataset.packageIdentifier = packageData.packageIdentifier;
- }
-
- /**
- * Removes a package by clicking on its delete button.
- */
- protected removePackage(event: Event): void {
- (event.currentTarget as HTMLElement).closest("li")!.remove();
-
- // remove field errors if the last package has been deleted
- DomUtil.innerError(this.packageList, "");
- }
-
- /**
- * Adds all necessary (hidden) form fields to the form when submitting the form.
- */
- protected submit(): void {
- DomTraverse.childrenByTag(this.packageList, "LI").forEach((listItem, index) =>
- this.createSubmitFields(listItem, index),
- );
- }
-
- /**
- * Returns `true` if the currently entered package data is valid. Otherwise `false` is returned and relevant error
- * messages are shown.
- */
- protected validateInput(): boolean {
- return this.validatePackageIdentifier();
- }
-
- /**
- * Returns `true` if the currently entered package identifier is valid. Otherwise `false` is returned and an error
- * message is shown.
- */
- protected validatePackageIdentifier(): boolean {
- const packageIdentifier = this.packageIdentifier.value;
-
- if (packageIdentifier === "") {
- DomUtil.innerError(this.packageIdentifier, Language.get("wcf.global.form.error.empty"));
-
- return false;
- }
-
- if (packageIdentifier.length < 3) {
- DomUtil.innerError(
- this.packageIdentifier,
- Language.get("wcf.acp.devtools.project.packageIdentifier.error.minimumLength"),
- );
-
- return false;
- } else if (packageIdentifier.length > 191) {
- DomUtil.innerError(
- this.packageIdentifier,
- Language.get("wcf.acp.devtools.project.packageIdentifier.error.maximumLength"),
- );
-
- return false;
- }
-
- if (!AbstractPackageList.packageIdentifierRegExp.test(packageIdentifier)) {
- DomUtil.innerError(
- this.packageIdentifier,
- Language.get("wcf.acp.devtools.project.packageIdentifier.error.format"),
- );
-
- return false;
- }
-
- // check if package has already been added
- const duplicate = DomTraverse.childrenByTag(this.packageList, "LI").some(
- (listItem) => listItem.dataset.packageIdentifier === packageIdentifier,
- );
-
- if (duplicate) {
- DomUtil.innerError(
- this.packageIdentifier,
- Language.get("wcf.acp.devtools.project.packageIdentifier.error.duplicate"),
- );
-
- return false;
- }
-
- // remove outdated errors
- DomUtil.innerError(this.packageIdentifier, "");
-
- return true;
- }
-
- /**
- * Returns `true` if the given version is valid. Otherwise `false` is returned and an error message is shown.
- */
- protected validateVersion(versionElement: HTMLInputElement): boolean {
- const version = versionElement.value;
-
- // see `wcf\data\package\Package::isValidVersion()`
- // the version is no a required attribute
- if (version !== "") {
- if (version.length > 255) {
- DomUtil.innerError(versionElement, Language.get("wcf.acp.devtools.project.packageVersion.error.maximumLength"));
-
- return false;
- }
-
- if (!AbstractPackageList.versionRegExp.test(version)) {
- DomUtil.innerError(versionElement, Language.get("wcf.acp.devtools.project.packageVersion.error.format"));
-
- return false;
- }
- }
-
- // remove outdated errors
- DomUtil.innerError(versionElement, "");
-
- return true;
- }
-}
-
-Core.enableLegacyInheritance(AbstractPackageList);
-
-export = AbstractPackageList;
+++ /dev/null
-export interface PackageData {
- packageIdentifier: string;
-}
-
-export interface ExcludedPackageData extends PackageData {
- version: string;
-}
-
-export interface RequiredPackageData extends PackageData {
- file: boolean;
- minVersion: string;
-}
+++ /dev/null
-/**
- * Manages the packages entered in a devtools project excluded package form field.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/ExcludedPackages
- * @see module:WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList
- * @since 5.2
- */
-
-import AbstractPackageList from "./AbstractPackageList";
-import * as Core from "../../../../../../Core";
-import * as Language from "../../../../../../Language";
-import { ExcludedPackageData } from "./Data";
-
-class ExcludedPackages<
- TPackageData extends ExcludedPackageData = ExcludedPackageData
-> extends AbstractPackageList<TPackageData> {
- protected readonly version: HTMLInputElement;
-
- constructor(formFieldId: string, existingPackages: TPackageData[]) {
- super(formFieldId, existingPackages);
-
- this.version = document.getElementById(`${this.formFieldId}_version`) as HTMLInputElement;
- if (this.version === null) {
- throw new Error(`Cannot find version form field for packages field with id '${this.formFieldId}'.`);
- }
- this.version.addEventListener("keypress", (ev) => this.keyPress(ev));
- }
-
- protected createSubmitFields(listElement: HTMLLIElement, index: number): void {
- super.createSubmitFields(listElement, index);
-
- const version = document.createElement("input");
- version.type = "hidden";
- version.name = `${this.formFieldId}[${index}][version]`;
- version.value = listElement.dataset.version!;
- this.form.appendChild(version);
- }
-
- protected emptyInput(): void {
- super.emptyInput();
-
- this.version.value = "";
- }
-
- protected getInputData(): TPackageData {
- return Core.extend(super.getInputData(), {
- version: this.version.value,
- }) as TPackageData;
- }
-
- protected populateListItem(listItem: HTMLLIElement, packageData: TPackageData): void {
- super.populateListItem(listItem, packageData);
-
- listItem.dataset.version = packageData.version;
-
- listItem.innerHTML = ` ${Language.get("wcf.acp.devtools.project.excludedPackage.excludedPackage", {
- packageIdentifier: packageData.packageIdentifier,
- version: packageData.version,
- })}`;
- }
-
- protected validateInput(): boolean {
- return super.validateInput() && this.validateVersion(this.version);
- }
-}
-
-Core.enableLegacyInheritance(ExcludedPackages);
-
-export = ExcludedPackages;
+++ /dev/null
-/**
- * Manages the instructions entered in a devtools project instructions form field.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions
- * @since 5.2
- */
-
-import * as Core from "../../../../../../Core";
-import Template from "../../../../../../Template";
-import * as Language from "../../../../../../Language";
-import * as DomTraverse from "../../../../../../Dom/Traverse";
-import DomChangeListener from "../../../../../../Dom/Change/Listener";
-import DomUtil from "../../../../../../Dom/Util";
-import UiSortableList from "../../../../../../Ui/Sortable/List";
-import UiDialog from "../../../../../../Ui/Dialog";
-import * as UiConfirmation from "../../../../../../Ui/Confirmation";
-
-interface Instruction {
- application: string;
- errors?: string[];
- pip: string;
- runStandalone: number;
- value: string;
-}
-
-interface InstructionsData {
- errors?: string[];
- fromVersion?: string;
- instructions?: Instruction[];
- type: InstructionsType;
-}
-
-type InstructionsType = "install" | "update";
-type InstructionsId = number | string;
-type PipFilenameMap = { [k: string]: string };
-
-class Instructions {
- protected readonly addButton: HTMLAnchorElement;
- protected readonly form: HTMLFormElement;
- protected readonly formFieldId: string;
- protected readonly fromVersion: HTMLInputElement;
- protected instructionCounter = 0;
- protected instructionsCounter = 0;
- protected readonly instructionsEditDialogTemplate: Template;
- protected readonly instructionsList: HTMLUListElement;
- protected readonly instructionsType: HTMLSelectElement;
- protected readonly instructionsTemplate: Template;
- protected readonly instructionEditDialogTemplate: Template;
- protected readonly pipDefaultFilenames: PipFilenameMap;
-
- protected static readonly applicationPips = ["acpTemplate", "file", "script", "template"];
-
- // see `wcf\data\package\Package::isValidPackageName()`
- protected static readonly packageIdentifierRegExp = new RegExp(/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/);
-
- // see `wcf\data\package\Package::isValidVersion()`
- protected static readonly versionRegExp = new RegExp(
- /^([0-9]+).([0-9]+)\.([0-9]+)( (a|alpha|b|beta|d|dev|rc|pl) ([0-9]+))?$/i,
- );
-
- constructor(
- formFieldId: string,
- instructionsTemplate: Template,
- instructionsEditDialogTemplate: Template,
- instructionEditDialogTemplate: Template,
- pipDefaultFilenames: PipFilenameMap,
- existingInstructions: InstructionsData[],
- ) {
- this.formFieldId = formFieldId;
- this.instructionsTemplate = instructionsTemplate;
- this.instructionsEditDialogTemplate = instructionsEditDialogTemplate;
- this.instructionEditDialogTemplate = instructionEditDialogTemplate;
- this.pipDefaultFilenames = pipDefaultFilenames;
-
- this.instructionsList = document.getElementById(`${this.formFieldId}_instructionsList`) as HTMLUListElement;
- if (this.instructionsList === null) {
- throw new Error(`Cannot find package list for packages field with id '${this.formFieldId}'.`);
- }
-
- this.instructionsType = document.getElementById(`${this.formFieldId}_instructionsType`) as HTMLSelectElement;
- if (this.instructionsType === null) {
- throw new Error(`Cannot find instruction type form field for instructions field with id '${this.formFieldId}'.`);
- }
- this.instructionsType.addEventListener("change", () => this.toggleFromVersionFormField());
-
- this.fromVersion = document.getElementById(`${this.formFieldId}_fromVersion`) as HTMLInputElement;
- if (this.fromVersion === null) {
- throw new Error(`Cannot find from version form field for instructions field with id '${this.formFieldId}'.`);
- }
- this.fromVersion.addEventListener("keypress", (ev) => this.instructionsKeyPress(ev));
-
- this.addButton = document.getElementById(`${this.formFieldId}_addButton`) as HTMLAnchorElement;
- if (this.addButton === null) {
- throw new Error(`Cannot find add button form field for instructions field with id '${this.formFieldId}'.`);
- }
- this.addButton.addEventListener("click", (ev) => this.addInstructions(ev));
-
- this.form = this.instructionsList.closest("form")!;
- if (this.form === null) {
- throw new Error(`Cannot find form element for instructions field with id '${this.formFieldId}'.`);
- }
- this.form.addEventListener("submit", () => this.submit());
-
- const hasInstallInstructions = existingInstructions.some((instructions) => instructions.type === "install");
-
- // ensure that there are always installation instructions
- if (!hasInstallInstructions) {
- this.addInstructionsByData({
- fromVersion: "",
- type: "install",
- });
- }
-
- existingInstructions.forEach((instructions) => this.addInstructionsByData(instructions));
-
- DomChangeListener.trigger();
- }
-
- /**
- * Adds an instruction to a set of instructions as a consequence of the given event.
- * If the instruction data is invalid, an error message is shown and no instruction is added.
- */
- protected addInstruction(event: Event): void {
- event.preventDefault();
- event.stopPropagation();
-
- const instructionsId = ((event.currentTarget as HTMLElement).closest("li.section") as HTMLElement).dataset
- .instructionsId!;
-
- // note: data will be validated/filtered by the server
-
- const pipField = document.getElementById(
- `${this.formFieldId}_instructions${instructionsId}_pip`,
- ) as HTMLInputElement;
-
- // ignore pressing button if no PIP has been selected
- if (!pipField.value) {
- return;
- }
-
- const valueField = document.getElementById(
- `${this.formFieldId}_instructions${instructionsId}_value`,
- ) as HTMLInputElement;
- const runStandaloneField = document.getElementById(
- `${this.formFieldId}_instructions${instructionsId}_runStandalone`,
- ) as HTMLInputElement;
- const applicationField = document.getElementById(
- `${this.formFieldId}_instructions${instructionsId}_application`,
- ) as HTMLSelectElement;
-
- this.addInstructionByData(instructionsId, {
- application: Instructions.applicationPips.indexOf(pipField.value) !== -1 ? applicationField.value : "",
- pip: pipField.value,
- runStandalone: ~~runStandaloneField.checked,
- value: valueField.value,
- });
-
- // empty fields
- pipField.value = "";
- valueField.value = "";
- runStandaloneField.checked = false;
- applicationField.value = "";
- document.getElementById(
- `${this.formFieldId}_instructions${instructionsId}_valueDescription`,
- )!.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
- this.toggleApplicationFormField(instructionsId);
-
- DomChangeListener.trigger();
- }
-
- /**
- * Adds an instruction to the set of instructions with the given id.
- */
- protected addInstructionByData(instructionsId: InstructionsId, instructionData: Instruction): void {
- const instructionId = ++this.instructionCounter;
-
- const instructionList = document.getElementById(
- `${this.formFieldId}_instructions${instructionsId}_instructionList`,
- )!;
-
- const listItem = document.createElement("li");
- listItem.className = "sortableNode";
- listItem.id = `${this.formFieldId}_instructions${instructionsId}`;
- listItem.dataset.instructionId = instructionId.toString();
- listItem.dataset.application = instructionData.application;
- listItem.dataset.pip = instructionData.pip;
- listItem.dataset.runStandalone = instructionData.runStandalone ? "true" : "false";
- listItem.dataset.value = instructionData.value;
-
- let content = `
- <div class="sortableNodeLabel">
- <div class="jsDevtoolsProjectInstruction">
- ${Language.get("wcf.acp.devtools.project.instruction.instruction", instructionData)}
- `;
-
- if (instructionData.errors) {
- instructionData.errors.forEach((error) => {
- content += `<small class="innerError">${error}</small>`;
- });
- }
-
- content += `
- </div>
- <span class="statusDisplay sortableButtonContainer">
- <span class="icon icon16 fa-pencil pointer jsTooltip" id="${
- this.formFieldId
- }_instruction${instructionId}_editButton" title="${Language.get("wcf.global.button.edit")}"></span>
- <span class="icon icon16 fa-times pointer jsTooltip" id="${
- this.formFieldId
- }_instruction${instructionId}_deleteButton" title="${Language.get("wcf.global.button.delete")}"></span>
- </span>
- </div>
- `;
-
- listItem.innerHTML = content;
-
- instructionList.appendChild(listItem);
-
- document
- .getElementById(`${this.formFieldId}_instruction${instructionsId}_deleteButton`)!
- .addEventListener("click", (ev) => this.removeInstruction(ev));
- document
- .getElementById(`${this.formFieldId}_instruction${instructionsId}_editButton`)!
- .addEventListener("click", (ev) => this.editInstruction(ev));
- }
-
- /**
- * Adds a set of instructions.
- *
- * If the instructions data is invalid, an error message is shown and no instruction set is added.
- */
- protected addInstructions(event: Event): void {
- event.preventDefault();
- event.stopPropagation();
-
- // validate data
- if (
- !this.validateInstructionsType() ||
- (this.instructionsType.value === "update" && !this.validateFromVersion(this.fromVersion))
- ) {
- return;
- }
-
- this.addInstructionsByData({
- fromVersion: this.instructionsType.value === "update" ? this.fromVersion.value : "",
- type: this.instructionsType.value as InstructionsType,
- });
-
- // empty fields
- this.instructionsType.value = "";
- this.fromVersion.value = "";
-
- this.toggleFromVersionFormField();
-
- DomChangeListener.trigger();
- }
-
- /**
- * Adds a set of instructions.
- */
- protected addInstructionsByData(instructionsData: InstructionsData): void {
- const instructionsId = ++this.instructionsCounter;
-
- const listItem = document.createElement("li");
- listItem.className = "section";
- listItem.innerHTML = this.instructionsTemplate.fetch({
- instructionsId: instructionsId,
- sectionTitle: Language.get(`wcf.acp.devtools.project.instructions.type.${instructionsData.type}.title`, {
- fromVersion: instructionsData.fromVersion,
- }),
- type: instructionsData.type,
- });
- listItem.id = `${this.formFieldId}_instructions${instructionsId}`;
- listItem.dataset.instructionsId = instructionsId.toString();
- listItem.dataset.type = instructionsData.type;
- listItem.dataset.fromVersion = instructionsData.fromVersion;
-
- this.instructionsList.appendChild(listItem);
-
- const instructionListContainer = document.getElementById(
- `${this.formFieldId}_instructions${instructionsId}_instructionListContainer`,
- )!;
- if (Array.isArray(instructionsData.errors)) {
- instructionsData.errors.forEach((errorMessage) => {
- DomUtil.innerError(instructionListContainer, errorMessage, true);
- });
- }
-
- new UiSortableList({
- containerId: instructionListContainer.id,
- isSimpleSorting: true,
- options: {
- toleranceElement: "> div",
- },
- });
-
- if (instructionsData.type === "update") {
- document
- .getElementById(`${this.formFieldId}_instructions${instructionsId}_deleteButton`)!
- .addEventListener("click", (ev) => this.removeInstructions(ev));
- document
- .getElementById(`${this.formFieldId}_instructions${instructionsId}_editButton`)!
- .addEventListener("click", (ev) => this.editInstructions(ev));
- }
-
- document
- .getElementById(`${this.formFieldId}_instructions${instructionsId}_pip`)!
- .addEventListener("change", (ev) => this.changeInstructionPip(ev));
-
- document
- .getElementById(`${this.formFieldId}_instructions${instructionsId}_value`)!
- .addEventListener("keypress", (ev) => this.instructionKeyPress(ev));
-
- document
- .getElementById(`${this.formFieldId}_instructions${instructionsId}_addButton`)!
- .addEventListener("click", (ev) => this.addInstruction(ev));
-
- if (instructionsData.instructions) {
- instructionsData.instructions.forEach((instruction) => {
- this.addInstructionByData(instructionsId, instruction);
- });
- }
- }
-
- /**
- * Is called if the selected package installation plugin of an instruction is changed.
- */
- protected changeInstructionPip(event: Event): void {
- const target = event.currentTarget as HTMLInputElement;
-
- const pip = target.value;
- const instructionsId = (target.closest("li.section") as HTMLElement).dataset.instructionsId!;
- const description = document.getElementById(`${this.formFieldId}_instructions${instructionsId}_valueDescription`)!;
-
- // update value description
- if (this.pipDefaultFilenames[pip] !== "") {
- description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description.defaultFilename", {
- defaultFilename: this.pipDefaultFilenames[pip],
- });
- } else {
- description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
- }
-
- // toggle application selector
- this.toggleApplicationFormField(instructionsId);
- }
-
- /**
- * Opens a dialog to edit an existing instruction.
- */
- protected editInstruction(event: Event): void {
- const listItem = (event.currentTarget as HTMLElement).closest("li")!;
-
- const instructionId = listItem.dataset.instructionId!;
- const application = listItem.dataset.application!;
- const pip = listItem.dataset.pip!;
- const runStandalone = Core.stringToBool(listItem.dataset.runStandalone!);
- const value = listItem.dataset.value!;
-
- const dialogContent = this.instructionEditDialogTemplate.fetch({
- runStandalone: runStandalone,
- value: value,
- });
-
- const dialogId = "instructionEditDialog" + instructionId;
- if (!UiDialog.getDialog(dialogId)) {
- UiDialog.openStatic(dialogId, dialogContent, {
- onSetup: (content) => {
- const applicationSelect = content.querySelector("select[name=application]") as HTMLSelectElement;
- const pipSelect = content.querySelector("select[name=pip]") as HTMLInputElement;
- const runStandaloneInput = content.querySelector("input[name=runStandalone]") as HTMLInputElement;
- const valueInput = content.querySelector("input[name=value]") as HTMLInputElement;
-
- // set values of `select` elements
- applicationSelect.value = application;
- pipSelect.value = pip;
-
- const submit = () => {
- const listItem = document.getElementById(`${this.formFieldId}_instruction${instructionId}`)!;
- listItem.dataset.application =
- Instructions.applicationPips.indexOf(pipSelect.value) !== -1 ? applicationSelect.value : "";
- listItem.dataset.pip = pipSelect.value;
- listItem.dataset.runStandalone = runStandaloneInput.checked ? "1" : "0";
- listItem.dataset.value = valueInput.value;
-
- // note: data will be validated/filtered by the server
-
- listItem.querySelector(".jsDevtoolsProjectInstruction")!.innerHTML = Language.get(
- "wcf.acp.devtools.project.instruction.instruction",
- {
- application: listItem.dataset.application,
- pip: listItem.dataset.pip,
- runStandalone: listItem.dataset.runStandalone,
- value: listItem.dataset.value,
- },
- );
-
- DomChangeListener.trigger();
-
- UiDialog.close(dialogId);
- };
-
- valueInput.addEventListener("keypress", (event) => {
- if (event.key === "Enter") {
- submit();
- }
- });
-
- content.querySelector("button[data-type=submit]")!.addEventListener("click", submit);
-
- const pipChange = () => {
- const pip = pipSelect.value;
-
- if (Instructions.applicationPips.indexOf(pip) !== -1) {
- DomUtil.show(applicationSelect.closest("dl")!);
- } else {
- DomUtil.hide(applicationSelect.closest("dl")!);
- }
-
- const description = DomTraverse.nextByTag(valueInput, "SMALL")!;
- if (this.pipDefaultFilenames[pip] !== "") {
- description.innerHTML = Language.get(
- "wcf.acp.devtools.project.instruction.value.description.defaultFilename",
- {
- defaultFilename: this.pipDefaultFilenames[pip],
- },
- );
- } else {
- description.innerHTML = Language.get("wcf.acp.devtools.project.instruction.value.description");
- }
- };
-
- pipSelect.addEventListener("change", pipChange);
- pipChange();
- },
- title: Language.get("wcf.acp.devtools.project.instruction.edit"),
- });
- } else {
- UiDialog.openStatic(dialogId, null);
- }
- }
-
- /**
- * Opens a dialog to edit an existing set of instructions.
- */
- protected editInstructions(event: Event): void {
- const listItem = (event.currentTarget as HTMLElement).closest("li")!;
-
- const instructionsId = listItem.dataset.instructionsId!;
- const fromVersion = listItem.dataset.fromVersion;
-
- const dialogContent = this.instructionsEditDialogTemplate.fetch({
- fromVersion: fromVersion,
- });
-
- const dialogId = "instructionsEditDialog" + instructionsId;
- if (!UiDialog.getDialog(dialogId)) {
- UiDialog.openStatic(dialogId, dialogContent, {
- onSetup: (content) => {
- const fromVersion = content.querySelector("input[name=fromVersion]") as HTMLInputElement;
-
- const submit = () => {
- if (!this.validateFromVersion(fromVersion)) {
- return;
- }
-
- const instructions = document.getElementById(`${this.formFieldId}_instructions${instructionsId}`)!;
- instructions.dataset.fromVersion = fromVersion.value;
-
- instructions.querySelector(".jsInstructionsTitle")!.innerHTML = Language.get(
- "wcf.acp.devtools.project.instructions.type.update.title",
- {
- fromVersion: fromVersion.value,
- },
- );
-
- DomChangeListener.trigger();
-
- UiDialog.close(dialogId);
- };
-
- fromVersion.addEventListener("keypress", (event) => {
- if (event.key === "Enter") {
- submit();
- }
- });
-
- content.querySelector("button[data-type=submit]")!.addEventListener("click", submit);
- },
- title: Language.get("wcf.acp.devtools.project.instructions.edit"),
- });
- } else {
- UiDialog.openStatic(dialogId, null);
- }
- }
-
- /**
- * Adds an instruction after pressing ENTER in a relevant text field.
- */
- protected instructionKeyPress(event: KeyboardEvent): void {
- if (event.key === "Enter") {
- this.addInstruction(event);
- }
- }
-
- /**
- * Adds a set of instruction after pressing ENTER in a relevant text field.
- */
- protected instructionsKeyPress(event: KeyboardEvent): void {
- if (event.key === "Enter") {
- this.addInstructions(event);
- }
- }
-
- /**
- * Removes an instruction by clicking on its delete button.
- */
- protected removeInstruction(event: Event): void {
- const instruction = (event.currentTarget as HTMLElement).closest("li")!;
-
- UiConfirmation.show({
- confirm: () => {
- instruction.remove();
- },
- message: Language.get("wcf.acp.devtools.project.instruction.delete.confirmMessages"),
- });
- }
-
- /**
- * Removes a set of instructions by clicking on its delete button.
- *
- * @param {Event} event delete button click event
- */
- protected removeInstructions(event: Event): void {
- const instructions = (event.currentTarget as HTMLElement).closest("li")!;
-
- UiConfirmation.show({
- confirm: () => {
- instructions.remove();
- },
- message: Language.get("wcf.acp.devtools.project.instructions.delete.confirmMessages"),
- });
- }
-
- /**
- * Adds all necessary (hidden) form fields to the form when submitting the form.
- */
- protected submit(): void {
- DomTraverse.childrenByTag(this.instructionsList, "LI").forEach((instructions, instructionsIndex) => {
- const namePrefix = `${this.formFieldId}[${instructionsIndex}]`;
-
- const instructionsType = document.createElement("input");
- instructionsType.type = "hidden";
- instructionsType.name = `${namePrefix}[type]`;
- instructionsType.value = instructions.dataset.type!;
- this.form.appendChild(instructionsType);
-
- if (instructionsType.value === "update") {
- const fromVersion = document.createElement("input");
- fromVersion.type = "hidden";
- fromVersion.name = `${this.formFieldId}[${instructionsIndex}][fromVersion]`;
- fromVersion.value = instructions.dataset.fromVersion!;
- this.form.appendChild(fromVersion);
- }
-
- DomTraverse.childrenByTag(document.getElementById(`${instructions.id}_instructionList`)!, "LI").forEach(
- (instruction, instructionIndex) => {
- const namePrefix = `${this.formFieldId}[${instructionsIndex}][instructions][${instructionIndex}]`;
-
- ["pip", "value", "runStandalone"].forEach((property) => {
- const element = document.createElement("input");
- element.type = "hidden";
- element.name = `${namePrefix}[${property}]`;
- element.value = instruction.dataset[property]!;
- this.form.appendChild(element);
- });
-
- if (Instructions.applicationPips.indexOf(instruction.dataset.pip!) !== -1) {
- const application = document.createElement("input");
- application.type = "hidden";
- application.name = `${namePrefix}[application]`;
- application.value = instruction.dataset.application!;
- this.form.appendChild(application);
- }
- },
- );
- });
- }
-
- /**
- * Toggles the visibility of the application form field based on the selected pip for the instructions with the given id.
- */
- protected toggleApplicationFormField(instructionsId: InstructionsId): void {
- const pip = (document.getElementById(`${this.formFieldId}_instructions${instructionsId}_pip`) as HTMLInputElement)
- .value;
-
- const valueDlClassList = document
- .getElementById(`${this.formFieldId}_instructions${instructionsId}_value`)!
- .closest("dl")!.classList;
- const applicationDl = document
- .getElementById(`${this.formFieldId}_instructions${instructionsId}_application`)!
- .closest("dl")!;
-
- if (Instructions.applicationPips.indexOf(pip) !== -1) {
- valueDlClassList.remove("col-md-9");
- valueDlClassList.add("col-md-7");
- DomUtil.show(applicationDl);
- } else {
- valueDlClassList.remove("col-md-7");
- valueDlClassList.add("col-md-9");
- DomUtil.hide(applicationDl);
- }
- }
-
- /**
- * Toggles the visibility of the `fromVersion` form field based on the selected instructions type.
- */
- protected toggleFromVersionFormField(): void {
- const instructionsTypeList = this.instructionsType.closest("dl")!.classList;
- const fromVersionDl = this.fromVersion.closest("dl")!;
-
- if (this.instructionsType.value === "update") {
- instructionsTypeList.remove("col-md-10");
- instructionsTypeList.add("col-md-5");
- DomUtil.show(fromVersionDl);
- } else {
- instructionsTypeList.remove("col-md-5");
- instructionsTypeList.add("col-md-10");
- DomUtil.hide(fromVersionDl);
- }
- }
-
- /**
- * Returns `true` if the currently entered update "from version" is valid. Otherwise `false` is returned and an error
- * message is shown.
- */
- protected validateFromVersion(inputField: HTMLInputElement): boolean {
- const version = inputField.value;
-
- if (version === "") {
- DomUtil.innerError(inputField, Language.get("wcf.global.form.error.empty"));
-
- return false;
- }
-
- if (version.length > 50) {
- DomUtil.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.maximumLength"));
-
- return false;
- }
-
- // wildcard versions are checked on the server side
- if (version.indexOf("*") === -1) {
- if (!Instructions.versionRegExp.test(version)) {
- DomUtil.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.format"));
-
- return false;
- }
- } else if (!Instructions.versionRegExp.test(version.replace("*", "0"))) {
- DomUtil.innerError(inputField, Language.get("wcf.acp.devtools.project.packageVersion.error.format"));
-
- return false;
- }
-
- // remove outdated errors
- DomUtil.innerError(inputField, "");
-
- return true;
- }
-
- /**
- * Returns `true` if the entered update instructions type is valid.
- * Otherwise `false` is returned and an error message is shown.
- */
- protected validateInstructionsType(): boolean {
- if (this.instructionsType.value !== "install" && this.instructionsType.value !== "update") {
- if (this.instructionsType.value === "") {
- DomUtil.innerError(this.instructionsType, Language.get("wcf.global.form.error.empty"));
- } else {
- DomUtil.innerError(this.instructionsType, Language.get("wcf.global.form.error.noValidSelection"));
- }
-
- return false;
- }
-
- // there may only be one set of installation instructions
- if (this.instructionsType.value === "install") {
- const hasInstall = Array.from(this.instructionsList.children).some(
- (instructions: HTMLElement) => instructions.dataset.type === "install",
- );
-
- if (hasInstall) {
- DomUtil.innerError(
- this.instructionsType,
- Language.get("wcf.acp.devtools.project.instructions.type.update.error.duplicate"),
- );
-
- return false;
- }
- }
-
- // remove outdated errors
- DomUtil.innerError(this.instructionsType, "");
-
- return true;
- }
-}
-
-Core.enableLegacyInheritance(Instructions);
-
-export = Instructions;
+++ /dev/null
-/**
- * Manages the packages entered in a devtools project optional package form field.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/OptionalPackages
- * @see module:WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList
- * @since 5.2
- */
-
-import AbstractPackageList from "./AbstractPackageList";
-import * as Core from "../../../../../../Core";
-import * as Language from "../../../../../../Language";
-import { PackageData } from "./Data";
-
-class OptionalPackages extends AbstractPackageList {
- protected populateListItem(listItem: HTMLLIElement, packageData: PackageData): void {
- super.populateListItem(listItem, packageData);
-
- listItem.innerHTML = ` ${Language.get("wcf.acp.devtools.project.optionalPackage.optionalPackage", {
- packageIdentifier: packageData.packageIdentifier,
- })}`;
- }
-}
-
-Core.enableLegacyInheritance(OptionalPackages);
-
-export = OptionalPackages;
+++ /dev/null
-/**
- * Manages the packages entered in a devtools project required package form field.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Acp/Builder/Field/Devtools/Project/RequiredPackages
- * @see module:WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList
- * @since 5.2
- */
-
-import AbstractPackageList from "./AbstractPackageList";
-import * as Core from "../../../../../../Core";
-import * as Language from "../../../../../../Language";
-import { RequiredPackageData } from "./Data";
-
-class RequiredPackages<
- TPackageData extends RequiredPackageData = RequiredPackageData
-> extends AbstractPackageList<TPackageData> {
- protected readonly file: HTMLInputElement;
- protected readonly minVersion: HTMLInputElement;
-
- constructor(formFieldId: string, existingPackages: TPackageData[]) {
- super(formFieldId, existingPackages);
-
- this.minVersion = document.getElementById(`${this.formFieldId}_minVersion`) as HTMLInputElement;
- if (this.minVersion === null) {
- throw new Error(`Cannot find minimum version form field for packages field with id '${this.formFieldId}'.`);
- }
- this.minVersion.addEventListener("keypress", (ev) => this.keyPress(ev));
-
- this.file = document.getElementById(`${this.formFieldId}_file`) as HTMLInputElement;
- if (this.file === null) {
- throw new Error(`Cannot find file form field for required field with id '${this.formFieldId}'.`);
- }
- }
-
- protected createSubmitFields(listElement: HTMLLIElement, index: number): void {
- super.createSubmitFields(listElement, index);
-
- ["minVersion", "file"].forEach((property) => {
- const element = document.createElement("input");
- element.type = "hidden";
- element.name = `${this.formFieldId}[${index}][${property}]`;
- element.value = listElement.dataset[property]!;
- this.form.appendChild(element);
- });
- }
-
- protected emptyInput(): void {
- super.emptyInput();
-
- this.minVersion.value = "";
- this.file.checked = false;
- }
-
- protected getInputData(): TPackageData {
- return Core.extend(super.getInputData(), {
- file: this.file.checked,
- minVersion: this.minVersion.value,
- }) as TPackageData;
- }
-
- protected populateListItem(listItem: HTMLLIElement, packageData: TPackageData): void {
- super.populateListItem(listItem, packageData);
-
- listItem.dataset.minVersion = packageData.minVersion;
- listItem.dataset.file = packageData.file ? "1" : "0";
-
- listItem.innerHTML = ` ${Language.get("wcf.acp.devtools.project.requiredPackage.requiredPackage", {
- file: packageData.file,
- minVersion: packageData.minVersion,
- packageIdentifier: packageData.packageIdentifier,
- })}`;
- }
-
- protected validateInput(): boolean {
- return super.validateInput() && this.validateVersion(this.minVersion);
- }
-}
-
-Core.enableLegacyInheritance(RequiredPackages);
-
-export = RequiredPackages;
+++ /dev/null
-/**
- * Provides the dialog overlay to add a new article.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Article/Add
- */
-
-import * as Language from "../../../Language";
-import UiDialog from "../../../Ui/Dialog";
-import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
-
-class ArticleAdd implements DialogCallbackObject {
- constructor(private readonly link: string) {
- document.querySelectorAll(".jsButtonArticleAdd").forEach((button: HTMLElement) => {
- button.addEventListener("click", (ev) => this.openDialog(ev));
- });
- }
-
- openDialog(event?: MouseEvent): void {
- if (event instanceof Event) {
- event.preventDefault();
- }
-
- UiDialog.open(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "articleAddDialog",
- options: {
- onSetup: (content) => {
- const button = content.querySelector("button") as HTMLElement;
- button.addEventListener("click", (event) => {
- event.preventDefault();
-
- const input = content.querySelector('input[name="isMultilingual"]:checked') as HTMLInputElement;
-
- window.location.href = this.link.replace("{$isMultilingual}", input.value);
- });
- },
- title: Language.get("wcf.acp.article.add"),
- },
- };
- }
-}
-
-let articleAdd: ArticleAdd;
-
-/**
- * Initializes the article add handler.
- */
-export function init(link: string): void {
- if (!articleAdd) {
- articleAdd = new ArticleAdd(link);
- }
-}
-
-/**
- * Opens the 'Add Article' dialog.
- */
-export function openDialog(): void {
- articleAdd.openDialog();
-}
+++ /dev/null
-/**
- * Handles article trash, restore and delete.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Article/InlineEditor
- */
-
-import * as Ajax from "../../../Ajax";
-import { AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../../Ajax/Data";
-import * as ControllerClipboard from "../../../Controller/Clipboard";
-import * as Core from "../../../Core";
-import DomUtil from "../../../Dom/Util";
-import * as EventHandler from "../../../Event/Handler";
-import * as Language from "../../../Language";
-import * as UiConfirmation from "../../../Ui/Confirmation";
-import UiDialog from "../../../Ui/Dialog";
-import * as UiNotification from "../../../Ui/Notification";
-
-interface InlineEditorOptions {
- i18n: {
- defaultLanguageId: number;
- isI18n: boolean;
- languages: {
- [key: string]: string;
- };
- };
- redirectUrl: string;
-}
-
-interface ArticleData {
- buttons: {
- delete: HTMLAnchorElement;
- restore: HTMLAnchorElement;
- trash: HTMLAnchorElement;
- };
- element: HTMLElement | undefined;
- isArticleEdit: boolean;
-}
-
-interface ClipboardResponseData {
- objectIDs: number[];
-}
-
-interface ClipboardActionData {
- data: {
- actionName: string;
- internalData: {
- template: string;
- };
- };
- responseData: ClipboardResponseData | null;
-}
-
-const articles = new Map<number, ArticleData>();
-
-class AcpUiArticleInlineEditor {
- private readonly options: InlineEditorOptions;
-
- /**
- * Initializes the ACP inline editor for articles.
- */
- constructor(objectId: number, options: InlineEditorOptions) {
- this.options = Core.extend(
- {
- i18n: {
- defaultLanguageId: 0,
- isI18n: false,
- languages: {},
- },
- redirectUrl: "",
- },
- options,
- ) as InlineEditorOptions;
-
- if (objectId) {
- this.initArticle(undefined, ~~objectId);
- } else {
- document.querySelectorAll(".jsArticleRow").forEach((article: HTMLElement) => this.initArticle(article, 0));
-
- EventHandler.add("com.woltlab.wcf.clipboard", "com.woltlab.wcf.article", (data) => this.clipboardAction(data));
- }
- }
-
- /**
- * Reacts to executed clipboard actions.
- */
- private clipboardAction(actionData: ClipboardActionData): void {
- // only consider events if the action has been executed
- if (actionData.responseData !== null) {
- const callbackFunction = new Map([
- ["com.woltlab.wcf.article.delete", (articleId: number) => this.triggerDelete(articleId)],
- ["com.woltlab.wcf.article.publish", (articleId: number) => this.triggerPublish(articleId)],
- ["com.woltlab.wcf.article.restore", (articleId: number) => this.triggerRestore(articleId)],
- ["com.woltlab.wcf.article.trash", (articleId: number) => this.triggerTrash(articleId)],
- ["com.woltlab.wcf.article.unpublish", (articleId: number) => this.triggerUnpublish(articleId)],
- ]);
-
- const triggerFunction = callbackFunction.get(actionData.data.actionName);
- if (triggerFunction) {
- actionData.responseData.objectIDs.forEach((objectId) => triggerFunction(objectId));
-
- UiNotification.show();
- }
- } else if (actionData.data.actionName === "com.woltlab.wcf.article.setCategory") {
- const dialog = UiDialog.openStatic("articleCategoryDialog", actionData.data.internalData.template, {
- title: Language.get("wcf.acp.article.setCategory"),
- });
-
- const submitButton = dialog.content.querySelector("[data-type=submit]") as HTMLButtonElement;
- submitButton.addEventListener("click", (ev) => this.submitSetCategory(ev, dialog.content));
- }
- }
-
- /**
- * Is called, if the set category dialog form is submitted.
- */
- private submitSetCategory(event: MouseEvent, content: HTMLElement): void {
- event.preventDefault();
-
- const innerError = content.querySelector(".innerError");
- const select = content.querySelector("select[name=categoryID]") as HTMLSelectElement;
-
- const categoryId = ~~select.value;
- if (categoryId) {
- Ajax.api(this, {
- actionName: "setCategory",
- parameters: {
- categoryID: categoryId,
- useMarkedArticles: true,
- },
- });
-
- if (innerError) {
- innerError.remove();
- }
-
- UiDialog.close("articleCategoryDialog");
- } else if (!innerError) {
- DomUtil.innerError(select, Language.get("wcf.global.form.error.empty"));
- }
- }
-
- /**
- * Initializes an article row element.
- */
- private initArticle(article: HTMLElement | undefined, objectId: number): void {
- let isArticleEdit = false;
- if (!article && ~~objectId > 0) {
- isArticleEdit = true;
- article = undefined;
- } else {
- objectId = ~~article!.dataset.objectId!;
- }
-
- const scope = article || document;
-
- const buttonDelete = scope.querySelector(".jsButtonDelete") as HTMLAnchorElement;
- buttonDelete.addEventListener("click", (ev) => this.prompt(ev, objectId, "delete"));
-
- const buttonRestore = scope.querySelector(".jsButtonRestore") as HTMLAnchorElement;
- buttonRestore.addEventListener("click", (ev) => this.prompt(ev, objectId, "restore"));
-
- const buttonTrash = scope.querySelector(".jsButtonTrash") as HTMLAnchorElement;
- buttonTrash.addEventListener("click", (ev) => this.prompt(ev, objectId, "trash"));
-
- if (isArticleEdit) {
- const buttonToggleI18n = scope.querySelector(".jsButtonToggleI18n") as HTMLAnchorElement;
- if (buttonToggleI18n !== null) {
- buttonToggleI18n.addEventListener("click", (ev) => this.toggleI18n(ev, objectId));
- }
- }
-
- articles.set(objectId, {
- buttons: {
- delete: buttonDelete,
- restore: buttonRestore,
- trash: buttonTrash,
- },
- element: article,
- isArticleEdit: isArticleEdit,
- });
- }
-
- /**
- * Prompts a user to confirm the clicked action before executing it.
- */
- private prompt(event: MouseEvent, objectId: number, actionName: string): void {
- event.preventDefault();
-
- const article = articles.get(objectId)!;
-
- UiConfirmation.show({
- confirm: () => {
- this.invoke(objectId, actionName);
- },
- message: article.buttons[actionName].dataset.confirmMessageHtml,
- messageIsHtml: true,
- });
- }
-
- /**
- * Toggles an article between i18n and monolingual.
- */
- private toggleI18n(event: MouseEvent, objectId: number): void {
- event.preventDefault();
-
- const phrase = Language.get(
- "wcf.acp.article.i18n." + (this.options.i18n.isI18n ? "fromI18n" : "toI18n") + ".confirmMessage",
- );
- let html = `<p>${phrase}</p>`;
-
- // build language selection
- if (this.options.i18n.isI18n) {
- html += `<dl><dt>${Language.get("wcf.acp.article.i18n.source")}</dt><dd>`;
-
- const defaultLanguageId = this.options.i18n.defaultLanguageId.toString();
- html += Object.entries(this.options.i18n.languages)
- .map(([languageId, languageName]) => {
- return `<label><input type="radio" name="i18nLanguage" value="${languageId}" ${
- defaultLanguageId === languageId ? "checked" : ""
- }> ${languageName}</label>`;
- })
- .join("");
- html += "</dd></dl>";
- }
-
- UiConfirmation.show({
- confirm: (parameters, content) => {
- let languageId = 0;
- if (this.options.i18n.isI18n) {
- const input = content.parentElement!.querySelector("input[name='i18nLanguage']:checked") as HTMLInputElement;
- languageId = ~~input.value;
- }
-
- Ajax.api(this, {
- actionName: "toggleI18n",
- objectIDs: [objectId],
- parameters: {
- languageID: languageId,
- },
- });
- },
- message: html,
- messageIsHtml: true,
- });
- }
-
- /**
- * Invokes the selected action.
- */
- private invoke(objectId: number, actionName: string): void {
- Ajax.api(this, {
- actionName: actionName,
- objectIDs: [objectId],
- });
- }
-
- /**
- * Handles an article being deleted.
- */
- private triggerDelete(articleId: number): void {
- const article = articles.get(articleId);
- if (!article) {
- // The affected article might be hidden by the filter settings.
- return;
- }
-
- if (article.isArticleEdit) {
- window.location.href = this.options.redirectUrl;
- } else {
- const tbody = article.element!.parentElement!;
- article.element!.remove();
-
- if (tbody.querySelector("tr") === null) {
- window.location.reload();
- }
- }
- }
-
- /**
- * Handles publishing an article via clipboard.
- */
- private triggerPublish(articleId: number): void {
- const article = articles.get(articleId);
- if (!article) {
- // The affected article might be hidden by the filter settings.
- return;
- }
-
- if (article.isArticleEdit) {
- // unsupported
- } else {
- const notice = article.element!.querySelector(".jsUnpublishedArticle")!;
- notice.remove();
- }
- }
-
- /**
- * Handles an article being restored.
- */
- private triggerRestore(articleId: number): void {
- const article = articles.get(articleId);
- if (!article) {
- // The affected article might be hidden by the filter settings.
- return;
- }
-
- DomUtil.hide(article.buttons.delete);
- DomUtil.hide(article.buttons.restore);
- DomUtil.show(article.buttons.trash);
-
- if (article.isArticleEdit) {
- const notice = document.querySelector(".jsArticleNoticeTrash") as HTMLElement;
- DomUtil.hide(notice);
- } else {
- const icon = article.element!.querySelector(".jsIconDeleted")!;
- icon.remove();
- }
- }
-
- /**
- * Handles an article being trashed.
- */
- private triggerTrash(articleId: number): void {
- const article = articles.get(articleId);
- if (!article) {
- // The affected article might be hidden by the filter settings.
- return;
- }
-
- DomUtil.show(article.buttons.delete);
- DomUtil.show(article.buttons.restore);
- DomUtil.hide(article.buttons.trash);
-
- if (article.isArticleEdit) {
- const notice = document.querySelector(".jsArticleNoticeTrash") as HTMLElement;
- DomUtil.show(notice);
- } else {
- const badge = document.createElement("span");
- badge.className = "badge label red jsIconDeleted";
- badge.textContent = Language.get("wcf.message.status.deleted");
-
- const h3 = article.element!.querySelector(".containerHeadline > h3") as HTMLHeadingElement;
- h3.insertAdjacentElement("afterbegin", badge);
- }
- }
-
- /**
- * Handles unpublishing an article via clipboard.
- */
- private triggerUnpublish(articleId: number): void {
- const article = articles.get(articleId);
- if (!article) {
- // The affected article might be hidden by the filter settings.
- return;
- }
-
- if (article.isArticleEdit) {
- // unsupported
- } else {
- const badge = document.createElement("span");
- badge.className = "badge jsUnpublishedArticle";
- badge.textContent = Language.get("wcf.acp.article.publicationStatus.unpublished");
-
- const h3 = article.element!.querySelector(".containerHeadline > h3") as HTMLHeadingElement;
- const a = h3.querySelector("a");
-
- h3.insertBefore(badge, a);
- h3.insertBefore(document.createTextNode(" "), a);
- }
- }
-
- _ajaxSuccess(data: DatabaseObjectActionResponse): void {
- let notificationCallback;
-
- switch (data.actionName) {
- case "delete":
- this.triggerDelete(data.objectIDs[0]);
- break;
-
- case "restore":
- this.triggerRestore(data.objectIDs[0]);
- break;
-
- case "setCategory":
- case "toggleI18n":
- notificationCallback = () => window.location.reload();
- break;
-
- case "trash":
- this.triggerTrash(data.objectIDs[0]);
- break;
- }
-
- UiNotification.show(undefined, notificationCallback);
- ControllerClipboard.reload();
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- className: "wcf\\data\\article\\ArticleAction",
- },
- };
- }
-}
-
-Core.enableLegacyInheritance(AcpUiArticleInlineEditor);
-
-export = AcpUiArticleInlineEditor;
+++ /dev/null
-/**
- * Provides the dialog overlay to add a new box.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Box/Add
- */
-
-import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
-import * as Language from "../../../Language";
-import UiDialog from "../../../Ui/Dialog";
-
-class AcpUiBoxAdd implements DialogCallbackObject {
- private supportsI18n = false;
- private link = "";
-
- /**
- * Initializes the box add handler.
- */
- init(link: string, supportsI18n: boolean): void {
- this.link = link;
- this.supportsI18n = supportsI18n;
-
- document.querySelectorAll(".jsButtonBoxAdd").forEach((button: HTMLElement) => {
- button.addEventListener("click", (ev) => this.openDialog(ev));
- });
- }
-
- /**
- * Opens the 'Add Box' dialog.
- */
- openDialog(event?: MouseEvent): void {
- if (event instanceof Event) {
- event.preventDefault();
- }
-
- UiDialog.open(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "boxAddDialog",
- options: {
- onSetup: (content) => {
- content.querySelector("button")!.addEventListener("click", (event) => {
- event.preventDefault();
-
- const boxTypeSelection = content.querySelector('input[name="boxType"]:checked') as HTMLInputElement;
- const boxType = boxTypeSelection.value;
- let isMultilingual = "0";
- if (boxType !== "system" && this.supportsI18n) {
- const i18nSelection = content.querySelector('input[name="isMultilingual"]:checked') as HTMLInputElement;
- isMultilingual = i18nSelection.value;
- }
-
- window.location.href = this.link
- .replace("{$boxType}", boxType)
- .replace("{$isMultilingual}", isMultilingual);
- });
-
- content.querySelectorAll('input[type="radio"][name="boxType"]').forEach((boxType: HTMLInputElement) => {
- boxType.addEventListener("change", () => {
- content
- .querySelectorAll('input[type="radio"][name="isMultilingual"]')
- .forEach((i18nSelection: HTMLInputElement) => {
- i18nSelection.disabled = boxType.value === "system";
- });
- });
- });
- },
- title: Language.get("wcf.acp.box.add"),
- },
- };
- }
-}
-
-let acpUiDialogAdd: AcpUiBoxAdd;
-
-function getAcpUiDialogAdd(): AcpUiBoxAdd {
- if (!acpUiDialogAdd) {
- acpUiDialogAdd = new AcpUiBoxAdd();
- }
-
- return acpUiDialogAdd;
-}
-
-/**
- * Initializes the box add handler.
- */
-export function init(link: string, availableLanguages: number): void {
- getAcpUiDialogAdd().init(link, availableLanguages > 1);
-}
-
-/**
- * Opens the 'Add Box' dialog.
- */
-export function openDialog(event?: MouseEvent): void {
- getAcpUiDialogAdd().openDialog(event);
-}
+++ /dev/null
-/**
- * Provides the interface logic to add and edit boxes.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Box/Controller/Handler
- */
-
-import * as Ajax from "../../../../Ajax";
-import DomUtil from "../../../../Dom/Util";
-import * as EventHandler from "../../../../Event/Handler";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../../Ajax/Data";
-
-interface AjaxResponse {
- returnValues: {
- template: string;
- };
-}
-
-class AcpUiBoxControllerHandler implements AjaxCallbackObject {
- private readonly boxConditions: HTMLElement;
- private readonly boxController: HTMLInputElement;
- private readonly boxControllerContainer: HTMLElement;
-
- constructor(initialObjectTypeId: number | undefined) {
- this.boxControllerContainer = document.getElementById("boxControllerContainer")!;
- this.boxController = document.getElementById("boxControllerID") as HTMLInputElement;
- this.boxConditions = document.getElementById("boxConditions")!;
-
- this.boxController.addEventListener("change", () => this.updateConditions());
-
- DomUtil.show(this.boxControllerContainer);
-
- if (initialObjectTypeId === undefined) {
- this.updateConditions();
- }
- }
-
- /**
- * Sets up ajax request object.
- */
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "getBoxConditionsTemplate",
- className: "wcf\\data\\box\\BoxAction",
- },
- };
- }
-
- /**
- * Handles successful AJAX requests.
- */
- _ajaxSuccess(data: AjaxResponse): void {
- DomUtil.setInnerHtml(this.boxConditions, data.returnValues.template);
- }
-
- /**
- * Updates the displayed box conditions based on the selected dynamic box controller.
- */
- private updateConditions(): void {
- EventHandler.fire("com.woltlab.wcf.boxControllerHandler", "updateConditions");
-
- Ajax.api(this, {
- parameters: {
- objectTypeID: ~~this.boxController.value,
- },
- });
- }
-}
-
-let acpUiBoxControllerHandler: AcpUiBoxControllerHandler;
-
-export function init(initialObjectTypeId: number | undefined): void {
- if (!acpUiBoxControllerHandler) {
- acpUiBoxControllerHandler = new AcpUiBoxControllerHandler(initialObjectTypeId);
- }
-}
+++ /dev/null
-import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
-import * as Language from "../../../Language";
-import * as UiDialog from "../../../Ui/Dialog";
-
-class AcpUiBoxCopy implements DialogCallbackObject {
- constructor() {
- document.querySelectorAll(".jsButtonCopyBox").forEach((button: HTMLElement) => {
- button.addEventListener("click", (ev) => this.click(ev));
- });
- }
-
- private click(event: MouseEvent): void {
- event.preventDefault();
-
- UiDialog.open(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "acpBoxCopyDialog",
- options: {
- title: Language.get("wcf.acp.box.copy"),
- },
- };
- }
-}
-
-let acpUiBoxCopy: AcpUiBoxCopy;
-
-export function init(): void {
- if (!acpUiBoxCopy) {
- acpUiBoxCopy = new AcpUiBoxCopy();
- }
-}
+++ /dev/null
-/**
- * Provides the interface logic to add and edit boxes.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Box/Handler
- */
-
-import Dictionary from "../../../Dictionary";
-import DomUtil from "../../../Dom/Util";
-import * as Language from "../../../Language";
-import * as UiPageSearchHandler from "../../../Ui/Page/Search/Handler";
-
-class AcpUiBoxHandler {
- private activePageId = 0;
- private readonly boxController: HTMLSelectElement | null;
- private readonly boxType: string;
- private readonly cache = new Map<number, number>();
- private readonly containerExternalLink: HTMLElement;
- private readonly containerPageId: HTMLElement;
- private readonly containerPageObjectId: HTMLElement;
- private readonly handlers: Map<number, string>;
- private readonly pageId: HTMLSelectElement;
- private readonly pageObjectId: HTMLInputElement;
- private readonly position: HTMLSelectElement;
-
- /**
- * Initializes the interface logic.
- */
- constructor(handlers: Map<number, string>, boxType: string) {
- this.boxType = boxType;
- this.handlers = handlers;
-
- this.boxController = document.getElementById("boxControllerID") as HTMLSelectElement;
-
- if (boxType !== "system") {
- this.containerPageId = document.getElementById("linkPageIDContainer")!;
- this.containerExternalLink = document.getElementById("externalURLContainer")!;
- this.containerPageObjectId = document.getElementById("linkPageObjectIDContainer")!;
-
- if (this.handlers.size) {
- this.pageId = document.getElementById("linkPageID") as HTMLSelectElement;
- this.pageId.addEventListener("change", () => this.togglePageId());
-
- this.pageObjectId = document.getElementById("linkPageObjectID") as HTMLInputElement;
-
- this.cache = new Map();
- this.activePageId = ~~this.pageId.value;
- if (this.activePageId && this.handlers.has(this.activePageId)) {
- this.cache.set(this.activePageId, ~~this.pageObjectId.value);
- }
-
- const searchButton = document.getElementById("searchLinkPageObjectID")!;
- searchButton.addEventListener("click", (ev) => this.openSearch(ev));
-
- // toggle page object id container on init
- if (this.handlers.has(~~this.pageId.value)) {
- DomUtil.show(this.containerPageObjectId);
- }
- }
-
- document.querySelectorAll('input[name="linkType"]').forEach((input: HTMLInputElement) => {
- input.addEventListener("change", () => this.toggleLinkType(input.value));
-
- if (input.checked) {
- this.toggleLinkType(input.value);
- }
- });
- }
-
- if (this.boxController) {
- this.position = document.getElementById("position") as HTMLSelectElement;
- this.boxController.addEventListener("change", () => this.setAvailableBoxPositions());
-
- // update positions on init
- this.setAvailableBoxPositions();
- }
- }
-
- /**
- * Toggles between the interface for internal and external links.
- */
- private toggleLinkType(value: string): void {
- switch (value) {
- case "none":
- DomUtil.hide(this.containerPageId);
- DomUtil.hide(this.containerPageObjectId);
- DomUtil.hide(this.containerExternalLink);
- break;
-
- case "internal":
- DomUtil.show(this.containerPageId);
- DomUtil.hide(this.containerExternalLink);
- if (this.handlers.size) {
- this.togglePageId();
- }
- break;
-
- case "external":
- DomUtil.hide(this.containerPageId);
- DomUtil.hide(this.containerPageObjectId);
- DomUtil.show(this.containerExternalLink);
- break;
- }
- }
-
- /**
- * Handles the changed page selection.
- */
- private togglePageId(): void {
- if (this.handlers.has(this.activePageId)) {
- this.cache.set(this.activePageId, ~~this.pageObjectId.value);
- }
-
- this.activePageId = ~~this.pageId.value;
-
- // page w/o pageObjectID support, discard value
- if (!this.handlers.has(this.activePageId)) {
- this.pageObjectId.value = "";
-
- DomUtil.hide(this.containerPageObjectId);
-
- return;
- }
-
- const newValue = this.cache.get(this.activePageId);
- this.pageObjectId.value = newValue ? newValue.toString() : "";
-
- const selectedOption = this.pageId.options[this.pageId.selectedIndex];
- const pageIdentifier = selectedOption.dataset.identifier!;
- let languageItem = `wcf.page.pageObjectID.${pageIdentifier}`;
- if (Language.get(languageItem) === languageItem) {
- languageItem = "wcf.page.pageObjectID";
- }
-
- this.containerPageObjectId.querySelector("label")!.textContent = Language.get(languageItem);
-
- DomUtil.show(this.containerPageObjectId);
- }
-
- /**
- * Opens the handler lookup dialog.
- */
- private openSearch(event: MouseEvent): void {
- event.preventDefault();
-
- const selectedOption = this.pageId.options[this.pageId.selectedIndex];
- const pageIdentifier = selectedOption.dataset.identifier!;
- const languageItem = `wcf.page.pageObjectID.search.${pageIdentifier}`;
-
- let labelLanguageItem;
- if (Language.get(languageItem) !== languageItem) {
- labelLanguageItem = languageItem;
- }
-
- UiPageSearchHandler.open(
- this.activePageId,
- selectedOption.textContent!.trim(),
- (objectId) => {
- this.pageObjectId.value = objectId.toString();
- this.cache.set(this.activePageId, objectId);
- },
- labelLanguageItem,
- );
- }
-
- /**
- * Updates the available box positions per box controller.
- */
- private setAvailableBoxPositions(): void {
- const selectedOption = this.boxController!.options[this.boxController!.selectedIndex];
- const supportedPositions: string[] = JSON.parse(selectedOption.dataset.supportedPositions!);
-
- Array.from(this.position).forEach((option: HTMLOptionElement) => {
- option.disabled = !supportedPositions.includes(option.value);
- });
- }
-}
-
-let acpUiBoxHandler: AcpUiBoxHandler;
-
-/**
- * Initializes the interface logic.
- */
-export function init(handlers: Dictionary<string> | Map<number, string>, boxType: string): void {
- if (!acpUiBoxHandler) {
- let map: Map<number, string>;
- if (!(handlers instanceof Map)) {
- map = new Map();
- handlers.forEach((value, key) => {
- map.set(~~key, value);
- });
- } else {
- map = handlers;
- }
-
- acpUiBoxHandler = new AcpUiBoxHandler(map, boxType);
- }
-}
+++ /dev/null
-import { Media, MediaInsertType } from "../../../Media/Data";
-import MediaManagerEditor from "../../../Media/Manager/Editor";
-import * as Core from "../../../Core";
-
-class AcpUiCodeMirrorMedia {
- protected readonly element: HTMLElement;
-
- constructor(elementId: string) {
- this.element = document.getElementById(elementId) as HTMLElement;
-
- const button = document.getElementById(`codemirror-${elementId}-media`)!;
- button.classList.add(button.id);
-
- new MediaManagerEditor({
- buttonClass: button.id,
- callbackInsert: (media, insertType, thumbnailSize) => this.insert(media, insertType, thumbnailSize),
- });
- }
-
- protected insert(mediaList: Map<number, Media>, insertType: MediaInsertType, thumbnailSize: string): void {
- switch (insertType) {
- case MediaInsertType.Separate: {
- const content = Array.from(mediaList.values())
- .map((item) => `{{ media="${item.mediaID}" size="${thumbnailSize}" }}`)
- .join("");
-
- (this.element as any).codemirror.replaceSelection(content);
- }
- }
- }
-}
-
-Core.enableLegacyInheritance(AcpUiCodeMirrorMedia);
-
-export = AcpUiCodeMirrorMedia;
+++ /dev/null
-import * as Core from "../../../Core";
-import * as UiPageSearch from "../../../Ui/Page/Search";
-
-class AcpUiCodeMirrorPage {
- private element: HTMLElement;
-
- constructor(elementId: string) {
- this.element = document.getElementById(elementId)!;
-
- const insertButton = document.getElementById(`codemirror-${elementId}-page`)!;
- insertButton.addEventListener("click", (ev) => this._click(ev));
- }
-
- private _click(event: MouseEvent): void {
- event.preventDefault();
-
- UiPageSearch.open((pageID) => this._insert(pageID));
- }
-
- _insert(pageID: string): void {
- (this.element as any).codemirror.replaceSelection(`{{ page="${pageID}" }}`);
- }
-}
-
-Core.enableLegacyInheritance(AcpUiCodeMirrorPage);
-
-export = AcpUiCodeMirrorPage;
+++ /dev/null
-/**
- * Executes user notification tests.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Devtools/Project/QuickSetup
- */
-
-import * as Ajax from "../../../../Ajax";
-import * as Language from "../../../../Language";
-import UiDialog from "../../../../Ui/Dialog";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../../Ajax/Data";
-import { DialogCallbackObject, DialogCallbackSetup } from "../../../../Ui/Dialog/Data";
-import DomUtil from "../../../../Dom/Util";
-
-interface AjaxResponse {
- returnValues: {
- eventID: number;
- template: string;
- };
-}
-
-class AcpUiDevtoolsNotificationTest implements AjaxCallbackObject, DialogCallbackObject {
- private readonly buttons: HTMLButtonElement[];
- private readonly titles = new Map<number, string>();
-
- /**
- * Initializes the user notification test handler.
- */
- constructor() {
- this.buttons = Array.from(document.querySelectorAll(".jsTestEventButton"));
-
- this.buttons.forEach((button) => {
- button.addEventListener("click", (ev) => this.test(ev));
-
- const eventId = ~~button.dataset.eventId!;
- const title = button.dataset.title!;
- this.titles.set(eventId, title);
- });
- }
-
- /**
- * Returns the data used to setup the AJAX request object.
- */
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "testEvent",
- className: "wcf\\data\\user\\notification\\event\\UserNotificationEventAction",
- },
- };
- }
-
- /**
- * Handles successful AJAX request.
- */
- _ajaxSuccess(data: AjaxResponse): void {
- UiDialog.open(this, data.returnValues.template);
- UiDialog.setTitle(this, this.titles.get(~~data.returnValues.eventID)!);
-
- const dialog = UiDialog.getDialog(this)!.dialog;
-
- dialog.querySelectorAll(".formSubmit button").forEach((button: HTMLButtonElement) => {
- button.addEventListener("click", (ev) => this.changeView(ev));
- });
-
- // fix some margin issues
- const errors: HTMLElement[] = Array.from(dialog.querySelectorAll(".error"));
- if (errors.length === 1) {
- errors[0].style.setProperty("margin-top", "0px");
- errors[0].style.setProperty("margin-bottom", "20px");
- }
-
- dialog.querySelectorAll(".notificationTestSection").forEach((section: HTMLElement) => {
- section.style.setProperty("margin-top", "0px");
- });
-
- document.getElementById("notificationTestDialog")!.parentElement!.scrollTop = 0;
-
- // restore buttons
- this.buttons.forEach((button) => {
- button.innerHTML = Language.get("wcf.acp.devtools.notificationTest.button.test");
- button.disabled = false;
- });
- }
-
- /**
- * Changes the view after clicking on one of the buttons.
- */
- private changeView(event: MouseEvent): void {
- const button = event.currentTarget as HTMLButtonElement;
-
- const dialog = UiDialog.getDialog(this)!.dialog;
-
- dialog.querySelectorAll(".notificationTestSection").forEach((section: HTMLElement) => DomUtil.hide(section));
- const containerId = button.id.replace("Button", "");
- DomUtil.show(document.getElementById(containerId)!);
-
- const primaryButton = dialog.querySelector(".formSubmit .buttonPrimary") as HTMLElement;
- primaryButton.classList.remove("buttonPrimary");
- primaryButton.classList.add("button");
-
- button.classList.remove("button");
- button.classList.add("buttonPrimary");
-
- document.getElementById("notificationTestDialog")!.parentElement!.scrollTop = 0;
- }
-
- /**
- * Returns the data used to setup the dialog.
- */
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "notificationTestDialog",
- source: null,
- };
- }
-
- /**
- * Executes a test after clicking on a test button.
- */
- private test(event: MouseEvent): void {
- const button = event.currentTarget as HTMLButtonElement;
-
- button.innerHTML = '<span class="icon icon16 fa-spinner"></span>';
-
- this.buttons.forEach((button) => (button.disabled = true));
-
- Ajax.api(this, {
- parameters: {
- eventID: ~~button.dataset.eventId!,
- },
- });
- }
-}
-
-let acpUiDevtoolsNotificationTest: AcpUiDevtoolsNotificationTest;
-
-/**
- * Initializes the user notification test handler.
- */
-export function init(): void {
- if (!acpUiDevtoolsNotificationTest) {
- acpUiDevtoolsNotificationTest = new AcpUiDevtoolsNotificationTest();
- }
-}
+++ /dev/null
-/**
- * Handles installing a project as a package.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Devtools/Project/Installation/Confirmation
- */
-
-import * as Ajax from "../../../../../Ajax";
-import * as Language from "../../../../../Language";
-import * as UiConfirmation from "../../../../../Ui/Confirmation";
-
-let _projectId: number;
-let _projectName: string;
-
-/**
- * Starts the package installation.
- */
-function installPackage(): void {
- Ajax.apiOnce({
- data: {
- actionName: "installPackage",
- className: "wcf\\data\\devtools\\project\\DevtoolsProjectAction",
- objectIDs: [_projectId],
- },
- success: (data) => {
- const packageInstallation = new window.WCF.ACP.Package.Installation(
- data.returnValues.queueID,
- "DevtoolsInstallPackage",
- data.returnValues.isApplication,
- false,
- { projectID: _projectId },
- );
-
- packageInstallation.prepareInstallation();
- },
- });
-}
-
-/**
- * Shows the confirmation to start package installation.
- */
-function showConfirmation(event: Event): void {
- event.preventDefault();
-
- UiConfirmation.show({
- confirm: () => installPackage(),
- message: Language.get("wcf.acp.devtools.project.installPackage.confirmMessage", {
- packageIdentifier: _projectName,
- }),
- messageIsHtml: true,
- });
-}
-
-/**
- * Initializes the confirmation to install a project as a package.
- */
-export function init(projectId: number, projectName: string): void {
- _projectId = projectId;
- _projectName = projectName;
-
- document.querySelectorAll(".jsDevtoolsInstallPackage").forEach((element: HTMLElement) => {
- element.addEventListener("click", (ev) => showConfirmation(ev));
- });
-}
+++ /dev/null
-/**
- * Handles the JavaScript part of the devtools project pip entry list.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Devtools/Project/Pip/Entry/List
- */
-
-import * as Ajax from "../../../../../../Ajax";
-import * as Core from "../../../../../../Core";
-import * as Language from "../../../../../../Language";
-import { ConfirmationCallbackParameters, show as showConfirmation } from "../../../../../../Ui/Confirmation";
-import * as UiNotification from "../../../../../../Ui/Notification";
-import { AjaxCallbackSetup } from "../../../../../../Ajax/Data";
-
-interface AjaxResponse {
- returnValues: {
- identifier: string;
- };
-}
-
-class DevtoolsProjectPipEntryList {
- private readonly entryType: string;
- private readonly pip: string;
- private readonly projectId: number;
- private readonly supportsDeleteInstruction: boolean;
- private readonly table: HTMLTableElement;
-
- /**
- * Initializes the devtools project pip entry list handler.
- */
- constructor(tableId: string, projectId: number, pip: string, entryType: string, supportsDeleteInstruction: boolean) {
- const table = document.getElementById(tableId);
- if (table === null) {
- throw new Error(`Unknown element with id '${tableId}'.`);
- } else if (!(table instanceof HTMLTableElement)) {
- throw new Error(`Element with id '${tableId}' is no table.`);
- }
- this.table = table;
-
- this.projectId = projectId;
- this.pip = pip;
- this.entryType = entryType;
- this.supportsDeleteInstruction = supportsDeleteInstruction;
-
- this.table.querySelectorAll(".jsDeleteButton").forEach((button: HTMLElement) => {
- button.addEventListener("click", (ev) => this._confirmDeletePipEntry(ev));
- });
- }
-
- /**
- * Returns the data used to setup the AJAX request object.
- */
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "deletePipEntry",
- className: "wcf\\data\\devtools\\project\\DevtoolsProjectAction",
- },
- };
- }
-
- /**
- * Handles successful AJAX request.
- */
- _ajaxSuccess(data: AjaxResponse): void {
- UiNotification.show();
-
- this.table.querySelectorAll("tbody > tr").forEach((pipEntry: HTMLTableRowElement) => {
- if (pipEntry.dataset.identifier === data.returnValues.identifier) {
- pipEntry.remove();
- }
- });
-
- // Reload page if the table is now empty.
- if (this.table.querySelector("tbody > tr") === null) {
- window.location.reload();
- }
- }
-
- /**
- * Shows the confirmation dialog when deleting a pip entry.
- */
- private _confirmDeletePipEntry(event: MouseEvent): void {
- event.preventDefault();
-
- const button = event.currentTarget as HTMLElement;
- const pipEntry = button.closest("tr")!;
-
- let template = "";
- if (this.supportsDeleteInstruction) {
- template = `
-<dl>
- <dt></dt>
- <dd>
- <label>
- <input type="checkbox" name="addDeleteInstruction" checked> ${Language.get(
- "wcf.acp.devtools.project.pip.entry.delete.addDeleteInstruction",
- )}
- </label>
- <small>${Language.get("wcf.acp.devtools.project.pip.entry.delete.addDeleteInstruction.description")}</small>
- </dd>
-</dl>`;
- }
-
- showConfirmation({
- confirm: (parameters, content) => this.deletePipEntry(parameters, content),
- message: Language.get("wcf.acp.devtools.project.pip.entry.delete.confirmMessage"),
- template,
- parameters: {
- pipEntry: pipEntry,
- },
- });
- }
-
- /**
- * Sends the AJAX request to delete a pip entry.
- */
- private deletePipEntry(parameters: ConfirmationCallbackParameters, content: HTMLElement): void {
- let addDeleteInstruction = false;
- if (this.supportsDeleteInstruction) {
- const input = content.querySelector("input[name=addDeleteInstruction]") as HTMLInputElement;
- addDeleteInstruction = input.checked;
- }
-
- const pipEntry = parameters.pipEntry as HTMLTableRowElement;
- Ajax.api(this, {
- objectIDs: [this.projectId],
- parameters: {
- addDeleteInstruction,
- entryType: this.entryType,
- identifier: pipEntry.dataset.identifier,
- pip: this.pip,
- },
- });
- }
-}
-
-Core.enableLegacyInheritance(DevtoolsProjectPipEntryList);
-
-export = DevtoolsProjectPipEntryList;
+++ /dev/null
-/**
- * Handles quick setup of all projects within a path.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Devtools/Project/QuickSetup
- */
-
-import * as Ajax from "../../../../Ajax";
-import DomUtil from "../../../../Dom/Util";
-import * as Language from "../../../../Language";
-import UiDialog from "../../../../Ui/Dialog";
-import * as UiNotification from "../../../../Ui/Notification";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../../Ajax/Data";
-import { DialogCallbackObject, DialogCallbackSetup } from "../../../../Ui/Dialog/Data";
-
-interface AjaxResponse {
- returnValues: {
- errorMessage?: string;
- successMessage: string;
- };
-}
-
-class AcpUiDevtoolsProjectQuickSetup implements AjaxCallbackObject, DialogCallbackObject {
- private readonly pathInput: HTMLInputElement;
- private readonly submitButton: HTMLButtonElement;
-
- /**
- * Initializes the project quick setup handler.
- */
- constructor() {
- document.querySelectorAll(".jsDevtoolsProjectQuickSetupButton").forEach((button: HTMLAnchorElement) => {
- button.addEventListener("click", (ev) => this.showDialog(ev));
- });
-
- this.submitButton = document.getElementById("projectQuickSetupSubmit") as HTMLButtonElement;
- this.submitButton.addEventListener("click", (ev) => this.submit(ev));
-
- this.pathInput = document.getElementById("projectQuickSetupPath") as HTMLInputElement;
- this.pathInput.addEventListener("keypress", (ev) => this.keyPress(ev));
- }
-
- /**
- * Returns the data used to setup the AJAX request object.
- */
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "quickSetup",
- className: "wcf\\data\\devtools\\project\\DevtoolsProjectAction",
- },
- };
- }
-
- /**
- * Handles successful AJAX request.
- */
- _ajaxSuccess(data: AjaxResponse): void {
- if (data.returnValues.errorMessage) {
- DomUtil.innerError(this.pathInput, data.returnValues.errorMessage);
-
- this.submitButton.disabled = false;
-
- return;
- }
-
- UiDialog.close(this);
-
- UiNotification.show(data.returnValues.successMessage, () => {
- window.location.reload();
- });
- }
-
- /**
- * Returns the data used to setup the dialog.
- */
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "projectQuickSetup",
- options: {
- onShow: () => this.onDialogShow(),
- title: Language.get("wcf.acp.devtools.project.quickSetup"),
- },
- };
- }
-
- /**
- * Handles the `[ENTER]` key to submit the form.
- */
- private keyPress(event: KeyboardEvent): void {
- if (event.key === "Enter") {
- this.submit(event);
- }
- }
-
- /**
- * Is called every time the dialog is shown.
- */
- private onDialogShow(): void {
- // reset path input
- this.pathInput.value = "";
- this.pathInput.focus();
-
- // hide error
- DomUtil.innerError(this.pathInput, false);
- }
-
- /**
- * Shows the dialog after clicking on the related button.
- */
- private showDialog(event: MouseEvent): void {
- event.preventDefault();
-
- UiDialog.open(this);
- }
-
- /**
- * Is called if the dialog form is submitted.
- */
- private submit(event: Event): void {
- event.preventDefault();
-
- // check if path is empty
- if (this.pathInput.value === "") {
- DomUtil.innerError(this.pathInput, Language.get("wcf.global.form.error.empty"));
-
- return;
- }
-
- Ajax.api(this, {
- parameters: {
- path: this.pathInput.value,
- },
- });
-
- this.submitButton.disabled = true;
- }
-}
-
-let acpUiDevtoolsProjectQuickSetup: AcpUiDevtoolsProjectQuickSetup;
-
-/**
- * Initializes the project quick setup handler.
- */
-export function init(): void {
- if (!acpUiDevtoolsProjectQuickSetup) {
- acpUiDevtoolsProjectQuickSetup = new AcpUiDevtoolsProjectQuickSetup();
- }
-}
+++ /dev/null
-import * as Ajax from "../../../../Ajax";
-import * as Language from "../../../../Language";
-import UiDialog from "../../../../Ui/Dialog";
-import * as UiNotification from "../../../../Ui/Notification";
-import { AjaxCallbackSetup, AjaxResponseException } from "../../../../Ajax/Data";
-import { DialogCallbackSetup } from "../../../../Ui/Dialog/Data";
-
-interface PipData {
- dependencies: string[];
- pluginName: string;
- targets: string[];
-}
-
-type PendingPip = [string, string];
-
-interface AjaxResponse {
- returnValues: {
- pluginName: string;
- target: string;
- timeElapsed: string;
- };
-}
-
-interface RequestData {
- parameters: {
- pluginName: string;
- target: string;
- };
-}
-
-class AcpUiDevtoolsProjectSync {
- private readonly buttons = new Map<string, HTMLButtonElement>();
- private readonly buttonStatus = new Map<string, HTMLElement>();
- private buttonSyncAll?: HTMLAnchorElement = undefined;
- private readonly container = document.getElementById("syncPipMatches")!;
- private readonly pips: PipData[] = [];
- private readonly projectId: number;
- private queue: PendingPip[] = [];
-
- constructor(projectId: number) {
- this.projectId = projectId;
-
- const restrictedSync = document.getElementById("syncShowOnlyMatches") as HTMLInputElement;
- restrictedSync.addEventListener("change", () => {
- this.container.classList.toggle("jsShowOnlyMatches");
- });
-
- const existingPips: string[] = [];
- const knownPips: string[] = [];
- const tmpPips: PipData[] = [];
- this.container
- .querySelectorAll(".jsHasPipTargets:not(.jsSkipTargetDetection)")
- .forEach((pip: HTMLTableRowElement) => {
- const pluginName = pip.dataset.pluginName!;
- const targets: string[] = [];
-
- this.container
- .querySelectorAll(`.jsHasPipTargets[data-plugin-name="${pluginName}"] .jsInvokePip`)
- .forEach((button: HTMLButtonElement) => {
- const target = button.dataset.target!;
- targets.push(target);
-
- button.addEventListener("click", (event) => {
- event.preventDefault();
-
- if (this.queue.length > 0) {
- return;
- }
-
- this.sync(pluginName, target);
- });
-
- const identifier = this.getButtonIdentifier(pluginName, target);
- this.buttons.set(identifier, button);
- this.buttonStatus.set(
- identifier,
- this.container.querySelector(
- `.jsHasPipTargets[data-plugin-name="${pluginName}"] .jsInvokePipResult[data-target="${target}"]`,
- ) as HTMLElement,
- );
- });
-
- const data: PipData = {
- dependencies: JSON.parse(pip.dataset.syncDependencies!),
- pluginName,
- targets,
- };
-
- if (data.dependencies.length > 0) {
- tmpPips.push(data);
- } else {
- this.pips.push(data);
- knownPips.push(pluginName);
- }
-
- existingPips.push(pluginName);
- });
-
- let resolvedDependency = false;
- while (tmpPips.length > 0) {
- resolvedDependency = false;
-
- tmpPips.forEach((item, index) => {
- if (resolvedDependency) {
- return;
- }
-
- const openDependencies = item.dependencies.filter((dependency) => {
- // Ignore any dependencies that are not present.
- if (existingPips.indexOf(dependency) === -1) {
- window.console.info(`The dependency "${dependency}" does not exist and has been ignored.`);
- return false;
- }
-
- return !knownPips.includes(dependency);
- });
-
- if (openDependencies.length === 0) {
- knownPips.push(item.pluginName);
- this.pips.push(item);
- tmpPips.splice(index, 1);
-
- resolvedDependency = true;
- }
- });
-
- if (!resolvedDependency) {
- // We could not resolve any dependency, either because there is no more pip
- // in `tmpPips` or we're facing a circular dependency. In case there are items
- // left, we simply append them to the end and hope for the operation to
- // complete anyway, despite unmatched dependencies.
- tmpPips.forEach((pip) => {
- window.console.warn("Unable to resolve dependencies for", pip);
-
- this.pips.push(pip);
- });
-
- break;
- }
- }
-
- const syncAll = document.createElement("li");
- syncAll.innerHTML = `<a href="#" class="button"><span class="icon icon16 fa-refresh"></span> ${Language.get(
- "wcf.acp.devtools.sync.syncAll",
- )}</a>`;
- this.buttonSyncAll = syncAll.children[0] as HTMLAnchorElement;
- this.buttonSyncAll.addEventListener("click", this.syncAll.bind(this));
-
- const list = document.querySelector(".contentHeaderNavigation > ul") as HTMLUListElement;
- list.insertAdjacentElement("afterbegin", syncAll);
- }
-
- private sync(pluginName: string, target: string): void {
- const identifier = this.getButtonIdentifier(pluginName, target);
- this.buttons.get(identifier)!.disabled = true;
- this.buttonStatus.get(identifier)!.innerHTML = '<span class="icon icon16 fa-spinner"></span>';
-
- Ajax.api(this, {
- parameters: {
- pluginName,
- target,
- },
- });
- }
-
- private syncAll(event: MouseEvent): void {
- event.preventDefault();
-
- if (this.buttonSyncAll!.classList.contains("disabled")) {
- return;
- }
-
- this.buttonSyncAll!.classList.add("disabled");
-
- this.queue = [];
- this.pips.forEach((pip) => {
- pip.targets.forEach((target) => {
- this.queue.push([pip.pluginName, target]);
- });
- });
- this.syncNext();
- }
-
- private syncNext(): void {
- if (this.queue.length === 0) {
- this.buttonSyncAll!.classList.remove("disabled");
-
- UiNotification.show();
-
- return;
- }
-
- const next = this.queue.shift()!;
- this.sync(next[0], next[1]);
- }
-
- private getButtonIdentifier(pluginName: string, target: string): string {
- return `${pluginName}-${target}`;
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- const identifier = this.getButtonIdentifier(data.returnValues.pluginName, data.returnValues.target);
- this.buttons.get(identifier)!.disabled = false;
- this.buttonStatus.get(identifier)!.innerHTML = data.returnValues.timeElapsed;
-
- this.syncNext();
- }
-
- _ajaxFailure(
- data: AjaxResponseException,
- responseText: string,
- xhr: XMLHttpRequest,
- requestData: RequestData,
- ): boolean {
- const identifier = this.getButtonIdentifier(requestData.parameters.pluginName, requestData.parameters.target);
- this.buttons.get(identifier)!.disabled = false;
-
- const buttonStatus = this.buttonStatus.get(identifier)!;
- buttonStatus.innerHTML = '<a href="#">' + Language.get("wcf.acp.devtools.sync.status.failure") + "</a>";
- buttonStatus.children[0].addEventListener("click", (event) => {
- event.preventDefault();
-
- UiDialog.open(this, Ajax.getRequestObject(this).getErrorHtml(data, xhr));
- });
-
- this.buttonSyncAll!.classList.remove("disabled");
-
- return false;
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "invoke",
- className: "wcf\\data\\package\\installation\\plugin\\PackageInstallationPluginAction",
- parameters: {
- projectID: this.projectId,
- },
- },
- };
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "devtoolsProjectSyncPipError",
- options: {
- title: Language.get("wcf.global.error.title"),
- },
- source: null,
- };
- }
-}
-
-let acpUiDevtoolsProjectSync: AcpUiDevtoolsProjectSync;
-
-export function init(projectId: number): void {
- if (!acpUiDevtoolsProjectSync) {
- acpUiDevtoolsProjectSync = new AcpUiDevtoolsProjectSync(projectId);
- }
-}
+++ /dev/null
-/**
- * Provides the interface logic to add and edit menu items.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Menu/Item/Handler
- */
-
-import Dictionary from "../../../../Dictionary";
-import DomUtil from "../../../../Dom/Util";
-import * as Language from "../../../../Language";
-import * as UiPageSearchHandler from "../../../../Ui/Page/Search/Handler";
-
-class AcpUiMenuItemHandler {
- private activePageId = 0;
- private readonly cache = new Map<number, number>();
- private readonly containerExternalLink: HTMLElement;
- private readonly containerInternalLink: HTMLElement;
- private readonly containerPageObjectId: HTMLElement;
- private readonly handlers: Map<number, string>;
- private readonly pageId: HTMLSelectElement;
- private readonly pageObjectId: HTMLInputElement;
-
- /**
- * Initializes the interface logic.
- */
- constructor(handlers: Map<number, string>) {
- this.handlers = handlers;
-
- this.containerInternalLink = document.getElementById("pageIDContainer")!;
- this.containerExternalLink = document.getElementById("externalURLContainer")!;
- this.containerPageObjectId = document.getElementById("pageObjectIDContainer")!;
-
- if (this.handlers.size) {
- this.pageId = document.getElementById("pageID") as HTMLSelectElement;
- this.pageId.addEventListener("change", this.togglePageId.bind(this));
-
- this.pageObjectId = document.getElementById("pageObjectID") as HTMLInputElement;
-
- this.activePageId = ~~this.pageId.value;
- if (this.activePageId && this.handlers.has(this.activePageId)) {
- this.cache.set(this.activePageId, ~~this.pageObjectId.value);
- }
-
- const searchButton = document.getElementById("searchPageObjectID")!;
- searchButton.addEventListener("click", (ev) => this.openSearch(ev));
-
- // toggle page object id container on init
- if (this.handlers.has(~~this.pageId.value)) {
- DomUtil.show(this.containerPageObjectId);
- }
- }
-
- document.querySelectorAll('input[name="isInternalLink"]').forEach((input: HTMLInputElement) => {
- input.addEventListener("change", () => this.toggleIsInternalLink(input.value));
-
- if (input.checked) {
- this.toggleIsInternalLink(input.value);
- }
- });
- }
-
- /**
- * Toggles between the interface for internal and external links.
- */
- private toggleIsInternalLink(value: string): void {
- if (~~value) {
- DomUtil.show(this.containerInternalLink);
- DomUtil.hide(this.containerExternalLink);
- if (this.handlers.size) {
- this.togglePageId();
- }
- } else {
- DomUtil.hide(this.containerInternalLink);
- DomUtil.hide(this.containerPageObjectId);
- DomUtil.show(this.containerExternalLink);
- }
- }
-
- /**
- * Handles the changed page selection.
- */
- private togglePageId(): void {
- if (this.handlers.has(this.activePageId)) {
- this.cache.set(this.activePageId, ~~this.pageObjectId.value);
- }
-
- this.activePageId = ~~this.pageId.value;
-
- // page w/o pageObjectID support, discard value
- if (!this.handlers.has(this.activePageId)) {
- this.pageObjectId.value = "";
-
- DomUtil.hide(this.containerPageObjectId);
-
- return;
- }
-
- const newValue = this.cache.get(this.activePageId);
- this.pageObjectId.value = newValue ? newValue.toString() : "";
-
- const selectedOption = this.pageId.options[this.pageId.selectedIndex];
- const pageIdentifier = selectedOption.dataset.identifier!;
- let languageItem = `wcf.page.pageObjectID.${pageIdentifier}`;
- if (Language.get(languageItem) === languageItem) {
- languageItem = "wcf.page.pageObjectID";
- }
-
- this.containerPageObjectId.querySelector("label")!.textContent = Language.get(languageItem);
-
- DomUtil.show(this.containerPageObjectId);
- }
-
- /**
- * Opens the handler lookup dialog.
- */
- private openSearch(event: MouseEvent): void {
- event.preventDefault();
-
- const selectedOption = this.pageId.options[this.pageId.selectedIndex];
- const pageIdentifier = selectedOption.dataset.identifier!;
- const languageItem = `wcf.page.pageObjectID.search.${pageIdentifier}`;
-
- let labelLanguageItem;
- if (Language.get(languageItem) !== languageItem) {
- labelLanguageItem = languageItem;
- }
-
- UiPageSearchHandler.open(
- this.activePageId,
- selectedOption.textContent!.trim(),
- (objectId) => {
- this.pageObjectId.value = objectId.toString();
- this.cache.set(this.activePageId, objectId);
- },
- labelLanguageItem,
- );
- }
-}
-
-let acpUiMenuItemHandler: AcpUiMenuItemHandler;
-
-export function init(handlers: Dictionary<string> | Map<number, string>): void {
- if (!acpUiMenuItemHandler) {
- let map: Map<number, string>;
- if (!(handlers instanceof Map)) {
- map = new Map();
- handlers.forEach((value, key) => {
- map.set(~~~key, value);
- });
- } else {
- map = handlers;
- }
-
- acpUiMenuItemHandler = new AcpUiMenuItemHandler(map);
- }
-}
+++ /dev/null
-/**
- * Simple SMTP connection testing.
- *
- * @author Alexander Ebert
- * @copyright 2001-2018 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Option/EmailSmtpTest
- */
-
-import * as Ajax from "../../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../Ajax/Data";
-import DomUtil from "../../../Dom/Util";
-import * as Language from "../../../Language";
-
-interface AjaxResponse {
- returnValues: {
- fieldName?: string;
- validationResult: string;
- };
-}
-
-class EmailSmtpTest implements AjaxCallbackObject {
- private readonly buttonRunTest: HTMLAnchorElement;
- private readonly container: HTMLDListElement;
-
- constructor() {
- let smtpCheckbox: HTMLInputElement | null = null;
- const methods = document.querySelectorAll('input[name="values[mail_send_method]"]');
- methods.forEach((checkbox: HTMLInputElement) => {
- checkbox.addEventListener("change", () => this.onChange(checkbox));
-
- if (checkbox.value === "smtp") {
- smtpCheckbox = checkbox;
- }
- });
-
- // This configuration part is unavailable when running in enterprise mode.
- if (methods.length === 0) {
- return;
- }
-
- this.container = document.createElement("dl");
- this.container.innerHTML = `<dt>${Language.get("wcf.acp.email.smtp.test")}</dt>
-<dd>
- <a href="#" class="button">${Language.get("wcf.acp.email.smtp.test.run")}</a>
- <small>${Language.get("wcf.acp.email.smtp.test.description")}</small>
-</dd>`;
-
- this.buttonRunTest = this.container.querySelector("a")!;
- this.buttonRunTest.addEventListener("click", (ev) => this.onClick(ev));
-
- if (smtpCheckbox) {
- this.onChange(smtpCheckbox);
- }
- }
-
- private onChange(checkbox: HTMLInputElement): void {
- if (checkbox.value === "smtp" && checkbox.checked) {
- if (this.container.parentElement === null) {
- this.initUi(checkbox);
- }
-
- DomUtil.show(this.container);
- } else if (this.container.parentElement !== null) {
- DomUtil.hide(this.container);
- }
- }
-
- private initUi(checkbox: HTMLInputElement): void {
- const insertAfter = checkbox.closest("dl") as HTMLDListElement;
- insertAfter.insertAdjacentElement("afterend", this.container);
- }
-
- private onClick(event: MouseEvent) {
- event.preventDefault();
-
- this.buttonRunTest.classList.add("disabled");
- this.buttonRunTest.innerHTML = `<span class="icon icon16 fa-spinner"></span> ${Language.get("wcf.global.loading")}`;
-
- DomUtil.innerError(this.buttonRunTest, false);
-
- window.setTimeout(() => {
- const startTls = document.querySelector('input[name="values[mail_smtp_starttls]"]:checked') as HTMLInputElement;
-
- const host = document.getElementById("mail_smtp_host") as HTMLInputElement;
- const port = document.getElementById("mail_smtp_port") as HTMLInputElement;
- const user = document.getElementById("mail_smtp_user") as HTMLInputElement;
- const password = document.getElementById("mail_smtp_password") as HTMLInputElement;
-
- Ajax.api(this, {
- parameters: {
- host: host.value,
- port: port.value,
- startTls: startTls ? startTls.value : "",
- user: user.value,
- password: password.value,
- },
- });
- }, 100);
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- const result = data.returnValues.validationResult;
- if (result === "") {
- this.resetButton(true);
- } else {
- this.resetButton(false, result);
- }
- }
-
- _ajaxFailure(data: AjaxResponse): boolean {
- let result = "";
- if (data && data.returnValues && data.returnValues.fieldName) {
- result = Language.get(`wcf.acp.email.smtp.test.error.empty.${data.returnValues.fieldName}`);
- }
-
- this.resetButton(false, result);
-
- return result === "";
- }
-
- private resetButton(success: boolean, errorMessage?: string): void {
- this.buttonRunTest.classList.remove("disabled");
-
- if (success) {
- this.buttonRunTest.innerHTML = `<span class="icon icon16 fa-check green"></span> ${Language.get(
- "wcf.acp.email.smtp.test.run.success",
- )}`;
- } else {
- this.buttonRunTest.innerHTML = Language.get("wcf.acp.email.smtp.test.run");
- }
-
- if (errorMessage) {
- DomUtil.innerError(this.buttonRunTest, errorMessage);
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "emailSmtpTest",
- className: "wcf\\data\\option\\OptionAction",
- },
- silent: true,
- };
- }
-}
-
-let emailSmtpTest: EmailSmtpTest;
-
-export function init(): void {
- if (!emailSmtpTest) {
- emailSmtpTest = new EmailSmtpTest();
- }
-}
+++ /dev/null
-/**
- * Automatic URL rewrite rule generation.
- *
- * @author Florian Gail
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Option/RewriteGenerator
- */
-
-import * as Ajax from "../../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "../../../Ajax/Data";
-import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
-import * as Language from "../../../Language";
-import UiDialog from "../../../Ui/Dialog";
-
-class RewriteGenerator implements AjaxCallbackObject, DialogCallbackObject {
- private readonly buttonGenerate: HTMLAnchorElement;
- private readonly container: HTMLDListElement;
-
- /**
- * Initializes the generator for rewrite rules
- */
- constructor() {
- const urlOmitIndexPhp = document.getElementById("url_omit_index_php");
-
- // This configuration part is unavailable when running in enterprise mode.
- if (urlOmitIndexPhp === null) {
- return;
- }
-
- this.container = document.createElement("dl");
- const dt = document.createElement("dt");
- dt.classList.add("jsOnly");
- const dd = document.createElement("dd");
-
- this.buttonGenerate = document.createElement("a");
- this.buttonGenerate.className = "button";
- this.buttonGenerate.href = "#";
- this.buttonGenerate.textContent = Language.get("wcf.acp.rewrite.generate");
- this.buttonGenerate.addEventListener("click", (ev) => this._onClick(ev));
- dd.appendChild(this.buttonGenerate);
-
- const description = document.createElement("small");
- description.textContent = Language.get("wcf.acp.rewrite.description");
- dd.appendChild(description);
-
- this.container.appendChild(dt);
- this.container.appendChild(dd);
-
- const insertAfter = urlOmitIndexPhp.closest("dl")!;
- insertAfter.insertAdjacentElement("afterend", this.container);
- }
-
- /**
- * Fires an AJAX request and opens the dialog
- */
- _onClick(event: MouseEvent): void {
- event.preventDefault();
-
- Ajax.api(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "dialogRewriteRules",
- source: null,
- options: {
- title: Language.get("wcf.acp.rewrite"),
- },
- };
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "generateRewriteRules",
- className: "wcf\\data\\option\\OptionAction",
- },
- };
- }
-
- _ajaxSuccess(data: ResponseData): void {
- UiDialog.open(this, data.returnValues);
- }
-}
-
-let rewriteGenerator: RewriteGenerator;
-
-export function init(): void {
- if (!rewriteGenerator) {
- rewriteGenerator = new RewriteGenerator();
- }
-}
+++ /dev/null
-/**
- * Automatic URL rewrite support testing.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Option/RewriteTest
- */
-
-import AjaxRequest from "../../../Ajax/Request";
-import * as Language from "../../../Language";
-import UiDialog from "../../../Ui/Dialog";
-import { DialogCallbackSetup } from "../../../Ui/Dialog/Data";
-import DomUtil from "../../../Dom/Util";
-
-interface TestResult {
- app: string;
- pass: boolean;
-}
-
-class RewriteTest {
- private readonly apps: Map<string, string>;
- private readonly buttonStartTest = document.getElementById("rewriteTestStart") as HTMLAnchorElement;
- private readonly callbackChange: (ev: MouseEvent) => void;
- private passed = false;
- private readonly urlOmitIndexPhp: HTMLInputElement;
-
- /**
- * Initializes the rewrite test, but aborts early if URL rewriting was
- * enabled at page init.
- */
- constructor(apps: Map<string, string>) {
- const urlOmitIndexPhp = document.getElementById("url_omit_index_php") as HTMLInputElement;
-
- // This configuration part is unavailable when running in enterprise mode.
- if (urlOmitIndexPhp === null) {
- return;
- }
-
- this.urlOmitIndexPhp = urlOmitIndexPhp;
- if (this.urlOmitIndexPhp.checked) {
- // option is already enabled, ignore it
- return;
- }
-
- this.callbackChange = (ev) => this.onChange(ev);
- this.urlOmitIndexPhp.addEventListener("change", this.callbackChange);
- this.apps = apps;
- }
-
- /**
- * Forces the rewrite test when attempting to enable the URL rewriting.
- */
- private onChange(event: Event): void {
- event.preventDefault();
-
- UiDialog.open(this);
- }
-
- /**
- * Runs the actual rewrite test.
- */
- private async runTest(event?: MouseEvent): Promise<void> {
- if (event instanceof Event) {
- event.preventDefault();
- }
-
- if (this.buttonStartTest.classList.contains("disabled")) {
- return;
- }
-
- this.buttonStartTest.classList.add("disabled");
- this.setStatus("running");
-
- const tests: Promise<TestResult>[] = Array.from(this.apps).map(([app, url]) => {
- return new Promise((resolve, reject) => {
- const request = new AjaxRequest({
- ignoreError: true,
- // bypass the LinkHandler, because rewrites aren't enabled yet
- url: url,
- type: "GET",
- includeRequestedWith: false,
- success: (data) => {
- if (
- !Object.prototype.hasOwnProperty.call(data, "core_rewrite_test") ||
- data.core_rewrite_test !== "passed"
- ) {
- reject({ app, pass: false });
- } else {
- resolve({ app, pass: true });
- }
- },
- failure: () => {
- reject({ app, pass: false });
-
- return true;
- },
- });
-
- request.sendRequest(false);
- });
- });
-
- const results: TestResult[] = await Promise.all(tests.map((test) => test.catch((result) => result)));
-
- const passed = results.every((result) => result.pass);
-
- // Delay the status update to prevent UI flicker.
- await new Promise((resolve) => window.setTimeout(resolve, 500));
-
- if (passed) {
- this.passed = true;
-
- this.setStatus("success");
-
- this.urlOmitIndexPhp.removeEventListener("change", this.callbackChange);
-
- await new Promise((resolve) => window.setTimeout(resolve, 1000));
-
- if (UiDialog.isOpen(this)) {
- UiDialog.close(this);
- }
- } else {
- this.buttonStartTest.classList.remove("disabled");
-
- const testFailureResults = document.getElementById("dialogRewriteTestFailureResults")!;
- testFailureResults.innerHTML = results
- .map((result) => {
- return `<li><span class="badge label ${result.pass ? "green" : "red"}">${Language.get(
- "wcf.acp.option.url_omit_index_php.test.status." + (result.pass ? "success" : "failure"),
- )}</span> ${result.app}</li>`;
- })
- .join("");
-
- this.setStatus("failure");
- }
- }
-
- /**
- * Displays the appropriate dialog message.
- */
- private setStatus(status: string): void {
- const containers = [
- document.getElementById("dialogRewriteTestRunning")!,
- document.getElementById("dialogRewriteTestSuccess")!,
- document.getElementById("dialogRewriteTestFailure")!,
- ];
-
- containers.forEach((element) => DomUtil.hide(element));
-
- let i = 0;
- if (status === "success") {
- i = 1;
- } else if (status === "failure") {
- i = 2;
- }
-
- DomUtil.show(containers[i]);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "dialogRewriteTest",
- options: {
- onClose: () => {
- if (!this.passed) {
- const urlOmitIndexPhpNo = document.getElementById("url_omit_index_php_no") as HTMLInputElement;
- urlOmitIndexPhpNo.checked = true;
- }
- },
- onSetup: () => {
- this.buttonStartTest.addEventListener("click", (ev) => {
- void this.runTest(ev);
- });
- },
- onShow: () => this.runTest(),
- title: Language.get("wcf.acp.option.url_omit_index_php"),
- },
- };
- }
-}
-
-let rewriteTest: RewriteTest;
-
-export function init(apps: Map<string, string>): void {
- if (!rewriteTest) {
- rewriteTest = new RewriteTest(apps);
- }
-}
+++ /dev/null
-/**
- * Attempts to download the requested package from the file and prompts for the
- * authentication credentials on rejection.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Package/PrepareInstallation
- */
-
-import * as Ajax from "../../../Ajax";
-import { AjaxCallbackSetup } from "../../../Ajax/Data";
-import * as Core from "../../../Core";
-import { DialogCallbackSetup } from "../../../Ui/Dialog/Data";
-import * as Language from "../../../Language";
-import UiDialog from "../../../Ui/Dialog";
-import DomUtil from "../../../Dom/Util";
-
-interface AjaxResponse {
- returnValues: {
- queueID?: number;
- template?: string;
- };
-}
-
-class AcpUiPackagePrepareInstallation {
- private identifier = "";
- private version = "";
-
- start(identifier: string, version: string): void {
- this.identifier = identifier;
- this.version = version;
-
- this.prepare({});
- }
-
- private prepare(authData: ArbitraryObject): void {
- const packages = {};
- packages[this.identifier] = this.version;
-
- Ajax.api(this, {
- parameters: {
- authData: authData,
- packages: packages,
- },
- });
- }
-
- private submit(packageUpdateServerId: number): void {
- const usernameInput = document.getElementById("packageUpdateServerUsername") as HTMLInputElement;
- const passwordInput = document.getElementById("packageUpdateServerPassword") as HTMLInputElement;
-
- DomUtil.innerError(usernameInput, false);
- DomUtil.innerError(passwordInput, false);
-
- const username = usernameInput.value.trim();
- if (username === "") {
- DomUtil.innerError(usernameInput, Language.get("wcf.global.form.error.empty"));
- } else {
- const password = passwordInput.value.trim();
- if (password === "") {
- DomUtil.innerError(passwordInput, Language.get("wcf.global.form.error.empty"));
- } else {
- const saveCredentials = document.getElementById("packageUpdateServerSaveCredentials") as HTMLInputElement;
-
- this.prepare({
- packageUpdateServerID: packageUpdateServerId,
- password,
- saveCredentials: saveCredentials.checked,
- username,
- });
- }
- }
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- if (data.returnValues.queueID) {
- if (UiDialog.isOpen(this)) {
- UiDialog.close(this);
- }
-
- const installation = new window.WCF.ACP.Package.Installation(data.returnValues.queueID, undefined, false);
- installation.prepareInstallation();
- } else if (data.returnValues.template) {
- UiDialog.open(this, data.returnValues.template);
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "prepareInstallation",
- className: "wcf\\data\\package\\update\\PackageUpdateAction",
- },
- };
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "packageDownloadAuthorization",
- options: {
- onSetup: (content) => {
- const button = content.querySelector(".formSubmit > button") as HTMLButtonElement;
- button.addEventListener("click", (event) => {
- event.preventDefault();
-
- const packageUpdateServerId = ~~button.dataset.packageUpdateServerId!;
- this.submit(packageUpdateServerId);
- });
- },
- title: Language.get("wcf.acp.package.update.unauthorized"),
- },
- source: null,
- };
- }
-}
-
-Core.enableLegacyInheritance(AcpUiPackagePrepareInstallation);
-
-export = AcpUiPackagePrepareInstallation;
+++ /dev/null
-/**
- * Search interface for the package server lists.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Package/Search
- */
-
-import AcpUiPackagePrepareInstallation from "./PrepareInstallation";
-import * as Ajax from "../../../Ajax";
-import AjaxRequest from "../../../Ajax/Request";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../Ajax/Data";
-import * as Core from "../../../Core";
-
-interface AjaxResponse {
- actionName: string;
- returnValues: {
- count: number;
- template: string;
- };
-}
-
-interface SearchOptions {
- delay: number;
- minLength: number;
-}
-
-class AcpUiPackageSearch implements AjaxCallbackObject {
- private readonly input: HTMLInputElement;
- private readonly installation: AcpUiPackagePrepareInstallation;
- private isBusy = false;
- private isFirstRequest = true;
- private lastValue = "";
- private options: SearchOptions;
- private request?: AjaxRequest = undefined;
- private readonly resultList: HTMLElement;
- private readonly resultListContainer: HTMLElement;
- private readonly resultCounter: HTMLElement;
- private timerDelay?: number = undefined;
-
- constructor() {
- this.input = document.getElementById("packageSearchInput") as HTMLInputElement;
- this.installation = new AcpUiPackagePrepareInstallation();
- this.options = {
- delay: 300,
- minLength: 3,
- };
- this.resultList = document.getElementById("packageSearchResultList")!;
- this.resultListContainer = document.getElementById("packageSearchResultContainer")!;
- this.resultCounter = document.getElementById("packageSearchResultCounter")!;
-
- this.input.addEventListener("keyup", () => this.keyup());
- }
-
- private keyup(): void {
- const value = this.input.value.trim();
- if (this.lastValue === value) {
- return;
- }
-
- this.lastValue = value;
-
- if (value.length < this.options.minLength) {
- this.setStatus("idle");
- return;
- }
-
- if (this.isFirstRequest) {
- if (!this.isBusy) {
- this.isBusy = true;
-
- this.setStatus("refreshDatabase");
-
- Ajax.api(this, {
- actionName: "refreshDatabase",
- });
- }
-
- return;
- }
-
- if (this.timerDelay !== null) {
- window.clearTimeout(this.timerDelay);
- }
-
- this.timerDelay = window.setTimeout(() => {
- this.setStatus("loading");
- this.search(value);
- }, this.options.delay);
- }
-
- private search(value: string): void {
- if (this.request) {
- this.request.abortPrevious();
- }
-
- this.request = Ajax.api(this, {
- parameters: {
- searchString: value,
- },
- });
- }
-
- private setStatus(status: string): void {
- this.resultListContainer.dataset.status = status;
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- switch (data.actionName) {
- case "refreshDatabase":
- this.isFirstRequest = false;
-
- this.lastValue = "";
- this.keyup();
- break;
-
- case "search":
- if (data.returnValues.count > 0) {
- this.resultList.innerHTML = data.returnValues.template;
- this.resultCounter.textContent = data.returnValues.count.toString();
-
- this.setStatus("showResults");
-
- this.resultList.querySelectorAll(".jsInstallPackage").forEach((button: HTMLAnchorElement) => {
- button.addEventListener("click", (event) => {
- event.preventDefault();
- button.blur();
-
- this.installation.start(button.dataset.package!, button.dataset.packageVersion!);
- });
- });
- } else {
- this.setStatus("noResults");
- }
- break;
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "search",
- className: "wcf\\data\\package\\update\\PackageUpdateAction",
- },
- silent: true,
- };
- }
-}
-
-Core.enableLegacyInheritance(AcpUiPackageSearch);
-
-export = AcpUiPackageSearch;
+++ /dev/null
-/**
- * Provides the dialog overlay to add a new page.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Page/Add
- */
-
-import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
-import * as Language from "../../../Language";
-import UiDialog from "../../../Ui/Dialog";
-
-class AcpUiPageAdd implements DialogCallbackObject {
- private readonly isI18n: boolean;
- private readonly link: string;
-
- constructor(link: string, isI18n: boolean) {
- this.link = link;
- this.isI18n = isI18n;
-
- document.querySelectorAll(".jsButtonPageAdd").forEach((button: HTMLAnchorElement) => {
- button.addEventListener("click", (ev) => this.openDialog(ev));
- });
- }
-
- /**
- * Opens the 'Add Page' dialog.
- */
- openDialog(event?: MouseEvent): void {
- if (event instanceof Event) {
- event.preventDefault();
- }
-
- UiDialog.open(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "pageAddDialog",
- options: {
- onSetup: (content) => {
- const button = content.querySelector("button") as HTMLButtonElement;
- button.addEventListener("click", (event) => {
- event.preventDefault();
-
- const pageType = (content.querySelector('input[name="pageType"]:checked') as HTMLInputElement).value;
- let isMultilingual = "0";
- if (this.isI18n) {
- isMultilingual = (content.querySelector('input[name="isMultilingual"]:checked') as HTMLInputElement)
- .value;
- }
-
- window.location.href = this.link
- .replace("{$pageType}", pageType)
- .replace("{$isMultilingual}", isMultilingual);
- });
- },
- title: Language.get("wcf.acp.page.add"),
- },
- };
- }
-}
-
-let acpUiPageAdd: AcpUiPageAdd;
-
-/**
- * Initializes the page add handler.
- */
-export function init(link: string, languages: number): void {
- if (!acpUiPageAdd) {
- acpUiPageAdd = new AcpUiPageAdd(link, languages > 0);
- }
-}
-
-/**
- * Opens the 'Add Page' dialog.
- */
-export function openDialog(): void {
- acpUiPageAdd.openDialog();
-}
+++ /dev/null
-/**
- * Provides helper functions to sort boxes per page.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Page/BoxOrder
- */
-
-import * as Ajax from "../../../Ajax";
-import DomChangeListener from "../../../Dom/Change/Listener";
-import * as Language from "../../../Language";
-import * as UiConfirmation from "../../../Ui/Confirmation";
-import * as UiNotification from "../../../Ui/Notification";
-import { AjaxCallbackSetup } from "../../../Ajax/Data";
-
-interface AjaxResponse {
- actionName: string;
-}
-
-interface BoxData {
- boxId: number;
- isDisabled: boolean;
- name: string;
-}
-
-class AcpUiPageBoxOrder {
- private readonly pageId: number;
- private readonly pbo: HTMLElement;
-
- /**
- * Initializes the sorting capabilities.
- */
- constructor(pageId: number, boxes: Map<string, BoxData[]>) {
- this.pageId = pageId;
- this.pbo = document.getElementById("pbo")!;
-
- boxes.forEach((boxData, position) => {
- const container = document.createElement("ul");
- boxData.forEach((box) => {
- const item = document.createElement("li");
- item.dataset.boxId = box.boxId.toString();
-
- let icon = "";
- if (box.isDisabled) {
- icon = ` <span class="icon icon16 fa-exclamation-triangle red jsTooltip" title="${Language.get(
- "wcf.acp.box.isDisabled",
- )}"></span>`;
- }
-
- item.innerHTML = box.name + icon;
-
- container.appendChild(item);
- });
-
- if (boxData.length > 1) {
- window.jQuery(container).sortable({
- opacity: 0.6,
- placeholder: "sortablePlaceholder",
- });
- }
-
- const wrapper = this.pbo.querySelector(`[data-placeholder="${position}"]`) as HTMLElement;
- wrapper.appendChild(container);
- });
-
- const submitButton = document.querySelector('button[data-type="submit"]') as HTMLButtonElement;
- submitButton.addEventListener("click", (ev) => this.save(ev));
-
- const buttonDiscard = document.querySelector(".jsButtonCustomShowOrder") as HTMLAnchorElement;
- if (buttonDiscard) buttonDiscard.addEventListener("click", (ev) => this.discard(ev));
-
- DomChangeListener.trigger();
- }
-
- /**
- * Saves the order of all boxes per position.
- */
- private save(event: MouseEvent): void {
- event.preventDefault();
-
- const data = {};
-
- // collect data
- this.pbo.querySelectorAll("[data-placeholder]").forEach((position: HTMLElement) => {
- const boxIds = Array.from(position.querySelectorAll("li"))
- .map((element) => ~~element.dataset.boxId!)
- .filter((id) => id > 0);
-
- const placeholder = position.dataset.placeholder!;
- data[placeholder] = boxIds;
- });
-
- Ajax.api(this, {
- parameters: {
- position: data,
- },
- });
- }
-
- /**
- * Shows an dialog to discard the individual box show order for this page.
- */
- private discard(event: MouseEvent): void {
- event.preventDefault();
-
- UiConfirmation.show({
- confirm: () => {
- Ajax.api(this, {
- actionName: "resetPosition",
- });
- },
- message: Language.get("wcf.acp.page.boxOrder.discard.confirmMessage"),
- });
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- switch (data.actionName) {
- case "updatePosition":
- UiNotification.show();
- break;
-
- case "resetPosition":
- UiNotification.show(undefined, () => {
- window.location.reload();
- });
- break;
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "updatePosition",
- className: "wcf\\data\\page\\PageAction",
- interfaceName: "wcf\\data\\ISortableAction",
- objectIDs: [this.pageId],
- },
- };
- }
-}
-
-let acpUiPageBoxOrder: AcpUiPageBoxOrder;
-
-/**
- * Initializes the sorting capabilities.
- */
-export function init(pageId: number, boxes: Map<string, BoxData[]>): void {
- if (!acpUiPageBoxOrder) {
- acpUiPageBoxOrder = new AcpUiPageBoxOrder(pageId, boxes);
- }
-}
+++ /dev/null
-import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
-import * as Language from "../../../Language";
-import UiDialog from "../../../Ui/Dialog";
-
-class AcpUiPageCopy implements DialogCallbackObject {
- constructor() {
- document.querySelectorAll(".jsButtonCopyPage").forEach((button: HTMLAnchorElement) => {
- button.addEventListener("click", (ev) => this.click(ev));
- });
- }
-
- private click(event: MouseEvent): void {
- event.preventDefault();
-
- UiDialog.open(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "acpPageCopyDialog",
- options: {
- title: Language.get("wcf.acp.page.copy"),
- },
- };
- }
-}
-
-let acpUiPageCopy: AcpUiPageCopy;
-
-export function init(): void {
- if (!acpUiPageCopy) {
- acpUiPageCopy = new AcpUiPageCopy();
- }
-}
+++ /dev/null
-/**
- * Provides the ACP menu navigation.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Page/Menu
- */
-
-import perfectScrollbar from "perfect-scrollbar";
-
-import * as EventHandler from "../../../Event/Handler";
-import * as UiScreen from "../../../Ui/Screen";
-
-const _acpPageMenu = document.getElementById("acpPageMenu") as HTMLElement;
-const _acpPageSubMenu = document.getElementById("acpPageSubMenu") as HTMLElement;
-let _activeMenuItem = "";
-const _menuItems = new Map<string, HTMLAnchorElement>();
-const _menuItemContainers = new Map<string, HTMLOListElement>();
-const _pageContainer = document.getElementById("pageContainer") as HTMLElement;
-let _perfectScrollbarActive = false;
-
-/**
- * Initializes the ACP menu navigation.
- */
-export function init(): void {
- document.querySelectorAll(".acpPageMenuLink").forEach((link: HTMLAnchorElement) => {
- const menuItem = link.dataset.menuItem!;
- if (link.classList.contains("active")) {
- _activeMenuItem = menuItem;
- }
-
- link.addEventListener("click", (ev) => toggle(ev));
-
- _menuItems.set(menuItem, link);
- });
-
- document.querySelectorAll(".acpPageSubMenuCategoryList").forEach((container: HTMLOListElement) => {
- const menuItem = container.dataset.menuItem!;
- _menuItemContainers.set(menuItem, container);
- });
-
- // menu is missing on the login page or during WCFSetup
- if (_acpPageMenu === null) {
- return;
- }
-
- UiScreen.on("screen-lg", {
- match: enablePerfectScrollbar,
- unmatch: disablePerfectScrollbar,
- setup: enablePerfectScrollbar,
- });
-
- window.addEventListener("resize", () => {
- if (_perfectScrollbarActive) {
- perfectScrollbar.update(_acpPageMenu);
- perfectScrollbar.update(_acpPageSubMenu);
- }
- });
-}
-
-function enablePerfectScrollbar(): void {
- const options = {
- wheelPropagation: false,
- swipePropagation: false,
- suppressScrollX: true,
- };
-
- perfectScrollbar.initialize(_acpPageMenu, options);
- perfectScrollbar.initialize(_acpPageSubMenu, options);
-
- _perfectScrollbarActive = true;
-}
-
-function disablePerfectScrollbar(): void {
- perfectScrollbar.destroy(_acpPageMenu);
- perfectScrollbar.destroy(_acpPageSubMenu);
-
- _perfectScrollbarActive = false;
-}
-
-/**
- * Toggles a menu item.
- */
-function toggle(event: MouseEvent): void {
- event.preventDefault();
- event.stopPropagation();
-
- const link = event.currentTarget as HTMLAnchorElement;
- const menuItem = link.dataset.menuItem!;
- let acpPageSubMenuActive = false;
-
- // remove active marking from currently active menu
- if (_activeMenuItem) {
- _menuItems.get(_activeMenuItem)!.classList.remove("active");
- _menuItemContainers.get(_activeMenuItem)!.classList.remove("active");
- }
-
- if (_activeMenuItem === menuItem) {
- // current item was active before
- _activeMenuItem = "";
- } else {
- link.classList.add("active");
- _menuItemContainers.get(menuItem)!.classList.add("active");
-
- _activeMenuItem = menuItem;
- acpPageSubMenuActive = true;
- }
-
- if (acpPageSubMenuActive) {
- _pageContainer.classList.add("acpPageSubMenuActive");
- } else {
- _pageContainer.classList.remove("acpPageSubMenuActive");
- }
-
- if (_perfectScrollbarActive) {
- _acpPageSubMenu.scrollTop = 0;
- perfectScrollbar.update(_acpPageSubMenu);
- }
-
- EventHandler.fire("com.woltlab.wcf.AcpMenu", "resize");
-}
+++ /dev/null
-/**
- * Provides the style editor.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Style/Editor
- */
-
-import * as Ajax from "../../../Ajax";
-import * as Core from "../../../Core";
-import DomUtil from "../../../Dom/Util";
-import * as EventHandler from "../../../Event/Handler";
-import * as UiScreen from "../../../Ui/Screen";
-
-const _stylePreviewRegions = new Map<string, HTMLElement>();
-let _stylePreviewRegionMarker: HTMLElement;
-const _stylePreviewWindow = document.getElementById("spWindow")!;
-
-let _isVisible = true;
-let _isSmartphone = false;
-let _updateRegionMarker: () => void;
-
-interface StyleRuleMap {
- [key: string]: string;
-}
-
-interface StyleEditorOptions {
- isTainted: boolean;
- styleId: number;
- styleRuleMap: StyleRuleMap;
-}
-
-/**
- * Handles the switch between static and fluid layout.
- */
-function handleLayoutWidth(): void {
- const useFluidLayout = document.getElementById("useFluidLayout") as HTMLInputElement;
- const fluidLayoutMinWidth = document.getElementById("fluidLayoutMinWidth") as HTMLInputElement;
- const fluidLayoutMaxWidth = document.getElementById("fluidLayoutMaxWidth") as HTMLInputElement;
- const fixedLayoutVariables = document.getElementById("fixedLayoutVariables") as HTMLDListElement;
-
- function change(): void {
- if (useFluidLayout.checked) {
- DomUtil.show(fluidLayoutMinWidth);
- DomUtil.show(fluidLayoutMaxWidth);
- DomUtil.hide(fixedLayoutVariables);
- } else {
- DomUtil.hide(fluidLayoutMinWidth);
- DomUtil.hide(fluidLayoutMaxWidth);
- DomUtil.show(fixedLayoutVariables);
- }
- }
-
- useFluidLayout.addEventListener("change", change);
-
- change();
-}
-
-/**
- * Handles SCSS input fields.
- */
-function handleScss(isTainted: boolean): void {
- const individualScss = document.getElementById("individualScss")!;
- const overrideScss = document.getElementById("overrideScss")!;
-
- if (isTainted) {
- EventHandler.add("com.woltlab.wcf.simpleTabMenu_styleTabMenuContainer", "select", () => {
- (individualScss as any).codemirror.refresh();
- (overrideScss as any).codemirror.refresh();
- });
- } else {
- EventHandler.add("com.woltlab.wcf.simpleTabMenu_advanced", "select", (data: { activeName: string }) => {
- if (data.activeName === "advanced-custom") {
- (document.getElementById("individualScssCustom") as any).codemirror.refresh();
- (document.getElementById("overrideScssCustom") as any).codemirror.refresh();
- } else if (data.activeName === "advanced-original") {
- (individualScss as any).codemirror.refresh();
- (overrideScss as any).codemirror.refresh();
- }
- });
- }
-}
-
-function handleProtection(styleId: number): void {
- const button = document.getElementById("styleDisableProtectionSubmit") as HTMLButtonElement;
- const checkbox = document.getElementById("styleDisableProtectionConfirm") as HTMLInputElement;
-
- checkbox.addEventListener("change", () => {
- button.disabled = !checkbox.checked;
- });
-
- button.addEventListener("click", () => {
- Ajax.apiOnce({
- data: {
- actionName: "markAsTainted",
- className: "wcf\\data\\style\\StyleAction",
- objectIDs: [styleId],
- },
- success: () => {
- window.location.reload();
- },
- });
- });
-}
-
-function initVisualEditor(styleRuleMap: StyleRuleMap): void {
- _stylePreviewWindow.querySelectorAll("[data-region]").forEach((region: HTMLElement) => {
- _stylePreviewRegions.set(region.dataset.region!, region);
- });
-
- _stylePreviewRegionMarker = document.createElement("div");
- _stylePreviewRegionMarker.id = "stylePreviewRegionMarker";
- _stylePreviewRegionMarker.innerHTML = '<div id="stylePreviewRegionMarkerBottom"></div>';
- DomUtil.hide(_stylePreviewRegionMarker);
- document.getElementById("colors")!.appendChild(_stylePreviewRegionMarker);
-
- const container = document.getElementById("spSidebar")!;
- const select = document.getElementById("spCategories") as HTMLSelectElement;
- let lastValue = select.value;
-
- _updateRegionMarker = (): void => {
- if (_isSmartphone) {
- return;
- }
-
- if (lastValue === "none") {
- DomUtil.hide(_stylePreviewRegionMarker);
- return;
- }
-
- const region = _stylePreviewRegions.get(lastValue)!;
- const rect = region.getBoundingClientRect();
-
- let top = rect.top + (window.scrollY || window.pageYOffset);
-
- DomUtil.setStyles(_stylePreviewRegionMarker, {
- height: `${region.clientHeight + 20}px`,
- left: `${rect.left + document.body.scrollLeft - 10}px`,
- top: `${top - 10}px`,
- width: `${region.clientWidth + 20}px`,
- });
-
- DomUtil.show(_stylePreviewRegionMarker);
-
- top = DomUtil.offset(region).top;
- // `+ 80` = account for sticky header + selection markers (20px)
- const firstVisiblePixel = (window.pageYOffset || window.scrollY) + 80;
- if (firstVisiblePixel > top) {
- window.scrollTo(0, Math.max(top - 80, 0));
- } else {
- const lastVisiblePixel = window.innerHeight + (window.pageYOffset || window.scrollY);
- if (lastVisiblePixel < top) {
- window.scrollTo(0, top);
- } else {
- const bottom = top + region.offsetHeight + 20;
- if (lastVisiblePixel < bottom) {
- window.scrollBy(0, bottom - top);
- }
- }
- }
- };
-
- const apiVersions = container.querySelector('.spSidebarBox[data-category="apiVersion"]') as HTMLElement;
- const callbackChange = () => {
- let element = container.querySelector(`.spSidebarBox[data-category="${lastValue}"]`) as HTMLElement;
- DomUtil.hide(element);
-
- lastValue = select.value;
- element = container.querySelector(`.spSidebarBox[data-category="${lastValue}"]`) as HTMLElement;
- DomUtil.show(element);
-
- const showCompatibilityNotice = element.querySelector(".spApiVersion") !== null;
- if (showCompatibilityNotice) {
- DomUtil.show(apiVersions);
- } else {
- DomUtil.hide(apiVersions);
- }
-
- // set region marker
- _updateRegionMarker();
- };
- select.addEventListener("change", callbackChange);
-
- // apply CSS rules
- const style = document.createElement("style");
- style.appendChild(document.createTextNode(""));
- style.dataset.createdBy = "WoltLab/Acp/Ui/Style/Editor";
- document.head.appendChild(style);
-
- function updateCSSRule(identifier: string, value: string): void {
- if (styleRuleMap[identifier] === undefined) {
- return;
- }
-
- const rule = styleRuleMap[identifier].replace(/VALUE/g, value + " !important");
- if (!rule) {
- return;
- }
-
- let rules: string[];
- if (rule.indexOf("__COMBO_RULE__")) {
- rules = rule.split("__COMBO_RULE__");
- } else {
- rules = [rule];
- }
-
- rules.forEach((rule) => {
- try {
- style.sheet!.insertRule(rule, style.sheet!.cssRules.length);
- } catch (e) {
- // ignore errors for unknown placeholder selectors
- if (!/[a-z]+-placeholder/.test(rule)) {
- console.debug(e.message);
- }
- }
- });
- }
-
- const wrapper = document.getElementById("spVariablesWrapper")!;
- wrapper.querySelectorAll(".styleVariableColor").forEach((colorField: HTMLElement) => {
- const variableName = colorField.dataset.store!.replace(/_value$/, "");
-
- const observer = new MutationObserver((mutations) => {
- mutations.forEach((mutation) => {
- if (mutation.attributeName === "style") {
- updateCSSRule(variableName, colorField.style.getPropertyValue("background-color"));
- }
- });
- });
-
- observer.observe(colorField, {
- attributes: true,
- });
-
- updateCSSRule(variableName, colorField.style.getPropertyValue("background-color"));
- });
-
- // category selection by clicking on the area
- const buttonToggleColorPalette = document.querySelector(".jsButtonToggleColorPalette") as HTMLAnchorElement;
- const buttonSelectCategoryByClick = document.querySelector(".jsButtonSelectCategoryByClick") as HTMLAnchorElement;
-
- function toggleSelectionMode(): void {
- buttonSelectCategoryByClick.classList.toggle("active");
- buttonToggleColorPalette.classList.toggle("disabled");
- _stylePreviewWindow.classList.toggle("spShowRegions");
- _stylePreviewRegionMarker.classList.toggle("forceHide");
- select.disabled = !select.disabled;
- }
-
- buttonSelectCategoryByClick.addEventListener("click", (event) => {
- event.preventDefault();
-
- toggleSelectionMode();
- });
-
- _stylePreviewWindow.querySelectorAll("[data-region]").forEach((region: HTMLElement) => {
- region.addEventListener("click", (event) => {
- if (!_stylePreviewWindow.classList.contains("spShowRegions")) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- toggleSelectionMode();
-
- select.value = region.dataset.region!;
-
- // Programmatically trigger the change event handler, rather than dispatching an event,
- // because Firefox fails to execute the event if it has previously been disabled.
- // See https://bugzilla.mozilla.org/show_bug.cgi?id=1426856
- callbackChange();
- });
- });
-
- // toggle view
- const spSelectCategory = document.getElementById("spSelectCategory") as HTMLSelectElement;
- buttonToggleColorPalette.addEventListener("click", (event) => {
- event.preventDefault();
-
- buttonSelectCategoryByClick.classList.toggle("disabled");
- DomUtil.toggle(spSelectCategory);
- buttonToggleColorPalette.classList.toggle("active");
- _stylePreviewWindow.classList.toggle("spColorPalette");
- _stylePreviewRegionMarker.classList.toggle("forceHide");
- select.disabled = !select.disabled;
- });
-}
-
-/**
- * Sets up dynamic style options.
- */
-export function setup(options: StyleEditorOptions): void {
- handleLayoutWidth();
- handleScss(options.isTainted);
-
- if (!options.isTainted) {
- handleProtection(options.styleId);
- }
-
- initVisualEditor(options.styleRuleMap);
-
- UiScreen.on("screen-sm-down", {
- match() {
- hideVisualEditor();
- },
- unmatch() {
- showVisualEditor();
- },
- setup() {
- hideVisualEditor();
- },
- });
-
- function callbackRegionMarker(): void {
- if (_isVisible) {
- _updateRegionMarker();
- }
- }
-
- window.addEventListener("resize", callbackRegionMarker);
- EventHandler.add("com.woltlab.wcf.AcpMenu", "resize", callbackRegionMarker);
- EventHandler.add("com.woltlab.wcf.simpleTabMenu_styleTabMenuContainer", "select", function (data) {
- _isVisible = data.activeName === "colors";
- callbackRegionMarker();
- });
-}
-
-export function hideVisualEditor(): void {
- DomUtil.hide(_stylePreviewWindow);
- document.getElementById("spVariablesWrapper")!.style.removeProperty("transform");
- DomUtil.hide(document.getElementById("stylePreviewRegionMarker")!);
-
- _isSmartphone = true;
-}
-
-export function showVisualEditor(): void {
- DomUtil.show(_stylePreviewWindow);
-
- window.setTimeout(() => {
- Core.triggerEvent(document.getElementById("spCategories")!, "change");
- }, 100);
-
- _isSmartphone = false;
-}
+++ /dev/null
-/**
- * Provides a dialog to copy an existing template group.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Template/Group/Copy
- */
-
-import * as Ajax from "../../../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../../Ajax/Data";
-import { DialogCallbackObject, DialogCallbackSetup } from "../../../../Ui/Dialog/Data";
-import * as Language from "../../../../Language";
-import UiDialog from "../../../../Ui/Dialog";
-import * as UiNotification from "../../../../Ui/Notification";
-import DomUtil from "../../../../Dom/Util";
-
-interface AjaxResponse {
- returnValues: {
- redirectURL: string;
- };
-}
-
-interface AjaxResponseError {
- returnValues?: {
- fieldName?: string;
- errorType?: string;
- };
-}
-
-class AcpUiTemplateGroupCopy implements AjaxCallbackObject, DialogCallbackObject {
- private folderName?: HTMLInputElement = undefined;
- private name?: HTMLInputElement = undefined;
- private readonly templateGroupId: number;
-
- constructor(templateGroupId: number) {
- this.templateGroupId = templateGroupId;
-
- const button = document.querySelector(".jsButtonCopy") as HTMLAnchorElement;
- button.addEventListener("click", (ev) => this.click(ev));
- }
-
- private click(event: MouseEvent): void {
- event.preventDefault();
-
- UiDialog.open(this);
- }
-
- _dialogSubmit(): void {
- Ajax.api(this, {
- parameters: {
- templateGroupName: this.name!.value,
- templateGroupFolderName: this.folderName!.value,
- },
- });
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- UiDialog.close(this);
-
- UiNotification.show(undefined, () => {
- window.location.href = data.returnValues.redirectURL;
- });
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "templateGroupCopy",
- options: {
- onSetup: () => {
- ["Name", "FolderName"].forEach((type) => {
- const input = document.getElementById("copyTemplateGroup" + type) as HTMLInputElement;
- input.value = (document.getElementById("templateGroup" + type) as HTMLInputElement).value;
-
- if (type === "Name") {
- this.name = input;
- } else {
- this.folderName = input;
- }
- });
- },
- title: Language.get("wcf.acp.template.group.copy"),
- },
- source: `<dl>
- <dt>
- <label for="copyTemplateGroupName">${Language.get("wcf.global.name")}</label>
- </dt>
- <dd>
- <input type="text" id="copyTemplateGroupName" class="long" data-dialog-submit-on-enter="true" required>
- </dd>
-</dl>
-<dl>
- <dt>
- <label for="copyTemplateGroupFolderName">${Language.get("wcf.acp.template.group.folderName")}</label>
- </dt>
- <dd>
- <input type="text" id="copyTemplateGroupFolderName" class="long" data-dialog-submit-on-enter="true" required>
- </dd>
-</dl>
-<div class="formSubmit">
- <button class="buttonPrimary" data-type="submit">${Language.get("wcf.global.button.submit")}</button>
-</div>`,
- };
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "copy",
- className: "wcf\\data\\template\\group\\TemplateGroupAction",
- objectIDs: [this.templateGroupId],
- },
- failure: (data: AjaxResponseError) => {
- if (data && data.returnValues && data.returnValues.fieldName && data.returnValues.errorType) {
- if (data.returnValues.fieldName === "templateGroupName") {
- DomUtil.innerError(
- this.name!,
- Language.get(`wcf.acp.template.group.name.error.${data.returnValues.errorType}`),
- );
- } else {
- DomUtil.innerError(
- this.folderName!,
- Language.get(`wcf.acp.template.group.folderName.error.${data.returnValues.errorType}`),
- );
- }
-
- return false;
- }
-
- return true;
- },
- };
- }
-}
-
-let acpUiTemplateGroupCopy: AcpUiTemplateGroupCopy;
-
-export function init(templateGroupId: number): void {
- if (!acpUiTemplateGroupCopy) {
- acpUiTemplateGroupCopy = new AcpUiTemplateGroupCopy(templateGroupId);
- }
-}
+++ /dev/null
-/**
- * Provides the trophy icon designer.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Trophy/Badge
- */
-
-import * as Language from "../../../Language";
-import UiDialog from "../../../Ui/Dialog";
-import * as UiStyleFontAwesome from "../../../Ui/Style/FontAwesome";
-import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
-
-interface Rgba {
- r: number;
- g: number;
- b: number;
- a: number;
-}
-
-type Color = string | Rgba;
-
-/**
- * @exports WoltLabSuite/Core/Acp/Ui/Trophy/Badge
- */
-class AcpUiTrophyBadge implements DialogCallbackObject {
- private badgeColor?: HTMLSpanElement = undefined;
- private readonly badgeColorInput: HTMLInputElement;
- private dialogContent?: HTMLElement = undefined;
- private icon?: HTMLSpanElement = undefined;
- private iconColor?: HTMLSpanElement = undefined;
- private readonly iconColorInput: HTMLInputElement;
- private readonly iconNameInput: HTMLInputElement;
-
- /**
- * Initializes the badge designer.
- */
- constructor() {
- const iconContainer = document.getElementById("badgeContainer")!;
- const button = iconContainer.querySelector(".button") as HTMLElement;
- button.addEventListener("click", (ev) => this.click(ev));
-
- this.iconNameInput = iconContainer.querySelector('input[name="iconName"]') as HTMLInputElement;
- this.iconColorInput = iconContainer.querySelector('input[name="iconColor"]') as HTMLInputElement;
- this.badgeColorInput = iconContainer.querySelector('input[name="badgeColor"]') as HTMLInputElement;
- }
-
- /**
- * Opens the icon designer.
- */
- private click(event: MouseEvent): void {
- event.preventDefault();
-
- UiDialog.open(this);
- }
-
- /**
- * Sets the icon name.
- */
- private setIcon(iconName: string): void {
- this.icon!.textContent = iconName;
-
- this.renderIcon();
- }
-
- /**
- * Sets the icon color, can be either a string or an object holding the
- * individual r, g, b and a values.
- */
- private setIconColor(color: Color): void {
- if (typeof color !== "string") {
- color = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
- }
-
- this.iconColor!.dataset.color = color;
- this.iconColor!.style.setProperty("background-color", color, "");
-
- this.renderIcon();
- }
-
- /**
- * Sets the badge color, can be either a string or an object holding the
- * individual r, g, b and a values.
- */
- private setBadgeColor(color: Color): void {
- if (typeof color !== "string") {
- color = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
- }
-
- this.badgeColor!.dataset.color = color;
- this.badgeColor!.style.setProperty("background-color", color, "");
-
- this.renderIcon();
- }
-
- /**
- * Renders the custom icon preview.
- */
- private renderIcon(): void {
- const iconColor = this.iconColor!.style.getPropertyValue("background-color");
- const badgeColor = this.badgeColor!.style.getPropertyValue("background-color");
-
- const icon = this.dialogContent!.querySelector(".jsTrophyIcon") as HTMLElement;
-
- // set icon
- icon.className = icon.className.replace(/\b(fa-[a-z0-9-]+)\b/, "");
- icon.classList.add(`fa-${this.icon!.textContent!}`);
-
- icon.style.setProperty("color", iconColor, "");
- icon.style.setProperty("background-color", badgeColor, "");
- }
-
- /**
- * Saves the custom icon design.
- */
- private save(event: MouseEvent): void {
- event.preventDefault();
-
- const iconColor = this.iconColor!.style.getPropertyValue("background-color");
- const badgeColor = this.badgeColor!.style.getPropertyValue("background-color");
- const icon = this.icon!.textContent!;
-
- this.iconNameInput.value = icon;
- this.badgeColorInput.value = badgeColor;
- this.iconColorInput.value = iconColor;
-
- const iconContainer = document.getElementById("iconContainer")!;
- const previewIcon = iconContainer.querySelector(".jsTrophyIcon") as HTMLElement;
-
- // set icon
- previewIcon.className = previewIcon.className.replace(/\b(fa-[a-z0-9-]+)\b/, "");
- previewIcon.classList.add("fa-" + icon);
- previewIcon.style.setProperty("color", iconColor, "");
- previewIcon.style.setProperty("background-color", badgeColor, "");
-
- UiDialog.close(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "trophyIconEditor",
- options: {
- onSetup: (context) => {
- this.dialogContent = context;
-
- this.iconColor = context.querySelector("#jsIconColorContainer .colorBoxValue") as HTMLSpanElement;
- this.badgeColor = context.querySelector("#jsBadgeColorContainer .colorBoxValue") as HTMLSpanElement;
- this.icon = context.querySelector(".jsTrophyIconName") as HTMLSpanElement;
-
- const buttonIconPicker = context.querySelector(".jsTrophyIconName + .button") as HTMLAnchorElement;
- buttonIconPicker.addEventListener("click", (event) => {
- event.preventDefault();
-
- UiStyleFontAwesome.open((iconName) => this.setIcon(iconName));
- });
-
- const iconColorContainer = document.getElementById("jsIconColorContainer")!;
- const iconColorPicker = iconColorContainer.querySelector(".jsButtonIconColorPicker") as HTMLAnchorElement;
- iconColorPicker.addEventListener("click", (event) => {
- event.preventDefault();
-
- const picker = iconColorContainer.querySelector(".jsColorPicker") as HTMLAnchorElement;
- picker.click();
- });
-
- const badgeColorContainer = document.getElementById("jsBadgeColorContainer")!;
- const badgeColorPicker = badgeColorContainer.querySelector(".jsButtonBadgeColorPicker") as HTMLAnchorElement;
- badgeColorPicker.addEventListener("click", (event) => {
- event.preventDefault();
-
- const picker = badgeColorContainer.querySelector(".jsColorPicker") as HTMLAnchorElement;
- picker.click();
- });
-
- const colorPicker = new window.WCF.ColorPicker(".jsColorPicker");
- colorPicker.setCallbackSubmit(() => this.renderIcon());
-
- const submitButton = context.querySelector(".formSubmit > .buttonPrimary") as HTMLElement;
- submitButton.addEventListener("click", (ev) => this.save(ev));
- },
- onShow: () => {
- this.setIcon(this.iconNameInput.value);
- this.setIconColor(this.iconColorInput.value);
- this.setBadgeColor(this.badgeColorInput.value);
- },
- title: Language.get("wcf.acp.trophy.badge.edit"),
- },
- };
- }
-}
-
-let acpUiTrophyBadge: AcpUiTrophyBadge;
-
-/**
- * Initializes the badge designer.
- */
-export function init(): void {
- if (!acpUiTrophyBadge) {
- acpUiTrophyBadge = new AcpUiTrophyBadge();
- }
-}
+++ /dev/null
-/**
- * Handles the trophy image upload.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Trophy/Upload
- */
-
-import * as Core from "../../../Core";
-import DomUtil from "../../../Dom/Util";
-import * as Language from "../../../Language";
-import * as UiNotification from "../../../Ui/Notification";
-import Upload from "../../../Upload";
-import { UploadOptions } from "../../../Upload/Data";
-
-interface AjaxResponse {
- returnValues: {
- url: string;
- };
-}
-
-interface AjaxResponseError {
- returnValues: {
- errorType: string;
- };
-}
-
-class TrophyUpload extends Upload {
- private readonly trophyId: number;
- private readonly tmpHash: string;
-
- constructor(trophyId: number, tmpHash: string, options: Partial<UploadOptions>) {
- super(
- "uploadIconFileButton",
- "uploadIconFileContent",
- Core.extend(
- {
- className: "wcf\\data\\trophy\\TrophyAction",
- },
- options,
- ),
- );
-
- this.trophyId = ~~trophyId;
- this.tmpHash = tmpHash;
- }
-
- protected _getParameters(): ArbitraryObject {
- return {
- trophyID: this.trophyId,
- tmpHash: this.tmpHash,
- };
- }
-
- protected _success(uploadId: number, data: AjaxResponse): void {
- DomUtil.innerError(this._button, false);
-
- this._target.innerHTML = `<img src="${data.returnValues.url}?timestamp=${Date.now()}" alt="">`;
-
- UiNotification.show();
- }
-
- protected _failure(uploadId: number, data: AjaxResponseError): boolean {
- DomUtil.innerError(this._button, Language.get(`wcf.acp.trophy.imageUpload.error.${data.returnValues.errorType}`));
-
- // remove previous images
- this._target.innerHTML = "";
-
- return false;
- }
-}
-
-Core.enableLegacyInheritance(TrophyUpload);
-
-export = TrophyUpload;
+++ /dev/null
-/**
- * Handles the user content remove clipboard action.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard
- * @since 5.4
- */
-
-import AcpUiWorker from "../../../Worker";
-import * as Ajax from "../../../../../Ajax";
-import * as Language from "../../../../../Language";
-import UiDialog from "../../../../../Ui/Dialog";
-import { AjaxCallbackSetup } from "../../../../../Ajax/Data";
-import { DialogCallbackSetup } from "../../../../../Ui/Dialog/Data";
-import * as EventHandler from "../../../../../Event/Handler";
-
-interface AjaxResponse {
- returnValues: {
- template: string;
- };
-}
-
-interface EventData {
- data: {
- actionName: string;
- internalData: any[];
- label: string;
- parameters: {
- objectIDs: number[];
- url: string;
- };
- };
- listItem: HTMLElement;
-}
-
-export class AcpUserContentRemoveClipboard {
- public userIds: number[];
- private readonly dialogId = "userContentRemoveClipboardPrepareDialog";
-
- /**
- * Initializes the content remove handler.
- */
- constructor() {
- EventHandler.add("com.woltlab.wcf.clipboard", "com.woltlab.wcf.user", (data: EventData) => {
- if (data.data.actionName === "com.woltlab.wcf.user.deleteUserContent") {
- this.userIds = data.data.parameters.objectIDs;
-
- Ajax.api(this);
- }
- });
- }
-
- /**
- * Executes the remove content worker.
- */
- private executeWorker(objectTypes: string[]): void {
- new AcpUiWorker({
- // dialog
- dialogId: "removeContentWorker",
- dialogTitle: Language.get("wcf.acp.content.removeContent"),
-
- // ajax
- className: "wcf\\system\\worker\\UserContentRemoveWorker",
- parameters: {
- userIDs: this.userIds,
- contentProvider: objectTypes,
- },
- });
- }
-
- /**
- * Handles a click on the submit button in the overlay.
- */
- private submit(): void {
- const objectTypes = Array.from<HTMLInputElement>(
- this.dialogContent.querySelectorAll("input.contentProviderObjectType"),
- )
- .filter((element) => element.checked)
- .map((element) => element.name);
-
- UiDialog.close(this.dialogId);
-
- if (objectTypes.length > 0) {
- window.setTimeout(() => {
- this.executeWorker(objectTypes);
- }, 200);
- }
- }
-
- get dialogContent(): HTMLElement {
- return UiDialog.getDialog(this.dialogId)!.content;
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- UiDialog.open(this, data.returnValues.template);
-
- const submitButton = this.dialogContent.querySelector('input[type="submit"]') as HTMLElement;
- submitButton.addEventListener("click", () => this.submit());
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "prepareRemoveContent",
- className: "wcf\\data\\user\\UserAction",
- parameters: {
- userIDs: this.userIds,
- },
- },
- };
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: this.dialogId,
- options: {
- title: Language.get("wcf.acp.content.removeContent"),
- },
- source: null,
- };
- }
-}
-
-export default AcpUserContentRemoveClipboard;
+++ /dev/null
-/**
- * Provides the trophy icon designer.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler
- * @since 5.2
- */
-
-import AcpUiWorker from "../../../Worker";
-import * as Ajax from "../../../../../Ajax";
-import * as Language from "../../../../../Language";
-import UiDialog from "../../../../../Ui/Dialog";
-import { AjaxCallbackSetup } from "../../../../../Ajax/Data";
-import { DialogCallbackSetup } from "../../../../../Ui/Dialog/Data";
-
-interface AjaxResponse {
- returnValues: {
- template: string;
- };
-}
-
-class AcpUserContentRemoveHandler {
- private readonly dialogId: string;
- private readonly userId: number;
-
- /**
- * Initializes the content remove handler.
- */
- constructor(element: HTMLElement, userId: number) {
- this.userId = userId;
- this.dialogId = `userRemoveContentHandler-${this.userId}`;
-
- element.addEventListener("click", (ev) => this.click(ev));
- }
-
- /**
- * Click on the remove content button.
- */
- private click(event: MouseEvent): void {
- event.preventDefault();
-
- Ajax.api(this);
- }
-
- /**
- * Executes the remove content worker.
- */
- private executeWorker(objectTypes: string[]): void {
- new AcpUiWorker({
- // dialog
- dialogId: "removeContentWorker",
- dialogTitle: Language.get("wcf.acp.content.removeContent"),
-
- // ajax
- className: "\\wcf\\system\\worker\\UserContentRemoveWorker",
- parameters: {
- userID: this.userId,
- contentProvider: objectTypes,
- },
- });
- }
-
- /**
- * Handles a click on the submit button in the overlay.
- */
- private submit(): void {
- const objectTypes = Array.from<HTMLInputElement>(
- this.dialogContent.querySelectorAll("input.contentProviderObjectType"),
- )
- .filter((element) => element.checked)
- .map((element) => element.name);
-
- UiDialog.close(this.dialogId);
-
- if (objectTypes.length > 0) {
- window.setTimeout(() => {
- this.executeWorker(objectTypes);
- }, 200);
- }
- }
-
- get dialogContent(): HTMLElement {
- return UiDialog.getDialog(this.dialogId)!.content;
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- UiDialog.open(this, data.returnValues.template);
-
- const submitButton = this.dialogContent.querySelector('input[type="submit"]') as HTMLElement;
- submitButton.addEventListener("click", () => this.submit());
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "prepareRemoveContent",
- className: "wcf\\data\\user\\UserAction",
- parameters: {
- userID: this.userId,
- },
- },
- };
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: this.dialogId,
- options: {
- title: Language.get("wcf.acp.content.removeContent"),
- },
- source: null,
- };
- }
-}
-
-export = AcpUserContentRemoveHandler;
+++ /dev/null
-/**
- * User editing capabilities for the user list.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/User/Editor
- * @since 3.1
- */
-
-import AcpUserContentRemoveHandler from "./Content/Remove/Handler";
-import * as Ajax from "../../../Ajax";
-import * as Core from "../../../Core";
-import * as EventHandler from "../../../Event/Handler";
-import * as Language from "../../../Language";
-import * as UiNotification from "../../../Ui/Notification";
-import UiDropdownSimple from "../../../Ui/Dropdown/Simple";
-import { AjaxCallbackObject, DatabaseObjectActionResponse } from "../../../Ajax/Data";
-import DomUtil from "../../../Dom/Util";
-
-interface RefreshUsersData {
- userIds: number[];
-}
-
-class AcpUiUserEditor {
- /**
- * Initializes the edit dropdown for each user.
- */
- constructor() {
- document.querySelectorAll(".jsUserRow").forEach((userRow: HTMLTableRowElement) => this.initUser(userRow));
-
- EventHandler.add("com.woltlab.wcf.acp.user", "refresh", (data: RefreshUsersData) => this.refreshUsers(data));
- }
-
- /**
- * Initializes the edit dropdown for a user.
- */
- private initUser(userRow: HTMLTableRowElement): void {
- const userId = ~~userRow.dataset.objectId!;
- const dropdownId = `userListDropdown${userId}`;
- const dropdownMenu = UiDropdownSimple.getDropdownMenu(dropdownId)!;
- const legacyButtonContainer = userRow.querySelector(".jsLegacyButtons") as HTMLElement;
-
- if (dropdownMenu.childElementCount === 0 && legacyButtonContainer.childElementCount === 0) {
- const toggleButton = userRow.querySelector(".dropdownToggle") as HTMLAnchorElement;
- toggleButton.classList.add("disabled");
-
- return;
- }
-
- UiDropdownSimple.registerCallback(dropdownId, (identifier, action) => {
- if (action === "open") {
- this.rebuild(dropdownMenu, legacyButtonContainer);
- }
- });
-
- const editLink = dropdownMenu.querySelector(".jsEditLink") as HTMLAnchorElement;
- if (editLink !== null) {
- const toggleButton = userRow.querySelector(".dropdownToggle") as HTMLAnchorElement;
- toggleButton.addEventListener("dblclick", (event) => {
- event.preventDefault();
-
- editLink.click();
- });
- }
-
- const sendNewPassword = dropdownMenu.querySelector(".jsSendNewPassword") as HTMLAnchorElement;
- if (sendNewPassword !== null) {
- sendNewPassword.addEventListener("click", (event) => {
- event.preventDefault();
-
- // emulate clipboard selection
- EventHandler.fire("com.woltlab.wcf.clipboard", "com.woltlab.wcf.user", {
- data: {
- actionName: "com.woltlab.wcf.user.sendNewPassword",
- parameters: {
- confirmMessage: Language.get("wcf.acp.user.action.sendNewPassword.confirmMessage"),
- objectIDs: [userId],
- },
- },
- responseData: {
- actionName: "com.woltlab.wcf.user.sendNewPassword",
- objectIDs: [userId],
- },
- });
- });
- }
-
- const deleteContent = dropdownMenu.querySelector(".jsDeleteContent") as HTMLAnchorElement;
- if (deleteContent !== null) {
- new AcpUserContentRemoveHandler(deleteContent, userId);
- }
-
- const toggleConfirmEmail = dropdownMenu.querySelector(".jsConfirmEmailToggle") as HTMLAnchorElement;
- if (toggleConfirmEmail !== null) {
- toggleConfirmEmail.addEventListener("click", (event) => {
- event.preventDefault();
-
- Ajax.api(
- {
- _ajaxSetup: () => {
- const isEmailConfirmed = Core.stringToBool(userRow.dataset.emailConfirmed!);
-
- return {
- data: {
- actionName: (isEmailConfirmed ? "un" : "") + "confirmEmail",
- className: "wcf\\data\\user\\UserAction",
- objectIDs: [userId],
- },
- };
- },
- } as AjaxCallbackObject,
- undefined,
- (data: DatabaseObjectActionResponse) => {
- document.querySelectorAll(".jsUserRow").forEach((userRow: HTMLTableRowElement) => {
- const userId = ~~userRow.dataset.objectId!;
- if (data.objectIDs.includes(userId)) {
- const confirmEmailButton = dropdownMenu.querySelector(".jsConfirmEmailToggle") as HTMLAnchorElement;
-
- switch (data.actionName) {
- case "confirmEmail":
- userRow.dataset.emailConfirmed = "true";
- confirmEmailButton.textContent = confirmEmailButton.dataset.unconfirmEmailMessage!;
- break;
-
- case "unconfirmEmail":
- userRow.dataset.emailEonfirmed = "false";
- confirmEmailButton.textContent = confirmEmailButton.dataset.confirmEmailMessage!;
- break;
-
- default:
- throw new Error("Unreachable");
- }
- }
- });
-
- UiNotification.show();
- },
- );
- });
- }
- }
-
- /**
- * Rebuilds the dropdown by adding wrapper links for legacy buttons,
- * that will eventually receive the click event.
- */
- private rebuild(dropdownMenu: HTMLElement, legacyButtonContainer: HTMLElement): void {
- dropdownMenu.querySelectorAll(".jsLegacyItem").forEach((element) => element.remove());
-
- // inject buttons
- const items: HTMLLIElement[] = [];
- let deleteButton: HTMLAnchorElement | null = null;
- Array.from(legacyButtonContainer.children).forEach((button: HTMLAnchorElement) => {
- if (button.classList.contains("jsDeleteButton")) {
- deleteButton = button;
-
- return;
- }
-
- const item = document.createElement("li");
- item.className = "jsLegacyItem";
- item.innerHTML = '<a href="#"></a>';
-
- const link = item.children[0] as HTMLAnchorElement;
- link.textContent = button.dataset.tooltip || button.title;
- link.addEventListener("click", (event) => {
- event.preventDefault();
-
- // forward click onto original button
- if (button.nodeName === "A") {
- button.click();
- } else {
- Core.triggerEvent(button, "click");
- }
- });
-
- items.push(item);
- });
-
- items.forEach((item) => {
- dropdownMenu.insertAdjacentElement("afterbegin", item);
- });
-
- if (deleteButton !== null) {
- const dispatchDeleteButton = dropdownMenu.querySelector(".jsDispatchDelete") as HTMLAnchorElement;
- dispatchDeleteButton.addEventListener("click", (event) => {
- event.preventDefault();
-
- deleteButton!.click();
- });
- }
-
- // check if there are visible items before each divider
- const listItems = Array.from(dropdownMenu.children) as HTMLElement[];
- listItems.forEach((element) => DomUtil.show(element));
-
- let hasItem = false;
- listItems.forEach((item) => {
- if (item.classList.contains("dropdownDivider")) {
- if (!hasItem) {
- DomUtil.hide(item);
- }
- } else {
- hasItem = true;
- }
- });
- }
-
- private refreshUsers(data: RefreshUsersData): void {
- document.querySelectorAll(".jsUserRow").forEach((userRow: HTMLTableRowElement) => {
- const userId = ~~userRow.dataset.objectId!;
- if (data.userIds.includes(userId)) {
- const userStatusIcons = userRow.querySelector(".userStatusIcons") as HTMLElement;
-
- const banned = Core.stringToBool(userRow.dataset.banned!);
- let iconBanned = userRow.querySelector(".jsUserStatusBanned") as HTMLElement;
- if (banned && iconBanned === null) {
- iconBanned = document.createElement("span");
- iconBanned.className = "icon icon16 fa-lock jsUserStatusBanned jsTooltip";
- iconBanned.title = Language.get("wcf.user.status.banned");
-
- userStatusIcons.appendChild(iconBanned);
- } else if (!banned && iconBanned !== null) {
- iconBanned.remove();
- }
-
- const isDisabled = !Core.stringToBool(userRow.dataset.enabled!);
- let iconIsDisabled = userRow.querySelector(".jsUserStatusIsDisabled") as HTMLElement;
- if (isDisabled && iconIsDisabled === null) {
- iconIsDisabled = document.createElement("span");
- iconIsDisabled.className = "icon icon16 fa-power-off jsUserStatusIsDisabled jsTooltip";
- iconIsDisabled.title = Language.get("wcf.user.status.isDisabled");
- userStatusIcons.appendChild(iconIsDisabled);
- } else if (!isDisabled && iconIsDisabled !== null) {
- iconIsDisabled.remove();
- }
- }
- });
- }
-}
-
-export = AcpUiUserEditor;
+++ /dev/null
-/**
- * Worker manager with support for custom callbacks and loop counts.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Acp/Ui/Worker
- */
-
-import * as Ajax from "../../Ajax";
-import * as Core from "../../Core";
-import * as Language from "../../Language";
-import UiDialog from "../../Ui/Dialog";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../../Ajax/Data";
-import { DialogCallbackObject, DialogCallbackSetup } from "../../Ui/Dialog/Data";
-import AjaxRequest from "../../Ajax/Request";
-
-interface AjaxResponse {
- loopCount: number;
- parameters: ArbitraryObject;
- proceedURL: string;
- progress: number;
- template?: string;
-}
-
-type CallbackAbort = () => void;
-type CallbackSuccess = (data: AjaxResponse) => void;
-
-interface WorkerOptions {
- // dialog
- dialogId: string;
- dialogTitle: string;
-
- // ajax
- className: string;
- loopCount: number;
- parameters: ArbitraryObject;
-
- // callbacks
- callbackAbort: CallbackAbort | null;
- callbackSuccess: CallbackSuccess | null;
-}
-
-class AcpUiWorker implements AjaxCallbackObject, DialogCallbackObject {
- private aborted = false;
- private readonly options: WorkerOptions;
- private readonly request: AjaxRequest;
-
- /**
- * Creates a new worker instance.
- */
- constructor(options: Partial<WorkerOptions>) {
- this.options = Core.extend(
- {
- // dialog
- dialogId: "",
- dialogTitle: "",
-
- // ajax
- className: "",
- loopCount: -1,
- parameters: {},
-
- // callbacks
- callbackAbort: null,
- callbackSuccess: null,
- },
- options,
- ) as WorkerOptions;
- this.options.dialogId += "Worker";
-
- // update title
- if (UiDialog.getDialog(this.options.dialogId) !== undefined) {
- UiDialog.setTitle(this.options.dialogId, this.options.dialogTitle);
- }
-
- this.request = Ajax.api(this);
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- if (this.aborted) {
- return;
- }
-
- if (typeof data.template === "string") {
- UiDialog.open(this, data.template);
- }
-
- const content = UiDialog.getDialog(this)!.content;
-
- // update progress
- const progress = content.querySelector("progress")!;
- progress.value = data.progress;
- progress.nextElementSibling!.textContent = `${data.progress}%`;
-
- // worker is still busy
- if (data.progress < 100) {
- Ajax.api(this, {
- loopCount: data.loopCount,
- parameters: data.parameters,
- });
- } else {
- const spinner = content.querySelector(".fa-spinner") as HTMLSpanElement;
- spinner.classList.remove("fa-spinner");
- spinner.classList.add("fa-check", "green");
-
- const formSubmit = document.createElement("div");
- formSubmit.className = "formSubmit";
- formSubmit.innerHTML = '<button class="buttonPrimary">' + Language.get("wcf.global.button.next") + "</button>";
-
- content.appendChild(formSubmit);
- UiDialog.rebuild(this);
-
- const button = formSubmit.children[0] as HTMLButtonElement;
- button.addEventListener("click", (event) => {
- event.preventDefault();
-
- if (typeof this.options.callbackSuccess === "function") {
- this.options.callbackSuccess(data);
-
- UiDialog.close(this);
- } else {
- window.location.href = data.proceedURL;
- }
- });
- button.focus();
- }
- }
-
- _ajaxFailure(): boolean {
- const dialog = UiDialog.getDialog(this);
- if (dialog !== undefined) {
- const spinner = dialog.content.querySelector(".fa-spinner") as HTMLSpanElement;
- spinner.classList.remove("fa-spinner");
- spinner.classList.add("fa-times", "red");
- }
-
- return true;
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- className: this.options.className,
- loopCount: this.options.loopCount,
- parameters: this.options.parameters,
- },
- silent: true,
- url: "index.php?worker-proxy/&t=" + window.SECURITY_TOKEN,
- };
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: this.options.dialogId,
- options: {
- backdropCloseOnClick: false,
- onClose: () => {
- this.aborted = true;
- this.request.abortPrevious();
-
- if (typeof this.options.callbackAbort === "function") {
- this.options.callbackAbort();
- } else {
- window.location.reload();
- }
- },
- title: this.options.dialogTitle,
- },
- source: null,
- };
- }
-}
-
-Core.enableLegacyInheritance(AcpUiWorker);
-
-export = AcpUiWorker;
+++ /dev/null
-/**
- * Handles AJAX requests.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Ajax (alias)
- * @module WoltLabSuite/Core/Ajax
- */
-
-import AjaxRequest from "./Ajax/Request";
-import { AjaxCallbackObject, CallbackSuccess, CallbackFailure, RequestData, RequestOptions } from "./Ajax/Data";
-
-const _cache = new WeakMap();
-
-/**
- * Shorthand function to perform a request against the WCF-API with overrides
- * for success and failure callbacks.
- */
-export function api(
- callbackObject: AjaxCallbackObject,
- data?: RequestData,
- success?: CallbackSuccess,
- failure?: CallbackFailure,
-): AjaxRequest {
- if (typeof data !== "object") data = {};
-
- let request = _cache.get(callbackObject);
- if (request === undefined) {
- if (typeof callbackObject._ajaxSetup !== "function") {
- throw new TypeError("Callback object must implement at least _ajaxSetup().");
- }
-
- const options = callbackObject._ajaxSetup();
-
- options.pinData = true;
- options.callbackObject = callbackObject;
-
- if (!options.url) {
- options.url = "index.php?ajax-proxy/&t=" + window.SECURITY_TOKEN;
- options.withCredentials = true;
- }
-
- request = new AjaxRequest(options);
-
- _cache.set(callbackObject, request);
- }
-
- let oldSuccess = null;
- let oldFailure = null;
-
- if (typeof success === "function") {
- oldSuccess = request.getOption("success");
- request.setOption("success", success);
- }
- if (typeof failure === "function") {
- oldFailure = request.getOption("failure");
- request.setOption("failure", failure);
- }
-
- request.setData(data);
- request.sendRequest();
-
- // restore callbacks
- if (oldSuccess !== null) request.setOption("success", oldSuccess);
- if (oldFailure !== null) request.setOption("failure", oldFailure);
-
- return request;
-}
-
-/**
- * Shorthand function to perform a single request against the WCF-API.
- *
- * Please use `Ajax.api` if you're about to repeatedly send requests because this
- * method will spawn an new and rather expensive `AjaxRequest` with each call.
- */
-export function apiOnce(options: RequestOptions): void {
- options.pinData = false;
- options.callbackObject = null;
- if (!options.url) {
- options.url = "index.php?ajax-proxy/&t=" + window.SECURITY_TOKEN;
- options.withCredentials = true;
- }
-
- const request = new AjaxRequest(options);
- request.sendRequest(false);
-}
-
-/**
- * Returns the request object used for an earlier call to `api()`.
- */
-export function getRequestObject(callbackObject: AjaxCallbackObject): AjaxRequest {
- if (!_cache.has(callbackObject)) {
- throw new Error("Expected a previously used callback object, provided object is unknown.");
- }
-
- return _cache.get(callbackObject);
-}
+++ /dev/null
-export interface RequestPayload {
- [key: string]: any;
-}
-
-export interface DatabaseObjectActionPayload extends RequestPayload {
- actionName: string;
- className: string;
- interfaceName?: string;
- objectIDs?: number[];
- parameters?: {
- [key: string]: any;
- };
-}
-
-export type RequestData = FormData | RequestPayload | DatabaseObjectActionPayload;
-
-export interface ResponseData {
- [key: string]: any;
-}
-
-export interface DatabaseObjectActionResponse extends ResponseData {
- actionName: string;
- objectIDs: number[];
- returnValues:
- | {
- [key: string]: any;
- }
- | any[];
-}
-
-/** Return `false` to suppress the error message. */
-export type CallbackFailure = (
- data: ResponseData,
- responseText: string,
- xhr: XMLHttpRequest,
- requestData: RequestData,
-) => boolean;
-export type CallbackFinalize = (xhr: XMLHttpRequest) => void;
-export type CallbackProgress = (event: ProgressEvent) => void;
-export type CallbackSuccess = (
- data: ResponseData | DatabaseObjectActionResponse,
- responseText: string,
- xhr: XMLHttpRequest,
- requestData: RequestData,
-) => void;
-export type CallbackUploadProgress = (event: ProgressEvent) => void;
-export type AjaxCallbackSetup = () => RequestOptions;
-
-export interface AjaxCallbackObject {
- _ajaxFailure?: CallbackFailure;
- _ajaxFinalize?: CallbackFinalize;
- _ajaxProgress?: CallbackProgress;
- _ajaxSuccess: CallbackSuccess;
- _ajaxUploadProgress?: CallbackUploadProgress;
- _ajaxSetup: AjaxCallbackSetup;
-}
-
-export interface RequestOptions {
- // request data
- data?: RequestData;
- contentType?: string | false;
- responseType?: string;
- type?: string;
- url?: string;
- withCredentials?: boolean;
-
- // behavior
- autoAbort?: boolean;
- ignoreError?: boolean;
- pinData?: boolean;
- silent?: boolean;
- includeRequestedWith?: boolean;
-
- // callbacks
- failure?: CallbackFailure;
- finalize?: CallbackFinalize;
- success?: CallbackSuccess;
- progress?: CallbackProgress;
- uploadProgress?: CallbackUploadProgress;
-
- callbackObject?: AjaxCallbackObject | null;
-}
-
-interface PreviousException {
- message: string;
- stacktrace: string;
-}
-
-export interface AjaxResponseException extends ResponseData {
- exceptionID?: string;
- previous: PreviousException[];
- file?: string;
- line?: number;
- message: string;
- returnValues?: {
- description?: string;
- };
- stacktrace?: string;
-}
+++ /dev/null
-/**
- * Provides a utility class to issue JSONP requests.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module AjaxJsonp (alias)
- * @module WoltLabSuite/Core/Ajax/Jsonp
- */
-
-import * as Core from "../Core";
-
-/**
- * Dispatch a JSONP request, the `url` must not contain a callback parameter.
- */
-export function send(
- url: string,
- success: (...args: unknown[]) => void,
- failure: () => void,
- options?: JsonpOptions,
-): void {
- url = typeof (url as any) === "string" ? url.trim() : "";
- if (url.length === 0) {
- throw new Error("Expected a non-empty string for parameter 'url'.");
- }
-
- if (typeof success !== "function") {
- throw new TypeError("Expected a valid callback function for parameter 'success'.");
- }
-
- options = Core.extend(
- {
- parameterName: "callback",
- timeout: 10,
- },
- options || {},
- ) as JsonpOptions;
-
- const callbackName = "wcf_jsonp_" + Core.getUuid().replace(/-/g, "").substr(0, 8);
- const script = document.createElement("script");
-
- const timeout = window.setTimeout(() => {
- if (typeof failure === "function") {
- failure();
- }
-
- window[callbackName] = undefined;
- script.remove();
- }, (~~options.timeout || 10) * 1_000);
-
- window[callbackName] = (...args: any[]) => {
- window.clearTimeout(timeout);
-
- success(...args);
-
- window[callbackName] = undefined;
- script.remove();
- };
-
- url += url.indexOf("?") === -1 ? "?" : "&";
- url += options.parameterName + "=" + callbackName;
-
- script.async = true;
- script.src = url;
-
- document.head.appendChild(script);
-}
-
-interface JsonpOptions {
- parameterName: string;
- timeout: number;
-}
+++ /dev/null
-/**
- * Versatile AJAX request handling.
- *
- * In case you want to issue JSONP requests, please use `AjaxJsonp` instead.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module AjaxRequest (alias)
- * @module WoltLabSuite/Core/Ajax/Request
- */
-
-import * as AjaxStatus from "./Status";
-import { ResponseData, RequestOptions, RequestData, AjaxResponseException } from "./Data";
-import * as Core from "../Core";
-import DomChangeListener from "../Dom/Change/Listener";
-import DomUtil from "../Dom/Util";
-import * as Language from "../Language";
-
-let _didInit = false;
-let _ignoreAllErrors = false;
-
-/**
- * @constructor
- */
-class AjaxRequest {
- private readonly _options: RequestOptions;
- private readonly _data: RequestData;
- private _previousXhr?: XMLHttpRequest;
- private _xhr?: XMLHttpRequest;
-
- constructor(options: RequestOptions) {
- this._options = Core.extend(
- {
- data: {},
- contentType: "application/x-www-form-urlencoded; charset=UTF-8",
- responseType: "application/json",
- type: "POST",
- url: "",
- withCredentials: false,
-
- // behavior
- autoAbort: false,
- ignoreError: false,
- pinData: false,
- silent: false,
- includeRequestedWith: true,
-
- // callbacks
- failure: null,
- finalize: null,
- success: null,
- progress: null,
- uploadProgress: null,
-
- callbackObject: null,
- },
- options,
- );
-
- if (typeof options.callbackObject === "object") {
- this._options.callbackObject = options.callbackObject;
- }
-
- this._options.url = Core.convertLegacyUrl(this._options.url!);
- if (this._options.url.indexOf("index.php") === 0) {
- this._options.url = window.WSC_API_URL + this._options.url;
- }
-
- if (this._options.url.indexOf(window.WSC_API_URL) === 0) {
- this._options.includeRequestedWith = true;
- // always include credentials when querying the very own server
- this._options.withCredentials = true;
- }
-
- if (this._options.pinData) {
- this._data = this._options.data!;
- }
-
- if (this._options.callbackObject) {
- if (typeof this._options.callbackObject._ajaxFailure === "function") {
- this._options.failure = this._options.callbackObject._ajaxFailure.bind(this._options.callbackObject);
- }
- if (typeof this._options.callbackObject._ajaxFinalize === "function") {
- this._options.finalize = this._options.callbackObject._ajaxFinalize.bind(this._options.callbackObject);
- }
- if (typeof this._options.callbackObject._ajaxSuccess === "function") {
- this._options.success = this._options.callbackObject._ajaxSuccess.bind(this._options.callbackObject);
- }
- if (typeof this._options.callbackObject._ajaxProgress === "function") {
- this._options.progress = this._options.callbackObject._ajaxProgress.bind(this._options.callbackObject);
- }
- if (typeof this._options.callbackObject._ajaxUploadProgress === "function") {
- this._options.uploadProgress = this._options.callbackObject._ajaxUploadProgress.bind(
- this._options.callbackObject,
- );
- }
- }
-
- if (!_didInit) {
- _didInit = true;
-
- window.addEventListener("beforeunload", () => (_ignoreAllErrors = true));
- }
- }
-
- /**
- * Dispatches a request, optionally aborting a currently active request.
- */
- sendRequest(abortPrevious?: boolean): void {
- if (abortPrevious || this._options.autoAbort) {
- this.abortPrevious();
- }
-
- if (!this._options.silent) {
- AjaxStatus.show();
- }
-
- if (this._xhr instanceof XMLHttpRequest) {
- this._previousXhr = this._xhr;
- }
-
- this._xhr = new XMLHttpRequest();
- this._xhr.open(this._options.type!, this._options.url!, true);
- if (this._options.contentType) {
- this._xhr.setRequestHeader("Content-Type", this._options.contentType);
- }
- if (this._options.withCredentials || this._options.includeRequestedWith) {
- this._xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
- }
- if (this._options.withCredentials) {
- this._xhr.withCredentials = true;
- }
-
- const options = Core.clone(this._options) as RequestOptions;
-
- // Use a local variable in all callbacks, because `this._xhr` can be overwritten by
- // subsequent requests while a request is still in-flight.
- const xhr = this._xhr;
- xhr.onload = () => {
- if (xhr.readyState === XMLHttpRequest.DONE) {
- if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
- if (options.responseType && xhr.getResponseHeader("Content-Type")!.indexOf(options.responseType) !== 0) {
- // request succeeded but invalid response type
- this._failure(xhr, options);
- } else {
- this._success(xhr, options);
- }
- } else {
- this._failure(xhr, options);
- }
- }
- };
- xhr.onerror = () => {
- this._failure(xhr, options);
- };
-
- if (this._options.progress) {
- xhr.onprogress = this._options.progress;
- }
- if (this._options.uploadProgress) {
- xhr.upload.onprogress = this._options.uploadProgress;
- }
-
- if (this._options.type === "POST") {
- let data: string | RequestData = this._options.data!;
- if (typeof data === "object" && Core.getType(data) !== "FormData") {
- data = Core.serialize(data);
- }
-
- xhr.send(data as any);
- } else {
- xhr.send();
- }
- }
-
- /**
- * Aborts a previous request.
- */
- abortPrevious(): void {
- if (!this._previousXhr) {
- return;
- }
-
- this._previousXhr.abort();
- this._previousXhr = undefined;
-
- if (!this._options.silent) {
- AjaxStatus.hide();
- }
- }
-
- /**
- * Sets a specific option.
- */
- setOption(key: string, value: unknown): void {
- this._options[key] = value;
- }
-
- /**
- * Returns an option by key or undefined.
- */
- getOption(key: string): unknown | null {
- if (Object.prototype.hasOwnProperty.call(this._options, key)) {
- return this._options[key];
- }
-
- return null;
- }
-
- /**
- * Sets request data while honoring pinned data from setup callback.
- */
- setData(data: RequestData): void {
- if (this._data !== null && Core.getType(data) !== "FormData") {
- data = Core.extend(this._data, data);
- }
-
- this._options.data = data;
- }
-
- /**
- * Handles a successful request.
- */
- _success(xhr: XMLHttpRequest, options: RequestOptions): void {
- if (!options.silent) {
- AjaxStatus.hide();
- }
-
- if (typeof options.success === "function") {
- let data: ResponseData | null = null;
- if (xhr.getResponseHeader("Content-Type")!.split(";", 1)[0].trim() === "application/json") {
- try {
- data = JSON.parse(xhr.responseText) as ResponseData;
- } catch (e) {
- // invalid JSON
- this._failure(xhr, options);
-
- return;
- }
-
- // trim HTML before processing, see http://jquery.com/upgrade-guide/1.9/#jquery-htmlstring-versus-jquery-selectorstring
- if (data && data.returnValues && data.returnValues.template !== undefined) {
- data.returnValues.template = data.returnValues.template.trim();
- }
-
- // force-invoke the background queue
- if (data && data.forceBackgroundQueuePerform) {
- void import("../BackgroundQueue").then((backgroundQueue) => backgroundQueue.invoke());
- }
- }
-
- options.success(data!, xhr.responseText, xhr, options.data!);
- }
-
- this._finalize(options);
- }
-
- /**
- * Handles failed requests, this can be both a successful request with
- * a non-success status code or an entirely failed request.
- */
- _failure(xhr: XMLHttpRequest, options: RequestOptions): void {
- if (_ignoreAllErrors) {
- return;
- }
-
- if (!options.silent) {
- AjaxStatus.hide();
- }
-
- let data: ResponseData | null = null;
- try {
- data = JSON.parse(xhr.responseText);
- } catch (e) {
- // Ignore JSON parsing failure.
- }
-
- let showError = true;
- if (typeof options.failure === "function") {
- showError = options.failure(data || {}, xhr.responseText || "", xhr, options.data!);
- }
-
- if (options.ignoreError !== true && showError) {
- const html = this.getErrorHtml(data as AjaxResponseException, xhr);
-
- if (html) {
- void import("../Ui/Dialog").then((UiDialog) => {
- UiDialog.openStatic(DomUtil.getUniqueId(), html, {
- title: Language.get("wcf.global.error.title"),
- });
- });
- }
- }
-
- this._finalize(options);
- }
-
- /**
- * Returns the inner HTML for an error/exception display.
- */
- getErrorHtml(data: AjaxResponseException | null, xhr: XMLHttpRequest): string | null {
- let details = "";
- let message: string;
-
- if (data !== null) {
- if (data.returnValues && data.returnValues.description) {
- details += `<br><p>Description:</p><p>${data.returnValues.description}</p>`;
- }
-
- if (data.file && data.line) {
- details += `<br><p>File:</p><p>${data.file} in line ${data.line}</p>`;
- }
-
- if (data.stacktrace) {
- details += `<br><p>Stacktrace:</p><p>${data.stacktrace}</p>`;
- } else if (data.exceptionID) {
- details += `<br><p>Exception ID: <code>${data.exceptionID}</code></p>`;
- }
-
- message = data.message;
-
- data.previous.forEach((previous) => {
- details += `<hr><p>${previous.message}</p>`;
- details += `<br><p>Stacktrace</p><p>${previous.stacktrace}</p>`;
- });
- } else {
- message = xhr.responseText;
- }
-
- if (!message || message === "undefined") {
- if (!window.ENABLE_DEBUG_MODE) {
- return null;
- }
-
- message = "XMLHttpRequest failed without a responseText. Check your browser console.";
- }
-
- return `<div class="ajaxDebugMessage"><p>${message}</p>${details}</div>`;
- }
-
- /**
- * Finalizes a request.
- *
- * @param {Object} options request options
- */
- _finalize(options: RequestOptions): void {
- if (typeof options.finalize === "function") {
- options.finalize(this._xhr!);
- }
-
- this._previousXhr = undefined;
-
- DomChangeListener.trigger();
-
- // fix anchor tags generated through WCF::getAnchor()
- document.querySelectorAll('a[href*="#"]').forEach((link: HTMLAnchorElement) => {
- let href = link.href;
- if (href.indexOf("AJAXProxy") !== -1 || href.indexOf("ajax-proxy") !== -1) {
- href = href.substr(href.indexOf("#"));
- link.href = document.location.toString().replace(/#.*/, "") + href;
- }
- });
- }
-}
-
-Core.enableLegacyInheritance(AjaxRequest);
-
-export = AjaxRequest;
+++ /dev/null
-/**
- * Provides the AJAX status overlay.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ajax/Status
- */
-
-import * as Language from "../Language";
-
-class AjaxStatus {
- private _activeRequests = 0;
- private readonly _overlay: Element;
- private _timer: number | null = null;
-
- constructor() {
- this._overlay = document.createElement("div");
- this._overlay.classList.add("spinner");
- this._overlay.setAttribute("role", "status");
-
- const icon = document.createElement("span");
- icon.className = "icon icon48 fa-spinner";
- this._overlay.appendChild(icon);
-
- const title = document.createElement("span");
- title.textContent = Language.get("wcf.global.loading");
- this._overlay.appendChild(title);
-
- document.body.appendChild(this._overlay);
- }
-
- show(): void {
- this._activeRequests++;
-
- if (this._timer === null) {
- this._timer = window.setTimeout(() => {
- if (this._activeRequests) {
- this._overlay.classList.add("active");
- }
-
- this._timer = null;
- }, 250);
- }
- }
-
- hide(): void {
- if (--this._activeRequests === 0) {
- if (this._timer !== null) {
- window.clearTimeout(this._timer);
- }
-
- this._overlay.classList.remove("active");
- }
- }
-}
-
-let status: AjaxStatus;
-function getStatus(): AjaxStatus {
- if (status === undefined) {
- status = new AjaxStatus();
- }
-
- return status;
-}
-
-/**
- * Shows the loading overlay.
- */
-export function show(): void {
- getStatus().show();
-}
-
-/**
- * Hides the loading overlay.
- */
-export function hide(): void {
- getStatus().hide();
-}
+++ /dev/null
-/**
- * Manages the invocation of the background queue.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/BackgroundQueue
- */
-
-import * as Ajax from "./Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "./Ajax/Data";
-
-class BackgroundQueue implements AjaxCallbackObject {
- private _invocations = 0;
- private _isBusy = false;
- private readonly _url: string;
-
- constructor(url: string) {
- this._url = url;
- }
-
- invoke(): void {
- if (this._isBusy) return;
-
- this._isBusy = true;
-
- Ajax.api(this);
- }
-
- _ajaxSuccess(data: ResponseData): void {
- this._invocations++;
-
- // invoke the queue up to 5 times in a row
- if (((data as unknown) as number) > 0 && this._invocations < 5) {
- window.setTimeout(() => {
- this._isBusy = false;
- this.invoke();
- }, 1000);
- } else {
- this._isBusy = false;
- this._invocations = 0;
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- url: this._url,
- ignoreError: true,
- silent: true,
- };
- }
-}
-
-let queue: BackgroundQueue;
-
-/**
- * Sets the url of the background queue perform action.
- */
-export function setUrl(url: string): void {
- if (!queue) {
- queue = new BackgroundQueue(url);
- }
-}
-
-/**
- * Invokes the background queue.
- */
-export function invoke(): void {
- if (!queue) {
- console.error("The background queue has not been initialized yet.");
- return;
- }
-
- queue.invoke();
-}
+++ /dev/null
-/**
- * Highlights code in the Code bbcode.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Bbcode/Code
- */
-
-import * as Language from "../Language";
-import * as Clipboard from "../Clipboard";
-import * as UiNotification from "../Ui/Notification";
-import Prism from "../Prism";
-import * as PrismHelper from "../Prism/Helper";
-import PrismMeta from "../prism-meta";
-
-async function waitForIdle(): Promise<void> {
- return new Promise((resolve, _reject) => {
- if ((window as any).requestIdleCallback) {
- (window as any).requestIdleCallback(resolve, { timeout: 5000 });
- } else {
- setTimeout(resolve, 0);
- }
- });
-}
-
-class Code {
- private static readonly chunkSize = 50;
-
- private readonly container: HTMLElement;
- private codeContainer: HTMLElement;
- private language: string | undefined;
-
- constructor(container: HTMLElement) {
- this.container = container;
- this.codeContainer = this.container.querySelector(".codeBoxCode > code") as HTMLElement;
-
- this.language = Array.from(this.codeContainer.classList)
- .find((klass) => /^language-([a-z0-9_-]+)$/.test(klass))
- ?.replace(/^language-/, "");
- }
-
- public static processAll(): void {
- document.querySelectorAll(".codeBox:not([data-processed])").forEach((codeBox: HTMLElement) => {
- codeBox.dataset.processed = "1";
-
- const handle = new Code(codeBox);
-
- if (handle.language) {
- void handle.highlight();
- }
-
- handle.createCopyButton();
- });
- }
-
- public createCopyButton(): void {
- const header = this.container.querySelector(".codeBoxHeader");
-
- if (!header) {
- return;
- }
-
- const button = document.createElement("span");
- button.className = "icon icon24 fa-files-o pointer jsTooltip";
- button.setAttribute("title", Language.get("wcf.message.bbcode.code.copy"));
- button.addEventListener("click", async () => {
- await Clipboard.copyElementTextToClipboard(this.codeContainer);
-
- UiNotification.show(Language.get("wcf.message.bbcode.code.copy.success"));
- });
-
- header.appendChild(button);
- }
-
- public async highlight(): Promise<void> {
- if (!this.language) {
- throw new Error("No language detected");
- }
- if (!PrismMeta[this.language]) {
- throw new Error(`Unknown language '${this.language}'`);
- }
-
- this.container.classList.add("highlighting");
-
- // Step 1) Load the requested grammar.
- await import("prism/components/prism-" + PrismMeta[this.language].file);
-
- // Step 2) Perform the highlighting into a temporary element.
- await waitForIdle();
-
- const grammar = Prism.languages[this.language];
- if (!grammar) {
- throw new Error(`Invalid language '${this.language}' given.`);
- }
-
- const container = document.createElement("div");
- container.innerHTML = Prism.highlight(this.codeContainer.textContent!, grammar, this.language);
-
- // Step 3) Insert the highlighted lines into the page.
- // This is performed in small chunks to prevent the UI thread from being blocked for complex
- // highlight results.
- await waitForIdle();
-
- const originalLines = this.codeContainer.querySelectorAll(".codeBoxLine > span");
- const highlightedLines = PrismHelper.splitIntoLines(container);
-
- for (let chunkStart = 0, max = originalLines.length; chunkStart < max; chunkStart += Code.chunkSize) {
- await waitForIdle();
-
- const chunkEnd = Math.min(chunkStart + Code.chunkSize, max);
-
- for (let offset = chunkStart; offset < chunkEnd; offset++) {
- const toReplace = originalLines[offset]!;
- const replacement = highlightedLines.next().value as Element;
- toReplace.parentNode!.replaceChild(replacement, toReplace);
- }
- }
-
- this.container.classList.remove("highlighting");
- this.container.classList.add("highlighted");
- }
-}
-
-export = Code;
+++ /dev/null
-/**
- * Generic handler for collapsible bbcode boxes.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Bbcode/Collapsible
- */
-
-function initContainer(container: HTMLElement, toggleButtons: HTMLElement[], overflowContainer: HTMLElement): void {
- toggleButtons.forEach((toggleButton) => {
- toggleButton.classList.add("jsToggleButtonEnabled");
- toggleButton.addEventListener("click", (ev) => toggleContainer(container, toggleButtons, ev));
- });
-
- // expand boxes that are initially scrolled
- if (overflowContainer.scrollTop !== 0) {
- overflowContainer.scrollTop = 0;
- toggleContainer(container, toggleButtons);
- }
- overflowContainer.addEventListener("scroll", () => {
- overflowContainer.scrollTop = 0;
- if (container.classList.contains("collapsed")) {
- toggleContainer(container, toggleButtons);
- }
- });
-}
-
-function toggleContainer(container: HTMLElement, toggleButtons: HTMLElement[], event?: Event): void {
- if (container.classList.toggle("collapsed")) {
- toggleButtons.forEach((toggleButton) => {
- const title = toggleButton.dataset.titleExpand!;
- if (toggleButton.classList.contains("icon")) {
- toggleButton.classList.remove("fa-compress");
- toggleButton.classList.add("fa-expand");
- toggleButton.title = title;
- } else {
- toggleButton.textContent = title;
- }
- });
-
- if (event instanceof Event) {
- // negative top value means the upper boundary is not within the viewport
- const top = container.getBoundingClientRect().top;
- if (top < 0) {
- let y = window.pageYOffset + (top - 100);
- if (y < 0) {
- y = 0;
- }
-
- window.scrollTo(window.pageXOffset, y);
- }
- }
- } else {
- toggleButtons.forEach((toggleButton) => {
- const title = toggleButton.dataset.titleCollapse!;
- if (toggleButton.classList.contains("icon")) {
- toggleButton.classList.add("fa-compress");
- toggleButton.classList.remove("fa-expand");
- toggleButton.title = title;
- } else {
- toggleButton.textContent = title;
- }
- });
- }
-}
-
-export function observe(): void {
- document.querySelectorAll(".jsCollapsibleBbcode").forEach((container: HTMLElement) => {
- // find the matching toggle button
- const toggleButtons = Array.from<HTMLElement>(
- container.querySelectorAll(".toggleButton:not(.jsToggleButtonEnabled)"),
- ).filter((button) => {
- return button.closest(".jsCollapsibleBbcode") === container;
- });
-
- const overflowContainer = (container.querySelector(".collapsibleBbcodeOverflow") as HTMLElement) || container;
-
- if (toggleButtons.length > 0) {
- initContainer(container, toggleButtons, overflowContainer);
- }
-
- container.classList.remove("jsCollapsibleBbcode");
- });
-}
+++ /dev/null
-/**
- * Generic handler for spoiler boxes.
- *
- * @author Alexander Ebert
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Bbcode/Spoiler
- */
-
-import * as Core from "../Core";
-import * as Language from "../Language";
-import DomUtil from "../Dom/Util";
-
-function onClick(event: Event, content: HTMLElement, toggleButton: HTMLAnchorElement): void {
- event.preventDefault();
-
- toggleButton.classList.toggle("active");
-
- const isActive = toggleButton.classList.contains("active");
- if (isActive) {
- DomUtil.show(content);
- } else {
- DomUtil.hide(content);
- }
-
- toggleButton.setAttribute("aria-expanded", isActive ? "true" : "false");
- content.setAttribute("aria-hidden", isActive ? "false" : "true");
-
- if (!Core.stringToBool(toggleButton.dataset.hasCustomLabel || "")) {
- toggleButton.textContent = Language.get(
- toggleButton.classList.contains("active") ? "wcf.bbcode.spoiler.hide" : "wcf.bbcode.spoiler.show",
- );
- }
-}
-
-export function observe(): void {
- const className = "jsSpoilerBox";
- document.querySelectorAll(`.${className}`).forEach((container: HTMLElement) => {
- container.classList.remove(className);
-
- const toggleButton = container.querySelector(".jsSpoilerToggle") as HTMLAnchorElement;
- const content = container.querySelector(".spoilerBoxContent") as HTMLElement;
-
- toggleButton.addEventListener("click", (ev) => onClick(ev, content, toggleButton));
- });
-}
+++ /dev/null
-/**
- * Bootstraps WCF's JavaScript.
- * It defines globals needed for backwards compatibility
- * and runs modules that are needed on page load.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Bootstrap
- */
-
-import * as Core from "./Core";
-import DatePicker from "./Date/Picker";
-import * as DateTimeRelative from "./Date/Time/Relative";
-import Devtools from "./Devtools";
-import DomChangeListener from "./Dom/Change/Listener";
-import * as Environment from "./Environment";
-import * as EventHandler from "./Event/Handler";
-import * as Language from "./Language";
-import * as StringUtil from "./StringUtil";
-import UiDialog from "./Ui/Dialog";
-import UiDropdownSimple from "./Ui/Dropdown/Simple";
-import * as UiMobile from "./Ui/Mobile";
-import * as UiPageAction from "./Ui/Page/Action";
-import * as UiTabMenu from "./Ui/TabMenu";
-import * as UiTooltip from "./Ui/Tooltip";
-
-// perfectScrollbar does not need to be bound anywhere, it just has to be loaded for WCF.js
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-import perfectScrollbar from "perfect-scrollbar";
-
-// non strict equals by intent
-if (window.WCF == null) {
- window.WCF = {};
-}
-if (window.WCF.Language == null) {
- window.WCF.Language = {};
-}
-window.WCF.Language.get = Language.get;
-window.WCF.Language.add = Language.add;
-window.WCF.Language.addObject = Language.addObject;
-// WCF.System.Event compatibility
-window.__wcf_bc_eventHandler = EventHandler;
-
-export interface BoostrapOptions {
- enableMobileMenu: boolean;
-}
-
-function initA11y() {
- document
- .querySelectorAll("nav:not([aria-label]):not([aria-labelledby]):not([role])")
- .forEach((element: HTMLElement) => {
- element.setAttribute("role", "presentation");
- });
-
- document
- .querySelectorAll("article:not([aria-label]):not([aria-labelledby]):not([role])")
- .forEach((element: HTMLElement) => {
- element.setAttribute("role", "presentation");
- });
-}
-
-/**
- * Initializes the core UI modifications and unblocks jQuery's ready event.
- */
-export function setup(options: BoostrapOptions): void {
- options = Core.extend(
- {
- enableMobileMenu: true,
- },
- options,
- ) as BoostrapOptions;
-
- StringUtil.setupI18n({
- decimalPoint: Language.get("wcf.global.decimalPoint"),
- thousandsSeparator: Language.get("wcf.global.thousandsSeparator"),
- });
-
- if (window.ENABLE_DEVELOPER_TOOLS) {
- Devtools._internal_.enable();
- }
-
- Environment.setup();
- DateTimeRelative.setup();
- DatePicker.init();
- UiDropdownSimple.setup();
- UiMobile.setup(options.enableMobileMenu);
- UiTabMenu.setup();
- UiDialog.setup();
- UiTooltip.setup();
-
- // Convert forms with `method="get"` into `method="post"`
- document.querySelectorAll("form[method=get]").forEach((form: HTMLFormElement) => {
- form.method = "post";
- });
-
- if (Environment.browser() === "microsoft") {
- window.onbeforeunload = () => {
- /* Prevent "Back navigation caching" (http://msdn.microsoft.com/en-us/library/ie/dn265017%28v=vs.85%29.aspx) */
- };
- }
-
- let interval = 0;
- interval = window.setInterval(() => {
- if (typeof window.jQuery === "function") {
- window.clearInterval(interval);
-
- // The 'jump to top' button triggers a style recalculation/"layout".
- // Placing it at the end of the jQuery queue avoids trashing the
- // layout too early and thus delaying the page initialization.
- window.jQuery(() => {
- UiPageAction.setup();
- });
-
- // jQuery.browser.mobile is a deprecated legacy property that was used
- // to determine the class of devices being used.
- const jq = window.jQuery as any;
- jq.browser = jq.browser || {};
- jq.browser.mobile = Environment.platform() !== "desktop";
-
- window.jQuery.holdReady(false);
- }
- }, 20);
-
- initA11y();
-
- DomChangeListener.add("WoltLabSuite/Core/Bootstrap", () => initA11y);
-}
+++ /dev/null
-/**
- * Bootstraps WCF's JavaScript with additions for the frontend usage.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/BootstrapFrontend
- */
-
-import * as BackgroundQueue from "./BackgroundQueue";
-import * as Bootstrap from "./Bootstrap";
-import * as ControllerStyleChanger from "./Controller/Style/Changer";
-import * as ControllerPopover from "./Controller/Popover";
-import * as UiUserIgnore from "./Ui/User/Ignore";
-import * as UiPageHeaderMenu from "./Ui/Page/Header/Menu";
-import * as UiMessageUserConsent from "./Ui/Message/UserConsent";
-
-interface BoostrapOptions {
- backgroundQueue: {
- url: string;
- force: boolean;
- };
- enableUserPopover: boolean;
- styleChanger: boolean;
-}
-
-/**
- * Initializes user profile popover.
- */
-function _initUserPopover(): void {
- ControllerPopover.init({
- className: "userLink",
- dboAction: "wcf\\data\\user\\UserProfileAction",
- identifier: "com.woltlab.wcf.user",
- });
-
- // @deprecated since 5.3
- ControllerPopover.init({
- attributeName: "data-user-id",
- className: "userLink",
- dboAction: "wcf\\data\\user\\UserProfileAction",
- identifier: "com.woltlab.wcf.user.deprecated",
- });
-}
-
-/**
- * Bootstraps general modules and frontend exclusive ones.
- */
-export function setup(options: BoostrapOptions): void {
- // Modify the URL of the background queue URL to always target the current domain to avoid CORS.
- options.backgroundQueue.url = window.WSC_API_URL + options.backgroundQueue.url.substr(window.WCF_PATH.length);
-
- Bootstrap.setup({ enableMobileMenu: true });
- UiPageHeaderMenu.init();
-
- if (options.styleChanger) {
- ControllerStyleChanger.setup();
- }
-
- if (options.enableUserPopover) {
- _initUserPopover();
- }
-
- BackgroundQueue.setUrl(options.backgroundQueue.url);
- if (Math.random() < 0.1 || options.backgroundQueue.force) {
- // invoke the queue roughly every 10th request or on demand
- BackgroundQueue.invoke();
- }
-
- if (globalThis.COMPILER_TARGET_DEFAULT) {
- UiUserIgnore.init();
- }
-
- UiMessageUserConsent.init();
-}
+++ /dev/null
-/**
- * Simple API to store and invoke multiple callbacks per identifier.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module CallbackList (alias)
- * @module WoltLabSuite/Core/CallbackList
- */
-
-import * as Core from "./Core";
-
-class CallbackList {
- private readonly _callbacks = new Map<string, Callback[]>();
-
- /**
- * Adds a callback for given identifier.
- */
- add(identifier: string, callback: Callback): void {
- if (typeof callback !== "function") {
- throw new TypeError("Expected a valid callback as second argument for identifier '" + identifier + "'.");
- }
-
- if (!this._callbacks.has(identifier)) {
- this._callbacks.set(identifier, []);
- }
-
- this._callbacks.get(identifier)!.push(callback);
- }
-
- /**
- * Removes all callbacks registered for given identifier
- */
- remove(identifier: string): void {
- this._callbacks.delete(identifier);
- }
-
- /**
- * Invokes callback function on each registered callback.
- */
- forEach(identifier: string | null, callback: (cb: Callback) => unknown): void {
- if (identifier === null) {
- this._callbacks.forEach((callbacks, _identifier) => {
- callbacks.forEach(callback);
- });
- } else {
- this._callbacks.get(identifier)?.forEach(callback);
- }
- }
-}
-
-type Callback = (...args: any[]) => void;
-
-Core.enableLegacyInheritance(CallbackList);
-
-export = CallbackList;
+++ /dev/null
-/**
- * Wrapper around the web browser's various clipboard APIs.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Clipboard
- */
-
-export async function copyTextToClipboard(text: string): Promise<void> {
- if (navigator.clipboard) {
- return navigator.clipboard.writeText(text);
- }
-
- throw new Error("navigator.clipboard is not supported.");
-}
-
-export async function copyElementTextToClipboard(element: HTMLElement): Promise<void> {
- return copyTextToClipboard(element.textContent!);
-}
+++ /dev/null
-/**
- * Helper functions to convert between different color formats.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module ColorUtil (alias)
- * @module WoltLabSuite/Core/ColorUtil
- */
-
-/**
- * Converts a HSV color into RGB.
- *
- * @see https://secure.wikimedia.org/wikipedia/de/wiki/HSV-Farbraum#Transformation_von_RGB_und_HSV
- */
-export function hsvToRgb(h: number, s: number, v: number): RGB {
- const rgb: RGB = { r: 0, g: 0, b: 0 };
-
- const h2 = Math.floor(h / 60);
- const f = h / 60 - h2;
-
- s /= 100;
- v /= 100;
-
- const p = v * (1 - s);
- const q = v * (1 - s * f);
- const t = v * (1 - s * (1 - f));
-
- if (s == 0) {
- rgb.r = rgb.g = rgb.b = v;
- } else {
- switch (h2) {
- case 1:
- rgb.r = q;
- rgb.g = v;
- rgb.b = p;
- break;
-
- case 2:
- rgb.r = p;
- rgb.g = v;
- rgb.b = t;
- break;
-
- case 3:
- rgb.r = p;
- rgb.g = q;
- rgb.b = v;
- break;
-
- case 4:
- rgb.r = t;
- rgb.g = p;
- rgb.b = v;
- break;
-
- case 5:
- rgb.r = v;
- rgb.g = p;
- rgb.b = q;
- break;
-
- case 0:
- case 6:
- rgb.r = v;
- rgb.g = t;
- rgb.b = p;
- break;
- }
- }
-
- return {
- r: Math.round(rgb.r * 255),
- g: Math.round(rgb.g * 255),
- b: Math.round(rgb.b * 255),
- };
-}
-
-/**
- * Converts a RGB color into HSV.
- *
- * @see https://secure.wikimedia.org/wikipedia/de/wiki/HSV-Farbraum#Transformation_von_RGB_und_HSV
- */
-export function rgbToHsv(r: number, g: number, b: number): HSV {
- let h: number, s: number;
-
- r /= 255;
- g /= 255;
- b /= 255;
-
- const max = Math.max(Math.max(r, g), b);
- const min = Math.min(Math.min(r, g), b);
- const diff = max - min;
-
- h = 0;
- if (max !== min) {
- switch (max) {
- case r:
- h = 60 * ((g - b) / diff);
- break;
-
- case g:
- h = 60 * (2 + (b - r) / diff);
- break;
-
- case b:
- h = 60 * (4 + (r - g) / diff);
- break;
- }
-
- if (h < 0) {
- h += 360;
- }
- }
-
- if (max === 0) {
- s = 0;
- } else {
- s = diff / max;
- }
-
- return {
- h: Math.round(h),
- s: Math.round(s * 100),
- v: Math.round(max * 100),
- };
-}
-
-/**
- * Converts HEX into RGB.
- */
-export function hexToRgb(hex: string): RGB | typeof Number.NaN {
- if (/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)) {
- // only convert #abc and #abcdef
- const parts = hex.split("");
-
- // drop the hashtag
- if (parts[0] === "#") {
- parts.shift();
- }
-
- // parse shorthand #xyz
- if (parts.length === 3) {
- return {
- r: parseInt(parts[0] + "" + parts[0], 16),
- g: parseInt(parts[1] + "" + parts[1], 16),
- b: parseInt(parts[2] + "" + parts[2], 16),
- };
- } else {
- return {
- r: parseInt(parts[0] + "" + parts[1], 16),
- g: parseInt(parts[2] + "" + parts[3], 16),
- b: parseInt(parts[4] + "" + parts[5], 16),
- };
- }
- }
-
- return Number.NaN;
-}
-
-/**
- * Converts a RGB into HEX.
- *
- * @see http://www.linuxtopia.org/online_books/javascript_guides/javascript_faq/rgbtohex.htm
- */
-export function rgbToHex(r: number, g: number, b: number): string {
- const charList = "0123456789ABCDEF";
-
- if (g === undefined) {
- if (/^rgba?\((\d+), ?(\d+), ?(\d+)(?:, ?[0-9.]+)?\)$/.exec(r.toString())) {
- r = +RegExp.$1;
- g = +RegExp.$2;
- b = +RegExp.$3;
- }
- }
-
- return (
- charList.charAt((r - (r % 16)) / 16) +
- "" +
- charList.charAt(r % 16) +
- "" +
- (charList.charAt((g - (g % 16)) / 16) + "" + charList.charAt(g % 16)) +
- "" +
- (charList.charAt((b - (b % 16)) / 16) + "" + charList.charAt(b % 16))
- );
-}
-
-interface RGB {
- r: number;
- g: number;
- b: number;
-}
-
-interface HSV {
- h: number;
- s: number;
- v: number;
-}
-
-// WCF.ColorPicker compatibility (color format conversion)
-window.__wcf_bc_colorUtil = {
- hexToRgb,
- hsvToRgb,
- rgbToHex,
- rgbToHsv,
-};
+++ /dev/null
-/**
- * Provides data of the active user.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Controller/Captcha
- */
-
-type CallbackCaptcha = () => unknown;
-
-const _captchas = new Map<string, CallbackCaptcha>();
-
-const ControllerCaptcha = {
- /**
- * Registers a captcha with the given identifier and callback used to get captcha data.
- */
- add(captchaId: string, callback: CallbackCaptcha): void {
- if (_captchas.has(captchaId)) {
- throw new Error(`Captcha with id '${captchaId}' is already registered.`);
- }
-
- if (typeof callback !== "function") {
- throw new TypeError("Expected a valid callback for parameter 'callback'.");
- }
-
- _captchas.set(captchaId, callback);
- },
-
- /**
- * Deletes the captcha with the given identifier.
- */
- delete(captchaId: string): void {
- if (!_captchas.has(captchaId)) {
- throw new Error(`Unknown captcha with id '${captchaId}'.`);
- }
-
- _captchas.delete(captchaId);
- },
-
- /**
- * Returns true if a captcha with the given identifier exists.
- */
- has(captchaId: string): boolean {
- return _captchas.has(captchaId);
- },
-
- /**
- * Returns the data of the captcha with the given identifier.
- *
- * @param {string} captchaId captcha identifier
- * @return {Object} captcha data
- */
- getData(captchaId: string): unknown {
- if (!_captchas.has(captchaId)) {
- throw new Error(`Unknown captcha with id '${captchaId}'.`);
- }
-
- return _captchas.get(captchaId)!();
- },
-};
-
-export = ControllerCaptcha;
+++ /dev/null
-/**
- * Clipboard API Handler.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Controller/Clipboard
- */
-
-import * as Ajax from "../Ajax";
-import { AjaxCallbackSetup } from "../Ajax/Data";
-import * as Core from "../Core";
-import DomChangeListener from "../Dom/Change/Listener";
-import DomUtil from "../Dom/Util";
-import * as EventHandler from "../Event/Handler";
-import * as Language from "../Language";
-import * as UiConfirmation from "../Ui/Confirmation";
-import UiDropdownSimple from "../Ui/Dropdown/Simple";
-import * as UiPageAction from "../Ui/Page/Action";
-import * as UiScreen from "../Ui/Screen";
-
-interface ClipboardOptions {
- hasMarkedItems: boolean;
- pageClassName: string;
- pageObjectId?: number;
-}
-
-interface ContainerData {
- checkboxes: HTMLCollectionOf<HTMLInputElement>;
- element: HTMLElement;
- markAll: HTMLInputElement | null;
- markedObjectIds: Set<number>;
-}
-
-interface ItemData {
- items: { [key: string]: ClipboardActionData };
- label: string;
- reloadPageOnSuccess: string[];
-}
-
-interface ClipboardActionData {
- actionName: string;
- internalData: ArbitraryObject;
- label: string;
- parameters: {
- actionName?: string;
- className?: string;
- objectIDs: number[];
- template: string;
- };
- url: string;
-}
-
-interface AjaxResponseMarkedItems {
- [key: string]: number[];
-}
-
-interface AjaxResponse {
- actionName: string;
- returnValues: {
- action: string;
- items?: {
- // They key is the `typeName`
- [key: string]: ItemData;
- };
- markedItems?: AjaxResponseMarkedItems;
- objectType: string;
- };
-}
-
-const _specialCheckboxSelector =
- '.messageCheckboxLabel > input[type="checkbox"], .message .messageClipboardCheckbox > input[type="checkbox"], .messageGroupList .columnMark > label > input[type="checkbox"]';
-
-class ControllerClipboard {
- private readonly containers = new Map<string, ContainerData>();
- private readonly editors = new Map<string, HTMLAnchorElement>();
- private readonly editorDropdowns = new Map<string, HTMLOListElement>();
- private itemData = new WeakMap<HTMLLIElement, ClipboardActionData>();
- private readonly knownCheckboxes = new WeakSet<HTMLInputElement>();
- private readonly pageClassNames: string[] = [];
- private pageObjectId? = 0;
- private readonly reloadPageOnSuccess = new Map<string, string[]>();
-
- /**
- * Initializes the clipboard API handler.
- */
- setup(options: ClipboardOptions) {
- if (!options.pageClassName) {
- throw new Error("Expected a non-empty string for parameter 'pageClassName'.");
- }
-
- let hasMarkedItems = false;
- if (this.pageClassNames.length === 0) {
- hasMarkedItems = options.hasMarkedItems;
- this.pageObjectId = options.pageObjectId;
- }
-
- this.pageClassNames.push(options.pageClassName);
-
- this.initContainers();
-
- if (hasMarkedItems && this.containers.size) {
- this.loadMarkedItems();
- }
-
- DomChangeListener.add("WoltLabSuite/Core/Controller/Clipboard", () => this.initContainers());
- }
-
- /**
- * Reloads the clipboard data.
- */
- reload(): void {
- if (this.containers.size) {
- this.loadMarkedItems();
- }
- }
-
- /**
- * Initializes clipboard containers.
- */
- private initContainers(): void {
- document.querySelectorAll(".jsClipboardContainer").forEach((container: HTMLElement) => {
- const containerId = DomUtil.identify(container);
-
- let containerData = this.containers.get(containerId);
- if (containerData === undefined) {
- const markAll = container.querySelector(".jsClipboardMarkAll") as HTMLInputElement;
-
- if (markAll !== null) {
- if (markAll.matches(_specialCheckboxSelector)) {
- const label = markAll.closest("label") as HTMLLabelElement;
- label.setAttribute("role", "checkbox");
- label.tabIndex = 0;
- label.setAttribute("aria-checked", "false");
- label.setAttribute("aria-label", Language.get("wcf.clipboard.item.markAll"));
-
- label.addEventListener("keyup", (event) => {
- if (event.key === "Enter" || event.key === "Space") {
- markAll.click();
- }
- });
- }
-
- markAll.dataset.containerId = containerId;
- markAll.addEventListener("click", (ev) => this.markAll(ev));
- }
-
- containerData = {
- checkboxes: container.getElementsByClassName("jsClipboardItem") as HTMLCollectionOf<HTMLInputElement>,
- element: container,
- markAll: markAll,
- markedObjectIds: new Set<number>(),
- };
- this.containers.set(containerId, containerData);
- }
-
- Array.from(containerData.checkboxes).forEach((checkbox) => {
- if (this.knownCheckboxes.has(checkbox)) {
- return;
- }
-
- checkbox.dataset.containerId = containerId;
-
- if (checkbox.matches(_specialCheckboxSelector)) {
- const label = checkbox.closest("label") as HTMLLabelElement;
- label.setAttribute("role", "checkbox");
- label.tabIndex = 0;
- label.setAttribute("aria-checked", "false");
- label.setAttribute("aria-label", Language.get("wcf.clipboard.item.mark"));
-
- label.addEventListener("keyup", (event) => {
- if (event.key === "Enter" || event.key === "Space") {
- checkbox.click();
- }
- });
- }
-
- const link = checkbox.closest("a");
- if (link === null) {
- checkbox.addEventListener("click", (ev) => this.mark(ev));
- } else {
- // Firefox will always trigger the link if the checkbox is
- // inside of one. Since 2000. Thanks Firefox.
- checkbox.addEventListener("click", (event) => {
- event.preventDefault();
-
- window.setTimeout(() => {
- checkbox.checked = !checkbox.checked;
-
- this.mark(checkbox);
- }, 10);
- });
- }
-
- this.knownCheckboxes.add(checkbox);
- });
- });
- }
-
- /**
- * Loads marked items from clipboard.
- */
- private loadMarkedItems(): void {
- Ajax.api(this, {
- actionName: "getMarkedItems",
- parameters: {
- pageClassNames: this.pageClassNames,
- pageObjectID: this.pageObjectId,
- },
- });
- }
-
- /**
- * Marks or unmarks all visible items at once.
- */
- private markAll(event: MouseEvent): void {
- const checkbox = event.currentTarget as HTMLInputElement;
- const isMarked = checkbox.nodeName !== "INPUT" || checkbox.checked;
-
- this.setParentAsMarked(checkbox, isMarked);
-
- const objectIds: number[] = [];
-
- const containerId = checkbox.dataset.containerId!;
- const data = this.containers.get(containerId)!;
- const type = data.element.dataset.type!;
-
- Array.from(data.checkboxes).forEach((item) => {
- const objectId = ~~item.dataset.objectId!;
-
- if (isMarked) {
- if (!item.checked) {
- item.checked = true;
-
- data.markedObjectIds.add(objectId);
- objectIds.push(objectId);
- }
- } else {
- if (item.checked) {
- item.checked = false;
-
- data.markedObjectIds["delete"](objectId);
- objectIds.push(objectId);
- }
- }
-
- this.setParentAsMarked(item, isMarked);
-
- const clipboardObject = checkbox.closest(".jsClipboardObject");
- if (clipboardObject !== null) {
- if (isMarked) {
- clipboardObject.classList.add("jsMarked");
- } else {
- clipboardObject.classList.remove("jsMarked");
- }
- }
- });
-
- this.saveState(type, objectIds, isMarked);
- }
-
- /**
- * Marks or unmarks an individual item.
- *
- */
- private mark(event: MouseEvent | HTMLInputElement): void {
- const checkbox = event instanceof Event ? (event.currentTarget as HTMLInputElement) : event;
-
- const objectId = ~~checkbox.dataset.objectId!;
- const isMarked = checkbox.checked;
- const containerId = checkbox.dataset.containerId!;
- const data = this.containers.get(containerId)!;
- const type = data.element.dataset.type!;
-
- const clipboardObject = checkbox.closest(".jsClipboardObject") as HTMLElement;
- if (isMarked) {
- data.markedObjectIds.add(objectId);
- clipboardObject.classList.add("jsMarked");
- } else {
- data.markedObjectIds.delete(objectId);
- clipboardObject.classList.remove("jsMarked");
- }
-
- if (data.markAll !== null) {
- data.markAll.checked = !Array.from(data.checkboxes).some((item) => !item.checked);
-
- this.setParentAsMarked(data.markAll, isMarked);
- }
-
- this.setParentAsMarked(checkbox, checkbox.checked);
-
- this.saveState(type, [objectId], isMarked);
- }
-
- /**
- * Saves the state for given item object ids.
- */
- private saveState(objectType: string, objectIds: number[], isMarked: boolean): void {
- Ajax.api(this, {
- actionName: isMarked ? "mark" : "unmark",
- parameters: {
- pageClassNames: this.pageClassNames,
- pageObjectID: this.pageObjectId,
- objectIDs: objectIds,
- objectType,
- },
- });
- }
-
- /**
- * Executes an editor action.
- */
- private executeAction(event: MouseEvent): void {
- const listItem = event.currentTarget as HTMLLIElement;
- const data = this.itemData.get(listItem)!;
-
- if (data.url) {
- window.location.href = data.url;
- return;
- }
-
- function triggerEvent() {
- const type = listItem.dataset.type!;
-
- EventHandler.fire("com.woltlab.wcf.clipboard", type, {
- data,
- listItem,
- responseData: null,
- });
- }
-
- const message = typeof data.internalData.confirmMessage === "string" ? data.internalData.confirmMessage : "";
- let fireEvent = true;
-
- if (Core.isPlainObject(data.parameters) && data.parameters.actionName && data.parameters.className) {
- if (data.parameters.actionName === "unmarkAll" || Array.isArray(data.parameters.objectIDs)) {
- if (message.length) {
- const template = typeof data.internalData.template === "string" ? data.internalData.template : "";
-
- UiConfirmation.show({
- confirm: () => {
- const formData = {};
-
- if (template.length) {
- UiConfirmation.getContentElement()
- .querySelectorAll("input, select, textarea")
- .forEach((item: HTMLInputElement) => {
- const name = item.name;
-
- switch (item.nodeName) {
- case "INPUT":
- if ((item.type !== "checkbox" && item.type !== "radio") || item.checked) {
- formData[name] = item.value;
- }
- break;
-
- case "SELECT":
- formData[name] = item.value;
- break;
-
- case "TEXTAREA":
- formData[name] = item.value.trim();
- break;
- }
- });
- }
-
- this.executeProxyAction(listItem, data, formData);
- },
- message,
- template,
- });
- } else {
- this.executeProxyAction(listItem, data);
- }
- }
- } else if (message.length) {
- fireEvent = false;
-
- UiConfirmation.show({
- confirm: triggerEvent,
- message,
- });
- }
-
- if (fireEvent) {
- triggerEvent();
- }
- }
-
- /**
- * Forwards clipboard actions to an individual handler.
- */
- private executeProxyAction(listItem: HTMLLIElement, data: ClipboardActionData, formData: ArbitraryObject = {}): void {
- const objectIds = data.parameters.actionName !== "unmarkAll" ? data.parameters.objectIDs : [];
- const parameters = { data: formData };
-
- if (Core.isPlainObject(data.internalData.parameters)) {
- Object.entries(data.internalData.parameters as ArbitraryObject).forEach(([key, value]) => {
- parameters[key] = value;
- });
- }
-
- Ajax.api(
- this,
- {
- actionName: data.parameters.actionName,
- className: data.parameters.className,
- objectIDs: objectIds,
- parameters,
- },
- (responseData: AjaxResponse) => {
- if (data.actionName !== "unmarkAll") {
- const type = listItem.dataset.type!;
-
- EventHandler.fire("com.woltlab.wcf.clipboard", type, {
- data,
- listItem,
- responseData,
- });
-
- const reloadPageOnSuccess = this.reloadPageOnSuccess.get(type);
- if (reloadPageOnSuccess && reloadPageOnSuccess.includes(responseData.actionName)) {
- window.location.reload();
- return;
- }
- }
-
- this.loadMarkedItems();
- },
- );
- }
-
- /**
- * Unmarks all clipboard items for an object type.
- */
- private unmarkAll(event: MouseEvent): void {
- const listItem = event.currentTarget as HTMLElement;
-
- Ajax.api(this, {
- actionName: "unmarkAll",
- parameters: {
- objectType: listItem.dataset.type!,
- },
- });
- }
-
- /**
- * Sets up ajax request object.
- */
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- className: "wcf\\data\\clipboard\\item\\ClipboardItemAction",
- },
- };
- }
-
- /**
- * Handles successful AJAX requests.
- */
- _ajaxSuccess(data: AjaxResponse): void {
- if (data.actionName === "unmarkAll") {
- const objectType = data.returnValues.objectType;
- this.containers.forEach((containerData) => {
- if (containerData.element.dataset.type !== objectType) {
- return;
- }
-
- containerData.element.querySelectorAll(".jsMarked").forEach((element) => element.classList.remove("jsMarked"));
-
- if (containerData.markAll !== null) {
- containerData.markAll.checked = false;
-
- this.setParentAsMarked(containerData.markAll, false);
- }
-
- Array.from(containerData.checkboxes).forEach((checkbox) => {
- checkbox.checked = false;
-
- this.setParentAsMarked(checkbox, false);
- });
-
- UiPageAction.remove(`wcfClipboard-${objectType}`);
- });
-
- return;
- }
-
- this.itemData = new WeakMap<HTMLLIElement, ClipboardActionData>();
- this.reloadPageOnSuccess.clear();
-
- // rebuild markings
- const markings = Core.isPlainObject(data.returnValues.markedItems) ? data.returnValues.markedItems! : {};
- this.containers.forEach((containerData) => {
- const typeName = containerData.element.dataset.type!;
-
- const objectIds = Array.isArray(markings[typeName]) ? markings[typeName] : [];
- this.rebuildMarkings(containerData, objectIds);
- });
-
- const keepEditors: string[] = Object.keys(data.returnValues.items || {});
-
- // clear editors
- this.editors.forEach((editor, typeName) => {
- if (keepEditors.includes(typeName)) {
- UiPageAction.remove(`wcfClipboard-${typeName}`);
-
- this.editorDropdowns.get(typeName)!.innerHTML = "";
- }
- });
-
- // no items
- if (!data.returnValues.items) {
- return;
- }
-
- // rebuild editors
- Object.entries(data.returnValues.items).forEach(([typeName, typeData]) => {
- this.reloadPageOnSuccess.set(typeName, typeData.reloadPageOnSuccess);
-
- let created = false;
-
- let editor = this.editors.get(typeName);
- let dropdown = this.editorDropdowns.get(typeName)!;
- if (editor === undefined) {
- created = true;
-
- editor = document.createElement("a");
- editor.className = "dropdownToggle";
- editor.textContent = typeData.label;
-
- this.editors.set(typeName, editor);
-
- dropdown = document.createElement("ol");
- dropdown.className = "dropdownMenu";
-
- this.editorDropdowns.set(typeName, dropdown);
- } else {
- editor.textContent = typeData.label;
- dropdown.innerHTML = "";
- }
-
- // create editor items
- Object.values(typeData.items).forEach((itemData) => {
- const item = document.createElement("li");
- const label = document.createElement("span");
- label.textContent = itemData.label;
- item.appendChild(label);
- dropdown.appendChild(item);
-
- item.dataset.type = typeName;
- item.addEventListener("click", (ev) => this.executeAction(ev));
-
- this.itemData.set(item, itemData);
- });
-
- const divider = document.createElement("li");
- divider.classList.add("dropdownDivider");
- dropdown.appendChild(divider);
-
- // add 'unmark all'
- const unmarkAll = document.createElement("li");
- unmarkAll.dataset.type = typeName;
- const label = document.createElement("span");
- label.textContent = Language.get("wcf.clipboard.item.unmarkAll");
- unmarkAll.appendChild(label);
- unmarkAll.addEventListener("click", (ev) => this.unmarkAll(ev));
- dropdown.appendChild(unmarkAll);
-
- if (keepEditors.indexOf(typeName) !== -1) {
- const actionName = `wcfClipboard-${typeName}`;
-
- if (UiPageAction.has(actionName)) {
- UiPageAction.show(actionName);
- } else {
- UiPageAction.add(actionName, editor);
- }
- }
-
- if (created) {
- const parent = editor.parentElement!;
- parent.classList.add("dropdown");
- parent.appendChild(dropdown);
- UiDropdownSimple.init(editor);
- }
- });
- }
-
- /**
- * Rebuilds the mark state for each item.
- */
- private rebuildMarkings(data: ContainerData, objectIds: number[]): void {
- let markAll = true;
-
- Array.from(data.checkboxes).forEach((checkbox) => {
- const clipboardObject = checkbox.closest(".jsClipboardObject") as HTMLElement;
-
- const isMarked = objectIds.includes(~~checkbox.dataset.objectId!);
- if (!isMarked) {
- markAll = false;
- }
-
- checkbox.checked = isMarked;
- if (isMarked) {
- clipboardObject.classList.add("jsMarked");
- } else {
- clipboardObject.classList.remove("jsMarked");
- }
-
- this.setParentAsMarked(checkbox, isMarked);
- });
-
- if (data.markAll !== null) {
- data.markAll.checked = markAll;
-
- this.setParentAsMarked(data.markAll, markAll);
-
- const parent = data.markAll.closest(".columnMark");
- if (parent) {
- if (markAll) {
- parent.classList.add("jsMarked");
- } else {
- parent.classList.remove("jsMarked");
- }
- }
- }
- }
-
- private setParentAsMarked(element: HTMLElement, isMarked: boolean): void {
- const parent = element.parentElement!;
- if (parent.getAttribute("role") === "checkbox") {
- parent.setAttribute("aria-checked", isMarked ? "true" : "false");
- }
- }
-
- /**
- * Hides the clipboard editor for the given object type.
- */
- hideEditor(objectType: string): void {
- UiPageAction.remove("wcfClipboard-" + objectType);
-
- UiScreen.pageOverlayOpen();
- }
-
- /**
- * Shows the clipboard editor.
- */
- showEditor(): void {
- this.loadMarkedItems();
-
- UiScreen.pageOverlayClose();
- }
-
- /**
- * Unmarks the objects with given clipboard object type and ids.
- */
- unmark(objectType: string, objectIds: number[]): void {
- this.saveState(objectType, objectIds, false);
- }
-}
-
-let controllerClipboard: ControllerClipboard;
-
-function getControllerClipboard(): ControllerClipboard {
- if (!controllerClipboard) {
- controllerClipboard = new ControllerClipboard();
- }
-
- return controllerClipboard;
-}
-
-/**
- * Initializes the clipboard API handler.
- */
-export function setup(options: ClipboardOptions): void {
- getControllerClipboard().setup(options);
-}
-
-/**
- * Reloads the clipboard data.
- */
-export function reload(): void {
- getControllerClipboard().reload();
-}
-
-/**
- * Hides the clipboard editor for the given object type.
- */
-export function hideEditor(objectType: string): void {
- getControllerClipboard().hideEditor(objectType);
-}
-
-/**
- * Shows the clipboard editor.
- */
-export function showEditor(): void {
- getControllerClipboard().showEditor();
-}
-
-/**
- * Unmarks the objects with given clipboard object type and ids.
- */
-export function unmark(objectType: string, objectIds: number[]): void {
- getControllerClipboard().unmark(objectType, objectIds);
-}
+++ /dev/null
-/**
- * Shows and hides an element that depends on certain selected pages when setting up conditions.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Controller/Condition/Page/Dependence
- */
-
-import DomUtil from "../../../Dom/Util";
-import * as EventHandler from "../../../Event/Handler";
-
-const _pages: HTMLInputElement[] = Array.from(document.querySelectorAll('input[name="pageIDs[]"]'));
-const _dependentElements: HTMLElement[] = [];
-const _pageIds = new WeakMap<HTMLElement, number[]>();
-const _hiddenElements = new WeakMap<HTMLElement, HTMLElement[]>();
-
-let _didInit = false;
-
-/**
- * Checks if only relevant pages are selected. If that is the case, the dependent
- * element is shown, otherwise it is hidden.
- */
-function checkVisibility(): void {
- _dependentElements.forEach((dependentElement) => {
- const pageIds = _pageIds.get(dependentElement)!;
-
- const checkedPageIds: number[] = [];
- _pages.forEach((page) => {
- if (page.checked) {
- checkedPageIds.push(~~page.value);
- }
- });
-
- const irrelevantPageIds = checkedPageIds.filter((pageId) => pageIds.includes(pageId));
-
- if (!checkedPageIds.length || irrelevantPageIds.length) {
- hideDependentElement(dependentElement);
- } else {
- showDependentElement(dependentElement);
- }
- });
-
- EventHandler.fire("com.woltlab.wcf.pageConditionDependence", "checkVisivility");
-}
-
-/**
- * Hides all elements that depend on the given element.
- */
-function hideDependentElement(dependentElement: HTMLElement): void {
- DomUtil.hide(dependentElement);
-
- const hiddenElements = _hiddenElements.get(dependentElement)!;
- hiddenElements.forEach((hiddenElement) => DomUtil.hide(hiddenElement));
-
- _hiddenElements.set(dependentElement, []);
-}
-
-/**
- * Shows all elements that depend on the given element.
- */
-function showDependentElement(dependentElement: HTMLElement): void {
- DomUtil.show(dependentElement);
-
- // make sure that all parent elements are also visible
- let parentElement = dependentElement;
- while ((parentElement = parentElement.parentElement!) && parentElement) {
- if (DomUtil.isHidden(parentElement)) {
- _hiddenElements.get(dependentElement)!.push(parentElement);
- }
-
- DomUtil.show(parentElement);
- }
-}
-
-export function register(dependentElement: HTMLElement, pageIds: number[]): void {
- _dependentElements.push(dependentElement);
- _pageIds.set(dependentElement, pageIds);
- _hiddenElements.set(dependentElement, []);
-
- if (!_didInit) {
- _pages.forEach((page) => {
- page.addEventListener("change", () => checkVisibility());
- });
-
- _didInit = true;
- }
-
- // remove the dependent element before submit if it is hidden
- dependentElement.closest("form")!.addEventListener("submit", () => {
- if (DomUtil.isHidden(dependentElement)) {
- dependentElement.remove();
- }
- });
-
- checkVisibility();
-}
+++ /dev/null
-/**
- * Map route planner based on Google Maps.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Controller/Map/Route/Planner
- */
-
-import * as AjaxStatus from "../../../Ajax/Status";
-import * as Core from "../../../Core";
-import DomUtil from "../../../Dom/Util";
-import * as Language from "../../../Language";
-import UiDialog from "../../../Ui/Dialog";
-import { DialogCallbackObject, DialogCallbackSetup } from "../../../Ui/Dialog/Data";
-
-interface LocationData {
- label?: string;
- location: google.maps.LatLng;
-}
-
-class ControllerMapRoutePlanner implements DialogCallbackObject {
- private readonly button: HTMLElement;
- private readonly destination: google.maps.LatLng;
- private didInitDialog = false;
- private directionsRenderer?: google.maps.DirectionsRenderer = undefined;
- private directionsService?: google.maps.DirectionsService = undefined;
- private googleLink?: HTMLAnchorElement = undefined;
- private lastOrigin?: google.maps.LatLng = undefined;
- private map?: google.maps.Map = undefined;
- private originInput?: HTMLInputElement = undefined;
- private travelMode?: HTMLSelectElement = undefined;
-
- constructor(buttonId: string, destination: google.maps.LatLng) {
- const button = document.getElementById(buttonId);
- if (button === null) {
- throw new Error(`Unknown button with id '${buttonId}'`);
- }
- this.button = button;
-
- this.button.addEventListener("click", (ev) => this.openDialog(ev));
-
- this.destination = destination;
- }
-
- /**
- * Calculates the route based on the given result of a location search.
- */
- _calculateRoute(data: LocationData): void {
- const dialog = UiDialog.getDialog(this)!.dialog;
-
- if (data.label) {
- this.originInput!.value = data.label;
- }
-
- if (this.map === undefined) {
- const mapContainer = dialog.querySelector(".googleMap") as HTMLElement;
- this.map = new google.maps.Map(mapContainer, {
- disableDoubleClickZoom: window.WCF.Location.GoogleMaps.Settings.get("disableDoubleClickZoom"),
- draggable: window.WCF.Location.GoogleMaps.Settings.get("draggable"),
- mapTypeId: google.maps.MapTypeId.ROADMAP,
- scaleControl: window.WCF.Location.GoogleMaps.Settings.get("scaleControl"),
- scrollwheel: window.WCF.Location.GoogleMaps.Settings.get("scrollwheel"),
- });
-
- this.directionsService = new google.maps.DirectionsService();
- this.directionsRenderer = new google.maps.DirectionsRenderer();
-
- this.directionsRenderer.setMap(this.map);
- const directionsContainer = dialog.querySelector(".googleMapsDirections") as HTMLElement;
- this.directionsRenderer.setPanel(directionsContainer);
-
- this.googleLink = dialog.querySelector(".googleMapsDirectionsGoogleLink") as HTMLAnchorElement;
- }
-
- const request = {
- destination: this.destination,
- origin: data.location,
- provideRouteAlternatives: true,
- travelMode: google.maps.TravelMode[this.travelMode!.value.toUpperCase()],
- };
-
- AjaxStatus.show();
- this.directionsService!.route(request, (result, status) => this.setRoute(result, status));
-
- this.googleLink!.href = this.getGoogleMapsLink(data.location, this.travelMode!.value);
-
- this.lastOrigin = data.location;
- }
-
- /**
- * Returns the Google Maps link based on the given optional directions origin
- * and optional travel mode.
- */
- private getGoogleMapsLink(origin?: google.maps.LatLng, travelMode?: string): string {
- if (origin) {
- let link = `https://www.google.com/maps/dir/?api=1&origin=${origin.lat()},${origin.lng()}&destination=${this.destination.lat()},${this.destination.lng()}`;
-
- if (travelMode) {
- link += `&travelmode=${travelMode}`;
- }
-
- return link;
- }
-
- return `https://www.google.com/maps/search/?api=1&query=${this.destination.lat()},${this.destination.lng()}`;
- }
-
- /**
- * Initializes the route planning dialog.
- */
- private initDialog(): void {
- if (!this.didInitDialog) {
- const dialog = UiDialog.getDialog(this)!.dialog;
-
- // make input element a location search
- this.originInput = dialog.querySelector('input[name="origin"]') as HTMLInputElement;
- new window.WCF.Location.GoogleMaps.LocationSearch(this.originInput, (data) => this._calculateRoute(data));
-
- this.travelMode = dialog.querySelector('select[name="travelMode"]') as HTMLSelectElement;
- this.travelMode.addEventListener("change", this.updateRoute.bind(this));
-
- this.didInitDialog = true;
- }
- }
-
- /**
- * Opens the route planning dialog.
- */
- private openDialog(event: Event): void {
- event.preventDefault();
-
- UiDialog.open(this);
- }
-
- /**
- * Handles the response of the direction service.
- */
- private setRoute(result: google.maps.DirectionsResult, status: google.maps.DirectionsStatus): void {
- AjaxStatus.hide();
-
- if (status === "OK") {
- DomUtil.show(this.map!.getDiv().parentElement!);
-
- google.maps.event.trigger(this.map, "resize");
-
- this.directionsRenderer!.setDirections(result);
-
- DomUtil.show(this.travelMode!.closest("dl")!);
- DomUtil.show(this.googleLink!);
-
- DomUtil.innerError(this.originInput!, false);
- } else {
- // map irrelevant errors to not found error
- if (status !== "OVER_QUERY_LIMIT" && status !== "REQUEST_DENIED") {
- status = google.maps.DirectionsStatus.NOT_FOUND;
- }
-
- DomUtil.innerError(this.originInput!, Language.get(`wcf.map.route.error.${status.toLowerCase()}`));
- }
- }
-
- /**
- * Updates the route after the travel mode has been changed.
- */
- private updateRoute(): void {
- this._calculateRoute({
- location: this.lastOrigin!,
- });
- }
-
- /**
- * Sets up the route planner dialog.
- */
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: this.button.id + "Dialog",
- options: {
- onShow: this.initDialog.bind(this),
- title: Language.get("wcf.map.route.planner"),
- },
- source: `
-<div class="googleMapsDirectionsContainer" style="display: none;">
- <div class="googleMap"></div>
- <div class="googleMapsDirections"></div>
-</div>
-<small class="googleMapsDirectionsGoogleLinkContainer">
- <a href="${this.getGoogleMapsLink()}" class="googleMapsDirectionsGoogleLink" target="_blank" style="display: none;">${Language.get(
- "wcf.map.route.viewOnGoogleMaps",
- )}</a>
-</small>
-<dl>
- <dt>${Language.get("wcf.map.route.origin")}</dt>
- <dd>
- <input type="text" name="origin" class="long" autofocus>
- </dd>
-</dl>
-<dl style="display: none;">
- <dt>${Language.get("wcf.map.route.travelMode")}</dt>
- <dd>
- <select name="travelMode">
- <option value="driving">${Language.get("wcf.map.route.travelMode.driving")}</option>
- <option value="walking">${Language.get("wcf.map.route.travelMode.walking")}</option>
- <option value="bicycling">${Language.get("wcf.map.route.travelMode.bicycling")}</option>
- <option value="transit">${Language.get("wcf.map.route.travelMode.transit")}</option>
- </select>
- </dd>
-</dl>`,
- };
- }
-}
-
-Core.enableLegacyInheritance(ControllerMapRoutePlanner);
-
-export = ControllerMapRoutePlanner;
+++ /dev/null
-/**
- * Initializes modules required for media list view.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Controller/Media/List
- */
-
-import MediaListUpload from "../../Media/List/Upload";
-import * as MediaClipboard from "../../Media/Clipboard";
-import * as EventHandler from "../../Event/Handler";
-import MediaEditor from "../../Media/Editor";
-import * as DomChangeListener from "../../Dom/Change/Listener";
-import * as Clipboard from "../../Controller/Clipboard";
-import { Media, MediaUploadSuccessEventData } from "../../Media/Data";
-import MediaManager from "../../Media/Manager/Base";
-
-const _mediaEditor = new MediaEditor({
- _editorSuccess: (media: Media, oldCategoryId: number) => {
- if (media.categoryID != oldCategoryId) {
- window.setTimeout(() => {
- window.location.reload();
- }, 500);
- }
- },
-});
-const _tableBody = document.getElementById("mediaListTableBody")!;
-let _upload: MediaListUpload;
-
-interface MediaListOptions {
- categoryId?: number;
- hasMarkedItems?: boolean;
-}
-
-export function init(options: MediaListOptions): void {
- options = options || {};
- _upload = new MediaListUpload("uploadButton", "mediaListTableBody", {
- categoryId: options.categoryId,
- multiple: true,
- elementTagSize: 48,
- });
-
- MediaClipboard.init("wcf\\acp\\page\\MediaListPage", options.hasMarkedItems || false, {
- clipboardDeleteMedia: (mediaIds: number[]) => clipboardDeleteMedia(mediaIds),
- } as MediaManager);
-
- EventHandler.add("com.woltlab.wcf.media.upload", "removedErroneousUploadRow", () => deleteCallback());
-
- // eslint-disable-next-line
- //@ts-ignore
- const deleteAction = new WCF.Action.Delete("wcf\\data\\media\\MediaAction", ".jsMediaRow");
- deleteAction.setCallback(deleteCallback);
-
- addButtonEventListeners();
-
- DomChangeListener.add("WoltLabSuite/Core/Controller/Media/List", () => addButtonEventListeners());
-
- EventHandler.add("com.woltlab.wcf.media.upload", "success", (data: MediaUploadSuccessEventData) =>
- openEditorAfterUpload(data),
- );
-}
-
-/**
- * Adds the `click` event listeners to the media edit icons in new media table rows.
- */
-function addButtonEventListeners(): void {
- Array.from(_tableBody.getElementsByClassName("jsMediaEditButton")).forEach((button) => {
- button.classList.remove("jsMediaEditButton");
- button.addEventListener("click", (ev) => edit(ev));
- });
-}
-
-/**
- * Is triggered after media files have been deleted using the delete icon.
- */
-function deleteCallback(objectIds?: number[]): void {
- const tableRowCount = _tableBody.getElementsByTagName("tr").length;
- if (objectIds === undefined) {
- if (!tableRowCount) {
- window.location.reload();
- }
- } else if (objectIds.length === tableRowCount) {
- // table is empty, reload page
- window.location.reload();
- } else {
- Clipboard.reload();
- }
-}
-
-/**
- * Is called when a media edit icon is clicked.
- */
-function edit(event: Event): void {
- _mediaEditor.edit(~~(event.currentTarget as HTMLElement).dataset.objectId!);
-}
-
-/**
- * Opens the media editor after uploading a single file.
- */
-function openEditorAfterUpload(data: MediaUploadSuccessEventData) {
- if (data.upload === _upload && !data.isMultiFileUpload && !_upload.hasPendingUploads()) {
- const keys = Object.keys(data.media);
-
- if (keys.length) {
- _mediaEditor.edit(data.media[keys[0]]);
- }
- }
-}
-
-/**
- * Is called after the media files with the given ids have been deleted via clipboard.
- */
-function clipboardDeleteMedia(mediaIds: number[]) {
- Array.from(document.getElementsByClassName("jsMediaRow")).forEach((media) => {
- const mediaID = ~~(media.querySelector(".jsClipboardItem") as HTMLElement).dataset.objectId!;
-
- if (mediaIds.indexOf(mediaID) !== -1) {
- media.remove();
- }
- });
-
- if (!document.getElementsByClassName("jsMediaRow").length) {
- window.location.reload();
- }
-}
+++ /dev/null
-/**
- * Handles dismissible user notices.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Controller/Notice/Dismiss
- */
-
-import * as Ajax from "../../Ajax";
-
-/**
- * Initializes dismiss buttons.
- */
-export function setup(): void {
- document.querySelectorAll(".jsDismissNoticeButton").forEach((button) => {
- button.addEventListener("click", (ev) => click(ev));
- });
-}
-
-/**
- * Sends a request to dismiss a notice and removes it afterwards.
- */
-function click(event: Event): void {
- const button = event.currentTarget as HTMLElement;
-
- Ajax.apiOnce({
- data: {
- actionName: "dismiss",
- className: "wcf\\data\\notice\\NoticeAction",
- objectIDs: [button.dataset.objectId!],
- },
- success: () => {
- button.parentElement!.remove();
- },
- });
-}
+++ /dev/null
-/**
- * Versatile popover manager.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Controller/Popover
- */
-
-import * as Ajax from "../Ajax";
-import DomChangeListener from "../Dom/Change/Listener";
-import DomUtil from "../Dom/Util";
-import * as Environment from "../Environment";
-import * as UiAlignment from "../Ui/Alignment";
-import { AjaxCallbackObject, AjaxCallbackSetup, CallbackFailure, CallbackSuccess, RequestPayload } from "../Ajax/Data";
-
-const enum State {
- None,
- Loading,
- Ready,
-}
-
-const enum Delay {
- Hide = 500,
- Show = 800,
-}
-
-type CallbackLoad = (objectId: number | string, popover: ControllerPopover, element: HTMLElement) => void;
-
-interface PopoverOptions {
- attributeName?: string;
- className: string;
- dboAction: string;
- identifier: string;
- legacy?: boolean;
- loadCallback?: CallbackLoad;
-}
-
-interface HandlerData {
- attributeName: string;
- dboAction: string;
- legacy: boolean;
- loadCallback?: CallbackLoad;
- selector: string;
-}
-
-interface ElementData {
- element: HTMLElement;
- identifier: string;
- objectId: number | string;
-}
-
-interface CacheData {
- content: DocumentFragment | null;
- state: State;
-}
-
-class ControllerPopover implements AjaxCallbackObject {
- private activeId = "";
- private readonly cache = new Map<string, CacheData>();
- private readonly elements = new Map<string, ElementData>();
- private readonly handlers = new Map<string, HandlerData>();
- private hoverId = "";
- private readonly popover: HTMLDivElement;
- private readonly popoverContent: HTMLDivElement;
- private suspended = false;
- private timerEnter?: number = undefined;
- private timerLeave?: number = undefined;
-
- /**
- * Builds popover DOM elements and binds event listeners.
- */
- constructor() {
- this.popover = document.createElement("div");
- this.popover.className = "popover forceHide";
-
- this.popoverContent = document.createElement("div");
- this.popoverContent.className = "popoverContent";
- this.popover.appendChild(this.popoverContent);
-
- const pointer = document.createElement("span");
- pointer.className = "elementPointer";
- pointer.appendChild(document.createElement("span"));
- this.popover.appendChild(pointer);
-
- document.body.appendChild(this.popover);
-
- // event listener
- this.popover.addEventListener("mouseenter", () => this.popoverMouseEnter());
- this.popover.addEventListener("mouseleave", () => this.mouseLeave());
-
- this.popover.addEventListener("animationend", () => this.clearContent());
-
- window.addEventListener("beforeunload", () => {
- this.suspended = true;
-
- if (this.timerEnter) {
- window.clearTimeout(this.timerEnter);
- this.timerEnter = undefined;
- }
-
- this.hidePopover();
- });
-
- DomChangeListener.add("WoltLabSuite/Core/Controller/Popover", (identifier) => this.initHandler(identifier));
- }
-
- /**
- * Initializes a popover handler.
- *
- * Usage:
- *
- * ControllerPopover.init({
- * attributeName: 'data-object-id',
- * className: 'fooLink',
- * identifier: 'com.example.bar.foo',
- * loadCallback: (objectId, popover) => {
- * // request data for object id (e.g. via WoltLabSuite/Core/Ajax)
- *
- * // then call this to set the content
- * popover.setContent('com.example.bar.foo', objectId, htmlTemplateString);
- * }
- * });
- */
- init(options: PopoverOptions): void {
- if (Environment.platform() !== "desktop") {
- return;
- }
-
- options.attributeName = options.attributeName || "data-object-id";
- options.legacy = (options.legacy as unknown) === true;
-
- if (this.handlers.has(options.identifier)) {
- return;
- }
-
- // Legacy implementations provided a selector for `className`.
- const selector = options.legacy ? options.className : `.${options.className}`;
-
- this.handlers.set(options.identifier, {
- attributeName: options.attributeName,
- dboAction: options.dboAction,
- legacy: options.legacy,
- loadCallback: options.loadCallback,
- selector,
- });
-
- this.initHandler(options.identifier);
- }
-
- /**
- * Initializes a popover handler.
- */
- private initHandler(identifier?: string): void {
- if (typeof identifier === "string" && identifier.length) {
- this.initElements(this.handlers.get(identifier)!, identifier);
- } else {
- this.handlers.forEach((value, key) => {
- this.initElements(value, key);
- });
- }
- }
-
- /**
- * Binds event listeners for popover-enabled elements.
- */
- private initElements(options: HandlerData, identifier: string): void {
- document.querySelectorAll(options.selector).forEach((element: HTMLElement) => {
- const id = DomUtil.identify(element);
- if (this.cache.has(id)) {
- return;
- }
-
- // Skip elements that are located inside a popover.
- if (element.closest(".popover") !== null) {
- this.cache.set(id, {
- content: null,
- state: State.None,
- });
-
- return;
- }
-
- const objectId = options.legacy ? id : ~~element.getAttribute(options.attributeName)!;
- if (objectId === 0) {
- return;
- }
-
- element.addEventListener("mouseenter", (ev) => this.mouseEnter(ev));
- element.addEventListener("mouseleave", () => this.mouseLeave());
-
- if (element instanceof HTMLAnchorElement && element.href) {
- element.addEventListener("click", () => this.hidePopover());
- }
-
- const cacheId = `${identifier}-${objectId}`;
- element.dataset.cacheId = cacheId;
-
- this.elements.set(id, {
- element,
- identifier,
- objectId: objectId.toString(),
- });
-
- if (!this.cache.has(cacheId)) {
- this.cache.set(cacheId, {
- content: null,
- state: State.None,
- });
- }
- });
- }
-
- /**
- * Sets the content for given identifier and object id.
- */
- setContent(identifier: string, objectId: number | string, content: string): void {
- const cacheId = `${identifier}-${objectId}`;
- const data = this.cache.get(cacheId);
- if (data === undefined) {
- throw new Error(`Unable to find element for object id '${objectId}' (identifier: '${identifier}').`);
- }
-
- let fragment = DomUtil.createFragmentFromHtml(content);
- if (!fragment.childElementCount) {
- fragment = DomUtil.createFragmentFromHtml("<p>" + content + "</p>");
- }
-
- data.content = fragment;
- data.state = State.Ready;
-
- if (this.activeId) {
- const activeElement = this.elements.get(this.activeId)!.element;
-
- if (activeElement.dataset.cacheId === cacheId) {
- this.show();
- }
- }
- }
-
- /**
- * Handles the mouse start hovering the popover-enabled element.
- */
- private mouseEnter(event: MouseEvent): void {
- if (this.suspended) {
- return;
- }
-
- if (this.timerEnter) {
- window.clearTimeout(this.timerEnter);
- this.timerEnter = undefined;
- }
-
- const id = DomUtil.identify(event.currentTarget as HTMLElement);
- if (this.activeId === id && this.timerLeave) {
- window.clearTimeout(this.timerLeave);
- this.timerLeave = undefined;
- }
-
- this.hoverId = id;
-
- this.timerEnter = window.setTimeout(() => {
- this.timerEnter = undefined;
-
- if (this.hoverId === id) {
- this.show();
- }
- }, Delay.Show);
- }
-
- /**
- * Handles the mouse leaving the popover-enabled element or the popover itself.
- */
- private mouseLeave(): void {
- this.hoverId = "";
-
- if (this.timerLeave) {
- return;
- }
-
- this.timerLeave = window.setTimeout(() => this.hidePopover(), Delay.Hide);
- }
-
- /**
- * Handles the mouse start hovering the popover element.
- */
- private popoverMouseEnter(): void {
- if (this.timerLeave) {
- window.clearTimeout(this.timerLeave);
- this.timerLeave = undefined;
- }
- }
-
- /**
- * Shows the popover and loads content on-the-fly.
- */
- private show(): void {
- if (this.timerLeave) {
- window.clearTimeout(this.timerLeave);
- this.timerLeave = undefined;
- }
-
- let forceHide = false;
- if (this.popover.classList.contains("active")) {
- if (this.activeId !== this.hoverId) {
- this.hidePopover();
-
- forceHide = true;
- }
- } else if (this.popoverContent.childElementCount) {
- forceHide = true;
- }
-
- if (forceHide) {
- this.popover.classList.add("forceHide");
-
- // force layout
- //noinspection BadExpressionStatementJS
- this.popover.offsetTop;
-
- this.clearContent();
-
- this.popover.classList.remove("forceHide");
- }
-
- this.activeId = this.hoverId;
-
- const elementData = this.elements.get(this.activeId);
- // check if source element is already gone
- if (elementData === undefined) {
- return;
- }
-
- const cacheId = elementData.element.dataset.cacheId!;
- const data = this.cache.get(cacheId)!;
-
- switch (data.state) {
- case State.Ready: {
- this.popoverContent.appendChild(data.content!);
-
- this.rebuild();
-
- break;
- }
-
- case State.None: {
- data.state = State.Loading;
-
- const handler = this.handlers.get(elementData.identifier)!;
- if (handler.loadCallback) {
- handler.loadCallback(elementData.objectId, this, elementData.element);
- } else if (handler.dboAction) {
- const callback = (data) => {
- this.setContent(elementData.identifier, elementData.objectId, data.returnValues.template);
-
- return true;
- };
-
- this.ajaxApi(
- {
- actionName: "getPopover",
- className: handler.dboAction,
- interfaceName: "wcf\\data\\IPopoverAction",
- objectIDs: [elementData.objectId],
- },
- callback,
- callback,
- );
- }
-
- break;
- }
-
- case State.Loading: {
- // Do not interrupt inflight requests.
- break;
- }
- }
- }
-
- /**
- * Hides the popover element.
- */
- private hidePopover(): void {
- if (this.timerLeave) {
- window.clearTimeout(this.timerLeave);
- this.timerLeave = undefined;
- }
-
- this.popover.classList.remove("active");
- }
-
- /**
- * Clears popover content by moving it back into the cache.
- */
- private clearContent(): void {
- if (this.activeId && this.popoverContent.childElementCount && !this.popover.classList.contains("active")) {
- const cacheId = this.elements.get(this.activeId)!.element.dataset.cacheId!;
- const activeElData = this.cache.get(cacheId)!;
- while (this.popoverContent.childNodes.length) {
- activeElData.content!.appendChild(this.popoverContent.childNodes[0]);
- }
- }
- }
-
- /**
- * Rebuilds the popover.
- */
- private rebuild(): void {
- if (this.popover.classList.contains("active")) {
- return;
- }
-
- this.popover.classList.remove("forceHide");
- this.popover.classList.add("active");
-
- UiAlignment.set(this.popover, this.elements.get(this.activeId)!.element, {
- pointer: true,
- vertical: "top",
- });
- }
-
- _ajaxSuccess() {
- // This class was designed in a strange way without utilizing this method.
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- silent: true,
- };
- }
-
- /**
- * Sends an AJAX requests to the server, simple wrapper to reuse the request object.
- */
- ajaxApi(data: RequestPayload, success: CallbackSuccess, failure: CallbackFailure): void {
- if (typeof success !== "function") {
- throw new TypeError("Expected a valid callback for parameter 'success'.");
- }
-
- Ajax.api(this, data, success, failure);
- }
-}
-
-let controllerPopover: ControllerPopover;
-
-function getControllerPopover(): ControllerPopover {
- if (!controllerPopover) {
- controllerPopover = new ControllerPopover();
- }
-
- return controllerPopover;
-}
-
-/**
- * Initializes a popover handler.
- *
- * Usage:
- *
- * ControllerPopover.init({
- * attributeName: 'data-object-id',
- * className: 'fooLink',
- * identifier: 'com.example.bar.foo',
- * loadCallback: function(objectId, popover) {
- * // request data for object id (e.g. via WoltLabSuite/Core/Ajax)
- *
- * // then call this to set the content
- * popover.setContent('com.example.bar.foo', objectId, htmlTemplateString);
- * }
- * });
- */
-export function init(options: PopoverOptions): void {
- getControllerPopover().init(options);
-}
-
-/**
- * Sets the content for given identifier and object id.
- */
-export function setContent(identifier: string, objectId: number, content: string): void {
- getControllerPopover().setContent(identifier, objectId, content);
-}
-
-/**
- * Sends an AJAX requests to the server, simple wrapper to reuse the request object.
- */
-export function ajaxApi(data: RequestPayload, success: CallbackSuccess, failure: CallbackFailure): void {
- getControllerPopover().ajaxApi(data, success, failure);
-}
+++ /dev/null
-/**
- * Dialog based style changer.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Controller/Style/Changer
- */
-
-import * as Ajax from "../../Ajax";
-import * as Language from "../../Language";
-import UiDialog from "../../Ui/Dialog";
-import { DialogCallbackSetup } from "../../Ui/Dialog/Data";
-
-class ControllerStyleChanger {
- /**
- * Adds the style changer to the bottom navigation.
- */
- constructor() {
- document.querySelectorAll(".jsButtonStyleChanger").forEach((link: HTMLAnchorElement) => {
- link.addEventListener("click", (ev) => this.showDialog(ev));
- });
- }
-
- /**
- * Loads and displays the style change dialog.
- */
- showDialog(event: MouseEvent): void {
- event.preventDefault();
-
- UiDialog.open(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "styleChanger",
- options: {
- disableContentPadding: true,
- title: Language.get("wcf.style.changeStyle"),
- },
- source: {
- data: {
- actionName: "getStyleChooser",
- className: "wcf\\data\\style\\StyleAction",
- },
- after: (content) => {
- content.querySelectorAll(".styleList > li").forEach((style: HTMLLIElement) => {
- style.classList.add("pointer");
- style.addEventListener("click", (ev) => this.click(ev));
- });
- },
- },
- };
- }
-
- /**
- * Changes the style and reloads current page.
- */
- private click(event: MouseEvent): void {
- event.preventDefault();
-
- const listElement = event.currentTarget as HTMLLIElement;
-
- Ajax.apiOnce({
- data: {
- actionName: "changeStyle",
- className: "wcf\\data\\style\\StyleAction",
- objectIDs: [listElement.dataset.styleId],
- },
- success: function () {
- window.location.reload();
- },
- });
- }
-}
-
-let controllerStyleChanger: ControllerStyleChanger;
-
-/**
- * Adds the style changer to the bottom navigation.
- */
-export function setup(): void {
- if (!controllerStyleChanger) {
- new ControllerStyleChanger();
- }
-}
-
-/**
- * Loads and displays the style change dialog.
- */
-export function showDialog(event: MouseEvent): void {
- controllerStyleChanger.showDialog(event);
-}
+++ /dev/null
-/**
- * Handles email notification type for user notification settings.
- *
- * @author Alexander Ebert
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Controller/User/Notification/Settings
- */
-
-import * as Language from "../../../Language";
-import * as UiDropdownReusable from "../../../Ui/Dropdown/Reusable";
-
-let _dropDownMenu: HTMLUListElement;
-let _objectId = 0;
-
-function stateChange(event: Event): void {
- const checkbox = event.currentTarget as HTMLInputElement;
-
- const objectId = ~~checkbox.dataset.objectId!;
- const emailSettingsType = document.querySelector(`.notificationSettingsEmailType[data-object-id="${objectId}"]`);
- if (emailSettingsType !== null) {
- if (checkbox.checked) {
- emailSettingsType.classList.remove("disabled");
- } else {
- emailSettingsType.classList.add("disabled");
- }
- }
-}
-
-function click(event: Event): void {
- event.preventDefault();
-
- const button = event.currentTarget as HTMLAnchorElement;
- _objectId = ~~button.dataset.objectId!;
-
- createDropDown();
-
- setCurrentEmailType(getCurrentEmailTypeInputElement().value);
-
- showDropDown(button);
-}
-
-function createDropDown(): void {
- if (_dropDownMenu) {
- return;
- }
-
- _dropDownMenu = document.createElement("ul");
- _dropDownMenu.className = "dropdownMenu";
-
- ["instant", "daily", "divider", "none"].forEach((value) => {
- const listItem = document.createElement("li");
- if (value === "divider") {
- listItem.className = "dropdownDivider";
- } else {
- const link = document.createElement("a");
- link.href = "#";
- link.textContent = Language.get(`wcf.user.notification.mailNotificationType.${value}`);
- listItem.appendChild(link);
- listItem.dataset.value = value;
- listItem.addEventListener("click", (ev) => setEmailType(ev));
- }
-
- _dropDownMenu.appendChild(listItem);
- });
-
- UiDropdownReusable.init("UiNotificationSettingsEmailType", _dropDownMenu);
-}
-
-function setCurrentEmailType(currentValue: string): void {
- _dropDownMenu.querySelectorAll("li").forEach((button) => {
- const value = button.dataset.value!;
- if (value === currentValue) {
- button.classList.add("active");
- } else {
- button.classList.remove("active");
- }
- });
-}
-
-function showDropDown(referenceElement: HTMLAnchorElement): void {
- UiDropdownReusable.toggleDropdown("UiNotificationSettingsEmailType", referenceElement);
-}
-
-function setEmailType(event: Event): void {
- event.preventDefault();
-
- const listItem = event.currentTarget as HTMLLIElement;
- const value = listItem.dataset.value!;
-
- getCurrentEmailTypeInputElement().value = value;
-
- const button = document.querySelector(
- `.notificationSettingsEmailType[data-object-id="${_objectId}"]`,
- ) as HTMLLIElement;
- button.title = Language.get(`wcf.user.notification.mailNotificationType.${value}`);
-
- const icon = button.querySelector(".jsIconNotificationSettingsEmailType") as HTMLSpanElement;
- icon.classList.remove("fa-clock-o", "fa-flash", "fa-times", "green", "red");
-
- switch (value) {
- case "daily":
- icon.classList.add("fa-clock-o", "green");
- break;
-
- case "instant":
- icon.classList.add("fa-flash", "green");
- break;
-
- case "none":
- icon.classList.add("fa-times", "red");
- break;
- }
-
- _objectId = 0;
-}
-
-function getCurrentEmailTypeInputElement(): HTMLInputElement {
- return document.getElementById(`settings_${_objectId}_mailNotificationType`) as HTMLInputElement;
-}
-
-/**
- * Binds event listeners for all notifications supporting emails.
- */
-export function init(): void {
- document.querySelectorAll(".jsCheckboxNotificationSettingsState").forEach((checkbox) => {
- checkbox.addEventListener("change", (ev) => stateChange(ev));
- });
-
- document.querySelectorAll(".notificationSettingsEmailType").forEach((button) => {
- button.addEventListener("click", (ev) => click(ev));
- });
-}
+++ /dev/null
-/**
- * Provides the basic core functionality.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Core (alias)
- * @module WoltLabSuite/Core/Core
- */
-
-const _clone = function (variable: any): any {
- if (typeof variable === "object" && (Array.isArray(variable) || isPlainObject(variable))) {
- return _cloneObject(variable);
- }
-
- return variable;
-};
-
-const _cloneObject = function (obj: object | any[]): object | any[] | null {
- if (!obj) {
- return null;
- }
-
- if (Array.isArray(obj)) {
- return obj.slice();
- }
-
- const newObj = {};
- Object.keys(obj).forEach((key) => (newObj[key] = _clone(obj[key])));
-
- return newObj;
-};
-
-const _prefix = "wsc" + window.WCF_PATH.hashCode() + "-";
-
-/**
- * Deep clones an object.
- */
-export function clone(obj: object | any[]): object | any[] {
- return _clone(obj);
-}
-
-/**
- * Converts WCF 2.0-style URLs into the default URL layout.
- */
-export function convertLegacyUrl(url: string): string {
- return url.replace(/^index\.php\/(.*?)\/\?/, (match: string, controller: string) => {
- const parts = controller.split(/([A-Z][a-z0-9]+)/);
- controller = "";
- for (let i = 0, length = parts.length; i < length; i++) {
- const part = parts[i].trim();
- if (part.length) {
- if (controller.length) {
- controller += "-";
- }
- controller += part.toLowerCase();
- }
- }
-
- return `index.php?${controller}/&`;
- });
-}
-
-/**
- * Merges objects with the first argument.
- *
- * @param {object} out destination object
- * @param {...object} args variable number of objects to be merged into the destination object
- * @return {object} destination object with all provided objects merged into
- */
-export function extend(out: object, ...args: object[]): object {
- out = out || {};
- const newObj = clone(out);
-
- for (let i = 0, length = args.length; i < length; i++) {
- const obj = args[i];
-
- if (!obj) {
- continue;
- }
-
- Object.keys(obj).forEach((key) => {
- if (!Array.isArray(obj[key]) && typeof obj[key] === "object") {
- if (isPlainObject(obj[key])) {
- // object literals have the prototype of Object which in return has no parent prototype
- newObj[key] = extend(out[key], obj[key]);
- } else {
- newObj[key] = obj[key];
- }
- } else {
- newObj[key] = obj[key];
- }
- });
- }
-
- return newObj;
-}
-
-/**
- * Inherits the prototype methods from one constructor to another
- * constructor.
- *
- * Usage:
- *
- * function MyDerivedClass() {}
- * Core.inherit(MyDerivedClass, TheAwesomeBaseClass, {
- * // regular prototype for `MyDerivedClass`
- *
- * overwrittenMethodFromBaseClass: function(foo, bar) {
- * // do stuff
- *
- * // invoke parent
- * MyDerivedClass._super.prototype.overwrittenMethodFromBaseClass.call(this, foo, bar);
- * }
- * });
- *
- * @see https://github.com/nodejs/node/blob/7d14dd9b5e78faabb95d454a79faa513d0bbc2a5/lib/util.js#L697-L735
- * @deprecated 5.4 Use the native `class` and `extends` keywords instead.
- */
-export function inherit(constructor: new () => any, superConstructor: new () => any, propertiesObject: object): void {
- if (constructor === undefined || constructor === null) {
- throw new TypeError("The constructor must not be undefined or null.");
- }
- if (superConstructor === undefined || superConstructor === null) {
- throw new TypeError("The super constructor must not be undefined or null.");
- }
- if (superConstructor.prototype === undefined) {
- throw new TypeError("The super constructor must have a prototype.");
- }
-
- (constructor as any)._super = superConstructor;
- constructor.prototype = extend(
- Object.create(superConstructor.prototype, {
- constructor: {
- configurable: true,
- enumerable: false,
- value: constructor,
- writable: true,
- },
- }),
- propertiesObject || {},
- );
-}
-
-/**
- * Returns true if `obj` is an object literal.
- */
-export function isPlainObject(obj: unknown): boolean {
- if (typeof obj !== "object" || obj === null) {
- return false;
- }
-
- return Object.getPrototypeOf(obj) === Object.prototype;
-}
-
-/**
- * Returns the object's class name.
- */
-export function getType(obj: object): string {
- return Object.prototype.toString.call(obj).replace(/^\[object (.+)]$/, "$1");
-}
-
-/**
- * Returns a RFC4122 version 4 compilant UUID.
- *
- * @see http://stackoverflow.com/a/2117523
- */
-export function getUuid(): string {
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
- const r = (Math.random() * 16) | 0,
- v = c == "x" ? r : (r & 0x3) | 0x8;
- return v.toString(16);
- });
-}
-
-/**
- * Recursively serializes an object into an encoded URI parameter string.
- */
-export function serialize(obj: object, prefix?: string): string {
- if (obj === null) {
- return "";
- }
-
- const parameters: string[] = [];
- Object.keys(obj).forEach((key) => {
- const parameterKey = prefix ? prefix + "[" + key + "]" : key;
- const value = obj[key];
-
- if (typeof value === "object") {
- parameters.push(serialize(value, parameterKey));
- } else {
- parameters.push(encodeURIComponent(parameterKey) + "=" + encodeURIComponent(value));
- }
- });
-
- return parameters.join("&");
-}
-
-/**
- * Triggers a custom or built-in event.
- */
-export function triggerEvent(element: EventTarget, eventName: string): void {
- if (eventName === "click" && element instanceof HTMLElement) {
- element.click();
- return;
- }
-
- const event = new Event(eventName, {
- bubbles: true,
- cancelable: true,
- });
-
- element.dispatchEvent(event);
-}
-
-/**
- * Returns the unique prefix for the localStorage.
- */
-export function getStoragePrefix(): string {
- return _prefix;
-}
-
-/**
- * Interprets a string value as a boolean value similar to the behavior of the
- * legacy functions `elAttrBool()` and `elDataBool()`.
- */
-export function stringToBool(value: string | null): boolean {
- return value === "1" || value === "true";
-}
-
-type DebounceCallback = (...args: any[]) => void;
-
-interface DebounceOptions {
- isImmediate: boolean;
-}
-
-/**
- * A function that emits a side effect and does not return anything.
- *
- * @see https://github.com/chodorowicz/ts-debounce/blob/62f30f2c3379b7b5e778fb1793e1fbfa17354894/src/index.ts
- */
-export function debounce<F extends DebounceCallback>(
- func: F,
- waitMilliseconds = 50,
- options: DebounceOptions = {
- isImmediate: false,
- },
-): (this: ThisParameterType<F>, ...args: Parameters<F>) => void {
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
-
- return function (this: ThisParameterType<F>, ...args: Parameters<F>) {
- const doLater = () => {
- timeoutId = undefined;
- if (!options.isImmediate) {
- func.apply(this, args);
- }
- };
-
- const shouldCallNow = options.isImmediate && timeoutId === undefined;
-
- if (timeoutId !== undefined) {
- clearTimeout(timeoutId);
- }
-
- timeoutId = setTimeout(doLater, waitMilliseconds);
-
- if (shouldCallNow) {
- func.apply(this, args);
- }
- };
-}
-
-export function enableLegacyInheritance<T>(legacyClass: T): void {
- (legacyClass as any).call = function (thisValue, ...args) {
- const constructed = Reflect.construct(legacyClass as any, args, thisValue.constructor);
- Object.entries(constructed).forEach(([key, value]) => {
- thisValue[key] = value;
- });
- };
-}
+++ /dev/null
-/**
- * Date picker with time support.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Date/Picker
- */
-
-import * as Core from "../Core";
-import * as DateUtil from "./Util";
-import DomChangeListener from "../Dom/Change/Listener";
-import * as EventHandler from "../Event/Handler";
-import * as Language from "../Language";
-import * as UiAlignment from "../Ui/Alignment";
-import UiCloseOverlay from "../Ui/CloseOverlay";
-import DomUtil from "../Dom/Util";
-
-let _didInit = false;
-let _firstDayOfWeek = 0;
-let _wasInsidePicker = false;
-
-const _data = new Map<HTMLInputElement, DatePickerData>();
-let _input: HTMLInputElement | null = null;
-let _maxDate: Date;
-let _minDate: Date;
-
-const _dateCells: HTMLAnchorElement[] = [];
-let _dateGrid: HTMLUListElement;
-let _dateHour: HTMLSelectElement;
-let _dateMinute: HTMLSelectElement;
-let _dateMonth: HTMLSelectElement;
-let _dateMonthNext: HTMLAnchorElement;
-let _dateMonthPrevious: HTMLAnchorElement;
-let _dateTime: HTMLElement;
-let _dateYear: HTMLSelectElement;
-let _datePicker: HTMLElement | null = null;
-
-/**
- * Creates the date picker DOM.
- */
-function createPicker() {
- if (_datePicker !== null) {
- return;
- }
-
- _datePicker = document.createElement("div");
- _datePicker.className = "datePicker";
- _datePicker.addEventListener("click", (event) => {
- event.stopPropagation();
- });
-
- const header = document.createElement("header");
- _datePicker.appendChild(header);
-
- _dateMonthPrevious = document.createElement("a");
- _dateMonthPrevious.className = "previous jsTooltip";
- _dateMonthPrevious.href = "#";
- _dateMonthPrevious.setAttribute("role", "button");
- _dateMonthPrevious.tabIndex = 0;
- _dateMonthPrevious.title = Language.get("wcf.date.datePicker.previousMonth");
- _dateMonthPrevious.setAttribute("aria-label", Language.get("wcf.date.datePicker.previousMonth"));
- _dateMonthPrevious.innerHTML = '<span class="icon icon16 fa-arrow-left"></span>';
- _dateMonthPrevious.addEventListener("click", (ev) => DatePicker.previousMonth(ev));
- header.appendChild(_dateMonthPrevious);
-
- const monthYearContainer = document.createElement("span");
- header.appendChild(monthYearContainer);
-
- _dateMonth = document.createElement("select");
- _dateMonth.className = "month jsTooltip";
- _dateMonth.title = Language.get("wcf.date.datePicker.month");
- _dateMonth.setAttribute("aria-label", Language.get("wcf.date.datePicker.month"));
- _dateMonth.addEventListener("change", changeMonth);
- monthYearContainer.appendChild(_dateMonth);
-
- let months = "";
- const monthNames = Language.get("__monthsShort");
- for (let i = 0; i < 12; i++) {
- months += `<option value="${i}">${monthNames[i]}</option>`;
- }
- _dateMonth.innerHTML = months;
-
- _dateYear = document.createElement("select");
- _dateYear.className = "year jsTooltip";
- _dateYear.title = Language.get("wcf.date.datePicker.year");
- _dateYear.setAttribute("aria-label", Language.get("wcf.date.datePicker.year"));
- _dateYear.addEventListener("change", changeYear);
- monthYearContainer.appendChild(_dateYear);
-
- _dateMonthNext = document.createElement("a");
- _dateMonthNext.className = "next jsTooltip";
- _dateMonthNext.href = "#";
- _dateMonthNext.setAttribute("role", "button");
- _dateMonthNext.tabIndex = 0;
- _dateMonthNext.title = Language.get("wcf.date.datePicker.nextMonth");
- _dateMonthNext.setAttribute("aria-label", Language.get("wcf.date.datePicker.nextMonth"));
- _dateMonthNext.innerHTML = '<span class="icon icon16 fa-arrow-right"></span>';
- _dateMonthNext.addEventListener("click", (ev) => DatePicker.nextMonth(ev));
- header.appendChild(_dateMonthNext);
-
- _dateGrid = document.createElement("ul");
- _datePicker.appendChild(_dateGrid);
-
- const item = document.createElement("li");
- item.className = "weekdays";
- _dateGrid.appendChild(item);
-
- const weekdays = Language.get("__daysShort");
- for (let i = 0; i < 7; i++) {
- let day = i + _firstDayOfWeek;
- if (day > 6) {
- day -= 7;
- }
-
- const span = document.createElement("span");
- span.textContent = weekdays[day];
- item.appendChild(span);
- }
-
- // create date grid
- for (let i = 0; i < 6; i++) {
- const row = document.createElement("li");
- _dateGrid.appendChild(row);
-
- for (let j = 0; j < 7; j++) {
- const cell = document.createElement("a");
- cell.addEventListener("click", click);
- _dateCells.push(cell);
-
- row.appendChild(cell);
- }
- }
-
- _dateTime = document.createElement("footer");
- _datePicker.appendChild(_dateTime);
-
- _dateHour = document.createElement("select");
- _dateHour.className = "hour";
- _dateHour.title = Language.get("wcf.date.datePicker.hour");
- _dateHour.setAttribute("aria-label", Language.get("wcf.date.datePicker.hour"));
- _dateHour.addEventListener("change", formatValue);
-
- const date = new Date(2000, 0, 1);
- const timeFormat = Language.get("wcf.date.timeFormat").replace(/:/, "").replace(/[isu]/g, "");
- let tmp = "";
- for (let i = 0; i < 24; i++) {
- date.setHours(i);
-
- const value = DateUtil.format(date, timeFormat);
- tmp += `<option value="${i}">${value}</option>`;
- }
- _dateHour.innerHTML = tmp;
-
- _dateTime.appendChild(_dateHour);
-
- _dateTime.appendChild(document.createTextNode("\u00A0:\u00A0"));
-
- _dateMinute = document.createElement("select");
- _dateMinute.className = "minute";
- _dateMinute.title = Language.get("wcf.date.datePicker.minute");
- _dateMinute.setAttribute("aria-label", Language.get("wcf.date.datePicker.minute"));
- _dateMinute.addEventListener("change", formatValue);
-
- tmp = "";
- for (let i = 0; i < 60; i++) {
- const value = i < 10 ? "0" + i.toString() : i;
- tmp += `<option value="${i}">${value}</option>`;
- }
- _dateMinute.innerHTML = tmp;
-
- _dateTime.appendChild(_dateMinute);
-
- document.body.appendChild(_datePicker);
-
- document.body.addEventListener("focus", maintainFocus, { capture: true });
-}
-
-/**
- * Initializes the minimum/maximum date range.
- */
-function initDateRange(element: HTMLInputElement, now: Date, isMinDate: boolean): void {
- const name = isMinDate ? "minDate" : "maxDate";
- let value = (element.dataset[name] || "").trim();
-
- if (/^(\d{4})-(\d{2})-(\d{2})$/.exec(value)) {
- // YYYY-mm-dd
- value = new Date(value).getTime().toString();
- } else if (value === "now") {
- value = now.getTime().toString();
- } else if (/^\d{1,3}$/.exec(value)) {
- // relative time span in years
- const date = new Date(now.getTime());
- date.setFullYear(date.getFullYear() + ~~value * (isMinDate ? -1 : 1));
-
- value = date.getTime().toString();
- } else if (/^datePicker-(.+)$/.exec(value)) {
- // element id, e.g. `datePicker-someOtherElement`
- value = RegExp.$1;
-
- if (document.getElementById(value) === null) {
- throw new Error(
- "Reference date picker identified by '" + value + "' does not exists (element id: '" + element.id + "').",
- );
- }
- } else if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
- value = new Date(value).getTime().toString();
- } else {
- value = new Date(isMinDate ? 1902 : 2038, 0, 1).getTime().toString();
- }
-
- element.dataset[name] = value;
-}
-
-/**
- * Sets up callbacks and event listeners.
- */
-function setup() {
- if (_didInit) {
- return;
- }
- _didInit = true;
-
- _firstDayOfWeek = parseInt(Language.get("wcf.date.firstDayOfTheWeek"), 10);
-
- DomChangeListener.add("WoltLabSuite/Core/Date/Picker", () => DatePicker.init());
- UiCloseOverlay.add("WoltLabSuite/Core/Date/Picker", () => close());
-}
-
-function getDateValue(attributeName: string): Date {
- let date = _input!.dataset[attributeName] || "";
- if (/^datePicker-(.+)$/.exec(date)) {
- const referenceElement = document.getElementById(RegExp.$1);
- if (referenceElement === null) {
- throw new Error(`Unable to find an element with the id '${RegExp.$1}'.`);
- }
- date = referenceElement.dataset.value || "";
- }
-
- return new Date(parseInt(date, 10));
-}
-
-/**
- * Opens the date picker.
- */
-function open(event: MouseEvent): void {
- event.preventDefault();
- event.stopPropagation();
-
- createPicker();
-
- const target = event.currentTarget as HTMLInputElement;
- const input = target.nodeName === "INPUT" ? target : (target.previousElementSibling as HTMLInputElement);
- if (input === _input) {
- close();
- return;
- }
-
- const dialogContent = input.closest(".dialogContent") as HTMLElement;
- if (dialogContent !== null) {
- if (!Core.stringToBool(dialogContent.dataset.hasDatepickerScrollListener || "")) {
- dialogContent.addEventListener("scroll", onDialogScroll);
- dialogContent.dataset.hasDatepickerScrollListener = "1";
- }
- }
-
- _input = input;
- const data = _data.get(_input) as DatePickerData;
- const value = _input.dataset.value!;
- let date: Date;
- if (value) {
- date = new Date(parseInt(value, 10));
-
- if (date.toString() === "Invalid Date") {
- date = new Date();
- }
- } else {
- date = new Date();
- }
-
- // set min/max date
- _minDate = getDateValue("minDate");
- if (_minDate.getTime() > date.getTime()) {
- date = _minDate;
- }
-
- _maxDate = getDateValue("maxDate");
-
- if (data.isDateTime) {
- _dateHour.value = date.getHours().toString();
- _dateMinute.value = date.getMinutes().toString();
-
- _datePicker!.classList.add("datePickerTime");
- } else {
- _datePicker!.classList.remove("datePickerTime");
- }
-
- _datePicker!.classList[data.isTimeOnly ? "add" : "remove"]("datePickerTimeOnly");
-
- renderPicker(date.getDate(), date.getMonth(), date.getFullYear());
-
- UiAlignment.set(_datePicker!, _input);
-
- _input.nextElementSibling!.setAttribute("aria-expanded", "true");
-
- _wasInsidePicker = false;
-}
-
-/**
- * Closes the date picker.
- */
-function close() {
- if (_datePicker === null || !_datePicker.classList.contains("active")) {
- return;
- }
-
- _datePicker.classList.remove("active");
-
- const data = _data.get(_input!) as DatePickerData;
- if (typeof data.onClose === "function") {
- data.onClose();
- }
-
- EventHandler.fire("WoltLabSuite/Core/Date/Picker", "close", { element: _input });
-
- const sibling = _input!.nextElementSibling as HTMLElement;
- sibling.setAttribute("aria-expanded", "false");
- _input = null;
-}
-
-/**
- * Updates the position of the date picker in a dialog if the dialog content
- * is scrolled.
- */
-function onDialogScroll(event: WheelEvent): void {
- if (_input === null) {
- return;
- }
-
- const dialogContent = event.currentTarget as HTMLElement;
-
- const offset = DomUtil.offset(_input);
- const dialogOffset = DomUtil.offset(dialogContent);
-
- // check if date picker input field is still (partially) visible
- if (offset.top + _input.clientHeight <= dialogOffset.top) {
- // top check
- close();
- } else if (offset.top >= dialogOffset.top + dialogContent.offsetHeight) {
- // bottom check
- close();
- } else if (offset.left <= dialogOffset.left) {
- // left check
- close();
- } else if (offset.left >= dialogOffset.left + dialogContent.offsetWidth) {
- // right check
- close();
- } else {
- UiAlignment.set(_datePicker!, _input);
- }
-}
-
-/**
- * Renders the full picker on init.
- */
-function renderPicker(day: number, month: number, year: number): void {
- renderGrid(day, month, year);
-
- // create options for month and year
- let years = "";
- for (let i = _minDate.getFullYear(), last = _maxDate.getFullYear(); i <= last; i++) {
- years += `<option value="${i}">${i}</option>`;
- }
- _dateYear.innerHTML = years;
- _dateYear.value = year.toString();
-
- _dateMonth.value = month.toString();
-
- _datePicker!.classList.add("active");
-}
-
-/**
- * Updates the date grid.
- */
-function renderGrid(day?: number, month?: number, year?: number): void {
- const hasDay = day !== undefined;
- const hasMonth = month !== undefined;
-
- if (typeof day !== "number") {
- day = parseInt(day || _dateGrid.dataset.day || "0", 10);
- }
- if (typeof month !== "number") {
- month = parseInt(month || "0", 10);
- }
- if (typeof year !== "number") {
- year = parseInt(year || "0", 10);
- }
-
- // rebuild cells
- if (hasMonth || year) {
- let rebuildMonths = year !== 0;
-
- // rebuild grid
- const fragment = document.createDocumentFragment();
- fragment.appendChild(_dateGrid);
-
- if (!hasMonth) {
- month = parseInt(_dateGrid.dataset.month!, 10);
- }
- if (!year) {
- year = parseInt(_dateGrid.dataset.year!, 10);
- }
-
- // check if current selection exceeds min/max date
- let date = new Date(
- year.toString() + "-" + ("0" + (month + 1).toString()).slice(-2) + "-" + ("0" + day.toString()).slice(-2),
- );
- if (date < _minDate) {
- year = _minDate.getFullYear();
- month = _minDate.getMonth();
- day = _minDate.getDate();
-
- _dateMonth.value = month.toString();
- _dateYear.value = year.toString();
-
- rebuildMonths = true;
- } else if (date > _maxDate) {
- year = _maxDate.getFullYear();
- month = _maxDate.getMonth();
- day = _maxDate.getDate();
-
- _dateMonth.value = month.toString();
- _dateYear.value = year.toString();
-
- rebuildMonths = true;
- }
-
- date = new Date(year.toString() + "-" + ("0" + (month + 1).toString()).slice(-2) + "-01");
-
- // shift until first displayed day equals first day of week
- while (date.getDay() !== _firstDayOfWeek) {
- date.setDate(date.getDate() - 1);
- }
-
- // show the last row
- DomUtil.show(_dateCells[35].parentNode as HTMLElement);
-
- let selectable: boolean;
- const comparableMinDate = new Date(_minDate.getFullYear(), _minDate.getMonth(), _minDate.getDate());
- for (let i = 0; i < 42; i++) {
- if (i === 35 && date.getMonth() !== month) {
- // skip the last row if it only contains the next month
- DomUtil.hide(_dateCells[35].parentNode as HTMLElement);
-
- break;
- }
-
- const cell = _dateCells[i];
-
- cell.textContent = date.getDate().toString();
- selectable = date.getMonth() === month;
- if (selectable) {
- if (date < comparableMinDate) {
- selectable = false;
- } else if (date > _maxDate) {
- selectable = false;
- }
- }
-
- cell.classList[selectable ? "remove" : "add"]("otherMonth");
- if (selectable) {
- cell.href = "#";
- cell.setAttribute("role", "button");
- cell.tabIndex = 0;
- cell.title = DateUtil.formatDate(date);
- cell.setAttribute("aria-label", DateUtil.formatDate(date));
- }
-
- date.setDate(date.getDate() + 1);
- }
-
- _dateGrid.dataset.month = month.toString();
- _dateGrid.dataset.year = year.toString();
-
- _datePicker!.insertBefore(fragment, _dateTime);
-
- if (!hasDay) {
- // check if date is valid
- date = new Date(year, month, day);
- if (date.getDate() !== day) {
- while (date.getMonth() !== month) {
- date.setDate(date.getDate() - 1);
- }
-
- day = date.getDate();
- }
- }
-
- if (rebuildMonths) {
- for (let i = 0; i < 12; i++) {
- const currentMonth = _dateMonth.children[i] as HTMLOptionElement;
-
- currentMonth.disabled =
- (year === _minDate.getFullYear() && +currentMonth.value < _minDate.getMonth()) ||
- (year === _maxDate.getFullYear() && +currentMonth.value > _maxDate.getMonth());
- }
-
- const nextMonth = new Date(year.toString() + "-" + ("0" + (month + 1).toString()).slice(-2) + "-01");
- nextMonth.setMonth(nextMonth.getMonth() + 1);
-
- _dateMonthNext.classList[nextMonth < _maxDate ? "add" : "remove"]("active");
-
- const previousMonth = new Date(year.toString() + "-" + ("0" + (month + 1).toString()).slice(-2) + "-01");
- previousMonth.setDate(previousMonth.getDate() - 1);
-
- _dateMonthPrevious.classList[previousMonth > _minDate ? "add" : "remove"]("active");
- }
- }
-
- // update active day
- if (day) {
- for (let i = 0; i < 35; i++) {
- const cell = _dateCells[i];
-
- cell.classList[!cell.classList.contains("otherMonth") && +cell.textContent! === day ? "add" : "remove"]("active");
- }
-
- _dateGrid.dataset.day = day.toString();
- }
-
- formatValue();
-}
-
-/**
- * Sets the visible and shadow value
- */
-function formatValue(): void {
- const data = _data.get(_input!) as DatePickerData;
- let date: Date;
-
- if (Core.stringToBool(_input!.dataset.empty || "")) {
- return;
- }
-
- if (data.isDateTime) {
- date = new Date(
- +_dateGrid.dataset.year!,
- +_dateGrid.dataset.month!,
- +_dateGrid.dataset.day!,
- +_dateHour.value,
- +_dateMinute.value,
- );
- } else {
- date = new Date(+_dateGrid.dataset.year!, +_dateGrid.dataset.month!, +_dateGrid.dataset.day!);
- }
-
- DatePicker.setDate(_input!, date);
-}
-
-/**
- * Handles changes to the month select element.
- */
-function changeMonth(event: Event): void {
- const target = event.currentTarget as HTMLSelectElement;
- renderGrid(undefined, +target.value);
-}
-
-/**
- * Handles changes to the year select element.
- */
-function changeYear(event: Event): void {
- const target = event.currentTarget as HTMLSelectElement;
- renderGrid(undefined, undefined, +target.value);
-}
-
-/**
- * Handles clicks on an individual day.
- */
-function click(event: MouseEvent): void {
- event.preventDefault();
-
- const target = event.currentTarget as HTMLAnchorElement;
- if (target.classList.contains("otherMonth")) {
- return;
- }
-
- _input!.dataset.empty = "false";
-
- renderGrid(+target.textContent!);
-
- const data = _data.get(_input!) as DatePickerData;
- if (!data.isDateTime) {
- close();
- }
-}
-
-/**
- * Validates given element or id if it represents an active date picker.
- */
-function getElement(element: InputElementOrString): HTMLInputElement {
- if (typeof element === "string") {
- element = document.getElementById(element) as HTMLInputElement;
- }
-
- if (!(element instanceof HTMLInputElement) || !element.classList.contains("inputDatePicker") || !_data.has(element)) {
- throw new Error("Expected a valid date picker input element or id.");
- }
-
- return element;
-}
-
-function maintainFocus(event: FocusEvent): void {
- if (_datePicker === null || !_datePicker.classList.contains("active")) {
- return;
- }
-
- if (!_datePicker.contains(event.target as HTMLElement)) {
- if (_wasInsidePicker) {
- const sibling = _input!.nextElementSibling as HTMLElement;
- sibling.focus();
- _wasInsidePicker = false;
- } else {
- _datePicker.querySelector<HTMLElement>(".previous")!.focus();
- }
- } else {
- _wasInsidePicker = true;
- }
-}
-
-const DatePicker = {
- /**
- * Initializes all date and datetime input fields.
- */
- init(): void {
- setup();
-
- const now = new Date();
- document
- .querySelectorAll<HTMLInputElement>(
- 'input[type="date"]:not(.inputDatePicker), input[type="datetime"]:not(.inputDatePicker)',
- )
- .forEach((element) => {
- element.classList.add("inputDatePicker");
- element.readOnly = true;
-
- // Use `getAttribute()`, because `.type` is normalized to "text" for unknown values.
- const isDateTime = element.getAttribute("type") === "datetime";
- const isTimeOnly = isDateTime && Core.stringToBool(element.dataset.timeOnly || "");
- const disableClear = Core.stringToBool(element.dataset.disableClear || "");
- const ignoreTimezone = isDateTime && Core.stringToBool(element.dataset.ignoreTimezone || "");
- const isBirthday = element.classList.contains("birthday");
-
- element.dataset.isDateTime = isDateTime ? "true" : "false";
- element.dataset.isTimeOnly = isTimeOnly ? "true" : "false";
-
- // convert value
- let date: Date | null = null;
- let value = element.value;
- if (!value) {
- // Some legacy code may incorrectly use `setAttribute("value", value)`.
- value = element.getAttribute("value") || "";
- }
-
- // ignore the timezone, if the value is only a date (YYYY-MM-DD)
- const isDateOnly = /^\d+-\d+-\d+$/.test(value);
-
- if (value) {
- if (isTimeOnly) {
- date = new Date();
- const tmp = value.split(":");
- date.setHours(+tmp[0], +tmp[1]);
- } else {
- if (ignoreTimezone || isBirthday || isDateOnly) {
- let timezoneOffset = new Date(value).getTimezoneOffset();
- let timezone = timezoneOffset > 0 ? "-" : "+"; // -120 equals GMT+0200
- timezoneOffset = Math.abs(timezoneOffset);
-
- const hours = Math.floor(timezoneOffset / 60).toString();
- const minutes = (timezoneOffset % 60).toString();
- timezone += hours.length === 2 ? hours : "0" + hours;
- timezone += ":";
- timezone += minutes.length === 2 ? minutes : "0" + minutes;
-
- if (isBirthday || isDateOnly) {
- value += "T00:00:00" + timezone;
- } else {
- value = value.replace(/[+-][0-9]{2}:[0-9]{2}$/, timezone);
- }
- }
-
- date = new Date(value);
- }
-
- const time = date.getTime();
-
- // check for invalid dates
- if (isNaN(time)) {
- value = "";
- } else {
- element.dataset.value = time.toString();
- if (isTimeOnly) {
- value = DateUtil.formatTime(date);
- } else {
- if (isDateTime) {
- value = DateUtil.formatDateTime(date);
- } else {
- value = DateUtil.formatDate(date);
- }
- }
- }
- }
-
- const isEmpty = value.length === 0;
-
- // handle birthday input
- if (isBirthday) {
- element.dataset.minDate = "120";
-
- // do not use 'now' here, all though it makes sense, it causes bad UX
- element.dataset.maxDate = new Date().getFullYear().toString() + "-12-31";
- } else {
- if (element.min) {
- element.dataset.minDate = element.min;
- }
- if (element.max) {
- element.dataset.maxDate = element.max;
- }
- }
-
- initDateRange(element, now, true);
- initDateRange(element, now, false);
-
- if ((element.dataset.minDate || "") === (element.dataset.maxDate || "")) {
- throw new Error("Minimum and maximum date cannot be the same (element id '" + element.id + "').");
- }
-
- // change type to prevent browser's datepicker to trigger
- element.type = "text";
- element.value = value;
- element.dataset.empty = isEmpty ? "true" : "false";
-
- const placeholder = element.dataset.placeholder || "";
- if (placeholder) {
- element.placeholder = placeholder;
- }
-
- // add a hidden element to hold the actual date
- const shadowElement = document.createElement("input");
- shadowElement.id = element.id + "DatePicker";
- shadowElement.name = element.name;
- shadowElement.type = "hidden";
-
- if (date !== null) {
- if (isTimeOnly) {
- shadowElement.value = DateUtil.format(date, "H:i");
- } else if (ignoreTimezone) {
- shadowElement.value = DateUtil.format(date, "Y-m-dTH:i:s");
- } else {
- shadowElement.value = DateUtil.format(date, isDateTime ? "c" : "Y-m-d");
- }
- }
-
- element.parentNode!.insertBefore(shadowElement, element);
- element.removeAttribute("name");
-
- element.addEventListener("click", open);
-
- let clearButton: HTMLAnchorElement | null = null;
- if (!element.disabled) {
- // create input addon
- const container = document.createElement("div");
- container.className = "inputAddon";
-
- clearButton = document.createElement("a");
-
- clearButton.className = "inputSuffix button jsTooltip";
- clearButton.href = "#";
- clearButton.setAttribute("role", "button");
- clearButton.tabIndex = 0;
- clearButton.title = Language.get("wcf.date.datePicker");
- clearButton.setAttribute("aria-label", Language.get("wcf.date.datePicker"));
- clearButton.setAttribute("aria-haspopup", "true");
- clearButton.setAttribute("aria-expanded", "false");
- clearButton.addEventListener("click", open);
- container.appendChild(clearButton);
-
- let icon = document.createElement("span");
- icon.className = "icon icon16 fa-calendar";
- clearButton.appendChild(icon);
-
- element.parentNode!.insertBefore(container, element);
- container.insertBefore(element, clearButton);
-
- if (!disableClear) {
- const button = document.createElement("a");
- button.className = "inputSuffix button";
- button.addEventListener("click", this.clear.bind(this, element));
- if (isEmpty) {
- button.style.setProperty("visibility", "hidden", "");
- }
-
- container.appendChild(button);
-
- icon = document.createElement("span");
- icon.className = "icon icon16 fa-times";
- button.appendChild(icon);
- }
- }
-
- // check if the date input has one of the following classes set otherwise default to 'short'
- const knownClasses = ["tiny", "short", "medium", "long"];
- let hasClass = false;
- for (let j = 0; j < 4; j++) {
- if (element.classList.contains(knownClasses[j])) {
- hasClass = true;
- }
- }
-
- if (!hasClass) {
- element.classList.add("short");
- }
-
- _data.set(element, {
- clearButton,
- shadow: shadowElement,
-
- disableClear,
- isDateTime,
- isEmpty,
- isTimeOnly,
- ignoreTimezone,
-
- onClose: null,
- });
- });
- },
-
- /**
- * Shows the previous month.
- */
- previousMonth(event: MouseEvent): void {
- event.preventDefault();
-
- if (_dateMonth.value === "0") {
- _dateMonth.value = "11";
- _dateYear.value = (+_dateYear.value - 1).toString();
- } else {
- _dateMonth.value = (+_dateMonth.value - 1).toString();
- }
-
- renderGrid(undefined, +_dateMonth.value, +_dateYear.value);
- },
-
- /**
- * Shows the next month.
- */
- nextMonth(event: MouseEvent): void {
- event.preventDefault();
-
- if (_dateMonth.value === "11") {
- _dateMonth.value = "0";
- _dateYear.value = (+_dateYear.value + 1).toString();
- } else {
- _dateMonth.value = (+_dateMonth.value + 1).toString();
- }
-
- renderGrid(undefined, +_dateMonth.value, +_dateYear.value);
- },
-
- /**
- * Returns the current Date object or null.
- */
- getDate(element: InputElementOrString): Date | null {
- element = getElement(element);
-
- const value = element.dataset.value || "";
- if (value) {
- return new Date(+value);
- }
-
- return null;
- },
-
- /**
- * Sets the date of given element.
- *
- * @param {(HTMLInputElement|string)} element input element or id
- * @param {Date} date Date object
- */
- setDate(element: InputElementOrString, date: Date): void {
- element = getElement(element);
- const data = _data.get(element) as DatePickerData;
-
- element.dataset.value = date.getTime().toString();
-
- let format = "";
- let value: string;
- if (data.isDateTime) {
- if (data.isTimeOnly) {
- value = DateUtil.formatTime(date);
- format = "H:i";
- } else if (data.ignoreTimezone) {
- value = DateUtil.formatDateTime(date);
- format = "Y-m-dTH:i:s";
- } else {
- value = DateUtil.formatDateTime(date);
- format = "c";
- }
- } else {
- value = DateUtil.formatDate(date);
- format = "Y-m-d";
- }
-
- element.value = value;
- data.shadow.value = DateUtil.format(date, format);
-
- // show clear button
- if (!data.disableClear) {
- data.clearButton!.style.removeProperty("visibility");
- }
- },
-
- /**
- * Returns the current value.
- */
- getValue(element: InputElementOrString): string {
- element = getElement(element);
- const data = _data.get(element);
-
- if (data) {
- return data.shadow.value;
- }
-
- return "";
- },
-
- /**
- * Clears the date value of given element.
- */
- clear(element: InputElementOrString): void {
- element = getElement(element);
- const data = _data.get(element) as DatePickerData;
-
- element.removeAttribute("data-value");
- element.value = "";
-
- if (!data.disableClear) {
- data.clearButton!.style.setProperty("visibility", "hidden", "");
- }
-
- data.isEmpty = true;
- data.shadow.value = "";
- },
-
- /**
- * Reverts the date picker into a normal input field.
- */
- destroy(element: InputElementOrString): void {
- element = getElement(element);
- const data = _data.get(element) as DatePickerData;
-
- const container = element.parentNode as HTMLElement;
- container.parentNode!.insertBefore(element, container);
- container.remove();
-
- element.setAttribute("type", "date" + (data.isDateTime ? "time" : ""));
- element.name = data.shadow.name;
- element.value = data.shadow.value;
-
- element.removeAttribute("data-value");
- element.removeEventListener("click", open);
- data.shadow.remove();
-
- element.classList.remove("inputDatePicker");
- element.readOnly = false;
- _data.delete(element);
- },
-
- /**
- * Sets the callback invoked on picker close.
- */
- setCloseCallback(element: InputElementOrString, callback: Callback): void {
- element = getElement(element);
- _data.get(element)!.onClose = callback;
- },
-};
-
-// backward-compatibility for `$.ui.datepicker` shim
-window.__wcf_bc_datePicker = DatePicker;
-
-export = DatePicker;
-
-type InputElementOrString = HTMLInputElement | string;
-
-type Callback = () => void;
-
-interface DatePickerData {
- clearButton: HTMLAnchorElement | null;
- shadow: HTMLInputElement;
-
- disableClear: boolean;
- isDateTime: boolean;
- isEmpty: boolean;
- isTimeOnly: boolean;
- ignoreTimezone: boolean;
-
- onClose: Callback | null;
-}
+++ /dev/null
-/**
- * Transforms <time> elements to display the elapsed time relative to the current time.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Date/Time/Relative
- */
-
-import * as Core from "../../Core";
-import * as DateUtil from "../Util";
-import DomChangeListener from "../../Dom/Change/Listener";
-import * as Language from "../../Language";
-import RepeatingTimer from "../../Timer/Repeating";
-
-let _isActive = true;
-let _isPending = false;
-let _offset: number;
-
-function onVisibilityChange(): void {
- if (document.hidden) {
- _isActive = false;
- _isPending = false;
- } else {
- _isActive = true;
-
- // force immediate refresh
- if (_isPending) {
- refresh();
- _isPending = false;
- }
- }
-}
-
-function refresh() {
- // activity is suspended while the tab is hidden, but force an
- // immediate refresh once the page is active again
- if (!_isActive) {
- if (!_isPending) _isPending = true;
- return;
- }
-
- const date = new Date();
- const timestamp = (date.getTime() - date.getMilliseconds()) / 1_000;
-
- document.querySelectorAll("time").forEach((element) => {
- rebuild(element, date, timestamp);
- });
-}
-
-function rebuild(element: HTMLTimeElement, date: Date, timestamp: number): void {
- if (!element.classList.contains("datetime") || Core.stringToBool(element.dataset.isFutureDate || "")) {
- return;
- }
-
- const elTimestamp = parseInt(element.dataset.timestamp!, 10) + _offset;
- const elDate = element.dataset.date!;
- const elTime = element.dataset.time!;
- const elOffset = element.dataset.offset!;
-
- if (!element.title) {
- element.title = Language.get("wcf.date.dateTimeFormat")
- .replace(/%date%/, elDate)
- .replace(/%time%/, elTime);
- }
-
- // timestamp is less than 60 seconds ago
- if (elTimestamp >= timestamp || timestamp < elTimestamp + 60) {
- element.textContent = Language.get("wcf.date.relative.now");
- }
- // timestamp is less than 60 minutes ago (display 1 hour ago rather than 60 minutes ago)
- else if (timestamp < elTimestamp + 3540) {
- const minutes = Math.max(Math.round((timestamp - elTimestamp) / 60), 1);
- element.textContent = Language.get("wcf.date.relative.minutes", { minutes: minutes });
- }
- // timestamp is less than 24 hours ago
- else if (timestamp < elTimestamp + 86400) {
- const hours = Math.round((timestamp - elTimestamp) / 3600);
- element.textContent = Language.get("wcf.date.relative.hours", { hours: hours });
- }
- // timestamp is less than 6 days ago
- else if (timestamp < elTimestamp + 518400) {
- const midnight = new Date(date.getFullYear(), date.getMonth(), date.getDate());
- const days = Math.ceil((midnight.getTime() / 1000 - elTimestamp) / 86400);
-
- // get day of week
- const dateObj = DateUtil.getTimezoneDate(elTimestamp * 1000, parseInt(elOffset, 10) * 1000);
- const dow = dateObj.getDay();
- const day = Language.get("__days")[dow];
-
- element.textContent = Language.get("wcf.date.relative.pastDays", { days: days, day: day, time: elTime });
- }
- // timestamp is between ~700 million years BC and last week
- else {
- element.textContent = Language.get("wcf.date.shortDateTimeFormat")
- .replace(/%date%/, elDate)
- .replace(/%time%/, elTime);
- }
-}
-
-/**
- * Transforms <time> elements on init and binds event listeners.
- */
-export function setup(): void {
- _offset = Math.trunc(Date.now() / 1_000 - window.TIME_NOW);
-
- new RepeatingTimer(refresh, 60_000);
-
- DomChangeListener.add("WoltLabSuite/Core/Date/Time/Relative", refresh);
-
- document.addEventListener("visibilitychange", onVisibilityChange);
-}
+++ /dev/null
-/**
- * Provides utility functions for date operations.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module DateUtil (alias)
- * @module WoltLabSuite/Core/Date/Util
- */
-
-import * as Language from "../Language";
-
-/**
- * Returns the formatted date.
- */
-export function formatDate(date: Date): string {
- return format(date, Language.get("wcf.date.dateFormat"));
-}
-
-/**
- * Returns the formatted time.
- */
-export function formatTime(date: Date): string {
- return format(date, Language.get("wcf.date.timeFormat"));
-}
-
-/**
- * Returns the formatted date time.
- */
-export function formatDateTime(date: Date): string {
- const dateTimeFormat = Language.get("wcf.date.dateTimeFormat");
- const dateFormat = Language.get("wcf.date.dateFormat");
- const timeFormat = Language.get("wcf.date.timeFormat");
-
- return format(date, dateTimeFormat.replace(/%date%/, dateFormat).replace(/%time%/, timeFormat));
-}
-
-/**
- * Formats a date using PHP's `date()` modifiers.
- */
-export function format(date: Date, format: string): string {
- // ISO 8601 date, best recognition by PHP's strtotime()
- if (format === "c") {
- format = "Y-m-dTH:i:sP";
- }
-
- let out = "";
- for (let i = 0, length = format.length; i < length; i++) {
- let char: string;
- switch (format[i]) {
- // seconds
- case "s":
- // `00` through `59`
- char = date.getSeconds().toString().padStart(2, "0");
- break;
-
- // minutes
- case "i":
- // `00` through `59`
- char = date.getMinutes().toString().padStart(2, "0");
- break;
-
- // hours
- case "a":
- // `am` or `pm`
- char = date.getHours() > 11 ? "pm" : "am";
- break;
- case "g": {
- // `1` through `12`
- const hours = date.getHours();
- if (hours === 0) {
- char = "12";
- } else if (hours > 12) {
- char = (hours - 12).toString();
- } else {
- char = hours.toString();
- }
-
- break;
- }
- case "h": {
- // `01` through `12`
- const hours = date.getHours();
- if (hours === 0) {
- char = "12";
- } else if (hours > 12) {
- char = (hours - 12).toString();
- } else {
- char = hours.toString();
- }
-
- char = char.padStart(2, "0");
-
- break;
- }
- case "A":
- // `AM` or `PM`
- char = date.getHours() > 11 ? "PM" : "AM";
- break;
- case "G":
- // `0` through `23`
- char = date.getHours().toString();
- break;
- case "H":
- // `00` through `23`
- char = date.getHours().toString().padStart(2, "0");
- break;
-
- // day
- case "d":
- // `01` through `31`
- char = date.getDate().toString().padStart(2, "0");
- break;
- case "j":
- // `1` through `31`
- char = date.getDate().toString();
- break;
- case "l":
- // `Monday` through `Sunday` (localized)
- char = Language.get("__days")[date.getDay()];
- break;
- case "D":
- // `Mon` through `Sun` (localized)
- char = Language.get("__daysShort")[date.getDay()];
- break;
- case "S":
- // ignore english ordinal suffix
- char = "";
- break;
-
- // month
- case "m":
- // `01` through `12`
- char = (date.getMonth() + 1).toString().padStart(2, "0");
- break;
- case "n":
- // `1` through `12`
- char = (date.getMonth() + 1).toString();
- break;
- case "F":
- // `January` through `December` (localized)
- char = Language.get("__months")[date.getMonth()];
- break;
- case "M":
- // `Jan` through `Dec` (localized)
- char = Language.get("__monthsShort")[date.getMonth()];
- break;
-
- // year
- case "y":
- // `00` through `99`
- char = date.getFullYear().toString().slice(-2);
- break;
- case "Y":
- // Examples: `1988` or `2015`
- char = date.getFullYear().toString();
- break;
-
- // timezone
- case "P": {
- let offset = date.getTimezoneOffset();
- char = offset > 0 ? "-" : "+";
-
- offset = Math.abs(offset);
-
- char += (~~(offset / 60)).toString().padStart(2, "0");
- char += ":";
- char += (offset % 60).toString().padStart(2, "0");
-
- break;
- }
-
- // specials
- case "r":
- char = date.toString();
- break;
- case "U":
- char = Math.round(date.getTime() / 1000).toString();
- break;
-
- // escape sequence
- case "\\":
- char = "";
- if (i + 1 < length) {
- char = format[++i];
- }
- break;
-
- default:
- char = format[i];
- break;
- }
-
- out += char;
- }
-
- return out;
-}
-
-/**
- * Returns UTC timestamp, if date is not given, current time will be used.
- */
-export function gmdate(date: Date): number {
- if (!(date instanceof Date)) {
- date = new Date();
- }
-
- return Math.round(
- Date.UTC(
- date.getUTCFullYear(),
- date.getUTCMonth(),
- date.getUTCDay(),
- date.getUTCHours(),
- date.getUTCMinutes(),
- date.getUTCSeconds(),
- ) / 1000,
- );
-}
-
-/**
- * Returns a `time` element based on the given date just like a `time`
- * element created by `wcf\system\template\plugin\TimeModifierTemplatePlugin`.
- *
- * Note: The actual content of the element is empty and is expected
- * to be automatically updated by `WoltLabSuite/Core/Date/Time/Relative`
- * (for dates not in the future) after the DOM change listener has been triggered.
- */
-export function getTimeElement(date: Date): HTMLElement {
- const time = document.createElement("time");
- time.className = "datetime";
-
- const formattedDate = formatDate(date);
- const formattedTime = formatTime(date);
-
- time.setAttribute("datetime", format(date, "c"));
- time.dataset.timestamp = ((date.getTime() - date.getMilliseconds()) / 1_000).toString();
- time.dataset.date = formattedDate;
- time.dataset.time = formattedTime;
- time.dataset.offset = (date.getTimezoneOffset() * 60).toString(); // PHP returns minutes, JavaScript returns seconds
-
- if (date.getTime() > Date.now()) {
- time.dataset.isFutureDate = "true";
-
- time.textContent = Language.get("wcf.date.dateTimeFormat")
- .replace("%time%", formattedTime)
- .replace("%date%", formattedDate);
- }
-
- return time;
-}
-
-/**
- * Returns a Date object with precise offset (including timezone and local timezone).
- */
-export function getTimezoneDate(timestamp: number, offset: number): Date {
- const date = new Date(timestamp);
- const localOffset = date.getTimezoneOffset() * 60_000;
-
- return new Date(timestamp + localOffset + offset);
-}
+++ /dev/null
-/**
- * Developer tools for WoltLab Suite.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Devtools (alias)
- * @module WoltLabSuite/Core/Devtools
- */
-
-let _settings = {
- editorAutosave: true,
- eventLogging: false,
-};
-
-function _updateConfig() {
- if (window.sessionStorage) {
- window.sessionStorage.setItem("__wsc_devtools_config", JSON.stringify(_settings));
- }
-}
-
-const Devtools = {
- /**
- * Prints the list of available commands.
- */
- help(): void {
- window.console.log("");
- window.console.log("%cAvailable commands:", "text-decoration: underline");
-
- Object.keys(Devtools)
- .filter((cmd) => cmd !== "_internal_")
- .sort()
- .forEach((cmd) => {
- window.console.log(`\tDevtools.${cmd}()`);
- });
-
- window.console.log("");
- },
-
- /**
- * Disables/re-enables the editor autosave feature.
- */
- toggleEditorAutosave(forceDisable: boolean): void {
- _settings.editorAutosave = forceDisable ? false : !_settings.editorAutosave;
- _updateConfig();
-
- window.console.log(
- "%c\tEditor autosave " + (_settings.editorAutosave ? "enabled" : "disabled"),
- "font-style: italic",
- );
- },
-
- /**
- * Enables/disables logging for fired event listener events.
- */
- toggleEventLogging(forceEnable: boolean): void {
- _settings.eventLogging = forceEnable ? true : !_settings.eventLogging;
- _updateConfig();
-
- window.console.log("%c\tEvent logging " + (_settings.eventLogging ? "enabled" : "disabled"), "font-style: italic");
- },
-
- /**
- * Internal methods not meant to be called directly.
- */
- _internal_: {
- enable(): void {
- window.Devtools = Devtools;
-
- window.console.log("%cDevtools for WoltLab Suite loaded", "font-weight: bold");
-
- if (window.sessionStorage) {
- const settings = window.sessionStorage.getItem("__wsc_devtools_config");
- try {
- if (settings !== null) {
- _settings = JSON.parse(settings);
- }
- } catch (e) {
- // Ignore JSON parsing failure.
- }
-
- if (!_settings.editorAutosave) {
- Devtools.toggleEditorAutosave(true);
- }
- if (_settings.eventLogging) {
- Devtools.toggleEventLogging(true);
- }
- }
-
- window.console.log("Settings are saved per browser session, enter `Devtools.help()` to learn more.");
- window.console.log("");
- },
-
- editorAutosave(): boolean {
- return _settings.editorAutosave;
- },
-
- eventLog(identifier: string, action: string): void {
- if (_settings.eventLogging) {
- window.console.log("[Devtools.EventLogging] Firing event: " + action + " @ " + identifier);
- }
- },
- },
-};
-
-export = Devtools;
+++ /dev/null
-/**
- * Dictionary implementation relying on an object or if supported on a Map to hold key => value data.
- *
- * If you're looking for a dictionary with object keys, please see `WoltLabSuite/Core/ObjectMap`.
- *
- * This is a legacy implementation, that does not implement all methods of `Map`, furthermore it has
- * the side effect of converting all numeric keys to string values, treating 1 === "1".
- *
- * @author Tim Duesterhus, Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Dictionary (alias)
- * @module WoltLabSuite/Core/Dictionary
- */
-
-import * as Core from "./Core";
-
-/** @deprecated 5.4 Use a `Map` instead. */
-class Dictionary<T> {
- private readonly _dictionary = new Map<number | string, T>();
-
- /**
- * Sets a new key with given value, will overwrite an existing key.
- */
- set(key: number | string, value: T): void {
- this._dictionary.set(key.toString(), value);
- }
-
- /**
- * Removes a key from the dictionary.
- */
- delete(key: number | string): boolean {
- return this._dictionary.delete(key.toString());
- }
-
- /**
- * Returns true if dictionary contains a value for given key and is not undefined.
- */
- has(key: number | string): boolean {
- return this._dictionary.has(key.toString());
- }
-
- /**
- * Retrieves a value by key, returns undefined if there is no match.
- */
- get(key: number | string): unknown {
- return this._dictionary.get(key.toString());
- }
-
- /**
- * Iterates over the dictionary keys and values, callback function should expect the
- * value as first parameter and the key name second.
- */
- forEach(callback: (value: T, key: number | string) => void): void {
- if (typeof callback !== "function") {
- throw new TypeError("forEach() expects a callback as first parameter.");
- }
-
- this._dictionary.forEach(callback);
- }
-
- /**
- * Merges one or more Dictionary instances into this one.
- */
- merge(...dictionaries: Dictionary<T>[]): void {
- for (let i = 0, length = dictionaries.length; i < length; i++) {
- const dictionary = dictionaries[i];
-
- dictionary.forEach((value, key) => this.set(key, value));
- }
- }
-
- /**
- * Returns the object representation of the dictionary.
- */
- toObject(): object {
- const object = {};
- this._dictionary.forEach((value, key) => (object[key] = value));
-
- return object;
- }
-
- /**
- * Creates a new Dictionary based on the given object.
- * All properties that are owned by the object will be added
- * as keys to the resulting Dictionary.
- */
- static fromObject(object: object): Dictionary<any> {
- const result = new Dictionary();
-
- Object.keys(object).forEach((key) => {
- result.set(key, object[key]);
- });
-
- return result;
- }
-
- get size(): number {
- return this._dictionary.size;
- }
-}
-
-Core.enableLegacyInheritance(Dictionary);
-
-export = Dictionary;
+++ /dev/null
-/**
- * Allows to be informed when the DOM may have changed and
- * new elements that are relevant to you may have been added.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Dom/ChangeListener (alias)
- * @module WoltLabSuite/Core/Dom/Change/Listener
- */
-
-import CallbackList from "../../CallbackList";
-
-const _callbackList = new CallbackList();
-let _hot = false;
-
-const DomChangeListener = {
- /**
- * @see CallbackList.add
- */
- add: _callbackList.add.bind(_callbackList),
-
- /**
- * @see CallbackList.remove
- */
- remove: _callbackList.remove.bind(_callbackList),
-
- /**
- * Triggers the execution of all the listeners.
- * Use this function when you added new elements to the DOM that might
- * be relevant to others.
- * While this function is in progress further calls to it will be ignored.
- */
- trigger(): void {
- if (_hot) return;
-
- try {
- _hot = true;
- _callbackList.forEach(null, (callback) => callback());
- } finally {
- _hot = false;
- }
- },
-};
-
-export = DomChangeListener;
+++ /dev/null
-/**
- * Provides helper functions to traverse the DOM.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Dom/Traverse (alias)
- * @module WoltLabSuite/Core/Dom/Traverse
- */
-
-const enum Type {
- None,
- Selector,
- ClassName,
- TagName,
-}
-
-type SiblingType = "nextElementSibling" | "previousElementSibling";
-
-const _test = new Map<Type, (...args: any[]) => boolean>([
- [Type.None, () => true],
- [Type.Selector, (element: Element, selector: string) => element.matches(selector)],
- [Type.ClassName, (element: Element, className: string) => element.classList.contains(className)],
- [Type.TagName, (element: Element, tagName: string) => element.nodeName === tagName],
-]);
-
-function _getChildren(element: Element, type: Type, value: string): Element[] {
- if (!(element instanceof Element)) {
- throw new TypeError("Expected a valid element as first argument.");
- }
-
- const children: Element[] = [];
- for (let i = 0; i < element.childElementCount; i++) {
- if (_test.get(type)!(element.children[i], value)) {
- children.push(element.children[i]);
- }
- }
-
- return children;
-}
-
-function _getParent(element: Element, type: Type, value: string, untilElement?: Element): Element | null {
- if (!(element instanceof Element)) {
- throw new TypeError("Expected a valid element as first argument.");
- }
-
- let target = element.parentNode;
- while (target instanceof Element) {
- if (target === untilElement) {
- return null;
- }
-
- if (_test.get(type)!(target, value)) {
- return target;
- }
-
- target = target.parentNode;
- }
-
- return null;
-}
-
-function _getSibling(element: Element, siblingType: SiblingType, type: Type, value: string): Element | null {
- if (!(element instanceof Element)) {
- throw new TypeError("Expected a valid element as first argument.");
- }
-
- if (element instanceof Element) {
- if (element[siblingType] !== null && _test.get(type)!(element[siblingType], value)) {
- return element[siblingType];
- }
- }
-
- return null;
-}
-
-/**
- * Examines child elements and returns the first child matching the given selector.
- */
-export function childBySel(element: Element, selector: string): Element | null {
- return _getChildren(element, Type.Selector, selector)[0] || null;
-}
-
-/**
- * Examines child elements and returns the first child that has the given CSS class set.
- */
-export function childByClass(element: Element, className: string): Element | null {
- return _getChildren(element, Type.ClassName, className)[0] || null;
-}
-
-/**
- * Examines child elements and returns the first child which equals the given tag.
- */
-export function childByTag<K extends Uppercase<keyof HTMLElementTagNameMap>>(
- element: Element,
- tagName: K,
-): HTMLElementTagNameMap[Lowercase<K>] | null;
-export function childByTag(element: Element, tagName: string): Element | null;
-export function childByTag(element: Element, tagName: string): Element | null {
- return _getChildren(element, Type.TagName, tagName)[0] || null;
-}
-
-/**
- * Examines child elements and returns all children matching the given selector.
- */
-export function childrenBySel(element: Element, selector: string): Element[] {
- return _getChildren(element, Type.Selector, selector);
-}
-
-/**
- * Examines child elements and returns all children that have the given CSS class set.
- */
-export function childrenByClass(element: Element, className: string): Element[] {
- return _getChildren(element, Type.ClassName, className);
-}
-
-/**
- * Examines child elements and returns all children which equal the given tag.
- */
-export function childrenByTag<K extends Uppercase<keyof HTMLElementTagNameMap>>(
- element: Element,
- tagName: K,
-): HTMLElementTagNameMap[Lowercase<K>][];
-export function childrenByTag(element: Element, tagName: string): Element[];
-export function childrenByTag(element: Element, tagName: string): Element[] {
- return _getChildren(element, Type.TagName, tagName);
-}
-
-/**
- * Examines parent nodes and returns the first parent that matches the given selector.
- */
-export function parentBySel(element: Element, selector: string, untilElement?: Element): Element | null {
- return _getParent(element, Type.Selector, selector, untilElement);
-}
-
-/**
- * Examines parent nodes and returns the first parent that has the given CSS class set.
- */
-export function parentByClass(element: Element, className: string, untilElement?: Element): Element | null {
- return _getParent(element, Type.ClassName, className, untilElement);
-}
-
-/**
- * Examines parent nodes and returns the first parent which equals the given tag.
- */
-export function parentByTag(element: Element, tagName: string, untilElement?: Element): Element | null {
- return _getParent(element, Type.TagName, tagName, untilElement);
-}
-
-/**
- * Returns the next element sibling.
- *
- * @deprecated 5.4 Use `element.nextElementSibling` instead.
- */
-export function next(element: Element): Element | null {
- return _getSibling(element, "nextElementSibling", Type.None, "");
-}
-
-/**
- * Returns the next element sibling that matches the given selector.
- */
-export function nextBySel(element: Element, selector: string): Element | null {
- return _getSibling(element, "nextElementSibling", Type.Selector, selector);
-}
-
-/**
- * Returns the next element sibling with given CSS class.
- */
-export function nextByClass(element: Element, className: string): Element | null {
- return _getSibling(element, "nextElementSibling", Type.ClassName, className);
-}
-
-/**
- * Returns the next element sibling with given CSS class.
- */
-export function nextByTag(element: Element, tagName: string): Element | null {
- return _getSibling(element, "nextElementSibling", Type.TagName, tagName);
-}
-
-/**
- * Returns the previous element sibling.
- *
- * @deprecated 5.4 Use `element.previousElementSibling` instead.
- */
-export function prev(element: Element): Element | null {
- return _getSibling(element, "previousElementSibling", Type.None, "");
-}
-
-/**
- * Returns the previous element sibling that matches the given selector.
- */
-export function prevBySel(element: Element, selector: string): Element | null {
- return _getSibling(element, "previousElementSibling", Type.Selector, selector);
-}
-
-/**
- * Returns the previous element sibling with given CSS class.
- */
-export function prevByClass(element: Element, className: string): Element | null {
- return _getSibling(element, "previousElementSibling", Type.ClassName, className);
-}
-
-/**
- * Returns the previous element sibling with given CSS class.
- */
-export function prevByTag(element: Element, tagName: string): Element | null {
- return _getSibling(element, "previousElementSibling", Type.TagName, tagName);
-}
+++ /dev/null
-/**
- * Provides helper functions to work with DOM nodes.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Dom/Util (alias)
- * @module WoltLabSuite/Core/Dom/Util
- */
-
-import * as StringUtil from "../StringUtil";
-
-function _isBoundaryNode(element: Element, ancestor: Element, position: string): boolean {
- if (!ancestor.contains(element)) {
- throw new Error("Ancestor element does not contain target element.");
- }
-
- let node: Node;
- let target: Node | null = element;
- const whichSibling = position + "Sibling";
- while (target !== null && target !== ancestor) {
- if (target[position + "ElementSibling"] !== null) {
- return false;
- } else if (target[whichSibling]) {
- node = target[whichSibling];
- while (node) {
- if (node.textContent!.trim() !== "") {
- return false;
- }
-
- node = node[whichSibling];
- }
- }
-
- target = target.parentNode;
- }
-
- return true;
-}
-
-let _idCounter = 0;
-
-const DomUtil = {
- /**
- * Returns a DocumentFragment containing the provided HTML string as DOM nodes.
- */
- createFragmentFromHtml(html: string): DocumentFragment {
- const tmp = document.createElement("div");
- this.setInnerHtml(tmp, html);
-
- const fragment = document.createDocumentFragment();
- while (tmp.childNodes.length) {
- fragment.appendChild(tmp.childNodes[0]);
- }
-
- return fragment;
- },
-
- /**
- * Returns a unique element id.
- */
- getUniqueId(): string {
- let elementId: string;
-
- do {
- elementId = `wcf${_idCounter++}`;
- } while (document.getElementById(elementId) !== null);
-
- return elementId;
- },
-
- /**
- * Returns the element's id. If there is no id set, a unique id will be
- * created and assigned.
- */
- identify(element: Element): string {
- if (!(element instanceof Element)) {
- throw new TypeError("Expected a valid DOM element as argument.");
- }
-
- let id = element.id;
- if (!id) {
- id = this.getUniqueId();
- element.id = id;
- }
-
- return id;
- },
-
- /**
- * Returns the outer height of an element including margins.
- */
- outerHeight(element: HTMLElement, styles?: CSSStyleDeclaration): number {
- styles = styles || window.getComputedStyle(element);
-
- let height = element.offsetHeight;
- height += ~~styles.marginTop + ~~styles.marginBottom;
-
- return height;
- },
-
- /**
- * Returns the outer width of an element including margins.
- */
- outerWidth(element: HTMLElement, styles?: CSSStyleDeclaration): number {
- styles = styles || window.getComputedStyle(element);
-
- let width = element.offsetWidth;
- width += ~~styles.marginLeft + ~~styles.marginRight;
-
- return width;
- },
-
- /**
- * Returns the outer dimensions of an element including margins.
- */
- outerDimensions(element: HTMLElement): Dimensions {
- const styles = window.getComputedStyle(element);
-
- return {
- height: this.outerHeight(element, styles),
- width: this.outerWidth(element, styles),
- };
- },
-
- /**
- * Returns the element's offset relative to the document's top left corner.
- *
- * @param {Element} element element
- * @return {{left: int, top: int}} offset relative to top left corner
- */
- offset(element: Element): Offset {
- const rect = element.getBoundingClientRect();
-
- return {
- top: Math.round(rect.top + (window.scrollY || window.pageYOffset)),
- left: Math.round(rect.left + (window.scrollX || window.pageXOffset)),
- };
- },
-
- /**
- * Prepends an element to a parent element.
- *
- * @deprecated 5.3 Use `parent.insertAdjacentElement('afterbegin', element)` instead.
- */
- prepend(element: Element, parent: Element): void {
- parent.insertAdjacentElement("afterbegin", element);
- },
-
- /**
- * Inserts an element after an existing element.
- *
- * @deprecated 5.3 Use `element.insertAdjacentElement('afterend', newElement)` instead.
- */
- insertAfter(newElement: Element, element: Element): void {
- element.insertAdjacentElement("afterend", newElement);
- },
-
- /**
- * Applies a list of CSS properties to an element.
- */
- setStyles(element: HTMLElement, styles: CssDeclarations): void {
- let important = false;
- Object.keys(styles).forEach((property) => {
- if (/ !important$/.test(styles[property])) {
- important = true;
-
- styles[property] = styles[property].replace(/ !important$/, "");
- } else {
- important = false;
- }
-
- // for a set style property with priority = important, some browsers are
- // not able to overwrite it with a property != important; removing the
- // property first solves this issue
- if (element.style.getPropertyPriority(property) === "important" && !important) {
- element.style.removeProperty(property);
- }
-
- element.style.setProperty(property, styles[property], important ? "important" : "");
- });
- },
-
- /**
- * Returns a style property value as integer.
- *
- * The behavior of this method is undefined for properties that are not considered
- * to have a "numeric" value, e.g. "background-image".
- */
- styleAsInt(styles: CSSStyleDeclaration, propertyName: string): number {
- const value = styles.getPropertyValue(propertyName);
- if (value === null) {
- return 0;
- }
-
- return parseInt(value, 10);
- },
-
- /**
- * Sets the inner HTML of given element and reinjects <script> elements to be properly executed.
- *
- * @see http://www.w3.org/TR/2008/WD-html5-20080610/dom.html#innerhtml0
- * @param {Element} element target element
- * @param {string} innerHtml HTML string
- */
- setInnerHtml(element: Element, innerHtml: string): void {
- element.innerHTML = innerHtml;
-
- const scripts = element.querySelectorAll<HTMLScriptElement>("script");
- for (let i = 0, length = scripts.length; i < length; i++) {
- const script = scripts[i];
- const newScript = document.createElement("script");
- if (script.src) {
- newScript.src = script.src;
- } else {
- newScript.textContent = script.textContent;
- }
-
- element.appendChild(newScript);
- script.remove();
- }
- },
-
- /**
- *
- * @param html
- * @param {Element} referenceElement
- * @param insertMethod
- */
- insertHtml(html: string, referenceElement: Element, insertMethod: string): void {
- const element = document.createElement("div");
- this.setInnerHtml(element, html);
-
- if (!element.childNodes.length) {
- return;
- }
-
- let node = element.childNodes[0] as Element;
- switch (insertMethod) {
- case "append":
- referenceElement.appendChild(node);
- break;
-
- case "after":
- this.insertAfter(node, referenceElement);
- break;
-
- case "prepend":
- this.prepend(node, referenceElement);
- break;
-
- case "before":
- if (referenceElement.parentNode === null) {
- throw new Error("The reference element has no parent, but the insert position was set to 'before'.");
- }
-
- referenceElement.parentNode.insertBefore(node, referenceElement);
- break;
-
- default:
- throw new Error("Unknown insert method '" + insertMethod + "'.");
- }
-
- let tmp;
- while (element.childNodes.length) {
- tmp = element.childNodes[0];
-
- this.insertAfter(tmp, node);
- node = tmp;
- }
- },
-
- /**
- * Returns true if `element` contains the `child` element.
- *
- * @deprecated 5.4 Use `element.contains(child)` instead.
- */
- contains(element: Element, child: Element): boolean {
- return element.contains(child);
- },
-
- /**
- * Retrieves all data attributes from target element, optionally allowing for
- * a custom prefix that serves two purposes: First it will restrict the results
- * for items starting with it and second it will remove that prefix.
- *
- * @deprecated 5.4 Use `element.dataset` instead.
- */
- getDataAttributes(
- element: Element,
- prefix?: string,
- camelCaseName?: boolean,
- idToUpperCase?: boolean,
- ): DataAttributes {
- prefix = prefix || "";
- if (prefix.indexOf("data-") !== 0) {
- prefix = "data-" + prefix;
- }
- camelCaseName = camelCaseName === true;
- idToUpperCase = idToUpperCase === true;
-
- const attributes = {};
- for (let i = 0, length = element.attributes.length; i < length; i++) {
- const attribute = element.attributes[i];
-
- if (attribute.name.indexOf(prefix) === 0) {
- let name = attribute.name.replace(new RegExp("^" + prefix), "");
- if (camelCaseName) {
- const tmp = name.split("-");
- name = "";
- for (let j = 0, innerLength = tmp.length; j < innerLength; j++) {
- if (name.length) {
- if (idToUpperCase && tmp[j] === "id") {
- tmp[j] = "ID";
- } else {
- tmp[j] = StringUtil.ucfirst(tmp[j]);
- }
- }
-
- name += tmp[j];
- }
- }
-
- attributes[name] = attribute.value;
- }
- }
-
- return attributes;
- },
-
- /**
- * Unwraps contained nodes by moving them out of `element` while
- * preserving their previous order. Target element will be removed
- * at the end of the operation.
- */
- unwrapChildNodes(element: Element): void {
- if (element.parentNode === null) {
- throw new Error("The element has no parent.");
- }
-
- const parent = element.parentNode;
- while (element.childNodes.length) {
- parent.insertBefore(element.childNodes[0], element);
- }
-
- element.remove();
- },
-
- /**
- * Replaces an element by moving all child nodes into the new element
- * while preserving their previous order. The old element will be removed
- * at the end of the operation.
- */
- replaceElement(oldElement: Element, newElement: Element): void {
- if (oldElement.parentNode === null) {
- throw new Error("The old element has no parent.");
- }
-
- while (oldElement.childNodes.length) {
- newElement.appendChild(oldElement.childNodes[0]);
- }
-
- oldElement.parentNode.insertBefore(newElement, oldElement);
- oldElement.remove();
- },
-
- /**
- * Returns true if given element is the most left node of the ancestor, that is
- * a node without any content nor elements before it or its parent nodes.
- */
- isAtNodeStart(element: Element, ancestor: Element): boolean {
- return _isBoundaryNode(element, ancestor, "previous");
- },
-
- /**
- * Returns true if given element is the most right node of the ancestor, that is
- * a node without any content nor elements after it or its parent nodes.
- */
- isAtNodeEnd(element: Element, ancestor: Element): boolean {
- return _isBoundaryNode(element, ancestor, "next");
- },
-
- /**
- * Returns the first ancestor element with position fixed or null.
- *
- * @param {Element} element target element
- * @returns {(Element|null)} first ancestor with position fixed or null
- */
- getFixedParent(element: HTMLElement): Element | null {
- while (element && element !== document.body) {
- if (window.getComputedStyle(element).getPropertyValue("position") === "fixed") {
- return element;
- }
-
- element = element.offsetParent as HTMLElement;
- }
-
- return null;
- },
-
- /**
- * Shorthand function to hide an element by setting its 'display' value to 'none'.
- */
- hide(element: HTMLElement): void {
- element.style.setProperty("display", "none", "");
- },
-
- /**
- * Shorthand function to show an element previously hidden by using `hide()`.
- */
- show(element: HTMLElement): void {
- element.style.removeProperty("display");
- },
-
- /**
- * Shorthand function to check if given element is hidden by setting its 'display'
- * value to 'none'.
- */
- isHidden(element: HTMLElement): boolean {
- return element.style.getPropertyValue("display") === "none";
- },
-
- /**
- * Shorthand function to toggle the element visibility using either `hide()` or `show()`.
- */
- toggle(element: HTMLElement): void {
- if (this.isHidden(element)) {
- this.show(element);
- } else {
- this.hide(element);
- }
- },
-
- /**
- * Displays or removes an error message below the provided element.
- */
- innerError(element: HTMLElement, errorMessage?: string | false | null, isHtml?: boolean): HTMLElement | null {
- const parent = element.parentNode;
- if (parent === null) {
- throw new Error("Only elements that have a parent element or document are valid.");
- }
-
- if (typeof errorMessage !== "string") {
- if (!errorMessage) {
- errorMessage = "";
- } else {
- throw new TypeError(
- "The error message must be a string; `false`, `null` or `undefined` can be used as a substitute for an empty string.",
- );
- }
- }
-
- let innerError = element.nextElementSibling;
- if (innerError === null || innerError.nodeName !== "SMALL" || !innerError.classList.contains("innerError")) {
- if (errorMessage === "") {
- innerError = null;
- } else {
- innerError = document.createElement("small");
- innerError.className = "innerError";
- parent.insertBefore(innerError, element.nextSibling);
- }
- }
-
- if (errorMessage === "") {
- if (innerError !== null) {
- innerError.remove();
- innerError = null;
- }
- } else {
- innerError![isHtml ? "innerHTML" : "textContent"] = errorMessage;
- }
-
- return innerError as HTMLElement | null;
- },
-
- /**
- * Finds the closest element that matches the provided selector. This is a helper
- * function because `closest()` does exist on elements only, for example, it is
- * missing on text nodes.
- */
- closest(node: Node, selector: string): HTMLElement | null {
- const element = node instanceof HTMLElement ? node : node.parentElement!;
- return element.closest(selector);
- },
-
- /**
- * Returns the `node` if it is an element or its parent. This is useful when working
- * with the range of a text selection.
- */
- getClosestElement(node: Node): HTMLElement {
- return node instanceof HTMLElement ? node : node.parentElement!;
- },
-};
-
-interface Dimensions {
- height: number;
- width: number;
-}
-
-interface Offset {
- top: number;
- left: number;
-}
-
-interface CssDeclarations {
- [key: string]: string;
-}
-
-interface DataAttributes {
- [key: string]: string;
-}
-
-// expose on window object for backward compatibility
-window.bc_wcfDomUtil = DomUtil;
-
-export = DomUtil;
+++ /dev/null
-/**
- * Provides basic details on the JavaScript environment.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Environment (alias)
- * @module WoltLabSuite/Core/Environment
- */
-
-let _browser = "other";
-let _editor = "none";
-let _platform = "desktop";
-let _touch = false;
-
-/**
- * Determines environment variables.
- */
-export function setup(): void {
- if (typeof (window as any).chrome === "object") {
- // this detects Opera as well, we could check for window.opr if we need to
- _browser = "chrome";
- } else {
- const styles = window.getComputedStyle(document.documentElement);
- for (let i = 0, length = styles.length; i < length; i++) {
- const property = styles[i];
-
- if (property.indexOf("-ms-") === 0) {
- // it is tempting to use 'msie', but it wouldn't really represent 'Edge'
- _browser = "microsoft";
- } else if (property.indexOf("-moz-") === 0) {
- _browser = "firefox";
- } else if (_browser !== "firefox" && property.indexOf("-webkit-") === 0) {
- _browser = "safari";
- }
- }
- }
-
- const ua = window.navigator.userAgent.toLowerCase();
- if (ua.indexOf("crios") !== -1) {
- _browser = "chrome";
- _platform = "ios";
- } else if (/(?:iphone|ipad|ipod)/.test(ua)) {
- _browser = "safari";
- _platform = "ios";
- } else if (ua.indexOf("android") !== -1) {
- _platform = "android";
- } else if (ua.indexOf("iemobile") !== -1) {
- _browser = "microsoft";
- _platform = "windows";
- }
-
- if (_platform === "desktop" && (ua.indexOf("mobile") !== -1 || ua.indexOf("tablet") !== -1)) {
- _platform = "mobile";
- }
-
- _editor = "redactor";
- _touch =
- "ontouchstart" in window ||
- ("msMaxTouchPoints" in window.navigator && window.navigator.msMaxTouchPoints > 0) ||
- ((window as any).DocumentTouch && document instanceof (window as any).DocumentTouch);
-
- // The iPad Pro 12.9" masquerades as a desktop browser.
- if (window.navigator.platform === "MacIntel" && window.navigator.maxTouchPoints > 1) {
- _browser = "safari";
- _platform = "ios";
- }
-}
-
-/**
- * Returns the lower-case browser identifier.
- *
- * Possible values:
- * - chrome: Chrome and Opera
- * - firefox
- * - microsoft: Internet Explorer and Microsoft Edge
- * - safari
- */
-export function browser(): string {
- return _browser;
-}
-
-/**
- * Returns the available editor's name or an empty string.
- */
-export function editor(): string {
- return _editor;
-}
-
-/**
- * Returns the browser platform.
- *
- * Possible values:
- * - desktop
- * - android
- * - ios: iPhone, iPad and iPod
- * - windows: Windows on phones/tablets
- */
-export function platform(): string {
- return _platform;
-}
-
-/**
- * Returns true if browser is potentially used with a touchscreen.
- *
- * Warning: Detecting touch is unreliable and should be avoided at all cost.
- *
- * @deprecated 3.0 - exists for backward-compatibility only, will be removed in the future
- */
-export function touch(): boolean {
- return _touch;
-}
+++ /dev/null
-/**
- * Versatile event system similar to the WCF-PHP counter part.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module EventHandler (alias)
- * @module WoltLabSuite/Core/Event/Handler
- */
-
-import * as Core from "../Core";
-import Devtools from "../Devtools";
-
-type Identifier = string;
-type Action = string;
-type Uuid = string;
-const _listeners = new Map<Identifier, Map<Action, Map<Uuid, Callback>>>();
-
-/**
- * Registers an event listener.
- */
-export function add(identifier: Identifier, action: Action, callback: Callback): Uuid {
- if (typeof callback !== "function") {
- throw new TypeError(`Expected a valid callback for '${action}'@'${identifier}'.`);
- }
-
- let actions = _listeners.get(identifier);
- if (actions === undefined) {
- actions = new Map<Action, Map<Uuid, Callback>>();
- _listeners.set(identifier, actions);
- }
-
- let callbacks = actions.get(action);
- if (callbacks === undefined) {
- callbacks = new Map<Uuid, Callback>();
- actions.set(action, callbacks);
- }
-
- const uuid = Core.getUuid();
- callbacks.set(uuid, callback);
-
- return uuid;
-}
-
-/**
- * Fires an event and notifies all listeners.
- */
-export function fire(identifier: Identifier, action: Action, data?: object): void {
- Devtools._internal_.eventLog(identifier, action);
-
- data = data || {};
-
- _listeners
- .get(identifier)
- ?.get(action)
- ?.forEach((callback) => callback(data));
-}
-
-/**
- * Removes an event listener, requires the uuid returned by add().
- */
-export function remove(identifier: Identifier, action: Action, uuid: Uuid): void {
- _listeners.get(identifier)?.get(action)?.delete(uuid);
-}
-
-/**
- * Removes all event listeners for given action. Omitting the second parameter will
- * remove all listeners for this identifier.
- */
-export function removeAll(identifier: Identifier, action?: Action): void {
- if (typeof action !== "string") action = undefined;
-
- const actions = _listeners.get(identifier);
- if (actions === undefined) {
- return;
- }
-
- if (action === undefined) {
- _listeners.delete(identifier);
- } else {
- actions.delete(action);
- }
-}
-
-/**
- * Removes all listeners registered for an identifier and ending with a special suffix.
- * This is commonly used to unbound event handlers for the editor.
- */
-export function removeAllBySuffix(identifier: Identifier, suffix: string): void {
- const actions = _listeners.get(identifier);
- if (actions === undefined) {
- return;
- }
-
- suffix = "_" + suffix;
- const length = suffix.length * -1;
- actions.forEach((callbacks, action) => {
- if (action.substr(length) === suffix) {
- removeAll(identifier, action);
- }
- });
-}
-
-type Callback = (...args: any[]) => void;
+++ /dev/null
-/**
- * Provides reliable checks for common key presses, uses `Event.key` on supported browsers
- * or the deprecated `Event.which`.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module EventKey (alias)
- * @module WoltLabSuite/Core/Event/Key
- */
-
-function _test(event: KeyboardEvent, key: string, which: number) {
- if (!(event instanceof Event)) {
- throw new TypeError("Expected a valid event when testing for key '" + key + "'.");
- }
-
- return event.key === key || event.which === which;
-}
-
-/**
- * Returns true if the pressed key equals 'ArrowDown'.
- *
- * @deprecated 5.4 Use `event.key === "ArrowDown"` instead.
- */
-export function ArrowDown(event: KeyboardEvent): boolean {
- return _test(event, "ArrowDown", 40);
-}
-
-/**
- * Returns true if the pressed key equals 'ArrowLeft'.
- *
- * @deprecated 5.4 Use `event.key === "ArrowLeft"` instead.
- */
-export function ArrowLeft(event: KeyboardEvent): boolean {
- return _test(event, "ArrowLeft", 37);
-}
-
-/**
- * Returns true if the pressed key equals 'ArrowRight'.
- *
- * @deprecated 5.4 Use `event.key === "ArrowRight"` instead.
- */
-export function ArrowRight(event: KeyboardEvent): boolean {
- return _test(event, "ArrowRight", 39);
-}
-
-/**
- * Returns true if the pressed key equals 'ArrowUp'.
- *
- * @deprecated 5.4 Use `event.key === "ArrowUp"` instead.
- */
-export function ArrowUp(event: KeyboardEvent): boolean {
- return _test(event, "ArrowUp", 38);
-}
-
-/**
- * Returns true if the pressed key equals 'Comma'.
- *
- * @deprecated 5.4 Use `event.key === ","` instead.
- */
-export function Comma(event: KeyboardEvent): boolean {
- return _test(event, ",", 44);
-}
-
-/**
- * Returns true if the pressed key equals 'End'.
- *
- * @deprecated 5.4 Use `event.key === "End"` instead.
- */
-export function End(event: KeyboardEvent): boolean {
- return _test(event, "End", 35);
-}
-
-/**
- * Returns true if the pressed key equals 'Enter'.
- *
- * @deprecated 5.4 Use `event.key === "Enter"` instead.
- */
-export function Enter(event: KeyboardEvent): boolean {
- return _test(event, "Enter", 13);
-}
-
-/**
- * Returns true if the pressed key equals 'Escape'.
- *
- * @deprecated 5.4 Use `event.key === "Escape"` instead.
- */
-export function Escape(event: KeyboardEvent): boolean {
- return _test(event, "Escape", 27);
-}
-
-/**
- * Returns true if the pressed key equals 'Home'.
- *
- * @deprecated 5.4 Use `event.key === "Home"` instead.
- */
-export function Home(event: KeyboardEvent): boolean {
- return _test(event, "Home", 36);
-}
-
-/**
- * Returns true if the pressed key equals 'Space'.
- *
- * @deprecated 5.4 Use `event.key === "Space"` instead.
- */
-export function Space(event: KeyboardEvent): boolean {
- return _test(event, "Space", 32);
-}
-
-/**
- * Returns true if the pressed key equals 'Tab'.
- *
- * @deprecated 5.4 Use `event.key === "Tab"` instead.
- */
-export function Tab(event: KeyboardEvent): boolean {
- return _test(event, "Tab", 9);
-}
+++ /dev/null
-/**
- * Provides helper functions for file handling.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/FileUtil
- */
-
-import * as StringUtil from "./StringUtil";
-
-const _fileExtensionIconMapping = new Map<string, string>(
- Object.entries({
- // archive
- zip: "archive",
- rar: "archive",
- tar: "archive",
- gz: "archive",
-
- // audio
- mp3: "audio",
- ogg: "audio",
- wav: "audio",
-
- // code
- php: "code",
- html: "code",
- htm: "code",
- tpl: "code",
- js: "code",
-
- // excel
- xls: "excel",
- ods: "excel",
- xlsx: "excel",
-
- // image
- gif: "image",
- jpg: "image",
- jpeg: "image",
- png: "image",
- bmp: "image",
- webp: "image",
-
- // video
- avi: "video",
- wmv: "video",
- mov: "video",
- mp4: "video",
- mpg: "video",
- mpeg: "video",
- flv: "video",
-
- // pdf
- pdf: "pdf",
-
- // powerpoint
- ppt: "powerpoint",
- pptx: "powerpoint",
-
- // text
- txt: "text",
-
- // word
- doc: "word",
- docx: "word",
- odt: "word",
- }),
-);
-
-const _mimeTypeExtensionMapping = new Map<string, string>(
- Object.entries({
- // archive
- "application/zip": "zip",
- "application/x-zip-compressed": "zip",
- "application/rar": "rar",
- "application/vnd.rar": "rar",
- "application/x-rar-compressed": "rar",
- "application/x-tar": "tar",
- "application/x-gzip": "gz",
- "application/gzip": "gz",
-
- // audio
- "audio/mpeg": "mp3",
- "audio/mp3": "mp3",
- "audio/ogg": "ogg",
- "audio/x-wav": "wav",
-
- // code
- "application/x-php": "php",
- "text/html": "html",
- "application/javascript": "js",
-
- // excel
- "application/vnd.ms-excel": "xls",
- "application/vnd.oasis.opendocument.spreadsheet": "ods",
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
-
- // image
- "image/gif": "gif",
- "image/jpeg": "jpg",
- "image/png": "png",
- "image/x-ms-bmp": "bmp",
- "image/bmp": "bmp",
- "image/webp": "webp",
-
- // video
- "video/x-msvideo": "avi",
- "video/x-ms-wmv": "wmv",
- "video/quicktime": "mov",
- "video/mp4": "mp4",
- "video/mpeg": "mpg",
- "video/x-flv": "flv",
-
- // pdf
- "application/pdf": "pdf",
-
- // powerpoint
- "application/vnd.ms-powerpoint": "ppt",
- "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
-
- // text
- "text/plain": "txt",
-
- // word
- "application/msword": "doc",
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
- "application/vnd.oasis.opendocument.text": "odt",
- }),
-);
-
-/**
- * Formats the given filesize.
- */
-export function formatFilesize(byte: number, precision = 2): string {
- let symbol = "Byte";
- if (byte >= 1000) {
- byte /= 1000;
- symbol = "kB";
- }
- if (byte >= 1000) {
- byte /= 1000;
- symbol = "MB";
- }
- if (byte >= 1000) {
- byte /= 1000;
- symbol = "GB";
- }
- if (byte >= 1000) {
- byte /= 1000;
- symbol = "TB";
- }
-
- return StringUtil.formatNumeric(byte, -precision) + " " + symbol;
-}
-
-/**
- * Returns the icon name for given filename.
- *
- * Note: For any file icon name like `fa-file-word`, only `word`
- * will be returned by this method.
- */
-export function getIconNameByFilename(filename: string): string {
- const lastDotPosition = filename.lastIndexOf(".");
- if (lastDotPosition !== -1) {
- const extension = filename.substr(lastDotPosition + 1);
-
- if (_fileExtensionIconMapping.has(extension)) {
- return _fileExtensionIconMapping.get(extension) as string;
- }
- }
-
- return "";
-}
-
-/**
- * Returns a known file extension including a leading dot or an empty string.
- */
-export function getExtensionByMimeType(mimetype: string): string {
- if (_mimeTypeExtensionMapping.has(mimetype)) {
- return "." + _mimeTypeExtensionMapping.get(mimetype)!;
- }
-
- return "";
-}
-
-/**
- * Constructs a File object from a Blob
- *
- * @param blob the blob to convert
- * @param filename the filename
- * @returns {File} the File object
- */
-export function blobToFile(blob: Blob, filename: string): File {
- const ext = getExtensionByMimeType(blob.type);
-
- return new File([blob], filename + ext, { type: blob.type });
-}
+++ /dev/null
-/**
- * Handles the dropdowns of form fields with a suffix.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Container/SuffixFormField
- * @since 5.2
- */
-
-import UiSimpleDropdown from "../../../Ui/Dropdown/Simple";
-import * as EventHandler from "../../../Event/Handler";
-import * as Core from "../../../Core";
-
-type DestroyDropdownData = {
- formId: string;
-};
-
-class SuffixFormField {
- protected readonly _formId: string;
- protected readonly _suffixField: HTMLInputElement;
- protected readonly _suffixDropdownMenu: HTMLElement;
- protected readonly _suffixDropdownToggle: HTMLElement;
-
- constructor(formId: string, suffixFieldId: string) {
- this._formId = formId;
-
- this._suffixField = document.getElementById(suffixFieldId)! as HTMLInputElement;
- this._suffixDropdownMenu = UiSimpleDropdown.getDropdownMenu(suffixFieldId + "_dropdown")!;
- this._suffixDropdownToggle = UiSimpleDropdown.getDropdown(suffixFieldId + "_dropdown")!.getElementsByClassName(
- "dropdownToggle",
- )[0] as HTMLInputElement;
- Array.from(this._suffixDropdownMenu.children).forEach((listItem: HTMLLIElement) => {
- listItem.addEventListener("click", (ev) => this._changeSuffixSelection(ev));
- });
-
- EventHandler.add("WoltLabSuite/Core/Form/Builder/Manager", "afterUnregisterForm", (data) =>
- this._destroyDropdown(data),
- );
- }
-
- /**
- * Handles changing the suffix selection.
- */
- protected _changeSuffixSelection(event: MouseEvent): void {
- const target = event.currentTarget! as HTMLElement;
- if (target.classList.contains("disabled")) {
- return;
- }
-
- Array.from(this._suffixDropdownMenu.children).forEach((listItem: HTMLLIElement) => {
- if (listItem === target) {
- listItem.classList.add("active");
- } else {
- listItem.classList.remove("active");
- }
- });
-
- this._suffixField.value = target.dataset.value!;
- this._suffixDropdownToggle.innerHTML =
- target.dataset.label! + ' <span class="icon icon16 fa-caret-down pointer"></span>';
- }
-
- /**
- * Destroys the suffix dropdown if the parent form is unregistered.
- */
- protected _destroyDropdown(data: DestroyDropdownData): void {
- if (data.formId === this._formId) {
- UiSimpleDropdown.destroy(this._suffixDropdownMenu.id);
- }
- }
-}
-
-Core.enableLegacyInheritance(SuffixFormField);
-
-export = SuffixFormField;
+++ /dev/null
-import { DialogOptions } from "../../Ui/Dialog/Data";
-
-interface InternalFormBuilderData {
- [key: string]: any;
-}
-
-export interface AjaxResponseReturnValues {
- dialog: string;
- formId: string;
-}
-
-export type FormBuilderData = InternalFormBuilderData | Promise<InternalFormBuilderData>;
-
-export interface FormBuilderDialogOptions {
- actionParameters: {
- [key: string]: any;
- };
- closeCallback: () => void;
- destroyOnClose: boolean;
- dialog: DialogOptions;
- onSubmit: (formData: FormBuilderData, submitButton: HTMLButtonElement) => void;
- submitActionName?: string;
- successCallback: (returnValues: AjaxResponseReturnValues) => void;
- usesDboAction: boolean;
-}
-
-export interface LabelFormFieldOptions {
- forceSelection: boolean;
- showWithoutSelection: boolean;
-}
+++ /dev/null
-/**
- * Provides API to create a dialog form created by form builder.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Dialog
- * @since 5.2
- */
-
-import * as Core from "../../Core";
-import UiDialog from "../../Ui/Dialog";
-import { DialogCallbackObject, DialogCallbackSetup, DialogData } from "../../Ui/Dialog/Data";
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse, RequestOptions } from "../../Ajax/Data";
-import * as FormBuilderManager from "./Manager";
-import { AjaxResponseReturnValues, FormBuilderData, FormBuilderDialogOptions } from "./Data";
-
-interface AjaxResponse extends DatabaseObjectActionResponse {
- returnValues: AjaxResponseReturnValues;
-}
-
-class FormBuilderDialog implements AjaxCallbackObject, DialogCallbackObject {
- protected _actionName: string;
- protected _className: string;
- protected _dialogContent: string;
- protected _dialogId: string;
- protected _formId: string;
- protected _options: FormBuilderDialogOptions;
- protected _additionalSubmitButtons: HTMLButtonElement[];
-
- constructor(dialogId: string, className: string, actionName: string, options: FormBuilderDialogOptions) {
- this.init(dialogId, className, actionName, options);
- }
-
- protected init(dialogId: string, className: string, actionName: string, options: FormBuilderDialogOptions): void {
- this._dialogId = dialogId;
- this._className = className;
- this._actionName = actionName;
- this._options = Core.extend(
- {
- actionParameters: {},
- destroyOnClose: false,
- usesDboAction: /\w+\\data\\/.test(this._className),
- },
- options,
- ) as FormBuilderDialogOptions;
- this._options.dialog = Core.extend(this._options.dialog || {}, {
- onClose: () => this._dialogOnClose(),
- });
-
- this._formId = "";
- this._dialogContent = "";
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- const options = {
- data: {
- actionName: this._actionName,
- className: this._className,
- parameters: this._options.actionParameters,
- },
- } as RequestOptions;
-
- // By default, `AJAXProxyAction` is used which relies on an `IDatabaseObjectAction` object; if
- // no such object is used but an `IAJAXInvokeAction` object, `AJAXInvokeAction` has to be used.
- if (!this._options.usesDboAction) {
- options.url = "index.php?ajax-invoke/&t=" + window.SECURITY_TOKEN;
- options.withCredentials = true;
- }
-
- return options;
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- switch (data.actionName) {
- case this._actionName:
- if (data.returnValues === undefined) {
- throw new Error("Missing return data.");
- } else if (data.returnValues.dialog === undefined) {
- throw new Error("Missing dialog template in return data.");
- } else if (data.returnValues.formId === undefined) {
- throw new Error("Missing form id in return data.");
- }
-
- this._openDialogContent(data.returnValues.formId, data.returnValues.dialog);
-
- break;
-
- case this._options.submitActionName:
- // If the validation failed, the dialog is shown again.
- if (data.returnValues && data.returnValues.formId && data.returnValues.dialog) {
- if (data.returnValues.formId !== this._formId) {
- throw new Error(
- "Mismatch between form ids: expected '" + this._formId + "' but got '" + data.returnValues.formId + "'.",
- );
- }
-
- this._openDialogContent(data.returnValues.formId, data.returnValues.dialog);
- } else {
- this.destroy();
-
- if (typeof this._options.successCallback === "function") {
- this._options.successCallback(data.returnValues || {});
- }
- }
-
- break;
-
- default:
- throw new Error("Cannot handle action '" + data.actionName + "'.");
- }
- }
-
- protected _closeDialog(): void {
- UiDialog.close(this);
-
- if (typeof this._options.closeCallback === "function") {
- this._options.closeCallback();
- }
- }
-
- protected _dialogOnClose(): void {
- if (this._options.destroyOnClose) {
- this.destroy();
- }
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: this._dialogId,
- options: this._options.dialog,
- source: this._dialogContent,
- };
- }
-
- _dialogSubmit(): void {
- void this.getData().then((formData: FormBuilderData) => this._submitForm(formData));
- }
-
- /**
- * Opens the form dialog with the given form content.
- */
- protected _openDialogContent(formId: string, dialogContent: string): void {
- this.destroy(true);
-
- this._formId = formId;
- this._dialogContent = dialogContent;
-
- const dialogData = UiDialog.open(this, this._dialogContent) as DialogData;
-
- const cancelButton = dialogData.content.querySelector("button[data-type=cancel]") as HTMLButtonElement;
- if (cancelButton !== null && !Core.stringToBool(cancelButton.dataset.hasEventListener || "")) {
- cancelButton.addEventListener("click", () => this._closeDialog());
- cancelButton.dataset.hasEventListener = "1";
- }
-
- this._additionalSubmitButtons = Array.from(
- dialogData.content.querySelectorAll(':not(.formSubmit) button[type="submit"]'),
- );
- this._additionalSubmitButtons.forEach((submit) => {
- submit.addEventListener("click", () => {
- // Mark the button that was clicked so that the button data handlers know
- // which data needs to be submitted.
- this._additionalSubmitButtons.forEach((button) => {
- button.dataset.isClicked = button === submit ? "1" : "0";
- });
-
- // Enable other `click` event listeners to be executed first before the form
- // is submitted.
- setTimeout(() => UiDialog.submit(this._dialogId), 0);
- });
- });
- }
-
- /**
- * Submits the form with the given form data.
- */
- protected _submitForm(formData: FormBuilderData): void {
- const dialogData = UiDialog.getDialog(this)!;
-
- const submitButton = dialogData.content.querySelector("button[data-type=submit]") as HTMLButtonElement;
-
- if (typeof this._options.onSubmit === "function") {
- this._options.onSubmit(formData, submitButton);
- } else if (typeof this._options.submitActionName === "string") {
- submitButton.disabled = true;
- this._additionalSubmitButtons.forEach((submit) => (submit.disabled = true));
-
- Ajax.api(this, {
- actionName: this._options.submitActionName,
- parameters: {
- data: formData,
- formId: this._formId,
- },
- });
- }
- }
-
- /**
- * Destroys the dialog form.
- */
- public destroy(ignoreDialog = false): void {
- if (this._formId !== "") {
- if (FormBuilderManager.hasForm(this._formId)) {
- FormBuilderManager.unregisterForm(this._formId);
- }
-
- if (ignoreDialog !== true) {
- UiDialog.destroy(this);
- }
- }
- }
-
- /**
- * Returns a promise that provides all of the dialog form's data.
- */
- public getData(): Promise<FormBuilderData> {
- if (this._formId === "") {
- throw new Error("Form has not been requested yet.");
- }
-
- return FormBuilderManager.getData(this._formId);
- }
-
- /**
- * Opens the dialog form.
- */
- public open(): void {
- if (UiDialog.getDialog(this._dialogId)) {
- UiDialog.open(this);
- } else {
- Ajax.api(this);
- }
- }
-}
-
-Core.enableLegacyInheritance(FormBuilderDialog);
-
-export = FormBuilderDialog;
+++ /dev/null
-/**
- * Data handler for a acl form builder field in an Ajax form.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Acl
- * @since 5.2.3
- */
-
-import Field from "./Field";
-import { FormBuilderData } from "../Data";
-import * as Core from "../../../Core";
-
-interface AclList {
- getData: () => object;
-}
-
-class Acl extends Field {
- protected _aclList: AclList;
-
- protected _getData(): FormBuilderData {
- return {
- [this._fieldId]: this._aclList.getData(),
- };
- }
-
- protected _readField(): void {
- // does nothing
- }
-
- public setAclList(aclList: AclList): Acl {
- this._aclList = aclList;
-
- return this;
- }
-}
-
-Core.enableLegacyInheritance(Acl);
-
-export = Acl;
+++ /dev/null
-/**
- * Data handler for a button form builder field in an Ajax form.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Value
- * @since 5.4
- */
-
-import Field from "./Field";
-import { FormBuilderData } from "../Data";
-
-export class Button extends Field {
- protected _getData(): FormBuilderData {
- const data = {};
-
- if (this._field!.dataset.isClicked === "1") {
- data[this._fieldId] = (this._field! as HTMLInputElement).value;
- }
-
- return data;
- }
-}
-
-export default Button;
+++ /dev/null
-/**
- * Data handler for a captcha form builder field in an Ajax form.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Captcha
- * @since 5.2
- */
-
-import Field from "./Field";
-import ControllerCaptcha from "../../../Controller/Captcha";
-import { FormBuilderData } from "../Data";
-import * as Core from "../../../Core";
-
-class Captcha extends Field {
- protected _getData(): FormBuilderData {
- if (ControllerCaptcha.has(this._fieldId)) {
- return ControllerCaptcha.getData(this._fieldId) as FormBuilderData;
- }
-
- return {};
- }
-
- protected _readField(): void {
- // does nothing
- }
-
- destroy(): void {
- if (ControllerCaptcha.has(this._fieldId)) {
- ControllerCaptcha.delete(this._fieldId);
- }
- }
-}
-
-Core.enableLegacyInheritance(Captcha);
-
-export = Captcha;
+++ /dev/null
-/**
- * Data handler for a form builder field in an Ajax form represented by checkboxes.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Checkboxes
- * @since 5.2
- */
-
-import Field from "./Field";
-import { FormBuilderData } from "../Data";
-import * as Core from "../../../Core";
-
-class Checkboxes extends Field {
- protected _fields: HTMLInputElement[];
-
- protected _getData(): FormBuilderData {
- const values = this._fields
- .map((input) => {
- if (input.checked) {
- return input.value;
- }
-
- return null;
- })
- .filter((v) => v !== null) as string[];
-
- return {
- [this._fieldId]: values,
- };
- }
-
- protected _readField(): void {
- this._fields = Array.from(document.querySelectorAll("input[name=" + this._fieldId + "]"));
- }
-}
-
-Core.enableLegacyInheritance(Checkboxes);
-
-export = Checkboxes;
+++ /dev/null
-/**
- * Data handler for a form builder field in an Ajax form that stores its value via a checkbox being
- * checked or not.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Checked
- * @since 5.2
- */
-
-import Field from "./Field";
-import { FormBuilderData } from "../Data";
-import * as Core from "../../../Core";
-
-class Checked extends Field {
- protected _getData(): FormBuilderData {
- return {
- [this._fieldId]: (this._field as HTMLInputElement).checked ? 1 : 0,
- };
- }
-}
-
-Core.enableLegacyInheritance(Checked);
-
-export = Checked;
+++ /dev/null
-/**
- * Handles the JavaScript part of the label form field.
- *
- * @author Alexander Ebert, Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Controller/Label
- * @since 5.2
- */
-
-import * as Core from "../../../../Core";
-import * as DomUtil from "../../../../Dom/Util";
-import * as Language from "../../../../Language";
-import UiDropdownSimple from "../../../../Ui/Dropdown/Simple";
-import { LabelFormFieldOptions } from "../../Data";
-
-class Label {
- protected readonly _formFieldContainer: HTMLElement;
- protected readonly _input: HTMLInputElement;
- protected readonly _labelChooser: HTMLElement;
- protected readonly _options: LabelFormFieldOptions;
-
- constructor(fieldId: string, labelId: string, options: Partial<LabelFormFieldOptions>) {
- this._formFieldContainer = document.getElementById(fieldId + "Container")!;
- this._labelChooser = this._formFieldContainer.getElementsByClassName("labelChooser")[0] as HTMLElement;
- this._options = Core.extend(
- {
- forceSelection: false,
- showWithoutSelection: false,
- },
- options,
- ) as LabelFormFieldOptions;
-
- this._input = document.createElement("input");
- this._input.type = "hidden";
- this._input.id = fieldId;
- this._input.name = fieldId;
- this._input.value = labelId;
- this._formFieldContainer.appendChild(this._input);
-
- const labelChooserId = DomUtil.identify(this._labelChooser);
-
- // init dropdown
- let dropdownMenu = UiDropdownSimple.getDropdownMenu(labelChooserId)!;
- if (dropdownMenu === null) {
- UiDropdownSimple.init(this._labelChooser.getElementsByClassName("dropdownToggle")[0] as HTMLElement);
- dropdownMenu = UiDropdownSimple.getDropdownMenu(labelChooserId)!;
- }
-
- let additionalOptionList: HTMLUListElement | null = null;
- if (this._options.showWithoutSelection || !this._options.forceSelection) {
- additionalOptionList = document.createElement("ul");
- dropdownMenu.appendChild(additionalOptionList);
-
- const dropdownDivider = document.createElement("li");
- dropdownDivider.classList.add("dropdownDivider");
- additionalOptionList.appendChild(dropdownDivider);
- }
-
- if (this._options.showWithoutSelection) {
- const listItem = document.createElement("li");
- listItem.dataset.labelId = "-1";
- this._blockScroll(listItem);
- additionalOptionList!.appendChild(listItem);
-
- const span = document.createElement("span");
- listItem.appendChild(span);
-
- const label = document.createElement("span");
- label.classList.add("badge", "label");
- label.innerHTML = Language.get("wcf.label.withoutSelection");
- span.appendChild(label);
- }
-
- if (!this._options.forceSelection) {
- const listItem = document.createElement("li");
- listItem.dataset.labelId = "0";
- this._blockScroll(listItem);
- additionalOptionList!.appendChild(listItem);
-
- const span = document.createElement("span");
- listItem.appendChild(span);
-
- const label = document.createElement("span");
- label.classList.add("badge", "label");
- label.innerHTML = Language.get("wcf.label.none");
- span.appendChild(label);
- }
-
- dropdownMenu.querySelectorAll("li:not(.dropdownDivider)").forEach((listItem: HTMLElement) => {
- listItem.addEventListener("click", (ev) => this._click(ev));
-
- if (labelId) {
- if (listItem.dataset.labelId === labelId) {
- this._selectLabel(listItem);
- }
- }
- });
- }
-
- _blockScroll(element: HTMLElement): void {
- element.addEventListener("wheel", (ev) => ev.preventDefault(), {
- passive: false,
- });
- }
-
- _click(event: Event): void {
- event.preventDefault();
-
- this._selectLabel(event.currentTarget as HTMLElement);
- }
-
- _selectLabel(label: HTMLElement): void {
- // save label
- let labelId = label.dataset.labelId;
- if (!labelId) {
- labelId = "0";
- }
-
- // replace button with currently selected label
- const displayLabel = label.querySelector("span > span")!;
- const button = this._labelChooser.querySelector(".dropdownToggle > span")!;
- button.className = displayLabel.className;
- button.textContent = displayLabel.textContent;
-
- this._input.value = labelId;
- }
-}
-
-Core.enableLegacyInheritance(Label);
-
-export = Label;
+++ /dev/null
-/**
- * Handles the JavaScript part of the rating form field.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Controller/Rating
- * @since 5.2
- */
-
-import * as Core from "../../../../Core";
-import * as Environment from "../../../../Environment";
-
-class Rating {
- protected readonly _activeCssClasses: string[];
- protected readonly _defaultCssClasses: string[];
- protected readonly _field: HTMLElement;
- protected readonly _input: HTMLInputElement;
- protected readonly _ratingElements: Map<string, HTMLElement>;
-
- constructor(fieldId: string, value: string, activeCssClasses: string[], defaultCssClasses: string[]) {
- this._field = document.getElementById(fieldId + "Container")!;
- if (this._field === null) {
- throw new Error("Unknown field with id '" + fieldId + "'");
- }
-
- this._input = document.createElement("input");
- this._input.id = fieldId;
- this._input.name = fieldId;
- this._input.type = "hidden";
- this._input.value = value;
- this._field.appendChild(this._input);
-
- this._activeCssClasses = activeCssClasses;
- this._defaultCssClasses = defaultCssClasses;
-
- this._ratingElements = new Map();
-
- const ratingList = this._field.querySelector(".ratingList")!;
- ratingList.addEventListener("mouseleave", () => this._restoreRating());
-
- ratingList.querySelectorAll("li").forEach((listItem) => {
- if (listItem.classList.contains("ratingMetaButton")) {
- listItem.addEventListener("click", (ev) => this._metaButtonClick(ev));
- listItem.addEventListener("mouseenter", () => this._restoreRating());
- } else {
- this._ratingElements.set(listItem.dataset.rating!, listItem);
-
- listItem.addEventListener("click", (ev) => this._listItemClick(ev));
- listItem.addEventListener("mouseenter", (ev) => this._listItemMouseEnter(ev));
- listItem.addEventListener("mouseleave", () => this._listItemMouseLeave());
- }
- });
- }
-
- /**
- * Saves the rating associated with the clicked rating element.
- */
- protected _listItemClick(event: Event): void {
- const target = event.currentTarget as HTMLElement;
- this._input.value = target.dataset.rating!;
-
- if (Environment.platform() !== "desktop") {
- this._restoreRating();
- }
- }
-
- /**
- * Updates the rating UI when hovering over a rating element.
- */
- protected _listItemMouseEnter(event: Event): void {
- const target = event.currentTarget as HTMLElement;
- const currentRating = target.dataset.rating!;
-
- this._ratingElements.forEach((ratingElement, rating) => {
- const icon = ratingElement.getElementsByClassName("icon")[0]! as HTMLElement;
-
- this._toggleIcon(icon, ~~rating <= ~~currentRating);
- });
- }
-
- /**
- * Updates the rating UI when leaving a rating element by changing all rating elements
- * to their default state.
- */
- protected _listItemMouseLeave(): void {
- this._ratingElements.forEach((ratingElement) => {
- const icon = ratingElement.getElementsByClassName("icon")[0]! as HTMLElement;
-
- this._toggleIcon(icon, false);
- });
- }
-
- /**
- * Handles clicks on meta buttons.
- */
- protected _metaButtonClick(event: Event): void {
- const target = event.currentTarget as HTMLElement;
- if (target.dataset.action === "removeRating") {
- this._input.value = "";
-
- this._listItemMouseLeave();
- }
- }
-
- /**
- * Updates the rating UI by changing the rating elements to the stored rating state.
- */
- protected _restoreRating(): void {
- this._ratingElements.forEach((ratingElement, rating) => {
- const icon = ratingElement.getElementsByClassName("icon")[0]! as HTMLElement;
-
- this._toggleIcon(icon, ~~rating <= ~~this._input.value);
- });
- }
-
- /**
- * Toggles the state of the given icon based on the given state parameter.
- */
- protected _toggleIcon(icon: HTMLElement, active = false): void {
- if (active) {
- icon.classList.remove(...this._defaultCssClasses);
- icon.classList.add(...this._activeCssClasses);
- } else {
- icon.classList.remove(...this._activeCssClasses);
- icon.classList.add(...this._defaultCssClasses);
- }
- }
-}
-
-Core.enableLegacyInheritance(Rating);
-
-export = Rating;
+++ /dev/null
-/**
- * Data handler for a date form builder field in an Ajax form.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Date
- * @since 5.2
- */
-import Field from "./Field";
-import { FormBuilderData } from "../Data";
-import DatePicker from "../../../Date/Picker";
-import * as Core from "../../../Core";
-
-class Date extends Field {
- protected _getData(): FormBuilderData {
- return {
- [this._fieldId]: DatePicker.getValue(this._field as HTMLInputElement),
- };
- }
-}
-
-Core.enableLegacyInheritance(Date);
-
-export = Date;
+++ /dev/null
-/**
- * Abstract implementation of a form field dependency.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
- * @since 5.2
- */
-
-import * as DependencyManager from "./Manager";
-import * as Core from "../../../../Core";
-
-abstract class FormBuilderFormFieldDependency {
- protected _dependentElement: HTMLElement;
- protected _field: HTMLElement;
- protected _fields: HTMLElement[];
- protected _noField?: HTMLInputElement;
-
- constructor(dependentElementId: string, fieldId: string) {
- this.init(dependentElementId, fieldId);
- }
-
- /**
- * Returns `true` if the dependency is met.
- */
- public checkDependency(): boolean {
- throw new Error(
- "Missing implementation of WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract.checkDependency!",
- );
- }
-
- /**
- * Return the node whose availability depends on the value of a field.
- */
- public getDependentNode(): HTMLElement {
- return this._dependentElement;
- }
-
- /**
- * Returns the field the availability of the element dependents on.
- */
- public getField(): HTMLElement {
- return this._field;
- }
-
- /**
- * Returns all fields requiring event listeners for this dependency to be properly resolved.
- */
- public getFields(): HTMLElement[] {
- return this._fields;
- }
-
- /**
- * Initializes the new dependency object.
- */
- protected init(dependentElementId: string, fieldId: string): void {
- this._dependentElement = document.getElementById(dependentElementId)!;
- if (this._dependentElement === null) {
- throw new Error("Unknown dependent element with container id '" + dependentElementId + "Container'.");
- }
-
- this._field = document.getElementById(fieldId)!;
- if (this._field === null) {
- this._fields = [];
- document.querySelectorAll("input[type=radio][name=" + fieldId + "]").forEach((field: HTMLInputElement) => {
- this._fields.push(field);
- });
-
- if (!this._fields.length) {
- document
- .querySelectorAll('input[type=checkbox][name="' + fieldId + '[]"]')
- .forEach((field: HTMLInputElement) => {
- this._fields.push(field);
- });
-
- if (!this._fields.length) {
- throw new Error("Unknown field with id '" + fieldId + "'.");
- }
- }
- } else {
- this._fields = [this._field];
-
- // Handle special case of boolean form fields that have two form fields.
- if (
- this._field.tagName === "INPUT" &&
- (this._field as HTMLInputElement).type === "radio" &&
- this._field.dataset.noInputId !== ""
- ) {
- this._noField = document.getElementById(this._field.dataset.noInputId!)! as HTMLInputElement;
- if (this._noField === null) {
- throw new Error("Cannot find 'no' input field for input field '" + fieldId + "'");
- }
-
- this._fields.push(this._noField);
- }
- }
-
- DependencyManager.addDependency(this);
- }
-}
-
-Core.enableLegacyInheritance(FormBuilderFormFieldDependency);
-
-export = FormBuilderFormFieldDependency;
+++ /dev/null
-/**
- * Abstract implementation of a handler for the visibility of container due the dependencies
- * of its children.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Abstract
- * @since 5.2
- */
-
-import * as DependencyManager from "../Manager";
-import * as Core from "../../../../../Core";
-
-abstract class Abstract {
- protected _container: HTMLElement;
-
- constructor(containerId: string) {
- this.init(containerId);
- }
-
- /**
- * Returns `true` if the dependency is met and thus if the container should be shown.
- */
- public checkContainer(): void {
- throw new Error(
- "Missing implementation of WoltLabSuite/Core/Form/Builder/Field/Dependency/Container.checkContainer!",
- );
- }
-
- /**
- * Initializes a new container dependency handler for the container with the given id.
- */
- protected init(containerId: string): void {
- if (typeof containerId !== "string") {
- throw new TypeError("Container id has to be a string.");
- }
-
- this._container = document.getElementById(containerId)!;
- if (this._container === null) {
- throw new Error("Unknown container with id '" + containerId + "'.");
- }
-
- DependencyManager.addContainerCheckCallback(() => this.checkContainer());
- }
-}
-
-Core.enableLegacyInheritance(Abstract);
-
-export = Abstract;
+++ /dev/null
-/**
- * Default implementation for a container visibility handler due to the dependencies of its
- * children that only considers the visibility of all of its children.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Default
- * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
- * @since 5.2
- */
-
-import Abstract from "./Abstract";
-import * as Core from "../../../../../Core";
-import * as DependencyManager from "../Manager";
-import DomUtil from "../../../../../Dom/Util";
-
-class Default extends Abstract {
- public checkContainer(): void {
- if (Core.stringToBool(this._container.dataset.ignoreDependencies || "")) {
- return;
- }
-
- // only consider containers that have not been hidden by their own dependencies
- if (DependencyManager.isHiddenByDependencies(this._container)) {
- return;
- }
-
- const containerIsVisible = !DomUtil.isHidden(this._container);
- const containerShouldBeVisible = Array.from(this._container.children).some((child: HTMLElement, index) => {
- // ignore container header for visibility considerations
- if (index === 0 && (child.tagName === "H2" || child.tagName === "HEADER")) {
- return false;
- }
-
- return !DomUtil.isHidden(child);
- });
-
- if (containerIsVisible !== containerShouldBeVisible) {
- if (containerShouldBeVisible) {
- DomUtil.show(this._container);
- } else {
- DomUtil.hide(this._container);
- }
-
- // check containers again to make sure parent containers can react to
- // changing the visibility of this container
- DependencyManager.checkContainers();
- }
- }
-}
-
-Core.enableLegacyInheritance(Default);
-
-export = Default;
+++ /dev/null
-/**
- * Container visibility handler implementation for a tab menu tab that, in addition to the
- * tab itself, also handles the visibility of the tab menu list item.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Tab
- * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
- * @since 5.2
- */
-
-import Abstract from "./Abstract";
-import * as DependencyManager from "../Manager";
-import * as DomUtil from "../../../../../Dom/Util";
-import * as UiTabMenu from "../../../../../Ui/TabMenu";
-import * as Core from "../../../../../Core";
-
-class Tab 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 = !DomUtil.isHidden(this._container);
- const containerShouldBeVisible = Array.from(this._container.children).some(
- (child: HTMLElement) => !DomUtil.isHidden(child),
- );
-
- if (containerIsVisible !== containerShouldBeVisible) {
- const tabMenuListItem = this._container.parentNode!.parentNode!.querySelector(
- "#" +
- DomUtil.identify(this._container.parentNode! as HTMLElement) +
- " > nav > ul > li[data-name=" +
- this._container.id +
- "]",
- )! as HTMLElement;
- if (tabMenuListItem === null) {
- throw new Error("Cannot find tab menu entry for tab '" + this._container.id + "'.");
- }
-
- if (containerShouldBeVisible) {
- DomUtil.show(this._container);
- DomUtil.show(tabMenuListItem);
- } else {
- DomUtil.hide(this._container);
- DomUtil.hide(tabMenuListItem);
-
- const tabMenu = UiTabMenu.getTabMenu(
- DomUtil.identify(tabMenuListItem.closest(".tabMenuContainer") as HTMLElement),
- )!;
-
- // check if currently active tab will be hidden
- if (tabMenu.getActiveTab() === tabMenuListItem) {
- tabMenu.selectFirstVisible();
- }
- }
-
- // Check containers again to make sure parent containers can react to changing the visibility
- // of this container.
- DependencyManager.checkContainers();
- }
- }
-}
-
-Core.enableLegacyInheritance(Tab);
-
-export = Tab;
+++ /dev/null
-/**
- * Container visibility handler implementation for a tab menu that checks visibility
- * based on the visibility of its tab menu list items.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/TabMenu
- * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
- * @since 5.2
- */
-
-import Abstract from "./Abstract";
-import * as DependencyManager from "../Manager";
-import * as DomUtil from "../../../../../Dom/Util";
-import * as UiTabMenu from "../../../../../Ui/TabMenu";
-import * as Core from "../../../../../Core";
-
-class TabMenu 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 = !DomUtil.isHidden(this._container);
- const listItems = this._container.parentNode!.querySelectorAll(
- "#" + DomUtil.identify(this._container) + " > nav > ul > li",
- );
- const containerShouldBeVisible = Array.from(listItems).some((child: HTMLElement) => !DomUtil.isHidden(child));
-
- if (containerIsVisible !== containerShouldBeVisible) {
- if (containerShouldBeVisible) {
- DomUtil.show(this._container);
-
- UiTabMenu.getTabMenu(DomUtil.identify(this._container))!.selectFirstVisible();
- } else {
- DomUtil.hide(this._container);
- }
-
- // check containers again to make sure parent containers can react to
- // changing the visibility of this container
- DependencyManager.checkContainers();
- }
- }
-}
-
-Core.enableLegacyInheritance(TabMenu);
-
-export = TabMenu;
+++ /dev/null
-/**
- * Form field dependency implementation that requires the value of a field to be empty.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Empty
- * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
- * @since 5.2
- */
-
-import Abstract from "./Abstract";
-import * as Core from "../../../../Core";
-
-class Empty extends Abstract {
- public checkDependency(): boolean {
- if (this._field !== null) {
- switch (this._field.tagName) {
- case "INPUT": {
- const field = this._field as HTMLInputElement;
- switch (field.type) {
- case "checkbox":
- return !field.checked;
-
- case "radio":
- if (this._noField && this._noField.checked) {
- return true;
- }
-
- return !field.checked;
-
- default:
- return field.value.trim().length === 0;
- }
- }
-
- case "SELECT": {
- const field = this._field as HTMLSelectElement;
- if (field.multiple) {
- return this._field.querySelectorAll("option:checked").length === 0;
- }
-
- return field.value == "0" || field.value.length === 0;
- }
-
- case "TEXTAREA": {
- return (this._field as HTMLTextAreaElement).value.trim().length === 0;
- }
- }
- }
-
- // Check that none of the fields are checked.
- return this._fields.every((field: HTMLInputElement) => !field.checked);
- }
-}
-
-Core.enableLegacyInheritance(Empty);
-
-export = Empty;
+++ /dev/null
-/**
- * Form field dependency implementation that requires that a button has not been clicked.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/IsNotClicked
- * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
- * @since 5.4
- */
-
-import Abstract from "./Abstract";
-import * as DependencyManager from "./Manager";
-
-export class IsNotClicked extends Abstract {
- constructor(dependentElementId: string, fieldId: string) {
- super(dependentElementId, fieldId);
-
- // To check for clicks after they occured, set `isClicked` in the field's data set and then
- // explicitly check the dependencies as the dependency manager itself does to listen to click
- // events.
- this._field.addEventListener("click", () => {
- this._field.dataset.isClicked = "1";
-
- DependencyManager.checkDependencies();
- });
- }
-
- checkDependency(): boolean {
- return this._field.dataset.isClicked !== "1";
- }
-}
-
-export default IsNotClicked;
+++ /dev/null
-/**
- * Manages form field dependencies.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Manager
- * @since 5.2
- */
-
-import DomUtil from "../../../../Dom/Util";
-import * as EventHandler from "../../../../Event/Handler";
-import FormBuilderFormFieldDependency from "./Abstract";
-
-type PropertiesMap = Map<string, string>;
-
-const _dependencyHiddenNodes = new Set<HTMLElement>();
-const _fields = new Map<string, HTMLElement>();
-const _forms = new WeakSet<HTMLElement>();
-const _nodeDependencies = new Map<string, FormBuilderFormFieldDependency[]>();
-const _validatedFieldProperties = new WeakMap<HTMLElement, PropertiesMap>();
-
-let _checkingContainers = false;
-let _checkContainersAgain = true;
-
-type Callback = (...args: any[]) => void;
-
-/**
- * Hides the given node because of its own dependencies.
- */
-function _hide(node: HTMLElement): void {
- DomUtil.hide(node);
- _dependencyHiddenNodes.add(node);
-
- // also hide tab menu entry
- if (node.classList.contains("tabMenuContent")) {
- node
- .parentNode!.querySelector(".tabMenu")!
- .querySelectorAll("li")
- .forEach((tabLink) => {
- if (tabLink.dataset.name === node.dataset.name) {
- DomUtil.hide(tabLink);
- }
- });
- }
-
- node.querySelectorAll("[max], [maxlength], [min], [required]").forEach((validatedField: HTMLInputElement) => {
- const properties = new Map<string, string>();
-
- const max = validatedField.getAttribute("max");
- if (max) {
- properties.set("max", max);
- validatedField.removeAttribute("max");
- }
-
- const maxlength = validatedField.getAttribute("maxlength");
- if (maxlength) {
- properties.set("maxlength", maxlength);
- validatedField.removeAttribute("maxlength");
- }
-
- const min = validatedField.getAttribute("min");
- if (min) {
- properties.set("min", min);
- validatedField.removeAttribute("min");
- }
-
- if (validatedField.required) {
- properties.set("required", "true");
- validatedField.removeAttribute("required");
- }
-
- _validatedFieldProperties.set(validatedField, properties);
- });
-}
-
-/**
- * Shows the given node because of its own dependencies.
- */
-function _show(node: HTMLElement): void {
- DomUtil.show(node);
- _dependencyHiddenNodes.delete(node);
-
- // also show tab menu entry
- if (node.classList.contains("tabMenuContent")) {
- node
- .parentNode!.querySelector(".tabMenu")!
- .querySelectorAll("li")
- .forEach((tabLink) => {
- if (tabLink.dataset.name === node.dataset.name) {
- DomUtil.show(tabLink);
- }
- });
- }
-
- node.querySelectorAll("input, select").forEach((validatedField: HTMLInputElement | HTMLSelectElement) => {
- // if a container is shown, ignore all fields that
- // have a hidden parent element within the container
- let parentNode = validatedField.parentNode! as HTMLElement;
- while (parentNode !== node && !DomUtil.isHidden(parentNode)) {
- parentNode = parentNode.parentNode! as HTMLElement;
- }
-
- if (parentNode === node && _validatedFieldProperties.has(validatedField)) {
- const properties = _validatedFieldProperties.get(validatedField)!;
-
- if (properties.has("max")) {
- validatedField.setAttribute("max", properties.get("max")!);
- }
- if (properties.has("maxlength")) {
- validatedField.setAttribute("maxlength", properties.get("maxlength")!);
- }
- if (properties.has("min")) {
- validatedField.setAttribute("min", properties.get("min")!);
- }
- if (properties.has("required")) {
- validatedField.setAttribute("required", "");
- }
-
- _validatedFieldProperties.delete(validatedField);
- }
- });
-}
-
-/**
- * Adds the given callback to the list of callbacks called when checking containers.
- */
-export function addContainerCheckCallback(callback: Callback): void {
- if (typeof callback !== "function") {
- throw new TypeError("Expected a valid callback for parameter 'callback'.");
- }
-
- EventHandler.add("com.woltlab.wcf.form.builder.dependency", "checkContainers", callback);
-}
-
-/**
- * Registers a new form field dependency.
- */
-export function addDependency(dependency: FormBuilderFormFieldDependency): void {
- const dependentNode = dependency.getDependentNode();
- if (!_nodeDependencies.has(dependentNode.id)) {
- _nodeDependencies.set(dependentNode.id, [dependency]);
- } else {
- _nodeDependencies.get(dependentNode.id)!.push(dependency);
- }
-
- dependency.getFields().forEach((field) => {
- const id = DomUtil.identify(field);
-
- if (!_fields.has(id)) {
- _fields.set(id, field);
-
- if (
- field.tagName === "INPUT" &&
- ((field as HTMLInputElement).type === "checkbox" ||
- (field as HTMLInputElement).type === "radio" ||
- (field as HTMLInputElement).type === "hidden")
- ) {
- field.addEventListener("change", () => checkDependencies());
- } else {
- field.addEventListener("input", () => checkDependencies());
- }
- }
- });
-}
-
-/**
- * Checks the containers for their availability.
- *
- * If this function is called while containers are currently checked, the containers
- * will be checked after the current check has been finished completely.
- */
-export function checkContainers(): void {
- // check if containers are currently being checked
- if (_checkingContainers === true) {
- // and if that is the case, calling this method indicates, that after the current round,
- // containters should be checked to properly propagate changes in children to their parents
- _checkContainersAgain = true;
-
- return;
- }
-
- // starting to check containers also resets the flag to check containers again after the current check
- _checkingContainers = true;
- _checkContainersAgain = false;
-
- EventHandler.fire("com.woltlab.wcf.form.builder.dependency", "checkContainers");
-
- // finish checking containers and check if containters should be checked again
- _checkingContainers = false;
- if (_checkContainersAgain) {
- checkContainers();
- }
-}
-
-/**
- * Checks if all dependencies are met.
- */
-export function checkDependencies(): void {
- const obsoleteNodeIds: string[] = [];
-
- _nodeDependencies.forEach((nodeDependencies, nodeId) => {
- const dependentNode = document.getElementById(nodeId);
- if (dependentNode === null) {
- obsoleteNodeIds.push(nodeId);
-
- return;
- }
-
- let dependenciesMet = true;
- nodeDependencies.forEach((dependency) => {
- if (!dependency.checkDependency()) {
- _hide(dependentNode);
- dependenciesMet = false;
- }
- });
-
- if (dependenciesMet) {
- _show(dependentNode);
- }
- });
-
- obsoleteNodeIds.forEach((id) => _nodeDependencies.delete(id));
-
- checkContainers();
-}
-
-/**
- * Returns `true` if the given node has been hidden because of its own dependencies.
- */
-export function isHiddenByDependencies(node: HTMLElement): boolean {
- if (_dependencyHiddenNodes.has(node)) {
- return true;
- }
-
- let returnValue = false;
- _dependencyHiddenNodes.forEach((hiddenNode) => {
- if (node.contains(hiddenNode)) {
- returnValue = true;
- }
- });
-
- return returnValue;
-}
-
-/**
- * Registers the form with the given id with the dependency manager.
- */
-export function register(formId: string): void {
- const form = document.getElementById(formId);
-
- if (form === null) {
- throw new Error("Unknown element with id '" + formId + "'");
- }
-
- if (_forms.has(form)) {
- throw new Error("Form with id '" + formId + "' has already been registered.");
- }
-
- _forms.add(form);
-}
-
-/**
- * Unregisters the form with the given id and all of its dependencies.
- */
-export function unregister(formId: string): void {
- const form = document.getElementById(formId);
-
- if (form === null) {
- throw new Error("Unknown element with id '" + formId + "'");
- }
-
- if (!_forms.has(form)) {
- throw new Error("Form with id '" + formId + "' has not been registered.");
- }
-
- _forms.delete(form);
-
- _dependencyHiddenNodes.forEach((hiddenNode) => {
- if (form.contains(hiddenNode)) {
- _dependencyHiddenNodes.delete(hiddenNode);
- }
- });
- _nodeDependencies.forEach((dependencies, nodeId) => {
- if (form.contains(document.getElementById(nodeId))) {
- _nodeDependencies.delete(nodeId);
- }
-
- dependencies.forEach((dependency) => {
- dependency.getFields().forEach((field) => {
- _fields.delete(field.id);
-
- _validatedFieldProperties.delete(field);
- });
- });
- });
-}
+++ /dev/null
-/**
- * Form field dependency implementation that requires the value of a field not to be empty.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/NonEmpty
- * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
- * @since 5.2
- */
-
-import Abstract from "./Abstract";
-import * as Core from "../../../../Core";
-
-class NonEmpty extends Abstract {
- public checkDependency(): boolean {
- if (this._field !== null) {
- switch (this._field.tagName) {
- case "INPUT": {
- const field = this._field as HTMLInputElement;
- switch (field.type) {
- case "checkbox":
- return field.checked;
-
- case "radio":
- if (this._noField && this._noField.checked) {
- return false;
- }
-
- return field.checked;
-
- default:
- return field.value.trim().length !== 0;
- }
- }
-
- case "SELECT": {
- const field = this._field as HTMLSelectElement;
- if (field.multiple) {
- return field.querySelectorAll("option:checked").length !== 0;
- }
-
- return field.value != "0" && field.value.length !== 0;
- }
-
- case "TEXTAREA": {
- return (this._field as HTMLTextAreaElement).value.trim().length !== 0;
- }
- }
- }
-
- // Check if any of the fields if checked.
- return this._fields.some((field: HTMLInputElement) => field.checked);
- }
-}
-
-Core.enableLegacyInheritance(NonEmpty);
-
-export = NonEmpty;
+++ /dev/null
-/**
- * Form field dependency implementation that requires a field to have a certain value.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Dependency/Value
- * @see module:WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract
- * @since 5.2
- */
-
-import Abstract from "./Abstract";
-import * as DependencyManager from "./Manager";
-import * as Core from "../../../../Core";
-
-class Value extends Abstract {
- protected _isNegated = false;
- protected _values?: string[];
-
- checkDependency(): boolean {
- if (!this._values) {
- throw new Error("Values have not been set.");
- }
-
- const values: string[] = [];
- if (this._field) {
- if (DependencyManager.isHiddenByDependencies(this._field)) {
- return false;
- }
-
- values.push((this._field as HTMLInputElement).value);
- } else {
- let hasCheckedField = true;
- this._fields.forEach((field: HTMLInputElement) => {
- if (field.checked) {
- if (DependencyManager.isHiddenByDependencies(field)) {
- hasCheckedField = false;
- return false;
- }
-
- values.push(field.value);
- }
- });
-
- if (!hasCheckedField) {
- return false;
- }
- }
-
- let foundMatch = false;
- this._values.forEach((value) => {
- values.forEach((selectedValue) => {
- if (value == selectedValue) {
- foundMatch = true;
- }
- });
- });
-
- if (foundMatch) {
- return !this._isNegated;
- }
-
- return this._isNegated;
- }
-
- /**
- * Sets if the field value may not have any of the set values.
- */
- negate(negate: boolean): Value {
- this._isNegated = negate;
-
- return this;
- }
-
- /**
- * Sets the possible values the field may have for the dependency to be met.
- */
- values(values: string[]): Value {
- this._values = values;
-
- return this;
- }
-}
-
-Core.enableLegacyInheritance(Value);
-
-export = Value;
+++ /dev/null
-/**
- * Data handler for a form builder field in an Ajax form.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Field
- * @since 5.2
- */
-
-import * as Core from "../../../Core";
-import { FormBuilderData } from "../Data";
-
-class Field {
- protected _fieldId: string;
- protected _field: HTMLElement | null;
-
- constructor(fieldId: string) {
- this.init(fieldId);
- }
-
- /**
- * Initializes the field.
- */
- protected init(fieldId: string): void {
- this._fieldId = fieldId;
-
- this._readField();
- }
-
- /**
- * Returns the current data of the field or a promise returning the current data
- * of the field.
- *
- * @return {Promise|data}
- */
- protected _getData(): FormBuilderData {
- throw new Error("Missing implementation of WoltLabSuite/Core/Form/Builder/Field/Field._getData!");
- }
-
- /**
- * Reads the field's HTML element.
- */
- protected _readField(): void {
- this._field = document.getElementById(this._fieldId);
-
- if (this._field === null) {
- throw new Error("Unknown field with id '" + this._fieldId + "'.");
- }
- }
-
- /**
- * Destroys the field.
- *
- * This function is useful for remove registered elements from other APIs like dialogs.
- */
- public destroy(): void {
- // does nothinbg
- }
-
- /**
- * Returns a promise providing the current data of the field.
- */
- public getData(): Promise<FormBuilderData> {
- return Promise.resolve(this._getData());
- }
-
- /**
- * Returns the id of the field.
- */
- public getId(): string {
- return this._fieldId;
- }
-}
-
-Core.enableLegacyInheritance(Field);
-
-export = Field;
+++ /dev/null
-/**
- * Data handler for an item list form builder field in an Ajax form.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/ItemList
- * @since 5.2
- */
-
-import Field from "./Field";
-import * as UiItemListStatic from "../../../Ui/ItemList/Static";
-import { FormBuilderData } from "../Data";
-import * as Core from "../../../Core";
-
-class ItemList extends Field {
- protected _getData(): FormBuilderData {
- const values: string[] = [];
- UiItemListStatic.getValues(this._fieldId).forEach((item) => {
- if (item.objectId) {
- values[item.objectId] = item.value;
- } else {
- values.push(item.value);
- }
- });
-
- return {
- [this._fieldId]: values,
- };
- }
-}
-
-Core.enableLegacyInheritance(ItemList);
-
-export = ItemList;
+++ /dev/null
-/**
- * Data handler for a content language form builder field in an Ajax form.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Language/ContentLanguage
- * @since 5.2
- */
-
-import Value from "../Value";
-import * as LanguageChooser from "../../../../Language/Chooser";
-import * as Core from "../../../../Core";
-
-class ContentLanguage extends Value {
- public destroy(): void {
- LanguageChooser.removeChooser(this._fieldId);
- }
-}
-
-Core.enableLegacyInheritance(ContentLanguage);
-
-export = ContentLanguage;
+++ /dev/null
-/**
- * Data handler for a radio button form builder field in an Ajax form.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/RadioButton
- * @since 5.2
- */
-
-import Field from "./Field";
-import { FormBuilderData } from "../Data";
-import * as Core from "../../../Core";
-
-class RadioButton extends Field {
- protected _fields: HTMLInputElement[];
-
- protected _getData(): FormBuilderData {
- const data = {};
-
- this._fields.some((input) => {
- if (input.checked) {
- data[this._fieldId] = input.value;
- return true;
- }
-
- return false;
- });
-
- return data;
- }
-
- protected _readField(): void {
- this._fields = Array.from(document.querySelectorAll("input[name=" + this._fieldId + "]"));
- }
-}
-
-Core.enableLegacyInheritance(RadioButton);
-
-export = RadioButton;
+++ /dev/null
-import Field from "./Field";
-import { FormBuilderData } from "../Data";
-import * as Core from "../../../Core";
-
-class SimpleAcl extends Field {
- protected _getData(): FormBuilderData {
- const groupIds = Array.from(document.querySelectorAll('input[name="' + this._fieldId + '[group][]"]')).map(
- (input: HTMLInputElement) => input.value,
- );
-
- const usersIds = Array.from(document.querySelectorAll('input[name="' + this._fieldId + '[user][]"]')).map(
- (input: HTMLInputElement) => input.value,
- );
-
- return {
- [this._fieldId]: {
- group: groupIds,
- user: usersIds,
- },
- };
- }
-
- protected _readField(): void {
- // does nothing
- }
-}
-
-Core.enableLegacyInheritance(SimpleAcl);
-
-export = SimpleAcl;
+++ /dev/null
-/**
- * Data handler for a tag form builder field in an Ajax form.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Tag
- * @since 5.2
- */
-
-import Field from "./Field";
-import * as UiItemList from "../../../Ui/ItemList";
-import { FormBuilderData } from "../Data";
-import * as Core from "../../../Core";
-
-class Tag extends Field {
- protected _getData(): FormBuilderData {
- const values: string[] = UiItemList.getValues(this._fieldId).map((item) => item.value);
-
- return {
- [this._fieldId]: values,
- };
- }
-}
-
-Core.enableLegacyInheritance(Tag);
-
-export = Tag;
+++ /dev/null
-/**
- * Data handler for a user form builder field in an Ajax form.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/User
- * @since 5.2
- */
-
-import Field from "./Field";
-import { FormBuilderData } from "../Data";
-import * as Core from "../../../Core";
-import * as UiItemList from "../../../Ui/ItemList/Static";
-
-class User extends Field {
- protected _getData(): FormBuilderData {
- const usernames = UiItemList.getValues(this._fieldId)
- .map((item) => {
- if (item.objectId) {
- return item.value;
- }
-
- return null;
- })
- .filter((v) => v !== null) as string[];
-
- return {
- [this._fieldId]: usernames.join(","),
- };
- }
-}
-
-Core.enableLegacyInheritance(User);
-
-export = User;
+++ /dev/null
-/**
- * Data handler for a form builder field in an Ajax form that stores its value in an input's value
- * attribute.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Value
- * @since 5.2
- */
-
-import Field from "./Field";
-import { FormBuilderData } from "../Data";
-import * as Core from "../../../Core";
-
-class Value extends Field {
- protected _getData(): FormBuilderData {
- return {
- [this._fieldId]: (this._field as HTMLInputElement).value,
- };
- }
-}
-
-Core.enableLegacyInheritance(Value);
-
-export = Value;
+++ /dev/null
-/**
- * Data handler for an i18n form builder field in an Ajax form that stores its value in an input's
- * value attribute.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/ValueI18n
- * @since 5.2
- */
-
-import Field from "./Field";
-import { FormBuilderData } from "../Data";
-import * as LanguageInput from "../../../Language/Input";
-import * as Core from "../../../Core";
-
-class ValueI18n extends Field {
- protected _getData(): FormBuilderData {
- const data = {};
-
- const values = LanguageInput.getValues(this._fieldId);
- if (values.size > 1) {
- values.forEach((value, key) => {
- data[this._fieldId + "_i18n"][key] = value;
- });
- } else {
- data[this._fieldId] = values.get(0);
- }
-
- return data;
- }
-
- destroy(): void {
- LanguageInput.unregister(this._fieldId);
- }
-}
-
-Core.enableLegacyInheritance(ValueI18n);
-
-export = ValueI18n;
+++ /dev/null
-/**
- * Data handler for a wysiwyg attachment form builder field that stores the temporary hash.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Field/Wysiwyg/Attachment
- * @since 5.2
- */
-
-import Value from "../Value";
-import * as Core from "../../../../Core";
-
-class Attachment extends Value {
- constructor(fieldId: string) {
- super(fieldId + "_tmpHash");
- }
-}
-
-Core.enableLegacyInheritance(Attachment);
-
-export = Attachment;
+++ /dev/null
-/**
- * Data handler for the poll options.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Poll
- * @since 5.2
- */
-
-import Field from "../Field";
-import * as Core from "../../../../Core";
-import { FormBuilderData } from "../../Data";
-import UiPollEditor from "../../../../Ui/Poll/Editor";
-
-class Poll extends Field {
- protected _pollEditor: UiPollEditor;
-
- protected _getData(): FormBuilderData {
- return this._pollEditor.getData();
- }
-
- protected _readField(): void {
- // does nothing
- }
-
- public setPollEditor(pollEditor: UiPollEditor): void {
- this._pollEditor = pollEditor;
- }
-}
-
-Core.enableLegacyInheritance(Poll);
-
-export = Poll;
+++ /dev/null
-/**
- * Manager for registered Ajax forms and its fields that can be used to retrieve the current data
- * of the registered forms.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Form/Builder/Manager
- * @since 5.2
- */
-
-import * as Core from "../../Core";
-import * as EventHandler from "../../Event/Handler";
-import Field from "./Field/Field";
-import * as DependencyManager from "./Field/Dependency/Manager";
-import { FormBuilderData } from "./Data";
-
-type FormId = string;
-type FieldId = string;
-
-const _fields = new Map<FormId, Map<FieldId, Field>>();
-const _forms = new Map<FormId, HTMLElement>();
-
-/**
- * Returns a promise returning the data of the form with the given id.
- */
-export function getData(formId: FieldId): Promise<FormBuilderData> {
- if (!hasForm(formId)) {
- throw new Error("Unknown form with id '" + formId + "'.");
- }
-
- const promises: Promise<FormBuilderData>[] = [];
-
- _fields.get(formId)!.forEach((field) => {
- const fieldData = field.getData();
-
- if (!(fieldData instanceof Promise)) {
- throw new TypeError("Data for field with id '" + field.getId() + "' is no promise.");
- }
-
- promises.push(fieldData);
- });
-
- return Promise.all(promises).then((promiseData: FormBuilderData[]) => {
- return promiseData.reduce((carry, current) => Core.extend(carry, current), {});
- });
-}
-
-/**
- * Returns the registered form field with given.
- *
- * @since 5.2.3
- */
-export function getField(formId: FieldId, fieldId: FieldId): Field {
- if (!hasField(formId, fieldId)) {
- throw new Error("Unknown field with id '" + formId + "' for form with id '" + fieldId + "'.");
- }
-
- return _fields.get(formId)!.get(fieldId)!;
-}
-
-/**
- * Returns the registered form with given id.
- */
-export function getForm(formId: FieldId): HTMLElement {
- if (!hasForm(formId)) {
- throw new Error("Unknown form with id '" + formId + "'.");
- }
-
- return _forms.get(formId)!;
-}
-
-/**
- * Returns `true` if a field with the given id has been registered for the form with the given id
- * and `false` otherwise.
- */
-export function hasField(formId: FieldId, fieldId: FieldId): boolean {
- if (!hasForm(formId)) {
- throw new Error("Unknown form with id '" + formId + "'.");
- }
-
- return _fields.get(formId)!.has(fieldId);
-}
-
-/**
- * Returns `true` if a form with the given id has been registered and `false` otherwise.
- */
-export function hasForm(formId: FieldId): boolean {
- return _forms.has(formId);
-}
-
-/**
- * Registers the given field for the form with the given id.
- */
-export function registerField(formId: FieldId, field: Field): void {
- if (!hasForm(formId)) {
- throw new Error("Unknown form with id '" + formId + "'.");
- }
-
- if (!(field instanceof Field)) {
- throw new Error("Add field is no instance of 'WoltLabSuite/Core/Form/Builder/Field/Field'.");
- }
-
- const fieldId = field.getId();
-
- if (hasField(formId, fieldId)) {
- throw new Error(
- "Form field with id '" + fieldId + "' has already been registered for form with id '" + formId + "'.",
- );
- }
-
- _fields.get(formId)!.set(fieldId, field);
-
- EventHandler.fire("WoltLabSuite/Core/Form/Builder/Manager", "registerField", {
- field: field,
- formId: formId,
- });
-}
-
-/**
- * Registers the form with the given id.
- */
-export function registerForm(formId: FieldId): void {
- if (hasForm(formId)) {
- throw new Error("Form with id '" + formId + "' has already been registered.");
- }
-
- const form = document.getElementById(formId);
- if (form === null) {
- throw new Error("Unknown form with id '" + formId + "'.");
- }
-
- _forms.set(formId, form);
- _fields.set(formId, new Map<FieldId, Field>());
-
- EventHandler.fire("WoltLabSuite/Core/Form/Builder/Manager", "registerForm", {
- formId: formId,
- });
-}
-
-/**
- * Unregisters the form with the given id.
- */
-export function unregisterForm(formId: FieldId): void {
- if (!hasForm(formId)) {
- throw new Error("Unknown form with id '" + formId + "'.");
- }
-
- EventHandler.fire("WoltLabSuite/Core/Form/Builder/Manager", "beforeUnregisterForm", {
- formId: formId,
- });
-
- _forms.delete(formId);
-
- _fields.get(formId)!.forEach(function (field) {
- field.destroy();
- });
-
- _fields.delete(formId);
-
- DependencyManager.unregister(formId);
-
- EventHandler.fire("WoltLabSuite/Core/Form/Builder/Manager", "afterUnregisterForm", {
- formId: formId,
- });
-}
+++ /dev/null
-/**
- * Generates plural phrases for the `plural` template plugin.
- *
- * @author Matthias Schmidt, Marcel Werk
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/I18n/Plural
- */
-
-import * as StringUtil from "../StringUtil";
-
-const enum Category {
- Few = "few",
- Many = "many",
- One = "one",
- Other = "other",
- Two = "two",
- Zero = "zero",
-}
-
-const Languages = {
- // Afrikaans
- af(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Amharic
- am(n: number): Category | undefined {
- const i = Math.floor(Math.abs(n));
- if (n == 1 || i === 0) {
- return Category.One;
- }
- },
-
- // Arabic
- ar(n: number): Category | undefined {
- if (n == 0) {
- return Category.Zero;
- }
- if (n == 1) {
- return Category.One;
- }
- if (n == 2) {
- return Category.Two;
- }
-
- const mod100 = n % 100;
- if (mod100 >= 3 && mod100 <= 10) {
- return Category.Few;
- }
- if (mod100 >= 11 && mod100 <= 99) {
- return Category.Many;
- }
- },
-
- // Assamese
- as(n: number): Category | undefined {
- const i = Math.floor(Math.abs(n));
- if (n == 1 || i === 0) {
- return Category.One;
- }
- },
-
- // Azerbaijani
- az(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Belarusian
- be(n: number): Category | undefined {
- const mod10 = n % 10;
- const mod100 = n % 100;
-
- if (mod10 == 1 && mod100 != 11) {
- return Category.One;
- }
- if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) {
- return Category.Few;
- }
- if (mod10 == 0 || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 11 && mod100 <= 14)) {
- return Category.Many;
- }
- },
-
- // Bulgarian
- bg(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Bengali
- bn(n: number): Category | undefined {
- const i = Math.floor(Math.abs(n));
- if (n == 1 || i === 0) {
- return Category.One;
- }
- },
-
- // Tibetan
- bo(_n: number): Category | undefined {
- return undefined;
- },
-
- // Bosnian
- bs(n: number): Category | undefined {
- const v = Plural.getV(n);
- const f = Plural.getF(n);
- const mod10 = n % 10;
- const mod100 = n % 100;
- const fMod10 = f % 10;
- const fMod100 = f % 100;
-
- if ((v == 0 && mod10 == 1 && mod100 != 11) || (fMod10 == 1 && fMod100 != 11)) {
- return Category.One;
- }
- if (
- (v == 0 && mod10 >= 2 && mod10 <= 4 && mod100 >= 12 && mod100 <= 14) ||
- (fMod10 >= 2 && fMod10 <= 4 && fMod100 >= 12 && fMod100 <= 14)
- ) {
- return Category.Few;
- }
- },
-
- // Czech
- cs(n: number): Category | undefined {
- const v = Plural.getV(n);
-
- if (n == 1 && v === 0) {
- return Category.One;
- }
- if (n >= 2 && n <= 4 && v === 0) {
- return Category.Few;
- }
- if (v === 0) {
- return Category.Many;
- }
- },
-
- // Welsh
- cy(n: number): Category | undefined {
- if (n == 0) {
- return Category.Zero;
- }
- if (n == 1) {
- return Category.One;
- }
- if (n == 2) {
- return Category.Two;
- }
- if (n == 3) {
- return Category.Few;
- }
- if (n == 6) {
- return Category.Many;
- }
- },
-
- // Danish
- da(n: number): Category | undefined {
- if (n > 0 && n < 2) {
- return Category.One;
- }
- },
-
- // Greek
- el(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Catalan (ca)
- // German (de)
- // English (en)
- // Estonian (et)
- // Finnish (fi)
- // Italian (it)
- // Dutch (nl)
- // Swedish (sv)
- // Swahili (sw)
- // Urdu (ur)
- en(n: number): Category | undefined {
- if (n == 1 && Plural.getV(n) === 0) {
- return Category.One;
- }
- },
-
- // Spanish
- es(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Basque
- eu(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Persian
- fa(n: number): Category | undefined {
- if (n >= 0 && n <= 1) {
- return Category.One;
- }
- },
-
- // French
- fr(n: number): Category | undefined {
- if (n >= 0 && n < 2) {
- return Category.One;
- }
- },
-
- // Irish
- ga(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- if (n == 2) {
- return Category.Two;
- }
- if (n == 3 || n == 4 || n == 5 || n == 6) {
- return Category.Few;
- }
- if (n == 7 || n == 8 || n == 9 || n == 10) {
- return Category.Many;
- }
- },
-
- // Gujarati
- gu(n: number): Category | undefined {
- if (n >= 0 && n <= 1) {
- return Category.One;
- }
- },
-
- // Hebrew
- he(n: number): Category | undefined {
- const v = Plural.getV(n);
-
- if (n == 1 && v === 0) {
- return Category.One;
- }
- if (n == 2 && v === 0) {
- return Category.Two;
- }
- if (n > 10 && v === 0 && n % 10 == 0) {
- return Category.Many;
- }
- },
-
- // Hindi
- hi(n: number): Category | undefined {
- if (n >= 0 && n <= 1) {
- return Category.One;
- }
- },
-
- // Croatian
- hr(n: number): Category | undefined {
- // same as Bosnian
- return Plural.bs(n);
- },
-
- // Hungarian
- hu(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Armenian
- hy(n: number): Category | undefined {
- if (n >= 0 && n < 2) {
- return Category.One;
- }
- },
-
- // Indonesian
- id(_n: number): Category | undefined {
- return undefined;
- },
-
- // Icelandic
- is(n: number): Category | undefined {
- const f = Plural.getF(n);
-
- if ((f === 0 && n % 10 === 1 && !(n % 100 === 11)) || !(f === 0)) {
- return Category.One;
- }
- },
-
- // Japanese
- ja(_n: number): Category | undefined {
- return undefined;
- },
-
- // Javanese
- jv(_n: number): Category | undefined {
- return undefined;
- },
-
- // Georgian
- ka(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Kazakh
- kk(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Khmer
- km(_n: number): Category | undefined {
- return undefined;
- },
-
- // Kannada
- kn(n: number): Category | undefined {
- if (n >= 0 && n <= 1) {
- return Category.One;
- }
- },
-
- // Korean
- ko(_n: number): Category | undefined {
- return undefined;
- },
-
- // Kurdish
- ku(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Kyrgyz
- ky(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Luxembourgish
- lb(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Lao
- lo(_n: number): Category | undefined {
- return undefined;
- },
-
- // Lithuanian
- lt(n: number): Category | undefined {
- const mod10 = n % 10;
- const mod100 = n % 100;
-
- if (mod10 == 1 && !(mod100 >= 11 && mod100 <= 19)) {
- return Category.One;
- }
- if (mod10 >= 2 && mod10 <= 9 && !(mod100 >= 11 && mod100 <= 19)) {
- return Category.Few;
- }
- if (Plural.getF(n) != 0) {
- return Category.Many;
- }
- },
-
- // Latvian
- lv(n: number): Category | undefined {
- const mod10 = n % 10;
- const mod100 = n % 100;
- const v = Plural.getV(n);
- const f = Plural.getF(n);
- const fMod10 = f % 10;
- const fMod100 = f % 100;
-
- if (mod10 == 0 || (mod100 >= 11 && mod100 <= 19) || (v == 2 && fMod100 >= 11 && fMod100 <= 19)) {
- return Category.Zero;
- }
- if ((mod10 == 1 && mod100 != 11) || (v == 2 && fMod10 == 1 && fMod100 != 11) || (v != 2 && fMod10 == 1)) {
- return Category.One;
- }
- },
-
- // Macedonian
- mk(n: number): Category | undefined {
- return Plural.bs(n);
- },
-
- // Malayalam
- ml(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Mongolian
- mn(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Marathi
- mr(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Malay
- ms(_n: number): Category | undefined {
- return undefined;
- },
-
- // Maltese
- mt(n: number): Category | undefined {
- const mod100 = n % 100;
-
- if (n == 1) {
- return Category.One;
- }
- if (n == 0 || (mod100 >= 2 && mod100 <= 10)) {
- return Category.Few;
- }
- if (mod100 >= 11 && mod100 <= 19) {
- return Category.Many;
- }
- },
-
- // Burmese
- my(_n: number): Category | undefined {
- return undefined;
- },
-
- // Norwegian
- no(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Nepali
- ne(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Odia
- or(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Punjabi
- pa(n: number): Category | undefined {
- if (n == 1 || n == 0) {
- return Category.One;
- }
- },
-
- // Polish
- pl(n: number): Category | undefined {
- const v = Plural.getV(n);
- const mod10 = n % 10;
- const mod100 = n % 100;
-
- if (n == 1 && v == 0) {
- return Category.One;
- }
- if (v == 0 && mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) {
- return Category.Few;
- }
- if (
- v == 0 &&
- ((n != 1 && mod10 >= 0 && mod10 <= 1) || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 12 && mod100 <= 14))
- ) {
- return Category.Many;
- }
- },
-
- // Pashto
- ps(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Portuguese
- pt(n: number): Category | undefined {
- if (n >= 0 && n < 2) {
- return Category.One;
- }
- },
-
- // Romanian
- ro(n: number): Category | undefined {
- const v = Plural.getV(n);
- const mod100 = n % 100;
-
- if (n == 1 && v === 0) {
- return Category.One;
- }
- if (v != 0 || n == 0 || (mod100 >= 2 && mod100 <= 19)) {
- return Category.Few;
- }
- },
-
- // Russian
- ru(n: number): Category | undefined {
- const mod10 = n % 10;
- const mod100 = n % 100;
-
- if (Plural.getV(n) == 0) {
- if (mod10 == 1 && mod100 != 11) {
- return Category.One;
- }
- if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) {
- return Category.Few;
- }
- if (mod10 == 0 || (mod10 >= 5 && mod10 <= 9) || (mod100 >= 11 && mod100 <= 14)) {
- return Category.Many;
- }
- }
- },
-
- // Sindhi
- sd(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Sinhala
- si(n: number): Category | undefined {
- if (n == 0 || n == 1 || (Math.floor(n) == 0 && Plural.getF(n) == 1)) {
- return Category.One;
- }
- },
-
- // Slovak
- sk(n: number): Category | undefined {
- // same as Czech
- return Plural.cs(n);
- },
-
- // Slovenian
- sl(n: number): Category | undefined {
- const v = Plural.getV(n);
- const mod100 = n % 100;
-
- if (v == 0 && mod100 == 1) {
- return Category.One;
- }
- if (v == 0 && mod100 == 2) {
- return Category.Two;
- }
- if ((v == 0 && (mod100 == 3 || mod100 == 4)) || v != 0) {
- return Category.Few;
- }
- },
-
- // Albanian
- sq(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Serbian
- sr(n: number): Category | undefined {
- // same as Bosnian
- return Plural.bs(n);
- },
-
- // Tamil
- ta(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Telugu
- te(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Tajik
- tg(_n: number): Category | undefined {
- return undefined;
- },
-
- // Thai
- th(_n: number): Category | undefined {
- return undefined;
- },
-
- // Turkmen
- tk(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Turkish
- tr(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Uyghur
- ug(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Ukrainian
- uk(n: number): Category | undefined {
- // same as Russian
- return Plural.ru(n);
- },
-
- // Uzbek
- uz(n: number): Category | undefined {
- if (n == 1) {
- return Category.One;
- }
- },
-
- // Vietnamese
- vi(_n: number): Category | undefined {
- return undefined;
- },
-
- // Chinese
- zh(_n: number): Category | undefined {
- return undefined;
- },
-};
-
-type ValidLanguage = keyof typeof Languages;
-
-// Note: This cannot be an interface due to the computed property.
-type Parameters = {
- value: number;
- other: string;
-} & {
- [category in Category]?: string;
-} & {
- [number: number]: string;
- };
-
-const Plural = {
- /**
- * Returns the plural category for the given value.
- */
- getCategory(value: number, languageCode?: ValidLanguage): Category {
- if (!languageCode) {
- languageCode = document.documentElement.lang as ValidLanguage;
- }
-
- // Fallback: handle unknown languages as English
- if (typeof Plural[languageCode] !== "function") {
- languageCode = "en";
- }
-
- const category = Plural[languageCode](value);
- if (category) {
- return category;
- }
-
- return Category.Other;
- },
-
- /**
- * Returns the value for a `plural` element used in the template.
- *
- * @see wcf\system\template\plugin\PluralFunctionTemplatePlugin::execute()
- */
- getCategoryFromTemplateParameters(parameters: Parameters): string {
- if (!parameters["value"]) {
- throw new Error("Missing parameter value");
- }
- if (!parameters["other"]) {
- throw new Error("Missing parameter other");
- }
-
- let value = parameters["value"];
- if (Array.isArray(value)) {
- value = value.length;
- }
-
- // handle numeric attributes
- const numericAttribute = Object.keys(parameters).find((key) => {
- return key.toString() === (~~key).toString() && key.toString() === value.toString();
- });
-
- if (numericAttribute) {
- return numericAttribute;
- }
-
- let category = Plural.getCategory(value);
- if (!parameters[category]) {
- category = Category.Other;
- }
-
- const string = parameters[category]!;
- if (string.indexOf("#") !== -1) {
- return string.replace("#", StringUtil.formatNumeric(value));
- }
-
- return string;
- },
-
- /**
- * `f` is the fractional number as a whole number (1.234 yields 234)
- */
- getF(n: number): number {
- const tmp = n.toString();
- const pos = tmp.indexOf(".");
- if (pos === -1) {
- return 0;
- }
-
- return parseInt(tmp.substr(pos + 1), 10);
- },
-
- /**
- * `v` represents the number of digits of the fractional part (1.234 yields 3)
- */
- getV(n: number): number {
- return n.toString().replace(/^[^.]*\.?/, "").length;
- },
-
- ...Languages,
-};
-
-export = Plural;
+++ /dev/null
-/**
- * Provides helper functions for Exif metadata handling.
- *
- * @author Tim Duesterhus, Maximilian Mader
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Image/ExifUtil
- */
-
-enum Tag {
- SOI = 0xd8, // Start of image
- APP0 = 0xe0, // JFIF tag
- APP1 = 0xe1, // EXIF / XMP
- APP2 = 0xe2, // General purpose tag
- APP3 = 0xe3, // General purpose tag
- APP4 = 0xe4, // General purpose tag
- APP5 = 0xe5, // General purpose tag
- APP6 = 0xe6, // General purpose tag
- APP7 = 0xe7, // General purpose tag
- APP8 = 0xe8, // General purpose tag
- APP9 = 0xe9, // General purpose tag
- APP10 = 0xea, // General purpose tag
- APP11 = 0xeb, // General purpose tag
- APP12 = 0xec, // General purpose tag
- APP13 = 0xed, // General purpose tag
- APP14 = 0xee, // Often used to store copyright information
- COM = 0xfe, // Comments
-}
-
-// Known sequence signatures
-const _signatureEXIF = "Exif";
-const _signatureXMP = "http://ns.adobe.com/xap/1.0/";
-const _signatureXMPExtension = "http://ns.adobe.com/xmp/extension/";
-
-function isExifSignature(signature: string): boolean {
- return signature === _signatureEXIF || signature === _signatureXMP || signature === _signatureXMPExtension;
-}
-
-function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array {
- let offset = 0;
- const length = arrays.reduce((sum, array) => sum + array.length, 0);
-
- const result = new Uint8Array(length);
- arrays.forEach((array) => {
- result.set(array, offset);
- offset += array.length;
- });
-
- return result;
-}
-
-async function blobToUint8(blob: Blob | File): Promise<Uint8Array> {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
-
- reader.addEventListener("error", () => {
- reader.abort();
- reject(reader.error);
- });
-
- reader.addEventListener("load", () => {
- resolve(new Uint8Array(reader.result! as ArrayBuffer));
- });
-
- reader.readAsArrayBuffer(blob);
- });
-}
-
-/**
- * Extracts the EXIF / XMP sections of a JPEG blob.
- */
-export async function getExifBytesFromJpeg(blob: Blob | File): Promise<Exif> {
- if (!((blob as any) instanceof Blob) && !(blob instanceof File)) {
- throw new TypeError("The argument must be a Blob or a File");
- }
-
- const bytes = await blobToUint8(blob);
-
- let exif = new Uint8Array(0);
-
- if (bytes[0] !== 0xff && bytes[1] !== Tag.SOI) {
- throw new Error("Not a JPEG");
- }
-
- for (let i = 2; i < bytes.length; ) {
- // each sequence starts with 0xFF
- if (bytes[i] !== 0xff) break;
-
- const length = 2 + ((bytes[i + 2] << 8) | bytes[i + 3]);
-
- // Check if the next byte indicates an EXIF sequence
- if (bytes[i + 1] === Tag.APP1) {
- let signature = "";
- for (let j = i + 4; bytes[j] !== 0 && j < bytes.length; j++) {
- signature += String.fromCharCode(bytes[j]);
- }
-
- // Only copy Exif and XMP data
- if (isExifSignature(signature)) {
- // append the found EXIF sequence, usually only a single EXIF (APP1) sequence should be defined
- const sequence = bytes.slice(i, length + i);
- exif = concatUint8Arrays(exif, sequence);
- }
- }
-
- i += length;
- }
-
- return exif;
-}
-
-/**
- * Removes all EXIF and XMP sections of a JPEG blob.
- */
-export async function removeExifData(blob: Blob | File): Promise<Blob> {
- if (!((blob as any) instanceof Blob) && !(blob instanceof File)) {
- throw new TypeError("The argument must be a Blob or a File");
- }
-
- const bytes = await blobToUint8(blob);
-
- if (bytes[0] !== 0xff && bytes[1] !== Tag.SOI) {
- throw new Error("Not a JPEG");
- }
-
- let result = bytes;
- for (let i = 2; i < result.length; ) {
- // each sequence starts with 0xFF
- if (result[i] !== 0xff) break;
-
- const length = 2 + ((result[i + 2] << 8) | result[i + 3]);
-
- // Check if the next byte indicates an EXIF sequence
- if (result[i + 1] === Tag.APP1) {
- let signature = "";
- for (let j = i + 4; result[j] !== 0 && j < result.length; j++) {
- signature += String.fromCharCode(result[j]);
- }
-
- // Only remove known signatures
- if (isExifSignature(signature)) {
- const start = result.slice(0, i);
- const end = result.slice(i + length);
- result = concatUint8Arrays(start, end);
- } else {
- i += length;
- }
- } else {
- i += length;
- }
- }
-
- return new Blob([result], { type: blob.type });
-}
-
-/**
- * Overrides the APP1 (EXIF / XMP) sections of a JPEG blob with the given data.
- */
-export async function setExifData(blob: Blob, exif: Exif): Promise<Blob> {
- blob = await removeExifData(blob);
-
- const bytes = await blobToUint8(blob);
-
- let offset = 2;
-
- // check if the second tag is the JFIF tag
- if (bytes[2] === 0xff && bytes[3] === Tag.APP0) {
- offset += 2 + ((bytes[4] << 8) | bytes[5]);
- }
-
- const start = bytes.slice(0, offset);
- const end = bytes.slice(offset);
-
- const result = concatUint8Arrays(start, exif, end);
-
- return new Blob([result], { type: blob.type });
-}
-
-export type Exif = Uint8Array;
+++ /dev/null
-/**
- * Provides helper functions for Image metadata handling.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Image/ImageUtil
- */
-
-/**
- * Returns whether the given canvas contains transparent pixels.
- */
-export function containsTransparentPixels(canvas: HTMLCanvasElement): boolean {
- const ctx = canvas.getContext("2d");
- if (!ctx) {
- throw new Error("Unable to get canvas context.");
- }
-
- const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
-
- for (let i = 3, max = imageData.data.length; i < max; i += 4) {
- if (imageData.data[i] !== 255) return true;
- }
-
- return false;
-}
+++ /dev/null
-/**
- * This module allows resizing and conversion of HTMLImageElements to Blob and File objects
- *
- * @author Tim Duesterhus, Maximilian Mader
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Image/Resizer
- */
-
-import * as Core from "../Core";
-import * as FileUtil from "../FileUtil";
-import * as ExifUtil from "./ExifUtil";
-import Pica from "pica";
-
-const pica = new Pica({ features: ["js", "wasm", "ww"] });
-
-const DEFAULT_WIDTH = 800;
-const DEFAULT_HEIGHT = 600;
-const DEFAULT_QUALITY = 0.8;
-const DEFAULT_FILETYPE = "image/jpeg";
-
-class ImageResizer {
- maxWidth = DEFAULT_WIDTH;
- maxHeight = DEFAULT_HEIGHT;
- quality = DEFAULT_QUALITY;
- fileType = DEFAULT_FILETYPE;
-
- /**
- * Sets the default maximum width for this instance
- */
- setMaxWidth(value: number): ImageResizer {
- if (value == null) {
- value = DEFAULT_WIDTH;
- }
-
- this.maxWidth = value;
- return this;
- }
-
- /**
- * Sets the default maximum height for this instance
- */
- setMaxHeight(value: number): ImageResizer {
- if (value == null) {
- value = DEFAULT_HEIGHT;
- }
-
- this.maxHeight = value;
- return this;
- }
-
- /**
- * Sets the default quality for this instance
- */
- setQuality(value: number): ImageResizer {
- if (value == null) {
- value = DEFAULT_QUALITY;
- }
-
- this.quality = value;
- return this;
- }
-
- /**
- * Sets the default file type for this instance
- */
- setFileType(value: string): ImageResizer {
- if (value == null) {
- value = DEFAULT_FILETYPE;
- }
-
- this.fileType = value;
- return this;
- }
-
- /**
- * Converts the given object of exif data and image data into a File.
- */
- async saveFile(
- data: CanvasPlusExif,
- fileName: string,
- fileType: string = this.fileType,
- quality: number = this.quality,
- ): Promise<File> {
- const basename = /(.+)(\..+?)$/.exec(fileName);
-
- let blob = await pica.toBlob(data.image, fileType, quality);
-
- if (fileType === "image/jpeg" && typeof data.exif !== "undefined") {
- blob = await ExifUtil.setExifData(blob, data.exif);
- }
-
- return FileUtil.blobToFile(blob, basename![1]);
- }
-
- /**
- * Loads the given file into an image object and parses Exif information.
- */
- async loadFile(file: File): Promise<ImagePlusExif> {
- let exifBytes: Promise<ExifUtil.Exif | undefined> = Promise.resolve(undefined);
-
- let fileData: Blob | File = file;
- if (file.type === "image/jpeg") {
- // Extract EXIF data
- exifBytes = ExifUtil.getExifBytesFromJpeg(file);
-
- // Strip EXIF data
- fileData = await ExifUtil.removeExifData(fileData);
- }
-
- const imageLoader: Promise<HTMLImageElement> = new Promise((resolve, reject) => {
- const reader = new FileReader();
- const image = new Image();
-
- reader.addEventListener("load", () => {
- image.src = reader.result! as string;
- });
-
- reader.addEventListener("error", () => {
- reader.abort();
- reject(reader.error);
- });
-
- image.addEventListener("error", reject);
-
- image.addEventListener("load", () => {
- resolve(image);
- });
-
- reader.readAsDataURL(fileData);
- });
-
- const [exif, image] = await Promise.all([exifBytes, imageLoader]);
-
- return { exif, image };
- }
-
- /**
- * Downscales an image given as File object.
- */
- async resize(
- image: HTMLImageElement,
- maxWidth: number = this.maxWidth,
- maxHeight: number = this.maxHeight,
- quality: number = this.quality,
- force = false,
- cancelPromise?: Promise<unknown>,
- ): Promise<HTMLCanvasElement | undefined> {
- const canvas = document.createElement("canvas");
-
- if (window.createImageBitmap as any) {
- const bitmap = await createImageBitmap(image);
-
- if (bitmap.height != image.height) {
- throw new Error("Chrome Bug #1069965");
- }
- }
-
- // Prevent upscaling
- const newWidth = Math.min(maxWidth, image.width);
- const newHeight = Math.min(maxHeight, image.height);
-
- if (image.width <= newWidth && image.height <= newHeight && !force) {
- return undefined;
- }
-
- // Keep image ratio
- const ratio = Math.min(newWidth / image.width, newHeight / image.height);
-
- canvas.width = Math.floor(image.width * ratio);
- canvas.height = Math.floor(image.height * ratio);
-
- // Map to Pica's quality
- let resizeQuality = 1;
- if (quality >= 0.8) {
- resizeQuality = 3;
- } else if (quality >= 0.4) {
- resizeQuality = 2;
- }
-
- const options = {
- quality: resizeQuality,
- cancelToken: cancelPromise,
- alpha: true,
- };
-
- return pica.resize(image, canvas, options);
- }
-}
-
-interface ImagePlusExif {
- image: HTMLImageElement;
- exif?: ExifUtil.Exif;
-}
-
-interface CanvasPlusExif {
- image: HTMLCanvasElement;
- exif?: ExifUtil.Exif;
-}
-
-Core.enableLegacyInheritance(ImageResizer);
-
-export = ImageResizer;
+++ /dev/null
-/**
- * Manages language items.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Language (alias)
- * @module WoltLabSuite/Core/Language
- */
-
-import Template from "./Template";
-
-const _languageItems = new Map<string, string | Template>();
-
-/**
- * Adds all the language items in the given object to the store.
- */
-export function addObject(object: LanguageItems): void {
- Object.keys(object).forEach((key) => {
- _languageItems.set(key, object[key]);
- });
-}
-
-/**
- * Adds a single language item to the store.
- */
-export function add(key: string, value: string): void {
- _languageItems.set(key, value);
-}
-
-/**
- * Fetches the language item specified by the given key.
- * If the language item is a string it will be evaluated as
- * WoltLabSuite/Core/Template with the given parameters.
- *
- * @param {string} key Language item to return.
- * @param {Object=} parameters Parameters to provide to WoltLabSuite/Core/Template.
- * @return {string}
- */
-export function get(key: string, parameters?: object): string {
- let value = _languageItems.get(key);
- if (value === undefined) {
- return key;
- }
-
- if (Template === undefined) {
- // @ts-expect-error: This is required due to a circular dependency.
- Template = require("./Template");
- }
-
- if (typeof value === "string") {
- // lazily convert to WCF.Template
- try {
- _languageItems.set(key, new Template(value));
- } catch (e) {
- _languageItems.set(
- key,
- new Template(
- "{literal}" + value.replace(/{\/literal}/g, "{/literal}{ldelim}/literal}{literal}") + "{/literal}",
- ),
- );
- }
- value = _languageItems.get(key);
- }
-
- if (value instanceof Template) {
- value = value.fetch(parameters || {});
- }
-
- return value as string;
-}
-
-interface LanguageItems {
- [key: string]: string;
-}
+++ /dev/null
-/**
- * Dropdown language chooser.
- *
- * @author Alexander Ebert, Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Language/Chooser
- */
-
-import * as Core from "../Core";
-import * as Language from "../Language";
-import DomUtil from "../Dom/Util";
-import UiDropdownSimple from "../Ui/Dropdown/Simple";
-
-type ChooserId = string;
-type CallbackSelect = (listItem: HTMLElement) => void;
-type SelectFieldOrHiddenInput = HTMLInputElement | HTMLSelectElement;
-
-interface LanguageData {
- iconPath: string;
- languageCode?: string;
- languageName: string;
-}
-
-interface Languages {
- [key: string]: LanguageData;
-}
-
-interface ChooserData {
- callback: CallbackSelect;
- dropdownMenu: HTMLUListElement;
- dropdownToggle: HTMLAnchorElement;
- element: SelectFieldOrHiddenInput;
-}
-
-const _choosers = new Map<ChooserId, ChooserData>();
-const _forms = new WeakMap<HTMLFormElement, ChooserId[]>();
-
-/**
- * Sets up DOM and event listeners for a language chooser.
- */
-function initElement(
- chooserId: string,
- element: SelectFieldOrHiddenInput,
- languageId: number,
- languages: Languages,
- callback: CallbackSelect,
- allowEmptyValue: boolean,
-) {
- let container: HTMLElement;
-
- const parent = element.parentElement!;
- if (parent.nodeName === "DD") {
- container = document.createElement("div");
- container.className = "dropdown";
-
- // language chooser is the first child so that descriptions and error messages
- // are always shown below the language chooser
- parent.insertAdjacentElement("afterbegin", container);
- } else {
- container = parent;
- container.classList.add("dropdown");
- }
-
- DomUtil.hide(element);
-
- const dropdownToggle = document.createElement("a");
- dropdownToggle.className = "dropdownToggle dropdownIndicator boxFlag box24 inputPrefix";
- if (parent.nodeName === "DD") {
- dropdownToggle.classList.add("button");
- }
- container.appendChild(dropdownToggle);
-
- const dropdownMenu = document.createElement("ul");
- dropdownMenu.className = "dropdownMenu";
- container.appendChild(dropdownMenu);
-
- function callbackClick(event: MouseEvent): void {
- const target = event.currentTarget as HTMLElement;
- const languageId = ~~target.dataset.languageId!;
-
- const activeItem = dropdownMenu.querySelector(".active");
- if (activeItem !== null) {
- activeItem.classList.remove("active");
- }
-
- if (languageId) {
- target.classList.add("active");
- }
-
- select(chooserId, languageId, target);
- }
-
- // add language dropdown items
- Object.entries(languages).forEach(([langId, language]) => {
- const listItem = document.createElement("li");
- listItem.className = "boxFlag";
- listItem.addEventListener("click", callbackClick);
- listItem.dataset.languageId = langId;
- if (language.languageCode !== undefined) {
- listItem.dataset.languageCode = language.languageCode;
- }
- dropdownMenu.appendChild(listItem);
-
- const link = document.createElement("a");
- link.className = "box24";
- listItem.appendChild(link);
-
- const img = document.createElement("img");
- img.src = language.iconPath;
- img.alt = "";
- img.className = "iconFlag";
- link.appendChild(img);
-
- const span = document.createElement("span");
- span.textContent = language.languageName;
- link.appendChild(span);
-
- if (+langId === languageId) {
- dropdownToggle.innerHTML = link.innerHTML;
- }
- });
-
- // add dropdown item for "no selection"
- if (allowEmptyValue) {
- const divider = document.createElement("li");
- divider.className = "dropdownDivider";
- dropdownMenu.appendChild(divider);
-
- const listItem = document.createElement("li");
- listItem.dataset.languageId = "0";
- listItem.addEventListener("click", callbackClick);
- dropdownMenu.appendChild(listItem);
-
- const link = document.createElement("a");
- link.textContent = Language.get("wcf.global.language.noSelection");
- listItem.appendChild(link);
-
- if (languageId === 0) {
- dropdownToggle.innerHTML = link.innerHTML;
- }
-
- listItem.addEventListener("click", callbackClick);
- } else if (languageId === 0) {
- dropdownToggle.innerHTML = "";
-
- const div = document.createElement("div");
- dropdownToggle.appendChild(div);
-
- const icon = document.createElement("span");
- icon.className = "icon icon24 fa-question pointer";
- div.appendChild(icon);
-
- const span = document.createElement("span");
- span.textContent = Language.get("wcf.global.language.noSelection");
- div.appendChild(span);
- }
-
- UiDropdownSimple.init(dropdownToggle);
-
- _choosers.set(chooserId, {
- callback: callback,
- dropdownMenu: dropdownMenu,
- dropdownToggle: dropdownToggle,
- element: element,
- });
-
- // bind to submit event
- const form = element.closest("form") as HTMLFormElement;
- if (form !== null) {
- form.addEventListener("submit", onSubmit);
-
- let chooserIds = _forms.get(form);
- if (chooserIds === undefined) {
- chooserIds = [];
- _forms.set(form, chooserIds);
- }
-
- chooserIds.push(chooserId);
- }
-}
-
-/**
- * Selects a language from the dropdown list.
- */
-function select(chooserId: string, languageId: number, listItem?: HTMLElement): void {
- const chooser = _choosers.get(chooserId)!;
-
- if (listItem === undefined) {
- listItem = Array.from(chooser.dropdownMenu.children).find((element: HTMLElement) => {
- return ~~element.dataset.languageId! === languageId;
- }) as HTMLElement;
-
- if (listItem === undefined) {
- throw new Error(`The language id '${languageId}' is unknown`);
- }
- }
-
- chooser.element.value = languageId.toString();
- Core.triggerEvent(chooser.element, "change");
-
- chooser.dropdownToggle.innerHTML = listItem.children[0].innerHTML;
-
- _choosers.set(chooserId, chooser);
-
- // execute callback
- if (typeof chooser.callback === "function") {
- chooser.callback(listItem);
- }
-}
-
-/**
- * Inserts hidden fields for the language chooser value on submit.
- */
-function onSubmit(event: Event): void {
- const form = event.currentTarget as HTMLFormElement;
- const elementIds = _forms.get(form)!;
-
- elementIds.forEach((elementId) => {
- const input = document.createElement("input");
- input.type = "hidden";
- input.name = elementId;
- input.value = getLanguageId(elementId).toString();
-
- form.appendChild(input);
- });
-}
-
-/**
- * Initializes a language chooser.
- */
-export function init(
- containerId: string,
- chooserId: string,
- languageId: number,
- languages: Languages,
- callback: CallbackSelect,
- allowEmptyValue: boolean,
-): void {
- if (_choosers.has(chooserId)) {
- return;
- }
-
- const container = document.getElementById(containerId);
- if (container === null) {
- throw new Error(`Expected a valid container id, cannot find '${chooserId}'.`);
- }
-
- let element = document.getElementById(chooserId) as SelectFieldOrHiddenInput;
- if (element === null) {
- element = document.createElement("input");
- element.type = "hidden";
- element.id = chooserId;
- element.name = chooserId;
- element.value = languageId.toString();
-
- container.appendChild(element);
- }
-
- initElement(chooserId, element, languageId, languages, callback, allowEmptyValue);
-}
-
-/**
- * Returns the chooser for an input field.
- */
-export function getChooser(chooserId: string): ChooserData {
- const chooser = _choosers.get(chooserId);
- if (chooser === undefined) {
- throw new Error(`Expected a valid language chooser input element, '${chooserId}' is not i18n input field.`);
- }
-
- return chooser;
-}
-
-/**
- * Returns the selected language for a certain chooser.
- */
-export function getLanguageId(chooserId: string): number {
- return ~~getChooser(chooserId).element.value;
-}
-
-/**
- * Removes the chooser with given id.
- */
-export function removeChooser(chooserId: string): void {
- _choosers.delete(chooserId);
-}
-
-/**
- * Sets the language for a certain chooser.
- */
-export function setLanguageId(chooserId: string, languageId: number): void {
- if (_choosers.get(chooserId) === undefined) {
- throw new Error(`Expected a valid input element, '${chooserId}' is not i18n input field.`);
- }
-
- select(chooserId, languageId);
-}
+++ /dev/null
-/**
- * I18n interface for input and textarea fields.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Language/Input
- */
-
-import DomUtil from "../Dom/Util";
-import * as Language from "../Language";
-import { NotificationAction } from "../Ui/Dropdown/Data";
-import UiDropdownSimple from "../Ui/Dropdown/Simple";
-import * as StringUtil from "../StringUtil";
-
-type LanguageId = number;
-
-export interface I18nValues {
- // languageID => value
- [key: string]: string;
-}
-
-export interface Languages {
- // languageID => languageName
- [key: string]: string;
-}
-
-type Values = Map<LanguageId, string>;
-
-export type InputOrTextarea = HTMLInputElement | HTMLTextAreaElement;
-
-type CallbackEvent = "select" | "submit";
-type Callback = (element: InputOrTextarea) => void;
-
-interface ElementData {
- buttonLabel: HTMLElement;
- callbacks: Map<CallbackEvent, Callback>;
- element: InputOrTextarea;
- languageId: number;
- isEnabled: boolean;
- forceSelection: boolean;
-}
-
-const _elements = new Map<string, ElementData>();
-const _forms = new WeakMap<HTMLFormElement, string[]>();
-const _values = new Map<string, Values>();
-
-/**
- * Sets up DOM and event listeners for an input field.
- */
-function initElement(
- elementId: string,
- element: InputOrTextarea,
- values: Values,
- availableLanguages: Languages,
- forceSelection: boolean,
-): void {
- let container = element.parentElement!;
- if (!container.classList.contains("inputAddon")) {
- container = document.createElement("div");
- container.className = "inputAddon";
- if (element.nodeName === "TEXTAREA") {
- container.classList.add("inputAddonTextarea");
- }
- container.dataset.inputId = elementId;
-
- const hasFocus = document.activeElement === element;
-
- // DOM manipulation causes focused element to lose focus
- element.insertAdjacentElement("beforebegin", container);
- container.appendChild(element);
-
- if (hasFocus) {
- element.focus();
- }
- }
-
- container.classList.add("dropdown");
- const button = document.createElement("span");
- button.className = "button dropdownToggle inputPrefix";
-
- const buttonLabel = document.createElement("span");
- buttonLabel.textContent = Language.get("wcf.global.button.disabledI18n");
-
- button.appendChild(buttonLabel);
- container.insertBefore(button, element);
-
- const dropdownMenu = document.createElement("ul");
- dropdownMenu.className = "dropdownMenu";
- button.insertAdjacentElement("afterend", dropdownMenu);
-
- const callbackClick = (event: MouseEvent | HTMLElement): void => {
- let target: HTMLElement;
- if (event instanceof HTMLElement) {
- target = event;
- } else {
- target = event.currentTarget as HTMLElement;
- }
-
- const languageId = ~~target.dataset.languageId!;
-
- const activeItem = dropdownMenu.querySelector(".active");
- if (activeItem !== null) {
- activeItem.classList.remove("active");
- }
-
- if (languageId) {
- target.classList.add("active");
- }
-
- const isInit = event instanceof HTMLElement;
- select(elementId, languageId, isInit);
- };
-
- // build language dropdown
- Object.entries(availableLanguages).forEach(([languageId, languageName]) => {
- const listItem = document.createElement("li");
- listItem.dataset.languageId = languageId;
-
- const span = document.createElement("span");
- span.textContent = languageName;
-
- listItem.appendChild(span);
- listItem.addEventListener("click", callbackClick);
- dropdownMenu.appendChild(listItem);
- });
-
- if (!forceSelection) {
- const divider = document.createElement("li");
- divider.className = "dropdownDivider";
- dropdownMenu.appendChild(divider);
-
- const listItem = document.createElement("li");
- listItem.dataset.languageId = "0";
- listItem.addEventListener("click", callbackClick);
-
- const span = document.createElement("span");
- span.textContent = Language.get("wcf.global.button.disabledI18n");
- listItem.appendChild(span);
-
- dropdownMenu.appendChild(listItem);
- }
-
- let activeItem: HTMLElement | undefined = undefined;
- if (forceSelection || values.size) {
- activeItem = Array.from(dropdownMenu.children).find((element: HTMLElement) => {
- return +element.dataset.languageId! === window.LANGUAGE_ID;
- }) as HTMLElement;
- }
-
- UiDropdownSimple.init(button);
- UiDropdownSimple.registerCallback(container.id, dropdownToggle);
-
- _elements.set(elementId, {
- buttonLabel,
- callbacks: new Map<CallbackEvent, Callback>(),
- element,
- languageId: 0,
- isEnabled: true,
- forceSelection,
- });
-
- // bind to submit event
- const form = element.closest("form");
- if (form !== null) {
- form.addEventListener("submit", submit);
-
- let elementIds = _forms.get(form);
- if (elementIds === undefined) {
- elementIds = [];
- _forms.set(form, elementIds);
- }
-
- elementIds.push(elementId);
- }
-
- if (activeItem) {
- callbackClick(activeItem);
- }
-}
-
-/**
- * Selects a language or non-i18n from the dropdown list.
- */
-function select(elementId: string, languageId: number, isInit: boolean): void {
- const data = _elements.get(elementId)!;
-
- const dropdownMenu = UiDropdownSimple.getDropdownMenu(data.element.closest(".inputAddon")!.id)!;
-
- const item = dropdownMenu.querySelector(`[data-language-id="${languageId}"]`);
- const label = item ? item.textContent! : "";
-
- // save current value
- if (data.languageId !== languageId) {
- const values = _values.get(elementId)!;
-
- if (data.languageId) {
- values.set(data.languageId, data.element.value);
- }
-
- if (languageId === 0) {
- _values.set(elementId, new Map<LanguageId, string>());
- } else if (data.buttonLabel.classList.contains("active") || isInit) {
- data.element.value = values.get(languageId) || "";
- }
-
- // update label
- data.buttonLabel.textContent = label;
- data.buttonLabel.classList[languageId ? "add" : "remove"]("active");
-
- data.languageId = languageId;
- }
-
- if (!isInit) {
- data.element.blur();
- data.element.focus();
- }
-
- if (data.callbacks.has("select")) {
- data.callbacks.get("select")!(data.element);
- }
-}
-
-/**
- * Callback for dropdowns being opened, flags items with a missing value for one or more languages.
- */
-function dropdownToggle(containerId: string, action: NotificationAction): void {
- if (action !== "open") {
- return;
- }
-
- const dropdownMenu = UiDropdownSimple.getDropdownMenu(containerId)!;
- const container = document.getElementById(containerId)!;
- const elementId = container.dataset.inputId!;
- const data = _elements.get(elementId)!;
- const values = _values.get(elementId)!;
-
- Array.from(dropdownMenu.children).forEach((item: HTMLElement) => {
- const languageId = ~~(item.dataset.languageId || "");
-
- if (languageId) {
- let hasMissingValue = false;
- if (data.languageId) {
- if (languageId === data.languageId) {
- hasMissingValue = data.element.value.trim() === "";
- } else {
- hasMissingValue = !values.get(languageId);
- }
- }
-
- if (hasMissingValue) {
- item.classList.add("missingValue");
- } else {
- item.classList.remove("missingValue");
- }
- }
- });
-}
-
-/**
- * Inserts hidden fields for i18n input on submit.
- */
-function submit(event: Event): void {
- const form = event.currentTarget as HTMLFormElement;
- const elementIds = _forms.get(form)!;
-
- elementIds.forEach((elementId) => {
- const data = _elements.get(elementId)!;
- if (!data.isEnabled) {
- return;
- }
-
- const values = _values.get(elementId)!;
-
- if (data.callbacks.has("submit")) {
- data.callbacks.get("submit")!(data.element);
- }
-
- // update with current value
- if (data.languageId) {
- values.set(data.languageId, data.element.value);
- }
-
- if (values.size) {
- values.forEach(function (value, languageId) {
- const input = document.createElement("input");
- input.type = "hidden";
- input.name = `${elementId}_i18n[${languageId}]`;
- input.value = value;
-
- form.appendChild(input);
- });
-
- // remove name attribute to enforce i18n values
- data.element.removeAttribute("name");
- }
- });
-}
-
-/**
- * Initializes an input field.
- */
-export function init(
- elementId: string,
- values: I18nValues,
- availableLanguages: Languages,
- forceSelection: boolean,
-): void {
- if (_values.has(elementId)) {
- return;
- }
-
- const element = document.getElementById(elementId) as InputOrTextarea;
- if (element === null) {
- throw new Error(`Expected a valid element id, cannot find '${elementId}'.`);
- }
-
- // unescape values
- const unescapedValues = new Map<LanguageId, string>();
- Object.entries(values).forEach(([languageId, value]) => {
- unescapedValues.set(+languageId, StringUtil.unescapeHTML(value));
- });
-
- _values.set(elementId, unescapedValues);
-
- initElement(elementId, element, unescapedValues, availableLanguages, forceSelection);
-}
-
-/**
- * Registers a callback for an element.
- */
-export function registerCallback(elementId: string, eventName: CallbackEvent, callback: Callback): void {
- if (!_values.has(elementId)) {
- throw new Error(`Unknown element id '${elementId}'.`);
- }
-
- _elements.get(elementId)!.callbacks.set(eventName, callback);
-}
-
-/**
- * Unregisters the element with the given id.
- *
- * @since 5.2
- */
-export function unregister(elementId: string): void {
- if (!_values.has(elementId)) {
- throw new Error(`Unknown element id '${elementId}'.`);
- }
-
- _values.delete(elementId);
- _elements.delete(elementId);
-}
-
-/**
- * Returns the values of an input field.
- */
-export function getValues(elementId: string): Values {
- const element = _elements.get(elementId)!;
- if (element === undefined) {
- throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
- }
-
- const values = _values.get(elementId)!;
-
- // update with current value
- values.set(element.languageId, element.element.value);
-
- return values;
-}
-
-/**
- * Sets the values of an input field.
- */
-export function setValues(elementId: string, newValues: Values | I18nValues): void {
- const element = _elements.get(elementId);
- if (element === undefined) {
- throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
- }
-
- element.element.value = "";
-
- const values = new Map<LanguageId, string>(
- Object.entries(newValues).map(([languageId, value]) => {
- return [+languageId, value];
- }),
- );
-
- if (values.has(0)) {
- element.element.value = values.get(0)!;
- values.delete(0);
-
- _values.set(elementId, values);
- select(elementId, 0, true);
-
- return;
- }
-
- _values.set(elementId, values);
-
- element.languageId = 0;
- select(elementId, window.LANGUAGE_ID, true);
-}
-
-/**
- * Disables the i18n interface for an input field.
- */
-export function disable(elementId: string): void {
- const element = _elements.get(elementId);
- if (element === undefined) {
- throw new Error(`Expected a valid element, '${elementId}' is not an i18n input field.`);
- }
-
- if (!element.isEnabled) {
- return;
- }
-
- element.isEnabled = false;
-
- // hide language dropdown
- const buttonContainer = element.buttonLabel.parentElement!;
- DomUtil.hide(buttonContainer);
- const dropdownContainer = buttonContainer.parentElement!;
- dropdownContainer.classList.remove("inputAddon", "dropdown");
-}
-
-/**
- * Enables the i18n interface for an input field.
- */
-export function enable(elementId: string): void {
- const element = _elements.get(elementId);
- if (element === undefined) {
- throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
- }
-
- if (element.isEnabled) {
- return;
- }
-
- element.isEnabled = true;
-
- // show language dropdown
- const buttonContainer = element.buttonLabel.parentElement!;
- DomUtil.show(buttonContainer);
- const dropdownContainer = buttonContainer.parentElement!;
- dropdownContainer.classList.add("inputAddon", "dropdown");
-}
-
-/**
- * Returns true if i18n input is enabled for an input field.
- */
-export function isEnabled(elementId: string): boolean {
- const element = _elements.get(elementId);
- if (element === undefined) {
- throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
- }
-
- return element.isEnabled;
-}
-
-/**
- * Returns true if the value of an i18n input field is valid.
- *
- * If the element is disabled, true is returned.
- */
-export function validate(elementId: string, permitEmptyValue: boolean): boolean {
- const element = _elements.get(elementId)!;
- if (element === undefined) {
- throw new Error(`Expected a valid i18n input element, '${elementId}' is not i18n input field.`);
- }
-
- if (!element.isEnabled) {
- return true;
- }
-
- const values = _values.get(elementId)!;
-
- const dropdownMenu = UiDropdownSimple.getDropdownMenu(element.element.parentElement!.id)!;
-
- if (element.languageId) {
- values.set(element.languageId, element.element.value);
- }
-
- let hasEmptyValue = false;
- let hasNonEmptyValue = false;
- Array.from(dropdownMenu.children).forEach((item: HTMLElement) => {
- const languageId = ~~item.dataset.languageId!;
-
- if (languageId) {
- if (!values.has(languageId) || values.get(languageId)!.length === 0) {
- // input has non-empty value for previously checked language
- if (hasNonEmptyValue) {
- return false;
- }
-
- hasEmptyValue = true;
- } else {
- // input has empty value for previously checked language
- if (hasEmptyValue) {
- return false;
- }
-
- hasNonEmptyValue = true;
- }
- }
- });
-
- return !hasEmptyValue || permitEmptyValue;
-}
+++ /dev/null
-/**
- * I18n interface for wysiwyg input fields.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Language/Text
- */
-
-import { I18nValues, InputOrTextarea, Languages } from "./Input";
-import * as LanguageInput from "./Input";
-
-/**
- * Refreshes the editor content on language switch.
- */
-function callbackSelect(element: InputOrTextarea): void {
- if (window.jQuery !== undefined) {
- window.jQuery(element).redactor("code.set", element.value);
- }
-}
-
-/**
- * Refreshes the input element value on submit.
- */
-function callbackSubmit(element: InputOrTextarea): void {
- if (window.jQuery !== undefined) {
- element.value = window.jQuery(element).redactor("code.get") as string;
- }
-}
-
-/**
- * Initializes an WYSIWYG input field.
- */
-export function init(
- elementId: string,
- values: I18nValues,
- availableLanguages: Languages,
- forceSelection: boolean,
-): void {
- const element = document.getElementById(elementId);
- if (!element || element.nodeName !== "TEXTAREA" || !element.classList.contains("wysiwygTextarea")) {
- throw new Error(`Expected <textarea class="wysiwygTextarea" /> for id '${elementId}'.`);
- }
-
- LanguageInput.init(elementId, values, availableLanguages, forceSelection);
-
- LanguageInput.registerCallback(elementId, "select", callbackSelect);
- LanguageInput.registerCallback(elementId, "submit", callbackSubmit);
-}
+++ /dev/null
-/**
- * List implementation relying on an array or if supported on a Set to hold values.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module List (alias)
- * @module WoltLabSuite/Core/List
- */
-
-import * as Core from "./Core";
-
-/** @deprecated 5.4 Use a `Set` instead. */
-class List<T = any> {
- private _set = new Set<T>();
-
- /**
- * Appends an element to the list, silently rejects adding an already existing value.
- */
- add(value: T): void {
- this._set.add(value);
- }
-
- /**
- * Removes all elements from the list.
- */
- clear(): void {
- this._set.clear();
- }
-
- /**
- * Removes an element from the list, returns true if the element was in the list.
- */
- delete(value: T): boolean {
- return this._set.delete(value);
- }
-
- /**
- * Invokes the `callback` for each element in the list.
- */
- forEach(callback: (value: T) => void): void {
- this._set.forEach(callback);
- }
-
- /**
- * Returns true if the list contains the element.
- */
- has(value: T): boolean {
- return this._set.has(value);
- }
-
- get size(): number {
- return this._set.size;
- }
-}
-
-Core.enableLegacyInheritance(List);
-
-export = List;
+++ /dev/null
-/**
- * Initializes modules required for media clipboard.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Media/Clipboard
- */
-
-import MediaManager from "./Manager/Base";
-import MediaManagerEditor from "./Manager/Editor";
-import * as Clipboard from "../Controller/Clipboard";
-import * as UiNotification from "../Ui/Notification";
-import * as UiDialog from "../Ui/Dialog";
-import * as EventHandler from "../Event/Handler";
-import * as Language from "../Language";
-import * as Ajax from "../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../Ajax/Data";
-import { DialogCallbackObject, DialogCallbackSetup } from "../Ui/Dialog/Data";
-
-let _mediaManager: MediaManager;
-
-class MediaClipboard implements AjaxCallbackObject, DialogCallbackObject {
- public _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- className: "wcf\\data\\media\\MediaAction",
- },
- };
- }
-
- public _ajaxSuccess(data): void {
- switch (data.actionName) {
- case "getSetCategoryDialog":
- UiDialog.open(this, data.returnValues.template);
-
- break;
-
- case "setCategory":
- UiDialog.close(this);
-
- UiNotification.show();
-
- Clipboard.reload();
-
- break;
- }
- }
-
- public _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "mediaSetCategoryDialog",
- options: {
- onSetup: (content) => {
- content.querySelector("button")!.addEventListener("click", (event) => {
- event.preventDefault();
-
- const category = content.querySelector('select[name="categoryID"]') as HTMLSelectElement;
- setCategory(~~category.value);
-
- const target = event.currentTarget as HTMLButtonElement;
- target.disabled = true;
- });
- },
- title: Language.get("wcf.media.setCategory"),
- },
- source: null,
- };
- }
-}
-
-const ajax = new MediaClipboard();
-
-let clipboardObjectIds: number[] = [];
-
-interface ClipboardActionData {
- data: {
- actionName: "com.woltlab.wcf.media.delete" | "com.woltlab.wcf.media.insert" | "com.woltlab.wcf.media.setCategory";
- parameters: {
- objectIDs: number[];
- };
- };
- responseData: null;
-}
-
-/**
- * Handles successful clipboard actions.
- */
-function clipboardAction(actionData: ClipboardActionData): void {
- const mediaIds = actionData.data.parameters.objectIDs;
-
- switch (actionData.data.actionName) {
- case "com.woltlab.wcf.media.delete":
- // only consider events if the action has been executed
- if (actionData.responseData !== null) {
- _mediaManager.clipboardDeleteMedia(mediaIds);
- }
-
- break;
-
- case "com.woltlab.wcf.media.insert": {
- const mediaManagerEditor = _mediaManager as MediaManagerEditor;
- mediaManagerEditor.clipboardInsertMedia(mediaIds);
-
- break;
- }
-
- case "com.woltlab.wcf.media.setCategory":
- clipboardObjectIds = mediaIds;
-
- Ajax.api(ajax, {
- actionName: "getSetCategoryDialog",
- });
-
- break;
- }
-}
-
-/**
- * Sets the category of the marked media files.
- */
-function setCategory(categoryID: number) {
- Ajax.api(ajax, {
- actionName: "setCategory",
- objectIDs: clipboardObjectIds,
- parameters: {
- categoryID: categoryID,
- },
- });
-}
-
-export function init(pageClassName: string, hasMarkedItems: boolean, mediaManager: MediaManager): void {
- Clipboard.setup({
- hasMarkedItems: hasMarkedItems,
- pageClassName: pageClassName,
- });
-
- _mediaManager = mediaManager;
-
- EventHandler.add("com.woltlab.wcf.clipboard", "com.woltlab.wcf.media", (data) => clipboardAction(data));
-}
+++ /dev/null
-/**
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Media/Data
- */
-
-import MediaUpload from "./Upload";
-import { FileElements, UploadOptions } from "../Upload/Data";
-import MediaEditor from "./Editor";
-import MediaManager from "./Manager/Base";
-import { RedactorEditor } from "../Ui/Redactor/Editor";
-import { I18nValues } from "../Language/Input";
-
-export interface Media {
- altText: I18nValues | string;
- caption: I18nValues | string;
- categoryID: number;
- elementTag: string;
- captionEnableHtml: number;
- filename: string;
- formattedFilesize: string;
- languageID: number | null;
- isImage: number;
- isMultilingual: number;
- link: string;
- mediaID: number;
- smallThumbnailLink: string;
- smallThumbnailType: string;
- tinyThumbnailLink: string;
- tinyThumbnailType: string;
- title: I18nValues | string;
-}
-
-export interface MediaManagerOptions {
- dialogTitle: string;
- imagesOnly: boolean;
- minSearchLength: number;
-}
-
-export const enum MediaInsertType {
- Separate = "separate",
-}
-
-export interface MediaManagerEditorOptions extends MediaManagerOptions {
- buttonClass?: string;
- callbackInsert: (media: Map<number, Media>, insertType: MediaInsertType, thumbnailSize: string) => void;
- editor?: RedactorEditor;
-}
-
-export interface MediaManagerSelectOptions extends MediaManagerOptions {
- buttonClass?: string;
-}
-
-export interface MediaEditorCallbackObject {
- _editorClose?: () => void;
- _editorSuccess?: (Media, number?) => void;
-}
-
-export interface MediaUploadSuccessEventData {
- files: FileElements;
- isMultiFileUpload: boolean;
- media: Media[];
- upload: MediaUpload;
- uploadId: number;
-}
-
-export interface MediaUploadOptions extends UploadOptions {
- elementTagSize: number;
- mediaEditor?: MediaEditor;
- mediaManager?: MediaManager;
-}
-
-export interface MediaListUploadOptions extends MediaUploadOptions {
- categoryId?: number;
-}
-
-export interface MediaUploadAjaxResponseData {
- returnValues: {
- errors: MediaUploadError[];
- media: Media[];
- };
-}
-
-export interface MediaUploadError {
- errorType: string;
- filename: string;
-}
+++ /dev/null
-/**
- * Handles editing media files via dialog.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Media/Editor
- */
-
-import * as Core from "../Core";
-import { Media, MediaEditorCallbackObject } from "./Data";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../Ajax/Data";
-import * as UiNotification from "../Ui/Notification";
-import * as UiDialog from "../Ui/Dialog";
-import { DialogCallbackObject } from "../Ui/Dialog/Data";
-import * as LanguageChooser from "../Language/Chooser";
-import * as LanguageInput from "../Language/Input";
-import * as DomUtil from "../Dom/Util";
-import * as DomTraverse from "../Dom/Traverse";
-import DomChangeListener from "../Dom/Change/Listener";
-import * as Language from "../Language";
-import * as Ajax from "../Ajax";
-import MediaReplace from "./Replace";
-import { I18nValues } from "../Language/Input";
-
-interface InitEditorData {
- returnValues: {
- availableLanguageCount: number;
- categoryIDs: number[];
- mediaData?: Media;
- };
-}
-
-class MediaEditor implements AjaxCallbackObject {
- protected _availableLanguageCount = 1;
- protected _categoryIds: number[] = [];
- protected _dialogs = new Map<string, DialogCallbackObject>();
- protected readonly _callbackObject: MediaEditorCallbackObject;
- protected _media: Media | null = null;
- protected _oldCategoryId = 0;
-
- constructor(callbackObject: MediaEditorCallbackObject) {
- this._callbackObject = callbackObject || {};
-
- if (this._callbackObject._editorClose && typeof this._callbackObject._editorClose !== "function") {
- throw new TypeError("Callback object has no function '_editorClose'.");
- }
- if (this._callbackObject._editorSuccess && typeof this._callbackObject._editorSuccess !== "function") {
- throw new TypeError("Callback object has no function '_editorSuccess'.");
- }
- }
-
- public _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "update",
- className: "wcf\\data\\media\\MediaAction",
- },
- };
- }
-
- public _ajaxSuccess(): void {
- UiNotification.show();
-
- if (this._callbackObject._editorSuccess) {
- this._callbackObject._editorSuccess(this._media, this._oldCategoryId);
- this._oldCategoryId = 0;
- }
-
- UiDialog.close(`mediaEditor_${this._media!.mediaID}`);
-
- this._media = null;
- }
-
- /**
- * Is called if an editor is manually closed by the user.
- */
- protected _close(): void {
- this._media = null;
-
- if (this._callbackObject._editorClose) {
- this._callbackObject._editorClose();
- }
- }
-
- /**
- * Initializes the editor dialog.
- *
- * @since 5.3
- */
- protected _initEditor(content: HTMLElement, data: InitEditorData): void {
- this._availableLanguageCount = ~~data.returnValues.availableLanguageCount;
- this._categoryIds = data.returnValues.categoryIDs.map((number) => ~~number);
-
- if (data.returnValues.mediaData) {
- this._media = data.returnValues.mediaData;
- }
- const mediaId = this._media!.mediaID;
-
- // make sure that the language chooser is initialized first
- setTimeout(() => {
- if (this._availableLanguageCount > 1) {
- LanguageChooser.setLanguageId(
- `mediaEditor_${mediaId}_languageID`,
- this._media!.languageID || window.LANGUAGE_ID,
- );
- }
-
- if (this._categoryIds.length) {
- const categoryID = content.querySelector("select[name=categoryID]") as HTMLSelectElement;
- if (this._media!.categoryID) {
- categoryID.value = this._media!.categoryID.toString();
- } else {
- categoryID.value = "0";
- }
- }
-
- const title = content.querySelector("input[name=title]") as HTMLInputElement;
- const altText = content.querySelector("input[name=altText]") as HTMLInputElement;
- const caption = content.querySelector("textarea[name=caption]") as HTMLInputElement;
-
- if (this._availableLanguageCount > 1 && this._media!.isMultilingual) {
- if (document.getElementById(`altText_${mediaId}`)) {
- LanguageInput.setValues(`altText_${mediaId}`, (this._media!.altText || {}) as I18nValues);
- }
-
- if (document.getElementById(`caption_${mediaId}`)) {
- LanguageInput.setValues(`caption_${mediaId}`, (this._media!.caption || {}) as I18nValues);
- }
-
- LanguageInput.setValues(`title_${mediaId}`, (this._media!.title || {}) as I18nValues);
- } else {
- title.value = this._media?.title[this._media.languageID || window.LANGUAGE_ID] || "";
- if (altText) {
- altText.value = this._media?.altText[this._media.languageID || window.LANGUAGE_ID] || "";
- }
- if (caption) {
- caption.value = this._media?.caption[this._media.languageID || window.LANGUAGE_ID] || "";
- }
- }
-
- if (this._availableLanguageCount > 1) {
- const isMultilingual = content.querySelector("input[name=isMultilingual]") as HTMLInputElement;
- isMultilingual.addEventListener("change", (ev) => this._updateLanguageFields(ev));
-
- this._updateLanguageFields(null, isMultilingual);
- }
-
- if (altText) {
- altText.addEventListener("keypress", (ev) => this._keyPress(ev));
- }
- title.addEventListener("keypress", (ev) => this._keyPress(ev));
-
- content.querySelector("button[data-type=submit]")!.addEventListener("click", () => this._saveData());
-
- // remove focus from input elements and scroll dialog to top
- (document.activeElement! as HTMLElement).blur();
- (document.getElementById(`mediaEditor_${mediaId}`)!.parentNode as HTMLElement).scrollTop = 0;
-
- // Initialize button to replace media file.
- const uploadButton = content.querySelector(".mediaManagerMediaReplaceButton")!;
- let target = content.querySelector(".mediaThumbnail");
- if (!target) {
- target = document.createElement("div");
- content.appendChild(target);
- }
- new MediaReplace(
- mediaId,
- DomUtil.identify(uploadButton),
- // Pass an anonymous element for non-images which is required internally
- // but not needed in this case.
- DomUtil.identify(target),
- {
- mediaEditor: this,
- },
- );
-
- DomChangeListener.trigger();
- }, 200);
- }
-
- /**
- * Handles the `[ENTER]` key to submit the form.
- */
- protected _keyPress(event: KeyboardEvent): void {
- if (event.key === "Enter") {
- event.preventDefault();
-
- this._saveData();
- }
- }
-
- /**
- * Saves the data of the currently edited media.
- */
- protected _saveData(): void {
- const content = UiDialog.getDialog(`mediaEditor_${this._media!.mediaID}`)!.content;
-
- const categoryId = content.querySelector("select[name=categoryID]") as HTMLSelectElement;
- const altText = content.querySelector("input[name=altText]") as HTMLInputElement;
- const caption = content.querySelector("textarea[name=caption]") as HTMLTextAreaElement;
- const captionEnableHtml = content.querySelector("input[name=captionEnableHtml]") as HTMLInputElement;
- const title = content.querySelector("input[name=title]") as HTMLInputElement;
-
- let hasError = false;
- const altTextError = altText ? DomTraverse.childByClass(altText.parentNode! as HTMLElement, "innerError") : false;
- const captionError = caption ? DomTraverse.childByClass(caption.parentNode! as HTMLElement, "innerError") : false;
- const titleError = DomTraverse.childByClass(title.parentNode! as HTMLElement, "innerError");
-
- // category
- this._oldCategoryId = this._media!.categoryID;
- if (this._categoryIds.length) {
- this._media!.categoryID = ~~categoryId.value;
-
- // if the selected category id not valid (manipulated DOM), ignore
- if (this._categoryIds.indexOf(this._media!.categoryID) === -1) {
- this._media!.categoryID = 0;
- }
- }
-
- // language and multilingualism
- if (this._availableLanguageCount > 1) {
- const isMultilingual = content.querySelector("input[name=isMultilingual]") as HTMLInputElement;
- this._media!.isMultilingual = ~~isMultilingual.checked;
- this._media!.languageID = this._media!.isMultilingual
- ? null
- : LanguageChooser.getLanguageId(`mediaEditor_${this._media!.mediaID}_languageID`);
- } else {
- this._media!.languageID = window.LANGUAGE_ID;
- }
-
- // altText, caption and title
- this._media!.altText = {};
- this._media!.caption = {};
- this._media!.title = {};
- if (this._availableLanguageCount > 1 && this._media!.isMultilingual) {
- if (altText && !LanguageInput.validate(altText.id, true)) {
- hasError = true;
- if (!altTextError) {
- DomUtil.innerError(altText, Language.get("wcf.global.form.error.multilingual"));
- }
- }
- if (caption && !LanguageInput.validate(caption.id, true)) {
- hasError = true;
- if (!captionError) {
- DomUtil.innerError(caption, Language.get("wcf.global.form.error.multilingual"));
- }
- }
- if (!LanguageInput.validate(title.id, true)) {
- hasError = true;
- if (!titleError) {
- DomUtil.innerError(title, Language.get("wcf.global.form.error.multilingual"));
- }
- }
-
- this._media!.altText = altText ? this.mapToI18nValues(LanguageInput.getValues(altText.id)) : "";
- this._media!.caption = caption ? this.mapToI18nValues(LanguageInput.getValues(caption.id)) : "";
- this._media!.title = this.mapToI18nValues(LanguageInput.getValues(title.id));
- } else {
- this._media!.altText[this._media!.languageID!] = altText ? altText.value : "";
- this._media!.caption[this._media!.languageID!] = caption ? caption.value : "";
- this._media!.title[this._media!.languageID!] = title.value;
- }
-
- // captionEnableHtml
- if (captionEnableHtml) {
- this._media!.captionEnableHtml = ~~captionEnableHtml.checked;
- } else {
- this._media!.captionEnableHtml = 0;
- }
-
- const aclValues = {
- allowAll: ~~(document.getElementById(`mediaEditor_${this._media!.mediaID}_aclAllowAll`)! as HTMLInputElement)
- .checked,
- group: Array.from(
- content.querySelectorAll(`input[name="mediaEditor_${this._media!.mediaID}_aclValues[group][]"]`),
- ).map((aclGroup: HTMLInputElement) => ~~aclGroup.value),
- user: Array.from(
- content.querySelectorAll(`input[name="mediaEditor_${this._media!.mediaID}_aclValues[user][]"]`),
- ).map((aclUser: HTMLInputElement) => ~~aclUser.value),
- };
-
- if (!hasError) {
- if (altTextError) {
- altTextError.remove();
- }
- if (captionError) {
- captionError.remove();
- }
- if (titleError) {
- titleError.remove();
- }
-
- Ajax.api(this, {
- actionName: "update",
- objectIDs: [this._media!.mediaID],
- parameters: {
- aclValues: aclValues,
- altText: this._media!.altText,
- caption: this._media!.caption,
- data: {
- captionEnableHtml: this._media!.captionEnableHtml,
- categoryID: this._media!.categoryID,
- isMultilingual: this._media!.isMultilingual,
- languageID: this._media!.languageID,
- },
- title: this._media!.title,
- },
- });
- }
- }
-
- private mapToI18nValues(values: Map<number, string>): I18nValues {
- const obj = {};
- values.forEach((value, key) => (obj[key] = value));
-
- return obj;
- }
-
- /**
- * Updates language-related input fields depending on whether multilingualis is enabled.
- */
- protected _updateLanguageFields(event: Event | null, element?: HTMLInputElement): void {
- if (event) {
- element = event.currentTarget as HTMLInputElement;
- }
-
- const mediaId = this._media!.mediaID;
- const languageChooserContainer = document.getElementById(`mediaEditor_${mediaId}_languageIDContainer`)!
- .parentNode! as HTMLElement;
-
- if (element!.checked) {
- LanguageInput.enable(`title_${mediaId}`);
- if (document.getElementById(`caption_${mediaId}`)) {
- LanguageInput.enable(`caption_${mediaId}`);
- }
- if (document.getElementById(`altText_${mediaId}`)) {
- LanguageInput.enable(`altText_${mediaId}`);
- }
-
- DomUtil.hide(languageChooserContainer);
- } else {
- LanguageInput.disable(`title_${mediaId}`);
- if (document.getElementById(`caption_${mediaId}`)) {
- LanguageInput.disable(`caption_${mediaId}`);
- }
- if (document.getElementById(`altText_${mediaId}`)) {
- LanguageInput.disable(`altText_${mediaId}`);
- }
-
- DomUtil.show(languageChooserContainer);
- }
- }
-
- /**
- * Edits the media with the given data or id.
- */
- public edit(editedMedia: Media | number): void {
- let media: Media;
- let mediaId = 0;
- if (typeof editedMedia === "object") {
- media = editedMedia;
- mediaId = media.mediaID;
- } else {
- media = {
- mediaID: editedMedia,
- } as Media;
- mediaId = editedMedia;
- }
-
- if (this._media !== null) {
- throw new Error(`Cannot edit media with id ${mediaId} while editing media with id '${this._media.mediaID}'.`);
- }
-
- this._media = media;
-
- if (!this._dialogs.has(`mediaEditor_${mediaId}`)) {
- this._dialogs.set(`mediaEditor_${mediaId}`, {
- _dialogSetup: () => {
- return {
- id: `mediaEditor_${mediaId}`,
- options: {
- backdropCloseOnClick: false,
- onClose: () => this._close(),
- title: Language.get("wcf.media.edit"),
- },
- source: {
- after: (content: HTMLElement, responseData: InitEditorData) => this._initEditor(content, responseData),
- data: {
- actionName: "getEditorDialog",
- className: "wcf\\data\\media\\MediaAction",
- objectIDs: [mediaId],
- },
- },
- };
- },
- });
- }
-
- UiDialog.open(this._dialogs.get(`mediaEditor_${mediaId}`)!);
- }
-
- /**
- * Updates the data of the currently edited media file.
- */
- public updateData(media: Media): void {
- if (this._callbackObject._editorSuccess) {
- this._callbackObject._editorSuccess(media);
- }
- }
-}
-
-Core.enableLegacyInheritance(MediaEditor);
-
-export = MediaEditor;
+++ /dev/null
-/**
- * Uploads media files.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Media/List/Upload
- */
-
-import MediaUpload from "../Upload";
-import { MediaListUploadOptions } from "../Data";
-import * as Core from "../../Core";
-
-class MediaListUpload extends MediaUpload<MediaListUploadOptions> {
- protected _createButton(): void {
- super._createButton();
-
- const span = this._button.querySelector("span") as HTMLSpanElement;
-
- const space = document.createTextNode(" ");
- span.insertBefore(space, span.childNodes[0]);
-
- const icon = document.createElement("span");
- icon.className = "icon icon16 fa-upload";
- span.insertBefore(icon, span.childNodes[0]);
- }
-
- protected _getParameters(): ArbitraryObject {
- if (this._options.categoryId) {
- return Core.extend(
- super._getParameters() as object,
- {
- categoryID: this._options.categoryId,
- } as object,
- ) as ArbitraryObject;
- }
-
- return super._getParameters();
- }
-}
-
-Core.enableLegacyInheritance(MediaListUpload);
-
-export = MediaListUpload;
+++ /dev/null
-/**
- * Provides the media manager dialog.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Media/Manager/Base
- */
-
-import * as Core from "../../Core";
-import { Media, MediaManagerOptions, MediaEditorCallbackObject, MediaUploadSuccessEventData } from "../Data";
-import * as Language from "../../Language";
-import * as Permission from "../../Permission";
-import * as DomChangeListener from "../../Dom/Change/Listener";
-import * as EventHandler from "../../Event/Handler";
-import * as DomTraverse from "../../Dom/Traverse";
-import * as DomUtil from "../../Dom/Util";
-import * as UiDialog from "../../Ui/Dialog";
-import { DialogCallbackSetup, DialogCallbackObject } from "../../Ui/Dialog/Data";
-import * as Clipboard from "../../Controller/Clipboard";
-import UiPagination from "../../Ui/Pagination";
-import * as UiNotification from "../../Ui/Notification";
-import * as StringUtil from "../../StringUtil";
-import MediaManagerSearch from "./Search";
-import MediaUpload from "../Upload";
-import MediaEditor from "../Editor";
-import * as MediaClipboard from "../Clipboard";
-
-let mediaManagerCounter = 0;
-
-interface DialogInitAjaxResponseData {
- returnValues: {
- hasMarkedItems: number;
- media: object;
- pageCount: number;
- };
-}
-
-interface SetMediaAdditionalData {
- pageCount: number;
- pageNo: number;
-}
-
-abstract class MediaManager<TOptions extends MediaManagerOptions = MediaManagerOptions>
- implements DialogCallbackObject, MediaEditorCallbackObject {
- protected _forceClipboard = false;
- protected _hadInitiallyMarkedItems = false;
- protected readonly _id;
- protected readonly _listItems = new Map<number, HTMLLIElement>();
- protected _media = new Map<number, Media>();
- protected _mediaCategorySelect: HTMLSelectElement | null;
- protected readonly _mediaEditor: MediaEditor | null = null;
- protected _mediaManagerMediaList: HTMLElement | null = null;
- protected _pagination: UiPagination | null = null;
- protected _search: MediaManagerSearch | null = null;
- protected _upload: any = null;
- protected readonly _options: TOptions;
-
- constructor(options: Partial<TOptions>) {
- this._options = Core.extend(
- {
- dialogTitle: Language.get("wcf.media.manager"),
- imagesOnly: false,
- minSearchLength: 3,
- },
- options,
- ) as TOptions;
-
- this._id = `mediaManager${mediaManagerCounter++}`;
-
- if (Permission.get("admin.content.cms.canManageMedia")) {
- this._mediaEditor = new MediaEditor(this);
- }
-
- DomChangeListener.add("WoltLabSuite/Core/Media/Manager", () => this._addButtonEventListeners());
-
- EventHandler.add("com.woltlab.wcf.media.upload", "success", (data: MediaUploadSuccessEventData) =>
- this._openEditorAfterUpload(data),
- );
- }
-
- /**
- * Adds click event listeners to media buttons.
- */
- protected _addButtonEventListeners(): void {
- if (!this._mediaManagerMediaList || !Permission.get("admin.content.cms.canManageMedia")) return;
-
- DomTraverse.childrenByTag(this._mediaManagerMediaList, "LI").forEach((listItem) => {
- const editIcon = listItem.querySelector(".jsMediaEditButton");
- if (editIcon) {
- editIcon.classList.remove("jsMediaEditButton");
- editIcon.addEventListener("click", (ev) => this._editMedia(ev));
- }
- });
- }
-
- /**
- * Is called when a new category is selected.
- */
- protected _categoryChange(): void {
- this._search!.search();
- }
-
- /**
- * Handles clicks on the media manager button.
- */
- protected _click(event: Event): void {
- event.preventDefault();
-
- UiDialog.open(this);
- }
-
- /**
- * Is called if the media manager dialog is closed.
- */
- protected _dialogClose(): void {
- // only show media clipboard if editor is open
- if (Permission.get("admin.content.cms.canManageMedia") || this._forceClipboard) {
- Clipboard.hideEditor("com.woltlab.wcf.media");
- }
- }
-
- /**
- * Initializes the dialog when first loaded.
- */
- protected _dialogInit(content: HTMLElement, data: DialogInitAjaxResponseData): void {
- // store media data locally
- Object.entries(data.returnValues.media || {}).forEach(([mediaId, media]) => {
- this._media.set(~~mediaId, media);
- });
-
- this._initPagination(~~data.returnValues.pageCount);
-
- this._hadInitiallyMarkedItems = data.returnValues.hasMarkedItems > 0;
- }
-
- /**
- * Returns all data to setup the media manager dialog.
- */
- public _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: this._id,
- options: {
- onClose: () => this._dialogClose(),
- onShow: () => this._dialogShow(),
- title: this._options.dialogTitle,
- },
- source: {
- after: (content: HTMLElement, data: DialogInitAjaxResponseData) => this._dialogInit(content, data),
- data: {
- actionName: "getManagementDialog",
- className: "wcf\\data\\media\\MediaAction",
- parameters: {
- mode: this.getMode(),
- imagesOnly: this._options.imagesOnly,
- },
- },
- },
- };
- }
-
- /**
- * Is called if the media manager dialog is shown.
- */
- protected _dialogShow(): void {
- if (!this._mediaManagerMediaList) {
- const dialog = this.getDialog();
-
- this._mediaManagerMediaList = dialog.querySelector(".mediaManagerMediaList");
-
- this._mediaCategorySelect = dialog.querySelector(".mediaManagerCategoryList > select");
- if (this._mediaCategorySelect) {
- this._mediaCategorySelect.addEventListener("change", () => this._categoryChange());
- }
-
- // store list items locally
- const listItems = DomTraverse.childrenByTag(this._mediaManagerMediaList!, "LI");
- listItems.forEach((listItem: HTMLLIElement) => {
- this._listItems.set(~~listItem.dataset.objectId!, listItem);
- });
-
- if (Permission.get("admin.content.cms.canManageMedia")) {
- const uploadButton = UiDialog.getDialog(this)!.dialog.querySelector(".mediaManagerMediaUploadButton")!;
- this._upload = new MediaUpload(DomUtil.identify(uploadButton), DomUtil.identify(this._mediaManagerMediaList!), {
- mediaManager: this,
- });
-
- // eslint-disable-next-line
- //@ts-ignore
- const deleteAction = new WCF.Action.Delete("wcf\\data\\media\\MediaAction", ".mediaFile");
- deleteAction._didTriggerEffect = (element) => this.removeMedia(element[0].dataset.objectId);
- }
-
- if (Permission.get("admin.content.cms.canManageMedia") || this._forceClipboard) {
- MediaClipboard.init("menuManagerDialog-" + this.getMode(), this._hadInitiallyMarkedItems ? true : false, this);
- } else {
- this._removeClipboardCheckboxes();
- }
-
- this._search = new MediaManagerSearch(this);
-
- if (!listItems.length) {
- this._search.hideSearch();
- }
- }
-
- // only show media clipboard if editor is open
- if (Permission.get("admin.content.cms.canManageMedia") || this._forceClipboard) {
- Clipboard.showEditor();
- }
- }
-
- /**
- * Opens the media editor for a media file.
- */
- protected _editMedia(event: Event): void {
- if (!Permission.get("admin.content.cms.canManageMedia")) {
- throw new Error("You are not allowed to edit media files.");
- }
-
- UiDialog.close(this);
-
- const target = event.currentTarget as HTMLElement;
-
- this._mediaEditor!.edit(this._media.get(~~target.dataset.objectId!)!);
- }
-
- /**
- * Re-opens the manager dialog after closing the editor dialog.
- */
- _editorClose(): void {
- UiDialog.open(this);
- }
-
- /**
- * Re-opens the manager dialog and updates the media data after successfully editing a media file.
- */
- _editorSuccess(media: Media, oldCategoryId?: number): void {
- // if the category changed of media changed and category
- // is selected, check if media list needs to be refreshed
- if (this._mediaCategorySelect) {
- const selectedCategoryId = ~~this._mediaCategorySelect.value;
-
- if (selectedCategoryId) {
- const newCategoryId = ~~media.categoryID;
-
- if (
- oldCategoryId != newCategoryId &&
- (oldCategoryId == selectedCategoryId || newCategoryId == selectedCategoryId)
- ) {
- this._search!.search();
- }
- }
- }
-
- UiDialog.open(this);
-
- this._media.set(~~media.mediaID, media);
-
- const listItem = this._listItems.get(~~media.mediaID)!;
- const p = listItem.querySelector(".mediaTitle")!;
- if (media.isMultilingual) {
- if (media.title && media.title[window.LANGUAGE_ID]) {
- p.textContent = media.title[window.LANGUAGE_ID];
- } else {
- p.textContent = media.filename;
- }
- } else {
- if (media.title && media.title[media.languageID!]) {
- p.textContent = media.title[media.languageID!];
- } else {
- p.textContent = media.filename;
- }
- }
-
- const thumbnail = listItem.querySelector(".mediaThumbnail")!;
- thumbnail.innerHTML = media.elementTag;
- // Bust browser cache by adding additional parameter.
- const img = thumbnail.querySelector("img");
- if (img) {
- img.src += `&refresh=${Date.now()}`;
- }
- }
-
- /**
- * Initializes the dialog pagination.
- */
- protected _initPagination(pageCount: number, pageNo?: number): void {
- if (pageNo === undefined) pageNo = 1;
-
- if (pageCount > 1) {
- const newPagination = document.createElement("div");
- newPagination.className = "paginationBottom jsPagination";
- DomUtil.replaceElement(
- UiDialog.getDialog(this)!.content.querySelector(".jsPagination") as HTMLElement,
- newPagination,
- );
-
- this._pagination = new UiPagination(newPagination, {
- activePage: pageNo,
- callbackSwitch: (pageNo: number) => this._search!.search(pageNo),
- maxPage: pageCount,
- });
- } else if (this._pagination) {
- DomUtil.hide(this._pagination.getElement());
- }
- }
-
- /**
- * Removes all media clipboard checkboxes.
- */
- _removeClipboardCheckboxes(): void {
- this._mediaManagerMediaList!.querySelectorAll(".mediaCheckbox").forEach((el) => el.remove());
- }
-
- /**
- * Opens the media editor after uploading a single file.
- *
- * @since 5.2
- */
- _openEditorAfterUpload(data: MediaUploadSuccessEventData): void {
- if (data.upload === this._upload && !data.isMultiFileUpload && !this._upload.hasPendingUploads()) {
- const keys = Object.keys(data.media);
-
- if (keys.length) {
- UiDialog.close(this);
-
- this._mediaEditor!.edit(this._media.get(~~data.media[keys[0]].mediaID)!);
- }
- }
- }
-
- /**
- * Sets the displayed media (after a search).
- */
- _setMedia(media: object): void {
- this._media = new Map<number, Media>(Object.entries(media).map(([mediaId, media]) => [~~mediaId, media]));
-
- let info = DomTraverse.nextByClass(this._mediaManagerMediaList!, "info") as HTMLElement;
-
- if (this._media.size) {
- if (info) {
- DomUtil.hide(info);
- }
- } else {
- if (info === null) {
- info = document.createElement("p");
- info.className = "info";
- info.textContent = Language.get("wcf.media.search.noResults");
- }
-
- DomUtil.show(info);
- DomUtil.insertAfter(info, this._mediaManagerMediaList!);
- }
-
- DomTraverse.childrenByTag(this._mediaManagerMediaList!, "LI").forEach((listItem) => {
- if (!this._media.has(~~listItem.dataset.objectId!)) {
- DomUtil.hide(listItem);
- } else {
- DomUtil.show(listItem);
- }
- });
-
- DomChangeListener.trigger();
-
- if (Permission.get("admin.content.cms.canManageMedia") || this._forceClipboard) {
- Clipboard.reload();
- } else {
- this._removeClipboardCheckboxes();
- }
- }
-
- /**
- * Adds a media file to the manager.
- */
- public addMedia(media: Media, listItem: HTMLLIElement): void {
- if (!media.languageID) media.isMultilingual = 1;
-
- this._media.set(~~media.mediaID, media);
- this._listItems.set(~~media.mediaID, listItem);
-
- if (this._listItems.size === 1) {
- this._search!.showSearch();
- }
- }
-
- /**
- * Is called after the media files with the given ids have been deleted via clipboard.
- */
- public clipboardDeleteMedia(mediaIds: number[]): void {
- mediaIds.forEach((mediaId) => {
- this.removeMedia(~~mediaId);
- });
-
- UiNotification.show();
- }
-
- /**
- * Returns the id of the currently selected category or `0` if no category is selected.
- */
- public getCategoryId(): number {
- if (this._mediaCategorySelect) {
- return ~~this._mediaCategorySelect.value;
- }
-
- return 0;
- }
-
- /**
- * Returns the media manager dialog element.
- */
- getDialog(): HTMLElement {
- return UiDialog.getDialog(this)!.dialog;
- }
-
- /**
- * Returns the mode of the media manager.
- */
- public getMode(): string {
- return "";
- }
-
- /**
- * Returns the media manager option with the given name.
- */
- public getOption(name: string): any {
- if (this._options[name]) {
- return this._options[name];
- }
-
- return null;
- }
-
- /**
- * Removes a media file.
- */
- public removeMedia(mediaId: number): void {
- if (this._listItems.has(mediaId)) {
- // remove list item
- try {
- this._listItems.get(mediaId)!.remove();
- } catch (e) {
- // ignore errors if item has already been removed like by WCF.Action.Delete
- }
-
- this._listItems.delete(mediaId);
- this._media.delete(mediaId);
- }
- }
-
- /**
- * Changes the displayed media to the previously displayed media.
- */
- public resetMedia(): void {
- // calling WoltLabSuite/Core/Media/Manager/Search.search() reloads the first page of the dialog
- this._search!.search();
- }
-
- /**
- * Sets the media files currently displayed.
- */
- setMedia(media: object, template: string, additionalData: SetMediaAdditionalData): void {
- const hasMedia = Object.entries(media).length > 0;
-
- if (hasMedia) {
- const ul = document.createElement("ul");
- ul.innerHTML = template;
-
- DomTraverse.childrenByTag(ul, "LI").forEach((listItem) => {
- if (!this._listItems.has(~~listItem.dataset.objectId!)) {
- this._listItems.set(~~listItem.dataset.objectId!, listItem);
-
- this._mediaManagerMediaList!.appendChild(listItem);
- }
- });
- }
-
- this._initPagination(additionalData.pageCount, additionalData.pageNo);
-
- this._setMedia(media);
- }
-
- /**
- * Sets up a new media element.
- */
- public setupMediaElement(media: Media, mediaElement: HTMLElement): void {
- const mediaInformation = DomTraverse.childByClass(mediaElement, "mediaInformation")!;
-
- const buttonGroupNavigation = document.createElement("nav");
- buttonGroupNavigation.className = "jsMobileNavigation buttonGroupNavigation";
- mediaInformation.parentNode!.appendChild(buttonGroupNavigation);
-
- const buttons = document.createElement("ul");
- buttons.className = "buttonList iconList";
- buttonGroupNavigation.appendChild(buttons);
-
- const listItem = document.createElement("li");
- listItem.className = "mediaCheckbox";
- buttons.appendChild(listItem);
-
- const a = document.createElement("a");
- listItem.appendChild(a);
-
- const label = document.createElement("label");
- a.appendChild(label);
-
- const checkbox = document.createElement("input");
- checkbox.className = "jsClipboardItem";
- checkbox.type = "checkbox";
- checkbox.dataset.objectId = media.mediaID.toString();
- label.appendChild(checkbox);
-
- if (Permission.get("admin.content.cms.canManageMedia")) {
- const editButton = document.createElement("li");
- editButton.className = "jsMediaEditButton";
- editButton.dataset.objectId = media.mediaID.toString();
- buttons.appendChild(editButton);
-
- editButton.innerHTML = `
- <a>
- <span class="icon icon16 fa-pencil jsTooltip" title="${Language.get("wcf.global.button.edit")}"></span>
- <span class="invisible">${Language.get("wcf.global.button.edit")}</span>
- </a>`;
-
- const deleteButton = document.createElement("li");
- deleteButton.className = "jsDeleteButton";
- deleteButton.dataset.objectId = media.mediaID.toString();
-
- // use temporary title to not unescape html in filename
- const uuid = Core.getUuid();
- deleteButton.dataset.confirmMessageHtml = StringUtil.unescapeHTML(
- Language.get("wcf.media.delete.confirmMessage", {
- title: uuid,
- }),
- ).replace(uuid, StringUtil.escapeHTML(media.filename));
- buttons.appendChild(deleteButton);
-
- deleteButton.innerHTML = `
- <a>
- <span class="icon icon16 fa-times jsTooltip" title="${Language.get("wcf.global.button.delete")}"></span>
- <span class="invisible">${Language.get("wcf.global.button.delete")}</span>
- </a>`;
- }
- }
-}
-
-Core.enableLegacyInheritance(MediaManager);
-
-export = MediaManager;
+++ /dev/null
-/**
- * Provides the media manager dialog for selecting media for Redactor editors.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Media/Manager/Editor
- */
-
-import MediaManager from "./Base";
-import * as Core from "../../Core";
-import { Media, MediaInsertType, MediaManagerEditorOptions, MediaUploadSuccessEventData } from "../Data";
-import * as EventHandler from "../../Event/Handler";
-import * as DomTraverse from "../../Dom/Traverse";
-import * as Language from "../../Language";
-import * as UiDialog from "../../Ui/Dialog";
-import * as Clipboard from "../../Controller/Clipboard";
-import { OnDropPayload } from "../../Ui/Redactor/DragAndDrop";
-import DomUtil from "../../Dom/Util";
-
-interface PasteFromClipboard {
- blob: Blob;
-}
-
-class MediaManagerEditor extends MediaManager<MediaManagerEditorOptions> {
- protected _activeButton;
- protected readonly _buttons: HTMLCollectionOf<HTMLElement>;
- protected _mediaToInsert: Map<number, Media>;
- protected _mediaToInsertByClipboard: boolean;
- protected _uploadData: OnDropPayload | PasteFromClipboard | null;
- protected _uploadId: number | null;
-
- constructor(options: Partial<MediaManagerEditorOptions>) {
- options = Core.extend(
- {
- callbackInsert: null,
- },
- options,
- );
-
- super(options);
-
- this._forceClipboard = true;
- this._activeButton = null;
- const context = this._options.editor ? this._options.editor.core.toolbar()[0] : undefined;
- this._buttons = (context || window.document).getElementsByClassName(
- this._options.buttonClass || "jsMediaEditorButton",
- ) as HTMLCollectionOf<HTMLElement>;
- Array.from(this._buttons).forEach((button) => {
- button.addEventListener("click", (ev) => this._click(ev));
- });
- this._mediaToInsert = new Map<number, Media>();
- this._mediaToInsertByClipboard = false;
- this._uploadData = null;
- this._uploadId = null;
-
- if (this._options.editor && !this._options.editor.opts.woltlab.attachments) {
- const editorId = this._options.editor.$editor[0].dataset.elementId as string;
-
- const uuid1 = EventHandler.add("com.woltlab.wcf.redactor2", `dragAndDrop_${editorId}`, (data: OnDropPayload) =>
- this._editorUpload(data),
- );
- const uuid2 = EventHandler.add(
- "com.woltlab.wcf.redactor2",
- `pasteFromClipboard_${editorId}`,
- (data: OnDropPayload) => this._editorUpload(data),
- );
-
- EventHandler.add("com.woltlab.wcf.redactor2", `destroy_${editorId}`, () => {
- EventHandler.remove("com.woltlab.wcf.redactor2", `dragAndDrop_${editorId}`, uuid1);
- EventHandler.remove("com.woltlab.wcf.redactor2", `dragAndDrop_${editorId}`, uuid2);
- });
-
- EventHandler.add("com.woltlab.wcf.media.upload", "success", (data) => this._mediaUploaded(data));
- }
- }
-
- protected _addButtonEventListeners(): void {
- super._addButtonEventListeners();
-
- if (!this._mediaManagerMediaList) {
- return;
- }
-
- DomTraverse.childrenByTag(this._mediaManagerMediaList, "LI").forEach((listItem) => {
- const insertIcon = listItem.querySelector(".jsMediaInsertButton");
- if (insertIcon) {
- insertIcon.classList.remove("jsMediaInsertButton");
- insertIcon.addEventListener("click", (ev) => this._openInsertDialog(ev));
- }
- });
- }
-
- /**
- * Builds the dialog to setup inserting media files.
- */
- protected _buildInsertDialog(): void {
- let thumbnailOptions = "";
-
- this._getThumbnailSizes().forEach((thumbnailSize) => {
- thumbnailOptions +=
- '<option value="' +
- thumbnailSize +
- '">' +
- Language.get("wcf.media.insert.imageSize." + thumbnailSize) +
- "</option>";
- });
- thumbnailOptions += '<option value="original">' + Language.get("wcf.media.insert.imageSize.original") + "</option>";
-
- const dialog = `
- <div class="section">
- <dl class="thumbnailSizeSelection">
- <dt>${Language.get("wcf.media.insert.imageSize")}</dt>
- <dd>
- <select name="thumbnailSize">
- ${thumbnailOptions}
- </select>
- </dd>
- </dl>
- </div>
- <div class="formSubmit">
- <button class="buttonPrimary">${Language.get("wcf.global.button.insert")}</button>
- </div>`;
-
- UiDialog.open({
- _dialogSetup: () => {
- return {
- id: this._getInsertDialogId(),
- options: {
- onClose: () => this._editorClose(),
- onSetup: (content) => {
- content.querySelector(".buttonPrimary")!.addEventListener("click", (ev) => this._insertMedia(ev));
-
- DomUtil.show(content.querySelector(".thumbnailSizeSelection") as HTMLElement);
- },
- title: Language.get("wcf.media.insert"),
- },
- source: dialog,
- };
- },
- });
- }
-
- protected _click(event: Event): void {
- this._activeButton = event.currentTarget;
-
- super._click(event);
- }
-
- protected _dialogShow(): void {
- super._dialogShow();
-
- // check if data needs to be uploaded
- if (this._uploadData) {
- const fileUploadData = this._uploadData as OnDropPayload;
- if (fileUploadData.file) {
- this._upload.uploadFile(fileUploadData.file);
- } else {
- const blobUploadData = this._uploadData as PasteFromClipboard;
- this._uploadId = this._upload.uploadBlob(blobUploadData.blob);
- }
-
- this._uploadData = null;
- }
- }
-
- /**
- * Handles pasting and dragging and dropping files into the editor.
- */
- protected _editorUpload(data: OnDropPayload): void {
- this._uploadData = data;
-
- UiDialog.open(this);
- }
-
- /**
- * Returns the id of the insert dialog based on the media files to be inserted.
- */
- protected _getInsertDialogId(): string {
- return ["mediaInsert", ...this._mediaToInsert.keys()].join("-");
- }
-
- /**
- * Returns the supported thumbnail sizes (excluding `original`) for all media images to be inserted.
- */
- protected _getThumbnailSizes(): string[] {
- return ["small", "medium", "large"]
- .map((size) => {
- const sizeSupported = Array.from(this._mediaToInsert.values()).every((media) => {
- return media[size + "ThumbnailType"] !== null;
- });
-
- if (sizeSupported) {
- return size;
- }
-
- return null;
- })
- .filter((s) => s !== null) as string[];
- }
-
- /**
- * Inserts media files into the editor.
- */
- protected _insertMedia(event?: Event | null, thumbnailSize?: string, closeEditor = false): void {
- if (closeEditor === undefined) closeEditor = true;
-
- // update insert options with selected values if method is called by clicking on 'insert' button
- // in dialog
- if (event) {
- UiDialog.close(this._getInsertDialogId());
-
- const dialogContent = (event.currentTarget as HTMLElement).closest(".dialogContent")!;
- const thumbnailSizeSelect = dialogContent.querySelector("select[name=thumbnailSize]") as HTMLSelectElement;
- thumbnailSize = thumbnailSizeSelect.value;
- }
-
- if (this._options.callbackInsert !== null) {
- this._options.callbackInsert(this._mediaToInsert, MediaInsertType.Separate, thumbnailSize!);
- } else {
- this._options.editor!.buffer.set();
- }
-
- if (this._mediaToInsertByClipboard) {
- Clipboard.unmark("com.woltlab.wcf.media", Array.from(this._mediaToInsert.keys()));
- }
-
- this._mediaToInsert = new Map<number, Media>();
- this._mediaToInsertByClipboard = false;
-
- // close manager dialog
- if (closeEditor) {
- UiDialog.close(this);
- }
- }
-
- /**
- * Inserts a single media item into the editor.
- */
- protected _insertMediaItem(thumbnailSize: string, media: Media): void {
- if (media.isImage) {
- let available = "";
- ["small", "medium", "large", "original"].some((size) => {
- if (media[size + "ThumbnailHeight"] != 0) {
- available = size;
-
- if (thumbnailSize == size) {
- return true;
- }
- }
-
- return false;
- });
-
- thumbnailSize = available;
-
- if (!thumbnailSize) {
- thumbnailSize = "original";
- }
-
- let link = media.link;
- if (thumbnailSize !== "original") {
- link = media[thumbnailSize + "ThumbnailLink"];
- }
-
- this._options.editor!.insert.html(
- `<img src="${link}" class="woltlabSuiteMedia" data-media-id="${media.mediaID}" data-media-size="${thumbnailSize}">`,
- );
- } else {
- this._options.editor!.insert.text(`[wsm='${media.mediaID}'][/wsm]`);
- }
- }
-
- /**
- * Is called after media files are successfully uploaded to insert copied media.
- */
- protected _mediaUploaded(data: MediaUploadSuccessEventData): void {
- if (this._uploadId !== null && this._upload === data.upload) {
- if (
- this._uploadId === data.uploadId ||
- (Array.isArray(this._uploadId) && this._uploadId.indexOf(data.uploadId) !== -1)
- ) {
- this._mediaToInsert = new Map<number, Media>(data.media.entries());
- this._insertMedia(null, "medium", false);
-
- this._uploadId = null;
- }
- }
- }
-
- /**
- * Handles clicking on the insert button.
- */
- protected _openInsertDialog(event: Event): void {
- const target = event.currentTarget as HTMLElement;
-
- this.insertMedia([~~target.dataset.objectId!]);
- }
-
- /**
- * Is called to insert the media files with the given ids into an editor.
- */
- public clipboardInsertMedia(mediaIds: number[]): void {
- this.insertMedia(mediaIds, true);
- }
-
- /**
- * Prepares insertion of the media files with the given ids.
- */
- public insertMedia(mediaIds: number[], insertedByClipboard?: boolean): void {
- this._mediaToInsert = new Map<number, Media>();
- this._mediaToInsertByClipboard = insertedByClipboard || false;
-
- // open the insert dialog if all media files are images
- let imagesOnly = true;
- mediaIds.forEach((mediaId) => {
- const media = this._media.get(mediaId)!;
- this._mediaToInsert.set(media.mediaID, media);
-
- if (!media.isImage) {
- imagesOnly = false;
- }
- });
-
- if (imagesOnly) {
- const thumbnailSizes = this._getThumbnailSizes();
- if (thumbnailSizes.length) {
- UiDialog.close(this);
- const dialogId = this._getInsertDialogId();
- if (UiDialog.getDialog(dialogId)) {
- UiDialog.openStatic(dialogId, null);
- } else {
- this._buildInsertDialog();
- }
- } else {
- this._insertMedia(undefined, "original");
- }
- } else {
- this._insertMedia();
- }
- }
-
- public getMode(): string {
- return "editor";
- }
-
- public setupMediaElement(media: Media, mediaElement: HTMLElement): void {
- super.setupMediaElement(media, mediaElement);
-
- // add media insertion icon
- const buttons = mediaElement.querySelector("nav.buttonGroupNavigation > ul")!;
-
- const listItem = document.createElement("li");
- listItem.className = "jsMediaInsertButton";
- listItem.dataset.objectId = media.mediaID.toString();
- buttons.appendChild(listItem);
-
- listItem.innerHTML = `
- <a>
- <span class="icon icon16 fa-plus jsTooltip" title="${Language.get("wcf.global.button.insert")}"></span>
- <span class="invisible">${Language.get("wcf.global.button.insert")}</span>
- </a>`;
- }
-}
-
-Core.enableLegacyInheritance(MediaManagerEditor);
-
-export = MediaManagerEditor;
+++ /dev/null
-/**
- * Provides the media search for the media manager.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Media/Manager/Search
- */
-
-import MediaManager from "./Base";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../../Ajax/Data";
-import { Media } from "../Data";
-import * as DomTraverse from "../../Dom/Traverse";
-import * as Language from "../../Language";
-import * as Ajax from "../../Ajax";
-import * as Core from "../../Core";
-import DomUtil from "../../Dom/Util";
-
-interface AjaxResponseData {
- returnValues: {
- media?: Media;
- pageCount?: number;
- pageNo?: number;
- template?: string;
- };
-}
-
-class MediaManagerSearch implements AjaxCallbackObject {
- protected readonly _cancelButton: HTMLSpanElement;
- protected readonly _input: HTMLInputElement;
- protected readonly _mediaManager: MediaManager;
- protected readonly _searchContainer: HTMLDivElement;
- protected _searchMode = false;
-
- constructor(mediaManager: MediaManager) {
- this._mediaManager = mediaManager;
-
- const dialog = mediaManager.getDialog();
-
- this._searchContainer = dialog.querySelector(".mediaManagerSearch") as HTMLDivElement;
- this._input = dialog.querySelector(".mediaManagerSearchField") as HTMLInputElement;
- this._input.addEventListener("keypress", (ev) => this._keyPress(ev));
-
- this._cancelButton = dialog.querySelector(".mediaManagerSearchCancelButton") as HTMLSpanElement;
- this._cancelButton.addEventListener("click", () => this._cancelSearch());
- }
-
- public _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "getSearchResultList",
- className: "wcf\\data\\media\\MediaAction",
- interfaceName: "wcf\\data\\ISearchAction",
- },
- };
- }
-
- public _ajaxSuccess(data: AjaxResponseData): void {
- this._mediaManager.setMedia(data.returnValues.media || ({} as Media), data.returnValues.template || "", {
- pageCount: data.returnValues.pageCount || 0,
- pageNo: data.returnValues.pageNo || 0,
- });
-
- this._mediaManager.getDialog().querySelector(".dialogContent")!.scrollTop = 0;
- }
-
- /**
- * Cancels the search after clicking on the cancel search button.
- */
- protected _cancelSearch(): void {
- if (this._searchMode) {
- this._searchMode = false;
-
- this.resetSearch();
- this._mediaManager.resetMedia();
- }
- }
-
- /**
- * Hides the search string threshold error.
- */
- protected _hideStringThresholdError(): void {
- const innerInfo = DomTraverse.childByClass(
- this._input.parentNode!.parentNode as HTMLElement,
- "innerInfo",
- ) as HTMLElement;
- if (innerInfo) {
- DomUtil.hide(innerInfo);
- }
- }
-
- /**
- * Handles the `[ENTER]` key to submit the form.
- */
- protected _keyPress(event: KeyboardEvent): void {
- if (event.key === "Enter") {
- event.preventDefault();
-
- if (this._input.value.length >= this._mediaManager.getOption("minSearchLength")) {
- this._hideStringThresholdError();
-
- this.search();
- } else {
- this._showStringThresholdError();
- }
- }
- }
-
- /**
- * Shows the search string threshold error.
- */
- protected _showStringThresholdError(): void {
- let innerInfo = DomTraverse.childByClass(
- this._input.parentNode!.parentNode as HTMLElement,
- "innerInfo",
- ) as HTMLParagraphElement;
- if (innerInfo) {
- DomUtil.show(innerInfo);
- } else {
- innerInfo = document.createElement("p");
- innerInfo.className = "innerInfo";
- innerInfo.textContent = Language.get("wcf.media.search.info.searchStringThreshold", {
- minSearchLength: this._mediaManager.getOption("minSearchLength"),
- });
-
- (this._input.parentNode! as HTMLElement).insertAdjacentElement("afterend", innerInfo);
- }
- }
-
- /**
- * Hides the media search.
- */
- public hideSearch(): void {
- DomUtil.hide(this._searchContainer);
- }
-
- /**
- * Resets the media search.
- */
- public resetSearch(): void {
- this._input.value = "";
- }
-
- /**
- * Shows the media search.
- */
- public showSearch(): void {
- DomUtil.show(this._searchContainer);
- }
-
- /**
- * Sends an AJAX request to fetch search results.
- */
- public search(pageNo?: number): void {
- if (typeof pageNo !== "number") {
- pageNo = 1;
- }
-
- let searchString = this._input.value;
- if (searchString && this._input.value.length < this._mediaManager.getOption("minSearchLength")) {
- this._showStringThresholdError();
-
- searchString = "";
- } else {
- this._hideStringThresholdError();
- }
-
- this._searchMode = true;
-
- Ajax.api(this, {
- parameters: {
- categoryID: this._mediaManager.getCategoryId(),
- imagesOnly: this._mediaManager.getOption("imagesOnly"),
- mode: this._mediaManager.getMode(),
- pageNo: pageNo,
- searchString: searchString,
- },
- });
- }
-}
-
-Core.enableLegacyInheritance(MediaManagerSearch);
-
-export = MediaManagerSearch;
+++ /dev/null
-/**
- * Provides the media manager dialog for selecting media for input elements.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Media/Manager/Select
- */
-
-import MediaManager from "./Base";
-import * as Core from "../../Core";
-import { Media, MediaManagerSelectOptions } from "../Data";
-import * as DomTraverse from "../../Dom/Traverse";
-import * as FileUtil from "../../FileUtil";
-import * as Language from "../../Language";
-import * as UiDialog from "../../Ui/Dialog";
-import DomUtil from "../../Dom/Util";
-
-class MediaManagerSelect extends MediaManager<MediaManagerSelectOptions> {
- protected _activeButton: HTMLElement | null = null;
- protected readonly _buttons: HTMLCollectionOf<HTMLInputElement>;
- protected readonly _storeElements = new WeakMap<HTMLElement, HTMLInputElement>();
-
- constructor(options: Partial<MediaManagerSelectOptions>) {
- super(options);
-
- this._buttons = document.getElementsByClassName(
- this._options.buttonClass || "jsMediaSelectButton",
- ) as HTMLCollectionOf<HTMLInputElement>;
- Array.from(this._buttons).forEach((button) => {
- // only consider buttons with a proper store specified
- const store = button.dataset.store;
- if (store) {
- const storeElement = document.getElementById(store) as HTMLInputElement;
- if (storeElement && storeElement.tagName === "INPUT") {
- button.addEventListener("click", (ev) => this._click(ev));
-
- this._storeElements.set(button, storeElement);
-
- // add remove button
- const removeButton = document.createElement("p");
- removeButton.className = "button";
- button.insertAdjacentElement("afterend", removeButton);
-
- const icon = document.createElement("span");
- icon.className = "icon icon16 fa-times";
- removeButton.appendChild(icon);
-
- if (!storeElement.value) {
- DomUtil.hide(removeButton);
- }
- removeButton.addEventListener("click", (ev) => this._removeMedia(ev));
- }
- }
- });
- }
-
- protected _addButtonEventListeners(): void {
- super._addButtonEventListeners();
-
- if (!this._mediaManagerMediaList) return;
-
- DomTraverse.childrenByTag(this._mediaManagerMediaList, "LI").forEach((listItem) => {
- const chooseIcon = listItem.querySelector(".jsMediaSelectButton");
- if (chooseIcon) {
- chooseIcon.classList.remove("jsMediaSelectButton");
- chooseIcon.addEventListener("click", (ev) => this._chooseMedia(ev));
- }
- });
- }
-
- /**
- * Handles clicking on a media choose icon.
- */
- protected _chooseMedia(event: Event): void {
- if (this._activeButton === null) {
- throw new Error("Media cannot be chosen if no button is active.");
- }
-
- const target = event.currentTarget as HTMLElement;
-
- const media = this._media.get(~~target.dataset.objectId!)!;
-
- // save selected media in store element
- const input = document.getElementById(this._activeButton.dataset.store!) as HTMLInputElement;
- input.value = media.mediaID.toString();
- Core.triggerEvent(input, "change");
-
- // display selected media
- const display = this._activeButton.dataset.display;
- if (display) {
- const displayElement = document.getElementById(display);
- if (displayElement) {
- if (media.isImage) {
- const thumbnailLink: string = media.smallThumbnailLink ? media.smallThumbnailLink : media.link;
- const altText: string =
- media.altText && media.altText[window.LANGUAGE_ID] ? media.altText[window.LANGUAGE_ID] : "";
- displayElement.innerHTML = `<img src="${thumbnailLink}" alt="${altText}" />`;
- } else {
- let fileIcon = FileUtil.getIconNameByFilename(media.filename);
- if (fileIcon) {
- fileIcon = "-" + fileIcon;
- }
-
- displayElement.innerHTML = `
- <div class="box48" style="margin-bottom: 10px;">
- <span class="icon icon48 fa-file${fileIcon}-o"></span>
- <div class="containerHeadline">
- <h3>${media.filename}</h3>
- <p>${media.formattedFilesize}</p>
- </div>
- </div>`;
- }
- }
- }
-
- // show remove button
- (this._activeButton.nextElementSibling as HTMLElement).style.removeProperty("display");
-
- UiDialog.close(this);
- }
-
- protected _click(event: Event): void {
- event.preventDefault();
- this._activeButton = event.currentTarget as HTMLInputElement;
-
- super._click(event);
-
- if (!this._mediaManagerMediaList) {
- return;
- }
-
- const storeElement = this._storeElements.get(this._activeButton)!;
- DomTraverse.childrenByTag(this._mediaManagerMediaList, "LI").forEach((listItem) => {
- if (storeElement.value && storeElement.value == listItem.dataset.objectId) {
- listItem.classList.add("jsSelected");
- } else {
- listItem.classList.remove("jsSelected");
- }
- });
- }
-
- public getMode(): string {
- return "select";
- }
-
- public setupMediaElement(media: Media, mediaElement: HTMLElement): void {
- super.setupMediaElement(media, mediaElement);
-
- // add media insertion icon
- const buttons = mediaElement.querySelector("nav.buttonGroupNavigation > ul") as HTMLUListElement;
-
- const listItem = document.createElement("li");
- listItem.className = "jsMediaSelectButton";
- listItem.dataset.objectId = media.mediaID.toString();
- buttons.appendChild(listItem);
-
- listItem.innerHTML =
- '<a><span class="icon icon16 fa-check jsTooltip" title="' +
- Language.get("wcf.media.button.select") +
- '"></span> <span class="invisible">' +
- Language.get("wcf.media.button.select") +
- "</span></a>";
- }
-
- /**
- * Handles clicking on the remove button.
- */
- protected _removeMedia(event: Event): void {
- event.preventDefault();
-
- const removeButton = event.currentTarget as HTMLSpanElement;
- const button = removeButton.previousElementSibling as HTMLElement;
-
- removeButton.remove();
-
- const input = document.getElementById(button.dataset.store!) as HTMLInputElement;
- input.value = "";
- Core.triggerEvent(input, "change");
- const display = button.dataset.display;
- if (display) {
- const displayElement = document.getElementById(display);
- if (displayElement) {
- displayElement.innerHTML = "";
- }
- }
- }
-}
-
-Core.enableLegacyInheritance(MediaManagerSelect);
-
-export = MediaManagerSelect;
+++ /dev/null
-/**
- * Uploads replacemnts for media files.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Media/Replace
- * @since 5.3
- */
-
-import * as Core from "../Core";
-import { MediaUploadAjaxResponseData, MediaUploadError, MediaUploadOptions } from "./Data";
-import MediaUpload from "./Upload";
-import * as Language from "../Language";
-import DomUtil from "../Dom/Util";
-import * as UiNotification from "../Ui/Notification";
-import * as DomChangeListener from "../Dom/Change/Listener";
-
-class MediaReplace extends MediaUpload {
- protected readonly _mediaID: number;
-
- constructor(mediaID: number, buttonContainerId: string, targetId: string, options: Partial<MediaUploadOptions>) {
- super(
- buttonContainerId,
- targetId,
- Core.extend(options, {
- action: "replaceFile",
- }),
- );
-
- this._mediaID = mediaID;
- }
-
- protected _createButton(): void {
- super._createButton();
-
- this._button.classList.add("small");
-
- this._button.querySelector("span")!.textContent = Language.get("wcf.media.button.replaceFile");
- }
-
- protected _createFileElement(): HTMLElement {
- return this._target;
- }
-
- protected _getFormData(): ArbitraryObject {
- return {
- objectIDs: [this._mediaID],
- };
- }
-
- protected _success(uploadId: number, data: MediaUploadAjaxResponseData): void {
- this._fileElements[uploadId].forEach((file) => {
- const internalFileId = file.dataset.internalFileId!;
- const media = data.returnValues.media[internalFileId];
-
- if (media) {
- if (media.isImage) {
- this._target.innerHTML = media.smallThumbnailTag;
- }
-
- document.getElementById("mediaFilename")!.textContent = media.filename;
- document.getElementById("mediaFilesize")!.textContent = media.formattedFilesize;
- if (media.isImage) {
- document.getElementById("mediaImageDimensions")!.textContent = media.imageDimensions;
- }
- document.getElementById("mediaUploader")!.innerHTML = media.userLinkElement;
-
- this._options.mediaEditor!.updateData(media);
-
- // Remove existing error messages.
- DomUtil.innerError(this._buttonContainer, "");
-
- UiNotification.show();
- } else {
- let error: MediaUploadError = data.returnValues.errors[internalFileId];
- if (!error) {
- error = {
- errorType: "uploadFailed",
- filename: file.dataset.filename!,
- };
- }
-
- DomUtil.innerError(
- this._buttonContainer,
- Language.get("wcf.media.upload.error." + error.errorType, {
- filename: error.filename,
- }),
- );
- }
-
- DomChangeListener.trigger();
- });
- }
-}
-
-Core.enableLegacyInheritance(MediaReplace);
-
-export = MediaReplace;
+++ /dev/null
-/**
- * Uploads media files.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Media/Upload
- */
-
-import Upload from "../Upload";
-import * as Core from "../Core";
-import * as DomUtil from "../Dom/Util";
-import * as DomTraverse from "../Dom/Traverse";
-import * as Language from "../Language";
-import User from "../User";
-import * as DateUtil from "../Date/Util";
-import * as FileUtil from "../FileUtil";
-import * as DomChangeListener from "../Dom/Change/Listener";
-import {
- Media,
- MediaUploadOptions,
- MediaUploadSuccessEventData,
- MediaUploadError,
- MediaUploadAjaxResponseData,
-} from "./Data";
-import * as EventHandler from "../Event/Handler";
-import MediaManager from "./Manager/Base";
-
-class MediaUpload<TOptions extends MediaUploadOptions = MediaUploadOptions> extends Upload<TOptions> {
- protected _categoryId: number | null = null;
- protected readonly _elementTagSize: number;
- protected readonly _mediaManager: MediaManager | null;
-
- constructor(buttonContainerId: string, targetId: string, options: Partial<TOptions>) {
- super(
- buttonContainerId,
- targetId,
- Core.extend(
- {
- className: "wcf\\data\\media\\MediaAction",
- multiple: options.mediaManager ? true : false,
- singleFileRequests: true,
- },
- options || {},
- ),
- );
-
- options = options || {};
-
- this._elementTagSize = 144;
- if (this._options.elementTagSize) {
- this._elementTagSize = this._options.elementTagSize;
- }
-
- this._mediaManager = null;
- if (this._options.mediaManager) {
- this._mediaManager = this._options.mediaManager;
- delete this._options.mediaManager;
- }
- }
-
- protected _createFileElement(file: File): HTMLElement {
- let fileElement: HTMLElement;
- if (this._target.nodeName === "OL" || this._target.nodeName === "UL") {
- fileElement = document.createElement("li");
- } else if (this._target.nodeName === "TBODY") {
- const firstTr = this._target.getElementsByTagName("TR")[0] as HTMLTableRowElement;
- const tableContainer = this._target.parentNode!.parentNode! as HTMLElement;
- if (tableContainer.style.getPropertyValue("display") === "none") {
- fileElement = firstTr;
-
- tableContainer.style.removeProperty("display");
-
- document.getElementById(this._target.dataset.noItemsInfo!)!.remove();
- } else {
- fileElement = firstTr.cloneNode(true) as HTMLTableRowElement;
-
- // regenerate id of table row
- fileElement.removeAttribute("id");
- DomUtil.identify(fileElement);
- }
-
- Array.from(fileElement.getElementsByTagName("TD")).forEach((cell: HTMLTableDataCellElement) => {
- if (cell.classList.contains("columnMark")) {
- cell.querySelectorAll("[data-object-id]").forEach((el: HTMLElement) => DomUtil.hide(el));
- } else if (cell.classList.contains("columnIcon")) {
- cell.querySelectorAll("[data-object-id]").forEach((el: HTMLElement) => DomUtil.hide(el));
-
- cell.querySelector(".mediaEditButton")!.classList.add("jsMediaEditButton");
- (cell.querySelector(".jsDeleteButton") as HTMLElement).dataset.confirmMessageHtml = Language.get(
- "wcf.media.delete.confirmMessage",
- {
- title: file.name,
- },
- );
- } else if (cell.classList.contains("columnFilename")) {
- // replace copied image with spinner
- let image = cell.querySelector("img");
- if (!image) {
- image = cell.querySelector(".icon48");
- }
-
- const spinner = document.createElement("span");
- spinner.className = "icon icon48 fa-spinner mediaThumbnail";
-
- DomUtil.replaceElement(image!, spinner);
-
- // replace title and uploading user
- const ps = cell.querySelectorAll(".box48 > div > p");
- ps[0].textContent = file.name;
-
- let userLink = ps[1].getElementsByTagName("A")[0];
- if (!userLink) {
- userLink = document.createElement("a");
- ps[1].getElementsByTagName("SMALL")[0].appendChild(userLink);
- }
-
- userLink.setAttribute("href", User.getLink());
- userLink.textContent = User.username;
- } else if (cell.classList.contains("columnUploadTime")) {
- cell.innerHTML = "";
- cell.appendChild(DateUtil.getTimeElement(new Date()));
- } else if (cell.classList.contains("columnDigits")) {
- cell.textContent = FileUtil.formatFilesize(file.size);
- } else {
- // empty the other cells
- cell.innerHTML = "";
- }
- });
-
- DomUtil.prepend(fileElement, this._target);
-
- return fileElement;
- } else {
- fileElement = document.createElement("p");
- }
-
- const thumbnail = document.createElement("div");
- thumbnail.className = "mediaThumbnail";
- fileElement.appendChild(thumbnail);
-
- const fileIcon = document.createElement("span");
- fileIcon.className = "icon icon144 fa-spinner";
- thumbnail.appendChild(fileIcon);
-
- const mediaInformation = document.createElement("div");
- mediaInformation.className = "mediaInformation";
- fileElement.appendChild(mediaInformation);
-
- const p = document.createElement("p");
- p.className = "mediaTitle";
- p.textContent = file.name;
- mediaInformation.appendChild(p);
-
- const progress = document.createElement("progress");
- progress.max = 100;
- mediaInformation.appendChild(progress);
-
- DomUtil.prepend(fileElement, this._target);
-
- DomChangeListener.trigger();
-
- return fileElement;
- }
-
- protected _getParameters(): ArbitraryObject {
- const parameters: ArbitraryObject = {
- elementTagSize: this._elementTagSize,
- };
- if (this._mediaManager) {
- parameters.imagesOnly = this._mediaManager.getOption("imagesOnly");
-
- const categoryId = this._mediaManager.getCategoryId();
- if (categoryId) {
- parameters.categoryID = categoryId;
- }
- }
-
- return Core.extend(super._getParameters() as object, parameters as object) as ArbitraryObject;
- }
-
- protected _replaceFileIcon(fileIcon: HTMLElement, media: Media, size: number): void {
- if (media.elementTag) {
- fileIcon.outerHTML = media.elementTag;
- } else if (media.tinyThumbnailType) {
- const img = document.createElement("img");
- img.src = media.tinyThumbnailLink;
- img.alt = "";
- img.style.setProperty("width", `${size}px`);
- img.style.setProperty("height", `${size}px`);
-
- DomUtil.replaceElement(fileIcon, img);
- } else {
- fileIcon.classList.remove("fa-spinner");
-
- let fileIconName = FileUtil.getIconNameByFilename(media.filename);
- if (fileIconName) {
- fileIconName = "-" + fileIconName;
- }
- fileIcon.classList.add(`fa-file${fileIconName}-o`);
- }
- }
-
- protected _success(uploadId: number, data: MediaUploadAjaxResponseData): void {
- const files = this._fileElements[uploadId];
- files.forEach((file) => {
- const internalFileId = file.dataset.internalFileId!;
- const media: Media = data.returnValues.media[internalFileId];
-
- if (file.tagName === "TR") {
- if (media) {
- // update object id
- file.querySelectorAll("[data-object-id]").forEach((el: HTMLElement) => {
- el.dataset.objectId = media.mediaID.toString();
- el.style.removeProperty("display");
- });
-
- file.querySelector(".columnMediaID")!.textContent = media.mediaID.toString();
-
- // update icon
- this._replaceFileIcon(file.querySelector(".fa-spinner") as HTMLSpanElement, media, 48);
- } else {
- let error: MediaUploadError = data.returnValues.errors[internalFileId];
- if (!error) {
- error = {
- errorType: "uploadFailed",
- filename: file.dataset.filename!,
- };
- }
-
- const fileIcon = file.querySelector(".fa-spinner") as HTMLSpanElement;
- fileIcon.classList.remove("fa-spinner");
- fileIcon.classList.add("fa-remove", "pointer", "jsTooltip");
- fileIcon.title = Language.get("wcf.global.button.delete");
- fileIcon.addEventListener("click", (event) => {
- const target = event.currentTarget as HTMLSpanElement;
- target.closest(".mediaFile")!.remove();
-
- EventHandler.fire("com.woltlab.wcf.media.upload", "removedErroneousUploadRow");
- });
-
- file.classList.add("uploadFailed");
-
- const p = file.querySelectorAll(".columnFilename .box48 > div > p")[1] as HTMLElement;
-
- DomUtil.innerError(
- p,
- Language.get(`wcf.media.upload.error.${error.errorType}`, {
- filename: error.filename,
- }),
- );
-
- p.remove();
- }
- } else {
- DomTraverse.childByTag(DomTraverse.childByClass(file, "mediaInformation")!, "PROGRESS")!.remove();
-
- if (media) {
- const fileIcon = DomTraverse.childByTag(DomTraverse.childByClass(file, "mediaThumbnail")!, "SPAN")!;
- this._replaceFileIcon(fileIcon, media, 144);
-
- file.className = "jsClipboardObject mediaFile";
- file.dataset.objectId = media.mediaID.toString();
-
- if (this._mediaManager) {
- this._mediaManager.setupMediaElement(media, file);
- this._mediaManager.addMedia(media, file as HTMLLIElement);
- }
- } else {
- let error: MediaUploadError = data.returnValues.errors[internalFileId];
- if (!error) {
- error = {
- errorType: "uploadFailed",
- filename: file.dataset.filename!,
- };
- }
-
- const fileIcon = DomTraverse.childByTag(DomTraverse.childByClass(file, "mediaThumbnail")!, "SPAN")!;
- fileIcon.classList.remove("fa-spinner");
- fileIcon.classList.add("fa-remove", "pointer");
-
- file.classList.add("uploadFailed", "jsTooltip");
- file.title = Language.get("wcf.global.button.delete");
- file.addEventListener("click", () => file.remove());
-
- const title = DomTraverse.childByClass(
- DomTraverse.childByClass(file, "mediaInformation")!,
- "mediaTitle",
- ) as HTMLElement;
- title.innerText = Language.get(`wcf.media.upload.error.${error.errorType}`, {
- filename: error.filename,
- });
- }
- }
-
- DomChangeListener.trigger();
- });
-
- EventHandler.fire("com.woltlab.wcf.media.upload", "success", {
- files: files,
- isMultiFileUpload: this._multiFileUploadIds.indexOf(uploadId) !== -1,
- media: data.returnValues.media,
- upload: this,
- uploadId: uploadId,
- } as MediaUploadSuccessEventData);
- }
-}
-
-Core.enableLegacyInheritance(MediaUpload);
-
-export = MediaUpload;
+++ /dev/null
-/**
- * Provides desktop notifications via periodic polling with an
- * increasing request delay on inactivity.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Notification/Handler
- */
-
-import * as Ajax from "../Ajax";
-import { AjaxCallbackSetup } from "../Ajax/Data";
-import * as Core from "../Core";
-import * as EventHandler from "../Event/Handler";
-import * as StringUtil from "../StringUtil";
-
-interface NotificationHandlerOptions {
- enableNotifications: boolean;
- icon: string;
-}
-
-interface PollingResult {
- notification: {
- link: string;
- message?: string;
- title: string;
- };
-}
-
-interface AjaxResponse {
- returnValues: {
- keepAliveData: unknown;
- lastRequestTimestamp: number;
- pollData: PollingResult;
- };
-}
-
-class NotificationHandler {
- private allowNotification: boolean;
- private readonly icon: string;
- private inactiveSince = 0;
- private lastRequestTimestamp = window.TIME_NOW;
- private requestTimer?: number = undefined;
-
- /**
- * Initializes the desktop notification system.
- */
- constructor(options: NotificationHandlerOptions) {
- options = Core.extend(
- {
- enableNotifications: false,
- icon: "",
- },
- options,
- ) as NotificationHandlerOptions;
-
- this.icon = options.icon;
-
- this.prepareNextRequest();
-
- document.addEventListener("visibilitychange", (ev) => this.onVisibilityChange(ev));
- window.addEventListener("storage", () => this.onStorage());
-
- this.onVisibilityChange();
-
- if (options.enableNotifications) {
- void this.enableNotifications();
- }
- }
-
- private async enableNotifications(): Promise<void> {
- switch (window.Notification.permission) {
- case "granted":
- this.allowNotification = true;
- break;
-
- case "default": {
- const result = await window.Notification.requestPermission();
- if (result === "granted") {
- this.allowNotification = true;
- }
- break;
- }
- }
- }
-
- /**
- * Detects when this window is hidden or restored.
- */
- private onVisibilityChange(event?: Event) {
- // document was hidden before
- if (event && !document.hidden) {
- const difference = (Date.now() - this.inactiveSince) / 60_000;
- if (difference > 4) {
- this.resetTimer();
- this.dispatchRequest();
- }
- }
-
- this.inactiveSince = document.hidden ? Date.now() : 0;
- }
-
- /**
- * Returns the delay in minutes before the next request should be dispatched.
- */
- private getNextDelay(): number {
- if (this.inactiveSince === 0) {
- return 5;
- }
-
- // milliseconds -> minutes
- const inactiveMinutes = ~~((Date.now() - this.inactiveSince) / 60_000);
- if (inactiveMinutes < 15) {
- return 5;
- } else if (inactiveMinutes < 30) {
- return 10;
- }
-
- return 15;
- }
-
- /**
- * Resets the request delay timer.
- */
- private resetTimer(): void {
- if (this.requestTimer) {
- window.clearTimeout(this.requestTimer);
- this.requestTimer = undefined;
- }
- }
-
- /**
- * Schedules the next request using a calculated delay.
- */
- private prepareNextRequest(): void {
- this.resetTimer();
-
- this.requestTimer = window.setTimeout(this.dispatchRequest.bind(this), this.getNextDelay() * 60_000);
- }
-
- /**
- * Requests new data from the server.
- */
- private dispatchRequest(): void {
- const parameters: ArbitraryObject = {};
-
- EventHandler.fire("com.woltlab.wcf.notification", "beforePoll", parameters);
-
- // this timestamp is used to determine new notifications and to avoid
- // notifications being displayed multiple times due to different origins
- // (=subdomains) used, because we cannot synchronize them in the client
- parameters.lastRequestTimestamp = this.lastRequestTimestamp;
-
- Ajax.api(this, {
- parameters: parameters,
- });
- }
-
- /**
- * Notifies subscribers for updated data received by another tab.
- */
- private onStorage(): void {
- // abort and re-schedule periodic request
- this.prepareNextRequest();
-
- let pollData;
- let keepAliveData;
- let abort = false;
- try {
- pollData = window.localStorage.getItem(Core.getStoragePrefix() + "notification");
- keepAliveData = window.localStorage.getItem(Core.getStoragePrefix() + "keepAliveData");
-
- pollData = JSON.parse(pollData);
- keepAliveData = JSON.parse(keepAliveData);
- } catch (e) {
- abort = true;
- }
-
- if (!abort) {
- EventHandler.fire("com.woltlab.wcf.notification", "onStorage", {
- pollData: pollData,
- keepAliveData: keepAliveData,
- });
- }
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- const keepAliveData = data.returnValues.keepAliveData;
- const pollData = data.returnValues.pollData;
-
- // forward keep alive data
- window.WCF.System.PushNotification.executeCallbacks({ returnValues: keepAliveData });
-
- // store response data in local storage
- let abort = false;
- try {
- window.localStorage.setItem(Core.getStoragePrefix() + "notification", JSON.stringify(pollData));
- window.localStorage.setItem(Core.getStoragePrefix() + "keepAliveData", JSON.stringify(keepAliveData));
- } catch (e) {
- // storage is unavailable, e.g. in private mode, log error and disable polling
- abort = true;
-
- window.console.log(e);
- }
-
- if (!abort) {
- this.prepareNextRequest();
- }
-
- this.lastRequestTimestamp = data.returnValues.lastRequestTimestamp;
-
- EventHandler.fire("com.woltlab.wcf.notification", "afterPoll", pollData);
-
- this.showNotification(pollData);
- }
-
- /**
- * Displays a desktop notification.
- */
- private showNotification(pollData: PollingResult): void {
- if (!this.allowNotification) {
- return;
- }
-
- if (typeof pollData.notification === "object" && typeof pollData.notification.message === "string") {
- const notification = new window.Notification(pollData.notification.title, {
- body: StringUtil.unescapeHTML(pollData.notification.message),
- icon: this.icon,
- });
- notification.onclick = () => {
- window.focus();
- notification.close();
-
- window.location.href = pollData.notification.link;
- };
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "poll",
- className: "wcf\\data\\session\\SessionAction",
- },
- ignoreError: !window.ENABLE_DEBUG_MODE,
- silent: !window.ENABLE_DEBUG_MODE,
- };
- }
-}
-
-let notificationHandler: NotificationHandler;
-
-/**
- * Initializes the desktop notification system.
- */
-export function setup(options: NotificationHandlerOptions): void {
- if (!notificationHandler) {
- notificationHandler = new NotificationHandler(options);
- }
-}
+++ /dev/null
-/**
- * Provides helper functions for Number handling.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/NumberUtil
- */
-
-/**
- * Decimal adjustment of a number.
- *
- * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round
- */
-export function round(value: number, exp: number): number {
- // If the exp is undefined or zero...
- if (typeof exp === "undefined" || +exp === 0) {
- return Math.round(value);
- }
- value = +value;
- exp = +exp;
-
- // If the value is not a number or the exp is not an integer...
- if (isNaN(value) || !(typeof (exp as any) === "number" && exp % 1 === 0)) {
- return NaN;
- }
-
- // Shift
- let tmp = value.toString().split("e");
- let exponent = tmp[1] ? +tmp[1] - exp : -exp;
- value = Math.round(+`${tmp[0]}e${exponent}`);
-
- // Shift back
- tmp = value.toString().split("e");
- exponent = tmp[1] ? +tmp[1] + exp : exp;
- return +`${tmp[0]}e${exponent}`;
-}
+++ /dev/null
-/**
- * Simple `object` to `object` map using a WeakMap.
- *
- * If you're looking for a dictionary with string keys, please see `WoltLabSuite/Core/Dictionary`.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module ObjectMap (alias)
- * @module WoltLabSuite/Core/ObjectMap
- */
-
-import * as Core from "./Core";
-
-/** @deprecated 5.4 Use a `WeakMap` instead. */
-class ObjectMap {
- private _map = new WeakMap<object, object>();
-
- /**
- * Sets a new key with given value, will overwrite an existing key.
- */
- set(key: object, value: object): void {
- if (typeof key !== "object" || key === null) {
- throw new TypeError("Only objects can be used as key");
- }
-
- if (typeof value !== "object" || value === null) {
- throw new TypeError("Only objects can be used as value");
- }
-
- this._map.set(key, value);
- }
-
- /**
- * Removes a key from the map.
- */
- delete(key: object): void {
- this._map.delete(key);
- }
-
- /**
- * Returns true if dictionary contains a value for given key.
- */
- has(key: object): boolean {
- return this._map.has(key);
- }
-
- /**
- * Retrieves a value by key, returns undefined if there is no match.
- */
- get(key: object): object | undefined {
- return this._map.get(key);
- }
-}
-
-Core.enableLegacyInheritance(ObjectMap);
-
-export = ObjectMap;
+++ /dev/null
-/**
- * Manages user permissions.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Permission (alias)
- * @module WoltLabSuite/Core/Permission
- */
-
-const _permissions = new Map<string, boolean>();
-
-/**
- * Adds a single permission to the store.
- */
-export function add(permission: string, value: boolean): void {
- if (typeof (value as any) !== "boolean") {
- throw new TypeError("The permission value has to be boolean.");
- }
-
- _permissions.set(permission, value);
-}
-
-/**
- * Adds all the permissions in the given object to the store.
- */
-export function addObject(object: PermissionObject): void {
- Object.keys(object).forEach((key) => add(key, object[key]));
-}
-
-/**
- * Returns the value of a permission.
- *
- * If the permission is unknown, false is returned.
- */
-export function get(permission: string): boolean {
- if (_permissions.has(permission)) {
- return _permissions.get(permission)!;
- }
-
- return false;
-}
-
-interface PermissionObject {
- [key: string]: boolean;
-}
+++ /dev/null
-import Prism from "prismjs";
-
-export default Prism;
+++ /dev/null
-/**
- * Loads Prism while disabling automated highlighting.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Prism
- */
-window.Prism = window.Prism || {};
-window.Prism.manual = true;
-define(['prism/prism'], function () {
- /**
- * @deprecated 5.4 - Use WoltLabSuite/Core/Prism/Helper#splitIntoLines.
- */
- Prism.wscSplitIntoLines = function (container) {
- var frag = document.createDocumentFragment();
- var lineNo = 1;
- var it, node, line;
- function newLine() {
- var line = elCreate('span');
- elData(line, 'number', lineNo++);
- frag.appendChild(line);
- return line;
- }
- // IE11 expects a fourth, non-standard, parameter (entityReferenceExpansion) and a valid function as third
- it = document.createNodeIterator(container, NodeFilter.SHOW_TEXT, function () {
- return NodeFilter.FILTER_ACCEPT;
- }, false);
- line = newLine(lineNo);
- while (node = it.nextNode()) {
- node.data.split(/\r?\n/).forEach(function (codeLine, index) {
- var current, parent;
- // We are behind a newline, insert \n and create new container.
- if (index >= 1) {
- line.appendChild(document.createTextNode("\n"));
- line = newLine(lineNo);
- }
- current = document.createTextNode(codeLine);
- // Copy hierarchy (to preserve CSS classes).
- parent = node.parentNode;
- while (parent !== container) {
- var clone = parent.cloneNode(false);
- clone.appendChild(current);
- current = clone;
- parent = parent.parentNode;
- }
- line.appendChild(current);
- });
- }
- return frag;
- };
- return Prism;
-});
+++ /dev/null
-/**
- * Provide helper functions for prism processing.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2021 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Prism/Helper
- */
-
-export function* splitIntoLines(container: Node): Generator<Element, void> {
- const it = document.createNodeIterator(container, NodeFilter.SHOW_TEXT, {
- acceptNode() {
- return NodeFilter.FILTER_ACCEPT;
- },
- });
-
- let line = document.createElement("span");
- let node;
- while ((node = it.nextNode())) {
- const text = node as Text;
- const lines = text.data.split(/\r?\n/);
-
- for (let i = 0, max = lines.length; i < max; i++) {
- const codeLine = lines[i];
- // We are behind a newline, insert \n and create new container.
- if (i >= 1) {
- line.appendChild(document.createTextNode("\n"));
- yield line;
- line = document.createElement("span");
- }
-
- let current: Node = document.createTextNode(codeLine);
- // Copy hierarchy (to preserve CSS classes).
- let parent = text.parentNode;
- while (parent && parent !== container) {
- const clone = parent.cloneNode(false);
- clone.appendChild(current);
- current = clone;
- parent = parent.parentNode;
- }
- line.appendChild(current);
- }
- }
- yield line;
-}
+++ /dev/null
-/**
- * Provides helper functions for String handling.
- *
- * @author Tim Duesterhus, Joshua Ruesweg
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module StringUtil (alias)
- * @module WoltLabSuite/Core/StringUtil
- */
-
-import * as NumberUtil from "./NumberUtil";
-
-let _decimalPoint = ".";
-let _thousandsSeparator = ",";
-
-/**
- * Adds thousands separators to a given number.
- *
- * @see http://stackoverflow.com/a/6502556/782822
- */
-export function addThousandsSeparator(number: number): string {
- return String(number).replace(/(^-?\d{1,3}|\d{3})(?=(?:\d{3})+(?:$|\.))/g, "$1" + _thousandsSeparator);
-}
-
-/**
- * Escapes special HTML-characters within a string
- */
-export function escapeHTML(string: string): string {
- return String(string).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
-}
-
-/**
- * Escapes a String to work with RegExp.
- *
- * @see https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/regexp.js#L25
- */
-export function escapeRegExp(string: string): string {
- return String(string).replace(/([.*+?^=!:${}()|[\]/\\])/g, "\\$1");
-}
-
-/**
- * Rounds number to given count of floating point digits, localizes decimal-point and inserts thousands separators.
- */
-export function formatNumeric(number: number, decimalPlaces?: number): string {
- let tmp = NumberUtil.round(number, decimalPlaces || -2).toString();
- const numberParts = tmp.split(".");
-
- tmp = addThousandsSeparator(+numberParts[0]);
- if (numberParts.length > 1) {
- tmp += _decimalPoint + numberParts[1];
- }
-
- tmp = tmp.replace("-", "\u2212");
-
- return tmp;
-}
-
-/**
- * Makes a string's first character lowercase.
- */
-export function lcfirst(string: string): string {
- return String(string).substring(0, 1).toLowerCase() + string.substring(1);
-}
-
-/**
- * Makes a string's first character uppercase.
- */
-export function ucfirst(string: string): string {
- return String(string).substring(0, 1).toUpperCase() + string.substring(1);
-}
-
-/**
- * Unescapes special HTML-characters within a string.
- */
-export function unescapeHTML(string: string): string {
- return String(string)
- .replace(/&/g, "&")
- .replace(/"/g, '"')
- .replace(/</g, "<")
- .replace(/>/g, ">");
-}
-
-/**
- * Shortens numbers larger than 1000 by using unit suffixes.
- */
-export function shortUnit(number: number): string {
- let unitSuffix = "";
-
- if (number >= 1000000) {
- number /= 1000000;
-
- if (number > 10) {
- number = Math.floor(number);
- } else {
- number = NumberUtil.round(number, -1);
- }
-
- unitSuffix = "M";
- } else if (number >= 1000) {
- number /= 1000;
-
- if (number > 10) {
- number = Math.floor(number);
- } else {
- number = NumberUtil.round(number, -1);
- }
-
- unitSuffix = "k";
- }
-
- return formatNumeric(number) + unitSuffix;
-}
-
-/**
- * Converts a lower-case string containing dashed to camelCase for use
- * with the `dataset` property.
- */
-export function toCamelCase(value: string): string {
- if (!value.includes("-")) {
- return value;
- }
-
- return value
- .split("-")
- .map((part, index) => {
- if (index > 0) {
- part = ucfirst(part);
- }
-
- return part;
- })
- .join("");
-}
-
-interface I18nValues {
- decimalPoint: string;
- thousandsSeparator: string;
-}
-
-export function setupI18n(values: I18nValues): void {
- _decimalPoint = values.decimalPoint;
- _thousandsSeparator = values.thousandsSeparator;
-}
+++ /dev/null
-export function parse(input: string): unknown;
+++ /dev/null
-/**
- * Grammar for WoltLabSuite/Core/Template.
- *
- * Recompile using:
- * jison -m amd -o Template.grammar.js Template.grammar.jison
- * after making changes to the grammar.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Template.grammar
- */
-
-%lex
-%s command
-%%
-
-\{\*[\s\S]*?\*\} /* comment */
-\{literal\}[\s\S]*?\{\/literal\} { yytext = yytext.substring(9, yytext.length - 10); return 'T_LITERAL'; }
-<command>\"([^"]|\\\.)*\" return 'T_QUOTED_STRING';
-<command>\'([^']|\\\.)*\' return 'T_QUOTED_STRING';
-<command>\$ return 'T_VARIABLE';
-<command>[0-9]+ { return 'T_DIGITS'; }
-<command>[_a-zA-Z][_a-zA-Z0-9]* { return 'T_VARIABLE_NAME'; }
-<command>"." return '.';
-<command>"[" return '[';
-<command>"]" return ']';
-<command>"(" return '(';
-<command>")" return ')';
-<command>"=" return '=';
-"{ldelim}" return '{ldelim}';
-"{rdelim}" return '{rdelim}';
-"{#" { this.begin('command'); return '{#'; }
-"{@" { this.begin('command'); return '{@'; }
-"{if " { this.begin('command'); return '{if'; }
-"{else if " { this.begin('command'); return '{elseif'; }
-"{elseif " { this.begin('command'); return '{elseif'; }
-"{else}" return '{else}';
-"{/if}" return '{/if}';
-"{lang}" return '{lang}';
-"{/lang}" return '{/lang}';
-"{include " { this.begin('command'); return '{include'; }
-"{implode " { this.begin('command'); return '{implode'; }
-"{plural " { this.begin('command'); return '{plural'; }
-"{/implode}" return '{/implode}';
-"{foreach " { this.begin('command'); return '{foreach'; }
-"{foreachelse}" return '{foreachelse}';
-"{/foreach}" return '{/foreach}';
-\{(?!\s) { this.begin('command'); return '{'; }
-<command>"}" { this.popState(); return '}';}
-\s+ return 'T_WS';
-<<EOF>> return 'EOF';
-[^{] return 'T_ANY';
-
-/lex
-
-%start TEMPLATE
-%ebnf
-
-%%
-
-// A valid template is any number of CHUNKs.
-TEMPLATE: CHUNK_STAR EOF { return $1 + ";"; };
-
-CHUNK_STAR: CHUNK* {
- var result = $1.reduce(function (carry, item) {
- if (item.encode && !carry[1]) carry[0] += " + '" + item.value;
- else if (item.encode && carry[1]) carry[0] += item.value;
- else if (!item.encode && carry[1]) carry[0] += "' + " + item.value;
- else if (!item.encode && !carry[1]) carry[0] += " + " + item.value;
-
- carry[1] = item.encode;
- return carry;
- }, [ "''", false ]);
- if (result[1]) result[0] += "'";
-
- $$ = result[0];
-};
-
-CHUNK:
- PLAIN_ANY -> { encode: true, value: $1.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/(\r\n|\n|\r)/g, '\\n') }
-| T_LITERAL -> { encode: true, value: $1.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/(\r\n|\n|\r)/g, '\\n') }
-| COMMAND -> { encode: false, value: $1 }
-;
-
-PLAIN_ANY: T_ANY | T_WS;
-
-COMMAND:
- '{if' COMMAND_PARAMETERS '}' CHUNK_STAR (ELSE_IF)* ELSE? '{/if}' {
- $$ = "(function() { if (" + $2 + ") { return " + $4 + "; } " + $5.join(' ') + " " + ($6 || '') + " return ''; })()";
- }
-| '{include' COMMAND_PARAMETER_LIST '}' {
- if (!$2['file']) throw new Error('Missing parameter file');
-
- $$ = $2['file'] + ".fetch(v)";
- }
-| '{implode' COMMAND_PARAMETER_LIST '}' CHUNK_STAR '{/implode}' {
- if (!$2['from']) throw new Error('Missing parameter from');
- if (!$2['item']) throw new Error('Missing parameter item');
- if (!$2['glue']) $2['glue'] = "', '";
-
- $$ = "(function() { return " + $2['from'] + ".map(function(item) { v[" + $2['item'] + "] = item; return " + $4 + "; }).join(" + $2['glue'] + "); })()";
- }
-| '{foreach' COMMAND_PARAMETER_LIST '}' CHUNK_STAR FOREACH_ELSE? '{/foreach}' {
- if (!$2['from']) throw new Error('Missing parameter from');
- if (!$2['item']) throw new Error('Missing parameter item');
-
- $$ = "(function() {"
- + "var looped = false, result = '';"
- + "if (" + $2['from'] + " instanceof Array) {"
- + "for (var i = 0; i < " + $2['from'] + ".length; i++) { looped = true;"
- + "v[" + $2['key'] + "] = i;"
- + "v[" + $2['item'] + "] = " + $2['from'] + "[i];"
- + "result += " + $4 + ";"
- + "}"
- + "} else {"
- + "for (var key in " + $2['from'] + ") {"
- + "if (!" + $2['from'] + ".hasOwnProperty(key)) continue;"
- + "looped = true;"
- + "v[" + $2['key'] + "] = key;"
- + "v[" + $2['item'] + "] = " + $2['from'] + "[key];"
- + "result += " + $4 + ";"
- + "}"
- + "}"
- + "return (looped ? result : " + ($5 || "''") + "); })()"
- }
-| '{plural' PLURAL_PARAMETER_LIST '}' {
- $$ = "I18nPlural.getCategoryFromTemplateParameters({"
- var needsComma = false;
- for (var key in $2) {
- if (objOwns($2, key)) {
- $$ += (needsComma ? ',' : '') + key + ': ' + $2[key];
- needsComma = true;
- }
- }
- $$ += "})";
- }
-| '{lang}' CHUNK_STAR '{/lang}' -> "Language.get(" + $2 + ", v)"
-| '{' VARIABLE '}' -> "StringUtil.escapeHTML(" + $2 + ")"
-| '{#' VARIABLE '}' -> "StringUtil.formatNumeric(" + $2 + ")"
-| '{@' VARIABLE '}' -> $2
-| '{ldelim}' -> "'{'"
-| '{rdelim}' -> "'}'"
-;
-
-ELSE: '{else}' CHUNK_STAR -> "else { return " + $2 + "; }"
-;
-
-ELSE_IF: '{elseif' COMMAND_PARAMETERS '}' CHUNK_STAR -> "else if (" + $2 + ") { return " + $4 + "; }"
-;
-
-FOREACH_ELSE: '{foreachelse}' CHUNK_STAR -> $2
-;
-
-// VARIABLE parses a valid variable access (with optional property access)
-VARIABLE: T_VARIABLE T_VARIABLE_NAME VARIABLE_SUFFIX* -> "v['" + $2 + "']" + $3.join('');
-;
-
-VARIABLE_SUFFIX:
- '[' COMMAND_PARAMETERS ']' -> $1 + $2 + $3
-| '.' T_VARIABLE_NAME -> "['" + $2 + "']"
-| '(' COMMAND_PARAMETERS? ')' -> $1 + ($2 || '') + $3
-;
-
-COMMAND_PARAMETER_LIST:
- T_VARIABLE_NAME '=' COMMAND_PARAMETER_VALUE T_WS COMMAND_PARAMETER_LIST { $$ = $5; $$[$1] = $3; }
-| T_VARIABLE_NAME '=' COMMAND_PARAMETER_VALUE { $$ = {}; $$[$1] = $3; }
-;
-
-COMMAND_PARAMETER_VALUE: T_QUOTED_STRING | T_DIGITS | VARIABLE;
-
-// COMMAND_PARAMETERS parses anything that is valid between a command name and the closing brace
-COMMAND_PARAMETERS: COMMAND_PARAMETER+ -> $1.join('')
-;
-COMMAND_PARAMETER: T_ANY | T_DIGITS | T_WS | '=' | T_QUOTED_STRING | VARIABLE | T_VARIABLE_NAME
-| '(' COMMAND_PARAMETERS ')' -> $1 + ($2 || '') + $3
-;
-
-PLURAL_PARAMETER_LIST:
- T_PLURAL_PARAMETER_NAME '=' COMMAND_PARAMETER_VALUE T_WS PLURAL_PARAMETER_LIST { $$ = $5; $$[$1] = $3; }
-| T_PLURAL_PARAMETER_NAME '=' COMMAND_PARAMETER_VALUE { $$ = {}; $$[$1] = $3; }
-;
-
-T_PLURAL_PARAMETER_NAME: T_DIGITS | T_VARIABLE_NAME;
+++ /dev/null
-define(function (require) {
- var o = function (k, v, o, l) { for (o = o || {}, l = k.length; l--; o[k[l]] = v)
- ; return o; }, $V0 = [2, 44], $V1 = [5, 9, 11, 12, 13, 18, 19, 21, 22, 23, 25, 26, 28, 29, 30, 32, 33, 34, 35, 37, 39, 41], $V2 = [1, 25], $V3 = [1, 27], $V4 = [1, 33], $V5 = [1, 31], $V6 = [1, 32], $V7 = [1, 28], $V8 = [1, 29], $V9 = [1, 26], $Va = [1, 35], $Vb = [1, 41], $Vc = [1, 40], $Vd = [11, 12, 15, 42, 43, 47, 49, 51, 52, 54, 55], $Ve = [9, 11, 12, 13, 18, 19, 21, 23, 26, 28, 30, 32, 33, 34, 35, 37, 39], $Vf = [11, 12, 15, 42, 43, 46, 47, 48, 49, 51, 52, 54, 55], $Vg = [1, 64], $Vh = [1, 65], $Vi = [18, 37, 39], $Vj = [12, 15];
- var parser = { trace: function trace() { },
- yy: {},
- symbols_: { "error": 2, "TEMPLATE": 3, "CHUNK_STAR": 4, "EOF": 5, "CHUNK_STAR_repetition0": 6, "CHUNK": 7, "PLAIN_ANY": 8, "T_LITERAL": 9, "COMMAND": 10, "T_ANY": 11, "T_WS": 12, "{if": 13, "COMMAND_PARAMETERS": 14, "}": 15, "COMMAND_repetition0": 16, "COMMAND_option0": 17, "{/if}": 18, "{include": 19, "COMMAND_PARAMETER_LIST": 20, "{implode": 21, "{/implode}": 22, "{foreach": 23, "COMMAND_option1": 24, "{/foreach}": 25, "{plural": 26, "PLURAL_PARAMETER_LIST": 27, "{lang}": 28, "{/lang}": 29, "{": 30, "VARIABLE": 31, "{#": 32, "{@": 33, "{ldelim}": 34, "{rdelim}": 35, "ELSE": 36, "{else}": 37, "ELSE_IF": 38, "{elseif": 39, "FOREACH_ELSE": 40, "{foreachelse}": 41, "T_VARIABLE": 42, "T_VARIABLE_NAME": 43, "VARIABLE_repetition0": 44, "VARIABLE_SUFFIX": 45, "[": 46, "]": 47, ".": 48, "(": 49, "VARIABLE_SUFFIX_option0": 50, ")": 51, "=": 52, "COMMAND_PARAMETER_VALUE": 53, "T_QUOTED_STRING": 54, "T_DIGITS": 55, "COMMAND_PARAMETERS_repetition_plus0": 56, "COMMAND_PARAMETER": 57, "T_PLURAL_PARAMETER_NAME": 58, "$accept": 0, "$end": 1 },
- terminals_: { 2: "error", 5: "EOF", 9: "T_LITERAL", 11: "T_ANY", 12: "T_WS", 13: "{if", 15: "}", 18: "{/if}", 19: "{include", 21: "{implode", 22: "{/implode}", 23: "{foreach", 25: "{/foreach}", 26: "{plural", 28: "{lang}", 29: "{/lang}", 30: "{", 32: "{#", 33: "{@", 34: "{ldelim}", 35: "{rdelim}", 37: "{else}", 39: "{elseif", 41: "{foreachelse}", 42: "T_VARIABLE", 43: "T_VARIABLE_NAME", 46: "[", 47: "]", 48: ".", 49: "(", 51: ")", 52: "=", 54: "T_QUOTED_STRING", 55: "T_DIGITS" },
- productions_: [0, [3, 2], [4, 1], [7, 1], [7, 1], [7, 1], [8, 1], [8, 1], [10, 7], [10, 3], [10, 5], [10, 6], [10, 3], [10, 3], [10, 3], [10, 3], [10, 3], [10, 1], [10, 1], [36, 2], [38, 4], [40, 2], [31, 3], [45, 3], [45, 2], [45, 3], [20, 5], [20, 3], [53, 1], [53, 1], [53, 1], [14, 1], [57, 1], [57, 1], [57, 1], [57, 1], [57, 1], [57, 1], [57, 1], [57, 3], [27, 5], [27, 3], [58, 1], [58, 1], [6, 0], [6, 2], [16, 0], [16, 2], [17, 0], [17, 1], [24, 0], [24, 1], [44, 0], [44, 2], [50, 0], [50, 1], [56, 1], [56, 2]],
- performAction: function anonymous(yytext, yyleng, yylineno, yy, yystate /* action[1] */, $$ /* vstack */, _$ /* lstack */) {
- /* this == yyval */
- var $0 = $$.length - 1;
- switch (yystate) {
- case 1:
- return $$[$0 - 1] + ";";
- break;
- case 2:
- var result = $$[$0].reduce(function (carry, item) {
- if (item.encode && !carry[1])
- carry[0] += " + '" + item.value;
- else if (item.encode && carry[1])
- carry[0] += item.value;
- else if (!item.encode && carry[1])
- carry[0] += "' + " + item.value;
- else if (!item.encode && !carry[1])
- carry[0] += " + " + item.value;
- carry[1] = item.encode;
- return carry;
- }, ["''", false]);
- if (result[1])
- result[0] += "'";
- this.$ = result[0];
- break;
- case 3:
- case 4:
- this.$ = { encode: true, value: $$[$0].replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/(\r\n|\n|\r)/g, '\\n') };
- break;
- case 5:
- this.$ = { encode: false, value: $$[$0] };
- break;
- case 8:
- this.$ = "(function() { if (" + $$[$0 - 5] + ") { return " + $$[$0 - 3] + "; } " + $$[$0 - 2].join(' ') + " " + ($$[$0 - 1] || '') + " return ''; })()";
- break;
- case 9:
- if (!$$[$0 - 1]['file'])
- throw new Error('Missing parameter file');
- this.$ = $$[$0 - 1]['file'] + ".fetch(v)";
- break;
- case 10:
- if (!$$[$0 - 3]['from'])
- throw new Error('Missing parameter from');
- if (!$$[$0 - 3]['item'])
- throw new Error('Missing parameter item');
- if (!$$[$0 - 3]['glue'])
- $$[$0 - 3]['glue'] = "', '";
- this.$ = "(function() { return " + $$[$0 - 3]['from'] + ".map(function(item) { v[" + $$[$0 - 3]['item'] + "] = item; return " + $$[$0 - 1] + "; }).join(" + $$[$0 - 3]['glue'] + "); })()";
- break;
- case 11:
- if (!$$[$0 - 4]['from'])
- throw new Error('Missing parameter from');
- if (!$$[$0 - 4]['item'])
- throw new Error('Missing parameter item');
- this.$ = "(function() {"
- + "var looped = false, result = '';"
- + "if (" + $$[$0 - 4]['from'] + " instanceof Array) {"
- + "for (var i = 0; i < " + $$[$0 - 4]['from'] + ".length; i++) { looped = true;"
- + "v[" + $$[$0 - 4]['key'] + "] = i;"
- + "v[" + $$[$0 - 4]['item'] + "] = " + $$[$0 - 4]['from'] + "[i];"
- + "result += " + $$[$0 - 2] + ";"
- + "}"
- + "} else {"
- + "for (var key in " + $$[$0 - 4]['from'] + ") {"
- + "if (!" + $$[$0 - 4]['from'] + ".hasOwnProperty(key)) continue;"
- + "looped = true;"
- + "v[" + $$[$0 - 4]['key'] + "] = key;"
- + "v[" + $$[$0 - 4]['item'] + "] = " + $$[$0 - 4]['from'] + "[key];"
- + "result += " + $$[$0 - 2] + ";"
- + "}"
- + "}"
- + "return (looped ? result : " + ($$[$0 - 1] || "''") + "); })()";
- break;
- case 12:
- this.$ = "I18nPlural.getCategoryFromTemplateParameters({";
- var needsComma = false;
- for (var key in $$[$0 - 1]) {
- if (objOwns($$[$0 - 1], key)) {
- this.$ += (needsComma ? ',' : '') + key + ': ' + $$[$0 - 1][key];
- needsComma = true;
- }
- }
- this.$ += "})";
- break;
- case 13:
- this.$ = "Language.get(" + $$[$0 - 1] + ", v)";
- break;
- case 14:
- this.$ = "StringUtil.escapeHTML(" + $$[$0 - 1] + ")";
- break;
- case 15:
- this.$ = "StringUtil.formatNumeric(" + $$[$0 - 1] + ")";
- break;
- case 16:
- this.$ = $$[$0 - 1];
- break;
- case 17:
- this.$ = "'{'";
- break;
- case 18:
- this.$ = "'}'";
- break;
- case 19:
- this.$ = "else { return " + $$[$0] + "; }";
- break;
- case 20:
- this.$ = "else if (" + $$[$0 - 2] + ") { return " + $$[$0] + "; }";
- break;
- case 21:
- this.$ = $$[$0];
- break;
- case 22:
- this.$ = "v['" + $$[$0 - 1] + "']" + $$[$0].join('');
- ;
- break;
- case 23:
- this.$ = $$[$0 - 2] + $$[$0 - 1] + $$[$0];
- break;
- case 24:
- this.$ = "['" + $$[$0] + "']";
- break;
- case 25:
- case 39:
- this.$ = $$[$0 - 2] + ($$[$0 - 1] || '') + $$[$0];
- break;
- case 26:
- case 40:
- this.$ = $$[$0];
- this.$[$$[$0 - 4]] = $$[$0 - 2];
- break;
- case 27:
- case 41:
- this.$ = {};
- this.$[$$[$0 - 2]] = $$[$0];
- break;
- case 31:
- this.$ = $$[$0].join('');
- break;
- case 44:
- case 46:
- case 52:
- this.$ = [];
- break;
- case 45:
- case 47:
- case 53:
- case 57:
- $$[$0 - 1].push($$[$0]);
- break;
- case 56:
- this.$ = [$$[$0]];
- break;
- }
- },
- table: [o([5, 9, 11, 12, 13, 19, 21, 23, 26, 28, 30, 32, 33, 34, 35], $V0, { 3: 1, 4: 2, 6: 3 }), { 1: [3] }, { 5: [1, 4] }, o([5, 18, 22, 25, 29, 37, 39, 41], [2, 2], { 7: 5, 8: 6, 10: 8, 9: [1, 7], 11: [1, 9], 12: [1, 10], 13: [1, 11], 19: [1, 12], 21: [1, 13], 23: [1, 14], 26: [1, 15], 28: [1, 16], 30: [1, 17], 32: [1, 18], 33: [1, 19], 34: [1, 20], 35: [1, 21] }), { 1: [2, 1] }, o($V1, [2, 45]), o($V1, [2, 3]), o($V1, [2, 4]), o($V1, [2, 5]), o($V1, [2, 6]), o($V1, [2, 7]), { 11: $V2, 12: $V3, 14: 22, 31: 30, 42: $V4, 43: $V5, 49: $V6, 52: $V7, 54: $V8, 55: $V9, 56: 23, 57: 24 }, { 20: 34, 43: $Va }, { 20: 36, 43: $Va }, { 20: 37, 43: $Va }, { 27: 38, 43: $Vb, 55: $Vc, 58: 39 }, o([9, 11, 12, 13, 19, 21, 23, 26, 28, 29, 30, 32, 33, 34, 35], $V0, { 6: 3, 4: 42 }), { 31: 43, 42: $V4 }, { 31: 44, 42: $V4 }, { 31: 45, 42: $V4 }, o($V1, [2, 17]), o($V1, [2, 18]), { 15: [1, 46] }, o([15, 47, 51], [2, 31], { 31: 30, 57: 47, 11: $V2, 12: $V3, 42: $V4, 43: $V5, 49: $V6, 52: $V7, 54: $V8, 55: $V9 }), o($Vd, [2, 56]), o($Vd, [2, 32]), o($Vd, [2, 33]), o($Vd, [2, 34]), o($Vd, [2, 35]), o($Vd, [2, 36]), o($Vd, [2, 37]), o($Vd, [2, 38]), { 11: $V2, 12: $V3, 14: 48, 31: 30, 42: $V4, 43: $V5, 49: $V6, 52: $V7, 54: $V8, 55: $V9, 56: 23, 57: 24 }, { 43: [1, 49] }, { 15: [1, 50] }, { 52: [1, 51] }, { 15: [1, 52] }, { 15: [1, 53] }, { 15: [1, 54] }, { 52: [1, 55] }, { 52: [2, 42] }, { 52: [2, 43] }, { 29: [1, 56] }, { 15: [1, 57] }, { 15: [1, 58] }, { 15: [1, 59] }, o($Ve, $V0, { 6: 3, 4: 60 }), o($Vd, [2, 57]), { 51: [1, 61] }, o($Vf, [2, 52], { 44: 62 }), o($V1, [2, 9]), { 31: 66, 42: $V4, 53: 63, 54: $Vg, 55: $Vh }, o([9, 11, 12, 13, 19, 21, 22, 23, 26, 28, 30, 32, 33, 34, 35], $V0, { 6: 3, 4: 67 }), o([9, 11, 12, 13, 19, 21, 23, 25, 26, 28, 30, 32, 33, 34, 35, 41], $V0, { 6: 3, 4: 68 }), o($V1, [2, 12]), { 31: 66, 42: $V4, 53: 69, 54: $Vg, 55: $Vh }, o($V1, [2, 13]), o($V1, [2, 14]), o($V1, [2, 15]), o($V1, [2, 16]), o($Vi, [2, 46], { 16: 70 }), o($Vd, [2, 39]), o([11, 12, 15, 42, 43, 47, 51, 52, 54, 55], [2, 22], { 45: 71, 46: [1, 72], 48: [1, 73], 49: [1, 74] }), { 12: [1, 75], 15: [2, 27] }, o($Vj, [2, 28]), o($Vj, [2, 29]), o($Vj, [2, 30]), { 22: [1, 76] }, { 24: 77, 25: [2, 50], 40: 78, 41: [1, 79] }, { 12: [1, 80], 15: [2, 41] }, { 17: 81, 18: [2, 48], 36: 83, 37: [1, 85], 38: 82, 39: [1, 84] }, o($Vf, [2, 53]), { 11: $V2, 12: $V3, 14: 86, 31: 30, 42: $V4, 43: $V5, 49: $V6, 52: $V7, 54: $V8, 55: $V9, 56: 23, 57: 24 }, { 43: [1, 87] }, { 11: $V2, 12: $V3, 14: 89, 31: 30, 42: $V4, 43: $V5, 49: $V6, 50: 88, 51: [2, 54], 52: $V7, 54: $V8, 55: $V9, 56: 23, 57: 24 }, { 20: 90, 43: $Va }, o($V1, [2, 10]), { 25: [1, 91] }, { 25: [2, 51] }, o([9, 11, 12, 13, 19, 21, 23, 25, 26, 28, 30, 32, 33, 34, 35], $V0, { 6: 3, 4: 92 }), { 27: 93, 43: $Vb, 55: $Vc, 58: 39 }, { 18: [1, 94] }, o($Vi, [2, 47]), { 18: [2, 49] }, { 11: $V2, 12: $V3, 14: 95, 31: 30, 42: $V4, 43: $V5, 49: $V6, 52: $V7, 54: $V8, 55: $V9, 56: 23, 57: 24 }, o([9, 11, 12, 13, 18, 19, 21, 23, 26, 28, 30, 32, 33, 34, 35], $V0, { 6: 3, 4: 96 }), { 47: [1, 97] }, o($Vf, [2, 24]), { 51: [1, 98] }, { 51: [2, 55] }, { 15: [2, 26] }, o($V1, [2, 11]), { 25: [2, 21] }, { 15: [2, 40] }, o($V1, [2, 8]), { 15: [1, 99] }, { 18: [2, 19] }, o($Vf, [2, 23]), o($Vf, [2, 25]), o($Ve, $V0, { 6: 3, 4: 100 }), o($Vi, [2, 20])],
- defaultActions: { 4: [2, 1], 40: [2, 42], 41: [2, 43], 78: [2, 51], 83: [2, 49], 89: [2, 55], 90: [2, 26], 92: [2, 21], 93: [2, 40], 96: [2, 19] },
- parseError: function parseError(str, hash) {
- if (hash.recoverable) {
- this.trace(str);
- }
- else {
- var error = new Error(str);
- error.hash = hash;
- throw error;
- }
- },
- parse: function parse(input) {
- var self = this, stack = [0], tstack = [], vstack = [null], lstack = [], table = this.table, yytext = '', yylineno = 0, yyleng = 0, recovering = 0, TERROR = 2, EOF = 1;
- var args = lstack.slice.call(arguments, 1);
- var lexer = Object.create(this.lexer);
- var sharedState = { yy: {} };
- for (var k in this.yy) {
- if (Object.prototype.hasOwnProperty.call(this.yy, k)) {
- sharedState.yy[k] = this.yy[k];
- }
- }
- lexer.setInput(input, sharedState.yy);
- sharedState.yy.lexer = lexer;
- sharedState.yy.parser = this;
- if (typeof lexer.yylloc == 'undefined') {
- lexer.yylloc = {};
- }
- var yyloc = lexer.yylloc;
- lstack.push(yyloc);
- var ranges = lexer.options && lexer.options.ranges;
- if (typeof sharedState.yy.parseError === 'function') {
- this.parseError = sharedState.yy.parseError;
- }
- else {
- this.parseError = Object.getPrototypeOf(this).parseError;
- }
- function popStack(n) {
- stack.length = stack.length - 2 * n;
- vstack.length = vstack.length - n;
- lstack.length = lstack.length - n;
- }
- _token_stack: var lex = function () {
- var token;
- token = lexer.lex() || EOF;
- if (typeof token !== 'number') {
- token = self.symbols_[token] || token;
- }
- return token;
- };
- var symbol, preErrorSymbol, state, action, a, r, yyval = {}, p, len, newState, expected;
- while (true) {
- state = stack[stack.length - 1];
- if (this.defaultActions[state]) {
- action = this.defaultActions[state];
- }
- else {
- if (symbol === null || typeof symbol == 'undefined') {
- symbol = lex();
- }
- action = table[state] && table[state][symbol];
- }
- if (typeof action === 'undefined' || !action.length || !action[0]) {
- var errStr = '';
- expected = [];
- for (p in table[state]) {
- if (this.terminals_[p] && p > TERROR) {
- expected.push('\'' + this.terminals_[p] + '\'');
- }
- }
- if (lexer.showPosition) {
- errStr = 'Parse error on line ' + (yylineno + 1) + ':\n' + lexer.showPosition() + '\nExpecting ' + expected.join(', ') + ', got \'' + (this.terminals_[symbol] || symbol) + '\'';
- }
- else {
- errStr = 'Parse error on line ' + (yylineno + 1) + ': Unexpected ' + (symbol == EOF ? 'end of input' : '\'' + (this.terminals_[symbol] || symbol) + '\'');
- }
- this.parseError(errStr, {
- text: lexer.match,
- token: this.terminals_[symbol] || symbol,
- line: lexer.yylineno,
- loc: yyloc,
- expected: expected
- });
- }
- if (action[0] instanceof Array && action.length > 1) {
- throw new Error('Parse Error: multiple actions possible at state: ' + state + ', token: ' + symbol);
- }
- switch (action[0]) {
- case 1:
- stack.push(symbol);
- vstack.push(lexer.yytext);
- lstack.push(lexer.yylloc);
- stack.push(action[1]);
- symbol = null;
- if (!preErrorSymbol) {
- yyleng = lexer.yyleng;
- yytext = lexer.yytext;
- yylineno = lexer.yylineno;
- yyloc = lexer.yylloc;
- if (recovering > 0) {
- recovering--;
- }
- }
- else {
- symbol = preErrorSymbol;
- preErrorSymbol = null;
- }
- break;
- case 2:
- len = this.productions_[action[1]][1];
- yyval.$ = vstack[vstack.length - len];
- yyval._$ = {
- first_line: lstack[lstack.length - (len || 1)].first_line,
- last_line: lstack[lstack.length - 1].last_line,
- first_column: lstack[lstack.length - (len || 1)].first_column,
- last_column: lstack[lstack.length - 1].last_column
- };
- if (ranges) {
- yyval._$.range = [
- lstack[lstack.length - (len || 1)].range[0],
- lstack[lstack.length - 1].range[1]
- ];
- }
- r = this.performAction.apply(yyval, [
- yytext,
- yyleng,
- yylineno,
- sharedState.yy,
- action[1],
- vstack,
- lstack
- ].concat(args));
- if (typeof r !== 'undefined') {
- return r;
- }
- if (len) {
- stack = stack.slice(0, -1 * len * 2);
- vstack = vstack.slice(0, -1 * len);
- lstack = lstack.slice(0, -1 * len);
- }
- stack.push(this.productions_[action[1]][0]);
- vstack.push(yyval.$);
- lstack.push(yyval._$);
- newState = table[stack[stack.length - 2]][stack[stack.length - 1]];
- stack.push(newState);
- break;
- case 3:
- return true;
- }
- }
- return true;
- } };
- /* generated by jison-lex 0.3.4 */
- var lexer = (function () {
- var lexer = ({
- EOF: 1,
- parseError: function parseError(str, hash) {
- if (this.yy.parser) {
- this.yy.parser.parseError(str, hash);
- }
- else {
- throw new Error(str);
- }
- },
- // resets the lexer, sets new input
- setInput: function (input, yy) {
- this.yy = yy || this.yy || {};
- this._input = input;
- this._more = this._backtrack = this.done = false;
- this.yylineno = this.yyleng = 0;
- this.yytext = this.matched = this.match = '';
- this.conditionStack = ['INITIAL'];
- this.yylloc = {
- first_line: 1,
- first_column: 0,
- last_line: 1,
- last_column: 0
- };
- if (this.options.ranges) {
- this.yylloc.range = [0, 0];
- }
- this.offset = 0;
- return this;
- },
- // consumes and returns one char from the input
- input: function () {
- var ch = this._input[0];
- this.yytext += ch;
- this.yyleng++;
- this.offset++;
- this.match += ch;
- this.matched += ch;
- var lines = ch.match(/(?:\r\n?|\n).*/g);
- if (lines) {
- this.yylineno++;
- this.yylloc.last_line++;
- }
- else {
- this.yylloc.last_column++;
- }
- if (this.options.ranges) {
- this.yylloc.range[1]++;
- }
- this._input = this._input.slice(1);
- return ch;
- },
- // unshifts one char (or a string) into the input
- unput: function (ch) {
- var len = ch.length;
- var lines = ch.split(/(?:\r\n?|\n)/g);
- this._input = ch + this._input;
- this.yytext = this.yytext.substr(0, this.yytext.length - len);
- //this.yyleng -= len;
- this.offset -= len;
- var oldLines = this.match.split(/(?:\r\n?|\n)/g);
- this.match = this.match.substr(0, this.match.length - 1);
- this.matched = this.matched.substr(0, this.matched.length - 1);
- if (lines.length - 1) {
- this.yylineno -= lines.length - 1;
- }
- var r = this.yylloc.range;
- this.yylloc = {
- first_line: this.yylloc.first_line,
- last_line: this.yylineno + 1,
- first_column: this.yylloc.first_column,
- last_column: lines ?
- (lines.length === oldLines.length ? this.yylloc.first_column : 0)
- + oldLines[oldLines.length - lines.length].length - lines[0].length :
- this.yylloc.first_column - len
- };
- if (this.options.ranges) {
- this.yylloc.range = [r[0], r[0] + this.yyleng - len];
- }
- this.yyleng = this.yytext.length;
- return this;
- },
- // When called from action, caches matched text and appends it on next action
- more: function () {
- this._more = true;
- return this;
- },
- // When called from action, signals the lexer that this rule fails to match the input, so the next matching rule (regex) should be tested instead.
- reject: function () {
- if (this.options.backtrack_lexer) {
- this._backtrack = true;
- }
- else {
- return this.parseError('Lexical error on line ' + (this.yylineno + 1) + '. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true).\n' + this.showPosition(), {
- text: "",
- token: null,
- line: this.yylineno
- });
- }
- return this;
- },
- // retain first n characters of the match
- less: function (n) {
- this.unput(this.match.slice(n));
- },
- // displays already matched input, i.e. for error messages
- pastInput: function () {
- var past = this.matched.substr(0, this.matched.length - this.match.length);
- return (past.length > 20 ? '...' : '') + past.substr(-20).replace(/\n/g, "");
- },
- // displays upcoming input, i.e. for error messages
- upcomingInput: function () {
- var next = this.match;
- if (next.length < 20) {
- next += this._input.substr(0, 20 - next.length);
- }
- return (next.substr(0, 20) + (next.length > 20 ? '...' : '')).replace(/\n/g, "");
- },
- // displays the character position where the lexing error occurred, i.e. for error messages
- showPosition: function () {
- var pre = this.pastInput();
- var c = new Array(pre.length + 1).join("-");
- return pre + this.upcomingInput() + "\n" + c + "^";
- },
- // test the lexed token: return FALSE when not a match, otherwise return token
- test_match: function (match, indexed_rule) {
- var token, lines, backup;
- if (this.options.backtrack_lexer) {
- // save context
- backup = {
- yylineno: this.yylineno,
- yylloc: {
- first_line: this.yylloc.first_line,
- last_line: this.last_line,
- first_column: this.yylloc.first_column,
- last_column: this.yylloc.last_column
- },
- yytext: this.yytext,
- match: this.match,
- matches: this.matches,
- matched: this.matched,
- yyleng: this.yyleng,
- offset: this.offset,
- _more: this._more,
- _input: this._input,
- yy: this.yy,
- conditionStack: this.conditionStack.slice(0),
- done: this.done
- };
- if (this.options.ranges) {
- backup.yylloc.range = this.yylloc.range.slice(0);
- }
- }
- lines = match[0].match(/(?:\r\n?|\n).*/g);
- if (lines) {
- this.yylineno += lines.length;
- }
- this.yylloc = {
- first_line: this.yylloc.last_line,
- last_line: this.yylineno + 1,
- first_column: this.yylloc.last_column,
- last_column: lines ?
- lines[lines.length - 1].length - lines[lines.length - 1].match(/\r?\n?/)[0].length :
- this.yylloc.last_column + match[0].length
- };
- this.yytext += match[0];
- this.match += match[0];
- this.matches = match;
- this.yyleng = this.yytext.length;
- if (this.options.ranges) {
- this.yylloc.range = [this.offset, this.offset += this.yyleng];
- }
- this._more = false;
- this._backtrack = false;
- this._input = this._input.slice(match[0].length);
- this.matched += match[0];
- token = this.performAction.call(this, this.yy, this, indexed_rule, this.conditionStack[this.conditionStack.length - 1]);
- if (this.done && this._input) {
- this.done = false;
- }
- if (token) {
- return token;
- }
- else if (this._backtrack) {
- // recover context
- for (var k in backup) {
- this[k] = backup[k];
- }
- return false; // rule action called reject() implying the next rule should be tested instead.
- }
- return false;
- },
- // return next match in input
- next: function () {
- if (this.done) {
- return this.EOF;
- }
- if (!this._input) {
- this.done = true;
- }
- var token, match, tempMatch, index;
- if (!this._more) {
- this.yytext = '';
- this.match = '';
- }
- var rules = this._currentRules();
- for (var i = 0; i < rules.length; i++) {
- tempMatch = this._input.match(this.rules[rules[i]]);
- if (tempMatch && (!match || tempMatch[0].length > match[0].length)) {
- match = tempMatch;
- index = i;
- if (this.options.backtrack_lexer) {
- token = this.test_match(tempMatch, rules[i]);
- if (token !== false) {
- return token;
- }
- else if (this._backtrack) {
- match = false;
- continue; // rule action called reject() implying a rule MISmatch.
- }
- else {
- // else: this is a lexer rule which consumes input without producing a token (e.g. whitespace)
- return false;
- }
- }
- else if (!this.options.flex) {
- break;
- }
- }
- }
- if (match) {
- token = this.test_match(match, rules[index]);
- if (token !== false) {
- return token;
- }
- // else: this is a lexer rule which consumes input without producing a token (e.g. whitespace)
- return false;
- }
- if (this._input === "") {
- return this.EOF;
- }
- else {
- return this.parseError('Lexical error on line ' + (this.yylineno + 1) + '. Unrecognized text.\n' + this.showPosition(), {
- text: "",
- token: null,
- line: this.yylineno
- });
- }
- },
- // return next match that has a token
- lex: function lex() {
- var r = this.next();
- if (r) {
- return r;
- }
- else {
- return this.lex();
- }
- },
- // activates a new lexer condition state (pushes the new lexer condition state onto the condition stack)
- begin: function begin(condition) {
- this.conditionStack.push(condition);
- },
- // pop the previously active lexer condition state off the condition stack
- popState: function popState() {
- var n = this.conditionStack.length - 1;
- if (n > 0) {
- return this.conditionStack.pop();
- }
- else {
- return this.conditionStack[0];
- }
- },
- // produce the lexer rule set which is active for the currently active lexer condition state
- _currentRules: function _currentRules() {
- if (this.conditionStack.length && this.conditionStack[this.conditionStack.length - 1]) {
- return this.conditions[this.conditionStack[this.conditionStack.length - 1]].rules;
- }
- else {
- return this.conditions["INITIAL"].rules;
- }
- },
- // return the currently active lexer condition state; when an index argument is provided it produces the N-th previous condition state, if available
- topState: function topState(n) {
- n = this.conditionStack.length - 1 - Math.abs(n || 0);
- if (n >= 0) {
- return this.conditionStack[n];
- }
- else {
- return "INITIAL";
- }
- },
- // alias for begin(condition)
- pushState: function pushState(condition) {
- this.begin(condition);
- },
- // return the number of states currently on the stack
- stateStackSize: function stateStackSize() {
- return this.conditionStack.length;
- },
- options: {},
- performAction: function anonymous(yy, yy_, $avoiding_name_collisions, YY_START) {
- var YYSTATE = YY_START;
- switch ($avoiding_name_collisions) {
- case 0: /* comment */
- break;
- case 1:
- yy_.yytext = yy_.yytext.substring(9, yy_.yytext.length - 10);
- return 9;
- break;
- case 2:
- return 54;
- break;
- case 3:
- return 54;
- break;
- case 4:
- return 42;
- break;
- case 5:
- return 55;
- break;
- case 6:
- return 43;
- break;
- case 7:
- return 48;
- break;
- case 8:
- return 46;
- break;
- case 9:
- return 47;
- break;
- case 10:
- return 49;
- break;
- case 11:
- return 51;
- break;
- case 12:
- return 52;
- break;
- case 13:
- return 34;
- break;
- case 14:
- return 35;
- break;
- case 15:
- this.begin('command');
- return 32;
- break;
- case 16:
- this.begin('command');
- return 33;
- break;
- case 17:
- this.begin('command');
- return 13;
- break;
- case 18:
- this.begin('command');
- return 39;
- break;
- case 19:
- this.begin('command');
- return 39;
- break;
- case 20:
- return 37;
- break;
- case 21:
- return 18;
- break;
- case 22:
- return 28;
- break;
- case 23:
- return 29;
- break;
- case 24:
- this.begin('command');
- return 19;
- break;
- case 25:
- this.begin('command');
- return 21;
- break;
- case 26:
- this.begin('command');
- return 26;
- break;
- case 27:
- return 22;
- break;
- case 28:
- this.begin('command');
- return 23;
- break;
- case 29:
- return 41;
- break;
- case 30:
- return 25;
- break;
- case 31:
- this.begin('command');
- return 30;
- break;
- case 32:
- this.popState();
- return 15;
- break;
- case 33:
- return 12;
- break;
- case 34:
- return 5;
- break;
- case 35:
- return 11;
- break;
- }
- },
- rules: [/^(?:\{\*[\s\S]*?\*\})/, /^(?:\{literal\}[\s\S]*?\{\/literal\})/, /^(?:"([^"]|\\\.)*")/, /^(?:'([^']|\\\.)*')/, /^(?:\$)/, /^(?:[0-9]+)/, /^(?:[_a-zA-Z][_a-zA-Z0-9]*)/, /^(?:\.)/, /^(?:\[)/, /^(?:\])/, /^(?:\()/, /^(?:\))/, /^(?:=)/, /^(?:\{ldelim\})/, /^(?:\{rdelim\})/, /^(?:\{#)/, /^(?:\{@)/, /^(?:\{if )/, /^(?:\{else if )/, /^(?:\{elseif )/, /^(?:\{else\})/, /^(?:\{\/if\})/, /^(?:\{lang\})/, /^(?:\{\/lang\})/, /^(?:\{include )/, /^(?:\{implode )/, /^(?:\{plural )/, /^(?:\{\/implode\})/, /^(?:\{foreach )/, /^(?:\{foreachelse\})/, /^(?:\{\/foreach\})/, /^(?:\{(?!\s))/, /^(?:\})/, /^(?:\s+)/, /^(?:$)/, /^(?:[^{])/],
- conditions: { "command": { "rules": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35], "inclusive": true }, "INITIAL": { "rules": [0, 1, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 33, 34, 35], "inclusive": true } }
- });
- return lexer;
- })();
- parser.lexer = lexer;
- return parser;
-});
+++ /dev/null
-/**
- * WoltLabSuite/Core/Template provides a template scripting compiler similar
- * to the PHP one of WoltLab Suite Core. It supports a limited
- * set of useful commands and compiles templates down to a pure
- * JavaScript Function.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Template
- */
-
-import * as Core from "./Core";
-import * as parser from "./Template.grammar";
-import * as StringUtil from "./StringUtil";
-import * as Language from "./Language";
-import * as I18nPlural from "./I18n/Plural";
-
-// @todo: still required?
-// work around bug in AMD module generation of Jison
-/*function Parser() {
- this.yy = {};
-}
-
-Parser.prototype = parser;
-parser.Parser = Parser;
-parser = new Parser();*/
-
-class Template {
- constructor(template: string) {
- if (Language === undefined) {
- // @ts-expect-error: This is required due to a circular dependency.
- Language = require("./Language");
- }
- if (StringUtil === undefined) {
- // @ts-expect-error: This is required due to a circular dependency.
- StringUtil = require("./StringUtil");
- }
-
- try {
- template = parser.parse(template) as string;
- template =
- "var tmp = {};\n" +
- "for (var key in v) tmp[key] = v[key];\n" +
- "v = tmp;\n" +
- "v.__wcf = window.WCF; v.__window = window;\n" +
- "return " +
- template;
-
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
- this.fetch = new Function("StringUtil", "Language", "I18nPlural", "v", template).bind(
- undefined,
- StringUtil,
- Language,
- I18nPlural,
- );
- } catch (e) {
- console.debug(e.message);
- throw e;
- }
- }
-
- /**
- * Evaluates the Template using the given parameters.
- */
- fetch(_v: object): string {
- // this will be replaced in the init function
- throw new Error("This Template is not initialized.");
- }
-}
-
-Object.defineProperty(Template, "callbacks", {
- enumerable: false,
- configurable: false,
- get: function () {
- throw new Error("WCF.Template.callbacks is no longer supported");
- },
- set: function (_value) {
- throw new Error("WCF.Template.callbacks is no longer supported");
- },
-});
-
-Core.enableLegacyInheritance(Template);
-
-export = Template;
+++ /dev/null
-/**
- * Provides an object oriented API on top of `setInterval`.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Timer/Repeating
- */
-
-import * as Core from "../Core";
-
-class RepeatingTimer {
- private readonly _callback: (timer: RepeatingTimer) => void;
- private _delta: number;
- private _timer: number | undefined;
-
- /**
- * Creates a new timer that executes the given `callback` every `delta` milliseconds.
- * It will be created in started mode. Call `stop()` if necessary.
- * The `callback` will be passed the owning instance of `Repeating`.
- */
- constructor(callback: (timer: RepeatingTimer) => void, delta: number) {
- if (typeof callback !== "function") {
- throw new TypeError("Expected a valid callback as first argument.");
- }
- if (delta < 0 || delta > 86_400 * 1_000) {
- throw new RangeError(`Invalid delta ${delta}. Delta must be in the interval [0, 86400000].`);
- }
-
- // curry callback with `this` as the first parameter
- this._callback = callback.bind(undefined, this);
- this._delta = delta;
-
- this.restart();
- }
-
- /**
- * Stops the timer and restarts it. The next call will occur in `delta` milliseconds.
- */
- restart(): void {
- this.stop();
-
- this._timer = setInterval(this._callback, this._delta);
- }
-
- /**
- * Stops the timer. It will no longer be called until you call `restart`.
- */
- stop(): void {
- if (this._timer !== undefined) {
- clearInterval(this._timer);
-
- this._timer = undefined;
- }
- }
-
- /**
- * Changes the `delta` of the timer and `restart`s it.
- */
- setDelta(delta: number): void {
- this._delta = delta;
-
- this.restart();
- }
-}
-
-Core.enableLegacyInheritance(RepeatingTimer);
-
-export = RepeatingTimer;
+++ /dev/null
-import * as Core from "../../Core";
-import * as Language from "../../Language";
-import * as StringUtil from "../../StringUtil";
-import DomChangeListener from "../../Dom/Change/Listener";
-import DomUtil from "../../Dom/Util";
-import UiUserSearchInput from "../User/Search/Input";
-
-class UiAclSimple {
- private readonly aclListContainer: HTMLElement;
- private readonly list: HTMLUListElement;
- private readonly prefix: string;
- private readonly inputName: string;
- private readonly searchInput: UiUserSearchInput;
-
- constructor(prefix?: string, inputName?: string) {
- this.prefix = prefix || "";
- this.inputName = inputName || "aclValues";
-
- const container = document.getElementById(this.prefix + "aclInputContainer")!;
-
- const allowAll = document.getElementById(this.prefix + "aclAllowAll") as HTMLInputElement;
- allowAll.addEventListener("change", () => {
- DomUtil.hide(container);
- });
-
- const denyAll = document.getElementById(this.prefix + "aclAllowAll_no")!;
- denyAll.addEventListener("change", () => {
- DomUtil.show(container);
- });
-
- this.list = document.getElementById(this.prefix + "aclAccessList") as HTMLUListElement;
- this.list.addEventListener("click", this.removeItem.bind(this));
-
- const excludedSearchValues: string[] = [];
- this.list.querySelectorAll(".aclLabel").forEach((label) => {
- excludedSearchValues.push(label.textContent!);
- });
-
- this.searchInput = new UiUserSearchInput(
- document.getElementById(this.prefix + "aclSearchInput") as HTMLInputElement,
- {
- callbackSelect: this.select.bind(this),
- includeUserGroups: true,
- excludedSearchValues: excludedSearchValues,
- preventSubmit: true,
- },
- );
-
- this.aclListContainer = document.getElementById(this.prefix + "aclListContainer")!;
-
- DomChangeListener.trigger();
- }
-
- private select(listItem: HTMLLIElement): boolean {
- const type = listItem.dataset.type!;
- const label = listItem.dataset.label!;
- const objectId = listItem.dataset.objectId!;
-
- const iconName = type === "group" ? "users" : "user";
- const html = `<span class="icon icon16 fa-${iconName}"></span>
- <span class="aclLabel">${StringUtil.escapeHTML(label)}</span>
- <span class="icon icon16 fa-times pointer jsTooltip" title="${Language.get("wcf.global.button.delete")}"></span>
- <input type="hidden" name="${this.inputName}[${type}][]" value="${objectId}">`;
-
- const item = document.createElement("li");
- item.innerHTML = html;
-
- const firstUser = this.list.querySelector(".fa-user");
- if (firstUser === null) {
- this.list.appendChild(item);
- } else {
- this.list.insertBefore(item, firstUser.parentNode);
- }
-
- DomUtil.show(this.aclListContainer);
-
- this.searchInput.addExcludedSearchValues(label);
-
- DomChangeListener.trigger();
-
- return false;
- }
-
- private removeItem(event: MouseEvent): void {
- const target = event.target as HTMLElement;
- if (target.classList.contains("fa-times")) {
- const parent = target.parentElement!;
- const label = parent.querySelector(".aclLabel")!;
- this.searchInput.removeExcludedSearchValues(label.textContent!);
-
- parent.remove();
-
- if (this.list.childElementCount === 0) {
- DomUtil.hide(this.aclListContainer);
- }
- }
- }
-}
-
-Core.enableLegacyInheritance(UiAclSimple);
-
-export = UiAclSimple;
+++ /dev/null
-/**
- * Utility class to align elements relatively to another.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Ui/Alignment (alias)
- * @module WoltLabSuite/Core/Ui/Alignment
- */
-
-import * as Core from "../Core";
-import * as DomTraverse from "../Dom/Traverse";
-import DomUtil from "../Dom/Util";
-import * as Language from "../Language";
-
-type HorizontalAlignment = "center" | "left" | "right";
-type VerticalAlignment = "bottom" | "top";
-type Offset = number | "auto";
-
-interface HorizontalResult {
- align: HorizontalAlignment;
- left: Offset;
- result: boolean;
- right: Offset;
-}
-
-interface VerticalResult {
- align: VerticalAlignment;
- bottom: Offset;
- result: boolean;
- top: Offset;
-}
-
-const enum PointerClass {
- Bottom = 0,
- Right = 1,
-}
-
-interface ElementDimensions {
- height: number;
- width: number;
-}
-
-interface ElementOffset {
- left: number;
- top: number;
-}
-
-/**
- * Calculates top/bottom position and verifies if the element would be still within the page's boundaries.
- */
-function tryAlignmentVertical(
- alignment: VerticalAlignment,
- elDimensions: ElementDimensions,
- refDimensions: ElementDimensions,
- refOffsets: ElementOffset,
- windowHeight: number,
- verticalOffset: number,
-): VerticalResult {
- let bottom: Offset = "auto";
- let top: Offset = "auto";
- let result = true;
- let pageHeaderOffset = 50;
-
- const pageHeaderPanel = document.getElementById("pageHeaderPanel");
- if (pageHeaderPanel !== null) {
- const position = window.getComputedStyle(pageHeaderPanel).position;
- if (position === "fixed" || position === "static") {
- pageHeaderOffset = pageHeaderPanel.offsetHeight;
- } else {
- pageHeaderOffset = 0;
- }
- }
-
- if (alignment === "top") {
- const bodyHeight = document.body.clientHeight;
- bottom = bodyHeight - refOffsets.top + verticalOffset;
- if (bodyHeight - (bottom + elDimensions.height) < (window.scrollY || window.pageYOffset) + pageHeaderOffset) {
- result = false;
- }
- } else {
- top = refOffsets.top + refDimensions.height + verticalOffset;
- if (top + elDimensions.height - (window.scrollY || window.pageYOffset) > windowHeight) {
- result = false;
- }
- }
-
- return {
- align: alignment,
- bottom: bottom,
- top: top,
- result: result,
- };
-}
-
-/**
- * Calculates left/right position and verifies if the element would be still within the page's boundaries.
- */
-function tryAlignmentHorizontal(
- alignment: HorizontalAlignment,
- elDimensions: ElementDimensions,
- refDimensions: ElementDimensions,
- refOffsets: ElementOffset,
- windowWidth: number,
-): HorizontalResult {
- let left: Offset = "auto";
- let right: Offset = "auto";
- let result = true;
-
- if (alignment === "left") {
- left = refOffsets.left;
-
- if (left + elDimensions.width > windowWidth) {
- result = false;
- }
- } else if (alignment === "right") {
- if (refOffsets.left + refDimensions.width < elDimensions.width) {
- result = false;
- } else {
- right = windowWidth - (refOffsets.left + refDimensions.width);
-
- if (right < 0) {
- result = false;
- }
- }
- } else {
- left = refOffsets.left + refDimensions.width / 2 - elDimensions.width / 2;
- left = ~~left;
-
- if (left < 0 || left + elDimensions.width > windowWidth) {
- result = false;
- }
- }
-
- return {
- align: alignment,
- left: left,
- right: right,
- result: result,
- };
-}
-
-/**
- * Sets the alignment for target element relatively to the reference element.
- */
-export function set(element: HTMLElement, referenceElement: HTMLElement, options?: AlignmentOptions): void {
- options = Core.extend(
- {
- // offset to reference element
- verticalOffset: 0,
- // align the pointer element, expects .elementPointer as a direct child of given element
- pointer: false,
- // use static pointer positions, expects two items: class to move it to the bottom and the second to move it to the right
- pointerClassNames: [],
- // alternate element used to calculate dimensions
- refDimensionsElement: null,
- // preferred alignment, possible values: left/right/center and top/bottom
- horizontal: "left",
- vertical: "bottom",
- // allow flipping over axis, possible values: both, horizontal, vertical and none
- allowFlip: "both",
- },
- options || {},
- ) as AlignmentOptions;
-
- if (!Array.isArray(options.pointerClassNames) || options.pointerClassNames.length !== (options.pointer ? 1 : 2)) {
- options.pointerClassNames = [];
- }
- if (["left", "right", "center"].indexOf(options.horizontal!) === -1) {
- options.horizontal = "left";
- }
- if (options.vertical !== "bottom") {
- options.vertical = "top";
- }
- if (["both", "horizontal", "vertical", "none"].indexOf(options.allowFlip!) === -1) {
- options.allowFlip = "both";
- }
-
- // Place the element in the upper left corner to prevent calculation issues due to possible scrollbars.
- DomUtil.setStyles(element, {
- bottom: "auto !important",
- left: "0 !important",
- right: "auto !important",
- top: "0 !important",
- visibility: "hidden !important",
- });
-
- const elDimensions = DomUtil.outerDimensions(element);
- const refDimensions = DomUtil.outerDimensions(
- options.refDimensionsElement instanceof HTMLElement ? options.refDimensionsElement : referenceElement,
- );
- const refOffsets = DomUtil.offset(referenceElement);
- const windowHeight = window.innerHeight;
- const windowWidth = document.body.clientWidth;
-
- let horizontal: HorizontalResult | null = null;
- let alignCenter = false;
- if (options.horizontal === "center") {
- alignCenter = true;
- horizontal = tryAlignmentHorizontal(options.horizontal, elDimensions, refDimensions, refOffsets, windowWidth);
- if (!horizontal.result) {
- if (options.allowFlip === "both" || options.allowFlip === "horizontal") {
- options.horizontal = "left";
- } else {
- horizontal.result = true;
- }
- }
- }
-
- // in rtl languages we simply swap the value for 'horizontal'
- if (Language.get("wcf.global.pageDirection") === "rtl") {
- options.horizontal = options.horizontal === "left" ? "right" : "left";
- }
-
- if (horizontal === null || !horizontal.result) {
- const horizontalCenter = horizontal;
- horizontal = tryAlignmentHorizontal(options.horizontal!, elDimensions, refDimensions, refOffsets, windowWidth);
- if (!horizontal.result && (options.allowFlip === "both" || options.allowFlip === "horizontal")) {
- const horizontalFlipped = tryAlignmentHorizontal(
- options.horizontal === "left" ? "right" : "left",
- elDimensions,
- refDimensions,
- refOffsets,
- windowWidth,
- );
- // only use these results if it fits into the boundaries, otherwise both directions exceed and we honor the demanded direction
- if (horizontalFlipped.result) {
- horizontal = horizontalFlipped;
- } else if (alignCenter) {
- horizontal = horizontalCenter;
- }
- }
- }
-
- const left = horizontal!.left;
- const right = horizontal!.right;
- let vertical = tryAlignmentVertical(
- options.vertical,
- elDimensions,
- refDimensions,
- refOffsets,
- windowHeight,
- options.verticalOffset!,
- );
- if (!vertical.result && (options.allowFlip === "both" || options.allowFlip === "vertical")) {
- const verticalFlipped = tryAlignmentVertical(
- options.vertical === "top" ? "bottom" : "top",
- elDimensions,
- refDimensions,
- refOffsets,
- windowHeight,
- options.verticalOffset!,
- );
- // only use these results if it fits into the boundaries, otherwise both directions exceed and we honor the demanded direction
- if (verticalFlipped.result) {
- vertical = verticalFlipped;
- }
- }
-
- const bottom = vertical.bottom;
- const top = vertical.top;
- // set pointer position
- if (options.pointer) {
- const pointers = DomTraverse.childrenByClass(element, "elementPointer");
- const pointer = pointers[0] || null;
- if (pointer === null) {
- throw new Error("Expected the .elementPointer element to be a direct children.");
- }
-
- if (horizontal!.align === "center") {
- pointer.classList.add("center");
- pointer.classList.remove("left", "right");
- } else {
- pointer.classList.add(horizontal!.align);
- pointer.classList.remove("center");
- pointer.classList.remove(horizontal!.align === "left" ? "right" : "left");
- }
-
- if (vertical.align === "top") {
- pointer.classList.add("flipVertical");
- } else {
- pointer.classList.remove("flipVertical");
- }
- } else if (options.pointerClassNames.length === 2) {
- element.classList[top === "auto" ? "add" : "remove"](options.pointerClassNames[PointerClass.Bottom]);
- element.classList[left === "auto" ? "add" : "remove"](options.pointerClassNames[PointerClass.Right]);
- }
-
- DomUtil.setStyles(element, {
- bottom: bottom === "auto" ? bottom : Math.round(bottom).toString() + "px",
- left: left === "auto" ? left : Math.ceil(left).toString() + "px",
- right: right === "auto" ? right : Math.floor(right).toString() + "px",
- top: top === "auto" ? top : Math.round(top).toString() + "px",
- });
-
- DomUtil.show(element);
- element.style.removeProperty("visibility");
-}
-
-export type AllowFlip = "both" | "horizontal" | "none" | "vertical";
-
-export interface AlignmentOptions {
- // offset to reference element
- verticalOffset?: number;
- // align the pointer element, expects .elementPointer as a direct child of given element
- pointer?: boolean;
- // use static pointer positions, expects two items: class to move it to the bottom and the second to move it to the right
- pointerClassNames?: string[];
- // alternate element used to calculate dimensions
- refDimensionsElement?: HTMLElement | null;
- // preferred alignment, possible values: left/right/center and top/bottom
- horizontal?: HorizontalAlignment;
- vertical?: VerticalAlignment;
- // allow flipping over axis, possible values: both, horizontal, vertical and none
- allowFlip?: AllowFlip;
-}
+++ /dev/null
-/**
- * Handles the 'mark as read' action for articles.
- *
- * @author Marcel Werk
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Article/MarkAllAsRead
- */
-
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../../Ajax/Data";
-
-class UiArticleMarkAllAsRead implements AjaxCallbackObject {
- constructor() {
- document.querySelectorAll(".markAllAsReadButton").forEach((button) => {
- button.addEventListener("click", this.click.bind(this));
- });
- }
-
- private click(event: MouseEvent): void {
- event.preventDefault();
-
- Ajax.api(this);
- }
-
- _ajaxSuccess(): void {
- /* remove obsolete badges */
- // main menu
- const badge = document.querySelector(".mainMenu .active .badge");
- if (badge) badge.remove();
-
- // article list
- document.querySelectorAll(".articleList .newMessageBadge").forEach((el) => el.remove());
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "markAllAsRead",
- className: "wcf\\data\\article\\ArticleAction",
- },
- };
- }
-}
-
-export function init(): void {
- new UiArticleMarkAllAsRead();
-}
+++ /dev/null
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../Ajax/Data";
-import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
-import DomUtil from "../../Dom/Util";
-import * as Language from "../../Language";
-import * as StringUtil from "../../StringUtil";
-import UiDialog from "../Dialog";
-
-type CallbackSelect = (articleId: number) => void;
-
-interface SearchResult {
- articleID: number;
- displayLink: string;
- name: string;
-}
-
-interface AjaxResponse extends DatabaseObjectActionResponse {
- returnValues: SearchResult[];
-}
-
-class UiArticleSearch implements AjaxCallbackObject, DialogCallbackObject {
- private callbackSelect?: CallbackSelect = undefined;
- private resultContainer?: HTMLElement = undefined;
- private resultList?: HTMLOListElement = undefined;
- private searchInput?: HTMLInputElement = undefined;
-
- open(callbackSelect: CallbackSelect) {
- this.callbackSelect = callbackSelect;
-
- UiDialog.open(this);
- }
-
- private search(event: KeyboardEvent): void {
- event.preventDefault();
-
- const inputContainer = this.searchInput!.parentElement!;
-
- const value = this.searchInput!.value.trim();
- if (value.length < 3) {
- DomUtil.innerError(inputContainer, Language.get("wcf.article.search.error.tooShort"));
- return;
- } else {
- DomUtil.innerError(inputContainer, false);
- }
-
- Ajax.api(this, {
- parameters: {
- searchString: value,
- },
- });
- }
-
- private click(event: MouseEvent): void {
- event.preventDefault();
-
- const target = event.currentTarget as HTMLElement;
- this.callbackSelect!(+target.dataset.articleId!);
-
- UiDialog.close(this);
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- const html = data.returnValues
- .map((article) => {
- return `<li>
- <div class="containerHeadline pointer" data-article-id="${article.articleID}">
- <h3>${StringUtil.escapeHTML(article.name)}</h3>
- <small>${StringUtil.escapeHTML(article.displayLink)}</small>
- </div>
- </li>`;
- })
- .join("");
-
- this.resultList!.innerHTML = html;
-
- if (html) {
- DomUtil.show(this.resultList!);
- } else {
- DomUtil.hide(this.resultList!);
- }
-
- if (html) {
- this.resultList!.querySelectorAll(".containerHeadline").forEach((item) => {
- item.addEventListener("click", this.click.bind(this));
- });
- } else {
- const parent = this.searchInput!.parentElement!;
- DomUtil.innerError(parent, Language.get("wcf.article.search.error.noResults"));
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "search",
- className: "wcf\\data\\article\\ArticleAction",
- },
- };
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "wcfUiArticleSearch",
- options: {
- onSetup: () => {
- this.searchInput = document.getElementById("wcfUiArticleSearchInput") as HTMLInputElement;
- this.searchInput.addEventListener("keydown", (event) => {
- if (event.key === "Enter") {
- this.search(event);
- }
- });
-
- const button = this.searchInput.nextElementSibling!;
- button.addEventListener("click", this.search.bind(this));
-
- this.resultContainer = document.getElementById("wcfUiArticleSearchResultContainer")!;
- this.resultList = document.getElementById("wcfUiArticleSearchResultList") as HTMLOListElement;
- },
- onShow: () => {
- this.searchInput!.focus();
- },
- title: Language.get("wcf.article.search"),
- },
- source: `<div class="section">
- <dl>
- <dt>
- <label for="wcfUiArticleSearchInput">${Language.get("wcf.article.search.name")}</label>
- </dt>
- <dd>
- <div class="inputAddon">
- <input type="text" id="wcfUiArticleSearchInput" class="long">
- <a href="#" class="inputSuffix"><span class="icon icon16 fa-search"></span></a>
- </div>
- </dd>
- </dl>
- </div>
- <section id="wcfUiArticleSearchResultContainer" class="section" style="display: none;">
- <header class="sectionHeader">
- <h2 class="sectionTitle">${Language.get("wcf.article.search.results")}</h2>
- </header>
- <ol id="wcfUiArticleSearchResultList" class="containerList"></ol>
- </section>`,
- };
- }
-}
-
-let uiArticleSearch: UiArticleSearch | undefined = undefined;
-
-function getUiArticleSearch() {
- if (!uiArticleSearch) {
- uiArticleSearch = new UiArticleSearch();
- }
-
- return uiArticleSearch;
-}
-
-export function open(callbackSelect: CallbackSelect): void {
- getUiArticleSearch().open(callbackSelect);
-}
+++ /dev/null
-/**
- * Allows to be informed when a click event bubbled up to the document's body.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Ui/CloseOverlay (alias)
- * @module WoltLabSuite/Core/Ui/CloseOverlay
- */
-
-import CallbackList from "../CallbackList";
-
-const _callbackList = new CallbackList();
-
-const UiCloseOverlay = {
- /**
- * @see CallbackList.add
- */
- add: _callbackList.add.bind(_callbackList),
-
- /**
- * @see CallbackList.remove
- */
- remove: _callbackList.remove.bind(_callbackList),
-
- /**
- * Invokes all registered callbacks.
- */
- execute(): void {
- _callbackList.forEach(null, (callback) => callback());
- },
-};
-
-document.body.addEventListener("click", () => UiCloseOverlay.execute());
-
-export = UiCloseOverlay;
+++ /dev/null
-/**
- * Wrapper class to provide color picker support. Constructing a new object does not
- * guarantee the picker to be ready at the time of call.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Color/Picker
- */
-
-import * as Core from "../../Core";
-
-let _marshal = (element: HTMLElement, options: ColorPickerOptions) => {
- if (typeof window.WCF === "object" && typeof window.WCF.ColorPicker === "function") {
- _marshal = (element, options) => {
- const picker = new window.WCF.ColorPicker(element);
-
- if (typeof options.callbackSubmit === "function") {
- picker.setCallbackSubmit(options.callbackSubmit);
- }
-
- return picker;
- };
-
- return _marshal(element, options);
- } else {
- if (_queue.length === 0) {
- window.__wcf_bc_colorPickerInit = () => {
- _queue.forEach((data) => {
- _marshal(data[0], data[1]);
- });
-
- window.__wcf_bc_colorPickerInit = undefined;
- _queue = [];
- };
- }
-
- _queue.push([element, options]);
- }
-};
-
-type QueueItem = [HTMLElement, ColorPickerOptions];
-
-let _queue: QueueItem[] = [];
-
-interface CallbackSubmitPayload {
- r: number;
- g: number;
- b: number;
- a: number;
-}
-
-interface ColorPickerOptions {
- callbackSubmit: (data: CallbackSubmitPayload) => void;
-}
-
-class UiColorPicker {
- /**
- * Initializes a new color picker instance. This is actually just a wrapper that does
- * not guarantee the picker to be ready at the time of call.
- */
- constructor(element: HTMLElement, options?: Partial<ColorPickerOptions>) {
- if (!(element instanceof Element)) {
- throw new TypeError(
- "Expected a valid DOM element, use `UiColorPicker.fromSelector()` if you want to use a CSS selector.",
- );
- }
-
- options = Core.extend(
- {
- callbackSubmit: null,
- },
- options || {},
- );
-
- _marshal(element, options as ColorPickerOptions);
- }
-
- /**
- * Initializes a color picker for all input elements matching the given selector.
- */
- static fromSelector(selector: string): void {
- document.querySelectorAll(selector).forEach((element: HTMLElement) => {
- new UiColorPicker(element);
- });
- }
-}
-
-Core.enableLegacyInheritance(UiColorPicker);
-
-export = UiColorPicker;
+++ /dev/null
-/**
- * Handles the comment add feature.
- *
- * Warning: This implementation is also used for responses, but in a slightly
- * modified version. Changes made to this class need to be verified
- * against the response implementation.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Comment/Add
- */
-
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
-import ControllerCaptcha from "../../Controller/Captcha";
-import * as Core from "../../Core";
-import DomChangeListener from "../../Dom/Change/Listener";
-import DomUtil from "../../Dom/Util";
-import * as EventHandler from "../../Event/Handler";
-import * as Language from "../../Language";
-import UiDialog from "../Dialog";
-import { RedactorEditor } from "../Redactor/Editor";
-import * as UiScroll from "../Scroll";
-import User from "../../User";
-import * as UiNotification from "../Notification";
-
-interface AjaxResponse {
- returnValues: {
- guestDialog?: string;
- template: string;
- };
-}
-
-class UiCommentAdd {
- protected readonly _container: HTMLElement;
- protected readonly _content: HTMLElement;
- protected readonly _textarea: HTMLTextAreaElement;
- protected _editor: RedactorEditor | null = null;
- protected _loadingOverlay: HTMLElement | null = null;
-
- /**
- * Initializes a new quick reply field.
- */
- constructor(container: HTMLElement) {
- this._container = container;
- this._content = this._container.querySelector(".jsOuterEditorContainer") as HTMLElement;
- this._textarea = this._container.querySelector(".wysiwygTextarea") as HTMLTextAreaElement;
-
- this._content.addEventListener("click", (event) => {
- if (this._content.classList.contains("collapsed")) {
- event.preventDefault();
-
- this._content.classList.remove("collapsed");
-
- this._focusEditor();
- }
- });
-
- // handle submit button
- const submitButton = this._container.querySelector('button[data-type="save"]') as HTMLButtonElement;
- submitButton.addEventListener("click", (ev) => this._submit(ev));
- }
-
- /**
- * Scrolls the editor into view and sets the caret to the end of the editor.
- */
- protected _focusEditor(): void {
- UiScroll.element(this._container, () => {
- window.jQuery(this._textarea).redactor("WoltLabCaret.endOfEditor");
- });
- }
-
- /**
- * Submits the guest dialog.
- */
- protected _submitGuestDialog(event: MouseEvent | KeyboardEvent): void {
- // only submit when enter key is pressed
- if (event instanceof KeyboardEvent && event.key !== "Enter") {
- return;
- }
-
- const target = event.currentTarget as HTMLInputElement;
- const dialogContent = target.closest(".dialogContent") as HTMLElement;
- const usernameInput = dialogContent.querySelector("input[name=username]") as HTMLInputElement;
- if (usernameInput.value === "") {
- DomUtil.innerError(usernameInput, Language.get("wcf.global.form.error.empty"));
- usernameInput.closest("dl")!.classList.add("formError");
-
- return;
- }
-
- let parameters: ArbitraryObject = {
- parameters: {
- data: {
- username: usernameInput.value,
- },
- },
- };
-
- if (ControllerCaptcha.has("commentAdd")) {
- const data = ControllerCaptcha.getData("commentAdd");
- if (data instanceof Promise) {
- void data.then((data) => {
- parameters = Core.extend(parameters, data) as ArbitraryObject;
- this._submit(undefined, parameters);
- });
- } else {
- parameters = Core.extend(parameters, data as ArbitraryObject) as ArbitraryObject;
- this._submit(undefined, parameters);
- }
- } else {
- this._submit(undefined, parameters);
- }
- }
-
- /**
- * Validates the message and submits it to the server.
- */
- protected _submit(event: MouseEvent | undefined, additionalParameters?: ArbitraryObject): void {
- if (event) {
- event.preventDefault();
- }
-
- if (!this._validate()) {
- // validation failed, bail out
- return;
- }
-
- this._showLoadingOverlay();
-
- // build parameters
- const parameters = this._getParameters();
-
- EventHandler.fire("com.woltlab.wcf.redactor2", "submit_text", parameters.data as any);
-
- if (!User.userId && !additionalParameters) {
- parameters.requireGuestDialog = true;
- }
-
- Ajax.api(
- this,
- Core.extend(
- {
- parameters: parameters,
- },
- additionalParameters as ArbitraryObject,
- ),
- );
- }
-
- /**
- * Returns the request parameters to add a comment.
- */
- protected _getParameters(): ArbitraryObject {
- const commentList = this._container.closest(".commentList") as HTMLElement;
-
- return {
- data: {
- message: this._getEditor().code.get(),
- objectID: ~~commentList.dataset.objectId!,
- objectTypeID: ~~commentList.dataset.objectTypeId!,
- },
- };
- }
-
- /**
- * Validates the message and invokes listeners to perform additional validation.
- */
- protected _validate(): boolean {
- // remove all existing error elements
- this._container.querySelectorAll(".innerError").forEach((el) => el.remove());
-
- // check if editor contains actual content
- if (this._getEditor().utils.isEmpty()) {
- this.throwError(this._textarea, Language.get("wcf.global.form.error.empty"));
- return false;
- }
-
- const data = {
- api: this,
- editor: this._getEditor(),
- message: this._getEditor().code.get(),
- valid: true,
- };
-
- EventHandler.fire("com.woltlab.wcf.redactor2", "validate_text", data);
-
- return data.valid;
- }
-
- /**
- * Throws an error by adding an inline error to target element.
- */
- throwError(element: HTMLElement, message: string): void {
- DomUtil.innerError(element, message === "empty" ? Language.get("wcf.global.form.error.empty") : message);
- }
-
- /**
- * Displays a loading spinner while the request is processed by the server.
- */
- protected _showLoadingOverlay(): void {
- if (this._loadingOverlay === null) {
- this._loadingOverlay = document.createElement("div");
- this._loadingOverlay.className = "commentLoadingOverlay";
- this._loadingOverlay.innerHTML = '<span class="icon icon96 fa-spinner"></span>';
- }
-
- this._content.classList.add("loading");
- this._content.appendChild(this._loadingOverlay);
- }
-
- /**
- * Hides the loading spinner.
- */
- protected _hideLoadingOverlay(): void {
- this._content.classList.remove("loading");
-
- const loadingOverlay = this._content.querySelector(".commentLoadingOverlay");
- if (loadingOverlay !== null) {
- loadingOverlay.remove();
- }
- }
-
- /**
- * Resets the editor contents and notifies event listeners.
- */
- protected _reset(): void {
- this._getEditor().code.set("<p>\u200b</p>");
-
- EventHandler.fire("com.woltlab.wcf.redactor2", "reset_text");
-
- if (document.activeElement instanceof HTMLElement) {
- document.activeElement.blur();
- }
-
- this._content.classList.add("collapsed");
- }
-
- /**
- * Handles errors occurred during server processing.
- */
- protected _handleError(data: ResponseData): void {
- this.throwError(this._textarea, data.returnValues.errorType);
- }
-
- /**
- * Returns the current editor instance.
- */
- protected _getEditor(): RedactorEditor {
- if (this._editor === null) {
- if (typeof window.jQuery === "function") {
- this._editor = window.jQuery(this._textarea).data("redactor") as RedactorEditor;
- } else {
- throw new Error("Unable to access editor, jQuery has not been loaded yet.");
- }
- }
-
- return this._editor;
- }
-
- /**
- * Inserts the rendered message.
- */
- protected _insertMessage(data: AjaxResponse): HTMLElement {
- // insert HTML
- DomUtil.insertHtml(data.returnValues.template, this._container, "after");
-
- UiNotification.show(Language.get("wcf.global.success.add"));
-
- DomChangeListener.trigger();
-
- return this._container.nextElementSibling as HTMLElement;
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- if (!User.userId && data.returnValues.guestDialog) {
- UiDialog.openStatic("jsDialogGuestComment", data.returnValues.guestDialog, {
- closable: false,
- onClose: () => {
- if (ControllerCaptcha.has("commentAdd")) {
- ControllerCaptcha.delete("commentAdd");
- }
- },
- title: Language.get("wcf.global.confirmation.title"),
- });
-
- const dialog = UiDialog.getDialog("jsDialogGuestComment")!;
-
- const submitButton = dialog.content.querySelector("input[type=submit]") as HTMLButtonElement;
- submitButton.addEventListener("click", (ev) => this._submitGuestDialog(ev));
- const cancelButton = dialog.content.querySelector('button[data-type="cancel"]') as HTMLButtonElement;
- cancelButton.addEventListener("click", () => this._cancelGuestDialog());
-
- const input = dialog.content.querySelector("input[type=text]") as HTMLInputElement;
- input.addEventListener("keypress", (ev) => this._submitGuestDialog(ev));
- } else {
- const scrollTarget = this._insertMessage(data);
-
- if (!User.userId) {
- UiDialog.close("jsDialogGuestComment");
- }
-
- this._reset();
-
- this._hideLoadingOverlay();
-
- window.setTimeout(() => {
- UiScroll.element(scrollTarget);
- }, 100);
- }
- }
-
- _ajaxFailure(data: ResponseData): boolean {
- this._hideLoadingOverlay();
-
- if (data === null || data.returnValues === undefined || data.returnValues.errorType === undefined) {
- return true;
- }
-
- this._handleError(data);
-
- return false;
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "addComment",
- className: "wcf\\data\\comment\\CommentAction",
- },
- silent: true,
- };
- }
-
- /**
- * Cancels the guest dialog and restores the comment editor.
- */
- protected _cancelGuestDialog(): void {
- UiDialog.close("jsDialogGuestComment");
-
- this._hideLoadingOverlay();
- }
-}
-
-Core.enableLegacyInheritance(UiCommentAdd);
-
-export = UiCommentAdd;
+++ /dev/null
-/**
- * Provides editing support for comments.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Comment/Edit
- */
-
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
-import * as Core from "../../Core";
-import DomChangeListener from "../../Dom/Change/Listener";
-import DomUtil from "../../Dom/Util";
-import * as Environment from "../../Environment";
-import * as EventHandler from "../../Event/Handler";
-import * as Language from "../../Language";
-import { RedactorEditor } from "../Redactor/Editor";
-import * as UiScroll from "../Scroll";
-import * as UiNotification from "../Notification";
-
-interface AjaxResponse {
- actionName: string;
- returnValues: {
- message: string;
- template: string;
- };
-}
-
-class UiCommentEdit {
- protected _activeElement: HTMLElement | null = null;
- protected readonly _comments = new WeakSet<HTMLElement>();
- protected readonly _container: HTMLElement;
- protected _editorContainer: HTMLElement | null = null;
-
- /**
- * Initializes the comment edit manager.
- */
- constructor(container: HTMLElement) {
- this._container = container;
-
- this.rebuild();
-
- DomChangeListener.add("Ui/Comment/Edit_" + DomUtil.identify(this._container), this.rebuild.bind(this));
- }
-
- /**
- * Initializes each applicable message, should be called whenever new
- * messages are being displayed.
- */
- rebuild(): void {
- this._container.querySelectorAll(".comment").forEach((comment: HTMLElement) => {
- if (this._comments.has(comment)) {
- return;
- }
-
- if (Core.stringToBool(comment.dataset.canEdit || "")) {
- const button = comment.querySelector(".jsCommentEditButton") as HTMLAnchorElement;
- if (button !== null) {
- button.addEventListener("click", (ev) => this._click(ev));
- }
- }
-
- this._comments.add(comment);
- });
- }
-
- /**
- * Handles clicks on the edit button.
- */
- protected _click(event: MouseEvent): void {
- event.preventDefault();
-
- if (this._activeElement === null) {
- const target = event.currentTarget as HTMLElement;
- this._activeElement = target.closest(".comment") as HTMLElement;
-
- this._prepare();
-
- Ajax.api(this, {
- actionName: "beginEdit",
- objectIDs: [this._getObjectId(this._activeElement)],
- });
- } else {
- UiNotification.show("wcf.message.error.editorAlreadyInUse", null, "warning");
- }
- }
-
- /**
- * Prepares the message for editor display.
- */
- protected _prepare(): void {
- this._editorContainer = document.createElement("div");
- this._editorContainer.className = "commentEditorContainer";
- this._editorContainer.innerHTML = '<span class="icon icon48 fa-spinner"></span>';
-
- const content = this._activeElement!.querySelector(".commentContentContainer")!;
- content.insertBefore(this._editorContainer, content.firstChild);
- }
-
- /**
- * Shows the message editor.
- */
- protected _showEditor(data: AjaxResponse): void {
- const id = this._getEditorId();
- const editorContainer = this._editorContainer!;
-
- const icon = editorContainer.querySelector(".icon")!;
- icon.remove();
-
- const editor = document.createElement("div");
- editor.className = "editorContainer";
- DomUtil.setInnerHtml(editor, data.returnValues.template);
- editorContainer.appendChild(editor);
-
- // bind buttons
- const formSubmit = editorContainer.querySelector(".formSubmit") as HTMLElement;
-
- const buttonSave = formSubmit.querySelector('button[data-type="save"]') as HTMLButtonElement;
- buttonSave.addEventListener("click", () => this._save());
-
- const buttonCancel = formSubmit.querySelector('button[data-type="cancel"]') as HTMLButtonElement;
- buttonCancel.addEventListener("click", () => this._restoreMessage());
-
- EventHandler.add("com.woltlab.wcf.redactor", `submitEditor_${id}`, (data) => {
- data.cancel = true;
-
- this._save();
- });
-
- const editorElement = document.getElementById(id) as HTMLElement;
- if (Environment.editor() === "redactor") {
- window.setTimeout(() => {
- UiScroll.element(this._activeElement!);
- }, 250);
- } else {
- editorElement.focus();
- }
- }
-
- /**
- * Restores the message view.
- */
- protected _restoreMessage(): void {
- this._destroyEditor();
-
- this._editorContainer!.remove();
-
- this._activeElement = null;
- }
-
- /**
- * Saves the editor message.
- */
- protected _save(): void {
- const parameters = {
- data: {
- message: "",
- },
- };
-
- const id = this._getEditorId();
-
- EventHandler.fire("com.woltlab.wcf.redactor2", `getText_${id}`, parameters.data);
-
- if (!this._validate(parameters)) {
- // validation failed
- return;
- }
-
- EventHandler.fire("com.woltlab.wcf.redactor2", `submit_${id}`, parameters);
-
- Ajax.api(this, {
- actionName: "save",
- objectIDs: [this._getObjectId(this._activeElement!)],
- parameters: parameters,
- });
-
- this._hideEditor();
- }
-
- /**
- * Validates the message and invokes listeners to perform additional validation.
- */
- protected _validate(parameters: ArbitraryObject): boolean {
- // remove all existing error elements
- this._activeElement!.querySelectorAll(".innerError").forEach((el) => el.remove());
-
- // check if editor contains actual content
- const editorElement = document.getElementById(this._getEditorId())!;
- const redactor: RedactorEditor = window.jQuery(editorElement).data("redactor");
- if (redactor.utils.isEmpty()) {
- this.throwError(editorElement, Language.get("wcf.global.form.error.empty"));
- return false;
- }
-
- const data = {
- api: this,
- parameters: parameters,
- valid: true,
- };
-
- EventHandler.fire("com.woltlab.wcf.redactor2", "validate_" + this._getEditorId(), data);
-
- return data.valid;
- }
-
- /**
- * Throws an error by adding an inline error to target element.
- */
- throwError(element: HTMLElement, message: string): void {
- DomUtil.innerError(element, message);
- }
-
- /**
- * Shows the update message.
- */
- protected _showMessage(data: AjaxResponse): void {
- // set new content
- const container = this._editorContainer!.parentElement!.querySelector(
- ".commentContent .userMessage",
- ) as HTMLElement;
- DomUtil.setInnerHtml(container, data.returnValues.message);
-
- this._restoreMessage();
-
- UiNotification.show();
- }
-
- /**
- * Hides the editor from view.
- */
- protected _hideEditor(): void {
- const editorContainer = this._editorContainer!.querySelector(".editorContainer") as HTMLElement;
- DomUtil.hide(editorContainer);
-
- const icon = document.createElement("span");
- icon.className = "icon icon48 fa-spinner";
- this._editorContainer!.appendChild(icon);
- }
-
- /**
- * Restores the previously hidden editor.
- */
- protected _restoreEditor(): void {
- const icon = this._editorContainer!.querySelector(".fa-spinner")!;
- icon.remove();
-
- const editorContainer = this._editorContainer!.querySelector(".editorContainer") as HTMLElement;
- if (editorContainer !== null) {
- DomUtil.show(editorContainer);
- }
- }
-
- /**
- * Destroys the editor instance.
- */
- protected _destroyEditor(): void {
- EventHandler.fire("com.woltlab.wcf.redactor2", `autosaveDestroy_${this._getEditorId()}`);
- EventHandler.fire("com.woltlab.wcf.redactor2", `destroy_${this._getEditorId()}`);
- }
-
- /**
- * Returns the unique editor id.
- */
- protected _getEditorId(): string {
- return `commentEditor${this._getObjectId(this._activeElement!)}`;
- }
-
- /**
- * Returns the element's `data-object-id` value.
- */
- protected _getObjectId(element: HTMLElement): number {
- return ~~element.dataset.objectId!;
- }
-
- _ajaxFailure(data: ResponseData): boolean {
- const editor = this._editorContainer!.querySelector(".redactor-layer") as HTMLElement;
-
- // handle errors occurring on editor load
- if (editor === null) {
- this._restoreMessage();
-
- return true;
- }
-
- this._restoreEditor();
-
- if (!data || data.returnValues === undefined || data.returnValues.errorType === undefined) {
- return true;
- }
-
- DomUtil.innerError(editor, data.returnValues.errorType);
-
- return false;
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- switch (data.actionName) {
- case "beginEdit":
- this._showEditor(data);
- break;
-
- case "save":
- this._showMessage(data);
- break;
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- const objectTypeId = ~~this._container.dataset.objectTypeId!;
-
- return {
- data: {
- className: "wcf\\data\\comment\\CommentAction",
- parameters: {
- data: {
- objectTypeID: objectTypeId,
- },
- },
- },
- silent: true,
- };
- }
-}
-
-Core.enableLegacyInheritance(UiCommentEdit);
-
-export = UiCommentEdit;
+++ /dev/null
-/**
- * Handles the comment response add feature.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Comment/Add
- */
-
-import { AjaxCallbackSetup } from "../../../Ajax/Data";
-import * as Core from "../../../Core";
-import DomChangeListener from "../../../Dom/Change/Listener";
-import DomUtil from "../../../Dom/Util";
-import * as Language from "../../../Language";
-import UiCommentAdd from "../Add";
-import * as UiNotification from "../../Notification";
-
-type CallbackInsert = () => void;
-
-interface ResponseAddOptions {
- callbackInsert: CallbackInsert | null;
-}
-
-interface AjaxResponse {
- returnValues: {
- guestDialog?: string;
- template: string;
- };
-}
-
-class UiCommentResponseAdd extends UiCommentAdd {
- protected _options: ResponseAddOptions;
-
- constructor(container: HTMLElement, options: Partial<ResponseAddOptions>) {
- super(container);
-
- this._options = Core.extend(
- {
- callbackInsert: null,
- },
- options,
- ) as ResponseAddOptions;
- }
-
- /**
- * Returns the editor container for placement.
- */
- getContainer(): HTMLElement {
- return this._container;
- }
-
- /**
- * Retrieves the current content from the editor.
- */
- getContent(): string {
- return window.jQuery(this._textarea).redactor("code.get") as string;
- }
-
- /**
- * Sets the content and places the caret at the end of the editor.
- */
- setContent(html: string): void {
- window.jQuery(this._textarea).redactor("code.set", html);
- window.jQuery(this._textarea).redactor("WoltLabCaret.endOfEditor");
-
- // the error message can appear anywhere in the container, not exclusively after the textarea
- const innerError = this._textarea.parentElement!.querySelector(".innerError");
- if (innerError !== null) {
- innerError.remove();
- }
-
- this._content.classList.remove("collapsed");
- this._focusEditor();
- }
-
- protected _getParameters(): ArbitraryObject {
- const parameters = super._getParameters();
-
- const comment = this._container.closest(".comment") as HTMLElement;
- (parameters.data as ArbitraryObject).commentID = ~~comment.dataset.objectId!;
-
- return parameters;
- }
-
- protected _insertMessage(data: AjaxResponse): HTMLElement {
- const commentContent = this._container.parentElement!.querySelector(".commentContent")!;
- let responseList = commentContent.nextElementSibling as HTMLElement;
- if (responseList === null || !responseList.classList.contains("commentResponseList")) {
- responseList = document.createElement("ul");
- responseList.className = "containerList commentResponseList";
- responseList.dataset.responses = "0";
-
- commentContent.insertAdjacentElement("afterend", responseList);
- }
-
- // insert HTML
- DomUtil.insertHtml(data.returnValues.template, responseList, "append");
-
- UiNotification.show(Language.get("wcf.global.success.add"));
-
- DomChangeListener.trigger();
-
- // reset editor
- window.jQuery(this._textarea).redactor("code.set", "");
-
- if (this._options.callbackInsert !== null) {
- this._options.callbackInsert();
- }
-
- // update counter
- responseList.dataset.responses = responseList.children.length.toString();
-
- return responseList.lastElementChild as HTMLElement;
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- const data = super._ajaxSetup();
- (data.data as ArbitraryObject).actionName = "addResponse";
-
- return data;
- }
-}
-
-Core.enableLegacyInheritance(UiCommentResponseAdd);
-
-export = UiCommentResponseAdd;
+++ /dev/null
-/**
- * Provides editing support for comment responses.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Comment/Response/Edit
- */
-
-import * as Ajax from "../../../Ajax";
-import { AjaxCallbackSetup } from "../../../Ajax/Data";
-import * as Core from "../../../Core";
-import DomChangeListener from "../../../Dom/Change/Listener";
-import DomUtil from "../../../Dom/Util";
-import UiCommentEdit from "../Edit";
-import * as UiNotification from "../../Notification";
-
-interface AjaxResponse {
- actionName: string;
- returnValues: {
- message: string;
- template: string;
- };
-}
-
-class UiCommentResponseEdit extends UiCommentEdit {
- protected readonly _responses = new WeakSet<HTMLElement>();
-
- /**
- * Initializes the comment edit manager.
- *
- * @param {Element} container container element
- */
- constructor(container: HTMLElement) {
- super(container);
-
- this.rebuildResponses();
-
- DomChangeListener.add("Ui/Comment/Response/Edit_" + DomUtil.identify(this._container), () =>
- this.rebuildResponses(),
- );
- }
-
- rebuild(): void {
- // Do nothing, we want to avoid implicitly invoking `UiCommentEdit.rebuild()`.
- }
-
- /**
- * Initializes each applicable message, should be called whenever new
- * messages are being displayed.
- */
- rebuildResponses(): void {
- this._container.querySelectorAll(".commentResponse").forEach((response: HTMLElement) => {
- if (this._responses.has(response)) {
- return;
- }
-
- if (Core.stringToBool(response.dataset.canEdit || "")) {
- const button = response.querySelector(".jsCommentResponseEditButton") as HTMLAnchorElement;
- if (button !== null) {
- button.addEventListener("click", (ev) => this._click(ev));
- }
- }
-
- this._responses.add(response);
- });
- }
-
- /**
- * Handles clicks on the edit button.
- */
- protected _click(event: MouseEvent): void {
- event.preventDefault();
-
- if (this._activeElement === null) {
- const target = event.currentTarget as HTMLElement;
- this._activeElement = target.closest(".commentResponse") as HTMLElement;
-
- this._prepare();
-
- Ajax.api(this, {
- actionName: "beginEdit",
- objectIDs: [this._getObjectId(this._activeElement)],
- });
- } else {
- UiNotification.show("wcf.message.error.editorAlreadyInUse", null, "warning");
- }
- }
-
- /**
- * Prepares the message for editor display.
- *
- * @protected
- */
- protected _prepare(): void {
- this._editorContainer = document.createElement("div");
- this._editorContainer.className = "commentEditorContainer";
- this._editorContainer.innerHTML = '<span class="icon icon48 fa-spinner"></span>';
-
- const content = this._activeElement!.querySelector(".commentResponseContent")!;
- content.insertBefore(this._editorContainer, content.firstChild);
- }
-
- /**
- * Shows the update message.
- */
- protected _showMessage(data: AjaxResponse): void {
- // set new content
- const parent = this._editorContainer!.parentElement!;
- DomUtil.setInnerHtml(parent.querySelector(".commentResponseContent .userMessage")!, data.returnValues.message);
-
- this._restoreMessage();
-
- UiNotification.show();
- }
-
- /**
- * Returns the unique editor id.
- */
- protected _getEditorId(): string {
- return `commentResponseEditor${this._getObjectId(this._activeElement!)}`;
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- const objectTypeId = ~~this._container.dataset.objectTypeId!;
-
- return {
- data: {
- className: "wcf\\data\\comment\\response\\CommentResponseAction",
- parameters: {
- data: {
- objectTypeID: objectTypeId,
- },
- },
- },
- silent: true,
- };
- }
-}
-
-Core.enableLegacyInheritance(UiCommentResponseEdit);
-
-export = UiCommentResponseEdit;
+++ /dev/null
-/**
- * Provides the confirmation dialog overlay.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Ui/Confirmation (alias)
- * @module WoltLabSuite/Core/Ui/Confirmation
- */
-
-import * as Core from "../Core";
-import * as Language from "../Language";
-import UiDialog from "./Dialog";
-import { DialogCallbackObject, DialogCallbackSetup } from "./Dialog/Data";
-
-class UiConfirmation implements DialogCallbackObject {
- private _active = false;
- private parameters: ConfirmationCallbackParameters;
-
- private readonly confirmButton: HTMLElement;
- private readonly _content: HTMLElement;
- private readonly dialog: HTMLElement;
- private readonly text: HTMLElement;
-
- private callbackCancel: CallbackCancel;
- private callbackConfirm: CallbackConfirm;
-
- constructor() {
- this.dialog = document.createElement("div");
- this.dialog.id = "wcfSystemConfirmation";
- this.dialog.classList.add("systemConfirmation");
-
- this.text = document.createElement("p");
- this.dialog.appendChild(this.text);
-
- this._content = document.createElement("div");
- this._content.id = "wcfSystemConfirmationContent";
- this.dialog.appendChild(this._content);
-
- const formSubmit = document.createElement("div");
- formSubmit.classList.add("formSubmit");
- this.dialog.appendChild(formSubmit);
-
- this.confirmButton = document.createElement("button");
- this.confirmButton.classList.add("buttonPrimary");
- this.confirmButton.textContent = Language.get("wcf.global.confirmation.confirm");
- this.confirmButton.addEventListener("click", (_ev) => this._confirm());
- formSubmit.appendChild(this.confirmButton);
-
- const cancelButton = document.createElement("button");
- cancelButton.textContent = Language.get("wcf.global.confirmation.cancel");
- cancelButton.addEventListener("click", () => {
- UiDialog.close(this);
- });
- formSubmit.appendChild(cancelButton);
-
- document.body.appendChild(this.dialog);
- }
-
- public open(options: ConfirmationOptions): void {
- this.parameters = options.parameters || {};
-
- this._content.innerHTML = typeof options.template === "string" ? options.template.trim() : "";
- this.text[options.messageIsHtml ? "innerHTML" : "textContent"] = options.message;
-
- if (typeof options.legacyCallback === "function") {
- this.callbackCancel = (parameters) => {
- options.legacyCallback!("cancel", parameters, this.content);
- };
- this.callbackConfirm = (parameters) => {
- options.legacyCallback!("confirm", parameters, this.content);
- };
- } else {
- if (typeof options.cancel !== "function") {
- options.cancel = () => {
- // Do nothing
- };
- }
-
- this.callbackCancel = options.cancel;
- this.callbackConfirm = options.confirm!;
- }
-
- this._active = true;
-
- UiDialog.open(this);
- }
-
- get active(): boolean {
- return this._active;
- }
-
- get content(): HTMLElement {
- return this._content;
- }
-
- /**
- * Invoked if the user confirms the dialog.
- */
- _confirm(): void {
- this.callbackConfirm(this.parameters, this.content);
-
- this._active = false;
-
- UiDialog.close("wcfSystemConfirmation");
- }
-
- /**
- * Invoked on dialog close or if user cancels the dialog.
- */
- _onClose(): void {
- if (this.active) {
- this.confirmButton.blur();
-
- this._active = false;
-
- this.callbackCancel(this.parameters);
- }
- }
-
- /**
- * Sets the focus on the confirm button on dialog open for proper keyboard support.
- */
- _onShow(): void {
- this.confirmButton.blur();
- this.confirmButton.focus();
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "wcfSystemConfirmation",
- options: {
- onClose: this._onClose.bind(this),
- onShow: this._onShow.bind(this),
- title: Language.get("wcf.global.confirmation.title"),
- },
- };
- }
-}
-
-let confirmation: UiConfirmation;
-
-function getConfirmation(): UiConfirmation {
- if (!confirmation) {
- confirmation = new UiConfirmation();
- }
- return confirmation;
-}
-
-type LegacyResult = "cancel" | "confirm";
-
-export type ConfirmationCallbackParameters = {
- [key: string]: any;
-};
-
-interface BasicConfirmationOptions {
- message: string;
- messageIsHtml?: boolean;
- parameters?: ConfirmationCallbackParameters;
- template?: string;
-}
-
-interface LegacyConfirmationOptions extends BasicConfirmationOptions {
- cancel?: never;
- confirm?: never;
- legacyCallback: (result: LegacyResult, parameters: ConfirmationCallbackParameters, element: HTMLElement) => void;
-}
-
-type CallbackCancel = (parameters: ConfirmationCallbackParameters) => void;
-type CallbackConfirm = (parameters: ConfirmationCallbackParameters, content: HTMLElement) => void;
-
-interface NewConfirmationOptions extends BasicConfirmationOptions {
- cancel?: CallbackCancel;
- confirm: CallbackConfirm;
- legacyCallback?: never;
-}
-
-export type ConfirmationOptions = LegacyConfirmationOptions | NewConfirmationOptions;
-
-/**
- * Shows the confirmation dialog.
- */
-export function show(options: ConfirmationOptions): void {
- if (getConfirmation().active) {
- return;
- }
-
- options = Core.extend(
- {
- cancel: null,
- confirm: null,
- legacyCallback: null,
- message: "",
- messageIsHtml: false,
- parameters: {},
- template: "",
- },
- options,
- ) as ConfirmationOptions;
- options.message = typeof (options.message as any) === "string" ? options.message.trim() : "";
- if (!options.message) {
- throw new Error("Expected a non-empty string for option 'message'.");
- }
- if (typeof options.confirm !== "function" && typeof options.legacyCallback !== "function") {
- throw new TypeError("Expected a valid callback for option 'confirm'.");
- }
-
- getConfirmation().open(options);
-}
-
-/**
- * Returns content container element.
- */
-export function getContentElement(): HTMLElement {
- return getConfirmation().content;
-}
+++ /dev/null
-/**
- * Modal dialog handler.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Ui/Dialog (alias)
- * @module WoltLabSuite/Core/Ui/Dialog
- */
-
-import * as Core from "../Core";
-import DomChangeListener from "../Dom/Change/Listener";
-import * as UiScreen from "./Screen";
-import DomUtil from "../Dom/Util";
-import {
- DialogCallbackObject,
- DialogData,
- DialogId,
- DialogOptions,
- DialogHtml,
- AjaxInitialization,
-} from "./Dialog/Data";
-import * as Language from "../Language";
-import * as Environment from "../Environment";
-import * as EventHandler from "../Event/Handler";
-import UiDropdownSimple from "./Dropdown/Simple";
-import { AjaxCallbackSetup } from "../Ajax/Data";
-
-let _activeDialog: string | null = null;
-let _callbackFocus: (event: FocusEvent) => void;
-let _container: HTMLElement;
-const _dialogs = new Map<ElementId, DialogData>();
-let _dialogFullHeight = false;
-const _dialogObjects = new WeakMap<DialogCallbackObject, DialogInternalData>();
-const _dialogToObject = new Map<ElementId, DialogCallbackObject>();
-let _keyupListener: (event: KeyboardEvent) => boolean;
-const _validCallbacks = ["onBeforeClose", "onClose", "onShow"];
-
-// list of supported `input[type]` values for dialog submit
-const _validInputTypes = ["number", "password", "search", "tel", "text", "url"];
-
-const _focusableElements = [
- 'a[href]:not([tabindex^="-"]):not([inert])',
- 'area[href]:not([tabindex^="-"]):not([inert])',
- "input:not([disabled]):not([inert])",
- "select:not([disabled]):not([inert])",
- "textarea:not([disabled]):not([inert])",
- "button:not([disabled]):not([inert])",
- 'iframe:not([tabindex^="-"]):not([inert])',
- 'audio:not([tabindex^="-"]):not([inert])',
- 'video:not([tabindex^="-"]):not([inert])',
- '[contenteditable]:not([tabindex^="-"]):not([inert])',
- '[tabindex]:not([tabindex^="-"]):not([inert])',
-];
-
-/**
- * @exports WoltLabSuite/Core/Ui/Dialog
- */
-const UiDialog = {
- /**
- * Sets up global container and internal variables.
- */
- setup(): void {
- _container = document.createElement("div");
- _container.classList.add("dialogOverlay");
- _container.setAttribute("aria-hidden", "true");
- _container.addEventListener("mousedown", (ev) => this._closeOnBackdrop(ev));
- _container.addEventListener(
- "wheel",
- (event) => {
- if (event.target === _container) {
- event.preventDefault();
- }
- },
- { passive: false },
- );
-
- document.getElementById("content")!.appendChild(_container);
-
- _keyupListener = (event: KeyboardEvent): boolean => {
- if (event.key === "Escape") {
- const target = event.target as HTMLElement;
- if (target.nodeName !== "INPUT" && target.nodeName !== "TEXTAREA") {
- this.close(_activeDialog!);
-
- return false;
- }
- }
-
- return true;
- };
-
- UiScreen.on("screen-xs", {
- match() {
- _dialogFullHeight = true;
- },
- unmatch() {
- _dialogFullHeight = false;
- },
- setup() {
- _dialogFullHeight = true;
- },
- });
-
- this._initStaticDialogs();
- DomChangeListener.add("Ui/Dialog", () => {
- this._initStaticDialogs();
- });
-
- window.addEventListener("resize", () => {
- _dialogs.forEach((dialog) => {
- if (!Core.stringToBool(dialog.dialog.getAttribute("aria-hidden"))) {
- this.rebuild(dialog.dialog.dataset.id || "");
- }
- });
- });
- },
-
- _initStaticDialogs(): void {
- document.querySelectorAll(".jsStaticDialog").forEach((button: HTMLElement) => {
- button.classList.remove("jsStaticDialog");
-
- const id = button.dataset.dialogId || "";
- if (id) {
- const container = document.getElementById(id);
- if (container !== null) {
- container.classList.remove("jsStaticDialogContent");
- container.dataset.isStaticDialog = "true";
- DomUtil.hide(container);
-
- button.addEventListener("click", (event) => {
- event.preventDefault();
-
- this.openStatic(container.id, null, { title: container.dataset.title || "" });
- });
- }
- }
- });
- },
-
- /**
- * Opens the dialog and implicitly creates it on first usage.
- */
- open(callbackObject: DialogCallbackObject, html?: DialogHtml): DialogData | object {
- let dialogData = _dialogObjects.get(callbackObject);
- if (dialogData && Core.isPlainObject(dialogData)) {
- // dialog already exists
- return this.openStatic(dialogData.id, typeof html === "undefined" ? null : html);
- }
-
- // initialize a new dialog
- if (typeof callbackObject._dialogSetup !== "function") {
- throw new Error("Callback object does not implement the method '_dialogSetup()'.");
- }
-
- const setupData = callbackObject._dialogSetup();
- if (!Core.isPlainObject(setupData)) {
- throw new Error("Expected an object literal as return value of '_dialogSetup()'.");
- }
-
- const id = setupData.id;
- dialogData = { id };
-
- let dialogElement: HTMLElement | null;
- if (setupData.source === undefined) {
- dialogElement = document.getElementById(id);
- if (dialogElement === null) {
- throw new Error(
- "Element id '" +
- id +
- "' is invalid and no source attribute was given. If you want to use the `html` argument instead, please add `source: null` to your dialog configuration.",
- );
- }
-
- setupData.source = document.createDocumentFragment();
- setupData.source.appendChild(dialogElement);
-
- dialogElement.removeAttribute("id");
- DomUtil.show(dialogElement);
- } else if (setupData.source === null) {
- // `null` means there is no static markup and `html` should be used instead
- setupData.source = html;
- } else if (typeof setupData.source === "function") {
- setupData.source();
- } else if (Core.isPlainObject(setupData.source)) {
- if (typeof html === "string" && html.trim() !== "") {
- setupData.source = html;
- } else {
- void import("../Ajax").then((Ajax) => {
- const source = setupData.source as AjaxInitialization;
- Ajax.api(this as any, source.data, (data) => {
- if (data.returnValues && typeof data.returnValues.template === "string") {
- this.open(callbackObject, data.returnValues.template);
-
- if (typeof source.after === "function") {
- source.after(_dialogs.get(id)!.content, data);
- }
- }
- });
- });
-
- return {};
- }
- } else {
- if (typeof setupData.source === "string") {
- dialogElement = document.createElement("div");
- dialogElement.id = id;
- DomUtil.setInnerHtml(dialogElement, setupData.source);
-
- setupData.source = document.createDocumentFragment();
- setupData.source.appendChild(dialogElement);
- }
-
- if (!setupData.source.nodeType || setupData.source.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
- throw new Error("Expected at least a document fragment as 'source' attribute.");
- }
- }
-
- _dialogObjects.set(callbackObject, dialogData);
- _dialogToObject.set(id, callbackObject);
-
- return this.openStatic(id, setupData.source as DialogHtml, setupData.options);
- },
-
- /**
- * Opens an dialog, if the dialog is already open the content container
- * will be replaced by the HTML string contained in the parameter html.
- *
- * If id is an existing element id, html will be ignored and the referenced
- * element will be appended to the content element instead.
- */
- openStatic(id: string, html: DialogHtml, options?: DialogOptions): DialogData {
- UiScreen.pageOverlayOpen();
-
- if (Environment.platform() !== "desktop") {
- if (!this.isOpen(id)) {
- UiScreen.scrollDisable();
- }
- }
-
- if (_dialogs.has(id)) {
- this._updateDialog(id, html as string);
- } else {
- options = Core.extend(
- {
- backdropCloseOnClick: true,
- closable: true,
- closeButtonLabel: Language.get("wcf.global.button.close"),
- closeConfirmMessage: "",
- disableContentPadding: false,
- title: "",
-
- onBeforeClose: null,
- onClose: null,
- onShow: null,
- },
- options || {},
- ) as InternalDialogOptions;
-
- if (!options.closable) options.backdropCloseOnClick = false;
- if (options.closeConfirmMessage) {
- options.onBeforeClose = (id) => {
- void import("./Confirmation").then((UiConfirmation) => {
- UiConfirmation.show({
- confirm: this.close.bind(this, id),
- message: options!.closeConfirmMessage || "",
- });
- });
- };
- }
-
- this._createDialog(id, html, options as InternalDialogOptions);
- }
-
- const data = _dialogs.get(id)!;
-
- // iOS breaks `position: fixed` when input elements or `contenteditable`
- // are focused, this will freeze the screen and force Safari to scroll
- // to the input field
- if (Environment.platform() === "ios") {
- window.setTimeout(() => {
- data.content.querySelector<HTMLElement>("input, textarea")?.focus();
- }, 200);
- }
-
- return data;
- },
-
- /**
- * Sets the dialog title.
- */
- setTitle(id: ElementIdOrCallbackObject, title: string): void {
- id = this._getDialogId(id);
-
- const data = _dialogs.get(id);
- if (data === undefined) {
- throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
- }
-
- const dialogTitle = data.dialog.querySelector(".dialogTitle");
- if (dialogTitle) {
- dialogTitle.textContent = title;
- }
- },
-
- /**
- * Sets a callback function on runtime.
- */
- setCallback(id: ElementIdOrCallbackObject, key: string, value: (...args: any[]) => void | null): void {
- if (typeof id === "object") {
- const dialogData = _dialogObjects.get(id);
- if (dialogData !== undefined) {
- id = dialogData.id;
- }
- }
-
- const data = _dialogs.get(id as string);
- if (data === undefined) {
- throw new Error(`Expected a valid dialog id, '${id as string}' does not match any active dialog.`);
- }
-
- if (_validCallbacks.indexOf(key) === -1) {
- throw new Error("Invalid callback identifier, '" + key + "' is not recognized.");
- }
-
- if (typeof value !== "function" && value !== null) {
- throw new Error(
- "Only functions or the 'null' value are acceptable callback values ('" + typeof value + "' given).",
- );
- }
-
- data[key] = value;
- },
-
- /**
- * Creates the DOM for a new dialog and opens it.
- */
- _createDialog(id: string, html: DialogHtml, options: InternalDialogOptions): void {
- let element: HTMLElement | null = null;
- if (html === null) {
- element = document.getElementById(id);
- if (element === null) {
- throw new Error("Expected either a HTML string or an existing element id.");
- }
- }
-
- const dialog = document.createElement("div");
- dialog.classList.add("dialogContainer");
- dialog.setAttribute("aria-hidden", "true");
- dialog.setAttribute("role", "dialog");
- dialog.dataset.id = id;
-
- const header = document.createElement("header");
- dialog.appendChild(header);
-
- const titleId = DomUtil.getUniqueId();
- dialog.setAttribute("aria-labelledby", titleId);
-
- const title = document.createElement("span");
- title.classList.add("dialogTitle");
- title.textContent = options.title!;
- title.id = titleId;
- header.appendChild(title);
-
- if (options.closable) {
- const closeButton = document.createElement("a");
- closeButton.className = "dialogCloseButton jsTooltip";
- closeButton.href = "#";
- closeButton.setAttribute("role", "button");
- closeButton.tabIndex = 0;
- closeButton.title = options.closeButtonLabel;
- closeButton.setAttribute("aria-label", options.closeButtonLabel);
- closeButton.addEventListener("click", (ev) => this._close(ev));
- header.appendChild(closeButton);
-
- const span = document.createElement("span");
- span.className = "icon icon24 fa-times";
- closeButton.appendChild(span);
- }
-
- const contentContainer = document.createElement("div");
- contentContainer.classList.add("dialogContent");
- if (options.disableContentPadding) contentContainer.classList.add("dialogContentNoPadding");
- dialog.appendChild(contentContainer);
-
- contentContainer.addEventListener(
- "wheel",
- (event) => {
- let allowScroll = false;
- let element: HTMLElement | null = event.target as HTMLElement;
- let clientHeight: number;
- let scrollHeight: number;
- let scrollTop: number;
- for (;;) {
- clientHeight = element.clientHeight;
- scrollHeight = element.scrollHeight;
-
- if (clientHeight < scrollHeight) {
- scrollTop = element.scrollTop;
-
- // negative value: scrolling up
- if (event.deltaY < 0 && scrollTop > 0) {
- allowScroll = true;
- break;
- } else if (event.deltaY > 0 && scrollTop + clientHeight < scrollHeight) {
- allowScroll = true;
- break;
- }
- }
-
- if (!element || element === contentContainer) {
- break;
- }
-
- element = element.parentNode as HTMLElement;
- }
-
- if (!allowScroll) {
- event.preventDefault();
- }
- },
- { passive: false },
- );
-
- let content: HTMLElement;
- if (element === null) {
- if (typeof html === "string") {
- content = document.createElement("div");
- content.id = id;
- DomUtil.setInnerHtml(content, html);
- } else if (html instanceof DocumentFragment) {
- const children: HTMLElement[] = [];
- let node: Node;
- for (let i = 0, length = html.childNodes.length; i < length; i++) {
- node = html.childNodes[i];
-
- if (node.nodeType === Node.ELEMENT_NODE) {
- children.push(node as HTMLElement);
- }
- }
-
- if (children[0].nodeName !== "DIV" || children.length > 1) {
- content = document.createElement("div");
- content.id = id;
- content.appendChild(html);
- } else {
- content = children[0];
- }
- } else {
- throw new TypeError("'html' must either be a string or a DocumentFragment");
- }
- } else {
- content = element;
- }
-
- contentContainer.appendChild(content);
-
- if (content.style.getPropertyValue("display") === "none") {
- DomUtil.show(content);
- }
-
- _dialogs.set(id, {
- backdropCloseOnClick: options.backdropCloseOnClick,
- closable: options.closable,
- content: content,
- dialog: dialog,
- header: header,
- onBeforeClose: options.onBeforeClose!,
- onClose: options.onClose!,
- onShow: options.onShow!,
-
- submitButton: null,
- inputFields: new Set<HTMLInputElement>(),
- });
-
- _container.insertBefore(dialog, _container.firstChild);
-
- if (typeof options.onSetup === "function") {
- options.onSetup(content);
- }
-
- this._updateDialog(id, null);
- },
-
- /**
- * Updates the dialog's content element.
- */
- _updateDialog(id: ElementId, html: string | null): void {
- const data = _dialogs.get(id);
- if (data === undefined) {
- throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
- }
-
- if (typeof html === "string") {
- DomUtil.setInnerHtml(data.content, html);
- }
-
- if (Core.stringToBool(data.dialog.getAttribute("aria-hidden"))) {
- // close existing dropdowns
- UiDropdownSimple.closeAll();
- window.WCF.Dropdown.Interactive.Handler.closeAll();
-
- if (_callbackFocus === null) {
- _callbackFocus = this._maintainFocus.bind(this);
- document.body.addEventListener("focus", _callbackFocus, { capture: true });
- }
-
- if (data.closable && Core.stringToBool(_container.getAttribute("aria-hidden"))) {
- window.addEventListener("keyup", _keyupListener);
- }
-
- // Move the dialog to the front to prevent it being hidden behind already open dialogs
- // if it was previously visible.
- data.dialog.parentNode!.insertBefore(data.dialog, data.dialog.parentNode!.firstChild);
-
- data.dialog.setAttribute("aria-hidden", "false");
- _container.setAttribute("aria-hidden", "false");
- _container.setAttribute("close-on-click", data.backdropCloseOnClick ? "true" : "false");
- _activeDialog = id;
-
- // Set the focus to the first focusable child of the dialog element.
- const closeButton = data.header.querySelector(".dialogCloseButton");
- if (closeButton) closeButton.setAttribute("inert", "true");
- this._setFocusToFirstItem(data.dialog, false);
- if (closeButton) closeButton.removeAttribute("inert");
-
- if (typeof data.onShow === "function") {
- data.onShow(data.content);
- }
-
- if (Core.stringToBool(data.content.dataset.isStaticDialog || "")) {
- EventHandler.fire("com.woltlab.wcf.dialog", "openStatic", {
- content: data.content,
- id: id,
- });
- }
- }
-
- this.rebuild(id);
-
- DomChangeListener.trigger();
- },
-
- _maintainFocus(event: FocusEvent): void {
- if (_activeDialog) {
- const data = _dialogs.get(_activeDialog) as DialogData;
- const target = event.target as HTMLElement;
- if (
- !data.dialog.contains(target) &&
- !target.closest(".dropdownMenuContainer") &&
- !target.closest(".datePicker")
- ) {
- this._setFocusToFirstItem(data.dialog, true);
- }
- }
- },
-
- _setFocusToFirstItem(dialog: HTMLElement, maintain: boolean): void {
- let focusElement = this._getFirstFocusableChild(dialog);
- if (focusElement !== null) {
- if (maintain) {
- if (focusElement.id === "username" || (focusElement as HTMLInputElement).name === "username") {
- if (Environment.browser() === "safari" && Environment.platform() === "ios") {
- // iOS Safari's username/password autofill breaks if the input field is focused
- focusElement = null;
- }
- }
- }
-
- if (focusElement) {
- // Setting the focus to a select element in iOS is pretty strange, because
- // it focuses it, but also displays the keyboard for a fraction of a second,
- // causing it to pop out from below and immediately vanish.
- //
- // iOS will only show the keyboard if an input element is focused *and* the
- // focus is an immediate result of a user interaction. This method must be
- // assumed to be called from within a click event, but we want to set the
- // focus without triggering the keyboard.
- //
- // We can break the condition by wrapping it in a setTimeout() call,
- // effectively tricking iOS into focusing the element without showing the
- // keyboard.
- setTimeout(() => {
- focusElement!.focus();
- }, 1);
- }
- }
- },
-
- _getFirstFocusableChild(element: HTMLElement): HTMLElement | null {
- const nodeList = element.querySelectorAll<HTMLElement>(_focusableElements.join(","));
- for (let i = 0, length = nodeList.length; i < length; i++) {
- if (nodeList[i].offsetWidth && nodeList[i].offsetHeight && nodeList[i].getClientRects().length) {
- return nodeList[i];
- }
- }
-
- return null;
- },
-
- /**
- * Rebuilds dialog identified by given id.
- */
- rebuild(elementId: ElementIdOrCallbackObject): void {
- const id = this._getDialogId(elementId);
-
- const data = _dialogs.get(id);
- if (data === undefined) {
- throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
- }
-
- // ignore non-active dialogs
- if (Core.stringToBool(data.dialog.getAttribute("aria-hidden"))) {
- return;
- }
-
- const contentContainer = data.content.parentNode as HTMLElement;
-
- const formSubmit = data.content.querySelector(".formSubmit") as HTMLElement;
- let unavailableHeight = 0;
- if (formSubmit !== null) {
- contentContainer.classList.add("dialogForm");
- formSubmit.classList.add("dialogFormSubmit");
-
- unavailableHeight += DomUtil.outerHeight(formSubmit);
-
- // Calculated height can be a fractional value and depending on the
- // browser the results can vary. By subtracting a single pixel we're
- // working around fractional values, without visually changing anything.
- unavailableHeight -= 1;
-
- contentContainer.style.setProperty("margin-bottom", `${unavailableHeight}px`, "");
- } else {
- contentContainer.classList.remove("dialogForm");
- contentContainer.style.removeProperty("margin-bottom");
- }
-
- unavailableHeight += DomUtil.outerHeight(data.header);
-
- const maximumHeight = window.innerHeight * (_dialogFullHeight ? 1 : 0.8) - unavailableHeight;
- contentContainer.style.setProperty("max-height", `${~~maximumHeight}px`, "");
-
- // fix for a calculation bug in Chrome causing the scrollbar to overlap the border
- if (Environment.browser() === "chrome") {
- if (data.content.scrollHeight > maximumHeight) {
- data.content.style.setProperty("margin-right", "-1px", "");
- } else {
- data.content.style.removeProperty("margin-right");
- }
- }
-
- // Chrome and Safari use heavy anti-aliasing when the dialog's width
- // cannot be evenly divided, causing the whole text to become blurry
- if (Environment.browser() === "chrome" || Environment.browser() === "safari") {
- // The new Microsoft Edge is detected as "chrome", because effectively we're detecting
- // Chromium rather than Chrome specifically. The workaround for fractional pixels does
- // not work well in Edge, there seems to be a different logic for fractional positions,
- // causing the text to be blurry.
- //
- // We can use `backface-visibility: hidden` to prevent the anti aliasing artifacts in
- // WebKit/Blink, which will also prevent some weird font rendering issues when resizing.
- contentContainer.classList.add("jsWebKitFractionalPixelFix");
- }
-
- const callbackObject = _dialogToObject.get(id);
- //noinspection JSUnresolvedVariable
- if (callbackObject !== undefined && typeof callbackObject._dialogSubmit === "function") {
- const inputFields = data.content.querySelectorAll<HTMLInputElement>('input[data-dialog-submit-on-enter="true"]');
-
- const submitButton = data.content.querySelector(
- '.formSubmit > input[type="submit"], .formSubmit > button[data-type="submit"]',
- );
- if (submitButton === null) {
- // check if there is at least one input field with submit handling,
- // otherwise we'll assume the dialog has not been populated yet
- if (inputFields.length === 0) {
- console.warn("Broken dialog, expected a submit button.", data.content);
- }
-
- return;
- }
-
- if (data.submitButton !== submitButton) {
- data.submitButton = submitButton as HTMLElement;
-
- submitButton.addEventListener("click", (event) => {
- event.preventDefault();
-
- this._submit(id);
- });
-
- const _callbackKeydown = (event: KeyboardEvent): void => {
- if (event.key === "Enter") {
- event.preventDefault();
-
- this._submit(id);
- }
- };
-
- // bind input fields
- let inputField: HTMLInputElement;
- for (let i = 0, length = inputFields.length; i < length; i++) {
- inputField = inputFields[i];
-
- if (data.inputFields.has(inputField)) continue;
-
- if (_validInputTypes.indexOf(inputField.type) === -1) {
- console.warn("Unsupported input type.", inputField);
- continue;
- }
-
- data.inputFields.add(inputField);
-
- inputField.addEventListener("keydown", _callbackKeydown);
- }
- }
- }
- },
-
- /**
- * Submits the dialog with the given id.
- */
- _submit(id: string): void {
- const data = _dialogs.get(id);
-
- let isValid = true;
- data!.inputFields.forEach((inputField) => {
- if (inputField.required) {
- if (inputField.value.trim() === "") {
- DomUtil.innerError(inputField, Language.get("wcf.global.form.error.empty"));
-
- isValid = false;
- } else {
- DomUtil.innerError(inputField, false);
- }
- }
- });
-
- if (isValid) {
- const callbackObject = _dialogToObject.get(id) as DialogCallbackObject;
- if (typeof callbackObject._dialogSubmit === "function") {
- callbackObject._dialogSubmit();
- }
- }
- },
-
- /**
- * Submits the dialog with the given id.
- */
- submit(id: string): void {
- this._submit(id);
- },
-
- /**
- * Handles clicks on the close button or the backdrop if enabled.
- */
- _close(event: MouseEvent): boolean {
- event.preventDefault();
-
- const data = _dialogs.get(_activeDialog!) as DialogData;
- if (typeof data.onBeforeClose === "function") {
- data.onBeforeClose(_activeDialog!);
-
- return false;
- }
-
- this.close(_activeDialog!);
-
- return true;
- },
-
- /**
- * Closes the current active dialog by clicks on the backdrop.
- */
- _closeOnBackdrop(event: MouseEvent): void {
- if (event.target !== _container) {
- return;
- }
-
- if (Core.stringToBool(_container.getAttribute("close-on-click"))) {
- this._close(event);
- } else {
- event.preventDefault();
- }
- },
-
- /**
- * Closes a dialog identified by given id.
- */
- close(id: ElementIdOrCallbackObject): void {
- id = this._getDialogId(id);
-
- let data = _dialogs.get(id);
- if (data === undefined) {
- throw new Error("Expected a valid dialog id, '" + id + "' does not match any active dialog.");
- }
-
- data.dialog.setAttribute("aria-hidden", "true");
-
- // Move the keyboard focus away from a now hidden element.
- const activeElement = document.activeElement as HTMLElement;
- if (activeElement.closest(".dialogContainer") === data.dialog) {
- activeElement.blur();
- }
-
- if (typeof data.onClose === "function") {
- data.onClose(id);
- }
-
- // get next active dialog
- _activeDialog = null;
- for (let i = 0; i < _container.childElementCount; i++) {
- const child = _container.children[i] as HTMLElement;
- if (!Core.stringToBool(child.getAttribute("aria-hidden"))) {
- _activeDialog = child.dataset.id || "";
- break;
- }
- }
-
- UiScreen.pageOverlayClose();
-
- if (_activeDialog === null) {
- _container.setAttribute("aria-hidden", "true");
- _container.dataset.closeOnClick = "false";
-
- if (data.closable) {
- window.removeEventListener("keyup", _keyupListener);
- }
- } else {
- data = _dialogs.get(_activeDialog) as DialogData;
- _container.dataset.closeOnClick = data.backdropCloseOnClick ? "true" : "false";
- }
-
- if (Environment.platform() !== "desktop") {
- UiScreen.scrollEnable();
- }
- },
-
- /**
- * Returns the dialog data for given element id.
- */
- getDialog(id: ElementIdOrCallbackObject): DialogData | undefined {
- return _dialogs.get(this._getDialogId(id));
- },
-
- /**
- * Returns true for open dialogs.
- */
- isOpen(id: ElementIdOrCallbackObject): boolean {
- const data = this.getDialog(id);
- return data !== undefined && data.dialog.getAttribute("aria-hidden") === "false";
- },
-
- /**
- * Destroys a dialog instance.
- *
- * @param {Object} callbackObject the same object that was used to invoke `_dialogSetup()` on first call
- */
- destroy(callbackObject: DialogCallbackObject): void {
- if (typeof callbackObject !== "object") {
- throw new TypeError("Expected the callback object as parameter.");
- }
-
- if (_dialogObjects.has(callbackObject)) {
- const id = _dialogObjects.get(callbackObject)!.id;
- if (this.isOpen(id)) {
- this.close(id);
- }
-
- // If the dialog is destroyed in the close callback, this method is
- // called twice resulting in `_dialogs.get(id)` being undefined for
- // the initial call.
- if (_dialogs.has(id)) {
- _dialogs.get(id)!.dialog.remove();
- _dialogs.delete(id);
- }
- _dialogObjects.delete(callbackObject);
- }
- },
-
- /**
- * Returns a dialog's id.
- *
- * @param {(string|object)} id element id or callback object
- * @return {string}
- * @protected
- */
- _getDialogId(id: ElementIdOrCallbackObject): DialogId {
- if (typeof id === "object") {
- const dialogData = _dialogObjects.get(id);
- if (dialogData !== undefined) {
- return dialogData.id;
- }
- }
-
- return id.toString();
- },
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {};
- },
-};
-
-export = UiDialog;
-
-interface DialogInternalData {
- id: string;
-}
-
-type ElementId = string;
-
-type ElementIdOrCallbackObject = DialogCallbackObject | ElementId;
-
-interface InternalDialogOptions extends DialogOptions {
- backdropCloseOnClick: boolean;
- closable: boolean;
- closeButtonLabel: string;
- closeConfirmMessage: string;
- disableContentPadding: boolean;
-}
+++ /dev/null
-import { RequestPayload, ResponseData } from "../../Ajax/Data";
-
-export type DialogHtml = DocumentFragment | string | null;
-
-export type DialogCallbackSetup = () => DialogSettings;
-export type CallbackSubmit = () => void;
-
-export interface DialogCallbackObject {
- _dialogSetup: DialogCallbackSetup;
- _dialogSubmit?: CallbackSubmit;
-}
-
-export interface AjaxInitialization extends RequestPayload {
- after?: (content: HTMLElement, responseData: ResponseData) => void;
-}
-
-export type ExternalInitialization = () => void;
-
-export type DialogId = string;
-
-export interface DialogSettings {
- id: DialogId;
- source?: AjaxInitialization | DocumentFragment | ExternalInitialization | string | null;
- options?: DialogOptions;
-}
-
-type CallbackOnBeforeClose = (id: string) => void;
-type CallbackOnClose = (id: string) => void;
-type CallbackOnSetup = (content: HTMLElement) => void;
-type CallbackOnShow = (content: HTMLElement) => void;
-
-export interface DialogOptions {
- backdropCloseOnClick?: boolean;
- closable?: boolean;
- closeButtonLabel?: string;
- closeConfirmMessage?: string;
- disableContentPadding?: boolean;
- title?: string;
-
- onBeforeClose?: CallbackOnBeforeClose | null;
- onClose?: CallbackOnClose | null;
- onSetup?: CallbackOnSetup | null;
- onShow?: CallbackOnShow | null;
-}
-
-export interface DialogData {
- backdropCloseOnClick: boolean;
- closable: boolean;
- content: HTMLElement;
- dialog: HTMLElement;
- header: HTMLElement;
-
- onBeforeClose: CallbackOnBeforeClose;
- onClose: CallbackOnClose;
- onShow: CallbackOnShow;
-
- submitButton: HTMLElement | null;
- inputFields: Set<HTMLInputElement>;
-}
+++ /dev/null
-/**
- * Generic interface for drag and Drop file uploads.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/DragAndDrop
- */
-
-import * as Core from "../Core";
-import * as EventHandler from "../Event/Handler";
-import { init, OnDropPayload, OnGlobalDropPayload, RedactorEditorLike } from "./Redactor/DragAndDrop";
-
-interface DragAndDropOptions {
- element: HTMLElement;
- elementId: string;
- onDrop: (data: OnDropPayload) => void;
- onGlobalDrop: (data: OnGlobalDropPayload) => void;
-}
-
-export function register(options: DragAndDropOptions): void {
- const uuid = Core.getUuid();
- options = Core.extend({
- element: null,
- elementId: "",
- onDrop: function (_data: OnDropPayload) {
- /* data: { file: File } */
- },
- onGlobalDrop: function (_data: OnGlobalDropPayload) {
- /* data: { cancelDrop: boolean, event: DragEvent } */
- },
- }) as DragAndDropOptions;
-
- EventHandler.add("com.woltlab.wcf.redactor2", `dragAndDrop_${options.elementId}`, options.onDrop);
- EventHandler.add("com.woltlab.wcf.redactor2", `dragAndDrop_globalDrop_${options.elementId}`, options.onGlobalDrop);
-
- init({
- uuid: uuid,
- $editor: [options.element],
- $element: [{ id: options.elementId }],
- } as RedactorEditorLike);
-}
+++ /dev/null
-/**
- * Simplified and consistent dropdown creation.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Dropdown/Builder
- */
-
-import * as Core from "../../Core";
-import UiDropdownSimple from "./Simple";
-
-const _validIconSizes = [16, 24, 32, 48, 64, 96, 144];
-
-function validateList(list: HTMLUListElement): void {
- if (!(list instanceof HTMLUListElement)) {
- throw new TypeError("Expected a reference to an <ul> element.");
- }
-
- if (!list.classList.contains("dropdownMenu")) {
- throw new Error("List does not appear to be a dropdown menu.");
- }
-}
-
-function buildItemFromData(data: DropdownBuilderItemData): HTMLLIElement {
- const item = document.createElement("li");
-
- // handle special `divider` type
- if (data === "divider") {
- item.className = "dropdownDivider";
- return item;
- }
-
- if (typeof data.identifier === "string") {
- item.dataset.identifier = data.identifier;
- }
-
- const link = document.createElement("a");
- link.href = typeof data.href === "string" ? data.href : "#";
- if (typeof data.callback === "function") {
- link.addEventListener("click", (event) => {
- event.preventDefault();
-
- data.callback!(link);
- });
- } else if (link.href === "#") {
- throw new Error("Expected either a `href` value or a `callback`.");
- }
-
- if (data.attributes && Core.isPlainObject(data.attributes)) {
- Object.keys(data.attributes).forEach((key) => {
- const value = data.attributes![key];
- if (typeof (value as any) !== "string") {
- throw new Error("Expected only string values.");
- }
-
- // Support the dash notation for backwards compatibility.
- if (key.indexOf("-") !== -1) {
- link.setAttribute(`data-${key}`, value);
- } else {
- link.dataset[key] = value;
- }
- });
- }
-
- item.appendChild(link);
-
- if (typeof data.icon !== "undefined" && Core.isPlainObject(data.icon)) {
- if (typeof (data.icon.name as any) !== "string") {
- throw new TypeError("Expected a valid icon name.");
- }
-
- let size = 16;
- if (typeof data.icon.size === "number" && _validIconSizes.indexOf(~~data.icon.size) !== -1) {
- size = ~~data.icon.size;
- }
-
- const icon = document.createElement("span");
- icon.className = `icon icon${size} fa-${data.icon.name}`;
-
- link.appendChild(icon);
- }
-
- const label = typeof (data.label as any) === "string" ? data.label!.trim() : "";
- const labelHtml = typeof (data.labelHtml as any) === "string" ? data.labelHtml!.trim() : "";
- if (label === "" && labelHtml === "") {
- throw new TypeError("Expected either a label or a `labelHtml`.");
- }
-
- const span = document.createElement("span");
- span[label ? "textContent" : "innerHTML"] = label ? label : labelHtml;
- link.appendChild(document.createTextNode(" "));
- link.appendChild(span);
-
- return item;
-}
-
-/**
- * Creates a new dropdown menu, optionally pre-populated with the supplied list of
- * dropdown items. The list element will be returned and must be manually injected
- * into the DOM by the callee.
- */
-export function create(items: DropdownBuilderItemData[], identifier?: string): HTMLUListElement {
- const list = document.createElement("ul");
- list.className = "dropdownMenu";
- if (typeof identifier === "string") {
- list.dataset.identifier = identifier;
- }
-
- if (Array.isArray(items) && items.length > 0) {
- appendItems(list, items);
- }
-
- return list;
-}
-
-/**
- * Creates a new dropdown item that can be inserted into lists using regular DOM operations.
- */
-export function buildItem(item: DropdownBuilderItemData): HTMLLIElement {
- return buildItemFromData(item);
-}
-
-/**
- * Appends a single item to the target list.
- */
-export function appendItem(list: HTMLUListElement, item: DropdownBuilderItemData): void {
- validateList(list);
-
- list.appendChild(buildItemFromData(item));
-}
-
-/**
- * Appends a list of items to the target list.
- */
-export function appendItems(list: HTMLUListElement, items: DropdownBuilderItemData[]): void {
- validateList(list);
-
- if (!Array.isArray(items)) {
- throw new TypeError("Expected an array of items.");
- }
-
- const length = items.length;
- if (length === 0) {
- throw new Error("Expected a non-empty list of items.");
- }
-
- if (length === 1) {
- appendItem(list, items[0]);
- } else {
- const fragment = document.createDocumentFragment();
- items.forEach((item) => {
- fragment.appendChild(buildItemFromData(item));
- });
- list.appendChild(fragment);
- }
-}
-
-/**
- * Replaces the existing list items with the provided list of new items.
- */
-export function setItems(list: HTMLUListElement, items: DropdownBuilderItemData[]): void {
- validateList(list);
-
- list.innerHTML = "";
-
- appendItems(list, items);
-}
-
-/**
- * Attaches the list to a button, visibility is from then on controlled through clicks
- * on the provided button element. Internally calls `Ui/SimpleDropdown.initFragment()`
- * to delegate the DOM management.
- */
-export function attach(list: HTMLUListElement, button: HTMLElement): void {
- validateList(list);
-
- UiDropdownSimple.initFragment(button, list);
-
- button.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
-
- UiDropdownSimple.toggleDropdown(button.id);
- });
-}
-
-/**
- * Helper method that returns the special string `"divider"` that causes a divider to
- * be created.
- */
-export function divider(): string {
- return "divider";
-}
-
-interface BaseItemData {
- attributes?: {
- [key: string]: string;
- };
- callback?: (link: HTMLAnchorElement) => void;
- href?: string;
- icon?: {
- name: string;
- size?: 16 | 24 | 32 | 48 | 64 | 96 | 144;
- };
- identifier?: string;
- label?: string;
- labelHtml?: string;
-}
-
-interface TextItemData extends BaseItemData {
- label: string;
- labelHtml?: never;
-}
-
-interface HtmlItemData extends BaseItemData {
- label?: never;
- labelHtml: string;
-}
-
-export type DropdownBuilderItemData = "divider" | HtmlItemData | TextItemData;
+++ /dev/null
-export type NotificationAction = "close" | "open";
-export type NotificationCallback = (containerId: string, action: NotificationAction) => void;
+++ /dev/null
-/**
- * Simple interface to work with reusable dropdowns that are not bound to a specific item.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Ui/ReusableDropdown (alias)
- * @module WoltLabSuite/Core/Ui/Dropdown/Reusable
- */
-
-import UiDropdownSimple from "./Simple";
-import { NotificationCallback } from "./Data";
-
-const _dropdowns = new Map<string, string>();
-let _ghostElementId = 0;
-
-/**
- * Returns dropdown name by internal identifier.
- */
-function getDropdownName(identifier: string): string {
- if (!_dropdowns.has(identifier)) {
- throw new Error("Unknown dropdown identifier '" + identifier + "'");
- }
-
- return _dropdowns.get(identifier)!;
-}
-
-/**
- * Initializes a new reusable dropdown.
- */
-export function init(identifier: string, menu: HTMLElement): void {
- if (_dropdowns.has(identifier)) {
- return;
- }
-
- const ghostElement = document.createElement("div");
- ghostElement.id = `reusableDropdownGhost${_ghostElementId++}`;
-
- UiDropdownSimple.initFragment(ghostElement, menu);
-
- _dropdowns.set(identifier, ghostElement.id);
-}
-
-/**
- * Returns the dropdown menu element.
- */
-export function getDropdownMenu(identifier: string): HTMLElement {
- return UiDropdownSimple.getDropdownMenu(getDropdownName(identifier))!;
-}
-
-/**
- * Registers a callback invoked upon open and close.
- */
-export function registerCallback(identifier: string, callback: NotificationCallback): void {
- UiDropdownSimple.registerCallback(getDropdownName(identifier), callback);
-}
-
-/**
- * Toggles a dropdown.
- */
-export function toggleDropdown(identifier: string, referenceElement: HTMLElement): void {
- UiDropdownSimple.toggleDropdown(getDropdownName(identifier), referenceElement);
-}
+++ /dev/null
-/**
- * Simple drop-down implementation.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Ui/SimpleDropdown (alias)
- * @module WoltLabSuite/Core/Ui/Dropdown/Simple
- */
-
-import CallbackList from "../../CallbackList";
-import * as Core from "../../Core";
-import DomChangeListener from "../../Dom/Change/Listener";
-import * as DomTraverse from "../../Dom/Traverse";
-import DomUtil from "../../Dom/Util";
-import * as UiAlignment from "../Alignment";
-import UiCloseOverlay from "../CloseOverlay";
-import { AllowFlip } from "../Alignment";
-import { NotificationAction, NotificationCallback } from "./Data";
-
-let _availableDropdowns: HTMLCollectionOf<HTMLElement>;
-const _callbacks = new CallbackList();
-let _didInit = false;
-const _dropdowns = new Map<string, HTMLElement>();
-const _menus = new Map<string, HTMLElement>();
-let _menuContainer: HTMLElement;
-let _activeTargetId = "";
-
-/**
- * Handles drop-down positions in overlays when scrolling in the overlay.
- */
-function onDialogScroll(event: WheelEvent): void {
- const dialogContent = event.currentTarget as HTMLElement;
- const dropdowns = dialogContent.querySelectorAll(".dropdown.dropdownOpen");
-
- for (let i = 0, length = dropdowns.length; i < length; i++) {
- const dropdown = dropdowns[i];
- const containerId = DomUtil.identify(dropdown);
- const offset = DomUtil.offset(dropdown);
- const dialogOffset = DomUtil.offset(dialogContent);
-
- // check if dropdown toggle is still (partially) visible
- if (offset.top + dropdown.clientHeight <= dialogOffset.top) {
- // top check
- UiDropdownSimple.toggleDropdown(containerId);
- } else if (offset.top >= dialogOffset.top + dialogContent.offsetHeight) {
- // bottom check
- UiDropdownSimple.toggleDropdown(containerId);
- } else if (offset.left <= dialogOffset.left) {
- // left check
- UiDropdownSimple.toggleDropdown(containerId);
- } else if (offset.left >= dialogOffset.left + dialogContent.offsetWidth) {
- // right check
- UiDropdownSimple.toggleDropdown(containerId);
- } else {
- UiDropdownSimple.setAlignment(_dropdowns.get(containerId)!, _menus.get(containerId)!);
- }
- }
-}
-
-/**
- * Recalculates drop-down positions on page scroll.
- */
-function onScroll() {
- _dropdowns.forEach((dropdown, containerId) => {
- if (dropdown.classList.contains("dropdownOpen")) {
- if (Core.stringToBool(dropdown.dataset.isOverlayDropdownButton || "")) {
- UiDropdownSimple.setAlignment(dropdown, _menus.get(containerId)!);
- } else {
- const menu = _menus.get(dropdown.id) as HTMLElement;
- if (!Core.stringToBool(menu.dataset.dropdownIgnorePageScroll || "")) {
- UiDropdownSimple.close(containerId);
- }
- }
- }
- });
-}
-
-/**
- * Notifies callbacks on status change.
- */
-function notifyCallbacks(containerId: string, action: NotificationAction): void {
- _callbacks.forEach(containerId, (callback) => {
- callback(containerId, action);
- });
-}
-
-/**
- * Toggles the drop-down's state between open and close.
- */
-function toggle(
- event: KeyboardEvent | MouseEvent | null,
- targetId?: string,
- alternateElement?: HTMLElement,
- disableAutoFocus?: boolean,
-): boolean {
- if (event !== null) {
- event.preventDefault();
- event.stopPropagation();
-
- const target = event.currentTarget as HTMLElement;
- targetId = target.dataset.target;
-
- if (disableAutoFocus === undefined && event instanceof MouseEvent) {
- disableAutoFocus = true;
- }
- }
-
- let dropdown = _dropdowns.get(targetId!) as HTMLElement;
- let preventToggle = false;
- if (dropdown !== undefined) {
- let button, parent;
-
- // check if the dropdown is still the same, as some components (e.g. page actions)
- // re-create the parent of a button
- if (event) {
- button = event.currentTarget;
- parent = button.parentNode;
- if (parent !== dropdown) {
- parent.classList.add("dropdown");
- parent.id = dropdown.id;
-
- // remove dropdown class and id from old parent
- dropdown.classList.remove("dropdown");
- dropdown.id = "";
-
- dropdown = parent;
- _dropdowns.set(targetId!, parent);
- }
- }
-
- if (disableAutoFocus === undefined) {
- button = dropdown.closest(".dropdownToggle");
- if (!button) {
- button = dropdown.querySelector(".dropdownToggle");
-
- if (!button && dropdown.id) {
- button = document.querySelector('[data-target="' + dropdown.id + '"]');
- }
- }
-
- if (button && Core.stringToBool(button.dataset.dropdownLazyInit || "")) {
- disableAutoFocus = true;
- }
- }
-
- // Repeated clicks on the dropdown button will not cause it to close, the only way
- // to close it is by clicking somewhere else in the document or on another dropdown
- // toggle. This is used with the search bar to prevent the dropdown from closing by
- // setting the caret position in the search input field.
- if (
- Core.stringToBool(dropdown.dataset.dropdownPreventToggle || "") &&
- dropdown.classList.contains("dropdownOpen")
- ) {
- preventToggle = true;
- }
-
- // check if 'isOverlayDropdownButton' is set which indicates that the dropdown toggle is within an overlay
- if (dropdown.dataset.isOverlayDropdownButton === "") {
- const dialogContent = DomTraverse.parentByClass(dropdown, "dialogContent");
- dropdown.dataset.isOverlayDropdownButton = dialogContent !== null ? "true" : "false";
-
- if (dialogContent !== null) {
- dialogContent.addEventListener("scroll", onDialogScroll);
- }
- }
- }
-
- // close all dropdowns
- _activeTargetId = "";
- _dropdowns.forEach((dropdown, containerId) => {
- const menu = _menus.get(containerId) as HTMLElement;
- let firstListItem: HTMLLIElement | null = null;
-
- if (dropdown.classList.contains("dropdownOpen")) {
- if (!preventToggle) {
- dropdown.classList.remove("dropdownOpen");
- menu.classList.remove("dropdownOpen");
-
- const button = dropdown.querySelector(".dropdownToggle");
- if (button) button.setAttribute("aria-expanded", "false");
-
- notifyCallbacks(containerId, "close");
- } else {
- _activeTargetId = targetId!;
- }
- } else if (containerId === targetId && menu.childElementCount > 0) {
- _activeTargetId = targetId;
- dropdown.classList.add("dropdownOpen");
- menu.classList.add("dropdownOpen");
-
- const button = dropdown.querySelector(".dropdownToggle");
- if (button) button.setAttribute("aria-expanded", "true");
-
- const list: HTMLElement | null = menu.childElementCount > 0 ? (menu.children[0] as HTMLElement) : null;
- if (list && Core.stringToBool(list.dataset.scrollToActive || "")) {
- delete list.dataset.scrollToActive;
-
- let active: HTMLElement | null = null;
- for (let i = 0, length = list.childElementCount; i < length; i++) {
- if (list.children[i].classList.contains("active")) {
- active = list.children[i] as HTMLElement;
- break;
- }
- }
-
- if (active) {
- list.scrollTop = Math.max(active.offsetTop + active.clientHeight - menu.clientHeight, 0);
- }
- }
-
- const itemList = menu.querySelector(".scrollableDropdownMenu");
- if (itemList !== null) {
- itemList.classList[itemList.scrollHeight > itemList.clientHeight ? "add" : "remove"]("forceScrollbar");
- }
-
- notifyCallbacks(containerId, "open");
-
- if (!disableAutoFocus) {
- menu.setAttribute("role", "menu");
- menu.tabIndex = -1;
- menu.removeEventListener("keydown", dropdownMenuKeyDown);
- menu.addEventListener("keydown", dropdownMenuKeyDown);
- menu.querySelectorAll("li").forEach((listItem) => {
- if (!listItem.clientHeight) return;
- if (firstListItem === null) firstListItem = listItem;
- else if (listItem.classList.contains("active")) firstListItem = listItem;
-
- listItem.setAttribute("role", "menuitem");
- listItem.tabIndex = -1;
- });
- }
-
- UiDropdownSimple.setAlignment(dropdown, menu, alternateElement);
-
- if (firstListItem !== null) {
- firstListItem.focus();
- }
- }
- });
-
- window.WCF.Dropdown.Interactive.Handler.closeAll();
-
- return event === null;
-}
-
-function handleKeyDown(event: KeyboardEvent): void {
- // <input> elements are not valid targets for drop-down menus. However, some developers
- // might still decide to combine them, in which case we try not to break things even more.
- const target = event.currentTarget as HTMLElement;
- if (target.nodeName === "INPUT") {
- return;
- }
-
- if (event.key === "Enter" || event.key === "Space") {
- event.preventDefault();
- toggle(event);
- }
-}
-
-function dropdownMenuKeyDown(event: KeyboardEvent): void {
- const activeItem = document.activeElement as HTMLElement;
- if (activeItem.nodeName !== "LI") {
- return;
- }
-
- if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "End" || event.key === "Home") {
- event.preventDefault();
-
- const listItems: HTMLElement[] = Array.from(activeItem.closest(".dropdownMenu")!.querySelectorAll("li"));
- if (event.key === "ArrowUp" || event.key === "End") {
- listItems.reverse();
- }
-
- let newActiveItem: HTMLElement | null = null;
- const isValidItem = (listItem) => {
- return !listItem.classList.contains("dropdownDivider") && listItem.clientHeight > 0;
- };
-
- let activeIndex = listItems.indexOf(activeItem);
- if (event.key === "End" || event.key === "Home") {
- activeIndex = -1;
- }
-
- for (let i = activeIndex + 1; i < listItems.length; i++) {
- if (isValidItem(listItems[i])) {
- newActiveItem = listItems[i];
- break;
- }
- }
-
- if (newActiveItem === null) {
- newActiveItem = listItems.find(isValidItem) || null;
- }
-
- if (newActiveItem !== null) {
- newActiveItem.focus();
- }
- } else if (event.key === "Enter" || event.key === "Space") {
- event.preventDefault();
-
- let target = activeItem;
- if (
- target.childElementCount === 1 &&
- (target.children[0].nodeName === "SPAN" || target.children[0].nodeName === "A")
- ) {
- target = target.children[0] as HTMLElement;
- }
-
- const dropdown = _dropdowns.get(_activeTargetId)!;
- const button = dropdown.querySelector(".dropdownToggle") as HTMLElement;
-
- const mouseEvent = dropdown.dataset.a11yMouseEvent || "click";
- Core.triggerEvent(target, mouseEvent);
-
- if (button) {
- button.focus();
- }
- } else if (event.key === "Escape" || event.key === "Tab") {
- event.preventDefault();
-
- const dropdown = _dropdowns.get(_activeTargetId)!;
- let button: HTMLElement | null = dropdown.querySelector(".dropdownToggle");
-
- // Remote controlled drop-down menus may not have a dedicated toggle button, instead the
- // `dropdown` element itself is the button.
- if (button === null && !dropdown.classList.contains("dropdown")) {
- button = dropdown;
- }
-
- toggle(null, _activeTargetId);
- if (button) {
- button.focus();
- }
- }
-}
-
-const UiDropdownSimple = {
- /**
- * Performs initial setup such as setting up dropdowns and binding listeners.
- */
- setup(): void {
- if (_didInit) return;
- _didInit = true;
-
- _menuContainer = document.createElement("div");
- _menuContainer.className = "dropdownMenuContainer";
- document.body.appendChild(_menuContainer);
-
- _availableDropdowns = document.getElementsByClassName("dropdownToggle") as HTMLCollectionOf<HTMLElement>;
-
- UiDropdownSimple.initAll();
-
- UiCloseOverlay.add("WoltLabSuite/Core/Ui/Dropdown/Simple", () => UiDropdownSimple.closeAll());
- DomChangeListener.add("WoltLabSuite/Core/Ui/Dropdown/Simple", () => UiDropdownSimple.initAll());
-
- document.addEventListener("scroll", onScroll);
-
- // expose on window object for backward compatibility
- window.bc_wcfSimpleDropdown = this;
- },
-
- /**
- * Loops through all possible dropdowns and registers new ones.
- */
- initAll(): void {
- for (let i = 0, length = _availableDropdowns.length; i < length; i++) {
- UiDropdownSimple.init(_availableDropdowns[i], false);
- }
- },
-
- /**
- * Initializes a dropdown.
- */
- init(button: HTMLElement, isLazyInitialization?: boolean | MouseEvent): boolean {
- UiDropdownSimple.setup();
-
- button.setAttribute("role", "button");
- button.tabIndex = 0;
- button.setAttribute("aria-haspopup", "true");
- button.setAttribute("aria-expanded", "false");
-
- if (button.classList.contains("jsDropdownEnabled") || button.dataset.target) {
- return false;
- }
-
- const dropdown = DomTraverse.parentByClass(button, "dropdown") as HTMLElement;
- if (dropdown === null) {
- throw new Error(
- "Invalid dropdown passed, button '" + DomUtil.identify(button) + "' does not have a parent with .dropdown.",
- );
- }
-
- const menu = DomTraverse.nextByClass(button, "dropdownMenu") as HTMLElement;
- if (menu === null) {
- throw new Error(
- "Invalid dropdown passed, button '" + DomUtil.identify(button) + "' does not have a menu as next sibling.",
- );
- }
-
- // move menu into global container
- _menuContainer.appendChild(menu);
-
- const containerId = DomUtil.identify(dropdown);
- if (!_dropdowns.has(containerId)) {
- button.classList.add("jsDropdownEnabled");
- button.addEventListener("click", toggle);
- button.addEventListener("keydown", handleKeyDown);
-
- _dropdowns.set(containerId, dropdown);
- _menus.set(containerId, menu);
-
- if (!/^wcf\d+$/.test(containerId)) {
- menu.dataset.source = containerId;
- }
-
- // prevent page scrolling
- if (menu.childElementCount && menu.children[0].classList.contains("scrollableDropdownMenu")) {
- const child = menu.children[0] as HTMLElement;
- child.dataset.scrollToActive = "true";
-
- let menuHeight: number | null = null;
- let menuRealHeight: number | null = null;
- child.addEventListener(
- "wheel",
- (event) => {
- if (menuHeight === null) menuHeight = child.clientHeight;
- if (menuRealHeight === null) menuRealHeight = child.scrollHeight;
-
- // negative value: scrolling up
- if (event.deltaY < 0 && child.scrollTop === 0) {
- event.preventDefault();
- } else if (event.deltaY > 0 && child.scrollTop + menuHeight === menuRealHeight) {
- event.preventDefault();
- }
- },
- { passive: false },
- );
- }
- }
-
- button.dataset.target = containerId;
-
- if (isLazyInitialization) {
- setTimeout(() => {
- button.dataset.dropdownLazyInit = isLazyInitialization instanceof MouseEvent ? "true" : "false";
-
- Core.triggerEvent(button, "click");
-
- setTimeout(() => {
- delete button.dataset.dropdownLazyInit;
- }, 10);
- }, 10);
- }
-
- return true;
- },
-
- /**
- * Initializes a remote-controlled dropdown.
- */
- initFragment(dropdown: HTMLElement, menu: HTMLElement): void {
- UiDropdownSimple.setup();
-
- const containerId = DomUtil.identify(dropdown);
- if (_dropdowns.has(containerId)) {
- return;
- }
-
- _dropdowns.set(containerId, dropdown);
- _menuContainer.appendChild(menu);
-
- _menus.set(containerId, menu);
- },
-
- /**
- * Registers a callback for open/close events.
- */
- registerCallback(containerId: string, callback: NotificationCallback): void {
- _callbacks.add(containerId, callback);
- },
-
- /**
- * Returns the requested dropdown wrapper element.
- */
- getDropdown(containerId: string): HTMLElement | undefined {
- return _dropdowns.get(containerId);
- },
-
- /**
- * Returns the requested dropdown menu list element.
- */
- getDropdownMenu(containerId: string): HTMLElement | undefined {
- return _menus.get(containerId);
- },
-
- /**
- * Toggles the requested dropdown between opened and closed.
- */
- toggleDropdown(containerId: string, referenceElement?: HTMLElement, disableAutoFocus?: boolean): void {
- toggle(null, containerId, referenceElement, disableAutoFocus);
- },
-
- /**
- * Calculates and sets the alignment of given dropdown.
- */
- setAlignment(dropdown: HTMLElement, dropdownMenu: HTMLElement, alternateElement?: HTMLElement): void {
- // check if button belongs to an i18n textarea
- const button = dropdown.querySelector(".dropdownToggle");
- const parent = button !== null ? (button.parentNode as HTMLElement) : null;
- let refDimensionsElement;
- if (parent && parent.classList.contains("inputAddonTextarea")) {
- refDimensionsElement = button;
- }
-
- UiAlignment.set(dropdownMenu, alternateElement || dropdown, {
- pointerClassNames: ["dropdownArrowBottom", "dropdownArrowRight"],
- refDimensionsElement: refDimensionsElement || null,
-
- // alignment
- horizontal: dropdownMenu.dataset.dropdownAlignmentHorizontal === "right" ? "right" : "left",
- vertical: dropdownMenu.dataset.dropdownAlignmentVertical === "top" ? "top" : "bottom",
-
- allowFlip: (dropdownMenu.dataset.dropdownAllowFlip as AllowFlip) || "both",
- });
- },
-
- /**
- * Calculates and sets the alignment of the dropdown identified by given id.
- */
- setAlignmentById(containerId: string): void {
- const dropdown = _dropdowns.get(containerId);
- if (dropdown === undefined) {
- throw new Error("Unknown dropdown identifier '" + containerId + "'.");
- }
-
- const menu = _menus.get(containerId) as HTMLElement;
-
- UiDropdownSimple.setAlignment(dropdown, menu);
- },
-
- /**
- * Returns true if target dropdown exists and is open.
- */
- isOpen(containerId: string): boolean {
- const menu = _menus.get(containerId);
- return menu !== undefined && menu.classList.contains("dropdownOpen");
- },
-
- /**
- * Opens the dropdown unless it is already open.
- */
- open(containerId: string, disableAutoFocus?: boolean): void {
- const menu = _menus.get(containerId);
- if (menu !== undefined && !menu.classList.contains("dropdownOpen")) {
- UiDropdownSimple.toggleDropdown(containerId, undefined, disableAutoFocus);
- }
- },
-
- /**
- * Closes the dropdown identified by given id without notifying callbacks.
- */
- close(containerId: string): void {
- const dropdown = _dropdowns.get(containerId);
- if (dropdown !== undefined) {
- dropdown.classList.remove("dropdownOpen");
- _menus.get(containerId)!.classList.remove("dropdownOpen");
- }
- },
-
- /**
- * Closes all dropdowns.
- */
- closeAll(): void {
- _dropdowns.forEach((dropdown, containerId) => {
- if (dropdown.classList.contains("dropdownOpen")) {
- dropdown.classList.remove("dropdownOpen");
- _menus.get(containerId)!.classList.remove("dropdownOpen");
-
- notifyCallbacks(containerId, "close");
- }
- });
- },
-
- /**
- * Destroys a dropdown identified by given id.
- */
- destroy(containerId: string): boolean {
- if (!_dropdowns.has(containerId)) {
- return false;
- }
-
- try {
- UiDropdownSimple.close(containerId);
-
- _menus.get(containerId)?.remove();
- } catch (e) {
- // the elements might not exist anymore thus ignore all errors while cleaning up
- }
-
- _menus.delete(containerId);
- _dropdowns.delete(containerId);
-
- return true;
- },
-
- // Legacy call required for `WCF.Dropdown`
- _toggle(
- event: KeyboardEvent | MouseEvent | null,
- targetId?: string,
- alternateElement?: HTMLElement,
- disableAutoFocus?: boolean,
- ): boolean {
- return toggle(event, targetId, alternateElement, disableAutoFocus);
- },
-};
-
-export = UiDropdownSimple;
+++ /dev/null
-// This helper interface exists to prevent a circular dependency
-// between `./Delete` and `./Upload`
-
-export interface FileUploadHandler {
- checkMaxFiles(): void;
-}
+++ /dev/null
-/**
- * Delete files which are uploaded via AJAX.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/File/Delete
- * @since 5.2
- */
-
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../Ajax/Data";
-import * as Core from "../../Core";
-import DomChangeListener from "../../Dom/Change/Listener";
-import * as Language from "../../Language";
-import { FileUploadHandler } from "./Data";
-
-interface AjaxResponse extends DatabaseObjectActionResponse {
- uniqueFileId: string;
-}
-
-interface ElementData {
- uniqueFileId: string;
- element: HTMLElement;
-}
-
-class UiFileDelete implements AjaxCallbackObject {
- private readonly buttonContainer: HTMLElement;
- private readonly containers = new Map<string, ElementData>();
- private deleteButton?: HTMLElement = undefined;
- private readonly internalId: string;
- private readonly isSingleImagePreview: boolean;
- private readonly target: HTMLElement;
- private readonly uploadHandler: FileUploadHandler;
-
- constructor(
- buttonContainerId: string,
- targetId: string,
- isSingleImagePreview: boolean,
- uploadHandler: FileUploadHandler,
- ) {
- this.isSingleImagePreview = isSingleImagePreview;
- this.uploadHandler = uploadHandler;
-
- const buttonContainer = document.getElementById(buttonContainerId);
- if (buttonContainer === null) {
- throw new Error(`Element id '${buttonContainerId}' is unknown.`);
- }
- this.buttonContainer = buttonContainer;
-
- const target = document.getElementById(targetId);
- if (target === null) {
- throw new Error(`Element id '${targetId}' is unknown.`);
- }
- this.target = target;
-
- const internalId = this.target.dataset.internalId;
- if (!internalId) {
- throw new Error("InternalId is unknown.");
- }
- this.internalId = internalId;
-
- this.rebuild();
- }
-
- /**
- * Creates the upload button.
- */
- private createButtons(): void {
- let triggerChange = false;
- this.target.querySelectorAll("li.uploadedFile").forEach((element: HTMLElement) => {
- const uniqueFileId = element.dataset.uniqueFileId!;
- if (this.containers.has(uniqueFileId)) {
- return;
- }
-
- const elementData: ElementData = {
- uniqueFileId: uniqueFileId,
- element: element,
- };
-
- this.containers.set(uniqueFileId, elementData);
- this.initDeleteButton(element, elementData);
-
- triggerChange = true;
- });
-
- if (triggerChange) {
- DomChangeListener.trigger();
- }
- }
-
- /**
- * Init the delete button for a specific element.
- */
- private initDeleteButton(element: HTMLElement, elementData: ElementData): void {
- const buttonGroup = element.querySelector(".buttonGroup");
- if (buttonGroup === null) {
- throw new Error(`Button group in '${this.target.id}' is unknown.`);
- }
-
- const li = document.createElement("li");
- const span = document.createElement("span");
- span.className = "button jsDeleteButton small";
- span.textContent = Language.get("wcf.global.button.delete");
- li.appendChild(span);
- buttonGroup.appendChild(li);
-
- li.addEventListener("click", this.deleteElement.bind(this, elementData.uniqueFileId));
- }
-
- /**
- * Delete a specific file with the given uniqueFileId.
- */
- private deleteElement(uniqueFileId: string): void {
- Ajax.api(this, {
- uniqueFileId: uniqueFileId,
- internalId: this.internalId,
- });
- }
-
- /**
- * Rebuilds the delete buttons for unknown files.
- */
- rebuild(): void {
- if (!this.isSingleImagePreview) {
- this.createButtons();
- return;
- }
-
- const img = this.target.querySelector("img");
- if (img !== null) {
- const uniqueFileId = img.dataset.uniqueFileId!;
-
- if (!this.containers.has(uniqueFileId)) {
- const elementData = {
- uniqueFileId: uniqueFileId,
- element: img,
- };
-
- this.containers.set(uniqueFileId, elementData);
-
- this.deleteButton = document.createElement("p");
- this.deleteButton.className = "button deleteButton";
-
- const span = document.createElement("span");
- span.textContent = Language.get("wcf.global.button.delete");
- this.deleteButton.appendChild(span);
-
- this.buttonContainer.appendChild(this.deleteButton);
-
- this.deleteButton.addEventListener("click", this.deleteElement.bind(this, elementData.uniqueFileId));
- }
- }
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- const elementData = this.containers.get(data.uniqueFileId)!;
- elementData.element.remove();
-
- if (this.isSingleImagePreview && this.deleteButton) {
- this.deleteButton.remove();
- this.deleteButton = undefined;
- }
-
- this.uploadHandler.checkMaxFiles();
- Core.triggerEvent(this.target, "change");
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- url: "index.php?ajax-file-delete/&t=" + window.SECURITY_TOKEN,
- };
- }
-}
-
-Core.enableLegacyInheritance(UiFileDelete);
-
-export = UiFileDelete;
+++ /dev/null
-/**
- * Uploads file via AJAX.
- *
- * @author Joshua Ruesweg, Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/File/Upload
- * @since 5.2
- */
-
-import { ResponseData } from "../../Ajax/Data";
-import * as Core from "../../Core";
-import { FileCollection, FileLikeObject, UploadId, UploadOptions } from "../../Upload/Data";
-import { default as DeleteHandler } from "./Delete";
-import DomUtil from "../../Dom/Util";
-import * as Language from "../../Language";
-import Upload from "../../Upload";
-import { FileUploadHandler } from "./Data";
-
-interface FileUploadOptions extends UploadOptions {
- // image preview
- imagePreview: boolean;
- // max files
- maxFiles: number | null;
-
- internalId: string;
-}
-
-interface FileData {
- filesize: number;
- icon: string;
- image: string | null;
- uniqueFileId: string;
-}
-
-interface ErrorData {
- errorMessage: string;
-}
-
-interface AjaxResponse {
- error: ErrorData[];
- files: FileData[];
-}
-
-class FileUpload extends Upload<FileUploadOptions> implements FileUploadHandler {
- protected readonly _deleteHandler: DeleteHandler;
-
- constructor(buttonContainerId: string, targetId: string, options: Partial<FileUploadOptions>) {
- options = options || {};
-
- if (options.internalId === undefined) {
- throw new Error("Missing internal id.");
- }
-
- // set default options
- options = Core.extend(
- {
- // image preview
- imagePreview: false,
- // max files
- maxFiles: null,
- // Dummy value, because it is checked in the base method, without using it with this upload handler.
- className: "invalid",
- // url
- url: `index.php?ajax-file-upload/&t=${window.SECURITY_TOKEN}`,
- },
- options,
- );
-
- options.multiple = options.maxFiles === null || (options.maxFiles as number) > 1;
-
- super(buttonContainerId, targetId, options);
-
- this.checkMaxFiles();
-
- this._deleteHandler = new DeleteHandler(buttonContainerId, targetId, this._options.imagePreview, this);
- }
-
- protected _createFileElement(file: File | FileLikeObject): HTMLElement {
- const element = super._createFileElement(file);
- element.classList.add("box64", "uploadedFile");
-
- const progress = element.querySelector("progress") as HTMLProgressElement;
-
- const icon = document.createElement("span");
- icon.className = "icon icon64 fa-spinner";
-
- const fileName = element.textContent;
- element.textContent = "";
- element.append(icon);
-
- const innerDiv = document.createElement("div");
- const fileNameP = document.createElement("p");
- fileNameP.textContent = fileName; // file.name
-
- const smallProgress = document.createElement("small");
- smallProgress.appendChild(progress);
-
- innerDiv.appendChild(fileNameP);
- innerDiv.appendChild(smallProgress);
-
- const div = document.createElement("div");
- div.appendChild(innerDiv);
-
- const ul = document.createElement("ul");
- ul.className = "buttonGroup";
- div.appendChild(ul);
-
- // reset element textContent and replace with own element style
- element.append(div);
-
- return element;
- }
-
- protected _failure(uploadId: number, data: ResponseData): boolean {
- this._fileElements[uploadId].forEach((fileElement) => {
- fileElement.classList.add("uploadFailed");
-
- const small = fileElement.querySelector("small") as HTMLElement;
- small.innerHTML = "";
-
- const icon = fileElement.querySelector(".icon") as HTMLElement;
- icon.classList.remove("fa-spinner");
- icon.classList.add("fa-ban");
-
- const innerError = document.createElement("span");
- innerError.className = "innerError";
- innerError.textContent = Language.get("wcf.upload.error.uploadFailed");
- small.insertAdjacentElement("afterend", innerError);
- });
-
- throw new Error(`Upload failed: ${data.message as string}`);
- }
-
- protected _upload(event: Event): UploadId;
- protected _upload(event: null, file: File): UploadId;
- protected _upload(event: null, file: null, blob: Blob): UploadId;
- protected _upload(event: Event | null, file?: File | null, blob?: Blob | null): UploadId {
- const parent = this._buttonContainer.parentElement!;
- const innerError = parent.querySelector("small.innerError:not(.innerFileError)");
- if (innerError) {
- innerError.remove();
- }
-
- return super._upload(event, file, blob);
- }
-
- protected _success(uploadId: number, data: AjaxResponse): void {
- this._fileElements[uploadId].forEach((fileElement, index) => {
- if (data.files[index] !== undefined) {
- const fileData = data.files[index];
-
- if (this._options.imagePreview) {
- if (fileData.image === null) {
- throw new Error("Expect image for uploaded file. None given.");
- }
-
- fileElement.remove();
-
- const previewImage = this._target.querySelector("img.previewImage") as HTMLImageElement;
- if (previewImage !== null) {
- previewImage.src = fileData.image;
- } else {
- const image = document.createElement("img");
- image.classList.add("previewImage");
- image.src = fileData.image;
- image.style.setProperty("max-width", "100%", "");
- image.dataset.uniqueFileId = fileData.uniqueFileId;
- this._target.appendChild(image);
- }
- } else {
- fileElement.dataset.uniqueFileId = fileData.uniqueFileId;
- fileElement.querySelector("small")!.textContent = fileData.filesize.toString();
-
- const icon = fileElement.querySelector(".icon") as HTMLElement;
- icon.classList.remove("fa-spinner");
- icon.classList.add(`fa-${fileData.icon}`);
- }
- } else if (data.error[index] !== undefined) {
- const errorData = data["error"][index];
-
- fileElement.classList.add("uploadFailed");
-
- const small = fileElement.querySelector("small") as HTMLElement;
- small.innerHTML = "";
-
- const icon = fileElement.querySelector(".icon") as HTMLElement;
- icon.classList.remove("fa-spinner");
- icon.classList.add("fa-ban");
-
- let innerError = fileElement.querySelector(".innerError") as HTMLElement;
- if (innerError === null) {
- innerError = document.createElement("span");
- innerError.className = "innerError";
- innerError.textContent = errorData.errorMessage;
-
- small.insertAdjacentElement("afterend", innerError);
- } else {
- innerError.textContent = errorData.errorMessage;
- }
- } else {
- throw new Error(`Unknown uploaded file for uploadId ${uploadId}.`);
- }
- });
-
- // create delete buttons
- this._deleteHandler.rebuild();
- this.checkMaxFiles();
- Core.triggerEvent(this._target, "change");
- }
-
- protected _getFormData(): ArbitraryObject {
- return {
- internalId: this._options.internalId,
- };
- }
-
- validateUpload(files: FileCollection): boolean {
- if (this._options.maxFiles === null || files.length + this.countFiles() <= this._options.maxFiles) {
- return true;
- } else {
- const parent = this._buttonContainer.parentElement!;
-
- let innerError = parent.querySelector("small.innerError:not(.innerFileError)");
- if (innerError === null) {
- innerError = document.createElement("small");
- innerError.className = "innerError";
- this._buttonContainer.insertAdjacentElement("afterend", innerError);
- }
-
- innerError.textContent = Language.get("wcf.upload.error.reachedRemainingLimit", {
- maxFiles: this._options.maxFiles - this.countFiles(),
- });
-
- return false;
- }
- }
-
- /**
- * Returns the count of the uploaded images.
- */
- countFiles(): number {
- if (this._options.imagePreview) {
- return this._target.querySelector("img") !== null ? 1 : 0;
- } else {
- return this._target.childElementCount;
- }
- }
-
- /**
- * Checks the maximum number of files and enables or disables the upload button.
- */
- checkMaxFiles(): void {
- if (this._options.maxFiles !== null && this.countFiles() >= this._options.maxFiles) {
- DomUtil.hide(this._button);
- } else {
- DomUtil.show(this._button);
- }
- }
-}
-
-Core.enableLegacyInheritance(FileUpload);
-
-export = FileUpload;
+++ /dev/null
-/**
- * Dynamically transforms menu-like structures to handle items exceeding the available width
- * by moving them into a separate dropdown.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/FlexibleMenu
- */
-
-import DomChangeListener from "../Dom/Change/Listener";
-import DomUtil from "../Dom/Util";
-import * as DomTraverse from "../Dom/Traverse";
-import UiDropdownSimple from "./Dropdown/Simple";
-
-const _containers = new Map<string, HTMLElement>();
-const _dropdowns = new Map<string, HTMLLIElement>();
-const _dropdownMenus = new Map<string, HTMLUListElement>();
-const _itemLists = new Map<string, HTMLUListElement>();
-
-/**
- * Register default menus and set up event listeners.
- */
-export function setup(): void {
- if (document.getElementById("mainMenu") !== null) {
- register("mainMenu");
- }
-
- const navigationHeader = document.querySelector(".navigationHeader");
- if (navigationHeader !== null) {
- register(DomUtil.identify(navigationHeader));
- }
-
- window.addEventListener("resize", rebuildAll);
- DomChangeListener.add("WoltLabSuite/Core/Ui/FlexibleMenu", registerTabMenus);
-}
-
-/**
- * Registers a menu by element id.
- */
-export function register(containerId: string): void {
- const container = document.getElementById(containerId);
- if (container === null) {
- throw "Expected a valid element id, '" + containerId + "' does not exist.";
- }
-
- if (_containers.has(containerId)) {
- return;
- }
-
- const list = DomTraverse.childByTag(container, "UL");
- if (list === null) {
- throw "Expected an <ul> element as child of container '" + containerId + "'.";
- }
-
- _containers.set(containerId, container);
- _itemLists.set(containerId, list);
-
- rebuild(containerId);
-}
-
-/**
- * Registers tab menus.
- */
-export function registerTabMenus(): void {
- document
- .querySelectorAll(".tabMenuContainer:not(.jsFlexibleMenuEnabled), .messageTabMenu:not(.jsFlexibleMenuEnabled)")
- .forEach((tabMenu) => {
- const nav = DomTraverse.childByTag(tabMenu, "NAV");
- if (nav !== null) {
- tabMenu.classList.add("jsFlexibleMenuEnabled");
- register(DomUtil.identify(nav));
- }
- });
-}
-
-/**
- * Rebuilds all menus, e.g. on window resize.
- */
-export function rebuildAll(): void {
- _containers.forEach((container, containerId) => {
- rebuild(containerId);
- });
-}
-
-/**
- * Rebuild the menu identified by given element id.
- */
-export function rebuild(containerId: string): void {
- const container = _containers.get(containerId);
- if (container === undefined) {
- throw "Expected a valid element id, '" + containerId + "' is unknown.";
- }
-
- const styles = window.getComputedStyle(container);
- const parent = container.parentNode as HTMLElement;
- let availableWidth = parent.clientWidth;
- availableWidth -= DomUtil.styleAsInt(styles, "margin-left");
- availableWidth -= DomUtil.styleAsInt(styles, "margin-right");
-
- const list = _itemLists.get(containerId)!;
- const items = DomTraverse.childrenByTag(list, "LI");
- let dropdown = _dropdowns.get(containerId);
- let dropdownWidth = 0;
- if (dropdown !== undefined) {
- // show all items for calculation
- for (let i = 0, length = items.length; i < length; i++) {
- const item = items[i];
- if (item.classList.contains("dropdown")) {
- continue;
- }
-
- DomUtil.show(item);
- }
- if (dropdown.parentNode !== null) {
- dropdownWidth = DomUtil.outerWidth(dropdown);
- }
- }
-
- const currentWidth = list.scrollWidth - dropdownWidth;
- const hiddenItems: HTMLLIElement[] = [];
- if (currentWidth > availableWidth) {
- // hide items starting with the last one
- for (let i = items.length - 1; i >= 0; i--) {
- const item = items[i];
-
- // ignore dropdown and active item
- if (
- item.classList.contains("dropdown") ||
- item.classList.contains("active") ||
- item.classList.contains("ui-state-active")
- ) {
- continue;
- }
-
- hiddenItems.push(item);
- DomUtil.hide(item);
-
- if (list.scrollWidth < availableWidth) {
- break;
- }
- }
- }
-
- if (hiddenItems.length) {
- let dropdownMenu: HTMLUListElement;
- if (dropdown === undefined) {
- dropdown = document.createElement("li");
- dropdown.className = "dropdown jsFlexibleMenuDropdown";
-
- const icon = document.createElement("a");
- icon.className = "icon icon16 fa-list";
- dropdown.appendChild(icon);
-
- dropdownMenu = document.createElement("ul");
- dropdownMenu.classList.add("dropdownMenu");
- dropdown.appendChild(dropdownMenu);
-
- _dropdowns.set(containerId, dropdown);
- _dropdownMenus.set(containerId, dropdownMenu);
- UiDropdownSimple.init(icon);
- } else {
- dropdownMenu = _dropdownMenus.get(containerId)!;
- }
-
- if (dropdown.parentNode === null) {
- list.appendChild(dropdown);
- }
-
- // build dropdown menu
- const fragment = document.createDocumentFragment();
- hiddenItems.forEach((hiddenItem) => {
- const item = document.createElement("li");
- item.innerHTML = hiddenItem.innerHTML;
-
- item.addEventListener("click", (event) => {
- event.preventDefault();
-
- hiddenItem.querySelector("a")?.click();
-
- // force a rebuild to guarantee the active item being visible
- setTimeout(() => {
- rebuild(containerId);
- }, 59);
- });
-
- fragment.appendChild(item);
- });
-
- dropdownMenu.innerHTML = "";
- dropdownMenu.appendChild(fragment);
- } else if (dropdown !== undefined && dropdown.parentNode !== null) {
- dropdown.remove();
- }
-}
+++ /dev/null
-/**
- * Flexible UI element featuring both a list of items and an input field with suggestion support.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/ItemList
- */
-
-import * as Core from "../Core";
-import * as DomTraverse from "../Dom/Traverse";
-import * as Language from "../Language";
-import UiSuggestion from "./Suggestion";
-import UiDropdownSimple from "./Dropdown/Simple";
-import { DatabaseObjectActionPayload } from "../Ajax/Data";
-import DomUtil from "../Dom/Util";
-
-const _data = new Map<string, ElementData>();
-
-/**
- * Creates the DOM structure for target element. If `element` is a `<textarea>`
- * it will be automatically replaced with an `<input>` element.
- */
-function createUI(element: ItemListInputElement, options: ItemListOptions): UiData {
- const parentElement = element.parentElement!;
-
- const list = document.createElement("ol");
- list.className = "inputItemList" + (element.disabled ? " disabled" : "");
- list.dataset.elementId = element.id;
- list.addEventListener("click", (event) => {
- if (event.target === list) {
- element.focus();
- }
- });
-
- const listItem = document.createElement("li");
- listItem.className = "input";
- list.appendChild(listItem);
- element.addEventListener("keydown", keyDown);
- element.addEventListener("keypress", keyPress);
- element.addEventListener("keyup", keyUp);
- element.addEventListener("paste", paste);
-
- const hasFocus = element === document.activeElement;
- if (hasFocus) {
- element.blur();
- }
- element.addEventListener("blur", blur);
- parentElement.insertBefore(list, element);
- listItem.appendChild(element);
-
- if (hasFocus) {
- window.setTimeout(() => {
- element.focus();
- }, 1);
- }
-
- if (options.maxLength !== -1) {
- element.maxLength = options.maxLength;
- }
-
- const limitReached = document.createElement("span");
- limitReached.className = "inputItemListLimitReached";
- limitReached.textContent = Language.get("wcf.global.form.input.maxItems");
- DomUtil.hide(limitReached);
- listItem.appendChild(limitReached);
-
- let shadow: HTMLInputElement | null = null;
- const values: string[] = [];
- if (options.isCSV) {
- shadow = document.createElement("input");
- shadow.className = "itemListInputShadow";
- shadow.type = "hidden";
- shadow.name = element.name;
- element.removeAttribute("name");
- list.parentNode!.insertBefore(shadow, list);
-
- element.value.split(",").forEach((value) => {
- value = value.trim();
- if (value) {
- values.push(value);
- }
- });
-
- if (element.nodeName === "TEXTAREA") {
- const inputElement = document.createElement("input");
- inputElement.type = "text";
- parentElement.insertBefore(inputElement, element);
- inputElement.id = element.id;
-
- element.remove();
- element = inputElement;
- }
- }
-
- return {
- element: element,
- limitReached: limitReached,
- list: list,
- shadow: shadow,
- values: values,
- };
-}
-
-/**
- * Returns true if the input accepts new items.
- */
-function acceptsNewItems(elementId: string): boolean {
- const data = _data.get(elementId)!;
- if (data.options.maxItems === -1) {
- return true;
- }
-
- return data.list.childElementCount - 1 < data.options.maxItems;
-}
-
-/**
- * Enforces the maximum number of items.
- */
-function handleLimit(elementId: string): void {
- const data = _data.get(elementId)!;
- if (acceptsNewItems(elementId)) {
- DomUtil.show(data.element);
- DomUtil.hide(data.limitReached);
- } else {
- DomUtil.hide(data.element);
- DomUtil.show(data.limitReached);
- }
-}
-
-/**
- * Sets the active item list id and handles keyboard access to remove an existing item.
- */
-function keyDown(event: KeyboardEvent): void {
- const input = event.currentTarget as HTMLInputElement;
-
- const lastItem = input.parentElement!.previousElementSibling as HTMLElement | null;
- if (event.key === "Backspace") {
- if (input.value.length === 0) {
- if (lastItem !== null) {
- if (lastItem.classList.contains("active")) {
- removeItem(lastItem);
- } else {
- lastItem.classList.add("active");
- }
- }
- }
- } else if (event.key === "Escape") {
- if (lastItem !== null && lastItem.classList.contains("active")) {
- lastItem.classList.remove("active");
- }
- }
-}
-
-/**
- * Handles the `[ENTER]` and `[,]` key to add an item to the list unless it is restricted.
- */
-function keyPress(event: KeyboardEvent): void {
- if (event.key === "Enter" || event.key === ",") {
- event.preventDefault();
-
- const input = event.currentTarget as HTMLInputElement;
- if (_data.get(input.id)!.options.restricted) {
- // restricted item lists only allow results from the dropdown to be picked
- return;
- }
- const value = input.value.trim();
- if (value.length) {
- addItem(input.id, { objectId: 0, value: value });
- }
- }
-}
-
-/**
- * Splits comma-separated values being pasted into the input field.
- */
-function paste(event: ClipboardEvent): void {
- event.preventDefault();
-
- const text = event.clipboardData!.getData("text/plain");
-
- const element = event.currentTarget as HTMLInputElement;
- const elementId = element.id;
- const maxLength = +element.maxLength;
- text.split(/,/).forEach((item) => {
- item = item.trim();
- if (maxLength && item.length > maxLength) {
- // truncating items provides a better UX than throwing an error or silently discarding it
- item = item.substr(0, maxLength);
- }
-
- if (item.length > 0 && acceptsNewItems(elementId)) {
- addItem(elementId, { objectId: 0, value: item });
- }
- });
-}
-
-/**
- * Handles the keyup event to unmark an item for deletion.
- */
-function keyUp(event: KeyboardEvent): void {
- const input = event.currentTarget as HTMLInputElement;
- if (input.value.length > 0) {
- const lastItem = input.parentElement!.previousElementSibling;
- if (lastItem !== null) {
- lastItem.classList.remove("active");
- }
- }
-}
-
-/**
- * Adds an item to the list.
- */
-function addItem(elementId: string, value: ItemData): void {
- const data = _data.get(elementId)!;
- const listItem = document.createElement("li");
- listItem.className = "item";
-
- const content = document.createElement("span");
- content.className = "content";
- content.dataset.objectId = value.objectId.toString();
- if (value.type) {
- content.dataset.type = value.type;
- }
- content.textContent = value.value;
- listItem.appendChild(content);
-
- if (!data.element.disabled) {
- const button = document.createElement("a");
- button.className = "icon icon16 fa-times";
- button.addEventListener("click", removeItem);
- listItem.appendChild(button);
- }
-
- data.list.insertBefore(listItem, data.listItem);
- data.suggestion.addExcludedValue(value.value);
- data.element.value = "";
- if (!data.element.disabled) {
- handleLimit(elementId);
- }
-
- let values = syncShadow(data);
- if (typeof data.options.callbackChange === "function") {
- if (values === null) {
- values = getValues(elementId);
- }
-
- data.options.callbackChange(elementId, values);
- }
-}
-
-/**
- * Removes an item from the list.
- */
-function removeItem(item: Event | HTMLElement, noFocus?: boolean): void {
- if (item instanceof Event) {
- const target = item.currentTarget as HTMLElement;
- item = target.parentElement!;
- }
-
- const parent = item.parentElement!;
- const elementId = parent.dataset.elementId || "";
- const data = _data.get(elementId)!;
- if (item.children[0].textContent) {
- data.suggestion.removeExcludedValue(item.children[0].textContent);
- }
-
- item.remove();
-
- if (!noFocus) {
- data.element.focus();
- }
-
- handleLimit(elementId);
-
- let values = syncShadow(data);
- if (typeof data.options.callbackChange === "function") {
- if (values === null) {
- values = getValues(elementId);
- }
-
- data.options.callbackChange(elementId, values);
- }
-}
-
-/**
- * Synchronizes the shadow input field with the current list item values.
- */
-function syncShadow(data: ElementData): ItemData[] | null {
- if (!data.options.isCSV) {
- return null;
- }
-
- if (typeof data.options.callbackSyncShadow === "function") {
- return data.options.callbackSyncShadow(data);
- }
-
- const values = getValues(data.element.id);
-
- data.shadow!.value = getValues(data.element.id)
- .map((value) => value.value)
- .join(",");
-
- return values;
-}
-
-/**
- * Handles the blur event.
- */
-function blur(event: FocusEvent): void {
- const input = event.currentTarget as HTMLInputElement;
- const data = _data.get(input.id)!;
-
- if (data.options.restricted) {
- // restricted item lists only allow results from the dropdown to be picked
- return;
- }
-
- const value = input.value.trim();
- if (value.length) {
- if (!data.suggestion || !data.suggestion.isActive()) {
- addItem(input.id, { objectId: 0, value: value });
- }
- }
-}
-
-/**
- * Initializes an item list.
- *
- * The `values` argument must be empty or contain a list of strings or object, e.g.
- * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
- */
-export function init(elementId: string, values: ItemDataOrPlainValue[], opts: Partial<ItemListOptions>): void {
- const element = document.getElementById(elementId) as ItemListInputElement;
- if (element === null) {
- throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
- }
-
- // remove data from previous instance
- if (_data.has(elementId)) {
- const tmp = _data.get(elementId)!;
- Object.keys(tmp).forEach((key) => {
- const el = tmp[key];
- if (el instanceof Element && el.parentNode) {
- el.remove();
- }
- });
-
- UiDropdownSimple.destroy(elementId);
- _data.delete(elementId);
- }
-
- const options = Core.extend(
- {
- // search parameters for suggestions
- ajax: {
- actionName: "getSearchResultList",
- className: "",
- data: {},
- },
- // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
- excludedSearchValues: [],
- // maximum number of items this list may contain, `-1` for infinite
- maxItems: -1,
- // maximum length of an item value, `-1` for infinite
- maxLength: -1,
- // disallow custom values, only values offered by the suggestion dropdown are accepted
- restricted: false,
- // initial value will be interpreted as comma separated value and submitted as such
- isCSV: false,
- // will be invoked whenever the items change, receives the element id first and list of values second
- callbackChange: null,
- // callback once the form is about to be submitted
- callbackSubmit: null,
- // Callback for the custom shadow synchronization.
- callbackSyncShadow: null,
- // Callback to set values during the setup.
- callbackSetupValues: null,
- // value may contain the placeholder `{$objectId}`
- submitFieldName: "",
- },
- opts,
- ) as ItemListOptions;
-
- const form = DomTraverse.parentByTag(element, "FORM") as HTMLFormElement;
- if (form !== null) {
- if (!options.isCSV) {
- if (!options.submitFieldName.length && typeof options.callbackSubmit !== "function") {
- throw new Error(
- "Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.",
- );
- }
-
- form.addEventListener("submit", () => {
- if (acceptsNewItems(elementId)) {
- const value = _data.get(elementId)!.element.value.trim();
- if (value.length) {
- addItem(elementId, { objectId: 0, value: value });
- }
- }
-
- const values = getValues(elementId);
- if (options.submitFieldName.length) {
- values.forEach((value) => {
- const input = document.createElement("input");
- input.type = "hidden";
- input.name = options.submitFieldName.replace("{$objectId}", value.objectId.toString());
- input.value = value.value;
- form.appendChild(input);
- });
- } else {
- options.callbackSubmit!(form, values);
- }
- });
- } else {
- form.addEventListener("submit", () => {
- if (acceptsNewItems(elementId)) {
- const value = _data.get(elementId)!.element.value.trim();
- if (value.length) {
- addItem(elementId, { objectId: 0, value: value });
- }
- }
- });
- }
- }
-
- const data = createUI(element, options);
-
- const suggestion = new UiSuggestion(elementId, {
- ajax: options.ajax as DatabaseObjectActionPayload,
- callbackSelect: addItem,
- excludedSearchValues: options.excludedSearchValues,
- });
-
- _data.set(elementId, {
- dropdownMenu: null,
- element: data.element,
- limitReached: data.limitReached,
- list: data.list,
- listItem: data.element.parentElement!,
- options: options,
- shadow: data.shadow,
- suggestion: suggestion,
- });
-
- if (options.callbackSetupValues) {
- values = options.callbackSetupValues();
- } else {
- values = data.values.length ? data.values : values;
- }
-
- if (Array.isArray(values)) {
- values.forEach((value) => {
- if (typeof value === "string") {
- value = { objectId: 0, value: value };
- }
-
- addItem(elementId, value);
- });
- }
-}
-
-/**
- * Returns the list of current values.
- */
-export function getValues(elementId: string): ItemData[] {
- const data = _data.get(elementId);
- if (!data) {
- throw new Error("Element id '" + elementId + "' is unknown.");
- }
-
- const values: ItemData[] = [];
- data.list.querySelectorAll(".item > span").forEach((span: HTMLSpanElement) => {
- values.push({
- objectId: +(span.dataset.objectId || ""),
- value: span.textContent!.trim(),
- type: span.dataset.type,
- });
- });
-
- return values;
-}
-
-/**
- * Sets the list of current values.
- */
-export function setValues(elementId: string, values: ItemData[]): void {
- const data = _data.get(elementId);
- if (!data) {
- throw new Error("Element id '" + elementId + "' is unknown.");
- }
-
- // remove all existing items first
- DomTraverse.childrenByClass(data.list, "item").forEach((item: HTMLElement) => {
- removeItem(item, true);
- });
-
- // add new items
- values.forEach((value) => {
- addItem(elementId, value);
- });
-}
-
-type ItemListInputElement = HTMLInputElement | HTMLTextAreaElement;
-
-export interface ItemData {
- objectId: number;
- value: string;
- type?: string;
-}
-
-type PlainValue = string;
-
-type ItemDataOrPlainValue = ItemData | PlainValue;
-
-export type CallbackChange = (elementId: string, values: ItemData[]) => void;
-
-export type CallbackSetupValues = () => ItemDataOrPlainValue[];
-
-export type CallbackSubmit = (form: HTMLFormElement, values: ItemData[]) => void;
-
-export type CallbackSyncShadow = (data: ElementData) => ItemData[];
-
-export interface ItemListOptions {
- // search parameters for suggestions
- ajax: {
- actionName?: string;
- className: string;
- parameters?: object;
- };
-
- // list of excluded string values, e.g. `['ignore', 'these strings', 'when', 'searching']`
- excludedSearchValues: string[];
-
- // maximum number of items this list may contain, `-1` for infinite
- maxItems: number;
-
- // maximum length of an item value, `-1` for infinite
- maxLength: number;
-
- // disallow custom values, only values offered by the suggestion dropdown are accepted
- restricted: boolean;
-
- // initial value will be interpreted as comma separated value and submitted as such
- isCSV: boolean;
-
- // will be invoked whenever the items change, receives the element id first and list of values second
- callbackChange: CallbackChange | null;
-
- // callback once the form is about to be submitted
- callbackSubmit: CallbackSubmit | null;
-
- // Callback for the custom shadow synchronization.
- callbackSyncShadow: CallbackSyncShadow | null;
-
- // Callback to set values during the setup.
- callbackSetupValues: CallbackSetupValues | null;
-
- // value may contain the placeholder `{$objectId}`
- submitFieldName: string;
-}
-
-export interface ElementData {
- dropdownMenu: HTMLElement | null;
- element: ItemListInputElement;
- limitReached: HTMLSpanElement;
- list: HTMLElement;
- listItem: HTMLElement;
- options: ItemListOptions;
- shadow: HTMLInputElement | null;
- suggestion: UiSuggestion;
-}
-
-interface UiData {
- element: ItemListInputElement;
- limitReached: HTMLSpanElement;
- list: HTMLOListElement;
- shadow: HTMLInputElement | null;
- values: string[];
-}
+++ /dev/null
-/**
- * Provides a filter input for checkbox lists.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/ItemList/Filter
- */
-
-import * as Core from "../../Core";
-import DomUtil from "../../Dom/Util";
-import * as Language from "../../Language";
-import * as StringUtil from "../../StringUtil";
-import UiDropdownSimple from "../Dropdown/Simple";
-
-interface ItemMetaData {
- item: HTMLLIElement;
- span: HTMLSpanElement;
- text: string;
-}
-
-interface FilterOptions {
- callbackPrepareItem: (listItem: HTMLLIElement) => ItemMetaData;
- enableVisibilityFilter: boolean;
- filterPosition: "bottom" | "top";
-}
-
-class UiItemListFilter {
- protected readonly _container: HTMLDivElement;
- protected _dropdownId = "";
- protected _dropdown?: HTMLUListElement = undefined;
- protected readonly _element: HTMLElement;
- protected _fragment?: DocumentFragment = undefined;
- protected readonly _input: HTMLInputElement;
- protected readonly _items = new Set<ItemMetaData>();
- protected readonly _options: FilterOptions;
- protected _value = "";
-
- /**
- * Creates a new filter input.
- *
- * @param {string} elementId list element id
- * @param {Object=} options options
- */
- constructor(elementId: string, options: Partial<FilterOptions>) {
- this._options = Core.extend(
- {
- callbackPrepareItem: undefined,
- enableVisibilityFilter: true,
- filterPosition: "bottom",
- },
- options,
- ) as FilterOptions;
-
- if (this._options.filterPosition !== "top") {
- this._options.filterPosition = "bottom";
- }
-
- const element = document.getElementById(elementId);
- if (element === null) {
- throw new Error("Expected a valid element id, '" + elementId + "' does not match anything.");
- } else if (
- !element.classList.contains("scrollableCheckboxList") &&
- typeof this._options.callbackPrepareItem !== "function"
- ) {
- throw new Error("Filter only works with elements with the CSS class 'scrollableCheckboxList'.");
- }
-
- if (typeof this._options.callbackPrepareItem !== "function") {
- this._options.callbackPrepareItem = (item) => this._prepareItem(item);
- }
-
- element.dataset.filter = "showAll";
-
- const container = document.createElement("div");
- container.className = "itemListFilter";
-
- element.insertAdjacentElement("beforebegin", container);
- container.appendChild(element);
-
- const inputAddon = document.createElement("div");
- inputAddon.className = "inputAddon";
-
- const input = document.createElement("input");
- input.className = "long";
- input.type = "text";
- input.placeholder = Language.get("wcf.global.filter.placeholder");
- input.addEventListener("keydown", (event) => {
- if (event.key === "Enter") {
- event.preventDefault();
- }
- });
- input.addEventListener("keyup", () => this._keyup());
-
- const clearButton = document.createElement("a");
- clearButton.href = "#";
- clearButton.className = "button inputSuffix jsTooltip";
- clearButton.title = Language.get("wcf.global.filter.button.clear");
- clearButton.innerHTML = '<span class="icon icon16 fa-times"></span>';
- clearButton.addEventListener("click", (event) => {
- event.preventDefault();
-
- this.reset();
- });
-
- inputAddon.appendChild(input);
- inputAddon.appendChild(clearButton);
-
- if (this._options.enableVisibilityFilter) {
- const visibilityButton = document.createElement("a");
- visibilityButton.href = "#";
- visibilityButton.className = "button inputSuffix jsTooltip";
- visibilityButton.title = Language.get("wcf.global.filter.button.visibility");
- visibilityButton.innerHTML = '<span class="icon icon16 fa-eye"></span>';
- visibilityButton.addEventListener("click", (ev) => this._toggleVisibility(ev));
- inputAddon.appendChild(visibilityButton);
- }
-
- if (this._options.filterPosition === "bottom") {
- container.appendChild(inputAddon);
- } else {
- container.insertBefore(inputAddon, element);
- }
-
- this._container = container;
- this._element = element;
- this._input = input;
- }
-
- /**
- * Resets the filter.
- */
- reset(): void {
- this._input.value = "";
- this._keyup();
- }
-
- /**
- * Builds the item list and rebuilds the items' DOM for easier manipulation.
- *
- * @protected
- */
- protected _buildItems(): void {
- this._items.clear();
-
- Array.from(this._element.children).forEach((item: HTMLLIElement) => {
- this._items.add(this._options.callbackPrepareItem(item));
- });
- }
-
- /**
- * Processes an item and returns the meta data.
- */
- protected _prepareItem(item: HTMLLIElement): ItemMetaData {
- const label = item.children[0] as HTMLElement;
- const text = label.textContent!.trim();
-
- const checkbox = label.children[0];
- while (checkbox.nextSibling) {
- label.removeChild(checkbox.nextSibling);
- }
-
- label.appendChild(document.createTextNode(" "));
-
- const span = document.createElement("span");
- span.textContent = text;
- label.appendChild(span);
-
- return {
- item,
- span,
- text,
- };
- }
-
- /**
- * Rebuilds the list on keyup, uses case-insensitive matching.
- */
- protected _keyup(): void {
- const value = this._input.value.trim();
- if (this._value === value) {
- return;
- }
-
- if (!this._fragment) {
- this._fragment = document.createDocumentFragment();
-
- // set fixed height to avoid layout jumps
- this._element.style.setProperty("height", `${this._element.offsetHeight}px`, "");
- }
-
- // move list into fragment before editing items, increases performance
- // by avoiding the browser to perform repaint/layout over and over again
- this._fragment.appendChild(this._element);
-
- if (!this._items.size) {
- this._buildItems();
- }
-
- const regexp = new RegExp("(" + StringUtil.escapeRegExp(value) + ")", "i");
- let hasVisibleItems = value === "";
- this._items.forEach((item) => {
- if (value === "") {
- item.span.textContent = item.text;
-
- DomUtil.show(item.item);
- } else {
- if (regexp.test(item.text)) {
- item.span.innerHTML = item.text.replace(regexp, "<u>$1</u>");
-
- DomUtil.show(item.item);
- hasVisibleItems = true;
- } else {
- DomUtil.hide(item.item);
- }
- }
- });
-
- if (this._options.filterPosition === "bottom") {
- this._container.insertAdjacentElement("afterbegin", this._element);
- } else {
- this._container.insertAdjacentElement("beforeend", this._element);
- }
-
- this._value = value;
-
- DomUtil.innerError(this._container, hasVisibleItems ? false : Language.get("wcf.global.filter.error.noMatches"));
- }
-
- /**
- * Toggles the visibility mode for marked items.
- */
- protected _toggleVisibility(event: MouseEvent): void {
- event.preventDefault();
- event.stopPropagation();
-
- const button = event.currentTarget as HTMLElement;
- if (!this._dropdown) {
- const dropdown = document.createElement("ul");
- dropdown.className = "dropdownMenu";
-
- ["activeOnly", "highlightActive", "showAll"].forEach((type) => {
- const link = document.createElement("a");
- link.dataset.type = type;
- link.href = "#";
- link.textContent = Language.get(`wcf.global.filter.visibility.${type}`);
- link.addEventListener("click", (ev) => this._setVisibility(ev));
-
- const li = document.createElement("li");
- li.appendChild(link);
-
- if (type === "showAll") {
- li.className = "active";
-
- const divider = document.createElement("li");
- divider.className = "dropdownDivider";
- dropdown.appendChild(divider);
- }
-
- dropdown.appendChild(li);
- });
-
- UiDropdownSimple.initFragment(button, dropdown);
-
- // add `active` classes required for the visibility filter
- this._setupVisibilityFilter();
-
- this._dropdown = dropdown;
- this._dropdownId = button.id;
- }
-
- UiDropdownSimple.toggleDropdown(button.id, button);
- }
-
- /**
- * Set-ups the visibility filter by assigning an active class to the
- * list items that hold the checkboxes and observing the checkboxes
- * for any changes.
- *
- * This process involves quite a few DOM changes and new event listeners,
- * therefore we'll delay this until the filter has been accessed for
- * the first time, because none of these changes matter before that.
- */
- protected _setupVisibilityFilter(): void {
- const nextSibling = this._element.nextSibling;
- const parent = this._element.parentElement!;
- const scrollTop = this._element.scrollTop;
-
- // mass-editing of DOM elements is slow while they're part of the document
- const fragment = document.createDocumentFragment();
- fragment.appendChild(this._element);
-
- this._element.querySelectorAll("li").forEach((li) => {
- const checkbox = li.querySelector('input[type="checkbox"]') as HTMLInputElement;
- if (checkbox) {
- if (checkbox.checked) {
- li.classList.add("active");
- }
-
- checkbox.addEventListener("change", () => {
- if (checkbox.checked) {
- li.classList.add("active");
- } else {
- li.classList.remove("active");
- }
- });
- } else {
- const radioButton = li.querySelector('input[type="radio"]') as HTMLInputElement;
- if (radioButton) {
- if (radioButton.checked) {
- li.classList.add("active");
- }
-
- radioButton.addEventListener("change", () => {
- this._element.querySelectorAll("li").forEach((el) => el.classList.remove("active"));
-
- if (radioButton.checked) {
- li.classList.add("active");
- } else {
- li.classList.remove("active");
- }
- });
- }
- }
- });
-
- // re-insert the modified DOM
- parent.insertBefore(this._element, nextSibling);
- this._element.scrollTop = scrollTop;
- }
-
- /**
- * Sets the visibility of marked items.
- */
- protected _setVisibility(event: MouseEvent): void {
- event.preventDefault();
-
- const link = event.currentTarget as HTMLElement;
- const type = link.dataset.type;
-
- UiDropdownSimple.close(this._dropdownId);
-
- if (this._element.dataset.filter === type) {
- // filter did not change
- return;
- }
-
- this._element.dataset.filter = type;
-
- const activeElement = this._dropdown!.querySelector(".active")!;
- activeElement.classList.remove("active");
- link.parentElement!.classList.add("active");
-
- const button = document.getElementById(this._dropdownId) as HTMLElement;
- if (type === "showAll") {
- button.classList.remove("active");
- } else {
- button.classList.add("active");
- }
-
- const icon = button.querySelector(".icon") as HTMLElement;
- if (type === "showAll") {
- icon.classList.add("fa-eye");
- icon.classList.remove("fa-eye-slash");
- } else {
- icon.classList.remove("fa-eye");
- icon.classList.add("fa-eye-slash");
- }
- }
-}
-
-Core.enableLegacyInheritance(UiItemListFilter);
-
-export = UiItemListFilter;
+++ /dev/null
-/**
- * Flexible UI element featuring both a list of items and an input field.
- *
- * @author Alexander Ebert, Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/ItemList/Static
- */
-
-import * as Core from "../../Core";
-import * as DomTraverse from "../../Dom/Traverse";
-import * as Language from "../../Language";
-import UiDropdownSimple from "../Dropdown/Simple";
-
-export type CallbackChange = (elementId: string, values: ItemData[]) => void;
-export type CallbackSubmit = (form: HTMLFormElement, values: ItemData[]) => void;
-
-export interface ItemListStaticOptions {
- maxItems: number;
- maxLength: number;
- isCSV: boolean;
- callbackChange: CallbackChange | null;
- callbackSubmit: CallbackSubmit | null;
- submitFieldName: string;
-}
-
-type ItemListInputElement = HTMLInputElement | HTMLTextAreaElement;
-
-export interface ItemData {
- objectId: number;
- value: string;
- type?: string;
-}
-
-type PlainValue = string;
-
-type ItemDataOrPlainValue = ItemData | PlainValue;
-
-interface UiData {
- element: HTMLInputElement | HTMLTextAreaElement;
- list: HTMLOListElement;
- shadow?: HTMLInputElement;
- values: string[];
-}
-
-interface ElementData {
- dropdownMenu: HTMLElement | null;
- element: ItemListInputElement;
- list: HTMLOListElement;
- listItem: HTMLElement;
- options: ItemListStaticOptions;
- shadow?: HTMLInputElement;
-}
-
-const _data = new Map<string, ElementData>();
-
-/**
- * Creates the DOM structure for target element. If `element` is a `<textarea>`
- * it will be automatically replaced with an `<input>` element.
- */
-function createUI(element: ItemListInputElement, options: ItemListStaticOptions): UiData {
- const list = document.createElement("ol");
- list.className = "inputItemList" + (element.disabled ? " disabled" : "");
- list.dataset.elementId = element.id;
- list.addEventListener("click", (event) => {
- if (event.target === list) {
- element.focus();
- }
- });
-
- const listItem = document.createElement("li");
- listItem.className = "input";
- list.appendChild(listItem);
-
- element.addEventListener("keydown", (ev: KeyboardEvent) => keyDown(ev));
- element.addEventListener("keypress", (ev: KeyboardEvent) => keyPress(ev));
- element.addEventListener("keyup", (ev: KeyboardEvent) => keyUp(ev));
- element.addEventListener("paste", (ev: ClipboardEvent) => paste(ev));
- element.addEventListener("blur", (ev: FocusEvent) => blur(ev));
-
- element.insertAdjacentElement("beforebegin", list);
- listItem.appendChild(element);
-
- if (options.maxLength !== -1) {
- element.maxLength = options.maxLength;
- }
-
- let shadow: HTMLInputElement | undefined;
- let values: string[] = [];
- if (options.isCSV) {
- shadow = document.createElement("input");
- shadow.className = "itemListInputShadow";
- shadow.type = "hidden";
- shadow.name = element.name;
- element.removeAttribute("name");
-
- list.insertAdjacentElement("beforebegin", shadow);
-
- values = element.value
- .split(",")
- .map((s) => s.trim())
- .filter((s) => s.length > 0);
-
- if (element.nodeName === "TEXTAREA") {
- const inputElement = document.createElement("input");
- inputElement.type = "text";
- element.parentElement!.insertBefore(inputElement, element);
- inputElement.id = element.id;
-
- element.remove();
- element = inputElement;
- }
- }
-
- return {
- element,
- list,
- shadow,
- values,
- };
-}
-
-/**
- * Enforces the maximum number of items.
- */
-function handleLimit(elementId: string): void {
- const data = _data.get(elementId)!;
- if (data.options.maxItems === -1) {
- return;
- }
-
- if (data.list.childElementCount - 1 < data.options.maxItems) {
- if (data.element.disabled) {
- data.element.disabled = false;
- data.element.removeAttribute("placeholder");
- }
- } else if (!data.element.disabled) {
- data.element.disabled = true;
- data.element.placeholder = Language.get("wcf.global.form.input.maxItems");
- }
-}
-
-/**
- * Sets the active item list id and handles keyboard access to remove an existing item.
- */
-function keyDown(event: KeyboardEvent): void {
- const input = event.currentTarget as HTMLInputElement;
- const lastItem = input.parentElement!.previousElementSibling as HTMLElement;
-
- if (event.key === "Backspace") {
- if (input.value.length === 0) {
- if (lastItem !== null) {
- if (lastItem.classList.contains("active")) {
- removeItem(lastItem);
- } else {
- lastItem.classList.add("active");
- }
- }
- }
- } else if (event.key === "Escape") {
- if (lastItem !== null && lastItem.classList.contains("active")) {
- lastItem.classList.remove("active");
- }
- }
-}
-
-/**
- * Handles the `[ENTER]` and `[,]` key to add an item to the list.
- */
-function keyPress(event: KeyboardEvent): void {
- if (event.key === "Enter" || event.key === "Comma") {
- event.preventDefault();
-
- const input = event.currentTarget as HTMLInputElement;
- const value = input.value.trim();
- if (value.length) {
- addItem(input.id, { objectId: 0, value: value });
- }
- }
-}
-
-/**
- * Splits comma-separated values being pasted into the input field.
- */
-function paste(event: ClipboardEvent): void {
- const input = event.currentTarget as HTMLInputElement;
-
- const text = event.clipboardData!.getData("text/plain");
- text
- .split(",")
- .map((s) => s.trim())
- .filter((s) => s.length > 0)
- .forEach((s) => {
- addItem(input.id, { objectId: 0, value: s });
- });
-
- event.preventDefault();
-}
-
-/**
- * Handles the keyup event to unmark an item for deletion.
- */
-function keyUp(event: KeyboardEvent): void {
- const input = event.currentTarget as HTMLInputElement;
-
- if (input.value.length > 0) {
- const lastItem = input.parentElement!.previousElementSibling;
- if (lastItem !== null) {
- lastItem.classList.remove("active");
- }
- }
-}
-
-/**
- * Adds an item to the list.
- */
-function addItem(elementId: string, value: ItemData, forceRemoveIcon?: boolean): void {
- const data = _data.get(elementId)!;
-
- const listItem = document.createElement("li");
- listItem.className = "item";
-
- const content = document.createElement("span");
- content.className = "content";
- content.dataset.objectId = value.objectId.toString();
- content.textContent = value.value;
- listItem.appendChild(content);
-
- if (forceRemoveIcon || !data.element.disabled) {
- const button = document.createElement("a");
- button.className = "icon icon16 fa-times";
- button.addEventListener("click", (ev) => removeItem(ev));
- listItem.appendChild(button);
- }
-
- data.list.insertBefore(listItem, data.listItem);
- data.element.value = "";
-
- if (!data.element.disabled) {
- handleLimit(elementId);
- }
- let values = syncShadow(data);
-
- if (typeof data.options.callbackChange === "function") {
- if (values === null) {
- values = getValues(elementId);
- }
- data.options.callbackChange(elementId, values);
- }
-}
-
-/**
- * Removes an item from the list.
- */
-function removeItem(item: MouseEvent | HTMLElement, noFocus?: boolean): void {
- if (item instanceof Event) {
- item = (item.currentTarget as HTMLElement).parentElement as HTMLElement;
- }
-
- const parent = item.parentElement!;
- const elementId = parent.dataset.elementId!;
- const data = _data.get(elementId)!;
-
- item.remove();
- if (!noFocus) {
- data.element.focus();
- }
-
- handleLimit(elementId);
- let values = syncShadow(data);
-
- if (typeof data.options.callbackChange === "function") {
- if (values === null) {
- values = getValues(elementId);
- }
- data.options.callbackChange(elementId, values);
- }
-}
-
-/**
- * Synchronizes the shadow input field with the current list item values.
- */
-function syncShadow(data: ElementData): ItemData[] | null {
- if (!data.options.isCSV) {
- return null;
- }
-
- const values = getValues(data.element.id);
-
- data.shadow!.value = values.map((v) => v.value).join(",");
-
- return values;
-}
-
-/**
- * Handles the blur event.
- */
-function blur(event: FocusEvent): void {
- const input = event.currentTarget as HTMLInputElement;
-
- window.setTimeout(() => {
- const value = input.value.trim();
- if (value.length) {
- addItem(input.id, { objectId: 0, value: value });
- }
- }, 100);
-}
-
-/**
- * Initializes an item list.
- *
- * The `values` argument must be empty or contain a list of strings or object, e.g.
- * `['foo', 'bar']` or `[{ objectId: 1337, value: 'baz'}, {...}]`
- */
-export function init(elementId: string, values: ItemDataOrPlainValue[], opts: Partial<ItemListStaticOptions>): void {
- const element = document.getElementById(elementId) as HTMLInputElement | HTMLTextAreaElement;
- if (element === null) {
- throw new Error("Expected a valid element id, '" + elementId + "' is invalid.");
- }
-
- // remove data from previous instance
- if (_data.has(elementId)) {
- const tmp = _data.get(elementId)!;
-
- Object.values(tmp).forEach((value) => {
- if (value instanceof HTMLElement && value.parentElement) {
- value.remove();
- }
- });
-
- UiDropdownSimple.destroy(elementId);
- _data.delete(elementId);
- }
-
- const options = Core.extend(
- {
- // maximum number of items this list may contain, `-1` for infinite
- maxItems: -1,
- // maximum length of an item value, `-1` for infinite
- maxLength: -1,
-
- // initial value will be interpreted as comma separated value and submitted as such
- isCSV: false,
-
- // will be invoked whenever the items change, receives the element id first and list of values second
- callbackChange: null,
- // callback once the form is about to be submitted
- callbackSubmit: null,
- // value may contain the placeholder `{$objectId}`
- submitFieldName: "",
- },
- opts,
- ) as ItemListStaticOptions;
-
- const form = DomTraverse.parentByTag(element, "FORM") as HTMLFormElement;
- if (form !== null) {
- if (!options.isCSV) {
- if (!options.submitFieldName.length && typeof options.callbackSubmit !== "function") {
- throw new Error(
- "Expected a valid function for option 'callbackSubmit', a non-empty value for option 'submitFieldName' or enabling the option 'submitFieldCSV'.",
- );
- }
-
- form.addEventListener("submit", () => {
- const values = getValues(elementId);
- if (options.submitFieldName.length) {
- values.forEach((value) => {
- const input = document.createElement("input");
- input.type = "hidden";
- input.name = options.submitFieldName.replace("{$objectId}", value.objectId.toString());
- input.value = value.value;
-
- form.appendChild(input);
- });
- } else {
- options.callbackSubmit!(form, values);
- }
- });
- }
- }
-
- const data = createUI(element, options);
- _data.set(elementId, {
- dropdownMenu: null,
- element: data.element,
- list: data.list,
- listItem: data.element.parentElement!,
- options: options,
- shadow: data.shadow,
- });
-
- values = data.values.length ? data.values : values;
- if (Array.isArray(values)) {
- const forceRemoveIcon = !data.element.disabled;
-
- values.forEach((value) => {
- if (typeof value === "string") {
- value = { objectId: 0, value: value };
- }
-
- addItem(elementId, value, forceRemoveIcon);
- });
- }
-}
-
-/**
- * Returns the list of current values.
- */
-export function getValues(elementId: string): ItemData[] {
- if (!_data.has(elementId)) {
- throw new Error(`Element id '${elementId}' is unknown.`);
- }
-
- const data = _data.get(elementId)!;
-
- const values: ItemData[] = [];
- data.list.querySelectorAll(".item > span").forEach((span: HTMLElement) => {
- values.push({
- objectId: ~~span.dataset.objectId!,
- value: span.textContent!,
- });
- });
-
- return values;
-}
-
-/**
- * Sets the list of current values.
- */
-export function setValues(elementId: string, values: ItemData[]): void {
- if (!_data.has(elementId)) {
- throw new Error(`Element id '${elementId}' is unknown.`);
- }
-
- const data = _data.get(elementId)!;
-
- // remove all existing items first
- const items = DomTraverse.childrenByClass(data.list, "item");
- items.forEach((item: HTMLElement) => removeItem(item, true));
-
- // add new items
- values.forEach((v) => addItem(elementId, v));
-}
+++ /dev/null
-/**
- * Provides an item list for users and groups.
- *
- * @author Alexander Ebert
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/ItemList/User
- */
-
-import { CallbackChange, CallbackSetupValues, CallbackSyncShadow, ElementData, ItemData } from "../ItemList";
-import * as UiItemList from "../ItemList";
-
-interface ItemListUserOptions {
- callbackChange?: CallbackChange;
- callbackSetupValues?: CallbackSetupValues;
- csvPerType?: boolean;
- excludedSearchValues?: string[];
- includeUserGroups?: boolean;
- maxItems?: number;
- restrictUserGroupIDs?: number[];
-}
-
-interface UserElementData extends ElementData {
- _shadowGroups?: HTMLInputElement;
-}
-
-function syncShadow(data: UserElementData): ReturnType<CallbackSyncShadow> {
- const values = getValues(data.element.id);
-
- const users: string[] = [];
- const groups: number[] = [];
- values.forEach((value) => {
- if (value.type && value.type === "group") {
- groups.push(value.objectId);
- } else {
- users.push(value.value);
- }
- });
-
- const shadowElement = data.shadow!;
- shadowElement.value = users.join(",");
- if (!data._shadowGroups) {
- data._shadowGroups = document.createElement("input");
- data._shadowGroups.type = "hidden";
- data._shadowGroups.name = `${shadowElement.name}GroupIDs`;
- shadowElement.insertAdjacentElement("beforebegin", data._shadowGroups);
- }
- data._shadowGroups.value = groups.join(",");
-
- return values;
-}
-
-/**
- * Initializes user suggestion support for an element.
- *
- * @param {string} elementId input element id
- * @param {object} options option list
- */
-export function init(elementId: string, options: ItemListUserOptions): void {
- UiItemList.init(elementId, [], {
- ajax: {
- className: "wcf\\data\\user\\UserAction",
- parameters: {
- data: {
- includeUserGroups: options.includeUserGroups ? ~~options.includeUserGroups : 0,
- restrictUserGroupIDs: Array.isArray(options.restrictUserGroupIDs) ? options.restrictUserGroupIDs : [],
- },
- },
- },
- callbackChange: typeof options.callbackChange === "function" ? options.callbackChange : null,
- callbackSyncShadow: options.csvPerType ? syncShadow : null,
- callbackSetupValues: typeof options.callbackSetupValues === "function" ? options.callbackSetupValues : null,
- excludedSearchValues: Array.isArray(options.excludedSearchValues) ? options.excludedSearchValues : [],
- isCSV: true,
- maxItems: options.maxItems ? ~~options.maxItems : -1,
- restricted: true,
- });
-}
-
-/**
- * @see WoltLabSuite/Core/Ui/ItemList::getValues()
- */
-export function getValues(elementId: string): ItemData[] {
- return UiItemList.getValues(elementId);
-}
+++ /dev/null
-/**
- * Provides interface elements to display and review likes.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Like/Handler
- * @deprecated 5.2 use ReactionHandler instead
- */
-
-import * as Core from "../../Core";
-import DomChangeListener from "../../Dom/Change/Listener";
-import * as Language from "../../Language";
-import * as StringUtil from "../../StringUtil";
-import UiReactionHandler from "../Reaction/Handler";
-import User from "../../User";
-
-interface LikeHandlerOptions {
- // settings
- badgeClassNames: string;
- isSingleItem: boolean;
- markListItemAsActive: boolean;
- renderAsButton: boolean;
- summaryPrepend: boolean;
- summaryUseIcon: boolean;
-
- // permissions
- canDislike: boolean;
- canLike: boolean;
- canLikeOwnContent: boolean;
- canViewSummary: boolean;
-
- // selectors
- badgeContainerSelector: string;
- buttonAppendToSelector: string;
- buttonBeforeSelector: string;
- containerSelector: string;
- summarySelector: string;
-}
-
-interface LikeUsers {
- [key: string]: number;
-}
-
-interface ElementData {
- badge: HTMLUListElement | null;
- dislikeButton: null;
- likeButton: HTMLAnchorElement | null;
- summary: null;
-
- dislikes: number;
- liked: number;
- likes: number;
- objectId: number;
- users: LikeUsers;
-}
-
-const availableReactions = new Map(Object.entries(window.REACTION_TYPES));
-
-class UiLikeHandler {
- protected readonly _containers = new WeakMap<HTMLElement, ElementData>();
- protected readonly _objectType: string;
- protected readonly _options: LikeHandlerOptions;
-
- /**
- * Initializes the like handler.
- */
- constructor(objectType: string, opts: Partial<LikeHandlerOptions>) {
- if (!opts.containerSelector) {
- throw new Error(
- "[WoltLabSuite/Core/Ui/Like/Handler] Expected a non-empty string for option 'containerSelector'.",
- );
- }
-
- this._objectType = objectType;
- this._options = Core.extend(
- {
- // settings
- badgeClassNames: "",
- isSingleItem: false,
- markListItemAsActive: false,
- renderAsButton: true,
- summaryPrepend: true,
- summaryUseIcon: true,
-
- // permissions
- canDislike: false,
- canLike: false,
- canLikeOwnContent: false,
- canViewSummary: false,
-
- // selectors
- badgeContainerSelector: ".messageHeader .messageStatus",
- buttonAppendToSelector: ".messageFooter .messageFooterButtons",
- buttonBeforeSelector: "",
- containerSelector: "",
- summarySelector: ".messageFooterGroup",
- },
- opts,
- ) as LikeHandlerOptions;
-
- this.initContainers();
-
- DomChangeListener.add(`WoltLabSuite/Core/Ui/Like/Handler-${objectType}`, () => this.initContainers());
-
- new UiReactionHandler(this._objectType, {
- containerSelector: this._options.containerSelector,
- });
- }
-
- /**
- * Initializes all applicable containers.
- */
- initContainers(): void {
- let triggerChange = false;
-
- document.querySelectorAll(this._options.containerSelector).forEach((element: HTMLElement) => {
- if (this._containers.has(element)) {
- return;
- }
-
- const elementData = {
- badge: null,
- dislikeButton: null,
- likeButton: null,
- summary: null,
-
- dislikes: ~~element.dataset.likeDislikes!,
- liked: ~~element.dataset.likeLiked!,
- likes: ~~element.dataset.likeLikes!,
- objectId: ~~element.dataset.objectId!,
- users: JSON.parse(element.dataset.likeUsers!),
- };
-
- this._containers.set(element, elementData);
- this._buildWidget(element, elementData);
-
- triggerChange = true;
- });
-
- if (triggerChange) {
- DomChangeListener.trigger();
- }
- }
-
- /**
- * Creates the interface elements.
- */
- protected _buildWidget(element: HTMLElement, elementData: ElementData): void {
- let badgeContainer: HTMLElement | null;
- let isSummaryPosition = true;
-
- if (this._options.isSingleItem) {
- badgeContainer = document.querySelector(this._options.summarySelector);
- } else {
- badgeContainer = element.querySelector(this._options.summarySelector);
- }
-
- if (badgeContainer === null) {
- if (this._options.isSingleItem) {
- badgeContainer = document.querySelector(this._options.badgeContainerSelector);
- } else {
- badgeContainer = element.querySelector(this._options.badgeContainerSelector);
- }
-
- isSummaryPosition = false;
- }
-
- if (badgeContainer !== null) {
- const summaryList = document.createElement("ul");
- summaryList.classList.add("reactionSummaryList");
- if (isSummaryPosition) {
- summaryList.classList.add("likesSummary");
- } else {
- summaryList.classList.add("reactionSummaryListTiny");
- }
-
- Object.entries(elementData.users).forEach(([reactionTypeId, count]) => {
- const reaction = availableReactions.get(reactionTypeId);
- if (reactionTypeId === "reactionTypeID" || !reaction) {
- return;
- }
-
- // create element
- const createdElement = document.createElement("li");
- createdElement.className = "reactCountButton";
- createdElement.setAttribute("reaction-type-id", reactionTypeId);
-
- const countSpan = document.createElement("span");
- countSpan.className = "reactionCount";
- countSpan.innerHTML = StringUtil.shortUnit(~~count);
- createdElement.appendChild(countSpan);
-
- createdElement.innerHTML = reaction.renderedIcon + createdElement.innerHTML;
-
- summaryList.appendChild(createdElement);
- });
-
- if (isSummaryPosition) {
- if (this._options.summaryPrepend) {
- badgeContainer.insertAdjacentElement("afterbegin", summaryList);
- } else {
- badgeContainer.insertAdjacentElement("beforeend", summaryList);
- }
- } else {
- if (badgeContainer.nodeName === "OL" || badgeContainer.nodeName === "UL") {
- const listItem = document.createElement("li");
- listItem.appendChild(summaryList);
- badgeContainer.appendChild(listItem);
- } else {
- badgeContainer.appendChild(summaryList);
- }
- }
-
- elementData.badge = summaryList;
- }
-
- // build reaction button
- if (this._options.canLike && (User.userId != ~~element.dataset.userId! || this._options.canLikeOwnContent)) {
- let appendTo: HTMLElement | null = null;
- if (this._options.buttonAppendToSelector) {
- if (this._options.isSingleItem) {
- appendTo = document.querySelector(this._options.buttonAppendToSelector);
- } else {
- appendTo = element.querySelector(this._options.buttonAppendToSelector);
- }
- }
-
- let insertPosition: HTMLElement | null = null;
- if (this._options.buttonBeforeSelector) {
- if (this._options.isSingleItem) {
- insertPosition = document.querySelector(this._options.buttonBeforeSelector);
- } else {
- insertPosition = element.querySelector(this._options.buttonBeforeSelector);
- }
- }
-
- if (insertPosition === null && appendTo === null) {
- throw new Error("Unable to find insert location for like/dislike buttons.");
- } else {
- elementData.likeButton = this._createButton(
- element,
- elementData.users.reactionTypeID,
- insertPosition,
- appendTo,
- );
- }
- }
- }
-
- /**
- * Creates a reaction button.
- */
- protected _createButton(
- element: HTMLElement,
- reactionTypeID: number,
- insertBefore: HTMLElement | null,
- appendTo: HTMLElement | null,
- ): HTMLAnchorElement {
- const title = Language.get("wcf.reactions.react");
-
- const listItem = document.createElement("li");
- listItem.className = "wcfReactButton";
-
- const button = document.createElement("a");
- button.className = "jsTooltip reactButton";
- if (this._options.renderAsButton) {
- button.classList.add("button");
- }
-
- button.href = "#";
- button.title = title;
-
- const icon = document.createElement("span");
- icon.className = "icon icon16 fa-smile-o";
-
- if (reactionTypeID === undefined || reactionTypeID == 0) {
- icon.dataset.reactionTypeId = "0";
- } else {
- button.dataset.reactionTypeId = reactionTypeID.toString();
- button.classList.add("active");
- }
-
- button.appendChild(icon);
-
- const invisibleText = document.createElement("span");
- invisibleText.className = "invisible";
- invisibleText.innerHTML = title;
-
- button.appendChild(document.createTextNode(" "));
- button.appendChild(invisibleText);
-
- listItem.appendChild(button);
-
- if (insertBefore) {
- insertBefore.insertAdjacentElement("beforebegin", listItem);
- } else {
- appendTo!.insertAdjacentElement("beforeend", listItem);
- }
-
- return button;
- }
-}
-
-Core.enableLegacyInheritance(UiLikeHandler);
-
-export = UiLikeHandler;
+++ /dev/null
-/**
- * Flexible message inline editor.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Message/InlineEditor
- */
-
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
-import * as Core from "../../Core";
-import DomChangeListener from "../../Dom/Change/Listener";
-import DomUtil from "../../Dom/Util";
-import * as Environment from "../../Environment";
-import * as EventHandler from "../../Event/Handler";
-import * as Language from "../../Language";
-import { NotificationAction } from "../Dropdown/Data";
-import * as UiDropdownReusable from "../Dropdown/Reusable";
-import * as UiNotification from "../Notification";
-import * as UiScroll from "../Scroll";
-
-interface MessageInlineEditorOptions {
- canEditInline: boolean;
-
- className: string;
- containerId: string;
- dropdownIdentifier: string;
- editorPrefix: string;
-
- messageSelector: string;
-
- // This is the legacy jQuery based class.
- quoteManager: any;
-}
-
-interface ElementData {
- button: HTMLAnchorElement;
- messageBody: HTMLElement;
- messageBodyEditor: HTMLElement | null;
- messageFooter: HTMLElement;
- messageFooterButtons: HTMLUListElement;
- messageHeader: HTMLElement;
- messageText: HTMLElement;
-}
-
-interface ItemData {
- item: "divider" | "editItem" | string;
- label?: string;
-}
-
-interface ElementVisibility {
- [key: string]: boolean;
-}
-
-interface ValidationData {
- api: UiMessageInlineEditor;
- parameters: ArbitraryObject;
- valid: boolean;
- promises: Promise<void>[];
-}
-
-interface AjaxResponseEditor extends ResponseData {
- returnValues: {
- template: string;
- };
-}
-
-interface AjaxResponseMessage extends ResponseData {
- returnValues: {
- attachmentList?: string;
- message: string;
- poll?: string;
- };
-}
-
-class UiMessageInlineEditor implements AjaxCallbackObject {
- protected _activeDropdownElement: HTMLElement | null;
- protected _activeElement: HTMLElement | null;
- protected _dropdownMenu: HTMLUListElement | null;
- protected _elements: WeakMap<HTMLElement, ElementData>;
- protected _options: MessageInlineEditorOptions;
-
- /**
- * Initializes the message inline editor.
- */
- constructor(opts: Partial<MessageInlineEditorOptions>) {
- this.init(opts);
- }
-
- /**
- * Helper initialization method for legacy inheritance support.
- */
- protected init(opts: Partial<MessageInlineEditorOptions>): void {
- // Define the properties again, the constructor might not be
- // called in legacy implementations.
- this._activeDropdownElement = null;
- this._activeElement = null;
- this._dropdownMenu = null;
- this._elements = new WeakMap<HTMLElement, ElementData>();
-
- this._options = Core.extend(
- {
- canEditInline: false,
-
- className: "",
- containerId: 0,
- dropdownIdentifier: "",
- editorPrefix: "messageEditor",
-
- messageSelector: ".jsMessage",
-
- quoteManager: null,
- },
- opts,
- ) as MessageInlineEditorOptions;
-
- this.rebuild();
-
- DomChangeListener.add(`Ui/Message/InlineEdit_${this._options.className}`, () => this.rebuild());
- }
-
- /**
- * Initializes each applicable message, should be called whenever new
- * messages are being displayed.
- */
- rebuild(): void {
- document.querySelectorAll(this._options.messageSelector).forEach((element: HTMLElement) => {
- if (this._elements.has(element)) {
- return;
- }
-
- const button = element.querySelector(".jsMessageEditButton") as HTMLAnchorElement;
- if (button !== null) {
- const canEdit = Core.stringToBool(element.dataset.canEdit || "");
- const canEditInline = Core.stringToBool(element.dataset.canEditInline || "");
-
- if (this._options.canEditInline || canEditInline) {
- button.addEventListener("click", (ev) => this._clickDropdown(element, ev));
- button.classList.add("jsDropdownEnabled");
-
- if (canEdit) {
- button.addEventListener("dblclick", (ev) => this._click(element, ev));
- }
- } else if (canEdit) {
- button.addEventListener("click", (ev) => this._click(element, ev));
- }
- }
-
- const messageBody = element.querySelector(".messageBody") as HTMLElement;
- const messageFooter = element.querySelector(".messageFooter") as HTMLElement;
- const messageFooterButtons = messageFooter.querySelector(".messageFooterButtons") as HTMLUListElement;
- const messageHeader = element.querySelector(".messageHeader") as HTMLElement;
- const messageText = messageBody.querySelector(".messageText") as HTMLElement;
-
- this._elements.set(element, {
- button,
- messageBody,
- messageBodyEditor: null,
- messageFooter,
- messageFooterButtons,
- messageHeader,
- messageText,
- });
- });
- }
-
- /**
- * Handles clicks on the edit button or the edit dropdown item.
- */
- protected _click(element: HTMLElement | null, event: MouseEvent | null): void {
- if (element === null) {
- element = this._activeDropdownElement;
- }
- if (event) {
- event.preventDefault();
- }
-
- if (this._activeElement === null) {
- this._activeElement = element;
-
- this._prepare();
-
- Ajax.api(this, {
- actionName: "beginEdit",
- parameters: {
- containerID: this._options.containerId,
- objectID: this._getObjectId(element!),
- },
- });
- } else {
- UiNotification.show("wcf.message.error.editorAlreadyInUse", undefined, "warning");
- }
- }
-
- /**
- * Creates and opens the dropdown on first usage.
- */
- protected _clickDropdown(element: HTMLElement, event: MouseEvent): void {
- event.preventDefault();
-
- const button = event.currentTarget as HTMLElement;
- if (button.classList.contains("dropdownToggle")) {
- return;
- }
-
- button.classList.add("dropdownToggle");
- button.parentElement!.classList.add("dropdown");
- button.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
-
- this._activeDropdownElement = element;
- UiDropdownReusable.toggleDropdown(this._options.dropdownIdentifier, button);
- });
-
- // build dropdown
- if (this._dropdownMenu === null) {
- this._dropdownMenu = document.createElement("ul");
- this._dropdownMenu.className = "dropdownMenu";
-
- const items = this._dropdownGetItems();
-
- EventHandler.fire("com.woltlab.wcf.inlineEditor", `dropdownInit_${this._options.dropdownIdentifier}`, {
- items: items,
- });
-
- this._dropdownBuild(items);
-
- UiDropdownReusable.init(this._options.dropdownIdentifier, this._dropdownMenu);
- UiDropdownReusable.registerCallback(this._options.dropdownIdentifier, (containerId, action) =>
- this._dropdownToggle(containerId, action),
- );
- }
-
- setTimeout(() => button.click(), 10);
- }
-
- /**
- * Creates the dropdown menu on first usage.
- */
- protected _dropdownBuild(items: ItemData[]): void {
- items.forEach((item) => {
- const listItem = document.createElement("li");
- listItem.dataset.item = item.item;
-
- if (item.item === "divider") {
- listItem.className = "dropdownDivider";
- } else {
- const label = document.createElement("span");
- label.textContent = Language.get(item.label!);
- listItem.appendChild(label);
-
- if (item.item === "editItem") {
- listItem.addEventListener("click", (ev) => this._click(null, ev));
- } else {
- listItem.addEventListener("click", (ev) => this._clickDropdownItem(ev));
- }
- }
-
- this._dropdownMenu!.appendChild(listItem);
- });
- }
-
- /**
- * Callback for dropdown toggle.
- */
- protected _dropdownToggle(containerId: string, action: NotificationAction): void {
- const elementData = this._elements.get(this._activeDropdownElement!)!;
- const buttonParent = elementData.button.parentElement!;
-
- if (action === "close") {
- buttonParent.classList.remove("dropdownOpen");
- elementData.messageFooterButtons.classList.remove("forceVisible");
-
- return;
- }
-
- buttonParent.classList.add("dropdownOpen");
- elementData.messageFooterButtons.classList.add("forceVisible");
-
- const visibility = new Map<string, boolean>(Object.entries(this._dropdownOpen()));
-
- EventHandler.fire("com.woltlab.wcf.inlineEditor", `dropdownOpen_${this._options.dropdownIdentifier}`, {
- element: this._activeDropdownElement,
- visibility,
- });
-
- const dropdownMenu = this._dropdownMenu!;
-
- let visiblePredecessor = false;
- const children = Array.from(dropdownMenu.children);
- children.forEach((listItem: HTMLElement, index) => {
- const item = listItem.dataset.item!;
-
- if (item === "divider") {
- if (visiblePredecessor) {
- DomUtil.show(listItem);
-
- visiblePredecessor = false;
- } else {
- DomUtil.hide(listItem);
- }
- } else {
- if (visibility.get(item) === false) {
- DomUtil.hide(listItem);
-
- // check if previous item was a divider
- if (index > 0 && index + 1 === children.length) {
- const previousElementSibling = listItem.previousElementSibling as HTMLElement;
- if (previousElementSibling.dataset.item === "divider") {
- DomUtil.hide(previousElementSibling);
- }
- }
- } else {
- DomUtil.show(listItem);
-
- visiblePredecessor = true;
- }
- }
- });
- }
-
- /**
- * Returns the list of dropdown items for this type.
- */
- protected _dropdownGetItems(): ItemData[] {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
- return [];
- }
-
- /**
- * Invoked once the dropdown for this type is shown, expects a list of type name and a boolean value
- * to represent the visibility of each item. Items that do not appear in this list will be considered
- * visible.
- */
- protected _dropdownOpen(): ElementVisibility {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
- return {};
- }
-
- /**
- * Invoked whenever the user selects an item from the dropdown menu, the selected item is passed as argument.
- */
- protected _dropdownSelect(_item: string): void {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
- }
-
- /**
- * Handles clicks on a dropdown item.
- */
- protected _clickDropdownItem(event: MouseEvent): void {
- event.preventDefault();
-
- const target = event.currentTarget as HTMLElement;
- const item = target.dataset.item!;
- const data = {
- cancel: false,
- element: this._activeDropdownElement,
- item,
- };
- EventHandler.fire("com.woltlab.wcf.inlineEditor", `dropdownItemClick_${this._options.dropdownIdentifier}`, data);
-
- if (data.cancel) {
- event.preventDefault();
- } else {
- this._dropdownSelect(item);
- }
- }
-
- /**
- * Prepares the message for editor display.
- */
- protected _prepare(): void {
- const data = this._elements.get(this._activeElement!)!;
-
- const messageBodyEditor = document.createElement("div");
- messageBodyEditor.className = "messageBody editor";
- data.messageBodyEditor = messageBodyEditor;
-
- const icon = document.createElement("span");
- icon.className = "icon icon48 fa-spinner";
- messageBodyEditor.appendChild(icon);
-
- data.messageBody.insertAdjacentElement("afterend", messageBodyEditor);
-
- DomUtil.hide(data.messageBody);
- }
-
- /**
- * Shows the message editor.
- */
- protected _showEditor(data: AjaxResponseEditor): void {
- const id = this._getEditorId();
- const activeElement = this._activeElement!;
- const elementData = this._elements.get(activeElement)!;
-
- activeElement.classList.add("jsInvalidQuoteTarget");
- const icon = elementData.messageBodyEditor!.querySelector(".icon") as HTMLElement;
- icon.remove();
-
- const messageBody = elementData.messageBodyEditor!;
- const editor = document.createElement("div");
- editor.className = "editorContainer";
- DomUtil.setInnerHtml(editor, data.returnValues.template);
- messageBody.appendChild(editor);
-
- // bind buttons
- const formSubmit = editor.querySelector(".formSubmit") as HTMLElement;
-
- const buttonSave = formSubmit.querySelector('button[data-type="save"]') as HTMLButtonElement;
- buttonSave.addEventListener("click", () => this._save());
-
- const buttonCancel = formSubmit.querySelector('button[data-type="cancel"]') as HTMLButtonElement;
- buttonCancel.addEventListener("click", () => this._restoreMessage());
-
- EventHandler.add("com.woltlab.wcf.redactor", `submitEditor_${id}`, (data: { cancel: boolean }) => {
- data.cancel = true;
-
- this._save();
- });
-
- // hide message header and footer
- DomUtil.hide(elementData.messageHeader);
- DomUtil.hide(elementData.messageFooter);
-
- if (Environment.editor() === "redactor") {
- window.setTimeout(() => {
- if (this._options.quoteManager) {
- this._options.quoteManager.setAlternativeEditor(id);
- }
-
- UiScroll.element(activeElement);
- }, 250);
- } else {
- const editorElement = document.getElementById(id) as HTMLElement;
- editorElement.focus();
- }
- }
-
- /**
- * Restores the message view.
- */
- protected _restoreMessage(): void {
- const activeElement = this._activeElement!;
- const elementData = this._elements.get(activeElement)!;
-
- this._destroyEditor();
-
- elementData.messageBodyEditor!.remove();
- elementData.messageBodyEditor = null;
-
- DomUtil.show(elementData.messageBody);
- DomUtil.show(elementData.messageFooter);
- DomUtil.show(elementData.messageHeader);
- activeElement.classList.remove("jsInvalidQuoteTarget");
-
- this._activeElement = null;
-
- if (this._options.quoteManager) {
- this._options.quoteManager.clearAlternativeEditor();
- }
- }
-
- /**
- * Saves the editor message.
- */
- protected _save(): void {
- const parameters = {
- containerID: this._options.containerId,
- data: {
- message: "",
- },
- objectID: this._getObjectId(this._activeElement!),
- removeQuoteIDs: this._options.quoteManager ? this._options.quoteManager.getQuotesMarkedForRemoval() : [],
- };
-
- const id = this._getEditorId();
-
- // add any available settings
- const settingsContainer = document.getElementById(`settings_${id}`);
- if (settingsContainer) {
- settingsContainer
- .querySelectorAll("input, select, textarea")
- .forEach((element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement) => {
- if (element.nodeName === "INPUT" && (element.type === "checkbox" || element.type === "radio")) {
- if (!(element as HTMLInputElement).checked) {
- return;
- }
- }
-
- const name = element.name;
- if (Object.prototype.hasOwnProperty.call(parameters, name)) {
- throw new Error(`Variable overshadowing, key '${name}' is already present.`);
- }
-
- parameters[name] = element.value.trim();
- });
- }
-
- EventHandler.fire("com.woltlab.wcf.redactor2", `getText_${id}`, parameters.data);
-
- let validateResult: unknown = this._validate(parameters);
-
- // Legacy validation methods returned a plain boolean.
- if (!(validateResult instanceof Promise)) {
- if (validateResult === false) {
- validateResult = Promise.reject();
- } else {
- validateResult = Promise.resolve();
- }
- }
-
- (validateResult as Promise<void[]>).then(
- () => {
- EventHandler.fire("com.woltlab.wcf.redactor2", `submit_${id}`, parameters);
-
- Ajax.api(this, {
- actionName: "save",
- parameters: parameters,
- });
-
- this._hideEditor();
- },
- (e) => {
- const errorMessage = (e as Error).message;
- console.log(`Validation of post edit failed: ${errorMessage}`);
- },
- );
- }
-
- /**
- * Validates the message and invokes listeners to perform additional validation.
- */
- protected _validate(parameters: ArbitraryObject): Promise<void[]> {
- // remove all existing error elements
- this._activeElement!.querySelectorAll(".innerError").forEach((el) => el.remove());
-
- const data: ValidationData = {
- api: this,
- parameters: parameters,
- valid: true,
- promises: [],
- };
-
- EventHandler.fire("com.woltlab.wcf.redactor2", `validate_${this._getEditorId()}`, data);
-
- if (data.valid) {
- data.promises.push(Promise.resolve());
- } else {
- data.promises.push(Promise.reject());
- }
-
- return Promise.all(data.promises);
- }
-
- /**
- * Throws an error by showing an inline error for the target element.
- */
- throwError(element: HTMLElement, message: string): void {
- DomUtil.innerError(element, message);
- }
-
- /**
- * Shows the update message.
- */
- protected _showMessage(data: AjaxResponseMessage): void {
- const activeElement = this._activeElement!;
- const editorId = this._getEditorId();
- const elementData = this._elements.get(activeElement)!;
-
- // set new content
- DomUtil.setInnerHtml(elementData.messageBody.querySelector(".messageText")!, data.returnValues.message);
-
- // handle attachment list
- if (typeof data.returnValues.attachmentList === "string") {
- elementData.messageFooter
- .querySelectorAll(".attachmentThumbnailList, .attachmentFileList")
- .forEach((el) => el.remove());
-
- const element = document.createElement("div");
- DomUtil.setInnerHtml(element, data.returnValues.attachmentList);
-
- let node;
- while (element.childNodes.length) {
- node = element.childNodes[element.childNodes.length - 1];
- elementData.messageFooter.insertBefore(node, elementData.messageFooter.firstChild);
- }
- }
-
- if (typeof data.returnValues.poll === "string") {
- const poll = elementData.messageBody.querySelector(".pollContainer");
- if (poll !== null) {
- // The poll container is wrapped inside `.jsInlineEditorHideContent`.
- poll.parentElement!.remove();
- }
-
- const pollContainer = document.createElement("div");
- pollContainer.className = "jsInlineEditorHideContent";
- DomUtil.setInnerHtml(pollContainer, data.returnValues.poll);
-
- elementData.messageBody.insertAdjacentElement("afterbegin", pollContainer);
- }
-
- this._restoreMessage();
-
- this._updateHistory(this._getHash(this._getObjectId(activeElement)));
-
- EventHandler.fire("com.woltlab.wcf.redactor", `autosaveDestroy_${editorId}`);
-
- UiNotification.show();
-
- if (this._options.quoteManager) {
- this._options.quoteManager.clearAlternativeEditor();
- this._options.quoteManager.countQuotes();
- }
- }
-
- /**
- * Hides the editor from view.
- */
- protected _hideEditor(): void {
- const elementData = this._elements.get(this._activeElement!)!;
- const editorContainer = elementData.messageBodyEditor!.querySelector(".editorContainer") as HTMLElement;
- DomUtil.hide(editorContainer);
-
- const icon = document.createElement("span");
- icon.className = "icon icon48 fa-spinner";
- elementData.messageBodyEditor!.appendChild(icon);
- }
-
- /**
- * Restores the previously hidden editor.
- */
- protected _restoreEditor(): void {
- const elementData = this._elements.get(this._activeElement!)!;
- const messageBodyEditor = elementData.messageBodyEditor!;
-
- const icon = messageBodyEditor.querySelector(".fa-spinner") as HTMLElement;
- icon.remove();
-
- const editorContainer = messageBodyEditor.querySelector(".editorContainer") as HTMLElement;
- if (editorContainer !== null) {
- DomUtil.show(editorContainer);
- }
- }
-
- /**
- * Destroys the editor instance.
- */
- protected _destroyEditor(): void {
- EventHandler.fire("com.woltlab.wcf.redactor2", `autosaveDestroy_${this._getEditorId()}`);
- EventHandler.fire("com.woltlab.wcf.redactor2", `destroy_${this._getEditorId()}`);
- }
-
- /**
- * Returns the hash added to the url after successfully editing a message.
- */
- protected _getHash(objectId: string): string {
- return `#message${objectId}`;
- }
-
- /**
- * Updates the history to avoid old content when going back in the browser
- * history.
- */
- protected _updateHistory(hash: string): void {
- window.location.hash = hash;
- }
-
- /**
- * Returns the unique editor id.
- */
- protected _getEditorId(): string {
- return this._options.editorPrefix + this._getObjectId(this._activeElement!).toString();
- }
-
- /**
- * Returns the element's `data-object-id` value.
- */
- protected _getObjectId(element: HTMLElement): string {
- return element.dataset.objectId || "";
- }
-
- _ajaxFailure(data: ResponseData): boolean {
- const elementData = this._elements.get(this._activeElement!)!;
- const editor = elementData.messageBodyEditor!.querySelector(".redactor-layer") as HTMLElement;
-
- // handle errors occurring on editor load
- if (editor === null) {
- this._restoreMessage();
-
- return true;
- }
-
- this._restoreEditor();
-
- if (!data || data.returnValues === undefined || data.returnValues.realErrorMessage === undefined) {
- return true;
- }
-
- DomUtil.innerError(editor, data.returnValues.realErrorMessage);
-
- return false;
- }
-
- _ajaxSuccess(data: ResponseData): void {
- switch (data.actionName) {
- case "beginEdit":
- this._showEditor(data as AjaxResponseEditor);
- break;
-
- case "save":
- this._showMessage(data as AjaxResponseMessage);
- break;
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- className: this._options.className,
- interfaceName: "wcf\\data\\IMessageInlineEditorAction",
- },
- silent: true,
- };
- }
-
- /** @deprecated 3.0 - used only for backward compatibility with `WCF.Message.InlineEditor` */
- legacyEdit(containerId: string): void {
- this._click(document.getElementById(containerId), null);
- }
-}
-
-Core.enableLegacyInheritance(UiMessageInlineEditor);
-
-export = UiMessageInlineEditor;
+++ /dev/null
-/**
- * Provides access and editing of message properties.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Message/Manager
- */
-
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
-import * as Core from "../../Core";
-import DomChangeListener from "../../Dom/Change/Listener";
-import * as Language from "../../Language";
-import * as StringUtil from "../../StringUtil";
-
-interface MessageManagerOptions {
- className: string;
- selector: string;
-}
-
-type StringableValue = boolean | number | string;
-
-class UiMessageManager implements AjaxCallbackObject {
- protected readonly _elements = new Map<string, HTMLElement>();
- protected readonly _options: MessageManagerOptions;
-
- /**
- * Initializes a new manager instance.
- */
- constructor(options: MessageManagerOptions) {
- this._options = Core.extend(
- {
- className: "",
- selector: "",
- },
- options,
- ) as MessageManagerOptions;
-
- this.rebuild();
-
- DomChangeListener.add(`Ui/Message/Manager${this._options.className}`, this.rebuild.bind(this));
- }
-
- /**
- * Rebuilds the list of observed messages. You should call this method whenever a
- * message has been either added or removed from the document.
- */
- rebuild(): void {
- this._elements.clear();
-
- document.querySelectorAll(this._options.selector).forEach((element: HTMLElement) => {
- this._elements.set(element.dataset.objectId!, element);
- });
- }
-
- /**
- * Returns a boolean value for the given permission. The permission should not start
- * with "can" or "can-" as this is automatically assumed by this method.
- */
- getPermission(objectId: string, permission: string): boolean {
- permission = "can" + StringUtil.ucfirst(permission);
- const element = this._elements.get(objectId);
- if (element === undefined) {
- throw new Error(`Unknown object id '${objectId}' for selector '${this._options.selector}'`);
- }
-
- return Core.stringToBool(element.dataset[permission] || "");
- }
-
- /**
- * Returns the given property value from a message, optionally supporting a boolean return value.
- */
- getPropertyValue(objectId: string, propertyName: string, asBool: boolean): boolean | string {
- const element = this._elements.get(objectId);
- if (element === undefined) {
- throw new Error(`Unknown object id '${objectId}' for selector '${this._options.selector}'`);
- }
-
- const value = element.dataset[StringUtil.toCamelCase(propertyName)] || "";
-
- if (asBool) {
- return Core.stringToBool(value);
- }
-
- return value;
- }
-
- /**
- * Invokes a method for given message object id in order to alter its state or properties.
- */
- update(objectId: string, actionName: string, parameters?: ArbitraryObject): void {
- Ajax.api(this, {
- actionName: actionName,
- parameters: parameters || {},
- objectIDs: [objectId],
- });
- }
-
- /**
- * Updates properties and states for given object ids. Keep in mind that this method does
- * not support setting individual properties per message, instead all property changes
- * are applied to all matching message objects.
- */
- updateItems(objectIds: string | string[], data: ArbitraryObject): void {
- if (!Array.isArray(objectIds)) {
- objectIds = [objectIds];
- }
-
- objectIds.forEach((objectId) => {
- const element = this._elements.get(objectId);
- if (element === undefined) {
- return;
- }
-
- Object.entries(data).forEach(([key, value]) => {
- this._update(element, key, value as StringableValue);
- });
- });
- }
-
- /**
- * Bulk updates the properties and states for all observed messages at once.
- */
- updateAllItems(data: ArbitraryObject): void {
- const objectIds = Array.from(this._elements.keys());
-
- this.updateItems(objectIds, data);
- }
-
- /**
- * Sets or removes a message note identified by its unique CSS class.
- */
- setNote(objectId: string, className: string, htmlContent: string): void {
- const element = this._elements.get(objectId);
- if (element === undefined) {
- throw new Error(`Unknown object id '${objectId}' for selector '${this._options.selector}'`);
- }
-
- const messageFooterNotes = element.querySelector(".messageFooterNotes") as HTMLElement;
- let note = messageFooterNotes.querySelector(`.${className}`);
- if (htmlContent) {
- if (note === null) {
- note = document.createElement("p");
- note.className = "messageFooterNote " + className;
-
- messageFooterNotes.appendChild(note);
- }
-
- note.innerHTML = htmlContent;
- } else if (note !== null) {
- note.remove();
- }
- }
-
- /**
- * Updates a single property of a message element.
- */
- protected _update(element: HTMLElement, propertyName: string, propertyValue: StringableValue): void {
- element.dataset[propertyName] = propertyValue.toString();
-
- // handle special properties
- const propertyValueBoolean = propertyValue == 1 || propertyValue === true || propertyValue === "true";
- this._updateState(element, propertyName, propertyValue, propertyValueBoolean);
- }
-
- /**
- * Updates the message element's state based upon a property change.
- */
- protected _updateState(
- element: HTMLElement,
- propertyName: string,
- propertyValue: StringableValue,
- propertyValueBoolean: boolean,
- ): void {
- switch (propertyName) {
- case "isDeleted":
- if (propertyValueBoolean) {
- element.classList.add("messageDeleted");
- } else {
- element.classList.remove("messageDeleted");
- }
-
- this._toggleMessageStatus(element, "jsIconDeleted", "wcf.message.status.deleted", "red", propertyValueBoolean);
-
- break;
-
- case "isDisabled":
- if (propertyValueBoolean) {
- element.classList.add("messageDisabled");
- } else {
- element.classList.remove("messageDisabled");
- }
-
- this._toggleMessageStatus(
- element,
- "jsIconDisabled",
- "wcf.message.status.disabled",
- "green",
- propertyValueBoolean,
- );
-
- break;
- }
- }
-
- /**
- * Toggles the message status bade for provided element.
- */
- protected _toggleMessageStatus(
- element: HTMLElement,
- className: string,
- phrase: string,
- badgeColor: string,
- addBadge: boolean,
- ): void {
- let messageStatus = element.querySelector(".messageStatus");
- if (messageStatus === null) {
- const messageHeaderMetaData = element.querySelector(".messageHeaderMetaData");
- if (messageHeaderMetaData === null) {
- // can't find appropriate location to insert badge
- return;
- }
-
- messageStatus = document.createElement("ul");
- messageStatus.className = "messageStatus";
- messageHeaderMetaData.insertAdjacentElement("afterend", messageStatus);
- }
-
- let badge = messageStatus.querySelector(`.${className}`);
- if (addBadge) {
- if (badge !== null) {
- // badge already exists
- return;
- }
-
- badge = document.createElement("span");
- badge.className = `badge label ${badgeColor} ${className}`;
- badge.textContent = Language.get(phrase);
-
- const listItem = document.createElement("li");
- listItem.appendChild(badge);
- messageStatus.appendChild(listItem);
- } else {
- if (badge === null) {
- // badge does not exist
- return;
- }
-
- badge.parentElement!.remove();
- }
- }
-
- /**
- * Transforms camel-cased property names into their attribute equivalent.
- *
- * @deprecated 5.4 Access the value via `element.dataset` which uses camel-case.
- */
- protected _getAttributeName(propertyName: string): string {
- if (propertyName.indexOf("-") !== -1) {
- return propertyName;
- }
-
- return propertyName
- .split(/([A-Z][a-z]+)/)
- .map((s) => s.trim().toLowerCase())
- .filter((s) => s.length > 0)
- .join("-");
- }
-
- _ajaxSuccess(_data: ResponseData): void {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
- throw new Error("Method _ajaxSuccess() must be implemented by deriving functions.");
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- className: this._options.className,
- },
- };
- }
-}
-
-Core.enableLegacyInheritance(UiMessageManager);
-
-export = UiMessageManager;
+++ /dev/null
-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;
-}
-
-export class UiMessageQuote implements AjaxCallbackObject {
- private activeMessageId = "";
-
- private readonly className: string;
-
- private containers = new Map<string, HTMLElement>();
-
- 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: any;
-
- /**
- * Initializes the quote handler for given object type.
- */
- constructor(
- quoteManager: any, // TODO
- className: string,
- objectType: string,
- containerSelector: string,
- messageBodySelector: string,
- 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) => {
- if (this.copyQuote.classList.contains("active")) {
- 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: 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)!;
- 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 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 (!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 {
- const copyQuote = document.getElementById("quoteManagerCopy");
- copyQuote?.remove();
-
- this.copyQuote.id = "quoteManagerCopy";
- this.copyQuote.classList.add("balloonTooltip", "interactive");
-
- const buttonSaveQuote = document.createElement("span");
- buttonSaveQuote.classList.add("jsQuoteManagerStore");
- buttonSaveQuote.textContent = Language.get("wcf.message.quote.quoteSelected");
- buttonSaveQuote.addEventListener("click", (event) => this.saveQuote(event));
- this.copyQuote.appendChild(buttonSaveQuote);
-
- if (supportDirectInsert) {
- const buttonSaveAndInsertQuote = document.createElement("span");
- buttonSaveAndInsertQuote.classList.add("jsQuoteManagerQuoteAndInsert");
- buttonSaveAndInsertQuote.textContent = Language.get("wcf.message.quote.quoteAndReply");
- buttonSaveAndInsertQuote.addEventListener("click", (event) => this.saveAndInsertQuote(event));
- this.copyQuote.appendChild(buttonSaveAndInsertQuote);
- }
-
- document.body.appendChild(this.copyQuote);
- }
-
- private getSelectedText(): string {
- const selection = window.getSelection()!;
- if (selection.rangeCount) {
- return this.getNodeText(selection.getRangeAt(0).cloneContents());
- }
-
- return "";
- }
-
- private saveFullQuote(event: MouseEvent): void {
- event.preventDefault();
-
- const listItem = event.currentTarget as HTMLElement;
-
- Ajax.api(this, {
- actionName: "saveFullQuote",
- objectIDs: [listItem.dataset.objectId],
- });
-
- // mark element as quoted
- const quoteLink = listItem.querySelector("a")!;
- if (Core.stringToBool(listItem.dataset.isQuoted || "")) {
- listItem.dataset.isQuoted = "false";
- quoteLink.classList.remove("active");
- } else {
- listItem.dataset.isQuoted = "true";
- quoteLink.classList.add("active");
- }
-
- // close navigation on mobile
- const navigationList = listItem.closest(".buttonGroupNavigation") as HTMLUListElement;
- if (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<AjaxCallbackSetup> {
- 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");
- }
- });
- }
-}
-
-export default UiMessageQuote;
+++ /dev/null
-/**
- * Handles user interaction with the quick reply feature.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Message/Reply
- */
-
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
-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 UiDialog from "../Dialog";
-import * as UiNotification from "../Notification";
-import User from "../../User";
-import ControllerCaptcha from "../../Controller/Captcha";
-import { RedactorEditor } from "../Redactor/Editor";
-import * as UiScroll from "../Scroll";
-
-interface MessageReplyOptions {
- ajax: {
- className: string;
- };
- quoteManager: any;
- successMessage: string;
-}
-
-interface AjaxResponse {
- returnValues: {
- guestDialog?: string;
- guestDialogID?: string;
- lastPostTime: number;
- template?: string;
- url?: string;
- };
-}
-
-class UiMessageReply {
- protected readonly _container: HTMLElement;
- protected readonly _content: HTMLElement;
- protected _editor: RedactorEditor | null = null;
- protected _guestDialogId = "";
- protected _loadingOverlay: HTMLElement | null = null;
- protected readonly _options: MessageReplyOptions;
- protected readonly _textarea: HTMLTextAreaElement;
-
- /**
- * Initializes a new quick reply field.
- */
- constructor(opts: Partial<MessageReplyOptions>) {
- this._options = Core.extend(
- {
- ajax: {
- className: "",
- },
- quoteManager: null,
- successMessage: "wcf.global.success.add",
- },
- opts,
- ) as MessageReplyOptions;
-
- this._container = document.getElementById("messageQuickReply") as HTMLElement;
- this._content = this._container.querySelector(".messageContent") as HTMLElement;
- this._textarea = document.getElementById("text") as HTMLTextAreaElement;
-
- // prevent marking of text for quoting
- this._container.querySelector(".message")!.classList.add("jsInvalidQuoteTarget");
-
- // handle submit button
- const submitButton = this._container.querySelector('button[data-type="save"]') as HTMLButtonElement;
- submitButton.addEventListener("click", (ev) => this._submit(ev));
-
- // bind reply button
- document.querySelectorAll(".jsQuickReply").forEach((replyButton: HTMLAnchorElement) => {
- replyButton.addEventListener("click", (event) => {
- event.preventDefault();
-
- this._getEditor().WoltLabReply.showEditor();
-
- UiScroll.element(this._container, () => {
- this._getEditor().WoltLabCaret.endOfEditor();
- });
- });
- });
- }
-
- /**
- * Submits the guest dialog.
- */
- protected _submitGuestDialog(event: KeyboardEvent | MouseEvent): void {
- // only submit when enter key is pressed
- if (event instanceof KeyboardEvent && event.key !== "Enter") {
- return;
- }
-
- const target = event.currentTarget as HTMLElement;
- const dialogContent = target.closest(".dialogContent")!;
- const usernameInput = dialogContent.querySelector("input[name=username]") as HTMLInputElement;
- if (usernameInput.value === "") {
- DomUtil.innerError(usernameInput, Language.get("wcf.global.form.error.empty"));
- usernameInput.closest("dl")!.classList.add("formError");
-
- return;
- }
-
- let parameters: ArbitraryObject = {
- parameters: {
- data: {
- username: usernameInput.value,
- },
- },
- };
-
- const captchaId = target.dataset.captchaId!;
- if (ControllerCaptcha.has(captchaId)) {
- const data = ControllerCaptcha.getData(captchaId);
- if (data instanceof Promise) {
- void data.then((data) => {
- parameters = Core.extend(parameters, data) as ArbitraryObject;
- this._submit(undefined, parameters);
- });
- } else {
- parameters = Core.extend(parameters, data as ArbitraryObject) as ArbitraryObject;
- this._submit(undefined, parameters);
- }
- } else {
- this._submit(undefined, parameters);
- }
- }
-
- /**
- * Validates the message and submits it to the server.
- */
- protected _submit(event: MouseEvent | undefined, additionalParameters?: ArbitraryObject): void {
- if (event) {
- event.preventDefault();
- }
-
- // Ignore requests to submit the message while a previous request is still pending.
- if (this._content.classList.contains("loading")) {
- if (!this._guestDialogId || !UiDialog.isOpen(this._guestDialogId)) {
- return;
- }
- }
-
- if (!this._validate()) {
- // validation failed, bail out
- return;
- }
-
- this._showLoadingOverlay();
-
- // build parameters
- const parameters: ArbitraryObject = {};
- Object.entries(this._container.dataset).forEach(([key, value]) => {
- parameters[key.replace(/Id$/, "ID")] = value;
- });
-
- parameters.data = { message: this._getEditor().code.get() };
- parameters.removeQuoteIDs = this._options.quoteManager
- ? this._options.quoteManager.getQuotesMarkedForRemoval()
- : [];
-
- // add any available settings
- const settingsContainer = document.getElementById("settings_text");
- if (settingsContainer) {
- settingsContainer
- .querySelectorAll("input, select, textarea")
- .forEach((element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement) => {
- if (element.nodeName === "INPUT" && (element.type === "checkbox" || element.type === "radio")) {
- if (!(element as HTMLInputElement).checked) {
- return;
- }
- }
-
- const name = element.name;
- if (Object.prototype.hasOwnProperty.call(parameters, name)) {
- throw new Error(`Variable overshadowing, key '${name}' is already present.`);
- }
-
- parameters[name] = element.value.trim();
- });
- }
-
- EventHandler.fire("com.woltlab.wcf.redactor2", "submit_text", parameters.data as any);
-
- if (!User.userId && !additionalParameters) {
- parameters.requireGuestDialog = true;
- }
-
- Ajax.api(
- this,
- Core.extend(
- {
- parameters: parameters,
- },
- additionalParameters as ArbitraryObject,
- ),
- );
- }
-
- /**
- * Validates the message and invokes listeners to perform additional validation.
- */
- protected _validate(): boolean {
- // remove all existing error elements
- this._container.querySelectorAll(".innerError").forEach((el) => el.remove());
-
- // check if editor contains actual content
- if (this._getEditor().utils.isEmpty()) {
- this.throwError(this._textarea, Language.get("wcf.global.form.error.empty"));
- return false;
- }
-
- const data = {
- api: this,
- editor: this._getEditor(),
- message: this._getEditor().code.get(),
- valid: true,
- };
-
- EventHandler.fire("com.woltlab.wcf.redactor2", "validate_text", data);
-
- return data.valid;
- }
-
- /**
- * Throws an error by adding an inline error to target element.
- *
- * @param {Element} element erroneous element
- * @param {string} message error message
- */
- throwError(element: HTMLElement, message: string): void {
- DomUtil.innerError(element, message === "empty" ? Language.get("wcf.global.form.error.empty") : message);
- }
-
- /**
- * Displays a loading spinner while the request is processed by the server.
- */
- protected _showLoadingOverlay(): void {
- if (this._loadingOverlay === null) {
- this._loadingOverlay = document.createElement("div");
- this._loadingOverlay.className = "messageContentLoadingOverlay";
- this._loadingOverlay.innerHTML = '<span class="icon icon96 fa-spinner"></span>';
- }
-
- this._content.classList.add("loading");
- this._content.appendChild(this._loadingOverlay);
- }
-
- /**
- * Hides the loading spinner.
- */
- protected _hideLoadingOverlay(): void {
- this._content.classList.remove("loading");
-
- const loadingOverlay = this._content.querySelector(".messageContentLoadingOverlay");
- if (loadingOverlay !== null) {
- loadingOverlay.remove();
- }
- }
-
- /**
- * Resets the editor contents and notifies event listeners.
- */
- protected _reset(): void {
- this._getEditor().code.set("<p>\u200b</p>");
-
- EventHandler.fire("com.woltlab.wcf.redactor2", "reset_text");
- }
-
- /**
- * Handles errors occurred during server processing.
- */
- protected _handleError(data: ResponseData): void {
- const parameters = {
- api: this,
- cancel: false,
- returnValues: data.returnValues,
- };
- EventHandler.fire("com.woltlab.wcf.redactor2", "handleError_text", parameters);
-
- if (!parameters.cancel) {
- this.throwError(this._textarea, data.returnValues.realErrorMessage);
- }
- }
-
- /**
- * Returns the current editor instance.
- */
- protected _getEditor(): RedactorEditor {
- if (this._editor === null) {
- if (typeof window.jQuery === "function") {
- this._editor = window.jQuery(this._textarea).data("redactor") as RedactorEditor;
- } else {
- throw new Error("Unable to access editor, jQuery has not been loaded yet.");
- }
- }
-
- return this._editor;
- }
-
- /**
- * Inserts the rendered message into the post list, unless the post is on the next
- * page in which case a redirect will be performed instead.
- */
- protected _insertMessage(data: AjaxResponse): void {
- this._getEditor().WoltLabAutosave.reset();
-
- // redirect to new page
- if (data.returnValues.url) {
- if (window.location.href == data.returnValues.url) {
- window.location.reload();
- }
- window.location.href = data.returnValues.url;
- } else {
- if (data.returnValues.template) {
- let elementId: string;
-
- // insert HTML
- if (this._container.dataset.sortOrder === "DESC") {
- DomUtil.insertHtml(data.returnValues.template, this._container, "after");
- elementId = DomUtil.identify(this._container.nextElementSibling!);
- } else {
- let insertBefore = this._container;
- if (
- insertBefore.previousElementSibling &&
- insertBefore.previousElementSibling.classList.contains("messageListPagination")
- ) {
- insertBefore = insertBefore.previousElementSibling as HTMLElement;
- }
-
- DomUtil.insertHtml(data.returnValues.template, insertBefore, "before");
- elementId = DomUtil.identify(insertBefore.previousElementSibling!);
- }
-
- // update last post time
- this._container.dataset.lastPostTime = data.returnValues.lastPostTime.toString();
-
- window.history.replaceState(undefined, "", `#${elementId}`);
- UiScroll.element(document.getElementById(elementId)!);
- }
-
- UiNotification.show(Language.get(this._options.successMessage));
-
- if (this._options.quoteManager) {
- this._options.quoteManager.countQuotes();
- }
-
- DomChangeListener.trigger();
- }
- }
-
- /**
- * @param {{returnValues:{guestDialog:string,guestDialogID:string}}} data
- * @protected
- */
- _ajaxSuccess(data: AjaxResponse): void {
- if (!User.userId && !data.returnValues.guestDialogID) {
- throw new Error("Missing 'guestDialogID' return value for guest.");
- }
-
- if (!User.userId && data.returnValues.guestDialog) {
- const guestDialogId = data.returnValues.guestDialogID!;
-
- UiDialog.openStatic(guestDialogId, data.returnValues.guestDialog, {
- closable: false,
- onClose: function () {
- if (ControllerCaptcha.has(guestDialogId)) {
- ControllerCaptcha.delete(guestDialogId);
- }
- },
- title: Language.get("wcf.global.confirmation.title"),
- });
-
- const dialog = UiDialog.getDialog(guestDialogId)!;
- const submit = dialog.content.querySelector("input[type=submit]") as HTMLInputElement;
- submit.addEventListener("click", (ev) => this._submitGuestDialog(ev));
- const input = dialog.content.querySelector("input[type=text]") as HTMLInputElement;
- input.addEventListener("keypress", (ev) => this._submitGuestDialog(ev));
-
- this._guestDialogId = guestDialogId;
- } else {
- this._insertMessage(data);
-
- if (!User.userId) {
- UiDialog.close(data.returnValues.guestDialogID!);
- }
-
- this._reset();
-
- this._hideLoadingOverlay();
- }
- }
-
- _ajaxFailure(data: ResponseData): boolean {
- this._hideLoadingOverlay();
-
- if (data === null || data.returnValues === undefined || data.returnValues.realErrorMessage === undefined) {
- return true;
- }
-
- this._handleError(data);
-
- return false;
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "quickReply",
- className: this._options.ajax.className,
- interfaceName: "wcf\\data\\IMessageQuickReplyAction",
- },
- silent: true,
- };
- }
-}
-
-Core.enableLegacyInheritance(UiMessageReply);
-
-export = UiMessageReply;
+++ /dev/null
-/**
- * Provides buttons to share a page through multiple social community sites.
- *
- * @author Marcel Werk
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Message/Share
- */
-
-import * as EventHandler from "../../Event/Handler";
-import * as StringUtil from "../../StringUtil";
-
-let _pageDescription = "";
-let _pageUrl = "";
-
-function share(objectName: string, url: string, appendUrl: boolean, pageUrl: string) {
- // fallback for plugins
- if (!pageUrl) {
- pageUrl = _pageUrl;
- }
-
- window.open(
- url.replace("{pageURL}", pageUrl).replace("{text}", _pageDescription + (appendUrl ? `%20${pageUrl}` : "")),
- objectName,
- "height=600,width=600",
- );
-}
-
-interface Provider {
- link: HTMLElement | null;
-
- share(event: MouseEvent): void;
-}
-
-interface Providers {
- [key: string]: Provider;
-}
-
-export function init(): void {
- const title = document.querySelector('meta[property="og:title"]') as HTMLMetaElement;
- if (title !== null) {
- _pageDescription = encodeURIComponent(title.content);
- }
-
- const url = document.querySelector('meta[property="og:url"]') as HTMLMetaElement;
- if (url !== null) {
- _pageUrl = encodeURIComponent(url.content);
- }
-
- document.querySelectorAll(".jsMessageShareButtons").forEach((container: HTMLElement) => {
- container.classList.remove("jsMessageShareButtons");
-
- let pageUrl = encodeURIComponent(StringUtil.unescapeHTML(container.dataset.url || ""));
- if (!pageUrl) {
- pageUrl = _pageUrl;
- }
-
- const providers: Providers = {
- facebook: {
- link: container.querySelector(".jsShareFacebook"),
- share(event: MouseEvent): void {
- event.preventDefault();
- share("facebook", "https://www.facebook.com/sharer.php?u={pageURL}&t={text}", true, pageUrl);
- },
- },
- reddit: {
- link: container.querySelector(".jsShareReddit"),
- share(event: MouseEvent): void {
- event.preventDefault();
- share("reddit", "https://ssl.reddit.com/submit?url={pageURL}", false, pageUrl);
- },
- },
- twitter: {
- link: container.querySelector(".jsShareTwitter"),
- share(event: MouseEvent): void {
- event.preventDefault();
- share("twitter", "https://twitter.com/share?url={pageURL}&text={text}", false, pageUrl);
- },
- },
- linkedIn: {
- link: container.querySelector(".jsShareLinkedIn"),
- share(event: MouseEvent): void {
- event.preventDefault();
- share("linkedIn", "https://www.linkedin.com/cws/share?url={pageURL}", false, pageUrl);
- },
- },
- pinterest: {
- link: container.querySelector(".jsSharePinterest"),
- share(event: MouseEvent): void {
- event.preventDefault();
- share(
- "pinterest",
- "https://www.pinterest.com/pin/create/link/?url={pageURL}&description={text}",
- false,
- pageUrl,
- );
- },
- },
- xing: {
- link: container.querySelector(".jsShareXing"),
- share(event: MouseEvent): void {
- event.preventDefault();
- share("xing", "https://www.xing.com/social_plugins/share?url={pageURL}", false, pageUrl);
- },
- },
- whatsApp: {
- link: container.querySelector(".jsShareWhatsApp"),
- share(event: MouseEvent): void {
- event.preventDefault();
- window.location.href = "https://api.whatsapp.com/send?text=" + _pageDescription + "%20" + _pageUrl;
- },
- },
- };
-
- EventHandler.fire("com.woltlab.wcf.message.share", "shareProvider", {
- container,
- providers,
- pageDescription: _pageDescription,
- pageUrl: _pageUrl,
- });
-
- Object.values(providers).forEach((provider) => {
- if (provider.link !== null) {
- const link = provider.link as HTMLAnchorElement;
- link.addEventListener("click", (ev) => provider.share(ev));
- }
- });
- });
-}
+++ /dev/null
-/**
- * Wrapper around Twitter's createTweet API.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Message/TwitterEmbed
- */
-
-import "https://platform.twitter.com/widgets.js";
-
-type CallbackReady = (twttr: Twitter) => void;
-
-const twitterReady = new Promise((resolve: CallbackReady) => {
- twttr.ready(resolve);
-});
-
-/**
- * Embed the tweet identified by the given tweetId into the given container.
- *
- * @param {HTMLElement} container
- * @param {string} tweetId
- * @param {boolean} removeChildren Whether to remove existing children of the given container after embedding the tweet.
- * @return {HTMLElement} The Tweet element created by Twitter.
- */
-export async function embedTweet(
- container: HTMLElement,
- tweetId: string,
- removeChildren = false,
-): Promise<HTMLElement> {
- const twitter = await twitterReady;
-
- const tweet = await twitter.widgets.createTweet(tweetId, container, {
- dnt: true,
- lang: document.documentElement.lang,
- });
-
- if (tweet && removeChildren) {
- while (container.lastChild) {
- container.removeChild(container.lastChild);
- }
- container.appendChild(tweet);
- }
-
- return tweet;
-}
-
-/**
- * Embeds tweets into all elements with a data-wsc-twitter-tweet attribute, removing
- * existing children.
- */
-export function embedAll(): void {
- document.querySelectorAll("[data-wsc-twitter-tweet]").forEach((container: HTMLElement) => {
- const tweetId = container.dataset.wscTwitterTweet;
- if (tweetId) {
- delete container.dataset.wscTwitterTweet;
-
- void embedTweet(container, tweetId, true);
- }
- });
-}
+++ /dev/null
-/**
- * Prompts the user for their consent before displaying external media.
- *
- * @author Alexander Ebert
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Message/UserConsent
- */
-
-import * as Ajax from "../../Ajax";
-import * as Core from "../../Core";
-import DomChangeListener from "../../Dom/Change/Listener";
-import DomUtil from "../../Dom/Util";
-import User from "../../User";
-
-class UserConsent {
- private enableAll = false;
- private readonly knownButtons = new WeakSet();
-
- constructor() {
- if (window.sessionStorage.getItem(`${Core.getStoragePrefix()}user-consent`) === "all") {
- this.enableAll = true;
- }
-
- this.registerEventListeners();
-
- DomChangeListener.add("WoltLabSuite/Core/Ui/Message/UserConsent", () => this.registerEventListeners());
- }
-
- private registerEventListeners(): void {
- if (this.enableAll) {
- this.enableAllExternalMedia();
- } else {
- document.querySelectorAll(".jsButtonMessageUserConsentEnable").forEach((button: HTMLAnchorElement) => {
- if (!this.knownButtons.has(button)) {
- this.knownButtons.add(button);
-
- button.addEventListener("click", (ev) => this.click(ev));
- }
- });
- }
- }
-
- private click(event: MouseEvent): void {
- event.preventDefault();
-
- this.enableAll = true;
-
- this.enableAllExternalMedia();
-
- if (User.userId) {
- Ajax.apiOnce({
- data: {
- actionName: "saveUserConsent",
- className: "wcf\\data\\user\\UserAction",
- },
- silent: true,
- });
- } else {
- window.sessionStorage.setItem(`${Core.getStoragePrefix()}user-consent`, "all");
- }
- }
-
- private enableExternalMedia(container: HTMLElement): void {
- const payload = atob(container.dataset.payload!);
-
- DomUtil.insertHtml(payload, container, "before");
- container.remove();
- }
-
- private enableAllExternalMedia(): void {
- document.querySelectorAll(".messageUserConsent").forEach((el: HTMLElement) => this.enableExternalMedia(el));
- }
-}
-
-let userConsent: UserConsent;
-
-export function init(): void {
- if (!userConsent) {
- userConsent = new UserConsent();
- }
-}
+++ /dev/null
-/**
- * Modifies the interface to provide a better usability for mobile devices.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Mobile
- */
-
-import * as Core from "../Core";
-import DomChangeListener from "../Dom/Change/Listener";
-import * as Environment from "../Environment";
-import * as EventHandler from "../Event/Handler";
-import * as UiAlignment from "./Alignment";
-import UiCloseOverlay from "./CloseOverlay";
-import * as UiDropdownReusable from "./Dropdown/Reusable";
-import UiPageMenuMain from "./Page/Menu/Main";
-import UiPageMenuUser from "./Page/Menu/User";
-import * as UiScreen from "./Screen";
-
-interface MainMenuMorePayload {
- identifier: string;
- handler: UiPageMenuMain;
-}
-
-let _dropdownMenu: HTMLUListElement | null = null;
-let _dropdownMenuMessage = null;
-let _enabled = false;
-let _enabledLGTouchNavigation = false;
-let _enableMobileMenu = false;
-const _knownMessages = new WeakSet<HTMLElement>();
-let _mobileSidebarEnabled = false;
-let _pageMenuMain: UiPageMenuMain;
-let _pageMenuUser: UiPageMenuUser;
-let _messageGroups: HTMLCollection | null = null;
-const _sidebars: HTMLElement[] = [];
-
-function _init(): void {
- _enabled = true;
-
- initSearchBar();
- _initButtonGroupNavigation();
- _initMessages();
- _initMobileMenu();
-
- UiCloseOverlay.add("WoltLabSuite/Core/Ui/Mobile", _closeAllMenus);
- DomChangeListener.add("WoltLabSuite/Core/Ui/Mobile", () => {
- _initButtonGroupNavigation();
- _initMessages();
- });
-}
-
-function initSearchBar(): void {
- const searchBar = document.getElementById("pageHeaderSearch")!;
- const searchInput = document.getElementById("pageHeaderSearchInput")!;
-
- let scrollTop: number | null = null;
- EventHandler.add("com.woltlab.wcf.MainMenuMobile", "more", (data: MainMenuMorePayload) => {
- if (data.identifier === "com.woltlab.wcf.search") {
- data.handler.close();
-
- if (Environment.platform() === "ios") {
- scrollTop = document.body.scrollTop;
- UiScreen.scrollDisable();
- }
-
- const pageHeader = document.getElementById("pageHeader")!;
- searchBar.style.setProperty("top", `${pageHeader.offsetHeight}px`, "");
- searchBar.classList.add("open");
- searchInput.focus();
-
- if (Environment.platform() === "ios") {
- document.body.scrollTop = 0;
- }
- }
- });
-
- document.getElementById("main")!.addEventListener("click", () => {
- if (searchBar) {
- searchBar.classList.remove("open");
- }
-
- if (Environment.platform() === "ios" && scrollTop) {
- UiScreen.scrollEnable();
- document.body.scrollTop = scrollTop;
- scrollTop = null;
- }
- });
-}
-
-function _initButtonGroupNavigation(): void {
- document.querySelectorAll(".buttonGroupNavigation").forEach((navigation) => {
- if (navigation.classList.contains("jsMobileButtonGroupNavigation")) {
- return;
- } else {
- navigation.classList.add("jsMobileButtonGroupNavigation");
- }
-
- const list = navigation.querySelector(".buttonList") as HTMLUListElement;
- if (list.childElementCount === 0) {
- // ignore objects without options
- return;
- }
-
- navigation.parentElement!.classList.add("hasMobileNavigation");
-
- const button = document.createElement("a");
- button.className = "dropdownLabel";
- const span = document.createElement("span");
- span.className = "icon icon24 fa-ellipsis-v";
- button.appendChild(span);
- button.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
-
- navigation.classList.toggle("open");
- });
-
- list.addEventListener("click", function (event) {
- event.stopPropagation();
- navigation.classList.remove("open");
- });
-
- navigation.insertBefore(button, navigation.firstChild);
- });
-}
-
-function _initMessages(): void {
- document.querySelectorAll(".message").forEach((message: HTMLElement) => {
- if (_knownMessages.has(message)) {
- return;
- }
-
- const navigation = message.querySelector(".jsMobileNavigation") as HTMLAnchorElement;
- if (navigation) {
- navigation.addEventListener("click", (event) => {
- event.stopPropagation();
-
- // mimic dropdown behavior
- window.setTimeout(() => {
- navigation.classList.remove("open");
- }, 10);
- });
-
- const quickOptions = message.querySelector(".messageQuickOptions");
- if (quickOptions && navigation.childElementCount) {
- quickOptions.classList.add("active");
- quickOptions.addEventListener("click", (event) => {
- const target = event.target as HTMLElement;
-
- if (_enabled && UiScreen.is("screen-sm-down") && target.nodeName !== "LABEL" && target.nodeName !== "INPUT") {
- event.preventDefault();
- event.stopPropagation();
-
- _toggleMobileNavigation(message, quickOptions, navigation);
- }
- });
- }
- }
- _knownMessages.add(message);
- });
-}
-
-function _initMobileMenu(): void {
- if (_enableMobileMenu) {
- _pageMenuMain = new UiPageMenuMain();
- _pageMenuUser = new UiPageMenuUser();
- }
-}
-
-function _closeAllMenus(): void {
- document.querySelectorAll(".jsMobileButtonGroupNavigation.open, .jsMobileNavigation.open").forEach((menu) => {
- menu.classList.remove("open");
- });
-
- if (_enabled && _dropdownMenu) {
- closeDropdown();
- }
-}
-
-function _enableMobileSidebar(): void {
- _mobileSidebarEnabled = true;
-}
-
-function _disableMobileSidebar(): void {
- _mobileSidebarEnabled = false;
- _sidebars.forEach(function (sidebar) {
- sidebar.classList.remove("open");
- });
-}
-
-function _setupMobileSidebar(): void {
- _sidebars.forEach(function (sidebar) {
- sidebar.addEventListener("mousedown", function (event) {
- if (_mobileSidebarEnabled && event.target === sidebar) {
- event.preventDefault();
- sidebar.classList.toggle("open");
- }
- });
- });
- _mobileSidebarEnabled = true;
-}
-
-function closeDropdown(): void {
- _dropdownMenu!.classList.remove("dropdownOpen");
-}
-
-function _toggleMobileNavigation(message, quickOptions, navigation): void {
- if (_dropdownMenu === null) {
- _dropdownMenu = document.createElement("ul");
- _dropdownMenu.className = "dropdownMenu";
- UiDropdownReusable.init("com.woltlab.wcf.jsMobileNavigation", _dropdownMenu);
- } else if (_dropdownMenu.classList.contains("dropdownOpen")) {
- closeDropdown();
- if (_dropdownMenuMessage === message) {
- // toggle behavior
- return;
- }
- }
- _dropdownMenu.innerHTML = "";
- UiCloseOverlay.execute();
- _rebuildMobileNavigation(navigation);
- const previousNavigation = navigation.previousElementSibling;
- if (previousNavigation && previousNavigation.classList.contains("messageFooterButtonsExtra")) {
- const divider = document.createElement("li");
- divider.className = "dropdownDivider";
- _dropdownMenu.appendChild(divider);
- _rebuildMobileNavigation(previousNavigation);
- }
- UiAlignment.set(_dropdownMenu, quickOptions, {
- horizontal: "right",
- allowFlip: "vertical",
- });
- _dropdownMenu.classList.add("dropdownOpen");
- _dropdownMenuMessage = message;
-}
-
-function _setupLGTouchNavigation(): void {
- _enabledLGTouchNavigation = true;
- document.querySelectorAll(".boxMenuHasChildren > a").forEach((element: HTMLElement) => {
- element.addEventListener("touchstart", function (event) {
- if (_enabledLGTouchNavigation && element.getAttribute("aria-expanded") === "false") {
- event.preventDefault();
-
- element.setAttribute("aria-expanded", "true");
-
- // Register an new event listener after the touch ended, which is triggered once when an
- // element on the page is pressed. This allows us to reset the touch status of the navigation
- // entry when the entry is no longer open, so that it does not redirect to the page when you
- // click it again.
- element.addEventListener(
- "touchend",
- () => {
- document.body.addEventListener(
- "touchstart",
- () => {
- document.body.addEventListener(
- "touchend",
- (event) => {
- const parent = element.parentElement!;
- const target = event.target as HTMLElement;
- if (!parent.contains(target) && target !== parent) {
- element.setAttribute("aria-expanded", "false");
- }
- },
- {
- once: true,
- },
- );
- },
- {
- once: true,
- },
- );
- },
- { once: true },
- );
- }
- });
- });
-}
-
-function _enableLGTouchNavigation(): void {
- _enabledLGTouchNavigation = true;
-}
-
-function _disableLGTouchNavigation(): void {
- _enabledLGTouchNavigation = false;
-}
-
-function _rebuildMobileNavigation(navigation: HTMLElement): void {
- navigation.querySelectorAll(".button").forEach((button: HTMLElement) => {
- if (button.classList.contains("ignoreMobileNavigation")) {
- // The reaction button was hidden up until 5.2.2, but was enabled again in 5.2.3. This check
- // exists to make sure that there is no unexpected behavior in 3rd party apps or plugins that
- // used the same code and hid the reaction button via a CSS class in the template.
- if (!button.classList.contains("reactButton")) {
- return;
- }
- }
-
- const item = document.createElement("li");
- if (button.classList.contains("active")) {
- item.className = "active";
- }
-
- const label = button.querySelector("span:not(.icon)")!;
- item.innerHTML = `<a href="#">${label.textContent!}</a>`;
- item.children[0].addEventListener("click", function (event) {
- event.preventDefault();
- event.stopPropagation();
- if (button.nodeName === "A") {
- button.click();
- } else {
- Core.triggerEvent(button, "click");
- }
- closeDropdown();
- });
- _dropdownMenu!.appendChild(item);
- });
-}
-
-/**
- * Initializes the mobile UI.
- */
-export function setup(enableMobileMenu: boolean): void {
- _enableMobileMenu = enableMobileMenu;
- document.querySelectorAll(".sidebar").forEach((sidebar: HTMLElement) => {
- _sidebars.push(sidebar);
- });
-
- if (Environment.touch()) {
- document.documentElement.classList.add("touch");
- }
- if (Environment.platform() !== "desktop") {
- document.documentElement.classList.add("mobile");
- }
-
- const messageGroupList = document.querySelector(".messageGroupList");
- if (messageGroupList) {
- _messageGroups = messageGroupList.getElementsByClassName("messageGroup");
- }
-
- UiScreen.on("screen-md-down", {
- match: enable,
- unmatch: disable,
- setup: _init,
- });
- UiScreen.on("screen-sm-down", {
- match: enableShadow,
- unmatch: disableShadow,
- setup: enableShadow,
- });
- UiScreen.on("screen-md-down", {
- match: _enableMobileSidebar,
- unmatch: _disableMobileSidebar,
- setup: _setupMobileSidebar,
- });
-
- // On the large tablets (e.g. iPad Pro) the navigation is not usable, because there is not the mobile
- // layout displayed, but the normal one for the desktop. The navigation reacts to a hover status if a
- // menu item has several submenu items. Logically, this cannot be created with the tablet, so that we
- // display the submenu here after a single click and only follow the link after another click.
- if (Environment.touch() && (Environment.platform() === "ios" || Environment.platform() === "android")) {
- UiScreen.on("screen-lg", {
- match: _enableLGTouchNavigation,
- unmatch: _disableLGTouchNavigation,
- setup: _setupLGTouchNavigation,
- });
- }
-}
-
-/**
- * Enables the mobile UI.
- */
-export function enable(): void {
- _enabled = true;
- if (_enableMobileMenu) {
- _pageMenuMain.enable();
- _pageMenuUser.enable();
- }
-}
-
-/**
- * Enables shadow links for larger click areas on messages.
- */
-export function enableShadow(): void {
- if (_messageGroups) {
- rebuildShadow(_messageGroups, ".messageGroupLink");
- }
-}
-
-/**
- * Disables the mobile UI.
- */
-export function disable(): void {
- _enabled = false;
- if (_enableMobileMenu) {
- _pageMenuMain.disable();
- _pageMenuUser.disable();
- }
-}
-
-/**
- * Disables shadow links.
- */
-export function disableShadow(): void {
- if (_messageGroups) {
- removeShadow(_messageGroups);
- }
- if (_dropdownMenu) {
- closeDropdown();
- }
-}
-
-export function rebuildShadow(elements: HTMLElement[] | HTMLCollection, linkSelector: string): void {
- Array.from(elements).forEach((element) => {
- const parent = element.parentElement as HTMLElement;
-
- let shadow = parent.querySelector(".mobileLinkShadow") as HTMLAnchorElement;
- if (shadow === null) {
- const link = element.querySelector(linkSelector) as HTMLAnchorElement;
- if (link.href) {
- shadow = document.createElement("a");
- shadow.className = "mobileLinkShadow";
- shadow.href = link.href;
- parent.appendChild(shadow);
- parent.classList.add("mobileLinkShadowContainer");
- }
- }
- });
-}
-
-export function removeShadow(elements: HTMLElement[] | HTMLCollection): void {
- Array.from(elements).forEach((element) => {
- const parent = element.parentElement!;
- if (parent.classList.contains("mobileLinkShadowContainer")) {
- const shadow = parent.querySelector(".mobileLinkShadow");
- if (shadow !== null) {
- shadow.remove();
- }
-
- parent.classList.remove("mobileLinkShadowContainer");
- }
- });
-}
+++ /dev/null
-/**
- * Simple notification overlay.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Ui/Notification (alias)
- * @module WoltLabSuite/Core/Ui/Notification
- */
-
-import * as Language from "../Language";
-
-type Callback = () => void;
-
-let _busy = false;
-let _callback: Callback | null = null;
-let _didInit = false;
-let _message: HTMLElement;
-let _notificationElement: HTMLElement;
-let _timeout: number;
-
-function init() {
- if (_didInit) {
- return;
- }
- _didInit = true;
-
- _notificationElement = document.createElement("div");
- _notificationElement.id = "systemNotification";
-
- _message = document.createElement("p");
- _message.addEventListener("click", hide);
- _notificationElement.appendChild(_message);
-
- document.body.appendChild(_notificationElement);
-}
-
-/**
- * Hides the notification and invokes the callback if provided.
- */
-function hide() {
- clearTimeout(_timeout);
-
- _notificationElement.classList.remove("active");
-
- if (_callback !== null) {
- _callback();
- }
-
- _busy = false;
-}
-
-/**
- * Displays a notification.
- */
-export function show(message?: string, callback?: Callback | null, cssClassName?: string): void {
- if (_busy) {
- return;
- }
- _busy = true;
-
- init();
-
- _callback = typeof callback === "function" ? callback : null;
- _message.className = cssClassName || "success";
- _message.textContent = Language.get(message || "wcf.global.success");
-
- _notificationElement.classList.add("active");
- _timeout = setTimeout(hide, 2000);
-}
+++ /dev/null
-/**
- * Provides page actions such as "jump to top" and clipboard actions.
- *
- * @author Alexander Ebert
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Page/Action
- */
-
-import * as Core from "../../Core";
-import * as Language from "../../Language";
-
-const _buttons = new Map<string, HTMLElement>();
-
-let _container: HTMLElement;
-let _didInit = false;
-let _lastPosition = -1;
-let _toTopButton: HTMLElement;
-let _wrapper: HTMLElement;
-
-const _resetLastPosition = Core.debounce(() => {
- _lastPosition = -1;
-}, 50);
-
-function buildToTopButton(): HTMLAnchorElement {
- const button = document.createElement("a");
- button.className = "button buttonPrimary pageActionButtonToTop initiallyHidden jsTooltip";
- button.href = "";
- button.title = Language.get("wcf.global.scrollUp");
- button.setAttribute("aria-hidden", "true");
- button.innerHTML = '<span class="icon icon32 fa-angle-up"></span>';
-
- button.addEventListener("click", scrollToTop);
-
- return button;
-}
-
-function onScroll(): void {
- if (document.documentElement.classList.contains("disableScrolling")) {
- // Ignore any scroll events that take place while body scrolling is disabled,
- // because it messes up the scroll offsets.
- return;
- }
-
- const offset = window.pageYOffset;
- if (offset === _lastPosition) {
- // Ignore any scroll event that is fired but without a position change. This can
- // happen after closing a dialog that prevented the body from being scrolled.
- _resetLastPosition();
- return;
- }
-
- if (offset >= 300) {
- if (_toTopButton.classList.contains("initiallyHidden")) {
- _toTopButton.classList.remove("initiallyHidden");
- }
-
- _toTopButton.setAttribute("aria-hidden", "false");
- } else {
- _toTopButton.setAttribute("aria-hidden", "true");
- }
-
- renderContainer();
-
- if (_lastPosition !== -1) {
- _wrapper.classList[offset < _lastPosition ? "remove" : "add"]("scrolledDown");
- }
-
- _lastPosition = -1;
-}
-
-function scrollToTop(event: MouseEvent): void {
- event.preventDefault();
-
- const topAnchor = document.getElementById("top")!;
- topAnchor.scrollIntoView({ behavior: "smooth" });
-}
-
-/**
- * Toggles the container's visibility.
- */
-function renderContainer() {
- const visibleChild = Array.from(_container.children).find((element) => {
- return element.getAttribute("aria-hidden") === "false";
- });
-
- _container.classList[visibleChild ? "add" : "remove"]("active");
-}
-
-/**
- * Initializes the page action container.
- */
-export function setup(): void {
- if (_didInit) {
- return;
- }
-
- _didInit = true;
-
- _wrapper = document.createElement("div");
- _wrapper.className = "pageAction";
-
- _container = document.createElement("div");
- _container.className = "pageActionButtons";
- _wrapper.appendChild(_container);
-
- _toTopButton = buildToTopButton();
- _wrapper.appendChild(_toTopButton);
-
- document.body.appendChild(_wrapper);
-
- const debounce = Core.debounce(onScroll, 100);
- window.addEventListener(
- "scroll",
- () => {
- if (_lastPosition === -1) {
- _lastPosition = window.pageYOffset;
-
- // Invoke the scroll handler once to immediately respond to
- // the user action before debouncing all further calls.
- window.setTimeout(() => {
- onScroll();
-
- _lastPosition = window.pageYOffset;
- }, 60);
- }
-
- debounce();
- },
- { passive: true },
- );
-
- window.addEventListener(
- "touchstart",
- () => {
- // Force a reset of the scroll position to trigger an immediate reaction
- // when the user touches the display again.
- if (_lastPosition !== -1) {
- _lastPosition = -1;
- }
- },
- { passive: true },
- );
-
- onScroll();
-}
-
-/**
- * Adds a button to the page action list. You can optionally provide a button name to
- * insert the button right before it. Unmatched button names or empty value will cause
- * the button to be prepended to the list.
- */
-export function add(buttonName: string, button: HTMLElement, insertBeforeButton?: string): void {
- setup();
-
- // The wrapper is required for backwards compatibility, because some implementations rely on a
- // dedicated parent element to insert elements, for example, for drop-down menus.
- const wrapper = document.createElement("div");
- wrapper.className = "pageActionButton";
- wrapper.dataset.name = buttonName;
- wrapper.setAttribute("aria-hidden", "true");
-
- button.classList.add("button");
- button.classList.add("buttonPrimary");
- wrapper.appendChild(button);
-
- let insertBefore: HTMLElement | null = null;
- if (insertBeforeButton) {
- insertBefore = _buttons.get(insertBeforeButton) || null;
- if (insertBefore) {
- insertBefore = insertBefore.parentElement;
- }
- }
-
- if (!insertBefore && _container.childElementCount) {
- insertBefore = _container.children[0] as HTMLElement;
- }
- if (!insertBefore) {
- insertBefore = _container.firstChild as HTMLElement;
- }
-
- _container.insertBefore(wrapper, insertBefore);
- _wrapper.classList.remove("scrolledDown");
-
- _buttons.set(buttonName, button);
-
- // Query a layout related property to force a reflow, otherwise the transition is optimized away.
- // noinspection BadExpressionStatementJS
- wrapper.offsetParent;
-
- // Toggle the visibility to force the transition to be applied.
- wrapper.setAttribute("aria-hidden", "false");
-
- renderContainer();
-}
-
-/**
- * Returns true if there is a registered button with the provided name.
- */
-export function has(buttonName: string): boolean {
- return _buttons.has(buttonName);
-}
-
-/**
- * Returns the stored button by name or undefined.
- */
-export function get(buttonName: string): HTMLElement | undefined {
- return _buttons.get(buttonName);
-}
-
-/**
- * Removes a button by its button name.
- */
-export function remove(buttonName: string): void {
- const button = _buttons.get(buttonName);
- if (button !== undefined) {
- const listItem = button.parentElement!;
- const callback = () => {
- try {
- if (Core.stringToBool(listItem.getAttribute("aria-hidden"))) {
- _container.removeChild(listItem);
- _buttons.delete(buttonName);
- }
-
- listItem.removeEventListener("transitionend", callback);
- } catch (e) {
- // ignore errors if the element has already been removed
- }
- };
-
- listItem.addEventListener("transitionend", callback);
-
- hide(buttonName);
- }
-}
-
-/**
- * Hides a button by its button name.
- */
-export function hide(buttonName: string): void {
- const button = _buttons.get(buttonName);
- if (button) {
- const parent = button.parentElement!;
- parent.setAttribute("aria-hidden", "true");
-
- renderContainer();
- }
-}
-
-/**
- * Shows a button by its button name.
- */
-export function show(buttonName: string): void {
- const button = _buttons.get(buttonName);
- if (button) {
- const parent = button.parentElement!;
- if (parent.classList.contains("initiallyHidden")) {
- parent.classList.remove("initiallyHidden");
- }
-
- parent.setAttribute("aria-hidden", "false");
- _wrapper.classList.remove("scrolledDown");
-
- renderContainer();
- }
-}
+++ /dev/null
-/**
- * Manages the sticky page header.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Page/Header/Fixed
- */
-
-import * as EventHandler from "../../../Event/Handler";
-import * as UiAlignment from "../../Alignment";
-import UiCloseOverlay from "../../CloseOverlay";
-import UiDropdownSimple from "../../Dropdown/Simple";
-import * as UiScreen from "../../Screen";
-
-let _isMobile = false;
-
-let _pageHeader: HTMLElement;
-let _pageHeaderPanel: HTMLElement;
-let _pageHeaderSearch: HTMLElement;
-let _searchInput: HTMLInputElement;
-let _topMenu: HTMLElement;
-let _userPanelSearchButton: HTMLElement;
-
-/**
- * Provides the collapsible search bar.
- */
-function initSearchBar(): void {
- _pageHeaderSearch = document.getElementById("pageHeaderSearch")!;
- _pageHeaderSearch.addEventListener("click", (ev) => ev.stopPropagation());
-
- _pageHeaderPanel = document.getElementById("pageHeaderPanel")!;
- _searchInput = document.getElementById("pageHeaderSearchInput") as HTMLInputElement;
- _topMenu = document.getElementById("topMenu")!;
-
- _userPanelSearchButton = document.getElementById("userPanelSearchButton")!;
- _userPanelSearchButton.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
-
- if (_pageHeader.classList.contains("searchBarOpen")) {
- closeSearchBar();
- } else {
- openSearchBar();
- }
- });
-
- UiCloseOverlay.add("WoltLabSuite/Core/Ui/Page/Header/Fixed", () => {
- if (_pageHeader.classList.contains("searchBarForceOpen")) {
- return;
- }
-
- closeSearchBar();
- });
-
- EventHandler.add("com.woltlab.wcf.MainMenuMobile", "more", (data) => {
- if (data.identifier === "com.woltlab.wcf.search") {
- data.handler.close(true);
-
- _userPanelSearchButton.click();
- }
- });
-}
-
-/**
- * Opens the search bar.
- */
-function openSearchBar(): void {
- window.WCF.Dropdown.Interactive.Handler.closeAll();
-
- _pageHeader.classList.add("searchBarOpen");
- _userPanelSearchButton.parentElement!.classList.add("open");
-
- if (!_isMobile) {
- // calculate value for `right` on desktop
- UiAlignment.set(_pageHeaderSearch, _topMenu, {
- horizontal: "right",
- });
- }
-
- _pageHeaderSearch.style.setProperty("top", `${_pageHeaderPanel.clientHeight}px`, "");
- _searchInput.focus();
-
- window.setTimeout(() => {
- _searchInput.selectionStart = _searchInput.selectionEnd = _searchInput.value.length;
- }, 1);
-}
-
-/**
- * Closes the search bar.
- */
-function closeSearchBar(): void {
- _pageHeader.classList.remove("searchBarOpen");
- _userPanelSearchButton.parentElement!.classList.remove("open");
-
- ["bottom", "left", "right", "top"].forEach((propertyName) => {
- _pageHeaderSearch.style.removeProperty(propertyName);
- });
-
- _searchInput.blur();
-
- // close the scope selection
- const scope = _pageHeaderSearch.querySelector(".pageHeaderSearchType")!;
- UiDropdownSimple.close(scope.id);
-}
-
-/**
- * Initializes the sticky page header handler.
- */
-export function init(): void {
- _pageHeader = document.getElementById("pageHeader")!;
-
- initSearchBar();
-
- UiScreen.on("screen-md-down", {
- match() {
- _isMobile = true;
- },
- unmatch() {
- _isMobile = false;
- },
- setup() {
- _isMobile = true;
- },
- });
-
- EventHandler.add("com.woltlab.wcf.Search", "close", closeSearchBar);
-}
+++ /dev/null
-/**
- * Handles main menu overflow and a11y.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Page/Header/Menu
- */
-
-import * as Environment from "../../../Environment";
-import * as Language from "../../../Language";
-import * as UiScreen from "../../Screen";
-
-let _enabled = false;
-
-let _buttonShowNext: HTMLAnchorElement;
-let _buttonShowPrevious: HTMLAnchorElement;
-let _firstElement: HTMLElement;
-let _menu: HTMLElement;
-
-let _marginLeft = 0;
-let _invisibleLeft: HTMLElement[] = [];
-let _invisibleRight: HTMLElement[] = [];
-
-/**
- * Enables the overflow handler.
- */
-function enable(): void {
- _enabled = true;
-
- // Safari waits three seconds for a font to be loaded which causes the header menu items
- // to be extremely wide while waiting for the font to be loaded. The extremely wide menu
- // items in turn can cause the overflow controls to be shown even if the width of the header
- // menu, after the font has been loaded successfully, does not require them. This width
- // issue results in the next button being shown for a short time. To circumvent this issue,
- // we wait a second before showing the obverflow controls in Safari.
- // see https://webkit.org/blog/6643/improved-font-loading/
- if (Environment.browser() === "safari") {
- window.setTimeout(rebuildVisibility, 1000);
- } else {
- rebuildVisibility();
-
- // IE11 sometimes suffers from a timing issue
- window.setTimeout(rebuildVisibility, 1000);
- }
-}
-
-/**
- * Disables the overflow handler.
- */
-function disable(): void {
- _enabled = false;
-}
-
-/**
- * Displays the next three menu items.
- */
-function showNext(event: MouseEvent): void {
- event.preventDefault();
-
- if (_invisibleRight.length) {
- const showItem = _invisibleRight.slice(0, 3).pop()!;
- setMarginLeft(_menu.clientWidth - (showItem.offsetLeft + showItem.clientWidth));
-
- if (_menu.lastElementChild === showItem) {
- _buttonShowNext.classList.remove("active");
- }
-
- _buttonShowPrevious.classList.add("active");
- }
-}
-
-/**
- * Displays the previous three menu items.
- */
-function showPrevious(event: MouseEvent): void {
- event.preventDefault();
-
- if (_invisibleLeft.length) {
- const showItem = _invisibleLeft.slice(-3)[0];
- setMarginLeft(showItem.offsetLeft * -1);
-
- if (_menu.firstElementChild === showItem) {
- _buttonShowPrevious.classList.remove("active");
- }
-
- _buttonShowNext.classList.add("active");
- }
-}
-
-/**
- * Sets the first item's margin-left value that is
- * used to move the menu contents around.
- */
-function setMarginLeft(offset: number): void {
- _marginLeft = Math.min(_marginLeft + offset, 0);
-
- _firstElement.style.setProperty("margin-left", `${_marginLeft}px`, "");
-}
-
-/**
- * Toggles button overlays and rebuilds the list
- * of invisible items from left to right.
- */
-function rebuildVisibility(): void {
- if (!_enabled) return;
-
- _invisibleLeft = [];
- _invisibleRight = [];
-
- const menuWidth = _menu.clientWidth;
- if (_menu.scrollWidth > menuWidth || _marginLeft < 0) {
- Array.from(_menu.children).forEach((child: HTMLElement) => {
- const offsetLeft = child.offsetLeft;
- if (offsetLeft < 0) {
- _invisibleLeft.push(child);
- } else if (offsetLeft + child.clientWidth > menuWidth) {
- _invisibleRight.push(child);
- }
- });
- }
-
- _buttonShowPrevious.classList[_invisibleLeft.length ? "add" : "remove"]("active");
- _buttonShowNext.classList[_invisibleRight.length ? "add" : "remove"]("active");
-}
-
-/**
- * Builds the UI and binds the event listeners.
- */
-function setup(): void {
- setupOverflow();
- setupA11y();
-}
-
-/**
- * Setups overflow handling.
- */
-function setupOverflow(): void {
- const menuParent = _menu.parentElement!;
-
- _buttonShowNext = document.createElement("a");
- _buttonShowNext.className = "mainMenuShowNext";
- _buttonShowNext.href = "#";
- _buttonShowNext.innerHTML = '<span class="icon icon32 fa-angle-right"></span>';
- _buttonShowNext.setAttribute("aria-hidden", "true");
- _buttonShowNext.addEventListener("click", showNext);
-
- menuParent.appendChild(_buttonShowNext);
-
- _buttonShowPrevious = document.createElement("a");
- _buttonShowPrevious.className = "mainMenuShowPrevious";
- _buttonShowPrevious.href = "#";
- _buttonShowPrevious.innerHTML = '<span class="icon icon32 fa-angle-left"></span>';
- _buttonShowPrevious.setAttribute("aria-hidden", "true");
- _buttonShowPrevious.addEventListener("click", showPrevious);
-
- menuParent.insertBefore(_buttonShowPrevious, menuParent.firstChild);
-
- _firstElement.addEventListener("transitionend", rebuildVisibility);
-
- window.addEventListener("resize", () => {
- _firstElement.style.setProperty("margin-left", "0px", "");
- _marginLeft = 0;
-
- rebuildVisibility();
- });
-
- enable();
-}
-
-/**
- * Setups a11y improvements.
- */
-function setupA11y(): void {
- _menu.querySelectorAll(".boxMenuHasChildren").forEach((element) => {
- const link = element.querySelector(".boxMenuLink")!;
- link.setAttribute("aria-haspopup", "true");
- link.setAttribute("aria-expanded", "false");
-
- const showMenuButton = document.createElement("button");
- showMenuButton.className = "visuallyHidden";
- showMenuButton.tabIndex = 0;
- showMenuButton.setAttribute("role", "button");
- showMenuButton.setAttribute("aria-label", Language.get("wcf.global.button.showMenu"));
- element.insertBefore(showMenuButton, link.nextSibling);
-
- let showMenu = false;
- showMenuButton.addEventListener("click", () => {
- showMenu = !showMenu;
- link.setAttribute("aria-expanded", showMenu ? "true" : "false");
- showMenuButton.setAttribute(
- "aria-label",
- Language.get(showMenu ? "wcf.global.button.hideMenu" : "wcf.global.button.showMenu"),
- );
- });
- });
-}
-
-/**
- * Initializes the main menu overflow handling.
- */
-export function init(): void {
- const menu = document.querySelector(".mainMenu .boxMenu") as HTMLElement;
- const firstElement = menu && menu.childElementCount ? (menu.children[0] as HTMLElement) : null;
- if (firstElement === null) {
- throw new Error("Unable to find the main menu.");
- }
-
- _menu = menu;
- _firstElement = firstElement;
-
- UiScreen.on("screen-lg", {
- match: enable,
- unmatch: disable,
- setup: setup,
- });
-}
+++ /dev/null
-/**
- * Utility class to provide a 'Jump To' overlay.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Page/JumpTo
- */
-
-import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
-import * as Language from "../../Language";
-import UiDialog from "../Dialog";
-
-class UiPageJumpTo implements DialogCallbackObject {
- private activeElement: HTMLElement;
- private description: HTMLElement;
- private elements = new Map<HTMLElement, Callback>();
- private input: HTMLInputElement;
- private submitButton: HTMLButtonElement;
-
- /**
- * Initializes a 'Jump To' element.
- */
- init(element: HTMLElement, callback?: Callback | null): void {
- if (!callback) {
- const redirectUrl = element.dataset.link;
- if (redirectUrl) {
- callback = (pageNo) => {
- window.location.href = redirectUrl.replace(/pageNo=%d/, `pageNo=${pageNo}`);
- };
- } else {
- callback = () => {
- // Do nothing.
- };
- }
- } else if (typeof callback !== "function") {
- throw new TypeError("Expected a valid function for parameter 'callback'.");
- }
-
- if (!this.elements.has(element)) {
- element.querySelectorAll(".jumpTo").forEach((jumpTo: HTMLElement) => {
- jumpTo.addEventListener("click", (ev) => this.click(element, ev));
- this.elements.set(element, callback!);
- });
- }
- }
-
- /**
- * Handles clicks on the trigger element.
- */
- private click(element: HTMLElement, event: MouseEvent): void {
- event.preventDefault();
-
- this.activeElement = element;
-
- UiDialog.open(this);
-
- const pages = element.dataset.pages || "0";
- this.input.value = pages;
- this.input.max = pages;
- this.input.select();
-
- this.description.textContent = Language.get("wcf.page.jumpTo.description").replace(/#pages#/, pages);
- }
-
- /**
- * Handles changes to the page number input field.
- *
- * @param {object} event event object
- */
- _keyUp(event: KeyboardEvent): void {
- if (event.key === "Enter" && !this.submitButton.disabled) {
- this.submit();
- return;
- }
-
- const pageNo = +this.input.value;
- this.submitButton.disabled = pageNo < 1 || pageNo > +this.input.max;
- }
-
- /**
- * Invokes the callback with the chosen page number as first argument.
- */
- private submit(): void {
- const callback = this.elements.get(this.activeElement) as Callback;
- callback(+this.input.value);
-
- UiDialog.close(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- const source = `<dl>
- <dt><label for="jsPaginationPageNo">${Language.get("wcf.page.jumpTo")}</label></dt>
- <dd>
- <input type="number" id="jsPaginationPageNo" value="1" min="1" max="1" class="tiny">
- <small></small>
- </dd>
- </dl>
- <div class="formSubmit">
- <button class="buttonPrimary">${Language.get("wcf.global.button.submit")}</button>
- </div>`;
-
- return {
- id: "paginationOverlay",
- options: {
- onSetup: (content) => {
- this.input = content.querySelector("input")!;
- this.input.addEventListener("keyup", (ev) => this._keyUp(ev));
-
- this.description = content.querySelector("small")!;
-
- this.submitButton = content.querySelector("button")!;
- this.submitButton.addEventListener("click", () => this.submit());
- },
- title: Language.get("wcf.global.page.pagination"),
- },
- source: source,
- };
- }
-}
-
-let jumpTo: UiPageJumpTo | null = null;
-
-function getUiPageJumpTo(): UiPageJumpTo {
- if (jumpTo === null) {
- jumpTo = new UiPageJumpTo();
- }
-
- return jumpTo;
-}
-
-/**
- * Initializes a 'Jump To' element.
- */
-export function init(element: HTMLElement, callback?: Callback | null): void {
- getUiPageJumpTo().init(element, callback);
-}
-
-type Callback = (pageNo: number) => void;
+++ /dev/null
-/**
- * Provides a touch-friendly fullscreen menu.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Page/Menu/Abstract
- */
-
-import * as Core from "../../../Core";
-import * as Environment from "../../../Environment";
-import * as EventHandler from "../../../Event/Handler";
-import * as Language from "../../../Language";
-import * as DomTraverse from "../../../Dom/Traverse";
-import * as UiScreen from "../../Screen";
-
-const _pageContainer = document.getElementById("pageContainer")!;
-
-const enum TouchPosition {
- AtEdge = 20,
- MovedHorizontally = 5,
- MovedVertically = 20,
-}
-
-/**
- * Which edge of the menu is touched? Empty string
- * if no menu is currently touched.
- *
- * One 'left', 'right' or ''.
- */
-let _androidTouching = "";
-
-interface ItemData {
- itemList: HTMLOListElement;
- parentItemList: HTMLOListElement;
-}
-
-abstract class UiPageMenuAbstract {
- private readonly activeList: HTMLOListElement[] = [];
- protected readonly button: HTMLElement;
- private depth = 0;
- private enabled = true;
- private readonly eventIdentifier: string;
- private readonly items = new Map<HTMLAnchorElement, ItemData>();
- protected readonly menu: HTMLElement;
- private removeActiveList = false;
-
- protected constructor(eventIdentifier: string, elementId: string, buttonSelector: string) {
- if (document.body.dataset.template === "packageInstallationSetup") {
- // work-around for WCFSetup on mobile
- return;
- }
-
- this.eventIdentifier = eventIdentifier;
- this.menu = document.getElementById(elementId)!;
-
- const callbackOpen = this.open.bind(this);
- this.button = document.querySelector(buttonSelector) as HTMLElement;
- this.button.addEventListener("click", callbackOpen);
-
- this.initItems();
- this.initHeader();
-
- EventHandler.add(this.eventIdentifier, "open", callbackOpen);
- EventHandler.add(this.eventIdentifier, "close", this.close.bind(this));
- EventHandler.add(this.eventIdentifier, "updateButtonState", this.updateButtonState.bind(this));
-
- this.menu.addEventListener("animationend", () => {
- if (!this.menu.classList.contains("open")) {
- this.menu.querySelectorAll(".menuOverlayItemList").forEach((itemList) => {
- // force the main list to be displayed
- itemList.classList.remove("active", "hidden");
- });
- }
- });
-
- this.menu.children[0].addEventListener("transitionend", () => {
- this.menu.classList.add("allowScroll");
-
- if (this.removeActiveList) {
- this.removeActiveList = false;
-
- const list = this.activeList.pop();
- if (list) {
- list.classList.remove("activeList");
- }
- }
- });
-
- const backdrop = document.createElement("div");
- backdrop.className = "menuOverlayMobileBackdrop";
- backdrop.addEventListener("click", this.close.bind(this));
-
- this.menu.insertAdjacentElement("afterend", backdrop);
-
- this.menu.parentElement!.insertBefore(backdrop, this.menu.nextSibling);
-
- this.updateButtonState();
-
- if (Environment.platform() === "android") {
- this.initializeAndroid();
- }
- }
-
- /**
- * Opens the menu.
- */
- open(event?: MouseEvent): boolean {
- if (!this.enabled) {
- return false;
- }
-
- if (event instanceof Event) {
- event.preventDefault();
- }
-
- this.menu.classList.add("open");
- this.menu.classList.add("allowScroll");
- this.menu.children[0].classList.add("activeList");
-
- UiScreen.scrollDisable();
-
- _pageContainer.classList.add("menuOverlay-" + this.menu.id);
-
- UiScreen.pageOverlayOpen();
-
- return true;
- }
-
- /**
- * Closes the menu.
- */
- close(event?: Event): boolean {
- if (event instanceof Event) {
- event.preventDefault();
- }
-
- if (this.menu.classList.contains("open")) {
- this.menu.classList.remove("open");
-
- UiScreen.scrollEnable();
- UiScreen.pageOverlayClose();
-
- _pageContainer.classList.remove("menuOverlay-" + this.menu.id);
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Enables the touch menu.
- */
- enable(): void {
- this.enabled = true;
- }
-
- /**
- * Disables the touch menu.
- */
- disable(): void {
- this.enabled = false;
-
- this.close();
- }
-
- /**
- * Initializes the Android Touch Menu.
- */
- private initializeAndroid(): void {
- // specify on which side of the page the menu appears
- let appearsAt: "left" | "right";
- switch (this.menu.id) {
- case "pageUserMenuMobile":
- appearsAt = "right";
- break;
- case "pageMainMenuMobile":
- appearsAt = "left";
- break;
- default:
- return;
- }
-
- const backdrop = this.menu.nextElementSibling as HTMLElement;
-
- // horizontal position of the touch start
- let touchStart: { x: number; y: number } | undefined = undefined;
-
- document.addEventListener("touchstart", (event) => {
- const touches = event.touches;
-
- let isLeftEdge: boolean;
- let isRightEdge: boolean;
-
- const isOpen = this.menu.classList.contains("open");
-
- // check whether we touch the edges of the menu
- if (appearsAt === "left") {
- isLeftEdge = !isOpen && touches[0].clientX < TouchPosition.AtEdge;
- isRightEdge = isOpen && Math.abs(this.menu.offsetWidth - touches[0].clientX) < TouchPosition.AtEdge;
- } else {
- isLeftEdge =
- isOpen &&
- Math.abs(document.body.clientWidth - this.menu.offsetWidth - touches[0].clientX) < TouchPosition.AtEdge;
- isRightEdge = !isOpen && document.body.clientWidth - touches[0].clientX < TouchPosition.AtEdge;
- }
-
- // abort if more than one touch
- if (touches.length > 1) {
- if (_androidTouching) {
- Core.triggerEvent(document, "touchend");
- }
- return;
- }
-
- // break if a touch is in progress
- if (_androidTouching) {
- return;
- }
-
- // break if no edge has been touched
- if (!isLeftEdge && !isRightEdge) {
- return;
- }
-
- // break if a different menu is open
- if (UiScreen.pageOverlayIsActive()) {
- const found = _pageContainer.classList.contains(`menuOverlay-${this.menu.id}`);
- if (!found) {
- return;
- }
- }
- // break if redactor is in use
- if (document.documentElement.classList.contains("redactorActive")) {
- return;
- }
-
- touchStart = {
- x: touches[0].clientX,
- y: touches[0].clientY,
- };
-
- if (isLeftEdge) {
- _androidTouching = "left";
- }
- if (isRightEdge) {
- _androidTouching = "right";
- }
- });
-
- document.addEventListener("touchend", (event) => {
- // break if we did not start a touch
- if (!_androidTouching || !touchStart) {
- return;
- }
-
- // break if the menu did not even start opening
- if (!this.menu.classList.contains("open")) {
- // reset
- touchStart = undefined;
- _androidTouching = "";
- return;
- }
-
- // last known position of the finger
- let position: number;
- if (event) {
- position = event.changedTouches[0].clientX;
- } else {
- position = touchStart.x;
- }
-
- // clean up touch styles
- this.menu.classList.add("androidMenuTouchEnd");
- this.menu.style.removeProperty("transform");
- backdrop.style.removeProperty(appearsAt);
- this.menu.addEventListener(
- "transitionend",
- () => {
- this.menu.classList.remove("androidMenuTouchEnd");
- },
- { once: true },
- );
-
- // check whether the user moved the finger far enough
- if (appearsAt === "left") {
- if (_androidTouching === "left" && position < touchStart.x + 100) {
- this.close();
- }
- if (_androidTouching === "right" && position < touchStart.x - 100) {
- this.close();
- }
- } else {
- if (_androidTouching === "left" && position > touchStart.x + 100) {
- this.close();
- }
- if (_androidTouching === "right" && position > touchStart.x - 100) {
- this.close();
- }
- }
-
- // reset
- touchStart = undefined;
- _androidTouching = "";
- });
-
- document.addEventListener("touchmove", (event) => {
- // break if we did not start a touch
- if (!_androidTouching || !touchStart) {
- return;
- }
-
- const touches = event.touches;
-
- // check whether the user started moving in the correct direction
- // this avoids false positives, in case the user just wanted to tap
- let movedFromEdge = false;
- if (_androidTouching === "left") {
- movedFromEdge = touches[0].clientX > touchStart.x + TouchPosition.MovedHorizontally;
- }
- if (_androidTouching === "right") {
- movedFromEdge = touches[0].clientX < touchStart.x - TouchPosition.MovedHorizontally;
- }
-
- const movedVertically = Math.abs(touches[0].clientY - touchStart.y) > TouchPosition.MovedVertically;
-
- let isOpen = this.menu.classList.contains("open");
- if (!isOpen && movedFromEdge && !movedVertically) {
- // the menu is not yet open, but the user moved into the right direction
- this.open();
- isOpen = true;
- }
-
- if (isOpen) {
- // update CSS to the new finger position
- let position = touches[0].clientX;
- if (appearsAt === "right") {
- position = document.body.clientWidth - position;
- }
- if (position > this.menu.offsetWidth) {
- position = this.menu.offsetWidth;
- }
- if (position < 0) {
- position = 0;
- }
-
- const offset = (appearsAt === "left" ? 1 : -1) * (position - this.menu.offsetWidth);
- this.menu.style.setProperty("transform", `translateX(${offset}px)`);
- backdrop.style.setProperty(appearsAt, Math.min(this.menu.offsetWidth, position).toString() + "px");
- }
- });
- }
-
- /**
- * Initializes all menu items.
- */
- private initItems(): void {
- this.menu.querySelectorAll(".menuOverlayItemLink").forEach((element: HTMLAnchorElement) => {
- this.initItem(element);
- });
- }
-
- /**
- * Initializes a single menu item.
- */
- private initItem(item: HTMLAnchorElement): void {
- // check if it should contain a 'more' link w/ an external callback
- const parent = item.parentElement!;
- const more = parent.dataset.more;
- if (more) {
- item.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
-
- EventHandler.fire(this.eventIdentifier, "more", {
- handler: this,
- identifier: more,
- item: item,
- parent: parent,
- });
- });
-
- return;
- }
-
- const itemList = item.nextElementSibling as HTMLOListElement;
- if (itemList === null) {
- return;
- }
-
- // handle static items with an icon-type button next to it (acp menu)
- if (itemList.nodeName !== "OL" && itemList.classList.contains("menuOverlayItemLinkIcon")) {
- // add wrapper
- const wrapper = document.createElement("span");
- wrapper.className = "menuOverlayItemWrapper";
- parent.insertBefore(wrapper, item);
- wrapper.appendChild(item);
-
- while (wrapper.nextElementSibling) {
- wrapper.appendChild(wrapper.nextElementSibling);
- }
-
- return;
- }
-
- const isLink = item.href !== "#";
- const parentItemList = parent.parentElement as HTMLOListElement;
- let itemTitle = itemList.dataset.title;
-
- this.items.set(item, {
- itemList: itemList,
- parentItemList: parentItemList,
- });
-
- if (!itemTitle) {
- itemTitle = DomTraverse.childByClass(item, "menuOverlayItemTitle")!.textContent!;
- itemList.dataset.title = itemTitle;
- }
-
- const callbackLink = this.showItemList.bind(this, item);
- if (isLink) {
- const wrapper = document.createElement("span");
- wrapper.className = "menuOverlayItemWrapper";
- parent.insertBefore(wrapper, item);
- wrapper.appendChild(item);
-
- const moreLink = document.createElement("a");
- moreLink.href = "#";
- moreLink.className = "menuOverlayItemLinkIcon" + (item.classList.contains("active") ? " active" : "");
- moreLink.innerHTML = '<span class="icon icon24 fa-angle-right"></span>';
- moreLink.addEventListener("click", callbackLink);
- wrapper.appendChild(moreLink);
- } else {
- item.classList.add("menuOverlayItemLinkMore");
- item.addEventListener("click", callbackLink);
- }
-
- const backLinkItem = document.createElement("li");
- backLinkItem.className = "menuOverlayHeader";
-
- const wrapper = document.createElement("span");
- wrapper.className = "menuOverlayItemWrapper";
-
- const backLink = document.createElement("a");
- backLink.href = "#";
- backLink.className = "menuOverlayItemLink menuOverlayBackLink";
- backLink.textContent = parentItemList.dataset.title || "";
- backLink.addEventListener("click", this.hideItemList.bind(this, item));
-
- const closeLink = document.createElement("a");
- closeLink.href = "#";
- closeLink.className = "menuOverlayItemLinkIcon";
- closeLink.innerHTML = '<span class="icon icon24 fa-times"></span>';
- closeLink.addEventListener("click", this.close.bind(this));
-
- wrapper.appendChild(backLink);
- wrapper.appendChild(closeLink);
- backLinkItem.appendChild(wrapper);
-
- itemList.insertBefore(backLinkItem, itemList.firstElementChild);
-
- if (!backLinkItem.nextElementSibling!.classList.contains("menuOverlayTitle")) {
- const titleItem = document.createElement("li");
- titleItem.className = "menuOverlayTitle";
- const title = document.createElement("span");
- title.textContent = itemTitle;
- titleItem.appendChild(title);
-
- itemList.insertBefore(titleItem, backLinkItem.nextElementSibling);
- }
- }
-
- /**
- * Renders the menu item list header.
- */
- private initHeader(): void {
- const listItem = document.createElement("li");
- listItem.className = "menuOverlayHeader";
-
- const wrapper = document.createElement("span");
- wrapper.className = "menuOverlayItemWrapper";
- listItem.appendChild(wrapper);
-
- const logoWrapper = document.createElement("span");
- logoWrapper.className = "menuOverlayLogoWrapper";
- wrapper.appendChild(logoWrapper);
-
- const logo = document.createElement("span");
- logo.className = "menuOverlayLogo";
- const pageLogo = this.menu.dataset.pageLogo!;
- logo.style.setProperty("background-image", `url("${pageLogo}")`, "");
- logoWrapper.appendChild(logo);
-
- const closeLink = document.createElement("a");
- closeLink.href = "#";
- closeLink.className = "menuOverlayItemLinkIcon";
- closeLink.innerHTML = '<span class="icon icon24 fa-times"></span>';
- closeLink.addEventListener("click", this.close.bind(this));
- wrapper.appendChild(closeLink);
-
- const list = DomTraverse.childByClass(this.menu, "menuOverlayItemList")!;
- list.insertBefore(listItem, list.firstElementChild);
- }
-
- /**
- * Hides an item list, return to the parent item list.
- */
- private hideItemList(item: HTMLAnchorElement, event: MouseEvent): void {
- if (event instanceof Event) {
- event.preventDefault();
- }
-
- this.menu.classList.remove("allowScroll");
- this.removeActiveList = true;
-
- const data = this.items.get(item)!;
- data.parentItemList.classList.remove("hidden");
-
- this.updateDepth(false);
- }
-
- /**
- * Shows the child item list.
- */
- private showItemList(item: HTMLAnchorElement, event: MouseEvent): void {
- event.preventDefault();
-
- const data = this.items.get(item)!;
-
- const load = data.itemList.dataset.load;
- if (load) {
- if (!Core.stringToBool(item.dataset.loaded || "")) {
- const target = event.currentTarget as HTMLElement;
- const icon = target.firstElementChild!;
- if (icon.classList.contains("fa-angle-right")) {
- icon.classList.remove("fa-angle-right");
- icon.classList.add("fa-spinner");
- }
-
- EventHandler.fire(this.eventIdentifier, "load_" + load);
-
- return;
- }
- }
-
- this.menu.classList.remove("allowScroll");
-
- data.itemList.classList.add("activeList");
- data.parentItemList.classList.add("hidden");
-
- this.activeList.push(data.itemList);
-
- this.updateDepth(true);
- }
-
- private updateDepth(increase: boolean): void {
- this.depth += increase ? 1 : -1;
-
- let offset = this.depth * -100;
- if (Language.get("wcf.global.pageDirection") === "rtl") {
- // reverse logic for RTL
- offset *= -1;
- }
-
- const child = this.menu.children[0] as HTMLElement;
- child.style.setProperty("transform", `translateX(${offset}%)`, "");
- }
-
- protected updateButtonState(): void {
- let hasNewContent = false;
- const itemList = this.menu.querySelector(".menuOverlayItemList");
- this.menu.querySelectorAll(".badgeUpdate").forEach((badge) => {
- const value = badge.textContent!;
- if (~~value > 0 && badge.closest(".menuOverlayItemList") === itemList) {
- hasNewContent = true;
- }
- });
-
- this.button.classList[hasNewContent ? "add" : "remove"]("pageMenuMobileButtonHasContent");
- }
-}
-
-Core.enableLegacyInheritance(UiPageMenuAbstract);
-
-export = UiPageMenuAbstract;
+++ /dev/null
-/**
- * Provides the touch-friendly fullscreen main menu.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Page/Menu/Main
- */
-
-import * as Core from "../../../Core";
-import DomUtil from "../../../Dom/Util";
-import * as Language from "../../../Language";
-import UiPageMenuAbstract from "./Abstract";
-
-class UiPageMenuMain extends UiPageMenuAbstract {
- private hasItems = false;
- private readonly navigationList: HTMLOListElement;
- private readonly title: HTMLElement;
-
- /**
- * Initializes the touch-friendly fullscreen main menu.
- */
- constructor() {
- super("com.woltlab.wcf.MainMenuMobile", "pageMainMenuMobile", "#pageHeader .mainMenu");
-
- this.title = document.getElementById("pageMainMenuMobilePageOptionsTitle") as HTMLElement;
- if (this.title !== null) {
- this.navigationList = document.querySelector(".jsPageNavigationIcons") as HTMLOListElement;
- }
-
- this.button.setAttribute("aria-label", Language.get("wcf.menu.page"));
- this.button.setAttribute("role", "button");
- }
-
- open(event?: MouseEvent): boolean {
- if (!super.open(event)) {
- return false;
- }
-
- if (this.title === null) {
- return true;
- }
-
- this.hasItems = this.navigationList && this.navigationList.childElementCount > 0;
-
- if (this.hasItems) {
- while (this.navigationList.childElementCount) {
- const item = this.navigationList.children[0];
-
- item.classList.add("menuOverlayItem", "menuOverlayItemOption");
- item.addEventListener("click", (ev) => {
- ev.stopPropagation();
-
- this.close();
- });
-
- const link = item.children[0];
- link.classList.add("menuOverlayItemLink");
- link.classList.add("box24");
-
- link.children[1].classList.remove("invisible");
- link.children[1].classList.add("menuOverlayItemTitle");
-
- this.title.insertAdjacentElement("afterend", item);
- }
-
- DomUtil.show(this.title);
- } else {
- DomUtil.hide(this.title);
- }
-
- return true;
- }
-
- close(event?: Event): boolean {
- if (!super.close(event)) {
- return false;
- }
-
- if (this.hasItems) {
- DomUtil.hide(this.title);
-
- let item = this.title.nextElementSibling;
- while (item && item.classList.contains("menuOverlayItemOption")) {
- item.classList.remove("menuOverlayItem", "menuOverlayItemOption");
- item.removeEventListener("click", (ev) => {
- ev.stopPropagation();
-
- this.close();
- });
-
- const link = item.children[0];
- link.classList.remove("menuOverlayItemLink");
- link.classList.remove("box24");
-
- link.children[1].classList.add("invisible");
- link.children[1].classList.remove("menuOverlayItemTitle");
-
- this.navigationList.appendChild(item);
-
- item = item.nextElementSibling;
- }
- }
-
- return true;
- }
-}
-
-Core.enableLegacyInheritance(UiPageMenuMain);
-
-export = UiPageMenuMain;
+++ /dev/null
-/**
- * Provides the touch-friendly fullscreen user menu.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Page/Menu/User
- */
-
-import * as Core from "../../../Core";
-import * as EventHandler from "../../../Event/Handler";
-import * as Language from "../../../Language";
-import UiPageMenuAbstract from "./Abstract";
-
-interface EventPayload {
- count: number;
- identifier: string;
-}
-
-class UiPageMenuUser extends UiPageMenuAbstract {
- /**
- * Initializes the touch-friendly fullscreen user menu.
- */
- constructor() {
- // check if user menu is actually empty
- const menu = document.querySelector("#pageUserMenuMobile > .menuOverlayItemList")!;
- if (menu.childElementCount === 1 && menu.children[0].classList.contains("menuOverlayTitle")) {
- const userPanel = document.querySelector("#pageHeader .userPanel")!;
- userPanel.classList.add("hideUserPanel");
- return;
- }
-
- super("com.woltlab.wcf.UserMenuMobile", "pageUserMenuMobile", "#pageHeader .userPanel");
-
- EventHandler.add("com.woltlab.wcf.userMenu", "updateBadge", (data) => this.updateBadge(data));
-
- this.button.setAttribute("aria-label", Language.get("wcf.menu.user"));
- this.button.setAttribute("role", "button");
- }
-
- close(event?: Event): boolean {
- // The user menu is not initialized if there are no items to display.
- if (this.menu === undefined) {
- return false;
- }
-
- const dropdown = window.WCF.Dropdown.Interactive.Handler.getOpenDropdown();
- if (dropdown) {
- if (event) {
- event.preventDefault();
- event.stopPropagation();
- }
-
- dropdown.close();
-
- return true;
- }
-
- return super.close(event);
- }
-
- private updateBadge(data: EventPayload): void {
- this.menu.querySelectorAll(".menuOverlayItemBadge").forEach((item: HTMLElement) => {
- if (item.dataset.badgeIdentifier === data.identifier) {
- let badge = item.querySelector(".badge");
- if (data.count) {
- if (badge === null) {
- badge = document.createElement("span");
- badge.className = "badge badgeUpdate";
- item.appendChild(badge);
- }
-
- badge.textContent = data.count.toString();
- } else if (badge !== null) {
- badge.remove();
- }
-
- this.updateButtonState();
- }
- });
- }
-}
-
-Core.enableLegacyInheritance(UiPageMenuUser);
-
-export = UiPageMenuUser;
+++ /dev/null
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../Ajax/Data";
-import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
-import DomUtil from "../../Dom/Util";
-import * as Language from "../../Language";
-import * as StringUtil from "../../StringUtil";
-import UiDialog from "../Dialog";
-
-type CallbackSelect = (value: string) => void;
-
-interface SearchResult {
- displayLink: string;
- name: string;
- pageID: number;
-}
-
-interface AjaxResponse extends DatabaseObjectActionResponse {
- returnValues: SearchResult[];
-}
-
-class UiPageSearch implements AjaxCallbackObject, DialogCallbackObject {
- private callbackSelect?: CallbackSelect = undefined;
- private resultContainer?: HTMLElement = undefined;
- private resultList?: HTMLOListElement = undefined;
- private searchInput?: HTMLInputElement = undefined;
-
- open(callbackSelect: CallbackSelect): void {
- this.callbackSelect = callbackSelect;
-
- UiDialog.open(this);
- }
-
- private search(event: Event): void {
- event.preventDefault();
-
- const inputContainer = this.searchInput!.parentNode as HTMLElement;
-
- const value = this.searchInput!.value.trim();
- if (value.length < 3) {
- DomUtil.innerError(inputContainer, Language.get("wcf.page.search.error.tooShort"));
- return;
- } else {
- DomUtil.innerError(inputContainer, false);
- }
-
- Ajax.api(this, {
- parameters: {
- searchString: value,
- },
- });
- }
-
- private click(event: MouseEvent): void {
- event.preventDefault();
-
- const page = event.currentTarget as HTMLElement;
- const pageTitle = page.querySelector("h3")!;
-
- this.callbackSelect!(page.dataset.pageId! + "#" + pageTitle.textContent!.replace(/['"]/g, ""));
-
- UiDialog.close(this);
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- const html = data.returnValues
- .map((page) => {
- const name = StringUtil.escapeHTML(page.name);
- const displayLink = StringUtil.escapeHTML(page.displayLink);
-
- return `<li>
- <div class="containerHeadline pointer" data-page-id="${page.pageID}">
- <h3>${name}</h3>
- <small>${displayLink}</small>
- </div>
- </li>`;
- })
- .join("");
-
- this.resultList!.innerHTML = html;
-
- DomUtil[html ? "show" : "hide"](this.resultContainer!);
-
- if (html) {
- this.resultList!.querySelectorAll(".containerHeadline").forEach((item: HTMLElement) => {
- item.addEventListener("click", (ev) => this.click(ev));
- });
- } else {
- DomUtil.innerError(this.searchInput!.parentElement!, Language.get("wcf.page.search.error.noResults"));
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "search",
- className: "wcf\\data\\page\\PageAction",
- },
- };
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "wcfUiPageSearch",
- options: {
- onSetup: () => {
- this.searchInput = document.getElementById("wcfUiPageSearchInput") as HTMLInputElement;
- this.searchInput.addEventListener("keydown", (event) => {
- if (event.key === "Enter") {
- this.search(event);
- }
- });
-
- this.searchInput.nextElementSibling!.addEventListener("click", (ev) => this.search(ev));
-
- this.resultContainer = document.getElementById("wcfUiPageSearchResultContainer") as HTMLElement;
- this.resultList = document.getElementById("wcfUiPageSearchResultList") as HTMLOListElement;
- },
- onShow: () => {
- this.searchInput!.focus();
- },
- title: Language.get("wcf.page.search"),
- },
- source: `<div class="section">
- <dl>
- <dt><label for="wcfUiPageSearchInput">${Language.get("wcf.page.search.name")}</label></dt>
- <dd>
- <div class="inputAddon">
- <input type="text" id="wcfUiPageSearchInput" class="long">
- <a href="#" class="inputSuffix"><span class="icon icon16 fa-search"></span></a>
- </div>
- </dd>
- </dl>
- </div>
- <section id="wcfUiPageSearchResultContainer" class="section" style="display: none;">
- <header class="sectionHeader">
- <h2 class="sectionTitle">${Language.get("wcf.page.search.results")}</h2>
- </header>
- <ol id="wcfUiPageSearchResultList" class="containerList"></ol>
- </section>`,
- };
- }
-}
-
-let uiPageSearch: UiPageSearch | undefined = undefined;
-
-function getUiPageSearch(): UiPageSearch {
- if (uiPageSearch === undefined) {
- uiPageSearch = new UiPageSearch();
- }
-
- return uiPageSearch;
-}
-
-export function open(callbackSelect: CallbackSelect): void {
- getUiPageSearch().open(callbackSelect);
-}
+++ /dev/null
-/**
- * Provides access to the lookup function of page handlers, allowing the user to search and
- * select page object ids.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Page/Search/Handler
- */
-
-import * as Language from "../../../Language";
-import * as StringUtil from "../../../StringUtil";
-import DomUtil from "../../../Dom/Util";
-import UiDialog from "../../Dialog";
-import { DialogCallbackObject, DialogCallbackSetup } from "../../Dialog/Data";
-import UiPageSearchInput from "./Input";
-import { DatabaseObjectActionResponse } from "../../../Ajax/Data";
-
-type CallbackSelect = (objectId: number) => void;
-
-interface ItemData {
- description?: string;
- image: string;
- link: string;
- objectID: number;
- title: string;
-}
-
-interface AjaxResponse extends DatabaseObjectActionResponse {
- returnValues: ItemData[];
-}
-
-class UiPageSearchHandler implements DialogCallbackObject {
- private callbackSuccess?: CallbackSelect = undefined;
- private resultList?: HTMLUListElement = undefined;
- private resultListContainer?: HTMLElement = undefined;
- private searchInput?: HTMLInputElement = undefined;
- private searchInputHandler?: UiPageSearchInput = undefined;
- private searchInputLabel?: HTMLLabelElement = undefined;
-
- /**
- * Opens the lookup overlay for provided page id.
- */
- open(pageId: number, title: string, callback: CallbackSelect, labelLanguageItem?: string): void {
- this.callbackSuccess = callback;
-
- UiDialog.open(this);
- UiDialog.setTitle(this, title);
-
- this.searchInputLabel!.textContent = Language.get(labelLanguageItem || "wcf.page.pageObjectID.search.terms");
-
- this._getSearchInputHandler().setPageId(pageId);
- }
-
- /**
- * Builds the result list.
- */
- private buildList(data: AjaxResponse): void {
- this.resetList();
-
- if (!Array.isArray(data.returnValues) || data.returnValues.length === 0) {
- DomUtil.innerError(this.searchInput!, Language.get("wcf.page.pageObjectID.search.noResults"));
- return;
- }
-
- data.returnValues.forEach((item) => {
- let image = item.image;
- if (/^fa-/.test(image)) {
- image = `<span class="icon icon48 ${image} pointer jsTooltip" title="${Language.get(
- "wcf.global.select",
- )}"></span>`;
- }
-
- const listItem = document.createElement("li");
- listItem.dataset.objectId = item.objectID.toString();
-
- const description = item.description ? `<p>${item.description}</p>` : "";
- listItem.innerHTML = `<div class="box48">
- ${image}
- <div>
- <div class="containerHeadline">
- <h3>
- <a href="${StringUtil.escapeHTML(item.link)}">${StringUtil.escapeHTML(item.title)}</a>
- </h3>
- ${description}
- </div>
- </div>
- </div>`;
-
- listItem.addEventListener("click", this.click.bind(this));
-
- this.resultList!.appendChild(listItem);
- });
-
- DomUtil.show(this.resultListContainer!);
- }
-
- /**
- * Resets the list and removes any error elements.
- */
- private resetList(): void {
- DomUtil.innerError(this.searchInput!, false);
-
- this.resultList!.innerHTML = "";
-
- DomUtil.hide(this.resultListContainer!);
- }
-
- /**
- * Initializes the search input handler and returns the instance.
- */
- _getSearchInputHandler(): UiPageSearchInput {
- if (!this.searchInputHandler) {
- const input = document.getElementById("wcfUiPageSearchInput") as HTMLInputElement;
- this.searchInputHandler = new UiPageSearchInput(input, {
- callbackSuccess: this.buildList.bind(this),
- });
- }
-
- return this.searchInputHandler;
- }
-
- /**
- * Handles clicks on the item unless the click occurred directly on a link.
- */
- private click(event: MouseEvent): void {
- const clickTarget = event.target as HTMLElement;
- if (clickTarget.nodeName === "A") {
- return;
- }
-
- event.stopPropagation();
-
- const eventTarget = event.currentTarget as HTMLElement;
- this.callbackSuccess!(+eventTarget.dataset.objectId!);
-
- UiDialog.close(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "wcfUiPageSearchHandler",
- options: {
- onShow: (content: HTMLElement): void => {
- if (!this.searchInput) {
- this.searchInput = document.getElementById("wcfUiPageSearchInput") as HTMLInputElement;
- this.searchInputLabel = content.querySelector('label[for="wcfUiPageSearchInput"]') as HTMLLabelElement;
- this.resultList = document.getElementById("wcfUiPageSearchResultList") as HTMLUListElement;
- this.resultListContainer = document.getElementById("wcfUiPageSearchResultListContainer") as HTMLElement;
- }
-
- // clear search input
- this.searchInput.value = "";
-
- // reset results
- DomUtil.hide(this.resultListContainer!);
- this.resultList!.innerHTML = "";
-
- this.searchInput.focus();
- },
- title: "",
- },
- source: `<div class="section">
- <dl>
- <dt>
- <label for="wcfUiPageSearchInput">${Language.get("wcf.page.pageObjectID.search.terms")}</label>
- </dt>
- <dd>
- <input type="text" id="wcfUiPageSearchInput" class="long">
- </dd>
- </dl>
- </div>
- <section id="wcfUiPageSearchResultListContainer" class="section sectionContainerList">
- <header class="sectionHeader">
- <h2 class="sectionTitle">${Language.get("wcf.page.pageObjectID.search.results")}</h2>
- </header>
- <ul id="wcfUiPageSearchResultList" class="containerList wcfUiPageSearchResultList"></ul>
- </section>`,
- };
- }
-}
-
-let uiPageSearchHandler: UiPageSearchHandler | undefined = undefined;
-
-function getUiPageSearchHandler(): UiPageSearchHandler {
- if (!uiPageSearchHandler) {
- uiPageSearchHandler = new UiPageSearchHandler();
- }
-
- return uiPageSearchHandler;
-}
-
-/**
- * Opens the lookup overlay for provided page id.
- */
-export function open(pageId: number, title: string, callback: CallbackSelect, labelLanguageItem?: string): void {
- getUiPageSearchHandler().open(pageId, title, callback, labelLanguageItem);
-}
+++ /dev/null
-/**
- * Suggestions for page object ids with external response data processing.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Page/Search/Input
- */
-
-import * as Core from "../../../Core";
-import UiSearchInput from "../../Search/Input";
-import { SearchInputOptions } from "../../Search/Data";
-import { DatabaseObjectActionPayload, DatabaseObjectActionResponse } from "../../../Ajax/Data";
-
-type CallbackSuccess = (data: DatabaseObjectActionResponse) => void;
-
-interface PageSearchOptions extends SearchInputOptions {
- callbackSuccess: CallbackSuccess;
-}
-
-class UiPageSearchInput extends UiSearchInput {
- private readonly callbackSuccess: CallbackSuccess;
- private pageId: number;
-
- constructor(element: HTMLInputElement, options: PageSearchOptions) {
- if (typeof options.callbackSuccess !== "function") {
- throw new Error("Expected a valid callback function for 'callbackSuccess'.");
- }
-
- options = Core.extend(
- {
- ajax: {
- className: "wcf\\data\\page\\PageAction",
- },
- },
- options,
- ) as any;
-
- super(element, options);
-
- this.callbackSuccess = options.callbackSuccess;
-
- this.pageId = 0;
- }
-
- /**
- * Sets the target page id.
- */
- setPageId(pageId: number): void {
- this.pageId = pageId;
- }
-
- protected getParameters(value: string): Partial<DatabaseObjectActionPayload> {
- const data = super.getParameters(value);
-
- data.objectIDs = [this.pageId];
-
- return data;
- }
-
- _ajaxSuccess(data: DatabaseObjectActionResponse): void {
- this.callbackSuccess(data);
- }
-}
-
-Core.enableLegacyInheritance(UiPageSearchInput);
-
-export = UiPageSearchInput;
+++ /dev/null
-/**
- * Callback-based pagination.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Pagination
- */
-
-import * as Core from "../Core";
-import * as Language from "../Language";
-import * as StringUtil from "../StringUtil";
-import * as UiPageJumpTo from "./Page/JumpTo";
-
-class UiPagination {
- /**
- * maximum number of displayed page links, should match the PHP implementation
- */
- static readonly showLinks = 11;
-
- private activePage: number;
- private readonly maxPage: number;
-
- private readonly element: HTMLElement;
-
- private readonly callbackSwitch: CallbackSwitch | null = null;
- private readonly callbackShouldSwitch: CallbackShouldSwitch | null = null;
-
- /**
- * Initializes the pagination.
- *
- * @param {Element} element container element
- * @param {object} options list of initialization options
- */
- constructor(element: HTMLElement, options: PaginationOptions) {
- this.element = element;
- this.activePage = options.activePage;
- this.maxPage = options.maxPage;
- if (typeof options.callbackSwitch === "function") {
- this.callbackSwitch = options.callbackSwitch;
- }
- if (typeof options.callbackShouldSwitch === "function") {
- this.callbackShouldSwitch = options.callbackShouldSwitch;
- }
-
- this.element.classList.add("pagination");
- this.rebuild();
- }
-
- /**
- * Rebuilds the entire pagination UI.
- */
- private rebuild() {
- let hasHiddenPages = false;
-
- // clear content
- this.element.innerHTML = "";
-
- const list = document.createElement("ul");
- let listItem = document.createElement("li");
- listItem.className = "skip";
- list.appendChild(listItem);
-
- let iconClassNames = "icon icon24 fa-chevron-left";
- if (this.activePage > 1) {
- const link = document.createElement("a");
- link.className = iconClassNames + " jsTooltip";
- link.href = "#";
- link.title = Language.get("wcf.global.page.previous");
- link.rel = "prev";
- listItem.appendChild(link);
- link.addEventListener("click", (ev) => this.switchPage(this.activePage - 1, ev));
- } else {
- listItem.innerHTML = '<span class="' + iconClassNames + '"></span>';
- listItem.classList.add("disabled");
- }
-
- // add first page
- list.appendChild(this.createLink(1));
-
- // calculate page links
- let maxLinks = UiPagination.showLinks - 4;
- let linksBefore = this.activePage - 2;
- if (linksBefore < 0) {
- linksBefore = 0;
- }
-
- let linksAfter = this.maxPage - (this.activePage + 1);
- if (linksAfter < 0) {
- linksAfter = 0;
- }
- if (this.activePage > 1 && this.activePage < this.maxPage) {
- maxLinks--;
- }
-
- const half = maxLinks / 2;
- let left = this.activePage;
- let right = this.activePage;
- if (left < 1) {
- left = 1;
- }
- if (right < 1) {
- right = 1;
- }
- if (right > this.maxPage - 1) {
- right = this.maxPage - 1;
- }
-
- if (linksBefore >= half) {
- left -= half;
- } else {
- left -= linksBefore;
- right += half - linksBefore;
- }
-
- if (linksAfter >= half) {
- right += half;
- } else {
- right += linksAfter;
- left -= half - linksAfter;
- }
-
- right = Math.ceil(right);
- left = Math.ceil(left);
- if (left < 1) {
- left = 1;
- }
- if (right > this.maxPage) {
- right = this.maxPage;
- }
-
- // left ... links
- const jumpToHtml = '<a class="jsTooltip" title="' + Language.get("wcf.page.jumpTo") + '">…</a>';
- if (left > 1) {
- if (left - 1 < 2) {
- list.appendChild(this.createLink(2));
- } else {
- listItem = document.createElement("li");
- listItem.className = "jumpTo";
- listItem.innerHTML = jumpToHtml;
- list.appendChild(listItem);
- hasHiddenPages = true;
- }
- }
-
- // visible links
- for (let i = left + 1; i < right; i++) {
- list.appendChild(this.createLink(i));
- }
-
- // right ... links
- if (right < this.maxPage) {
- if (this.maxPage - right < 2) {
- list.appendChild(this.createLink(this.maxPage - 1));
- } else {
- listItem = document.createElement("li");
- listItem.className = "jumpTo";
- listItem.innerHTML = jumpToHtml;
- list.appendChild(listItem);
- hasHiddenPages = true;
- }
- }
-
- // add last page
- list.appendChild(this.createLink(this.maxPage));
-
- // add next button
- listItem = document.createElement("li");
- listItem.className = "skip";
- list.appendChild(listItem);
- iconClassNames = "icon icon24 fa-chevron-right";
- if (this.activePage < this.maxPage) {
- const link = document.createElement("a");
- link.className = iconClassNames + " jsTooltip";
- link.href = "#";
- link.title = Language.get("wcf.global.page.next");
- link.rel = "next";
- listItem.appendChild(link);
- link.addEventListener("click", (ev) => this.switchPage(this.activePage + 1, ev));
- } else {
- listItem.innerHTML = '<span class="' + iconClassNames + '"></span>';
- listItem.classList.add("disabled");
- }
-
- if (hasHiddenPages) {
- list.dataset.pages = this.maxPage.toString();
- UiPageJumpTo.init(list, this.switchPage.bind(this));
- }
-
- this.element.appendChild(list);
- }
-
- /**
- * Creates a link to a specific page.
- */
- private createLink(pageNo: number): HTMLElement {
- const listItem = document.createElement("li");
- if (pageNo !== this.activePage) {
- const link = document.createElement("a");
- link.textContent = StringUtil.addThousandsSeparator(pageNo);
- link.addEventListener("click", (ev) => this.switchPage(pageNo, ev));
- listItem.appendChild(link);
- } else {
- listItem.classList.add("active");
- listItem.innerHTML =
- "<span>" +
- StringUtil.addThousandsSeparator(pageNo) +
- '</span><span class="invisible">' +
- Language.get("wcf.page.pagePosition", {
- pageNo: pageNo,
- pages: this.maxPage,
- }) +
- "</span>";
- }
- return listItem;
- }
-
- /**
- * Returns the active page.
- */
- getActivePage(): number {
- return this.activePage;
- }
-
- /**
- * Returns the pagination Ui element.
- */
- getElement(): HTMLElement {
- return this.element;
- }
-
- /**
- * Returns the maximum page.
- */
- getMaxPage(): number {
- return this.maxPage;
- }
-
- /**
- * Switches to given page number.
- */
- switchPage(pageNo: number, event?: MouseEvent): void {
- if (event instanceof MouseEvent) {
- event.preventDefault();
-
- const target = event.currentTarget as HTMLElement;
- // force tooltip to vanish and strip positioning
- if (target && target.dataset.tooltip) {
- const tooltip = document.getElementById("balloonTooltip");
- if (tooltip) {
- Core.triggerEvent(target, "mouseleave");
- tooltip.style.removeProperty("top");
- tooltip.style.removeProperty("bottom");
- }
- }
- }
-
- pageNo = ~~pageNo;
- if (pageNo > 0 && this.activePage !== pageNo && pageNo <= this.maxPage) {
- if (this.callbackShouldSwitch !== null) {
- if (!this.callbackShouldSwitch(pageNo)) {
- return;
- }
- }
-
- this.activePage = pageNo;
- this.rebuild();
-
- if (this.callbackSwitch !== null) {
- this.callbackSwitch(pageNo);
- }
- }
- }
-}
-
-Core.enableLegacyInheritance(UiPagination);
-
-export = UiPagination;
-
-type CallbackSwitch = (pageNo: number) => void;
-type CallbackShouldSwitch = (pageNo: number) => boolean;
-
-interface PaginationOptions {
- activePage: number;
- maxPage: number;
- callbackShouldSwitch?: CallbackShouldSwitch | null;
- callbackSwitch?: CallbackSwitch | null;
-}
+++ /dev/null
-/**
- * Handles the data to create and edit a poll in a form created via form builder.
- *
- * @author Alexander Ebert, Matthias Schmidt
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Poll/Editor
- */
-
-import * as Core from "../../Core";
-import * as Language from "../../Language";
-import UiSortableList from "../Sortable/List";
-import * as EventHandler from "../../Event/Handler";
-import * as DatePicker from "../../Date/Picker";
-import { DatabaseObjectActionResponse } from "../../Ajax/Data";
-
-interface UiPollEditorOptions {
- isAjax: boolean;
- maxOptions: number;
-}
-
-interface PollOption {
- optionID: string;
- optionValue: string;
-}
-
-interface AjaxReturnValue {
- errorType: string;
- fieldName: string;
-}
-
-interface AjaxResponse extends DatabaseObjectActionResponse {
- returnValues: AjaxReturnValue;
-}
-
-interface ValidationApi {
- throwError: (container: HTMLElement, message: string) => void;
-}
-
-interface ValidationData {
- api: ValidationApi;
- valid: boolean;
-}
-
-class UiPollEditor {
- private readonly container: HTMLElement;
- private readonly endTimeField: HTMLInputElement;
- private readonly isChangeableNoField: HTMLInputElement;
- private readonly isChangeableYesField: HTMLInputElement;
- private readonly isPublicNoField: HTMLInputElement;
- private readonly isPublicYesField: HTMLInputElement;
- private readonly maxVotesField: HTMLInputElement;
- private optionCount: number;
- private readonly options: UiPollEditorOptions;
- private readonly optionList: HTMLOListElement;
- private readonly questionField: HTMLInputElement;
- private readonly resultsRequireVoteNoField: HTMLInputElement;
- private readonly resultsRequireVoteYesField: HTMLInputElement;
- private readonly sortByVotesNoField: HTMLInputElement;
- private readonly sortByVotesYesField: HTMLInputElement;
- private readonly wysiwygId: string;
-
- constructor(containerId: string, pollOptions: PollOption[], wysiwygId: string, options: UiPollEditorOptions) {
- const container = document.getElementById(containerId);
- if (container === null) {
- throw new Error("Unknown poll editor container with id '" + containerId + "'.");
- }
- this.container = container;
-
- this.wysiwygId = wysiwygId;
- if (wysiwygId !== "" && document.getElementById(wysiwygId) === null) {
- throw new Error("Unknown wysiwyg field with id '" + wysiwygId + "'.");
- }
-
- this.questionField = document.getElementById(this.wysiwygId + "Poll_question") as HTMLInputElement;
-
- const optionList = this.container.querySelector(".sortableList");
- if (optionList === null) {
- throw new Error("Cannot find poll options list for container with id '" + containerId + "'.");
- }
- this.optionList = optionList as HTMLOListElement;
-
- this.endTimeField = document.getElementById(this.wysiwygId + "Poll_endTime") as HTMLInputElement;
- this.maxVotesField = document.getElementById(this.wysiwygId + "Poll_maxVotes") as HTMLInputElement;
- this.isChangeableYesField = document.getElementById(this.wysiwygId + "Poll_isChangeable") as HTMLInputElement;
- this.isChangeableNoField = document.getElementById(this.wysiwygId + "Poll_isChangeable_no") as HTMLInputElement;
- this.isPublicYesField = document.getElementById(this.wysiwygId + "Poll_isPublic") as HTMLInputElement;
- this.isPublicNoField = document.getElementById(this.wysiwygId + "Poll_isPublic_no") as HTMLInputElement;
- this.resultsRequireVoteYesField = document.getElementById(
- this.wysiwygId + "Poll_resultsRequireVote",
- ) as HTMLInputElement;
- this.resultsRequireVoteNoField = document.getElementById(
- this.wysiwygId + "Poll_resultsRequireVote_no",
- ) as HTMLInputElement;
- this.sortByVotesYesField = document.getElementById(this.wysiwygId + "Poll_sortByVotes") as HTMLInputElement;
- this.sortByVotesNoField = document.getElementById(this.wysiwygId + "Poll_sortByVotes_no") as HTMLInputElement;
-
- this.optionCount = 0;
-
- this.options = Core.extend(
- {
- isAjax: false,
- maxOptions: 20,
- },
- options,
- ) as UiPollEditorOptions;
-
- this.createOptionList(pollOptions || []);
-
- new UiSortableList({
- containerId: containerId,
- options: {
- toleranceElement: "> div",
- },
- });
-
- if (this.options.isAjax) {
- ["handleError", "reset", "submit", "validate"].forEach((event) => {
- EventHandler.add("com.woltlab.wcf.redactor2", event + "_" + this.wysiwygId, (...args: unknown[]) =>
- this[event](...args),
- );
- });
- } else {
- const form = this.container.closest("form");
- if (form === null) {
- throw new Error("Cannot find form for container with id '" + containerId + "'.");
- }
-
- form.addEventListener("submit", (ev) => this.submit(ev));
- }
- }
-
- /**
- * Creates a poll option with the given data or an empty poll option of no data is given.
- */
- private createOption(optionValue?: string, optionId?: string, insertAfter?: HTMLElement): void {
- optionValue = optionValue || "";
- optionId = optionId || "0";
-
- const listItem = document.createElement("LI") as HTMLLIElement;
- listItem.classList.add("sortableNode");
- listItem.dataset.optionId = optionId;
-
- if (insertAfter) {
- insertAfter.insertAdjacentElement("afterend", listItem);
- } else {
- this.optionList.appendChild(listItem);
- }
-
- const pollOptionInput = document.createElement("div");
- pollOptionInput.classList.add("pollOptionInput");
- listItem.appendChild(pollOptionInput);
-
- const sortHandle = document.createElement("span");
- sortHandle.classList.add("icon", "icon16", "fa-arrows", "sortableNodeHandle");
- pollOptionInput.appendChild(sortHandle);
-
- // buttons
- const addButton = document.createElement("a");
- listItem.setAttribute("role", "button");
- listItem.setAttribute("href", "#");
- addButton.classList.add("icon", "icon16", "fa-plus", "jsTooltip", "jsAddOption", "pointer");
- addButton.setAttribute("title", Language.get("wcf.poll.button.addOption"));
- addButton.addEventListener("click", () => this.createOption());
- pollOptionInput.appendChild(addButton);
-
- const deleteButton = document.createElement("a");
- deleteButton.setAttribute("role", "button");
- deleteButton.setAttribute("href", "#");
- deleteButton.classList.add("icon", "icon16", "fa-times", "jsTooltip", "jsDeleteOption", "pointer");
- deleteButton.setAttribute("title", Language.get("wcf.poll.button.removeOption"));
- deleteButton.addEventListener("click", (ev) => this.removeOption(ev));
- pollOptionInput.appendChild(deleteButton);
-
- // input field
- const optionInput = document.createElement("input");
- optionInput.type = "text";
- optionInput.value = optionValue;
- optionInput.maxLength = 255;
- optionInput.addEventListener("keydown", (ev) => this.optionInputKeyDown(ev));
- optionInput.addEventListener("click", () => {
- // work-around for some weird focus issue on iOS/Android
- if (document.activeElement !== optionInput) {
- optionInput.focus();
- }
- });
- pollOptionInput.appendChild(optionInput);
-
- if (insertAfter !== null) {
- optionInput.focus();
- }
-
- this.optionCount++;
- if (this.optionCount === this.options.maxOptions) {
- this.optionList.querySelectorAll(".jsAddOption").forEach((icon: HTMLSpanElement) => {
- icon.classList.remove("pointer");
- icon.classList.add("disabled");
- });
- }
- }
-
- /**
- * Populates the option list with the current options.
- */
- private createOptionList(pollOptions: PollOption[]): void {
- pollOptions.forEach((option) => {
- this.createOption(option.optionValue, option.optionID);
- });
-
- if (this.optionCount < this.options.maxOptions) {
- this.createOption();
- }
- }
-
- /**
- * Handles validation errors returned by Ajax request.
- */
- private handleError(data: AjaxResponse): void {
- switch (data.returnValues.fieldName) {
- case this.wysiwygId + "Poll_endTime":
- case this.wysiwygId + "Poll_maxVotes": {
- const fieldName = data.returnValues.fieldName.replace(this.wysiwygId + "Poll_", "");
-
- const small = document.createElement("small");
- small.classList.add("innerError");
- small.innerHTML = Language.get("wcf.poll." + fieldName + ".error." + data.returnValues.errorType);
-
- const field = document.getElementById(data.returnValues.fieldName)!;
- (field.nextSibling! as HTMLElement).insertAdjacentElement("afterbegin", small);
-
- data.cancel = true;
- break;
- }
- }
- }
-
- /**
- * Adds another option field below the current option field after pressing Enter.
- */
- private optionInputKeyDown(event: KeyboardEvent): void {
- if (event.key !== "Enter") {
- return;
- }
-
- const target = event.currentTarget as HTMLInputElement;
- const addOption = target.parentElement!.querySelector(".jsAddOption") as HTMLSpanElement;
- Core.triggerEvent(addOption, "click");
-
- event.preventDefault();
- }
-
- /**
- * Removes a poll option after clicking on its deletion button.
- */
- private removeOption(event: Event): void {
- event.preventDefault();
-
- const button = event.currentTarget as HTMLSpanElement;
- button.closest("li")!.remove();
-
- this.optionCount--;
-
- if (this.optionList.childElementCount === 0) {
- this.createOption();
- } else {
- this.optionList.querySelectorAll(".jsAddOption").forEach((icon) => {
- icon.classList.add("pointer");
- icon.classList.remove("disabled");
- });
- }
- }
-
- /**
- * Resets all poll fields.
- */
- private reset(): void {
- this.questionField.value = "";
-
- this.optionCount = 0;
- this.optionList.innerHTML = "";
- this.createOption();
-
- DatePicker.clear(this.endTimeField);
-
- this.maxVotesField.value = "1";
- this.isChangeableYesField.checked = false;
- this.isChangeableNoField.checked = true;
- this.isPublicYesField.checked = false;
- this.isPublicNoField.checked = true;
- this.resultsRequireVoteYesField.checked = false;
- this.resultsRequireVoteNoField.checked = true;
- this.sortByVotesYesField.checked = false;
- this.sortByVotesNoField.checked = true;
-
- EventHandler.fire("com.woltlab.wcf.poll.editor", "reset", {
- pollEditor: this,
- });
- }
-
- /**
- * Handles the poll data if the form is submitted.
- */
- private submit(event: Event): void {
- if (this.options.isAjax) {
- EventHandler.fire("com.woltlab.wcf.poll.editor", "submit", {
- event: event,
- pollEditor: this,
- });
- } else {
- const form = this.container.closest("form")!;
-
- this.getOptions().forEach((option, i) => {
- const input = document.createElement("input");
- input.type = "hidden";
- input.name = `${this.wysiwygId} + 'Poll_options[${i}}]`;
- input.value = option;
- form.appendChild(input);
- });
- }
- }
-
- /**
- * Validates the poll data.
- */
- private validate(data: ValidationData): void {
- if (this.questionField.value.trim() === "") {
- return;
- }
-
- let nonEmptyOptionCount = 0;
- Array.from(this.optionList.children).forEach((listItem: HTMLLIElement) => {
- const optionInput = listItem.querySelector("input[type=text]") as HTMLInputElement;
- if (optionInput.value.trim() !== "") {
- nonEmptyOptionCount++;
- }
- });
-
- if (nonEmptyOptionCount === 0) {
- data.api.throwError(this.container, Language.get("wcf.global.form.error.empty"));
- data.valid = false;
- } else {
- const maxVotes = ~~this.maxVotesField.value;
-
- if (maxVotes && maxVotes > nonEmptyOptionCount) {
- data.api.throwError(this.maxVotesField.parentElement!, Language.get("wcf.poll.maxVotes.error.invalid"));
- data.valid = false;
- } else {
- EventHandler.fire("com.woltlab.wcf.poll.editor", "validate", {
- data: data,
- pollEditor: this,
- });
- }
- }
- }
-
- /**
- * Returns the data of the poll.
- */
- public getData(): object {
- return {
- [this.questionField.id]: this.questionField.value,
- [this.wysiwygId + "Poll_options"]: this.getOptions(),
- [this.endTimeField.id]: this.endTimeField.value,
- [this.maxVotesField.id]: this.maxVotesField.value,
- [this.isChangeableYesField.id]: !!this.isChangeableYesField.checked,
- [this.isPublicYesField.id]: !!this.isPublicYesField.checked,
- [this.resultsRequireVoteYesField.id]: !!this.resultsRequireVoteYesField.checked,
- [this.sortByVotesYesField.id]: !!this.sortByVotesYesField.checked,
- };
- }
-
- /**
- * Returns the selectable options in the poll.
- *
- * Format: `{optionID}_{option}` with `optionID = 0` if it is a new option.
- */
- public getOptions(): string[] {
- const options: string[] = [];
- Array.from(this.optionList.children).forEach((listItem: HTMLLIElement) => {
- const optionValue = (listItem.querySelector("input[type=text]")! as HTMLInputElement).value.trim();
-
- if (optionValue !== "") {
- options.push(`${listItem.dataset.optionId!}_${optionValue}`);
- }
- });
-
- return options;
- }
-}
-
-Core.enableLegacyInheritance(UiPollEditor);
-
-export = UiPollEditor;
+++ /dev/null
-/**
- * Provides interface elements to use reactions.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Reaction/Handler
- * @since 5.2
- */
-
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
-import * as Core from "../../Core";
-import { DialogCallbackSetup } from "../Dialog/Data";
-import DomChangeListener from "../../Dom/Change/Listener";
-import DomUtil from "../../Dom/Util";
-import * as EventHandler from "../../Event/Handler";
-import { Reaction, ReactionStats } from "./Data";
-import * as StringUtil from "../../StringUtil";
-import UiDialog from "../Dialog";
-
-interface CountButtonsOptions {
- // selectors
- summaryListSelector: string;
- containerSelector: string;
- isSingleItem: boolean;
-
- // optional parameters
- parameters: {
- data: {
- [key: string]: unknown;
- };
- };
-}
-
-interface ElementData {
- element: HTMLElement;
- objectId: number;
- reactButton: null;
- summary: null;
-}
-
-interface AjaxResponse extends ResponseData {
- returnValues: {
- template: string;
- title: string;
- };
-}
-
-const availableReactions = new Map<string, Reaction>(Object.entries(window.REACTION_TYPES));
-
-class CountButtons {
- protected readonly _containers = new Map<string, ElementData>();
- protected _currentObjectId = 0;
- protected readonly _objects = new Map<number, ElementData[]>();
- protected readonly _objectType: string;
- protected readonly _options: CountButtonsOptions;
-
- /**
- * Initializes the like handler.
- */
- constructor(objectType: string, opts: Partial<CountButtonsOptions>) {
- if (!opts.containerSelector) {
- throw new Error(
- "[WoltLabSuite/Core/Ui/Reaction/CountButtons] Expected a non-empty string for option 'containerSelector'.",
- );
- }
-
- this._objectType = objectType;
-
- this._options = Core.extend(
- {
- // selectors
- summaryListSelector: ".reactionSummaryList",
- containerSelector: "",
- isSingleItem: false,
-
- // optional parameters
- parameters: {
- data: {},
- },
- },
- opts,
- ) as CountButtonsOptions;
-
- this.initContainers();
-
- DomChangeListener.add(`WoltLabSuite/Core/Ui/Reaction/CountButtons-${objectType}`, () => this.initContainers());
- }
-
- /**
- * Initialises the containers.
- */
- initContainers(): void {
- let triggerChange = false;
- document.querySelectorAll(this._options.containerSelector).forEach((element: HTMLElement) => {
- const elementId = DomUtil.identify(element);
- if (this._containers.has(elementId)) {
- return;
- }
-
- const objectId = ~~element.dataset.objectId!;
- const elementData: ElementData = {
- reactButton: null,
- summary: null,
-
- objectId: objectId,
- element: element,
- };
-
- this._containers.set(elementId, elementData);
- this._initReactionCountButtons(element, elementData);
-
- const objects = this._objects.get(objectId) || [];
-
- objects.push(elementData);
-
- this._objects.set(objectId, objects);
-
- triggerChange = true;
- });
-
- if (triggerChange) {
- DomChangeListener.trigger();
- }
- }
-
- /**
- * Update the count buttons with the given data.
- */
- updateCountButtons(objectId: number, data: ReactionStats): void {
- let triggerChange = false;
- this._objects.get(objectId)!.forEach((elementData) => {
- let summaryList: HTMLElement | null;
- if (this._options.isSingleItem) {
- summaryList = document.querySelector(this._options.summaryListSelector);
- } else {
- summaryList = elementData.element.querySelector(this._options.summaryListSelector);
- }
-
- // summary list for the object not found; abort
- if (summaryList === null) {
- return;
- }
-
- const existingReactions = new Map<string, number>(Object.entries(data));
-
- const sortedElements = new Map<string, HTMLElement>();
- summaryList.querySelectorAll(".reactCountButton").forEach((reaction: HTMLElement) => {
- const reactionTypeId = reaction.dataset.reactionTypeId!;
- if (existingReactions.has(reactionTypeId)) {
- sortedElements.set(reactionTypeId, reaction);
- } else {
- // The reaction no longer has any reactions.
- reaction.remove();
- }
- });
-
- existingReactions.forEach((count, reactionTypeId) => {
- if (sortedElements.has(reactionTypeId)) {
- const reaction = sortedElements.get(reactionTypeId)!;
- const reactionCount = reaction.querySelector(".reactionCount") as HTMLElement;
- reactionCount.innerHTML = StringUtil.shortUnit(count);
- } else if (availableReactions.has(reactionTypeId)) {
- const createdElement = document.createElement("span");
- createdElement.className = "reactCountButton";
- createdElement.innerHTML = availableReactions.get(reactionTypeId)!.renderedIcon;
- createdElement.dataset.reactionTypeId = reactionTypeId;
-
- const countSpan = document.createElement("span");
- countSpan.className = "reactionCount";
- countSpan.innerHTML = StringUtil.shortUnit(count);
- createdElement.appendChild(countSpan);
-
- summaryList!.appendChild(createdElement);
-
- triggerChange = true;
- }
- });
-
- if (summaryList.childElementCount > 0) {
- DomUtil.show(summaryList);
- } else {
- DomUtil.hide(summaryList);
- }
- });
-
- if (triggerChange) {
- DomChangeListener.trigger();
- }
- }
-
- /**
- * Initialized the reaction count buttons.
- */
- protected _initReactionCountButtons(element: HTMLElement, elementData: ElementData): void {
- let summaryList: HTMLElement | null;
- if (this._options.isSingleItem) {
- summaryList = document.querySelector(this._options.summaryListSelector);
- } else {
- summaryList = element.querySelector(this._options.summaryListSelector);
- }
-
- if (summaryList !== null) {
- summaryList.addEventListener("click", (ev) => this._showReactionOverlay(elementData.objectId, ev));
- }
- }
-
- /**
- * Shows the reaction overly for a specific object.
- */
- protected _showReactionOverlay(objectId: number, event: MouseEvent): void {
- event.preventDefault();
-
- this._currentObjectId = objectId;
- this._showOverlay();
- }
-
- /**
- * Shows a specific page of the current opened reaction overlay.
- */
- protected _showOverlay(): void {
- this._options.parameters.data.containerID = `${this._objectType}-${this._currentObjectId}`;
- this._options.parameters.data.objectID = this._currentObjectId;
- this._options.parameters.data.objectType = this._objectType;
-
- Ajax.api(this, {
- parameters: this._options.parameters,
- });
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- EventHandler.fire("com.woltlab.wcf.ReactionCountButtons", "openDialog", data);
-
- UiDialog.open(this, data.returnValues.template);
- UiDialog.setTitle("userReactionOverlay-" + this._objectType, data.returnValues.title);
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "getReactionDetails",
- className: "\\wcf\\data\\reaction\\ReactionAction",
- },
- };
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: `userReactionOverlay-${this._objectType}`,
- options: {
- title: "",
- },
- source: null,
- };
- }
-}
-
-Core.enableLegacyInheritance(CountButtons);
-
-export = CountButtons;
+++ /dev/null
-export interface Reaction {
- title: string;
- renderedIcon: string;
- iconPath: string;
- showOrder: number;
- reactionTypeID: number;
- isAssignable: 1 | 0;
-}
-
-export interface ReactionStats {
- [key: string]: number;
-}
+++ /dev/null
-/**
- * Provides interface elements to use reactions.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Reaction/Handler
- * @since 5.2
- */
-
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackSetup } from "../../Ajax/Data";
-import * as Core from "../../Core";
-import DomChangeListener from "../../Dom/Change/Listener";
-import DomUtil from "../../Dom/Util";
-import * as UiAlignment from "../Alignment";
-import UiCloseOverlay from "../CloseOverlay";
-import * as UiScreen from "../Screen";
-import CountButtons from "./CountButtons";
-import { Reaction, ReactionStats } from "./Data";
-
-interface ReactionHandlerOptions {
- // selectors
- buttonSelector: string;
- containerSelector: string;
- isButtonGroupNavigation: boolean;
- isSingleItem: boolean;
-
- // other stuff
- parameters: {
- data: {
- [key: string]: unknown;
- };
- reactionTypeID?: number;
- };
-}
-
-interface ElementData {
- reactButton: HTMLElement | null;
- objectId: number;
- element: HTMLElement;
-}
-
-interface AjaxResponse {
- returnValues: {
- objectID: number;
- objectType: string;
- reactions: ReactionStats;
- reactionTypeID: number;
- reputationCount: number;
- };
-}
-
-const availableReactions = Object.values(window.REACTION_TYPES);
-
-class UiReactionHandler {
- readonly countButtons: CountButtons;
- protected readonly _cache = new Map<string, unknown>();
- protected readonly _containers = new Map<string, ElementData>();
- protected readonly _options: ReactionHandlerOptions;
- protected readonly _objects = new Map<number, ElementData[]>();
- protected readonly _objectType: string;
- protected _popoverCurrentObjectId = 0;
- protected _popover: HTMLElement | null;
- protected _popoverContent: HTMLElement | null;
-
- /**
- * Initializes the reaction handler.
- */
- constructor(objectType: string, opts: Partial<ReactionHandlerOptions>) {
- if (!opts.containerSelector) {
- throw new Error(
- "[WoltLabSuite/Core/Ui/Reaction/Handler] Expected a non-empty string for option 'containerSelector'.",
- );
- }
-
- this._objectType = objectType;
-
- this._popover = null;
- this._popoverContent = null;
-
- this._options = Core.extend(
- {
- // selectors
- buttonSelector: ".reactButton",
- containerSelector: "",
- isButtonGroupNavigation: false,
- isSingleItem: false,
-
- // other stuff
- parameters: {
- data: {},
- },
- },
- opts,
- ) as ReactionHandlerOptions;
-
- this.initReactButtons();
-
- this.countButtons = new CountButtons(this._objectType, this._options);
-
- DomChangeListener.add(`WoltLabSuite/Core/Ui/Reaction/Handler-${objectType}`, () => this.initReactButtons());
- UiCloseOverlay.add("WoltLabSuite/Core/Ui/Reaction/Handler", () => this._closePopover());
- }
-
- /**
- * Initializes all applicable react buttons with the given selector.
- */
- initReactButtons(): void {
- let triggerChange = false;
-
- document.querySelectorAll(this._options.containerSelector).forEach((element: HTMLElement) => {
- const elementId = DomUtil.identify(element);
- if (this._containers.has(elementId)) {
- return;
- }
-
- const objectId = ~~element.dataset.objectId!;
- const elementData: ElementData = {
- reactButton: null,
- objectId: objectId,
- element: element,
- };
-
- this._containers.set(elementId, elementData);
- this._initReactButton(element, elementData);
-
- const objects = this._objects.get(objectId) || [];
-
- objects.push(elementData);
-
- this._objects.set(objectId, objects);
-
- triggerChange = true;
- });
-
- if (triggerChange) {
- DomChangeListener.trigger();
- }
- }
-
- /**
- * Initializes a specific react button.
- */
- _initReactButton(element: HTMLElement, elementData: ElementData): void {
- if (this._options.isSingleItem) {
- elementData.reactButton = document.querySelector(this._options.buttonSelector) as HTMLElement;
- } else {
- elementData.reactButton = element.querySelector(this._options.buttonSelector) as HTMLElement;
- }
-
- if (elementData.reactButton === null) {
- // The element may have no react button.
- return;
- }
-
- if (availableReactions.length === 1) {
- const reaction = availableReactions[0];
- elementData.reactButton.title = reaction.title;
- const textSpan = elementData.reactButton.querySelector(".invisible")!;
- textSpan.textContent = reaction.title;
- }
-
- elementData.reactButton.addEventListener("click", (ev) => {
- this._toggleReactPopover(elementData.objectId, elementData.reactButton!, ev);
- });
- }
-
- protected _updateReactButton(objectID: number, reactionTypeID: number): void {
- this._objects.get(objectID)!.forEach((elementData) => {
- if (elementData.reactButton !== null) {
- if (reactionTypeID) {
- elementData.reactButton.classList.add("active");
- elementData.reactButton.dataset.reactionTypeId = reactionTypeID.toString();
- } else {
- elementData.reactButton.dataset.reactionTypeId = "0";
- elementData.reactButton.classList.remove("active");
- }
- }
- });
- }
-
- protected _markReactionAsActive(): void {
- let reactionTypeID = 0;
- this._objects.get(this._popoverCurrentObjectId)!.forEach((element) => {
- if (element.reactButton !== null) {
- reactionTypeID = ~~element.reactButton.dataset.reactionTypeId!;
- }
- });
-
- if (!reactionTypeID) {
- throw new Error("Unable to find react button for current popover.");
- }
-
- // Clear the old active state.
- const popover = this._getPopover();
- popover.querySelectorAll(".reactionTypeButton.active").forEach((el) => el.classList.remove("active"));
-
- const scrollableContainer = popover.querySelector(".reactionPopoverContent") as HTMLElement;
- if (reactionTypeID) {
- const reactionTypeButton = popover.querySelector(
- `.reactionTypeButton[data-reaction-type-id="${reactionTypeID}"]`,
- ) as HTMLElement;
- reactionTypeButton.classList.add("active");
-
- if (~~reactionTypeButton.dataset.isAssignable! === 0) {
- DomUtil.show(reactionTypeButton);
- }
-
- this._scrollReactionIntoView(scrollableContainer, reactionTypeButton);
- } else {
- // The "first" reaction is positioned as close as possible to the toggle button,
- // which means that we need to scroll the list to the bottom if the popover is
- // displayed above the toggle button.
- if (UiScreen.is("screen-xs")) {
- if (popover.classList.contains("inverseOrder")) {
- scrollableContainer.scrollTop = 0;
- } else {
- scrollableContainer.scrollTop = scrollableContainer.scrollHeight - scrollableContainer.clientHeight;
- }
- }
- }
- }
-
- protected _scrollReactionIntoView(scrollableContainer: HTMLElement, reactionTypeButton: HTMLElement): void {
- // Do not scroll if the button is located in the upper 75%.
- if (reactionTypeButton.offsetTop < scrollableContainer.clientHeight * 0.75) {
- scrollableContainer.scrollTop = 0;
- } else {
- // `Element.scrollTop` permits arbitrary values and will always clamp them to
- // the maximum possible offset value. We can abuse this behavior by calculating
- // the values to place the selected reaction in the center of the popover,
- // regardless of the offset being out of range.
- scrollableContainer.scrollTop =
- reactionTypeButton.offsetTop + reactionTypeButton.clientHeight / 2 - scrollableContainer.clientHeight / 2;
- }
- }
-
- /**
- * Toggle the visibility of the react popover.
- */
- protected _toggleReactPopover(objectId: number, element: HTMLElement, event: MouseEvent): void {
- if (event !== null) {
- event.preventDefault();
- event.stopPropagation();
- }
-
- if (availableReactions.length === 1) {
- const reaction = availableReactions[0];
- this._popoverCurrentObjectId = objectId;
-
- this._react(reaction.reactionTypeID);
- } else {
- if (this._popoverCurrentObjectId === 0 || this._popoverCurrentObjectId !== objectId) {
- this._openReactPopover(objectId, element);
- } else {
- this._closePopover();
- }
- }
- }
-
- /**
- * Opens the react popover for a specific react button.
- */
- protected _openReactPopover(objectId: number, element: HTMLElement): void {
- if (this._popoverCurrentObjectId !== 0) {
- this._closePopover();
- }
-
- this._popoverCurrentObjectId = objectId;
-
- UiAlignment.set(this._getPopover(), element, {
- pointer: true,
- horizontal: this._options.isButtonGroupNavigation ? "left" : "center",
- vertical: UiScreen.is("screen-xs") ? "bottom" : "top",
- });
-
- if (this._options.isButtonGroupNavigation) {
- element.closest("nav")!.style.setProperty("opacity", "1", "");
- }
-
- const popover = this._getPopover();
-
- // The popover could be rendered below the input field on mobile, in which case
- // the "first" button is displayed at the bottom and thus farthest away. Reversing
- // the display order will restore the logic by placing the "first" button as close
- // to the react button as possible.
- const inverseOrder = popover.style.getPropertyValue("bottom") === "auto";
- if (inverseOrder) {
- popover.classList.add("inverseOrder");
- } else {
- popover.classList.remove("inverseOrder");
- }
-
- this._markReactionAsActive();
-
- this._rebuildOverflowIndicator();
-
- popover.classList.remove("forceHide");
- popover.classList.add("active");
- }
-
- /**
- * Returns the react popover element.
- */
- protected _getPopover(): HTMLElement {
- if (this._popover == null) {
- this._popover = document.createElement("div");
- this._popover.className = "reactionPopover forceHide";
-
- this._popoverContent = document.createElement("div");
- this._popoverContent.className = "reactionPopoverContent";
-
- const popoverContentHTML = document.createElement("ul");
- popoverContentHTML.className = "reactionTypeButtonList";
-
- this._getSortedReactionTypes().forEach((reactionType) => {
- const reactionTypeItem = document.createElement("li");
- reactionTypeItem.className = "reactionTypeButton jsTooltip";
- reactionTypeItem.dataset.reactionTypeId = reactionType.reactionTypeID.toString();
- reactionTypeItem.dataset.title = reactionType.title;
- reactionTypeItem.dataset.isAssignable = reactionType.isAssignable.toString();
-
- reactionTypeItem.title = reactionType.title;
-
- const reactionTypeItemSpan = document.createElement("span");
- reactionTypeItemSpan.className = "reactionTypeButtonTitle";
- reactionTypeItemSpan.innerHTML = reactionType.title;
-
- reactionTypeItem.innerHTML = reactionType.renderedIcon;
-
- reactionTypeItem.appendChild(reactionTypeItemSpan);
-
- reactionTypeItem.addEventListener("click", () => this._react(reactionType.reactionTypeID));
-
- if (!reactionType.isAssignable) {
- DomUtil.hide(reactionTypeItem);
- }
-
- popoverContentHTML.appendChild(reactionTypeItem);
- });
-
- this._popoverContent.appendChild(popoverContentHTML);
- this._popoverContent.addEventListener("scroll", () => this._rebuildOverflowIndicator(), { passive: true });
-
- this._popover.appendChild(this._popoverContent);
-
- const pointer = document.createElement("span");
- pointer.className = "elementPointer";
- pointer.appendChild(document.createElement("span"));
- this._popover.appendChild(pointer);
-
- document.body.appendChild(this._popover);
-
- DomChangeListener.trigger();
- }
-
- return this._popover;
- }
-
- protected _rebuildOverflowIndicator(): void {
- const popoverContent = this._popoverContent!;
- const hasTopOverflow = popoverContent.scrollTop > 0;
- if (hasTopOverflow) {
- popoverContent.classList.add("overflowTop");
- } else {
- popoverContent.classList.remove("overflowTop");
- }
-
- const hasBottomOverflow = popoverContent.scrollTop + popoverContent.clientHeight < popoverContent.scrollHeight;
- if (hasBottomOverflow) {
- popoverContent.classList.add("overflowBottom");
- } else {
- popoverContent.classList.remove("overflowBottom");
- }
- }
-
- /**
- * Sort the reaction types by the showOrder field.
- */
- protected _getSortedReactionTypes(): Reaction[] {
- return availableReactions.sort((a, b) => a.showOrder - b.showOrder);
- }
-
- /**
- * Closes the react popover.
- */
- protected _closePopover(): void {
- if (this._popoverCurrentObjectId !== 0) {
- const popover = this._getPopover();
- popover.classList.remove("active");
-
- popover
- .querySelectorAll('.reactionTypeButton[data-is-assignable="0"]')
- .forEach((el: HTMLElement) => DomUtil.hide(el));
-
- if (this._options.isButtonGroupNavigation) {
- this._objects.get(this._popoverCurrentObjectId)!.forEach((elementData) => {
- elementData.reactButton!.closest("nav")!.style.cssText = "";
- });
- }
-
- this._popoverCurrentObjectId = 0;
- }
- }
-
- /**
- * React with the given reactionTypeId on an object.
- */
- protected _react(reactionTypeId: number): void {
- if (~~this._popoverCurrentObjectId === 0) {
- // Double clicking the reaction will cause the first click to go through, but
- // causes the second to fail because the overlay is already closing.
- return;
- }
-
- this._options.parameters.reactionTypeID = reactionTypeId;
- this._options.parameters.data.objectID = this._popoverCurrentObjectId;
- this._options.parameters.data.objectType = this._objectType;
-
- Ajax.api(this, {
- parameters: this._options.parameters,
- });
-
- this._closePopover();
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- this.countButtons.updateCountButtons(data.returnValues.objectID, data.returnValues.reactions);
-
- this._updateReactButton(data.returnValues.objectID, data.returnValues.reactionTypeID);
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "react",
- className: "\\wcf\\data\\reaction\\ReactionAction",
- },
- };
- }
-}
-
-Core.enableLegacyInheritance(UiReactionHandler);
-
-export = UiReactionHandler;
+++ /dev/null
-/**
- * Handles the reaction list in the user profile.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Reaction/Profile/Loader
- * @since 5.2
- */
-
-import * as Ajax from "../../../Ajax";
-import { AjaxCallbackSetup, ResponseData } from "../../../Ajax/Data";
-import * as Core from "../../../Core";
-import DomUtil from "../../../Dom/Util";
-import * as Language from "../../../Language";
-
-interface AjaxParameters {
- parameters: {
- [key: string]: number | string;
- };
-}
-
-interface AjaxResponse extends ResponseData {
- returnValues: {
- template?: string;
- lastLikeTime: number;
- };
-}
-
-class UiReactionProfileLoader {
- protected readonly _container: HTMLElement;
- protected readonly _loadButton: HTMLButtonElement;
- protected readonly _noMoreEntries: HTMLElement;
- protected readonly _options: AjaxParameters;
- protected _reactionTypeID: number | null = null;
- protected _targetType = "received";
- protected readonly _userID: number;
-
- /**
- * Initializes a new ReactionListLoader object.
- */
- constructor(userID: number) {
- this._container = document.getElementById("likeList")!;
- this._userID = userID;
- this._options = {
- parameters: {},
- };
-
- if (!this._userID) {
- throw new Error("[WoltLabSuite/Core/Ui/Reaction/Profile/Loader] Invalid parameter 'userID' given.");
- }
-
- const loadButtonList = document.createElement("li");
- loadButtonList.className = "likeListMore showMore";
- this._noMoreEntries = document.createElement("small");
- this._noMoreEntries.innerHTML = Language.get("wcf.like.reaction.noMoreEntries");
- this._noMoreEntries.style.display = "none";
- loadButtonList.appendChild(this._noMoreEntries);
-
- this._loadButton = document.createElement("button");
- this._loadButton.className = "small";
- this._loadButton.innerHTML = Language.get("wcf.like.reaction.more");
- this._loadButton.addEventListener("click", () => this._loadReactions());
- this._loadButton.style.display = "none";
- loadButtonList.appendChild(this._loadButton);
- this._container.appendChild(loadButtonList);
-
- if (document.querySelectorAll("#likeList > li").length === 2) {
- this._noMoreEntries.style.display = "";
- } else {
- this._loadButton.style.display = "";
- }
-
- this._setupReactionTypeButtons();
- this._setupTargetTypeButtons();
- }
-
- /**
- * Set up the reaction type buttons.
- */
- protected _setupReactionTypeButtons(): void {
- document.querySelectorAll("#reactionType .button").forEach((element: HTMLElement) => {
- element.addEventListener("click", () => this._changeReactionTypeValue(~~element.dataset.reactionTypeId!));
- });
- }
-
- /**
- * Set up the target type buttons.
- */
- protected _setupTargetTypeButtons(): void {
- document.querySelectorAll("#likeType .button").forEach((element: HTMLElement) => {
- element.addEventListener("click", () => this._changeTargetType(element.dataset.likeType!));
- });
- }
-
- /**
- * Changes the reaction target type (given or received) and reload the entire element.
- */
- protected _changeTargetType(targetType: string): void {
- if (targetType !== "given" && targetType !== "received") {
- throw new Error("[WoltLabSuite/Core/Ui/Reaction/Profile/Loader] Invalid parameter 'targetType' given.");
- }
-
- if (targetType !== this._targetType) {
- // remove old active state
- document.querySelector("#likeType .button.active")!.classList.remove("active");
-
- // add active status to new button
- document.querySelector(`#likeType .button[data-like-type="${targetType}"]`)!.classList.add("active");
-
- this._targetType = targetType;
- this._reload();
- }
- }
-
- /**
- * Changes the reaction type value and reload the entire element.
- */
- protected _changeReactionTypeValue(reactionTypeID: number): void {
- // remove old active state
- const activeButton = document.querySelector("#reactionType .button.active");
- if (activeButton) {
- activeButton.classList.remove("active");
- }
-
- if (this._reactionTypeID !== reactionTypeID) {
- // add active status to new button
- document
- .querySelector(`#reactionType .button[data-reaction-type-id="${reactionTypeID}"]`)!
- .classList.add("active");
-
- this._reactionTypeID = reactionTypeID;
- } else {
- this._reactionTypeID = null;
- }
-
- this._reload();
- }
-
- /**
- * Handles reload.
- */
- protected _reload(): void {
- document.querySelectorAll("#likeList > li:not(:first-child):not(:last-child)").forEach((el) => el.remove());
-
- this._container.dataset.lastLikeTime = "0";
-
- this._loadReactions();
- }
-
- /**
- * Load a list of reactions.
- */
- protected _loadReactions(): void {
- this._options.parameters.userID = this._userID;
- this._options.parameters.lastLikeTime = ~~this._container.dataset.lastLikeTime!;
- this._options.parameters.targetType = this._targetType;
- this._options.parameters.reactionTypeID = ~~this._reactionTypeID!;
-
- Ajax.api(this, {
- parameters: this._options.parameters,
- });
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- if (data.returnValues.template) {
- document
- .querySelector("#likeList > li:nth-last-child(1)")!
- .insertAdjacentHTML("beforebegin", data.returnValues.template);
-
- this._container.dataset.lastLikeTime = data.returnValues.lastLikeTime.toString();
- DomUtil.hide(this._noMoreEntries);
- DomUtil.show(this._loadButton);
- } else {
- DomUtil.show(this._noMoreEntries);
- DomUtil.hide(this._loadButton);
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "load",
- className: "\\wcf\\data\\reaction\\ReactionAction",
- },
- };
- }
-}
-
-Core.enableLegacyInheritance(UiReactionProfileLoader);
-
-export = UiReactionProfileLoader;
+++ /dev/null
-/**
- * Converts `<woltlab-metacode>` into the bbcode representation.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Redactor/Article
- */
-
-import * as Core from "../../Core";
-import * as UiArticleSearch from "../Article/Search";
-import { RedactorEditor } from "./Editor";
-
-class UiRedactorArticle {
- protected readonly _editor: RedactorEditor;
-
- constructor(editor: RedactorEditor, button: HTMLAnchorElement) {
- this._editor = editor;
-
- button.addEventListener("click", (ev) => this._click(ev));
- }
-
- protected _click(event: MouseEvent): void {
- event.preventDefault();
-
- UiArticleSearch.open((articleId) => this._insert(articleId));
- }
-
- protected _insert(articleId: number): void {
- this._editor.buffer.set();
-
- this._editor.insert.text(`[wsa='${articleId}'][/wsa]`);
- }
-}
-
-Core.enableLegacyInheritance(UiRedactorArticle);
-
-export = UiRedactorArticle;
+++ /dev/null
-/**
- * Manages the autosave process storing the current editor message in the local
- * storage to recover it on browser crash or accidental navigation.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Redactor/Autosave
- */
-
-import * as Core from "../../Core";
-import Devtools from "../../Devtools";
-import * as EventHandler from "../../Event/Handler";
-import * as Language from "../../Language";
-import { RedactorEditor } from "./Editor";
-import * as UiRedactorMetacode from "./Metacode";
-
-interface AutosaveMetaData {
- [key: string]: unknown;
-}
-
-interface AutosaveContent {
- content: string;
- meta: AutosaveMetaData;
- timestamp: number;
-}
-
-// time between save requests in seconds
-const _frequency = 15;
-
-class UiRedactorAutosave {
- protected _container: HTMLElement | null = null;
- protected _editor: RedactorEditor | null = null;
- protected readonly _element: HTMLTextAreaElement;
- protected _isActive = true;
- protected _isPending = false;
- protected readonly _key: string;
- protected _lastMessage = "";
- protected _metaData: AutosaveMetaData = {};
- protected _originalMessage = "";
- protected _restored = false;
- protected _timer: number | null = null;
-
- /**
- * Initializes the autosave handler and removes outdated messages from storage.
- *
- * @param {Element} element textarea element
- */
- constructor(element: HTMLTextAreaElement) {
- this._element = element;
- this._key = Core.getStoragePrefix() + this._element.dataset.autosave!;
-
- this._cleanup();
-
- // remove attribute to prevent Redactor's built-in autosave to kick in
- delete this._element.dataset.autosave;
-
- const form = this._element.closest("form");
- if (form !== null) {
- form.addEventListener("submit", this.destroy.bind(this));
- }
-
- // export meta data
- EventHandler.add("com.woltlab.wcf.redactor2", `getMetaData_${this._element.id}`, (data: AutosaveMetaData) => {
- Object.entries(this._metaData).forEach(([key, value]) => {
- data[key] = value;
- });
- });
-
- // clear editor content on reset
- EventHandler.add("com.woltlab.wcf.redactor2", `reset_${this._element.id}`, () => this.hideOverlay());
-
- document.addEventListener("visibilitychange", () => this._onVisibilityChange());
- }
-
- protected _onVisibilityChange(): void {
- this._isActive = !document.hidden;
- this._isPending = document.hidden;
- }
-
- /**
- * Returns the initial value for the textarea, used to inject message
- * from storage into the editor before initialization.
- *
- * @return {string} message content
- */
- getInitialValue(): string {
- if (window.ENABLE_DEVELOPER_TOOLS && !Devtools._internal_.editorAutosave()) {
- return this._element.value;
- }
-
- let value = "";
- try {
- value = window.localStorage.getItem(this._key) || "";
- } catch (e) {
- const errorMessage = (e as Error).message;
- window.console.warn(`Unable to access local storage: ${errorMessage}`);
- }
-
- let metaData: AutosaveContent | null = null;
- try {
- metaData = JSON.parse(value);
- } catch (e) {
- // We do not care for JSON errors.
- }
-
- // Check if the storage is outdated.
- if (metaData !== null && typeof metaData === "object" && metaData.content) {
- const lastEditTime = ~~this._element.dataset.autosaveLastEditTime!;
- if (lastEditTime * 1_000 <= metaData.timestamp) {
- // Compare the stored version with the editor content, but only use the `innerText` property
- // in order to ignore differences in whitespace, e. g. caused by indentation of HTML tags.
- const div1 = document.createElement("div");
- div1.innerHTML = this._element.value;
- const div2 = document.createElement("div");
- div2.innerHTML = metaData.content;
-
- if (div1.innerText.trim() !== div2.innerText.trim()) {
- this._originalMessage = this._element.value;
- this._restored = true;
-
- this._metaData = metaData.meta || {};
-
- return metaData.content;
- }
- }
- }
-
- return this._element.value;
- }
-
- /**
- * Returns the stored meta data.
- */
- getMetaData(): AutosaveMetaData {
- return this._metaData;
- }
-
- /**
- * Enables periodical save of editor contents to local storage.
- */
- watch(editor: RedactorEditor): void {
- this._editor = editor;
-
- if (this._timer !== null) {
- throw new Error("Autosave timer is already active.");
- }
-
- this._timer = window.setInterval(() => this._saveToStorage(), _frequency * 1_000);
-
- this._saveToStorage();
-
- this._isPending = false;
- }
-
- /**
- * Disables autosave handler, for use on editor destruction.
- */
- destroy(): void {
- this.clear();
-
- this._editor = null;
-
- if (this._timer) {
- window.clearInterval(this._timer);
- }
-
- this._timer = null;
- this._isPending = false;
- }
-
- /**
- * Removed the stored message, for use after a message has been submitted.
- */
- clear(): void {
- this._metaData = {};
- this._lastMessage = "";
-
- try {
- window.localStorage.removeItem(this._key);
- } catch (e) {
- const errorMessage = (e as Error).message;
- window.console.warn(`Unable to remove from local storage: ${errorMessage}`);
- }
- }
-
- /**
- * Creates the autosave controls, used to keep or discard the restored draft.
- */
- createOverlay(): void {
- if (!this._restored) {
- return;
- }
-
- const editor = this._editor!;
-
- const container = document.createElement("div");
- container.className = "redactorAutosaveRestored active";
-
- const title = document.createElement("span");
- title.textContent = Language.get("wcf.editor.autosave.restored");
- container.appendChild(title);
-
- const buttonKeep = document.createElement("a");
- buttonKeep.className = "jsTooltip";
- buttonKeep.href = "#";
- buttonKeep.title = Language.get("wcf.editor.autosave.keep");
- buttonKeep.innerHTML = '<span class="icon icon16 fa-check green"></span>';
- buttonKeep.addEventListener("click", (event) => {
- event.preventDefault();
-
- this.hideOverlay();
- });
- container.appendChild(buttonKeep);
-
- const buttonDiscard = document.createElement("a");
- buttonDiscard.className = "jsTooltip";
- buttonDiscard.href = "#";
- buttonDiscard.title = Language.get("wcf.editor.autosave.discard");
- buttonDiscard.innerHTML = '<span class="icon icon16 fa-times red"></span>';
- buttonDiscard.addEventListener("click", (event) => {
- event.preventDefault();
-
- // remove from storage
- this.clear();
-
- // set code
- const content = UiRedactorMetacode.convertFromHtml(editor.core.element()[0].id, this._originalMessage);
- editor.code.start(content);
-
- // set value
- editor.core.textarea().val(editor.clean.onSync(editor.$editor.html()));
-
- this.hideOverlay();
- });
- container.appendChild(buttonDiscard);
-
- editor.core.box()[0].appendChild(container);
-
- editor.core.editor()[0].addEventListener("click", () => this.hideOverlay(), { once: true });
-
- this._container = container;
- }
-
- /**
- * Hides the autosave controls.
- */
- hideOverlay(): void {
- if (this._container !== null) {
- this._container.classList.remove("active");
-
- window.setTimeout(() => {
- if (this._container !== null) {
- this._container.remove();
- }
-
- this._container = null;
- this._originalMessage = "";
- }, 1_000);
- }
- }
-
- /**
- * Saves the current message to storage unless there was no change.
- */
- protected _saveToStorage(): void {
- if (!this._isActive) {
- if (!this._isPending) {
- return;
- }
-
- // save one last time before suspending
- this._isPending = false;
- }
-
- //noinspection JSUnresolvedVariable
- if (window.ENABLE_DEVELOPER_TOOLS && !Devtools._internal_.editorAutosave()) {
- return;
- }
-
- const editor = this._editor!;
- let content = editor.code.get();
- if (editor.utils.isEmpty(content)) {
- content = "";
- }
-
- if (this._lastMessage === content) {
- // break if content hasn't changed
- return;
- }
-
- if (content === "") {
- return this.clear();
- }
-
- try {
- EventHandler.fire("com.woltlab.wcf.redactor2", `autosaveMetaData_${this._element.id}`, this._metaData);
-
- window.localStorage.setItem(
- this._key,
- JSON.stringify({
- content: content,
- meta: this._metaData,
- timestamp: Date.now(),
- } as AutosaveContent),
- );
-
- this._lastMessage = content;
- } catch (e) {
- const errorMessage = (e as Error).message;
- window.console.warn(`Unable to write to local storage: ${errorMessage}`);
- }
- }
-
- /**
- * Removes stored messages older than one week.
- */
- protected _cleanup(): void {
- const oneWeekAgo = Date.now() - 7 * 24 * 3_600 * 1_000;
-
- Object.keys(window.localStorage)
- .filter((key) => key.startsWith(Core.getStoragePrefix()))
- .forEach((key) => {
- let value = "";
- try {
- value = window.localStorage.getItem(key) || "";
- } catch (e) {
- const errorMessage = (e as Error).message;
- window.console.warn(`Unable to access local storage: ${errorMessage}`);
- }
-
- let timestamp = 0;
- try {
- const content: AutosaveContent = JSON.parse(value);
- timestamp = content.timestamp;
- } catch (e) {
- // We do not care for JSON errors.
- }
-
- if (!value || timestamp < oneWeekAgo) {
- try {
- window.localStorage.removeItem(key);
- } catch (e) {
- const errorMessage = (e as Error).message;
- window.console.warn(`Unable to remove from local storage: ${errorMessage}`);
- }
- }
- });
- }
-}
-
-Core.enableLegacyInheritance(UiRedactorAutosave);
-
-export = UiRedactorAutosave;
+++ /dev/null
-/**
- * Manages code blocks.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Redactor/Code
- */
-
-import * as Core from "../../Core";
-import DomUtil from "../../Dom/Util";
-import * as EventHandler from "../../Event/Handler";
-import * as Language from "../../Language";
-import * as StringUtil from "../../StringUtil";
-import UiDialog from "../Dialog";
-import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
-import { RedactorEditor, WoltLabEventData } from "./Editor";
-import * as UiRedactorPseudoHeader from "./PseudoHeader";
-import PrismMeta from "../../prism-meta";
-
-type Highlighter = [string, string];
-
-let _headerHeight = 0;
-
-class UiRedactorCode implements DialogCallbackObject {
- protected readonly _callbackEdit: (ev: MouseEvent) => void;
- protected readonly _editor: RedactorEditor;
- protected readonly _elementId: string;
- protected _pre: HTMLElement | null = null;
-
- /**
- * Initializes the source code management.
- */
- constructor(editor: RedactorEditor) {
- this._editor = editor;
- this._elementId = this._editor.$element[0].id;
-
- EventHandler.add("com.woltlab.wcf.redactor2", `bbcode_code_${this._elementId}`, (data) => this._bbcodeCode(data));
- EventHandler.add("com.woltlab.wcf.redactor2", `observe_load_${this._elementId}`, () => this._observeLoad());
-
- // support for active button marking
- this._editor.opts.activeButtonsStates.pre = "code";
-
- // static bind to ensure that removing works
- this._callbackEdit = this._edit.bind(this);
-
- // bind listeners on init
- this._observeLoad();
- }
-
- /**
- * Intercepts the insertion of `[code]` tags and uses a native `<pre>` instead.
- */
- protected _bbcodeCode(data: WoltLabEventData): void {
- data.cancel = true;
-
- let pre = this._editor.selection.block();
- if (pre && pre.nodeName === "PRE" && pre.classList.contains("woltlabHtml")) {
- return;
- }
-
- this._editor.button.toggle({}, "pre", "func", "block.format");
-
- pre = this._editor.selection.block();
- if (pre && pre.nodeName === "PRE" && !pre.classList.contains("woltlabHtml")) {
- if (pre.childElementCount === 1 && pre.children[0].nodeName === "BR") {
- // drop superfluous linebreak
- pre.removeChild(pre.children[0]);
- }
-
- this._setTitle(pre);
-
- pre.addEventListener("click", this._callbackEdit);
-
- // work-around for Safari
- this._editor.caret.end(pre);
- }
- }
-
- /**
- * Binds event listeners and sets quote title on both editor
- * initialization and when switching back from code view.
- */
- protected _observeLoad(): void {
- this._editor.$editor[0].querySelectorAll("pre:not(.woltlabHtml)").forEach((pre: HTMLElement) => {
- pre.addEventListener("mousedown", this._callbackEdit);
- this._setTitle(pre);
- });
- }
-
- /**
- * Opens the dialog overlay to edit the code's properties.
- */
- protected _edit(event: MouseEvent): void {
- const pre = event.currentTarget as HTMLPreElement;
-
- if (_headerHeight === 0) {
- _headerHeight = UiRedactorPseudoHeader.getHeight(pre);
- }
-
- // check if the click hit the header
- const offset = DomUtil.offset(pre);
- if (event.pageY > offset.top && event.pageY < offset.top + _headerHeight) {
- event.preventDefault();
-
- this._editor.selection.save();
- this._pre = pre;
-
- UiDialog.open(this);
- }
- }
-
- /**
- * Saves the changes to the code's properties.
- */
- _dialogSubmit(): void {
- const id = "redactor-code-" + this._elementId;
- const pre = this._pre!;
-
- ["file", "highlighter", "line"].forEach((attr) => {
- const input = document.getElementById(`${id}-${attr}`) as HTMLInputElement;
- pre.dataset[attr] = input.value;
- });
-
- this._setTitle(pre);
- this._editor.caret.after(pre);
-
- UiDialog.close(this);
- }
-
- /**
- * Sets or updates the code's header title.
- */
- protected _setTitle(pre: HTMLElement): void {
- const file = pre.dataset.file!;
- let highlighter = pre.dataset.highlighter!;
-
- highlighter =
- this._editor.opts.woltlab.highlighters.indexOf(highlighter) !== -1 ? PrismMeta[highlighter].title : "";
-
- const title = Language.get("wcf.editor.code.title", {
- file,
- highlighter,
- });
-
- if (pre.dataset.title !== title) {
- pre.dataset.title = title;
- }
- }
-
- protected _delete(event: MouseEvent): void {
- event.preventDefault();
-
- const pre = this._pre!;
- let caretEnd = pre.nextElementSibling || pre.previousElementSibling;
- if (caretEnd === null && pre.parentElement !== this._editor.core.editor()[0]) {
- caretEnd = pre.parentElement;
- }
-
- if (caretEnd === null) {
- this._editor.code.set("");
- this._editor.focus.end();
- } else {
- pre.remove();
- this._editor.caret.end(caretEnd);
- }
-
- UiDialog.close(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- const id = `redactor-code-${this._elementId}`;
- const idButtonDelete = `${id}-button-delete`;
- const idButtonSave = `${id}-button-save`;
- const idFile = `${id}-file`;
- const idHighlighter = `${id}-highlighter`;
- const idLine = `${id}-line`;
-
- return {
- id: id,
- options: {
- onClose: () => {
- this._editor.selection.restore();
-
- UiDialog.destroy(this);
- },
-
- onSetup: () => {
- document.getElementById(idButtonDelete)!.addEventListener("click", (ev) => this._delete(ev));
-
- // set highlighters
- let highlighters = `<option value="">${Language.get("wcf.editor.code.highlighter.detect")}</option>
- <option value="plain">${Language.get("wcf.editor.code.highlighter.plain")}</option>`;
-
- const values: Highlighter[] = this._editor.opts.woltlab.highlighters.map((highlighter: string) => {
- return [highlighter, PrismMeta[highlighter].title];
- });
-
- // sort by label
- values.sort((a, b) => a[1].localeCompare(b[1]));
-
- highlighters += values
- .map(([highlighter, title]) => {
- return `<option value="${highlighter}">${StringUtil.escapeHTML(title)}</option>`;
- })
- .join("\n");
-
- document.getElementById(idHighlighter)!.innerHTML = highlighters;
- },
-
- onShow: () => {
- const pre = this._pre!;
-
- const highlighter = document.getElementById(idHighlighter) as HTMLSelectElement;
- highlighter.value = pre.dataset.highlighter || "";
- const line = ~~(pre.dataset.line || 1);
-
- const lineInput = document.getElementById(idLine) as HTMLInputElement;
- lineInput.value = line.toString();
-
- const filename = document.getElementById(idFile) as HTMLInputElement;
- filename.value = pre.dataset.file || "";
- },
-
- title: Language.get("wcf.editor.code.edit"),
- },
- source: `<div class="section">
- <dl>
- <dt>
- <label for="${idHighlighter}">${Language.get("wcf.editor.code.highlighter")}</label>
- </dt>
- <dd>
- <select id="${idHighlighter}"></select>
- <small>${Language.get("wcf.editor.code.highlighter.description")}</small>
- </dd>
- </dl>
- <dl>
- <dt>
- <label for="${idLine}">${Language.get("wcf.editor.code.line")}</label>
- </dt>
- <dd>
- <input type="number" id="${idLine}" min="0" value="1" class="long" data-dialog-submit-on-enter="true">
- <small>${Language.get("wcf.editor.code.line.description")}</small>
- </dd>
- </dl>
- <dl>
- <dt>
- <label for="${idFile}">${Language.get("wcf.editor.code.file")}</label>
- </dt>
- <dd>
- <input type="text" id="${idFile}" class="long" data-dialog-submit-on-enter="true">
- <small>${Language.get("wcf.editor.code.file.description")}</small>
- </dd>
- </dl>
- </div>
- <div class="formSubmit">
- <button id="${idButtonSave}" class="buttonPrimary" data-type="submit">${Language.get(
- "wcf.global.button.save",
- )}</button>
- <button id="${idButtonDelete}">${Language.get("wcf.global.button.delete")}</button>
- </div>`,
- };
- }
-}
-
-Core.enableLegacyInheritance(UiRedactorCode);
-
-export = UiRedactorCode;
+++ /dev/null
-/**
- * Drag and Drop file uploads.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Redactor/DragAndDrop
- */
-
-import * as EventHandler from "../../Event/Handler";
-import * as Language from "../../Language";
-import { RedactorEditor } from "./Editor";
-
-type Uuid = string;
-
-interface EditorData {
- editor: RedactorEditor | RedactorEditorLike;
- element: HTMLElement | null;
-}
-
-let _didInit = false;
-const _dragArea = new Map<Uuid, EditorData>();
-let _isDragging = false;
-let _isFile = false;
-let _timerLeave: number | null = null;
-
-/**
- * Handles items dragged into the browser window.
- */
-function _dragOver(event: DragEvent): void {
- event.preventDefault();
-
- if (!event.dataTransfer || !event.dataTransfer.types) {
- return;
- }
-
- const isFirefox = Object.keys(event.dataTransfer).some((property) => property.startsWith("moz"));
-
- // IE and WebKit set 'Files', Firefox sets 'application/x-moz-file' for files being dragged
- // and Safari just provides 'Files' along with a huge list of garbage
- _isFile = false;
- if (isFirefox) {
- // Firefox sets the 'Files' type even if the user is just dragging an on-page element
- if (event.dataTransfer.types[0] === "application/x-moz-file") {
- _isFile = true;
- }
- } else {
- _isFile = event.dataTransfer.types.some((type) => type === "Files");
- }
-
- if (!_isFile) {
- // user is just dragging around some garbage, ignore it
- return;
- }
-
- if (_isDragging) {
- // user is still dragging the file around
- return;
- }
-
- _isDragging = true;
-
- _dragArea.forEach((data, uuid) => {
- const editor = data.editor.$editor[0];
- if (!editor.parentElement) {
- _dragArea.delete(uuid);
- return;
- }
-
- let element: HTMLElement | null = data.element;
- if (element === null) {
- element = document.createElement("div");
- element.className = "redactorDropArea";
- element.dataset.elementId = data.editor.$element[0].id;
- element.dataset.dropHere = Language.get("wcf.attachment.dragAndDrop.dropHere");
- element.dataset.dropNow = Language.get("wcf.attachment.dragAndDrop.dropNow");
-
- element.addEventListener("dragover", () => {
- element!.classList.add("active");
- });
- element.addEventListener("dragleave", () => {
- element!.classList.remove("active");
- });
- element.addEventListener("drop", (ev) => drop(ev));
-
- data.element = element;
- }
-
- editor.parentElement.insertBefore(element, editor);
- element.style.setProperty("top", `${editor.offsetTop}px`, "");
- });
-}
-
-/**
- * Handles items dropped onto an editor's drop area
- */
-function drop(event: DragEvent): void {
- if (!_isFile) {
- return;
- }
-
- if (!event.dataTransfer || !event.dataTransfer.files.length) {
- return;
- }
-
- event.preventDefault();
-
- const target = event.currentTarget as HTMLElement;
- const elementId = target.dataset.elementId!;
-
- Array.from(event.dataTransfer.files).forEach((file) => {
- const eventData: OnDropPayload = { file };
- EventHandler.fire("com.woltlab.wcf.redactor2", `dragAndDrop_${elementId}`, eventData);
- });
-
- // this will reset all drop areas
- dragLeave();
-}
-
-/**
- * Invoked whenever the item is no longer dragged or was dropped.
- *
- * @protected
- */
-function dragLeave() {
- if (!_isDragging || !_isFile) {
- return;
- }
-
- if (_timerLeave !== null) {
- window.clearTimeout(_timerLeave);
- }
-
- _timerLeave = window.setTimeout(() => {
- if (!_isDragging) {
- _dragArea.forEach((data) => {
- if (data.element && data.element.parentElement) {
- data.element.classList.remove("active");
- data.element.remove();
- }
- });
- }
-
- _timerLeave = null;
- }, 100);
-
- _isDragging = false;
-}
-
-/**
- * Handles the global drop event.
- */
-function globalDrop(event: DragEvent): void {
- const target = event.target as HTMLElement;
- if (target.closest(".redactor-layer") === null) {
- const eventData: OnGlobalDropPayload = { cancelDrop: true, event: event };
- _dragArea.forEach((data) => {
- EventHandler.fire("com.woltlab.wcf.redactor2", `dragAndDrop_globalDrop_${data.editor.$element[0].id}`, eventData);
- });
-
- if (eventData.cancelDrop) {
- event.preventDefault();
- }
- }
-
- dragLeave();
-}
-
-/**
- * Binds listeners to global events.
- *
- * @protected
- */
-function setup() {
- // discard garbage event
- window.addEventListener("dragend", (ev) => ev.preventDefault());
-
- window.addEventListener("dragover", (ev) => _dragOver(ev));
- window.addEventListener("dragleave", () => dragLeave());
- window.addEventListener("drop", (ev) => globalDrop(ev));
-
- _didInit = true;
-}
-
-/**
- * Initializes drag and drop support for provided editor instance.
- */
-export function init(editor: RedactorEditor | RedactorEditorLike): void {
- if (!_didInit) {
- setup();
- }
-
- _dragArea.set(editor.uuid, {
- editor: editor,
- element: null,
- });
-}
-
-export interface RedactorEditorLike {
- uuid: string;
- $editor: HTMLElement[];
- $element: HTMLElement[];
-}
-
-export interface OnDropPayload {
- file: File;
-}
-
-export interface OnGlobalDropPayload {
- cancelDrop: boolean;
- event: DragEvent;
-}
+++ /dev/null
-export interface RedactorEditor {
- uuid: string;
- $editor: JQuery;
- $element: JQuery;
-
- opts: {
- [key: string]: any;
- };
-
- buffer: {
- set(): void;
- };
- button: {
- addCallback(button: JQuery, callback: () => void): void;
- toggle(event: MouseEvent | object, btnName: string, type: string, callback: string, args?: object): void;
- };
- caret: {
- after(node: Node): void;
- end(node: Node): void;
- };
- clean: {
- onSync(html: string): string;
- };
- code: {
- get(): string;
- set(html: string): void;
- start(html: string): void;
- };
- core: {
- box(): JQuery;
- editor(): JQuery;
- element(): JQuery;
- textarea(): JQuery;
- toolbar(): JQuery;
- };
- focus: {
- end(): void;
- };
- insert: {
- html(html: string): void;
- text(text: string): void;
- };
- selection: {
- block(): HTMLElement | false;
- restore(): void;
- save(): void;
- };
- utils: {
- isEmpty(html?: string): boolean;
- };
-
- WoltLabAutosave: {
- reset(): void;
- };
- WoltLabCaret: {
- endOfEditor(): void;
- paragraphAfterBlock(quote: HTMLElement): void;
- };
- WoltLabEvent: {
- register(event: string, callback: (data: WoltLabEventData) => void): void;
- };
- WoltLabReply: {
- showEditor(): void;
- };
- WoltLabSource: {
- isActive(): boolean;
- };
-}
-
-export interface WoltLabEventData {
- cancel: boolean;
- event: Event;
- redactor: RedactorEditor;
-}
+++ /dev/null
-/**
- * Provides helper methods to add and remove format elements. These methods should in
- * theory work with non-editor elements but has not been tested and any usage outside
- * the editor is not recommended.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Redactor/Format
- */
-
-import DomUtil from "../../Dom/Util";
-
-type SelectionMarker = [string, string];
-
-function isValidSelection(editorElement: HTMLElement): boolean {
- let element = window.getSelection()!.anchorNode;
- while (element) {
- if (element === editorElement) {
- return true;
- }
-
- element = element.parentNode;
- }
-
- return false;
-}
-
-/**
- * Slices relevant parent nodes and removes matching ancestors.
- *
- * @param {Element} strikeElement strike element representing the text selection
- * @param {Element} lastMatchingParent last matching ancestor element
- * @param {string} property CSS property that should be removed
- */
-function handleParentNodes(strikeElement: HTMLElement, lastMatchingParent: HTMLElement, property: string): void {
- const parent = lastMatchingParent.parentElement!;
-
- // selection does not begin at parent node start, slice all relevant parent
- // nodes to ensure that selection is then at the beginning while preserving
- // all proper ancestor elements
- //
- // before: (the pipe represents the node boundary)
- // |otherContent <-- selection -->
- // after:
- // |otherContent| |<-- selection -->
- if (!DomUtil.isAtNodeStart(strikeElement, lastMatchingParent)) {
- const range = document.createRange();
- range.setStartBefore(lastMatchingParent);
- range.setEndBefore(strikeElement);
-
- const fragment = range.extractContents();
- parent.insertBefore(fragment, lastMatchingParent);
- }
-
- // selection does not end at parent node end, slice all relevant parent nodes
- // to ensure that selection is then at the end while preserving all proper
- // ancestor elements
- //
- // before: (the pipe represents the node boundary)
- // <-- selection --> otherContent|
- // after:
- // <-- selection -->| |otherContent|
- if (!DomUtil.isAtNodeEnd(strikeElement, lastMatchingParent)) {
- const range = document.createRange();
- range.setStartAfter(strikeElement);
- range.setEndAfter(lastMatchingParent);
-
- const fragment = range.extractContents();
- parent.insertBefore(fragment, lastMatchingParent.nextSibling);
- }
-
- // the strike element is now some kind of isolated, meaning we can now safely
- // remove all offending parent nodes without influencing formatting of any content
- // before or after the element
- lastMatchingParent.querySelectorAll("span").forEach((span) => {
- if (span.style.getPropertyValue(property)) {
- DomUtil.unwrapChildNodes(span);
- }
- });
-
- // finally remove the parent itself
- DomUtil.unwrapChildNodes(lastMatchingParent);
-}
-
-/**
- * Finds the last matching ancestor until it reaches the editor element.
- */
-function getLastMatchingParent(
- strikeElement: HTMLElement,
- editorElement: HTMLElement,
- property: string,
-): HTMLElement | null {
- let parent = strikeElement.parentElement!;
- let match: HTMLElement | null = null;
- while (parent !== editorElement) {
- if (parent.nodeName === "SPAN" && parent.style.getPropertyValue(property) !== "") {
- match = parent;
- }
-
- parent = parent.parentElement!;
- }
-
- return match;
-}
-
-/**
- * Returns true if provided element is the first or last element
- * of its parent, ignoring empty text nodes appearing between the
- * element and the boundary.
- */
-function isBoundaryElement(
- element: HTMLElement,
- parent: HTMLElement,
- type: "previousSibling" | "nextSibling",
-): boolean {
- let node: Node | null = element;
- while ((node = node[type])) {
- if (node.nodeType !== Node.TEXT_NODE || node.textContent!.replace(/\u200B/, "") !== "") {
- return false;
- }
- }
-
- return true;
-}
-
-/**
- * Returns a custom selection marker element, can be either `strike`, `sub` or `sup`. Using other kind
- * of formattings is not possible due to the inconsistent behavior across browsers.
- */
-function getSelectionMarker(editorElement: HTMLElement, selection: Selection): SelectionMarker {
- const tags = ["DEL", "SUB", "SUP"];
- const tag = tags.find((tagName) => {
- const anchorNode = selection.anchorNode!;
- let node: HTMLElement =
- anchorNode.nodeType === Node.ELEMENT_NODE ? (anchorNode as HTMLElement) : anchorNode.parentElement!;
- const hasNode = node.querySelector(tagName.toLowerCase()) !== null;
-
- if (!hasNode) {
- while (node && node !== editorElement) {
- if (node.nodeName === tagName) {
- return true;
- }
-
- node = node.parentElement!;
- }
- }
-
- return false;
- });
-
- if (tag === "DEL" || tag === undefined) {
- return ["strike", "strikethrough"];
- }
-
- return [tag.toLowerCase(), tag.toLowerCase() + "script"];
-}
-
-/**
- * Slightly modified version of Redactor's `utils.isEmpty()`.
- */
-function isEmpty(html: string): boolean {
- html = html.replace(/[\u200B-\u200D\uFEFF]/g, "");
- html = html.replace(/ /gi, "");
- html = html.replace(/<\/?br\s?\/?>/g, "");
- html = html.replace(/\s/g, "");
- html = html.replace(/^<p>[^\W\w\D\d]*?<\/p>$/i, "");
- html = html.replace(/<iframe(.*?[^>])>$/i, "iframe");
- html = html.replace(/<source(.*?[^>])>$/i, "source");
-
- // remove empty tags
- html = html.replace(/<[^/>][^>]*><\/[^>]+>/gi, "");
- html = html.replace(/<[^/>][^>]*><\/[^>]+>/gi, "");
-
- return html.trim() === "";
-}
-
-/**
- * Applies format elements to the selected text.
- */
-export function format(editorElement: HTMLElement, property: string, value: string): void {
- const selection = window.getSelection()!;
- if (!selection.rangeCount) {
- // no active selection
- return;
- }
-
- if (!isValidSelection(editorElement)) {
- console.error("Invalid selection, range exists outside of the editor:", selection.anchorNode);
- return;
- }
-
- let range = selection.getRangeAt(0);
- let markerStart: HTMLElement | null = null;
- let markerEnd: HTMLElement | null = null;
- let tmpElement: HTMLElement | null = null;
- if (range.collapsed) {
- tmpElement = document.createElement("strike");
- tmpElement.textContent = "\u200B";
- range.insertNode(tmpElement);
-
- range = document.createRange();
- range.selectNodeContents(tmpElement);
-
- selection.removeAllRanges();
- selection.addRange(range);
- } else {
- // removing existing format causes the selection to vanish,
- // these markers are used to restore it afterwards
- markerStart = document.createElement("mark");
- markerEnd = document.createElement("mark");
-
- let tmpRange = range.cloneRange();
- tmpRange.collapse(true);
- tmpRange.insertNode(markerStart);
-
- tmpRange = range.cloneRange();
- tmpRange.collapse(false);
- tmpRange.insertNode(markerEnd);
-
- range = document.createRange();
- range.setStartAfter(markerStart);
- range.setEndBefore(markerEnd);
-
- selection.removeAllRanges();
- selection.addRange(range);
-
- // remove existing format before applying new one
- removeFormat(editorElement, property);
-
- range = document.createRange();
- range.setStartAfter(markerStart);
- range.setEndBefore(markerEnd);
-
- selection.removeAllRanges();
- selection.addRange(range);
- }
-
- let selectionMarker: SelectionMarker = ["strike", "strikethrough"];
- if (tmpElement === null) {
- selectionMarker = getSelectionMarker(editorElement, selection);
-
- document.execCommand(selectionMarker[1]);
- }
-
- const selectElements: HTMLElement[] = [];
- editorElement.querySelectorAll(selectionMarker[0]).forEach((strike) => {
- const formatElement = document.createElement("span");
-
- // we're bypassing `style.setPropertyValue()` on purpose here,
- // as it prevents browsers from mangling the value
- formatElement.setAttribute("style", `${property}: ${value}`);
-
- DomUtil.replaceElement(strike, formatElement);
- selectElements.push(formatElement);
- });
-
- const count = selectElements.length;
- if (count) {
- const firstSelectedElement = selectElements[0];
- const lastSelectedElement = selectElements[count - 1];
-
- // check if parent is of the same format
- // and contains only the selected nodes
- if (tmpElement === null && firstSelectedElement.parentElement === lastSelectedElement.parentElement) {
- const parent = firstSelectedElement.parentElement!;
- if (parent.nodeName === "SPAN" && parent.style.getPropertyValue(property) !== "") {
- if (
- isBoundaryElement(firstSelectedElement, parent, "previousSibling") &&
- isBoundaryElement(lastSelectedElement, parent, "nextSibling")
- ) {
- DomUtil.unwrapChildNodes(parent);
- }
- }
- }
-
- range = document.createRange();
- range.setStart(firstSelectedElement, 0);
- range.setEnd(lastSelectedElement, lastSelectedElement.childNodes.length);
-
- selection.removeAllRanges();
- selection.addRange(range);
- }
-
- if (markerStart !== null) {
- markerStart.remove();
- markerEnd!.remove();
- }
-}
-
-/**
- * Removes a format element from the current selection.
- *
- * The removal uses a few techniques to remove the target element(s) without harming
- * nesting nor any other formatting present. The steps taken are described below:
- *
- * 1. The browser will wrap all parts of the selection into <strike> tags
- *
- * This isn't the most efficient way to isolate each selected node, but is the
- * most reliable way to accomplish this because the browser will insert them
- * exactly where the range spans without harming the node nesting.
- *
- * Basically it is a trade-off between efficiency and reliability, the performance
- * is still excellent but could be better at the expense of an increased complexity,
- * which simply doesn't exactly pay off.
- *
- * 2. Iterate over each inserted <strike> and isolate all relevant ancestors
- *
- * Format tags can appear both as a child of the <strike> as well as once or multiple
- * times as an ancestor.
- *
- * It uses ranges to select the contents before the <strike> element up to the start
- * of the last matching ancestor and cuts out the nodes. The browser will ensure that
- * the resulting fragment will include all relevant ancestors that were present before.
- *
- * The example below will use the fictional <bar> elements as the tag to remove, the
- * pipe ("|") is used to denote the outer node boundaries.
- *
- * Before:
- * |<bar>This is <foo>a <strike>simple <bar>example</bar></strike></foo></bar>|
- * After:
- * |<bar>This is <foo>a </foo></bar>|<bar><foo>simple <bar>example</bar></strike></foo></bar>|
- *
- * As a result we can now remove <bar> both inside the <strike> element as well as
- * the outer <bar> without harming the effect of <bar> for the preceding siblings.
- *
- * This process is repeated for siblings appearing after the <strike> element too, it
- * works as described above but flipped. This is an expensive operation and will only
- * take place if there are any matching ancestors that need to be considered.
- *
- * Inspired by http://stackoverflow.com/a/12899461
- *
- * 3. Remove all matching ancestors, child elements and last the <strike> element itself
- *
- * Depending on the amount of nested matching nodes, this process will move a lot of
- * nodes around. Removing the <bar> element will require all its child nodes to be moved
- * in front of <bar>, they will actually become a sibling of <bar>. Afterwards the
- * (now empty) <bar> element can be safely removed without losing any nodes.
- *
- *
- * One last hint: This method will not check if the selection at some point contains at
- * least one target element, it assumes that the user will not take any action that invokes
- * this method for no reason (unless they want to waste CPU cycles, in that case they're
- * welcome).
- *
- * This is especially important for developers as this method shouldn't be called for
- * no good reason. Even though it is super fast, it still comes with expensive DOM operations
- * and especially low-end devices (such as cheap smartphones) might not exactly like executing
- * this method on large documents.
- *
- * If you fell the need to invoke this method anyway, go ahead. I'm a comment, not a cop.
- */
-export function removeFormat(editorElement: HTMLElement, property: string): void {
- const selection = window.getSelection()!;
- if (!selection.rangeCount) {
- return;
- } else if (!isValidSelection(editorElement)) {
- console.error("Invalid selection, range exists outside of the editor:", selection.anchorNode);
- return;
- }
-
- // Removing a span from an empty selection in an empty line containing a `<br>` causes a selection
- // shift where the caret is moved into the span again. Unlike inline changes to the formatting, any
- // removal of the format in an empty line should remove it from its entirely, instead of just around
- // the caret position.
- let range = selection.getRangeAt(0);
- let helperTextNode: Text | null = null;
- const rangeIsCollapsed = range.collapsed;
- if (rangeIsCollapsed) {
- let container = range.startContainer as HTMLElement;
- const tree = [container];
- for (;;) {
- const parent = container.parentElement!;
- if (parent === editorElement || parent.nodeName === "TD") {
- break;
- }
-
- container = parent;
- tree.push(container);
- }
-
- if (isEmpty(container.innerHTML)) {
- const marker = document.createElement("woltlab-format-marker");
- range.insertNode(marker);
-
- // Find the offending span and remove it entirely.
- tree.forEach((element) => {
- if (element.nodeName === "SPAN") {
- if (element.style.getPropertyValue(property)) {
- DomUtil.unwrapChildNodes(element);
- }
- }
- });
-
- // Firefox messes up the selection if the ancestor element was removed and there is
- // an adjacent `<br>` present. Instead of keeping the caret in front of the <br>, it
- // is implicitly moved behind it.
- range = document.createRange();
- range.selectNode(marker);
- range.collapse(true);
-
- selection.removeAllRanges();
- selection.addRange(range);
-
- marker.remove();
-
- return;
- }
-
- // Fill up the range with a zero length whitespace to give the browser
- // something to strike through. If the range is completely empty, the
- // "strike" is remembered by the browser, but not actually inserted into
- // the DOM, causing the next keystroke to magically insert it.
- helperTextNode = document.createTextNode("\u200B");
- range.insertNode(helperTextNode);
- }
-
- let strikeElements = editorElement.querySelectorAll("strike");
-
- // remove any <strike> element first, all though there shouldn't be any at all
- strikeElements.forEach((el) => DomUtil.unwrapChildNodes(el));
-
- const selectionMarker = getSelectionMarker(editorElement, selection);
-
- document.execCommand(selectionMarker[1]);
- if (selectionMarker[0] !== "strike") {
- strikeElements = editorElement.querySelectorAll(selectionMarker[0]);
- }
-
- // Safari 13 sometimes refuses to execute the `strikeThrough` command.
- if (rangeIsCollapsed && helperTextNode !== null && strikeElements.length === 0) {
- // Executing the command again will toggle off the previous command that had no
- // effect anyway, effectively cancelling out the previous call. Only works if the
- // first call had no effect, otherwise it will enable it.
- document.execCommand(selectionMarker[1]);
-
- const tmp = document.createElement(selectionMarker[0]);
- helperTextNode.parentElement!.insertBefore(tmp, helperTextNode);
- tmp.appendChild(helperTextNode);
- }
-
- strikeElements.forEach((strikeElement: HTMLElement) => {
- const lastMatchingParent = getLastMatchingParent(strikeElement, editorElement, property);
-
- if (lastMatchingParent !== null) {
- handleParentNodes(strikeElement, lastMatchingParent, property);
- }
-
- // remove offending elements from child nodes
- strikeElement.querySelectorAll("span").forEach((span) => {
- if (span.style.getPropertyValue(property)) {
- DomUtil.unwrapChildNodes(span);
- }
- });
-
- // remove strike element itself
- DomUtil.unwrapChildNodes(strikeElement);
- });
-
- // search for tags that are still floating around, but are completely empty
- editorElement.querySelectorAll("span").forEach((element) => {
- if (element.parentNode && !element.textContent!.length && element.style.getPropertyValue(property) !== "") {
- if (element.childElementCount === 1 && element.children[0].nodeName === "MARK") {
- element.parentNode.insertBefore(element.children[0], element);
- }
-
- if (element.childElementCount === 0) {
- element.remove();
- }
- }
- });
-}
+++ /dev/null
-/**
- * Manages html code blocks.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Redactor/Html
- */
-
-import * as Core from "../../Core";
-import * as EventHandler from "../../Event/Handler";
-import * as Language from "../../Language";
-import { RedactorEditor } from "./Editor";
-
-class UiRedactorHtml {
- protected readonly _editor: RedactorEditor;
- protected readonly _elementId: string;
- protected _pre: HTMLElement | null = null;
-
- /**
- * Initializes the source code management.
- */
- constructor(editor: RedactorEditor) {
- this._editor = editor;
- this._elementId = this._editor.$element[0].id;
-
- EventHandler.add("com.woltlab.wcf.redactor2", `bbcode_woltlabHtml_${this._elementId}`, (data) =>
- this._bbcodeCode(data),
- );
- EventHandler.add("com.woltlab.wcf.redactor2", `observe_load_${this._elementId}`, () => this._observeLoad());
-
- // support for active button marking
- this._editor.opts.activeButtonsStates["woltlab-html"] = "woltlabHtml";
-
- // bind listeners on init
- this._observeLoad();
- }
-
- /**
- * Intercepts the insertion of `[woltlabHtml]` tags and uses a native `<pre>` instead.
- */
- protected _bbcodeCode(data: { cancel: boolean }): void {
- data.cancel = true;
-
- let pre = this._editor.selection.block();
- if (pre && pre.nodeName === "PRE" && !pre.classList.contains("woltlabHtml")) {
- return;
- }
-
- this._editor.button.toggle({}, "pre", "func", "block.format");
-
- pre = this._editor.selection.block();
- if (pre && pre.nodeName === "PRE") {
- pre.classList.add("woltlabHtml");
-
- if (pre.childElementCount === 1 && pre.children[0].nodeName === "BR") {
- // drop superfluous linebreak
- pre.removeChild(pre.children[0]);
- }
-
- this._setTitle(pre);
-
- // work-around for Safari
- this._editor.caret.end(pre);
- }
- }
-
- /**
- * Binds event listeners and sets quote title on both editor
- * initialization and when switching back from code view.
- */
- protected _observeLoad(): void {
- this._editor.$editor[0].querySelectorAll("pre.woltlabHtml").forEach((pre: HTMLElement) => {
- this._setTitle(pre);
- });
- }
-
- /**
- * Sets or updates the code's header title.
- */
- protected _setTitle(pre: HTMLElement): void {
- ["title", "description"].forEach((title) => {
- const phrase = Language.get(`wcf.editor.html.${title}`);
-
- if (pre.dataset[title] !== phrase) {
- pre.dataset[title] = phrase;
- }
- });
- }
-}
-
-Core.enableLegacyInheritance(UiRedactorHtml);
-
-export = UiRedactorHtml;
+++ /dev/null
-import DomUtil from "../../Dom/Util";
-import * as Language from "../../Language";
-import UiDialog from "../Dialog";
-import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
-
-type SubmitCallback = () => boolean;
-
-interface LinkOptions {
- insert: boolean;
- submitCallback: SubmitCallback;
-}
-
-class UiRedactorLink implements DialogCallbackObject {
- private boundListener = false;
- private submitCallback: SubmitCallback;
-
- open(options: LinkOptions) {
- UiDialog.open(this);
-
- UiDialog.setTitle(this, Language.get("wcf.editor.link." + (options.insert ? "add" : "edit")));
-
- const submitButton = document.getElementById("redactor-modal-button-action")!;
- submitButton.textContent = Language.get("wcf.global.button." + (options.insert ? "insert" : "save"));
-
- this.submitCallback = options.submitCallback;
-
- // Redactor might modify the button, thus we cannot bind it in the dialog's `onSetup()` callback.
- if (!this.boundListener) {
- this.boundListener = true;
-
- submitButton.addEventListener("click", () => this.submit());
- }
- }
-
- private submit(): void {
- if (this.submitCallback()) {
- UiDialog.close(this);
- } else {
- const url = document.getElementById("redactor-link-url") as HTMLInputElement;
-
- const errorMessage = url.value.trim() === "" ? "wcf.global.form.error.empty" : "wcf.editor.link.error.invalid";
- DomUtil.innerError(url, Language.get(errorMessage));
- }
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "redactorDialogLink",
- options: {
- onClose: () => {
- const url = document.getElementById("redactor-link-url") as HTMLInputElement;
- const small = url.nextElementSibling;
- if (small && small.nodeName === "SMALL") {
- small.remove();
- }
- },
- onSetup: (content) => {
- const submitButton = content.querySelector(".formSubmit > .buttonPrimary") as HTMLButtonElement;
-
- if (submitButton !== null) {
- content.querySelectorAll('input[type="url"], input[type="text"]').forEach((input: HTMLInputElement) => {
- input.addEventListener("keyup", (event) => {
- if (event.key === "Enter") {
- submitButton.click();
- }
- });
- });
- }
- },
- onShow: () => {
- const url = document.getElementById("redactor-link-url") as HTMLInputElement;
- url.focus();
- },
- },
- source: `<dl>
- <dt>
- <label for="redactor-link-url">${Language.get("wcf.editor.link.url")}</label>
- </dt>
- <dd>
- <input type="url" id="redactor-link-url" class="long">
- </dd>
- </dl>
- <dl>
- <dt>
- <label for="redactor-link-url-text">${Language.get("wcf.editor.link.text")}</label>
- </dt>
- <dd>
- <input type="text" id="redactor-link-url-text" class="long">
- </dd>
- </dl>
- <div class="formSubmit">
- <button id="redactor-modal-button-action" class="buttonPrimary"></button>
- </div>`,
- };
- }
-}
-
-let uiRedactorLink: UiRedactorLink;
-
-export function showDialog(options: LinkOptions): void {
- if (!uiRedactorLink) {
- uiRedactorLink = new UiRedactorLink();
- }
-
- uiRedactorLink.open(options);
-}
+++ /dev/null
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
-import * as Core from "../../Core";
-import * as StringUtil from "../../StringUtil";
-import UiCloseOverlay from "../CloseOverlay";
-import { RedactorEditor, WoltLabEventData } from "./Editor";
-
-interface DropDownPosition {
- top: number;
- left: number;
-}
-
-interface Mention {
- range: Range;
- selection: Selection;
-}
-
-interface MentionItem {
- icon: string;
- label: string;
- objectID: number;
-}
-
-interface AjaxResponse extends ResponseData {
- returnValues: MentionItem[];
-}
-
-let _dropdownContainer: HTMLElement | null = null;
-
-const DropDownPixelOffset = 7;
-
-class UiRedactorMention {
- protected _active = false;
- protected _dropdownActive = false;
- protected _dropdownMenu: HTMLOListElement | null = null;
- protected _itemIndex = 0;
- protected _lineHeight: number | null = null;
- protected _mentionStart = "";
- protected _redactor: RedactorEditor;
- protected _timer: number | null = null;
-
- constructor(redactor: RedactorEditor) {
- this._redactor = redactor;
-
- redactor.WoltLabEvent.register("keydown", (data) => this._keyDown(data));
- redactor.WoltLabEvent.register("keyup", (data) => this._keyUp(data));
-
- UiCloseOverlay.add(`UiRedactorMention-${redactor.core.element()[0].id}`, () => this._hideDropdown());
- }
-
- protected _keyDown(data: WoltLabEventData): void {
- if (!this._dropdownActive) {
- return;
- }
-
- const event = data.event as KeyboardEvent;
-
- switch (event.key) {
- case "Enter":
- this._setUsername(null, this._dropdownMenu!.children[this._itemIndex].children[0] as HTMLElement);
- break;
-
- case "ArrowUp":
- this._selectItem(-1);
- break;
-
- case "ArrowDown":
- this._selectItem(1);
- break;
-
- default:
- this._hideDropdown();
- return;
- }
-
- event.preventDefault();
- data.cancel = true;
- }
-
- protected _keyUp(data: WoltLabEventData): void {
- const event = data.event as KeyboardEvent;
-
- // ignore return key
- if (event.key === "Enter") {
- this._active = false;
-
- return;
- }
-
- if (this._dropdownActive) {
- data.cancel = true;
-
- // ignore arrow up/down
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
- return;
- }
- }
-
- const text = this._getTextLineInFrontOfCaret();
- if (text.length > 0 && text.length < 25) {
- const match = /@([^,]{3,})$/.exec(text);
- if (match) {
- // if mentioning is at text begin or there's a whitespace character
- // before the '@', everything is fine
- if (!match.index || /\s/.test(text[match.index - 1])) {
- this._mentionStart = match[1];
-
- if (this._timer !== null) {
- window.clearTimeout(this._timer);
- this._timer = null;
- }
-
- this._timer = window.setTimeout(() => {
- Ajax.api(this, {
- parameters: {
- data: {
- searchString: this._mentionStart,
- },
- },
- });
-
- this._timer = null;
- }, 500);
- }
- } else {
- this._hideDropdown();
- }
- } else {
- this._hideDropdown();
- }
- }
-
- protected _getTextLineInFrontOfCaret(): string {
- const data = this._selectMention(false);
- if (data !== null) {
- return data.range
- .cloneContents()
- .textContent!.replace(/\u200B/g, "")
- .replace(/\u00A0/g, " ")
- .trim();
- }
-
- return "";
- }
-
- protected _getDropdownMenuPosition(): DropDownPosition | null {
- const data = this._selectMention();
- if (data === null) {
- return null;
- }
-
- this._redactor.selection.save();
-
- data.selection.removeAllRanges();
- data.selection.addRange(data.range);
-
- // get the offsets of the bounding box of current text selection
- const rect = data.selection.getRangeAt(0).getBoundingClientRect();
- const offsets: DropDownPosition = {
- top: Math.round(rect.bottom) + (window.scrollY || window.pageYOffset),
- left: Math.round(rect.left) + document.body.scrollLeft,
- };
-
- if (this._lineHeight === null) {
- this._lineHeight = Math.round(rect.bottom - rect.top);
- }
-
- // restore caret position
- this._redactor.selection.restore();
-
- return offsets;
- }
-
- protected _setUsername(event: MouseEvent | null, item?: HTMLElement): void {
- if (event) {
- event.preventDefault();
- item = event.currentTarget as HTMLElement;
- }
-
- const data = this._selectMention();
- if (data === null) {
- this._hideDropdown();
-
- return;
- }
-
- // allow redactor to undo this
- this._redactor.buffer.set();
-
- data.selection.removeAllRanges();
- data.selection.addRange(data.range);
-
- let range = window.getSelection()!.getRangeAt(0);
- range.deleteContents();
- range.collapse(true);
-
- // Mentions only allow for one whitespace per match, putting the username in apostrophes
- // will allow an arbitrary number of spaces.
- let username = item!.dataset.username!.trim();
- if (username.split(/\s/g).length > 2) {
- username = "'" + username.replace(/'/g, "''") + "'";
- }
-
- const text = document.createTextNode("@" + username + "\u00A0");
- range.insertNode(text);
-
- range = document.createRange();
- range.selectNode(text);
- range.collapse(false);
-
- data.selection.removeAllRanges();
- data.selection.addRange(range);
-
- this._hideDropdown();
- }
-
- protected _selectMention(skipCheck?: boolean): Mention | null {
- const selection = window.getSelection()!;
- if (!selection.rangeCount || !selection.isCollapsed) {
- return null;
- }
-
- let container = selection.anchorNode as HTMLElement;
- if (container.nodeType === Node.TEXT_NODE) {
- // work-around for Firefox after suggestions have been presented
- container = container.parentElement!;
- }
-
- // check if there is an '@' within the current range
- if (container.textContent!.indexOf("@") === -1) {
- return null;
- }
-
- // check if we're inside code or quote blocks
- const editor = this._redactor.core.editor()[0];
- while (container && container !== editor) {
- if (["PRE", "WOLTLAB-QUOTE"].indexOf(container.nodeName) !== -1) {
- return null;
- }
-
- container = container.parentElement!;
- }
-
- let range = selection.getRangeAt(0);
- let endContainer = range.startContainer;
- let endOffset = range.startOffset;
-
- // find the appropriate end location
- while (endContainer.nodeType === Node.ELEMENT_NODE) {
- if (endOffset === 0 && endContainer.childNodes.length === 0) {
- // invalid start location
- return null;
- }
-
- // startOffset for elements will always be after a node index
- // or at the very start, which means if there is only text node
- // and the caret is after it, startOffset will equal `1`
- endContainer = endContainer.childNodes[endOffset ? endOffset - 1 : 0];
- if (endOffset > 0) {
- if (endContainer.nodeType === Node.TEXT_NODE) {
- endOffset = endContainer.textContent!.length;
- } else {
- endOffset = endContainer.childNodes.length;
- }
- }
- }
-
- let startContainer = endContainer;
- let startOffset = -1;
- while (startContainer !== null) {
- if (startContainer.nodeType !== Node.TEXT_NODE) {
- return null;
- }
-
- if (startContainer.textContent!.indexOf("@") !== -1) {
- startOffset = startContainer.textContent!.lastIndexOf("@");
-
- break;
- }
-
- startContainer = startContainer.previousSibling!;
- }
-
- if (startOffset === -1) {
- // there was a non-text node that was in our way
- return null;
- }
-
- try {
- // mark the entire text, starting from the '@' to the current cursor position
- range = document.createRange();
- range.setStart(startContainer, startOffset);
- range.setEnd(endContainer, endOffset);
- } catch (e) {
- window.console.debug(e);
- return null;
- }
-
- if (skipCheck === false) {
- // check if the `@` occurs at the very start of the container
- // or at least has a whitespace in front of it
- let text = "";
- if (startOffset) {
- text = startContainer.textContent!.substr(0, startOffset);
- }
-
- while ((startContainer = startContainer.previousSibling!)) {
- if (startContainer.nodeType === Node.TEXT_NODE) {
- text = startContainer.textContent! + text;
- } else {
- break;
- }
- }
-
- if (/\S$/.test(text.replace(/\u200B/g, ""))) {
- return null;
- }
- } else {
- // check if new range includes the mention text
- if (
- range
- .cloneContents()
- .textContent!.replace(/\u200B/g, "")
- .replace(/\u00A0/g, "")
- .trim()
- .replace(/^@/, "") !== this._mentionStart
- ) {
- // string mismatch
- return null;
- }
- }
-
- return {
- range: range,
- selection: selection,
- };
- }
-
- protected _updateDropdownPosition(): void {
- const offset = this._getDropdownMenuPosition();
- if (offset === null) {
- this._hideDropdown();
-
- return;
- }
- offset.top += DropDownPixelOffset;
-
- const dropdownMenu = this._dropdownMenu!;
- dropdownMenu.style.setProperty("left", `${offset.left}px`, "");
- dropdownMenu.style.setProperty("top", `${offset.top}px`, "");
-
- this._selectItem(0);
-
- if (offset.top + dropdownMenu.offsetHeight + 10 > window.innerHeight + (window.scrollY || window.pageYOffset)) {
- const top = offset.top - dropdownMenu.offsetHeight - 2 * this._lineHeight! + DropDownPixelOffset;
- dropdownMenu.style.setProperty("top", `${top}px`, "");
- }
- }
-
- protected _selectItem(step: number): void {
- const dropdownMenu = this._dropdownMenu!;
-
- // find currently active item
- const item = dropdownMenu.querySelector(".active");
- if (item !== null) {
- item.classList.remove("active");
- }
-
- this._itemIndex += step;
- if (this._itemIndex < 0) {
- this._itemIndex = dropdownMenu.childElementCount - 1;
- } else if (this._itemIndex >= dropdownMenu.childElementCount) {
- this._itemIndex = 0;
- }
-
- dropdownMenu.children[this._itemIndex].classList.add("active");
- }
-
- protected _hideDropdown(): void {
- if (this._dropdownMenu !== null) {
- this._dropdownMenu.classList.remove("dropdownOpen");
- }
- this._dropdownActive = false;
- this._itemIndex = 0;
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "getSearchResultList",
- className: "wcf\\data\\user\\UserAction",
- interfaceName: "wcf\\data\\ISearchAction",
- parameters: {
- data: {
- includeUserGroups: true,
- scope: "mention",
- },
- },
- },
- silent: true,
- };
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- if (!Array.isArray(data.returnValues) || !data.returnValues.length) {
- this._hideDropdown();
-
- return;
- }
-
- if (this._dropdownMenu === null) {
- this._dropdownMenu = document.createElement("ol");
- this._dropdownMenu.className = "dropdownMenu";
-
- if (_dropdownContainer === null) {
- _dropdownContainer = document.createElement("div");
- _dropdownContainer.className = "dropdownMenuContainer";
- document.body.appendChild(_dropdownContainer);
- }
-
- _dropdownContainer.appendChild(this._dropdownMenu);
- }
-
- this._dropdownMenu.innerHTML = "";
-
- data.returnValues.forEach((item) => {
- const listItem = document.createElement("li");
- const link = document.createElement("a");
- link.addEventListener("mousedown", (ev) => this._setUsername(ev));
- link.className = "box16";
- link.innerHTML = `<span>${item.icon}</span> <span>${StringUtil.escapeHTML(item.label)}</span>`;
- link.dataset.userId = item.objectID.toString();
- link.dataset.username = item.label;
-
- listItem.appendChild(link);
- this._dropdownMenu!.appendChild(listItem);
- });
-
- this._dropdownMenu.classList.add("dropdownOpen");
- this._dropdownActive = true;
-
- this._updateDropdownPosition();
- }
-}
-
-Core.enableLegacyInheritance(UiRedactorMention);
-
-export = UiRedactorMention;
+++ /dev/null
-/**
- * Converts `<woltlab-metacode>` into the bbcode representation.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Redactor/Metacode
- */
-
-import * as EventHandler from "../../Event/Handler";
-import DomUtil from "../../Dom/Util";
-
-type Attributes = string[];
-
-/**
- * Returns a text node representing the opening bbcode tag.
- */
-function getOpeningTag(name: string, attributes: Attributes): Text {
- let buffer = "[" + name;
- if (attributes.length) {
- buffer += "=";
- buffer += attributes.map((attribute) => `'${attribute}'`).join(",");
- }
-
- return document.createTextNode(buffer + "]");
-}
-
-/**
- * Returns a text node representing the closing bbcode tag.
- */
-function getClosingTag(name: string): Text {
- return document.createTextNode(`[/${name}]`);
-}
-
-/**
- * Returns the first paragraph of provided element. If there are no children or
- * the first child is not a paragraph, a new paragraph is created and inserted
- * as first child.
- */
-function getFirstParagraph(element: HTMLElement): HTMLElement {
- let paragraph: HTMLElement;
- if (element.childElementCount === 0) {
- paragraph = document.createElement("p");
- element.appendChild(paragraph);
- } else {
- const firstChild = element.children[0] as HTMLElement;
-
- if (firstChild.nodeName === "P") {
- paragraph = firstChild;
- } else {
- paragraph = document.createElement("p");
- element.insertBefore(paragraph, firstChild);
- }
- }
-
- return paragraph;
-}
-
-/**
- * Returns the last paragraph of provided element. If there are no children or
- * the last child is not a paragraph, a new paragraph is created and inserted
- * as last child.
- */
-function getLastParagraph(element: HTMLElement): HTMLElement {
- const count = element.childElementCount;
-
- let paragraph: HTMLElement;
- if (count === 0) {
- paragraph = document.createElement("p");
- element.appendChild(paragraph);
- } else {
- const lastChild = element.children[count - 1] as HTMLElement;
-
- if (lastChild.nodeName === "P") {
- paragraph = lastChild;
- } else {
- paragraph = document.createElement("p");
- element.appendChild(paragraph);
- }
- }
-
- return paragraph;
-}
-
-/**
- * Parses the attributes string.
- */
-function parseAttributes(attributes: string): Attributes {
- try {
- attributes = JSON.parse(atob(attributes));
- } catch (e) {
- /* invalid base64 data or invalid json */
- }
-
- if (!Array.isArray(attributes)) {
- return [];
- }
-
- return attributes.map((attribute: string | number) => {
- return attribute.toString().replace(/^'(.*)'$/, "$1");
- });
-}
-
-export function convertFromHtml(editorId: string, html: string): string {
- const div = document.createElement("div");
- div.innerHTML = html;
-
- div.querySelectorAll("woltlab-metacode").forEach((metacode: HTMLElement) => {
- const name = metacode.dataset.name!;
- const attributes = parseAttributes(metacode.dataset.attributes || "");
-
- const data = {
- attributes: attributes,
- cancel: false,
- metacode: metacode,
- };
-
- EventHandler.fire("com.woltlab.wcf.redactor2", `metacode_${name}_${editorId}`, data);
- if (data.cancel) {
- return;
- }
-
- const tagOpen = getOpeningTag(name, attributes);
- const tagClose = getClosingTag(name);
-
- if (metacode.parentElement === div) {
- const paragraph = getFirstParagraph(metacode);
- paragraph.insertBefore(tagOpen, paragraph.firstChild);
- getLastParagraph(metacode).appendChild(tagClose);
- } else {
- metacode.insertBefore(tagOpen, metacode.firstChild);
- metacode.appendChild(tagClose);
- }
-
- DomUtil.unwrapChildNodes(metacode);
- });
-
- // convert `<kbd>…</kbd>` to `[tt]…[/tt]`
- div.querySelectorAll("kbd").forEach((inlineCode) => {
- inlineCode.insertBefore(document.createTextNode("[tt]"), inlineCode.firstChild);
- inlineCode.appendChild(document.createTextNode("[/tt]"));
-
- DomUtil.unwrapChildNodes(inlineCode);
- });
-
- return div.innerHTML;
-}
+++ /dev/null
-/**
- * Converts `<woltlab-metacode>` into the bbcode representation.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Redactor/Page
- */
-
-import * as Core from "../../Core";
-import * as UiPageSearch from "../Page/Search";
-import { RedactorEditor } from "./Editor";
-
-class UiRedactorPage {
- protected _editor: RedactorEditor;
-
- constructor(editor: RedactorEditor, button: HTMLAnchorElement) {
- this._editor = editor;
-
- button.addEventListener("click", (ev) => this._click(ev));
- }
-
- protected _click(event: MouseEvent): void {
- event.preventDefault();
-
- UiPageSearch.open((pageId) => this._insert(pageId));
- }
-
- protected _insert(pageId: string): void {
- this._editor.buffer.set();
-
- this._editor.insert.text(`[wsp='${pageId}'][/wsp]`);
- }
-}
-
-Core.enableLegacyInheritance(UiRedactorPage);
-
-export = UiRedactorPage;
+++ /dev/null
-/**
- * Helper class to deal with clickable block headers using the pseudo
- * `::before` element.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Redactor/PseudoHeader
- */
-
-/**
- * Returns the height within a click should be treated as a click
- * within the block element's title. This method expects that the
- * `::before` element is used and that removing the attribute
- * `data-title` does cause the title to collapse.
- */
-export function getHeight(element: HTMLElement): number {
- let height = ~~window.getComputedStyle(element).paddingTop.replace(/px$/, "");
-
- const styles = window.getComputedStyle(element, "::before");
- height += ~~styles.paddingTop.replace(/px$/, "");
- height += ~~styles.paddingBottom.replace(/px$/, "");
-
- let titleHeight = ~~styles.height.replace(/px$/, "");
- if (titleHeight === 0) {
- // firefox returns garbage for pseudo element height
- // https://bugzilla.mozilla.org/show_bug.cgi?id=925694
-
- titleHeight = element.scrollHeight;
- element.classList.add("redactorCalcHeight");
- titleHeight -= element.scrollHeight;
- element.classList.remove("redactorCalcHeight");
- }
-
- height += titleHeight;
-
- return height;
-}
+++ /dev/null
-/**
- * Manages quotes.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Redactor/Quote
- */
-
-import * as Core from "../../Core";
-import DomUtil from "../../Dom/Util";
-import * as EventHandler from "../../Event/Handler";
-import * as Language from "../../Language";
-import * as StringUtil from "../../StringUtil";
-import UiDialog from "../Dialog";
-import { DialogCallbackSetup } from "../Dialog/Data";
-import { RedactorEditor } from "./Editor";
-import * as UiRedactorMetacode from "./Metacode";
-import * as UiRedactorPseudoHeader from "./PseudoHeader";
-
-interface QuoteData {
- author: string;
- content: string;
- isText: boolean;
- link: string;
-}
-
-let _headerHeight = 0;
-
-class UiRedactorQuote {
- protected readonly _editor: RedactorEditor;
- protected readonly _elementId: string;
- protected _quote: HTMLElement | null = null;
-
- /**
- * Initializes the quote management.
- */
- constructor(editor: RedactorEditor, button: JQuery) {
- this._editor = editor;
- this._elementId = this._editor.$element[0].id;
-
- EventHandler.add("com.woltlab.wcf.redactor2", `observe_load_${this._elementId}`, () => this._observeLoad());
-
- this._editor.button.addCallback(button, this._click.bind(this));
-
- // bind listeners on init
- this._observeLoad();
-
- // quote manager
- EventHandler.add("com.woltlab.wcf.redactor2", `insertQuote_${this._elementId}`, (data) => this._insertQuote(data));
- }
-
- /**
- * Inserts a quote.
- */
- protected _insertQuote(data: QuoteData): void {
- if (this._editor.WoltLabSource.isActive()) {
- return;
- }
-
- EventHandler.fire("com.woltlab.wcf.redactor2", "showEditor");
-
- const editor = this._editor.core.editor()[0];
- this._editor.selection.restore();
-
- this._editor.buffer.set();
-
- // caret must be within a `<p>`, if it is not: move it
- let block = this._editor.selection.block();
- if (block === false) {
- this._editor.focus.end();
- block = this._editor.selection.block() as HTMLElement;
- }
-
- while (block && block.parentElement !== editor) {
- block = block.parentElement!;
- }
-
- const quote = document.createElement("woltlab-quote");
- quote.dataset.author = data.author;
- quote.dataset.link = data.link;
-
- let content = data.content;
- if (data.isText) {
- content = StringUtil.escapeHTML(content);
- content = `<p>${content}</p>`;
- content = content.replace(/\n\n/g, "</p><p>");
- content = content.replace(/\n/g, "<br>");
- } else {
- content = UiRedactorMetacode.convertFromHtml(this._editor.$element[0].id, content);
- }
-
- // bypass the editor as `insert.html()` doesn't like us
- quote.innerHTML = content;
-
- const blockParent = block.parentElement!;
- blockParent.insertBefore(quote, block.nextSibling);
-
- if (block.nodeName === "P" && (block.innerHTML === "<br>" || block.innerHTML.replace(/\u200B/g, "") === "")) {
- blockParent.removeChild(block);
- }
-
- // avoid adjacent blocks that are not paragraphs
- let sibling = quote.previousElementSibling;
- if (sibling && sibling.nodeName !== "P") {
- sibling = document.createElement("p");
- sibling.textContent = "\u200B";
- quote.insertAdjacentElement("beforebegin", sibling);
- }
-
- this._editor.WoltLabCaret.paragraphAfterBlock(quote);
-
- this._editor.buffer.set();
- }
-
- /**
- * Toggles the quote block on button click.
- */
- protected _click(): void {
- this._editor.button.toggle({}, "woltlab-quote", "func", "block.format");
-
- const quote = this._editor.selection.block();
- if (quote && quote.nodeName === "WOLTLAB-QUOTE") {
- this._setTitle(quote);
-
- quote.addEventListener("click", (ev) => this._edit(ev));
-
- // work-around for Safari
- this._editor.caret.end(quote);
- }
- }
-
- /**
- * Binds event listeners and sets quote title on both editor
- * initialization and when switching back from code view.
- */
- protected _observeLoad(): void {
- document.querySelectorAll("woltlab-quote").forEach((quote: HTMLElement) => {
- quote.addEventListener("mousedown", (ev) => this._edit(ev));
- this._setTitle(quote);
- });
- }
-
- /**
- * Opens the dialog overlay to edit the quote's properties.
- */
- protected _edit(event: MouseEvent): void {
- const quote = event.currentTarget as HTMLElement;
-
- if (_headerHeight === 0) {
- _headerHeight = UiRedactorPseudoHeader.getHeight(quote);
- }
-
- // check if the click hit the header
- const offset = DomUtil.offset(quote);
- if (event.pageY > offset.top && event.pageY < offset.top + _headerHeight) {
- event.preventDefault();
-
- this._editor.selection.save();
- this._quote = quote;
-
- UiDialog.open(this);
- }
- }
-
- /**
- * Saves the changes to the quote's properties.
- *
- * @protected
- */
- _dialogSubmit(): void {
- const id = `redactor-quote-${this._elementId}`;
- const urlInput = document.getElementById(`${id}-url`) as HTMLInputElement;
-
- const url = urlInput.value.replace(/\u200B/g, "").trim();
- // simple test to check if it at least looks like it could be a valid url
- if (url.length && !/^https?:\/\/[^/]+/.test(url)) {
- DomUtil.innerError(urlInput, Language.get("wcf.editor.quote.url.error.invalid"));
-
- return;
- } else {
- DomUtil.innerError(urlInput, false);
- }
-
- const quote = this._quote!;
-
- // set author
- const author = document.getElementById(id + "-author") as HTMLInputElement;
- quote.dataset.author = author.value;
-
- // set url
- quote.dataset.link = url;
-
- this._setTitle(quote);
- this._editor.caret.after(quote);
-
- UiDialog.close(this);
- }
-
- /**
- * Sets or updates the quote's header title.
- */
- protected _setTitle(quote: HTMLElement): void {
- const title = Language.get("wcf.editor.quote.title", {
- author: quote.dataset.author!,
- url: quote.dataset.url!,
- });
-
- if (quote.dataset.title !== title) {
- quote.dataset.title = title;
- }
- }
-
- protected _delete(event: MouseEvent): void {
- event.preventDefault();
-
- const quote = this._quote!;
-
- let caretEnd = quote.nextElementSibling || quote.previousElementSibling;
- if (caretEnd === null && quote.parentElement !== this._editor.core.editor()[0]) {
- caretEnd = quote.parentElement;
- }
-
- if (caretEnd === null) {
- this._editor.code.set("");
- this._editor.focus.end();
- } else {
- quote.remove();
- this._editor.caret.end(caretEnd);
- }
-
- UiDialog.close(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- const id = `redactor-quote-${this._elementId}`;
- const idAuthor = `${id}-author`;
- const idButtonDelete = `${id}-button-delete`;
- const idButtonSave = `${id}-button-save`;
- const idUrl = `${id}-url`;
-
- return {
- id: id,
- options: {
- onClose: () => {
- this._editor.selection.restore();
-
- UiDialog.destroy(this);
- },
-
- onSetup: () => {
- const button = document.getElementById(idButtonDelete) as HTMLButtonElement;
- button.addEventListener("click", (ev) => this._delete(ev));
- },
-
- onShow: () => {
- const author = document.getElementById(idAuthor) as HTMLInputElement;
- author.value = this._quote!.dataset.author || "";
-
- const url = document.getElementById(idUrl) as HTMLInputElement;
- url.value = this._quote!.dataset.link || "";
- },
-
- title: Language.get("wcf.editor.quote.edit"),
- },
- source: `<div class="section">
- <dl>
- <dt>
- <label for="${idAuthor}">${Language.get("wcf.editor.quote.author")}</label>
- </dt>
- <dd>
- <input type="text" id="${idAuthor}" class="long" data-dialog-submit-on-enter="true">
- </dd>
- </dl>
- <dl>
- <dt>
- <label for="${idUrl}">${Language.get("wcf.editor.quote.url")}</label>
- </dt>
- <dd>
- <input type="text" id="${idUrl}" class="long" data-dialog-submit-on-enter="true">
- <small>${Language.get("wcf.editor.quote.url.description")}</small>
- </dd>
- </dl>
- </div>
- <div class="formSubmit">
- <button id="${idButtonSave}" class="buttonPrimary" data-type="submit">${Language.get(
- "wcf.global.button.save",
- )}</button>
- <button id="${idButtonDelete}">${Language.get("wcf.global.button.delete")}</button>
- </div>`,
- };
- }
-}
-
-Core.enableLegacyInheritance(UiRedactorQuote);
-
-export = UiRedactorQuote;
+++ /dev/null
-/**
- * Manages spoilers.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Redactor/Spoiler
- */
-
-import * as Core from "../../Core";
-import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
-import DomUtil from "../../Dom/Util";
-import * as EventHandler from "../../Event/Handler";
-import * as Language from "../../Language";
-import UiDialog from "../Dialog";
-import { RedactorEditor, WoltLabEventData } from "./Editor";
-import * as UiRedactorPseudoHeader from "./PseudoHeader";
-
-let _headerHeight = 0;
-
-class UiRedactorSpoiler implements DialogCallbackObject {
- protected readonly _editor: RedactorEditor;
- protected readonly _elementId: string;
- protected _spoiler: HTMLElement | null = null;
-
- /**
- * Initializes the spoiler management.
- */
- constructor(editor: RedactorEditor) {
- this._editor = editor;
- this._elementId = this._editor.$element[0].id;
-
- EventHandler.add("com.woltlab.wcf.redactor2", `bbcode_spoiler_${this._elementId}`, (data) =>
- this._bbcodeSpoiler(data),
- );
- EventHandler.add("com.woltlab.wcf.redactor2", `observe_load_${this._elementId}`, () => this._observeLoad());
-
- // bind listeners on init
- this._observeLoad();
- }
-
- /**
- * Intercepts the insertion of `[spoiler]` tags and uses
- * the custom `<woltlab-spoiler>` element instead.
- */
- protected _bbcodeSpoiler(data: WoltLabEventData): void {
- data.cancel = true;
-
- this._editor.button.toggle({}, "woltlab-spoiler", "func", "block.format");
-
- let spoiler = this._editor.selection.block();
- if (spoiler) {
- // iOS Safari might set the caret inside the spoiler.
- if (spoiler.nodeName === "P") {
- spoiler = spoiler.parentElement!;
- }
-
- if (spoiler.nodeName === "WOLTLAB-SPOILER") {
- this._setTitle(spoiler);
-
- spoiler.addEventListener("click", (ev) => this._edit(ev));
-
- // work-around for Safari
- this._editor.caret.end(spoiler);
- }
- }
- }
-
- /**
- * Binds event listeners and sets quote title on both editor
- * initialization and when switching back from code view.
- */
- protected _observeLoad(): void {
- this._editor.$editor[0].querySelectorAll("woltlab-spoiler").forEach((spoiler: HTMLElement) => {
- spoiler.addEventListener("mousedown", (ev) => this._edit(ev));
- this._setTitle(spoiler);
- });
- }
-
- /**
- * Opens the dialog overlay to edit the spoiler's properties.
- */
- protected _edit(event: MouseEvent): void {
- const spoiler = event.currentTarget as HTMLElement;
-
- if (_headerHeight === 0) {
- _headerHeight = UiRedactorPseudoHeader.getHeight(spoiler);
- }
-
- // check if the click hit the header
- const offset = DomUtil.offset(spoiler);
- if (event.pageY > offset.top && event.pageY < offset.top + _headerHeight) {
- event.preventDefault();
-
- this._editor.selection.save();
- this._spoiler = spoiler;
-
- UiDialog.open(this);
- }
- }
-
- /**
- * Saves the changes to the spoiler's properties.
- *
- * @protected
- */
- _dialogSubmit(): void {
- const spoiler = this._spoiler!;
-
- const label = document.getElementById("redactor-spoiler-" + this._elementId + "-label") as HTMLInputElement;
- spoiler.dataset.label = label.value;
-
- this._setTitle(spoiler);
- this._editor.caret.after(spoiler);
-
- UiDialog.close(this);
- }
-
- /**
- * Sets or updates the spoiler's header title.
- */
- protected _setTitle(spoiler: HTMLElement): void {
- const title = Language.get("wcf.editor.spoiler.title", { label: spoiler.dataset.label || "" });
-
- if (spoiler.dataset.title !== title) {
- spoiler.dataset.title = title;
- }
- }
-
- protected _delete(event: MouseEvent): void {
- event.preventDefault();
-
- const spoiler = this._spoiler!;
-
- let caretEnd = spoiler.nextElementSibling || spoiler.previousElementSibling;
- if (caretEnd === null && spoiler.parentElement !== this._editor.core.editor()[0]) {
- caretEnd = spoiler.parentElement;
- }
-
- if (caretEnd === null) {
- this._editor.code.set("");
- this._editor.focus.end();
- } else {
- spoiler.remove();
- this._editor.caret.end(caretEnd);
- }
-
- UiDialog.close(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- const id = `redactor-spoiler-${this._elementId}`;
- const idButtonDelete = `${id}-button-delete`;
- const idButtonSave = `${id}-button-save`;
- const idLabel = `${id}-label`;
-
- return {
- id: id,
- options: {
- onClose: () => {
- this._editor.selection.restore();
-
- UiDialog.destroy(this);
- },
-
- onSetup: () => {
- const button = document.getElementById(idButtonDelete) as HTMLButtonElement;
- button.addEventListener("click", (ev) => this._delete(ev));
- },
-
- onShow: () => {
- const label = document.getElementById(idLabel) as HTMLInputElement;
- label.value = this._spoiler!.dataset.label || "";
- },
-
- title: Language.get("wcf.editor.spoiler.edit"),
- },
- source: `<div class="section">
- <dl>
- <dt>
- <label for="${idLabel}">${Language.get("wcf.editor.spoiler.label")}</label>
- </dt>
- <dd>
- <input type="text" id="${idLabel}" class="long" data-dialog-submit-on-enter="true">
- <small>${Language.get("wcf.editor.spoiler.label.description")}</small>
- </dd>
- </dl>
- </div>
- <div class="formSubmit">
- <button id="${idButtonSave}" class="buttonPrimary" data-type="submit">${Language.get(
- "wcf.global.button.save",
- )}</button>
- <button id="${idButtonDelete}">${Language.get("wcf.global.button.delete")}</button>
- </div>`,
- };
- }
-}
-
-Core.enableLegacyInheritance(UiRedactorSpoiler);
-
-export = UiRedactorSpoiler;
+++ /dev/null
-import * as Language from "../../Language";
-import UiDialog from "../Dialog";
-import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
-
-type CallbackSubmit = () => void;
-
-interface TableOptions {
- submitCallback: CallbackSubmit;
-}
-
-class UiRedactorTable implements DialogCallbackObject {
- protected callbackSubmit: CallbackSubmit;
-
- open(options: TableOptions): void {
- UiDialog.open(this);
-
- this.callbackSubmit = options.submitCallback;
- }
-
- _dialogSubmit(): void {
- // check if rows and cols are within the boundaries
- let isValid = true;
- ["rows", "cols"].forEach((type) => {
- const input = document.getElementById("redactor-table-" + type) as HTMLInputElement;
- if (+input.value < 1 || +input.value > 100) {
- isValid = false;
- }
- });
-
- if (!isValid) {
- return;
- }
-
- this.callbackSubmit();
-
- UiDialog.close(this);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "redactorDialogTable",
- options: {
- onShow: () => {
- const rows = document.getElementById("redactor-table-rows") as HTMLInputElement;
- rows.value = "2";
-
- const cols = document.getElementById("redactor-table-cols") as HTMLInputElement;
- cols.value = "3";
- },
-
- title: Language.get("wcf.editor.table.insertTable"),
- },
- source: `<dl>
- <dt>
- <label for="redactor-table-rows">${Language.get("wcf.editor.table.rows")}</label>
- </dt>
- <dd>
- <input type="number" id="redactor-table-rows" class="small" min="1" max="100" value="2" data-dialog-submit-on-enter="true">
- </dd>
- </dl>
- <dl>
- <dt>
- <label for="redactor-table-cols">${Language.get("wcf.editor.table.cols")}</label>
- </dt>
- <dd>
- <input type="number" id="redactor-table-cols" class="small" min="1" max="100" value="3" data-dialog-submit-on-enter="true">
- </dd>
- </dl>
- <div class="formSubmit">
- <button id="redactor-modal-button-action" class="buttonPrimary" data-type="submit">${Language.get(
- "wcf.global.button.insert",
- )}</button>
- </div>`,
- };
- }
-}
-
-let uiRedactorTable: UiRedactorTable;
-
-export function showDialog(options: TableOptions): void {
- if (!uiRedactorTable) {
- uiRedactorTable = new UiRedactorTable();
- }
-
- uiRedactorTable.open(options);
-}
+++ /dev/null
-/**
- * Provides consistent support for media queries and body scrolling.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Ui/Screen (alias)
- * @module WoltLabSuite/Core/Ui/Screen
- */
-
-import * as Core from "../Core";
-import * as Environment from "../Environment";
-
-const _mql = new Map<string, MediaQueryData>();
-
-let _scrollDisableCounter = 0;
-let _scrollOffsetFrom: "body" | "documentElement";
-let _scrollTop = 0;
-let _pageOverlayCounter = 0;
-
-const _mqMap = new Map<string, string>(
- Object.entries({
- "screen-xs": "(max-width: 544px)" /* smartphone */,
- "screen-sm": "(min-width: 545px) and (max-width: 768px)" /* tablet (portrait) */,
- "screen-sm-down": "(max-width: 768px)" /* smartphone + tablet (portrait) */,
- "screen-sm-up": "(min-width: 545px)" /* tablet (portrait) + tablet (landscape) + desktop */,
- "screen-sm-md": "(min-width: 545px) and (max-width: 1024px)" /* tablet (portrait) + tablet (landscape) */,
- "screen-md": "(min-width: 769px) and (max-width: 1024px)" /* tablet (landscape) */,
- "screen-md-down": "(max-width: 1024px)" /* smartphone + tablet (portrait) + tablet (landscape) */,
- "screen-md-up": "(min-width: 769px)" /* tablet (landscape) + desktop */,
- "screen-lg": "(min-width: 1025px)" /* desktop */,
- "screen-lg-only": "(min-width: 1025px) and (max-width: 1280px)",
- "screen-lg-down": "(max-width: 1280px)",
- "screen-xl": "(min-width: 1281px)",
- }),
-);
-
-// Microsoft Edge rewrites the media queries to whatever it
-// pleases, causing the input and output query to mismatch
-const _mqMapEdge = new Map<string, string>();
-
-/**
- * Registers event listeners for media query match/unmatch.
- *
- * The `callbacks` object may contain the following keys:
- * - `match`, triggered when media query matches
- * - `unmatch`, triggered when media query no longer matches
- * - `setup`, invoked when media query first matches
- *
- * Returns a UUID that is used to internal identify the callbacks, can be used
- * to remove binding by calling the `remove` method.
- */
-export function on(query: string, callbacks: Partial<Callbacks>): string {
- const uuid = Core.getUuid(),
- queryObject = _getQueryObject(query);
-
- if (typeof callbacks.match === "function") {
- queryObject.callbacksMatch.set(uuid, callbacks.match);
- }
-
- if (typeof callbacks.unmatch === "function") {
- queryObject.callbacksUnmatch.set(uuid, callbacks.unmatch);
- }
-
- if (typeof callbacks.setup === "function") {
- if (queryObject.mql.matches) {
- callbacks.setup();
- } else {
- queryObject.callbacksSetup.set(uuid, callbacks.setup);
- }
- }
-
- return uuid;
-}
-
-/**
- * Removes all listeners identified by their common UUID.
- */
-export function remove(query: string, uuid: string): void {
- const queryObject = _getQueryObject(query);
-
- queryObject.callbacksMatch.delete(uuid);
- queryObject.callbacksUnmatch.delete(uuid);
- queryObject.callbacksSetup.delete(uuid);
-}
-
-/**
- * Returns a boolean value if a media query expression currently matches.
- */
-export function is(query: string): boolean {
- return _getQueryObject(query).mql.matches;
-}
-
-/**
- * Disables scrolling of body element.
- */
-export function scrollDisable(): void {
- if (_scrollDisableCounter === 0) {
- _scrollTop = document.body.scrollTop;
- _scrollOffsetFrom = "body";
- if (!_scrollTop) {
- _scrollTop = document.documentElement.scrollTop;
- _scrollOffsetFrom = "documentElement";
- }
-
- const pageContainer = document.getElementById("pageContainer")!;
-
- // setting translateY causes Mobile Safari to snap
- if (Environment.platform() === "ios") {
- pageContainer.style.setProperty("position", "relative", "");
- pageContainer.style.setProperty("top", `-${_scrollTop}px`, "");
- } else {
- pageContainer.style.setProperty("margin-top", `-${_scrollTop}px`, "");
- }
-
- document.documentElement.classList.add("disableScrolling");
- }
-
- _scrollDisableCounter++;
-}
-
-/**
- * Re-enables scrolling of body element.
- */
-export function scrollEnable(): void {
- if (_scrollDisableCounter) {
- _scrollDisableCounter--;
-
- if (_scrollDisableCounter === 0) {
- document.documentElement.classList.remove("disableScrolling");
-
- const pageContainer = document.getElementById("pageContainer")!;
- if (Environment.platform() === "ios") {
- pageContainer.style.removeProperty("position");
- pageContainer.style.removeProperty("top");
- } else {
- pageContainer.style.removeProperty("margin-top");
- }
-
- if (_scrollTop) {
- document[_scrollOffsetFrom].scrollTop = ~~_scrollTop;
- }
- }
- }
-}
-
-/**
- * Indicates that at least one page overlay is currently open.
- */
-export function pageOverlayOpen(): void {
- if (_pageOverlayCounter === 0) {
- document.documentElement.classList.add("pageOverlayActive");
- }
-
- _pageOverlayCounter++;
-}
-
-/**
- * Marks one page overlay as closed.
- */
-export function pageOverlayClose(): void {
- if (_pageOverlayCounter) {
- _pageOverlayCounter--;
-
- if (_pageOverlayCounter === 0) {
- document.documentElement.classList.remove("pageOverlayActive");
- }
- }
-}
-
-/**
- * Returns true if at least one page overlay is currently open.
- *
- * @returns {boolean}
- */
-export function pageOverlayIsActive(): boolean {
- return _pageOverlayCounter > 0;
-}
-
-/**
- * @deprecated 5.4 - This method is a noop.
- */
-export function setDialogContainer(_container: Element): void {
- // Do nothing.
-}
-
-function _getQueryObject(query: string): MediaQueryData {
- if (typeof (query as any) !== "string" || query.trim() === "") {
- throw new TypeError("Expected a non-empty string for parameter 'query'.");
- }
-
- // Microsoft Edge rewrites the media queries to whatever it
- // pleases, causing the input and output query to mismatch
- if (_mqMapEdge.has(query)) query = _mqMapEdge.get(query)!;
-
- if (_mqMap.has(query)) query = _mqMap.get(query) as string;
-
- let queryObject = _mql.get(query);
- if (!queryObject) {
- queryObject = {
- callbacksMatch: new Map<string, Callback>(),
- callbacksUnmatch: new Map<string, Callback>(),
- callbacksSetup: new Map<string, Callback>(),
- mql: window.matchMedia(query),
- };
- //noinspection JSDeprecatedSymbols
- queryObject.mql.addListener(_mqlChange);
-
- _mql.set(query, queryObject);
-
- if (query !== queryObject.mql.media) {
- _mqMapEdge.set(queryObject.mql.media, query);
- }
- }
-
- return queryObject;
-}
-
-/**
- * Triggered whenever a registered media query now matches or no longer matches.
- */
-function _mqlChange(event: MediaQueryListEvent): void {
- const queryObject = _getQueryObject(event.media);
- if (event.matches) {
- if (queryObject.callbacksSetup.size) {
- queryObject.callbacksSetup.forEach((callback) => {
- callback();
- });
-
- // discard all setup callbacks after execution
- queryObject.callbacksSetup = new Map<string, Callback>();
- } else {
- queryObject.callbacksMatch.forEach((callback) => {
- callback();
- });
- }
- } else {
- // Chromium based browsers running on Windows suffer from a bug when
- // used with the responsive mode of the DevTools. Enabling and
- // disabling it will trigger some media queries to report a change
- // even when there isn't really one. This cause errors when invoking
- // "unmatch" handlers that rely on the setup being executed before.
- if (queryObject.callbacksSetup.size) {
- return;
- }
-
- queryObject.callbacksUnmatch.forEach((callback) => {
- callback();
- });
- }
-}
-
-type Callback = () => void;
-
-interface Callbacks {
- match: Callback;
- setup: Callback;
- unmatch: Callback;
-}
-
-interface MediaQueryData {
- callbacksMatch: Map<string, Callback>;
- callbacksSetup: Map<string, Callback>;
- callbacksUnmatch: Map<string, Callback>;
- mql: MediaQueryList;
-}
+++ /dev/null
-/**
- * Smoothly scrolls to an element while accounting for potential sticky headers.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Ui/Scroll (alias)
- * @module WoltLabSuite/Core/Ui/Scroll
- */
-import DomUtil from "../Dom/Util";
-
-type Callback = () => void;
-
-let _callback: Callback | null = null;
-let _offset: number | null = null;
-let _timeoutScroll: number | null = null;
-
-/**
- * Monitors scroll event to only execute the callback once scrolling has ended.
- */
-function onScroll(): void {
- if (_timeoutScroll !== null) {
- window.clearTimeout(_timeoutScroll);
- }
-
- _timeoutScroll = window.setTimeout(() => {
- if (_callback !== null) {
- _callback();
- }
-
- window.removeEventListener("scroll", onScroll);
- _callback = null;
- _timeoutScroll = null;
- }, 100);
-}
-
-/**
- * Scrolls to target element, optionally invoking the provided callback once scrolling has ended.
- *
- * @param {Element} element target element
- * @param {function=} callback callback invoked once scrolling has ended
- */
-export function element(element: HTMLElement, callback?: Callback): void {
- if (!(element instanceof HTMLElement)) {
- throw new TypeError("Expected a valid DOM element.");
- } else if (callback !== undefined && typeof callback !== "function") {
- throw new TypeError("Expected a valid callback function.");
- } else if (!document.body.contains(element)) {
- throw new Error("Element must be part of the visible DOM.");
- } else if (_callback !== null) {
- throw new Error("Cannot scroll to element, a concurrent request is running.");
- }
-
- if (callback) {
- _callback = callback;
- window.addEventListener("scroll", onScroll);
- }
-
- let y = DomUtil.offset(element).top;
- if (_offset === null) {
- _offset = 50;
- const pageHeader = document.getElementById("pageHeaderPanel");
- if (pageHeader !== null) {
- const position = window.getComputedStyle(pageHeader).position;
- if (position === "fixed" || position === "static") {
- _offset = pageHeader.offsetHeight;
- } else {
- _offset = 0;
- }
- }
- }
-
- if (_offset > 0) {
- if (y <= _offset) {
- y = 0;
- } else {
- // add an offset to account for a sticky header
- y -= _offset;
- }
- }
-
- const offset = window.pageYOffset;
- window.scrollTo({
- left: 0,
- top: y,
- behavior: "smooth",
- });
-
- window.setTimeout(() => {
- // no scrolling took place
- if (offset === window.pageYOffset) {
- onScroll();
- }
- }, 100);
-}
+++ /dev/null
-import { DatabaseObjectActionPayload } from "../../Ajax/Data";
-
-export type CallbackDropdownInit = (list: HTMLUListElement) => void;
-
-export type CallbackSelect = (item: HTMLElement) => boolean;
-
-export interface SearchInputOptions {
- ajax?: Partial<DatabaseObjectActionPayload>;
- autoFocus?: boolean;
- callbackDropdownInit?: CallbackDropdownInit;
- callbackSelect?: CallbackSelect;
- delay?: number;
- excludedSearchValues?: string[];
- minLength?: number;
- noResultPlaceholder?: string;
- preventSubmit?: boolean;
-}
+++ /dev/null
-/**
- * Provides suggestions using an input field, designed to work with `wcf\data\ISearchAction`.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Search/Input
- */
-
-import * as Ajax from "../../Ajax";
-import * as Core from "../../Core";
-import DomUtil from "../../Dom/Util";
-import UiDropdownSimple from "../Dropdown/Simple";
-import { AjaxCallbackSetup, DatabaseObjectActionPayload, DatabaseObjectActionResponse } from "../../Ajax/Data";
-import AjaxRequest from "../../Ajax/Request";
-import { CallbackDropdownInit, CallbackSelect, SearchInputOptions } from "./Data";
-
-class UiSearchInput {
- private activeItem?: HTMLLIElement = undefined;
- private readonly ajaxPayload: DatabaseObjectActionPayload;
- private readonly autoFocus: boolean;
- private readonly callbackDropdownInit?: CallbackDropdownInit = undefined;
- private readonly callbackSelect?: CallbackSelect = undefined;
- private readonly delay: number;
- private dropdownContainerId = "";
- private readonly element: HTMLInputElement;
- private readonly excludedSearchValues = new Set<string>();
- private list?: HTMLUListElement = undefined;
- private lastValue = "";
- private readonly minLength: number;
- private readonly noResultPlaceholder: string;
- private readonly preventSubmit: boolean;
- private request?: AjaxRequest = undefined;
- private timerDelay?: number = undefined;
-
- /**
- * Initializes the search input field.
- *
- * @param {Element} element target input[type="text"]
- * @param {Object} options search options and settings
- */
- constructor(element: HTMLInputElement, options: SearchInputOptions) {
- this.element = element;
- if (!(this.element instanceof HTMLInputElement)) {
- throw new TypeError("Expected a valid DOM element.");
- } else if (this.element.nodeName !== "INPUT" || (this.element.type !== "search" && this.element.type !== "text")) {
- throw new Error('Expected an input[type="text"].');
- }
-
- options = Core.extend(
- {
- ajax: {
- actionName: "getSearchResultList",
- className: "",
- interfaceName: "wcf\\data\\ISearchAction",
- },
- autoFocus: true,
- callbackDropdownInit: undefined,
- callbackSelect: undefined,
- delay: 500,
- excludedSearchValues: [],
- minLength: 3,
- noResultPlaceholder: "",
- preventSubmit: false,
- },
- options,
- ) as SearchInputOptions;
-
- this.ajaxPayload = options.ajax as DatabaseObjectActionPayload;
- this.autoFocus = options.autoFocus!;
- this.callbackDropdownInit = options.callbackDropdownInit;
- this.callbackSelect = options.callbackSelect;
- this.delay = options.delay!;
- options.excludedSearchValues!.forEach((value) => {
- this.addExcludedSearchValues(value);
- });
- this.minLength = options.minLength!;
- this.noResultPlaceholder = options.noResultPlaceholder!;
- this.preventSubmit = options.preventSubmit!;
-
- // Disable auto-complete because it collides with the suggestion dropdown.
- this.element.autocomplete = "off";
-
- this.element.addEventListener("keydown", (ev) => this.keydown(ev));
- this.element.addEventListener("keyup", (ev) => this.keyup(ev));
- }
-
- /**
- * Adds an excluded search value.
- */
- addExcludedSearchValues(value: string): void {
- this.excludedSearchValues.add(value);
- }
-
- /**
- * Removes a value from the excluded search values.
- */
- removeExcludedSearchValues(value: string): void {
- this.excludedSearchValues.delete(value);
- }
-
- /**
- * Handles the 'keydown' event.
- */
- private keydown(event: KeyboardEvent): void {
- if ((this.activeItem !== null && UiDropdownSimple.isOpen(this.dropdownContainerId)) || this.preventSubmit) {
- if (event.key === "Enter") {
- event.preventDefault();
- }
- }
-
- if (["ArrowUp", "ArrowDown", "Escape"].includes(event.key)) {
- event.preventDefault();
- }
- }
-
- /**
- * Handles the 'keyup' event, provides keyboard navigation and executes search queries.
- */
- private keyup(event: KeyboardEvent): void {
- // handle dropdown keyboard navigation
- if (this.activeItem !== null || !this.autoFocus) {
- if (UiDropdownSimple.isOpen(this.dropdownContainerId)) {
- if (event.key === "ArrowUp") {
- event.preventDefault();
-
- return this.keyboardPreviousItem();
- } else if (event.key === "ArrowDown") {
- event.preventDefault();
-
- return this.keyboardNextItem();
- } else if (event.key === "Enter") {
- event.preventDefault();
-
- return this.keyboardSelectItem();
- }
- } else {
- this.activeItem = undefined;
- }
- }
-
- // close list on escape
- if (event.key === "Escape") {
- UiDropdownSimple.close(this.dropdownContainerId);
-
- return;
- }
-
- const value = this.element.value.trim();
- if (this.lastValue === value) {
- // value did not change, e.g. previously it was "Test" and now it is "Test ",
- // but the trailing whitespace has been ignored
- return;
- }
-
- this.lastValue = value;
-
- if (value.length < this.minLength) {
- if (this.dropdownContainerId) {
- UiDropdownSimple.close(this.dropdownContainerId);
- this.activeItem = undefined;
- }
-
- // value below threshold
- return;
- }
-
- if (this.delay) {
- if (this.timerDelay) {
- window.clearTimeout(this.timerDelay);
- }
-
- this.timerDelay = window.setTimeout(() => {
- this.search(value);
- }, this.delay);
- } else {
- this.search(value);
- }
- }
-
- /**
- * Queries the server with the provided search string.
- */
- private search(value: string): void {
- if (this.request) {
- this.request.abortPrevious();
- }
-
- this.request = Ajax.api(this, this.getParameters(value));
- }
-
- /**
- * Returns additional AJAX parameters.
- */
- protected getParameters(value: string): Partial<DatabaseObjectActionPayload> {
- return {
- parameters: {
- data: {
- excludedSearchValues: this.excludedSearchValues,
- searchString: value,
- },
- },
- };
- }
-
- /**
- * Selects the next dropdown item.
- */
- private keyboardNextItem(): void {
- let nextItem: HTMLLIElement | undefined = undefined;
-
- if (this.activeItem) {
- this.activeItem.classList.remove("active");
-
- if (this.activeItem.nextElementSibling) {
- nextItem = this.activeItem.nextElementSibling as HTMLLIElement;
- }
- }
-
- this.activeItem = nextItem || (this.list!.children[0] as HTMLLIElement);
- this.activeItem.classList.add("active");
- }
-
- /**
- * Selects the previous dropdown item.
- */
- private keyboardPreviousItem(): void {
- let nextItem: HTMLLIElement | undefined = undefined;
-
- if (this.activeItem) {
- this.activeItem.classList.remove("active");
-
- if (this.activeItem.previousElementSibling) {
- nextItem = this.activeItem.previousElementSibling as HTMLLIElement;
- }
- }
-
- this.activeItem = nextItem || (this.list!.children[this.list!.childElementCount - 1] as HTMLLIElement);
- this.activeItem.classList.add("active");
- }
-
- /**
- * Selects the active item from the dropdown.
- */
- private keyboardSelectItem(): void {
- this.selectItem(this.activeItem!);
- }
-
- /**
- * Selects an item from the dropdown by clicking it.
- */
- private clickSelectItem(event: MouseEvent): void {
- this.selectItem(event.currentTarget as HTMLLIElement);
- }
-
- /**
- * Selects an item.
- */
- private selectItem(item: HTMLLIElement): void {
- if (this.callbackSelect && !this.callbackSelect(item)) {
- this.element.value = "";
- } else {
- this.element.value = item.dataset.label || "";
- }
-
- this.activeItem = undefined;
- UiDropdownSimple.close(this.dropdownContainerId);
- }
-
- /**
- * Handles successful AJAX requests.
- */
- _ajaxSuccess(data: DatabaseObjectActionResponse): void {
- let createdList = false;
- if (!this.list) {
- this.list = document.createElement("ul");
- this.list.className = "dropdownMenu";
-
- createdList = true;
-
- if (typeof this.callbackDropdownInit === "function") {
- this.callbackDropdownInit(this.list);
- }
- } else {
- // reset current list
- this.list.innerHTML = "";
- }
-
- if (typeof data.returnValues === "object") {
- const callbackClick = this.clickSelectItem.bind(this);
- let listItem;
-
- Object.keys(data.returnValues).forEach((key) => {
- listItem = this.createListItem(data.returnValues[key]);
-
- listItem.addEventListener("click", callbackClick);
- this.list!.appendChild(listItem);
- });
- }
-
- if (createdList) {
- this.element.insertAdjacentElement("afterend", this.list);
- const parent = this.element.parentElement!;
- UiDropdownSimple.initFragment(parent, this.list);
-
- this.dropdownContainerId = DomUtil.identify(parent);
- }
-
- if (this.dropdownContainerId) {
- this.activeItem = undefined;
-
- if (!this.list.childElementCount && !this.handleEmptyResult()) {
- UiDropdownSimple.close(this.dropdownContainerId);
- } else {
- UiDropdownSimple.open(this.dropdownContainerId, true);
-
- // mark first item as active
- const firstChild = this.list.childElementCount ? (this.list.children[0] as HTMLLIElement) : undefined;
- if (this.autoFocus && firstChild && ~~(firstChild.dataset.objectId || "")) {
- this.activeItem = firstChild;
- this.activeItem.classList.add("active");
- }
- }
- }
- }
-
- /**
- * Handles an empty result set, return a boolean false to hide the dropdown.
- */
- private handleEmptyResult(): boolean {
- if (!this.noResultPlaceholder) {
- return false;
- }
-
- const listItem = document.createElement("li");
- listItem.className = "dropdownText";
-
- const span = document.createElement("span");
- span.textContent = this.noResultPlaceholder;
- listItem.appendChild(span);
-
- this.list!.appendChild(listItem);
-
- return true;
- }
-
- /**
- * Creates an list item from response data.
- */
- protected createListItem(item: ListItemData): HTMLLIElement {
- const listItem = document.createElement("li");
- listItem.dataset.objectId = item.objectID.toString();
- listItem.dataset.label = item.label;
-
- const span = document.createElement("span");
- span.textContent = item.label;
- listItem.appendChild(span);
-
- return listItem;
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: this.ajaxPayload,
- };
- }
-}
-
-Core.enableLegacyInheritance(UiSearchInput);
-
-export = UiSearchInput;
-
-interface ListItemData {
- label: string;
- objectID: number;
-}
+++ /dev/null
-import * as Core from "../../Core";
-import * as DomTraverse from "../../Dom/Traverse";
-import DomUtil from "../../Dom/Util";
-import UiDropdownSimple from "../Dropdown/Simple";
-import * as UiScreen from "../Screen";
-import UiSearchInput from "./Input";
-
-function click(event: MouseEvent): void {
- event.preventDefault();
-
- const pageHeader = document.getElementById("pageHeader") as HTMLElement;
- pageHeader.classList.add("searchBarForceOpen");
- window.setTimeout(() => {
- pageHeader.classList.remove("searchBarForceOpen");
- }, 10);
-
- const target = event.currentTarget as HTMLElement;
- const objectType = target.dataset.objectType;
-
- const container = document.getElementById("pageHeaderSearchParameters") as HTMLElement;
- container.innerHTML = "";
-
- const extendedLink = target.dataset.extendedLink;
- if (extendedLink) {
- const link = document.querySelector(".pageHeaderSearchExtendedLink") as HTMLAnchorElement;
- link.href = extendedLink;
- }
-
- const parameters = new Map<string, string>();
- try {
- const data = JSON.parse(target.dataset.parameters || "");
- if (Core.isPlainObject(data)) {
- Object.keys(data).forEach((key) => {
- parameters.set(key, data[key]);
- });
- }
- } catch (e) {
- // Ignore JSON parsing failure.
- }
-
- if (objectType) {
- parameters.set("types[]", objectType);
- }
-
- parameters.forEach((value, key) => {
- const input = document.createElement("input");
- input.type = "hidden";
- input.name = key;
- input.value = value;
- container.appendChild(input);
- });
-
- // update label
- const inputContainer = document.getElementById("pageHeaderSearchInputContainer") as HTMLElement;
- const button = inputContainer.querySelector(
- ".pageHeaderSearchType > .button > .pageHeaderSearchTypeLabel",
- ) as HTMLElement;
- button.textContent = target.textContent;
-}
-
-export function init(objectType: string): void {
- const searchInput = document.getElementById("pageHeaderSearchInput") as HTMLInputElement;
-
- new UiSearchInput(searchInput, {
- ajax: {
- className: "wcf\\data\\search\\keyword\\SearchKeywordAction",
- },
- autoFocus: false,
- callbackDropdownInit(dropdownMenu) {
- dropdownMenu.classList.add("dropdownMenuPageSearch");
-
- if (UiScreen.is("screen-lg")) {
- dropdownMenu.dataset.dropdownAlignmentHorizontal = "right";
-
- const minWidth = searchInput.clientWidth;
- dropdownMenu.style.setProperty("min-width", `${minWidth}px`, "");
-
- // calculate offset to ignore the width caused by the submit button
- const parent = searchInput.parentElement!;
- const offsetRight =
- DomUtil.offset(parent).left + parent.clientWidth - (DomUtil.offset(searchInput).left + minWidth);
- const offsetTop = DomUtil.styleAsInt(window.getComputedStyle(parent), "padding-bottom");
- dropdownMenu.style.setProperty(
- "transform",
- `translateX(-${Math.ceil(offsetRight)}px) translateY(-${offsetTop}px)`,
- "",
- );
- }
- },
- callbackSelect() {
- setTimeout(() => {
- const form = DomTraverse.parentByTag(searchInput, "FORM") as HTMLFormElement;
- form.submit();
- }, 1);
-
- return true;
- },
- });
-
- const searchType = document.querySelector(".pageHeaderSearchType") as HTMLElement;
- const dropdownMenu = UiDropdownSimple.getDropdownMenu(DomUtil.identify(searchType))!;
- dropdownMenu.querySelectorAll("a[data-object-type]").forEach((link) => {
- link.addEventListener("click", click);
- });
-
- // trigger click on init
- const link = dropdownMenu.querySelector('a[data-object-type="' + objectType + '"]') as HTMLAnchorElement;
- link.click();
-}
+++ /dev/null
-/**
- * Inserts smilies into a WYSIWYG editor instance, with WAI-ARIA keyboard support.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Smiley/Insert
- */
-
-import * as Core from "../../Core";
-import * as EventHandler from "../../Event/Handler";
-
-class UiSmileyInsert {
- private readonly container: HTMLElement;
- private readonly editorId: string;
-
- constructor(editorId: string) {
- this.editorId = editorId;
-
- let container = document.getElementById("smilies-" + this.editorId);
- if (!container) {
- // form builder
- container = document.getElementById(this.editorId + "SmiliesTabContainer");
- if (!container) {
- throw new Error("Unable to find the message tab menu container containing the smilies.");
- }
- }
-
- this.container = container;
-
- this.container.addEventListener("keydown", (ev) => this.keydown(ev));
- this.container.addEventListener("mousedown", (ev) => this.mousedown(ev));
- }
-
- keydown(event: KeyboardEvent): void {
- const activeButton = document.activeElement as HTMLAnchorElement;
- if (!activeButton.classList.contains("jsSmiley")) {
- return;
- }
-
- if (["ArrowLeft", "ArrowRight", "End", "Home"].includes(event.key)) {
- event.preventDefault();
-
- const target = event.currentTarget as HTMLAnchorElement;
- const smilies: HTMLAnchorElement[] = Array.from(target.querySelectorAll(".jsSmiley"));
- if (event.key === "ArrowLeft") {
- smilies.reverse();
- }
-
- let index = smilies.indexOf(activeButton);
- if (event.key === "Home") {
- index = 0;
- } else if (event.key === "End") {
- index = smilies.length - 1;
- } else {
- index = index + 1;
- if (index === smilies.length) {
- index = 0;
- }
- }
-
- smilies[index].focus();
- } else if (event.key === "Enter" || event.key === "Space") {
- event.preventDefault();
-
- const image = activeButton.querySelector("img") as HTMLImageElement;
- this.insert(image);
- }
- }
-
- mousedown(event: MouseEvent): void {
- const target = event.target as HTMLElement;
-
- // Clicks may occur on a few different elements, but we are only looking for the image.
- const listItem = target.closest("li");
- if (listItem && this.container.contains(listItem)) {
- event.preventDefault();
-
- const img = listItem.querySelector("img");
- if (img) {
- this.insert(img);
- }
- }
- }
-
- insert(img: HTMLImageElement): void {
- EventHandler.fire("com.woltlab.wcf.redactor2", "insertSmiley_" + this.editorId, {
- img,
- });
- }
-}
-
-Core.enableLegacyInheritance(UiSmileyInsert);
-
-export = UiSmileyInsert;
+++ /dev/null
-/**
- * Sortable lists with optimized handling per device sizes.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Sortable/List
- */
-
-import * as Core from "../../Core";
-import * as UiScreen from "../Screen";
-
-interface UnknownObject {
- [key: string]: unknown;
-}
-
-interface SortableListOptions {
- containerId: string;
- className: string;
- offset: number;
- options: UnknownObject;
- isSimpleSorting: boolean;
- additionalParameters: UnknownObject;
-}
-
-class UiSortableList {
- protected readonly _options: SortableListOptions;
-
- /**
- * Initializes the sortable list controller.
- */
- constructor(opts: Partial<SortableListOptions>) {
- this._options = Core.extend(
- {
- containerId: "",
- className: "",
- offset: 0,
- options: {},
- isSimpleSorting: false,
- additionalParameters: {},
- },
- opts,
- ) as SortableListOptions;
-
- UiScreen.on("screen-sm-md", {
- match: () => this._enable(true),
- unmatch: () => this._disable(),
- setup: () => this._enable(true),
- });
-
- UiScreen.on("screen-lg", {
- match: () => this._enable(false),
- unmatch: () => this._disable(),
- setup: () => this._enable(false),
- });
- }
-
- /**
- * Enables sorting with an optional sort handle.
- */
- protected _enable(hasHandle: boolean): void {
- const options = this._options.options;
- if (hasHandle) {
- options.handle = ".sortableNodeHandle";
- }
-
- new window.WCF.Sortable.List(
- this._options.containerId,
- this._options.className,
- this._options.offset,
- options,
- this._options.isSimpleSorting,
- this._options.additionalParameters,
- );
- }
-
- /**
- * Disables sorting for registered containers.
- */
- protected _disable(): void {
- window
- .jQuery(`#${this._options.containerId} .sortableList`)
- [this._options.isSimpleSorting ? "sortable" : "nestedSortable"]("destroy");
- }
-}
-
-Core.enableLegacyInheritance(UiSortableList);
-
-export = UiSortableList;
+++ /dev/null
-/**
- * Provides a selection dialog for FontAwesome icons with filter capabilities.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Style/FontAwesome
- */
-
-import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
-import * as Language from "../../Language";
-import UiDialog from "../Dialog";
-import UiItemListFilter from "../ItemList/Filter";
-
-type CallbackSelect = (icon: string) => void;
-
-class UiStyleFontAwesome implements DialogCallbackObject {
- private callback?: CallbackSelect = undefined;
- private iconList?: HTMLElement = undefined;
- private itemListFilter?: UiItemListFilter = undefined;
- private readonly icons: string[];
-
- constructor(icons: string[]) {
- this.icons = icons;
- }
-
- open(callback: CallbackSelect): void {
- this.callback = callback;
-
- UiDialog.open(this);
- }
-
- /**
- * Selects an icon, notifies the callback and closes the dialog.
- */
- protected click(event: MouseEvent): void {
- event.preventDefault();
-
- const target = event.target as HTMLElement;
- const item = target.closest("li") as HTMLLIElement;
- const icon = item.querySelector("small")!.textContent!.trim();
-
- UiDialog.close(this);
-
- this.callback!(icon);
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "fontAwesomeSelection",
- options: {
- onSetup: () => {
- this.iconList = document.getElementById("fontAwesomeIcons") as HTMLElement;
-
- // build icons
- this.iconList.innerHTML = this.icons
- .map((icon) => `<li><span class="icon icon48 fa-${icon}"></span><small>${icon}</small></li>`)
- .join("");
-
- this.iconList.addEventListener("click", (ev) => this.click(ev));
-
- this.itemListFilter = new UiItemListFilter("fontAwesomeIcons", {
- callbackPrepareItem: (item) => {
- const small = item.querySelector("small") as HTMLElement;
- const text = small.textContent!.trim();
-
- return {
- item,
- span: small,
- text,
- };
- },
- enableVisibilityFilter: false,
- filterPosition: "top",
- });
- },
- onShow: () => {
- this.itemListFilter!.reset();
- },
- title: Language.get("wcf.global.fontAwesome.selectIcon"),
- },
- source: '<ul class="fontAwesomeIcons" id="fontAwesomeIcons"></ul>',
- };
- }
-}
-
-let uiStyleFontAwesome: UiStyleFontAwesome;
-
-/**
- * Sets the list of available icons, must be invoked prior to any call
- * to the `open()` method.
- */
-export function setup(icons: string[]): void {
- if (!uiStyleFontAwesome) {
- uiStyleFontAwesome = new UiStyleFontAwesome(icons);
- }
-}
-
-/**
- * Shows the FontAwesome selection dialog, supplied callback will be
- * invoked with the selection icon's name as the only argument.
- */
-export function open(callback: CallbackSelect): void {
- if (!uiStyleFontAwesome) {
- throw new Error(
- "Missing icon data, please include the template before calling this method using `{include file='fontAwesomeJavaScript'}`.",
- );
- }
-
- uiStyleFontAwesome.open(callback);
-}
+++ /dev/null
-/**
- * Flexible UI element featuring both a list of items and an input field with suggestion support.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Suggestion
- */
-
-import * as Ajax from "../Ajax";
-import * as Core from "../Core";
-import {
- AjaxCallbackObject,
- AjaxCallbackSetup,
- DatabaseObjectActionPayload,
- DatabaseObjectActionResponse,
-} from "../Ajax/Data";
-import UiDropdownSimple from "./Dropdown/Simple";
-
-interface ItemData {
- icon?: string;
- label: string;
- objectID: number;
- type?: string;
-}
-
-interface AjaxResponse extends DatabaseObjectActionResponse {
- returnValues: ItemData[];
-}
-
-class UiSuggestion implements AjaxCallbackObject {
- private readonly ajaxPayload: DatabaseObjectActionPayload;
- private readonly callbackSelect: CallbackSelect;
- private dropdownMenu: HTMLElement | null = null;
- private readonly excludedSearchValues: Set<string>;
- private readonly element: HTMLElement;
- private readonly threshold: number;
- private value = "";
-
- /**
- * Initializes a new suggestion input.
- */
- constructor(elementId: string, options: SuggestionOptions) {
- const element = document.getElementById(elementId);
- if (element === null) {
- throw new Error("Expected a valid element id.");
- }
-
- this.element = element;
-
- this.ajaxPayload = Core.extend(
- {
- actionName: "getSearchResultList",
- className: "",
- interfaceName: "wcf\\data\\ISearchAction",
- parameters: {
- data: {},
- },
- },
- options.ajax,
- ) as DatabaseObjectActionPayload;
-
- if (typeof options.callbackSelect !== "function") {
- throw new Error("Expected a valid callback for option 'callbackSelect'.");
- }
- this.callbackSelect = options.callbackSelect;
-
- this.excludedSearchValues = new Set(
- Array.isArray(options.excludedSearchValues) ? options.excludedSearchValues : [],
- );
- this.threshold = options.threshold === undefined ? 3 : options.threshold;
-
- this.element.addEventListener("click", (ev) => ev.preventDefault());
- this.element.addEventListener("keydown", (ev) => this.keyDown(ev));
- this.element.addEventListener("keyup", (ev) => this.keyUp(ev));
- }
-
- /**
- * Adds an excluded search value.
- */
- addExcludedValue(value: string): void {
- this.excludedSearchValues.add(value);
- }
-
- /**
- * Removes an excluded search value.
- */
- removeExcludedValue(value: string): void {
- this.excludedSearchValues.delete(value);
- }
-
- /**
- * Returns true if the suggestions are active.
- */
- isActive(): boolean {
- return this.dropdownMenu !== null && UiDropdownSimple.isOpen(this.element.id);
- }
-
- /**
- * Handles the keyboard navigation for interaction with the suggestion list.
- */
- private keyDown(event: KeyboardEvent): boolean {
- if (!this.isActive()) {
- return true;
- }
-
- if (["ArrowDown", "ArrowUp", "Enter", "Escape"].indexOf(event.key) === -1) {
- return true;
- }
-
- let active!: HTMLElement;
- let i = 0;
- const length = this.dropdownMenu!.childElementCount;
- while (i < length) {
- active = this.dropdownMenu!.children[i] as HTMLElement;
- if (active.classList.contains("active")) {
- break;
- }
- i++;
- }
-
- if (event.key === "Enter") {
- UiDropdownSimple.close(this.element.id);
- this.select(undefined, active);
- } else if (event.key === "Escape") {
- if (UiDropdownSimple.isOpen(this.element.id)) {
- UiDropdownSimple.close(this.element.id);
- } else {
- // let the event pass through
- return true;
- }
- } else {
- let index = 0;
- if (event.key === "ArrowUp") {
- index = (i === 0 ? length : i) - 1;
- } else if (event.key === "ArrowDown") {
- index = i + 1;
- if (index === length) {
- index = 0;
- }
- }
- if (index !== i) {
- active.classList.remove("active");
- this.dropdownMenu!.children[index].classList.add("active");
- }
- }
-
- event.preventDefault();
- return false;
- }
-
- /**
- * Selects an item from the list.
- */
- private select(event: MouseEvent): void;
- private select(event: undefined, item: HTMLElement): void;
- private select(event: MouseEvent | undefined, item?: HTMLElement): void {
- if (event instanceof MouseEvent) {
- const target = event.currentTarget as HTMLElement;
- item = target.parentNode as HTMLElement;
- }
-
- const anchor = item!.children[0] as HTMLElement;
- this.callbackSelect(this.element.id, {
- objectId: +(anchor.dataset.objectId || 0),
- value: item!.textContent || "",
- type: anchor.dataset.type || "",
- });
-
- if (event instanceof MouseEvent) {
- this.element.focus();
- }
- }
-
- /**
- * Performs a search for the input value unless it is below the threshold.
- */
- private keyUp(event: KeyboardEvent): void {
- const target = event.currentTarget as HTMLInputElement;
- const value = target.value.trim();
- if (this.value === value) {
- return;
- } else if (value.length < this.threshold) {
- if (this.dropdownMenu !== null) {
- UiDropdownSimple.close(this.element.id);
- }
-
- this.value = value;
- return;
- }
-
- this.value = value;
- Ajax.api(this, {
- parameters: {
- data: {
- excludedSearchValues: Array.from(this.excludedSearchValues),
- searchString: value,
- },
- },
- });
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: this.ajaxPayload,
- };
- }
-
- /**
- * Handles successful Ajax requests.
- */
- _ajaxSuccess(data: AjaxResponse): void {
- if (this.dropdownMenu === null) {
- this.dropdownMenu = document.createElement("div");
- this.dropdownMenu.className = "dropdownMenu";
- UiDropdownSimple.initFragment(this.element, this.dropdownMenu);
- } else {
- this.dropdownMenu.innerHTML = "";
- }
-
- if (Array.isArray(data.returnValues)) {
- data.returnValues.forEach((item, index) => {
- const anchor = document.createElement("a");
- if (item.icon) {
- anchor.className = "box16";
- anchor.innerHTML = `${item.icon} <span></span>`;
- anchor.children[1].textContent = item.label;
- } else {
- anchor.textContent = item.label;
- }
-
- anchor.dataset.objectId = item.objectID.toString();
- if (item.type) {
- anchor.dataset.type = item.type;
- }
- anchor.addEventListener("click", (ev) => this.select(ev));
-
- const listItem = document.createElement("li");
- if (index === 0) {
- listItem.className = "active";
- }
- listItem.appendChild(anchor);
- this.dropdownMenu!.appendChild(listItem);
- });
-
- UiDropdownSimple.open(this.element.id, true);
- } else {
- UiDropdownSimple.close(this.element.id);
- }
- }
-}
-
-Core.enableLegacyInheritance(UiSuggestion);
-
-export = UiSuggestion;
-
-interface CallbackSelectData {
- objectId: number;
- value: string;
- type: string;
-}
-
-type CallbackSelect = (elementId: string, data: CallbackSelectData) => void;
-
-interface SuggestionOptions {
- ajax: DatabaseObjectActionPayload;
-
- // will be executed once a value from the dropdown has been selected
- callbackSelect: CallbackSelect;
-
- // list of excluded search values
- excludedSearchValues?: string[];
-
- // minimum number of characters required to trigger a search request
- threshold?: number;
-}
+++ /dev/null
-/**
- * Common interface for tab menu access.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Ui/TabMenu (alias)
- * @module WoltLabSuite/Core/Ui/TabMenu
- */
-
-import DomChangeListener from "../Dom/Change/Listener";
-import DomUtil from "../Dom/Util";
-import TabMenuSimple from "./TabMenu/Simple";
-import UiCloseOverlay from "./CloseOverlay";
-import * as UiScreen from "./Screen";
-import * as UiScroll from "./Scroll";
-
-let _activeList: HTMLUListElement | null = null;
-let _enableTabScroll = false;
-const _tabMenus = new Map<string, TabMenuSimple>();
-
-/**
- * Initializes available tab menus.
- */
-function init() {
- document.querySelectorAll(".tabMenuContainer:not(.staticTabMenuContainer)").forEach((container: HTMLElement) => {
- const containerId = DomUtil.identify(container);
- if (_tabMenus.has(containerId)) {
- return;
- }
-
- let tabMenu = new TabMenuSimple(container);
- if (!tabMenu.validate()) {
- return;
- }
-
- const returnValue = tabMenu.init();
- _tabMenus.set(containerId, tabMenu);
- if (returnValue instanceof HTMLElement) {
- const parent = returnValue.parentNode as HTMLElement;
- const parentTabMenu = getTabMenu(parent.id);
- if (parentTabMenu) {
- tabMenu = parentTabMenu;
- tabMenu.select(returnValue.id, undefined, true);
- }
- }
-
- const list = document.querySelector("#" + containerId + " > nav > ul") as HTMLUListElement;
- list.addEventListener("click", (event) => {
- event.preventDefault();
- event.stopPropagation();
- if (event.target === list) {
- list.classList.add("active");
- _activeList = list;
- } else {
- list.classList.remove("active");
- _activeList = null;
- }
- });
-
- // bind scroll listener
- container.querySelectorAll(".tabMenu, .menu").forEach((menu: HTMLElement) => {
- function callback() {
- timeout = null;
-
- rebuildMenuOverflow(menu);
- }
-
- let timeout: number | null = null;
- menu.querySelector("ul")!.addEventListener(
- "scroll",
- () => {
- if (timeout !== null) {
- window.clearTimeout(timeout);
- }
-
- // slight delay to avoid calling this function too often
- timeout = window.setTimeout(callback, 10);
- },
- { passive: true },
- );
- });
-
- // The validation of input fields, e.g. [required], yields strange results when
- // the erroneous element is hidden inside a tab. The submit button will appear
- // to not work and a warning is displayed on the console. We can work around this
- // by manually checking if the input fields validate on submit and display the
- // parent tab ourselves.
- const form = container.closest("form");
- if (form !== null) {
- const submitButton = form.querySelector('input[type="submit"]');
- if (submitButton !== null) {
- submitButton.addEventListener("click", (event) => {
- if (event.defaultPrevented) {
- return;
- }
-
- container.querySelectorAll("input, select").forEach((element: HTMLInputElement | HTMLSelectElement) => {
- if (!element.checkValidity()) {
- event.preventDefault();
-
- // Select the tab that contains the erroneous element.
- const tabMenu = getTabMenu(element.closest(".tabMenuContainer")!.id)!;
- const tabMenuContent = element.closest(".tabMenuContent") as HTMLElement;
- tabMenu.select(tabMenuContent.dataset.name || "");
- UiScroll.element(element, () => {
- element.reportValidity();
- });
-
- return;
- }
- });
- });
- }
- }
- });
-}
-
-/**
- * Selects the first tab containing an element with class `formError`.
- */
-function selectErroneousTabs(): void {
- _tabMenus.forEach((tabMenu) => {
- let foundError = false;
- tabMenu.getContainers().forEach((container) => {
- if (!foundError && container.querySelector(".formError") !== null) {
- foundError = true;
- tabMenu.select(container.id);
- }
- });
- });
-}
-
-function scrollEnable(isSetup: boolean) {
- _enableTabScroll = true;
- _tabMenus.forEach((tabMenu) => {
- const activeTab = tabMenu.getActiveTab();
- if (isSetup) {
- rebuildMenuOverflow(activeTab.closest(".menu, .tabMenu") as HTMLElement);
- } else {
- scrollToTab(activeTab);
- }
- });
-}
-
-function scrollDisable() {
- _enableTabScroll = false;
-}
-
-function scrollMenu(
- list: HTMLElement,
- left: number,
- scrollLeft: number,
- scrollWidth: number,
- width: number,
- paddingRight: boolean,
-) {
- // allow some padding to indicate overflow
- if (paddingRight) {
- left -= 15;
- } else if (left > 0) {
- left -= 15;
- }
-
- if (left < 0) {
- left = 0;
- } else {
- // ensure that our left value is always within the boundaries
- left = Math.min(left, scrollWidth - width);
- }
-
- if (scrollLeft === left) {
- return;
- }
-
- list.classList.add("enableAnimation");
-
- // new value is larger, we're scrolling towards the end
- if (scrollLeft < left) {
- (list.firstElementChild as HTMLElement).style.setProperty("margin-left", `${scrollLeft - left}px`, "");
- } else {
- // new value is smaller, we're scrolling towards the start
- list.style.setProperty("padding-left", `${scrollLeft - left}px`, "");
- }
-
- setTimeout(() => {
- list.classList.remove("enableAnimation");
- (list.firstElementChild as HTMLElement).style.removeProperty("margin-left");
- list.style.removeProperty("padding-left");
- list.scrollLeft = left;
- }, 300);
-}
-
-function rebuildMenuOverflow(menu: HTMLElement): void {
- if (!_enableTabScroll) {
- return;
- }
-
- const width = menu.clientWidth;
- const list = menu.querySelector("ul") as HTMLElement;
- const scrollLeft = list.scrollLeft;
- const scrollWidth = list.scrollWidth;
- const overflowLeft = scrollLeft > 0;
-
- let overlayLeft = menu.querySelector(".tabMenuOverlayLeft");
- if (overflowLeft) {
- if (overlayLeft === null) {
- overlayLeft = document.createElement("span");
- overlayLeft.className = "tabMenuOverlayLeft icon icon24 fa-angle-left";
- overlayLeft.addEventListener("click", () => {
- const listWidth = list.clientWidth;
- scrollMenu(list, list.scrollLeft - ~~(listWidth / 2), list.scrollLeft, list.scrollWidth, listWidth, false);
- });
- menu.insertBefore(overlayLeft, menu.firstChild);
- }
-
- overlayLeft.classList.add("active");
- } else if (overlayLeft !== null) {
- overlayLeft.classList.remove("active");
- }
-
- const overflowRight = width + scrollLeft < scrollWidth;
- let overlayRight = menu.querySelector(".tabMenuOverlayRight");
- if (overflowRight) {
- if (overlayRight === null) {
- overlayRight = document.createElement("span");
- overlayRight.className = "tabMenuOverlayRight icon icon24 fa-angle-right";
- overlayRight.addEventListener("click", () => {
- const listWidth = list.clientWidth;
- scrollMenu(list, list.scrollLeft + ~~(listWidth / 2), list.scrollLeft, list.scrollWidth, listWidth, false);
- });
-
- menu.appendChild(overlayRight);
- }
- overlayRight.classList.add("active");
- } else if (overlayRight !== null) {
- overlayRight.classList.remove("active");
- }
-}
-
-/**
- * Sets up tab menus and binds listeners.
- */
-export function setup(): void {
- init();
- selectErroneousTabs();
-
- DomChangeListener.add("WoltLabSuite/Core/Ui/TabMenu", init);
- UiCloseOverlay.add("WoltLabSuite/Core/Ui/TabMenu", () => {
- if (_activeList) {
- _activeList.classList.remove("active");
- _activeList = null;
- }
- });
-
- UiScreen.on("screen-sm-down", {
- match() {
- scrollEnable(false);
- },
- unmatch: scrollDisable,
- setup() {
- scrollEnable(true);
- },
- });
-
- window.addEventListener("hashchange", () => {
- const hash = TabMenuSimple.getIdentifierFromHash();
- const element = hash ? document.getElementById(hash) : null;
- if (element !== null && element.classList.contains("tabMenuContent")) {
- _tabMenus.forEach((tabMenu) => {
- if (tabMenu.hasTab(hash)) {
- tabMenu.select(hash);
- }
- });
- }
- });
-
- const hash = TabMenuSimple.getIdentifierFromHash();
- if (hash) {
- window.setTimeout(() => {
- // check if page was initially scrolled using a tab id
- const tabMenuContent = document.getElementById(hash);
- if (tabMenuContent && tabMenuContent.classList.contains("tabMenuContent")) {
- const scrollY = window.scrollY || window.pageYOffset;
- if (scrollY > 0) {
- const parent = tabMenuContent.parentNode as HTMLElement;
-
- let offsetTop = parent.offsetTop - 50;
- if (offsetTop < 0) {
- offsetTop = 0;
- }
-
- if (scrollY > offsetTop) {
- let y = DomUtil.offset(parent).top;
- if (y <= 50) {
- y = 0;
- } else {
- y -= 50;
- }
-
- window.scrollTo(0, y);
- }
- }
- }
- }, 100);
- }
-}
-
-/**
- * Returns a TabMenuSimple instance for given container id.
- */
-export function getTabMenu(containerId: string): TabMenuSimple | undefined {
- return _tabMenus.get(containerId);
-}
-
-export function scrollToTab(tab: HTMLElement): void {
- if (!_enableTabScroll) {
- return;
- }
-
- const list = tab.closest("ul")!;
- const width = list.clientWidth;
- const scrollLeft = list.scrollLeft;
- const scrollWidth = list.scrollWidth;
- if (width === scrollWidth) {
- // no overflow, ignore
- return;
- }
-
- // check if tab is currently visible
- const left = tab.offsetLeft;
- let shouldScroll = false;
- if (left < scrollLeft) {
- shouldScroll = true;
- }
-
- let paddingRight = false;
- if (!shouldScroll) {
- const visibleWidth = width - (left - scrollLeft);
- let virtualWidth = tab.clientWidth;
- if (tab.nextElementSibling !== null) {
- paddingRight = true;
- virtualWidth += 20;
- }
-
- if (visibleWidth < virtualWidth) {
- shouldScroll = true;
- }
- }
-
- if (shouldScroll) {
- scrollMenu(list, left, scrollLeft, scrollWidth, width, paddingRight);
- }
-}
+++ /dev/null
-/**
- * Simple tab menu implementation with a straight-forward logic.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/TabMenu/Simple
- */
-
-import * as Core from "../../Core";
-import * as DomTraverse from "../../Dom/Traverse";
-import DomUtil from "../../Dom/Util";
-import * as Environment from "../../Environment";
-import * as EventHandler from "../../Event/Handler";
-
-class TabMenuSimple {
- private readonly container: HTMLElement;
- private readonly containers = new Map<string, HTMLElement>();
- private isLegacy = false;
- private store: HTMLInputElement | null = null;
- private readonly tabs = new Map<string, HTMLLIElement>();
-
- constructor(container: HTMLElement) {
- this.container = container;
- }
-
- /**
- * Validates the properties and DOM structure of this container.
- *
- * Expected DOM:
- * <div class="tabMenuContainer">
- * <nav>
- * <ul>
- * <li data-name="foo"><a>bar</a></li>
- * </ul>
- * </nav>
- *
- * <div id="foo">baz</div>
- * </div>
- */
- validate(): boolean {
- if (!this.container.classList.contains("tabMenuContainer")) {
- return false;
- }
-
- const nav = DomTraverse.childByTag(this.container, "NAV");
- if (nav === null) {
- return false;
- }
-
- // get children
- const tabs = nav.querySelectorAll("li");
- if (tabs.length === 0) {
- return false;
- }
-
- DomTraverse.childrenByTag(this.container, "DIV").forEach((container) => {
- let name = container.dataset.name;
- if (!name) {
- name = DomUtil.identify(container);
- container.dataset.name = name;
- }
-
- this.containers.set(name, container);
- });
-
- const containerId = this.container.id;
- tabs.forEach((tab) => {
- const name = this._getTabName(tab);
- if (!name) {
- return;
- }
-
- if (this.tabs.has(name)) {
- throw new Error(
- "Tab names must be unique, li[data-name='" +
- name +
- "'] (tab menu id: '" +
- containerId +
- "') exists more than once.",
- );
- }
-
- const container = this.containers.get(name);
- if (container === undefined) {
- throw new Error(
- "Expected content element for li[data-name='" + name + "'] (tab menu id: '" + containerId + "').",
- );
- } else if (container.parentNode !== this.container) {
- throw new Error(
- "Expected content element '" + name + "' (tab menu id: '" + containerId + "') to be a direct children.",
- );
- }
-
- // check if tab holds exactly one children which is an anchor element
- if (tab.childElementCount !== 1 || tab.children[0].nodeName !== "A") {
- throw new Error(
- "Expected exactly one <a> as children for li[data-name='" + name + "'] (tab menu id: '" + containerId + "').",
- );
- }
-
- this.tabs.set(name, tab);
- });
-
- if (!this.tabs.size) {
- throw new Error("Expected at least one tab (tab menu id: '" + containerId + "').");
- }
-
- if (this.isLegacy) {
- this.container.dataset.isLegacy = "true";
-
- this.tabs.forEach(function (tab, name) {
- tab.setAttribute("aria-controls", name);
- });
- }
-
- return true;
- }
-
- /**
- * Initializes this tab menu.
- */
- init(oldTabs?: Map<string, HTMLLIElement> | null): HTMLElement | null {
- // bind listeners
- this.tabs.forEach((tab) => {
- if (!oldTabs || oldTabs.get(tab.dataset.name || "") !== tab) {
- const firstChild = tab.children[0] as HTMLElement;
- firstChild.addEventListener("click", (ev) => this._onClick(ev));
-
- // iOS 13 changed the behavior for click events after scrolling the menu. It prevents
- // the synthetic mouse events like "click" from triggering for a short duration after
- // a scrolling has occurred. If the user scrolls to the end of the list and immediately
- // attempts to click the tab, nothing will happen. However, if the user waits for some
- // time, the tap will trigger a "click" event again.
- //
- // A "click" event is basically the result of a touch without any (significant) finger
- // movement indicated by a "touchmove" event. This changes allows the user to scroll
- // both the menu and the page normally, but still benefit from snappy reactions when
- // tapping a menu item.
- if (Environment.platform() === "ios") {
- let isClick = false;
- firstChild.addEventListener("touchstart", () => {
- isClick = true;
- });
- firstChild.addEventListener("touchmove", () => {
- isClick = false;
- });
- firstChild.addEventListener("touchend", (event) => {
- if (isClick) {
- isClick = false;
-
- // This will block the regular click event from firing.
- event.preventDefault();
-
- // Invoke the click callback manually.
- this._onClick(event);
- }
- });
- }
- }
- });
-
- let returnValue: HTMLElement | null = null;
- if (!oldTabs) {
- const hash = TabMenuSimple.getIdentifierFromHash();
- let selectTab: HTMLLIElement | undefined = undefined;
- if (hash !== "") {
- selectTab = this.tabs.get(hash);
-
- // check for parent tab menu
- if (selectTab) {
- const item = this.container.parentNode as HTMLElement;
- if (item.classList.contains("tabMenuContainer")) {
- returnValue = item;
- }
- }
- }
-
- if (!selectTab) {
- let preselect: unknown = this.container.dataset.preselect || this.container.dataset.active;
- if (preselect === "true" || !preselect) {
- preselect = true;
- }
-
- if (preselect === true) {
- this.tabs.forEach(function (tab) {
- if (
- !selectTab &&
- !DomUtil.isHidden(tab) &&
- (!tab.previousElementSibling || DomUtil.isHidden(tab.previousElementSibling as HTMLElement))
- ) {
- selectTab = tab;
- }
- });
- } else if (typeof preselect === "string" && preselect !== "false") {
- selectTab = this.tabs.get(preselect);
- }
- }
-
- if (selectTab) {
- this.containers.forEach((container) => {
- container.classList.add("hidden");
- });
-
- this.select(null, selectTab, true);
- }
-
- const store = this.container.dataset.store;
- if (store) {
- const input = document.createElement("input");
- input.type = "hidden";
- input.name = store;
- input.value = this.getActiveTab().dataset.name || "";
-
- this.container.appendChild(input);
-
- this.store = input;
- }
- }
-
- return returnValue;
- }
-
- /**
- * Selects a tab.
- *
- * @param {?(string|int)} name tab name or sequence no
- * @param {Element=} tab tab element
- * @param {boolean=} disableEvent suppress event handling
- */
- select(name: number | string | null, tab?: HTMLLIElement, disableEvent?: boolean): void {
- name = name ? name.toString() : "";
- tab = tab || this.tabs.get(name);
-
- if (!tab) {
- // check if name is an integer
- if (~~name === +name) {
- name = ~~name;
-
- let i = 0;
- this.tabs.forEach((item) => {
- if (i === name) {
- tab = item;
- }
-
- i++;
- });
- }
-
- if (!tab) {
- throw new Error(`Expected a valid tab name, '${name}' given (tab menu id: '${this.container.id}').`);
- }
- }
-
- name = (name || tab.dataset.name || "") as string;
-
- // unmark active tab
- const oldTab = this.getActiveTab();
- let oldContent: HTMLElement | null = null;
- if (oldTab) {
- const oldTabName = oldTab.dataset.name;
- if (oldTabName === name) {
- // same tab
- return;
- }
-
- if (!disableEvent) {
- EventHandler.fire("com.woltlab.wcf.simpleTabMenu_" + this.container.id, "beforeSelect", {
- tab: oldTab,
- tabName: oldTabName,
- });
- }
-
- oldTab.classList.remove("active");
- oldContent = this.containers.get(oldTab.dataset.name || "")!;
- oldContent.classList.remove("active");
- oldContent.classList.add("hidden");
-
- if (this.isLegacy) {
- oldTab.classList.remove("ui-state-active");
- oldContent.classList.remove("ui-state-active");
- }
- }
-
- tab.classList.add("active");
- const newContent = this.containers.get(name)!;
- newContent.classList.add("active");
- newContent.classList.remove("hidden");
-
- if (this.isLegacy) {
- tab.classList.add("ui-state-active");
- newContent.classList.add("ui-state-active");
- }
-
- if (this.store) {
- this.store.value = name;
- }
-
- if (!disableEvent) {
- EventHandler.fire("com.woltlab.wcf.simpleTabMenu_" + this.container.id, "select", {
- active: tab,
- activeName: name,
- previous: oldTab,
- previousName: oldTab ? oldTab.dataset.name : null,
- });
-
- const jQuery = this.isLegacy && typeof window.jQuery === "function" ? window.jQuery : null;
- if (jQuery) {
- // simulate jQuery UI Tabs event
- jQuery(this.container).trigger("wcftabsbeforeactivate", {
- newTab: jQuery(tab),
- oldTab: jQuery(oldTab),
- newPanel: jQuery(newContent),
- oldPanel: jQuery(oldContent!),
- });
- }
-
- let location = window.location.href.replace(/#+[^#]*$/, "");
- if (TabMenuSimple.getIdentifierFromHash() === name) {
- location += window.location.hash;
- } else {
- location += "#" + name;
- }
-
- // update history
- window.history.replaceState(undefined, "", location);
- }
-
- void import("../TabMenu").then((UiTabMenu) => {
- UiTabMenu.scrollToTab(tab!);
- });
- }
-
- /**
- * Selects the first visible tab of the tab menu and return `true`. If there is no
- * visible tab, `false` is returned.
- *
- * The visibility of a tab is determined by calling `elIsHidden` with the tab menu
- * item as the parameter.
- */
- selectFirstVisible(): boolean {
- let selectTab: HTMLLIElement | null = null;
- this.tabs.forEach((tab) => {
- if (!selectTab && !DomUtil.isHidden(tab)) {
- selectTab = tab;
- }
- });
-
- if (selectTab) {
- this.select(null, selectTab, false);
- }
-
- return selectTab !== null;
- }
-
- /**
- * Rebuilds all tabs, must be invoked after adding or removing of tabs.
- *
- * Warning: Do not remove tabs if you plan to add these later again or at least clone the nodes
- * to prevent issues with already bound event listeners. Consider hiding them via CSS.
- */
- rebuild(): void {
- const oldTabs = new Map<string, HTMLLIElement>(this.tabs);
-
- this.validate();
- this.init(oldTabs);
- }
-
- /**
- * Returns true if this tab menu has a tab with provided name.
- */
- hasTab(name: string): boolean {
- return this.tabs.has(name);
- }
-
- /**
- * Handles clicks on a tab.
- */
- _onClick(event: MouseEvent | TouchEvent): void {
- event.preventDefault();
-
- const target = event.currentTarget as HTMLElement;
- this.select(null, target.parentNode as HTMLLIElement);
- }
-
- /**
- * Returns the tab name.
- */
- _getTabName(tab: HTMLLIElement): string | null {
- let name = tab.dataset.name || null;
-
- // handle legacy tab menus
- if (!name) {
- if (tab.childElementCount === 1 && tab.children[0].nodeName === "A") {
- const link = tab.children[0] as HTMLAnchorElement;
- if (/#([^#]+)$/.exec(link.href)) {
- name = RegExp.$1;
-
- if (document.getElementById(name) === null) {
- name = null;
- } else {
- this.isLegacy = true;
- tab.dataset.name = name;
- }
- }
- }
- }
-
- return name;
- }
-
- /**
- * Returns the currently active tab.
- */
- getActiveTab(): HTMLLIElement {
- return document.querySelector("#" + this.container.id + " > nav > ul > li.active") as HTMLLIElement;
- }
-
- /**
- * Returns the list of registered content containers.
- */
- getContainers(): Map<string, HTMLElement> {
- return this.containers;
- }
-
- /**
- * Returns the list of registered tabs.
- */
- getTabs(): Map<string, HTMLLIElement> {
- return this.tabs;
- }
-
- static getIdentifierFromHash(): string {
- if (/^#+([^/]+)+(?:\/.+)?/.exec(window.location.hash)) {
- return RegExp.$1;
- }
-
- return "";
- }
-}
-
-Core.enableLegacyInheritance(TabMenuSimple);
-
-export = TabMenuSimple;
+++ /dev/null
-/**
- * Provides a simple toggle to show or hide certain elements when the
- * target element is checked.
- *
- * Be aware that the list of elements to show or hide accepts selectors
- * which will be passed to `elBySel()`, causing only the first matched
- * element to be used. If you require a whole list of elements identified
- * by a single selector to be handled, please provide the actual list of
- * elements instead.
- *
- * Usage:
- *
- * new UiToggleInput('input[name="foo"][value="bar"]', {
- * show: ['#showThisContainer', '.makeThisVisibleToo'],
- * hide: ['.notRelevantStuff', document.getElementById('fooBar')]
- * });
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Toggle/Input
- */
-
-import * as Core from "../../Core";
-import DomUtil from "../../Dom/Util";
-
-class UiToggleInput {
- private readonly element: HTMLInputElement;
- private readonly hide: HTMLElement[];
- private readonly show: HTMLElement[];
-
- /**
- * Initializes a new input toggle.
- */
- constructor(elementSelector: string, options: Partial<ToggleOptions>) {
- const element = document.querySelector(elementSelector) as HTMLInputElement;
- if (element === null) {
- throw new Error("Unable to find element by selector '" + elementSelector + "'.");
- }
-
- const type = element.nodeName === "INPUT" ? element.type : "";
- if (type !== "checkbox" && type !== "radio") {
- throw new Error("Illegal element, expected input[type='checkbox'] or input[type='radio'].");
- }
-
- this.element = element;
-
- this.hide = this.getElements("hide", Array.isArray(options.hide) ? options.hide : []);
- this.hide = this.getElements("show", Array.isArray(options.show) ? options.show : []);
-
- this.element.addEventListener("change", (ev) => this.change(ev));
-
- this.updateVisibility(this.show, this.element.checked);
- this.updateVisibility(this.hide, !this.element.checked);
- }
-
- private getElements(type: string, items: ElementOrSelector[]): HTMLElement[] {
- const elements: HTMLElement[] = [];
- items.forEach((item) => {
- let element: HTMLElement | null = null;
- if (typeof item === "string") {
- element = document.querySelector(item);
- if (element === null) {
- throw new Error(`Unable to find an element with the selector '${item}'.`);
- }
- } else if (item instanceof HTMLElement) {
- element = item;
- } else {
- throw new TypeError(`The array '${type}' may only contain string selectors or DOM elements.`);
- }
-
- elements.push(element);
- });
-
- return elements;
- }
-
- /**
- * Triggered when element is checked / unchecked.
- */
- private change(event: Event): void {
- const target = event.currentTarget as HTMLInputElement;
- const showElements = target.checked;
-
- this.updateVisibility(this.show, showElements);
- this.updateVisibility(this.hide, !showElements);
- }
-
- /**
- * Loops through the target elements and shows / hides them.
- */
- private updateVisibility(elements: HTMLElement[], showElement: boolean) {
- elements.forEach((element) => {
- DomUtil[showElement ? "show" : "hide"](element);
- });
- }
-}
-
-Core.enableLegacyInheritance(UiToggleInput);
-
-export = UiToggleInput;
-
-type ElementOrSelector = Element | string;
-
-interface ToggleOptions {
- show: ElementOrSelector[];
- hide: ElementOrSelector[];
-}
+++ /dev/null
-/**
- * Provides enhanced tooltips.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/Tooltip
- */
-
-import DomChangeListener from "../Dom/Change/Listener";
-import * as Environment from "../Environment";
-import * as UiAlignment from "./Alignment";
-
-let _pointer: HTMLElement;
-let _text: HTMLElement;
-let _tooltip: HTMLElement;
-
-/**
- * Displays the tooltip on mouse enter.
- */
-function mouseEnter(event: MouseEvent): void {
- const element = event.currentTarget as HTMLElement;
-
- let title = element.title.trim();
- if (title !== "") {
- element.dataset.tooltip = title;
- element.setAttribute("aria-label", title);
- element.removeAttribute("title");
- }
-
- title = element.dataset.tooltip || "";
-
- // reset tooltip position
- _tooltip.style.removeProperty("top");
- _tooltip.style.removeProperty("left");
-
- // ignore empty tooltip
- if (!title.length) {
- _tooltip.classList.remove("active");
- return;
- } else {
- _tooltip.classList.add("active");
- }
-
- _text.textContent = title;
- UiAlignment.set(_tooltip, element, {
- horizontal: "center",
- verticalOffset: 4,
- pointer: true,
- pointerClassNames: ["inverse"],
- vertical: "top",
- });
-}
-
-/**
- * Hides the tooltip once the mouse leaves the element.
- */
-function mouseLeave(): void {
- _tooltip.classList.remove("active");
-}
-
-/**
- * Initializes the tooltip element and binds event listener.
- */
-export function setup(): void {
- if (Environment.platform() !== "desktop") {
- return;
- }
-
- _tooltip = document.createElement("div");
- _tooltip.id = "balloonTooltip";
- _tooltip.classList.add("balloonTooltip");
- _tooltip.addEventListener("transitionend", () => {
- if (!_tooltip.classList.contains("active")) {
- // reset back to the upper left corner, prevent it from staying outside
- // the viewport if the body overflow was previously hidden
- ["bottom", "left", "right", "top"].forEach((property) => {
- _tooltip.style.removeProperty(property);
- });
- }
- });
-
- _text = document.createElement("span");
- _text.id = "balloonTooltipText";
- _tooltip.appendChild(_text);
-
- _pointer = document.createElement("span");
- _pointer.classList.add("elementPointer");
- _pointer.appendChild(document.createElement("span"));
- _tooltip.appendChild(_pointer);
-
- document.body.appendChild(_tooltip);
-
- init();
-
- DomChangeListener.add("WoltLabSuite/Core/Ui/Tooltip", init);
- window.addEventListener("scroll", mouseLeave);
-}
-
-/**
- * Initializes tooltip elements.
- */
-export function init(): void {
- document.querySelectorAll(".jsTooltip").forEach((element: HTMLElement) => {
- element.classList.remove("jsTooltip");
-
- const title = element.title.trim();
- if (title.length) {
- element.dataset.tooltip = title;
- element.removeAttribute("title");
- element.setAttribute("aria-label", title);
-
- element.addEventListener("mouseenter", mouseEnter);
- element.addEventListener("mouseleave", mouseLeave);
- element.addEventListener("click", mouseLeave);
- }
- });
-}
+++ /dev/null
-import * as Ajax from "../../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../../../Ajax/Data";
-import * as Core from "../../../Core";
-import * as Language from "../../../Language";
-import DomUtil from "../../../Dom/Util";
-
-interface AjaxResponse {
- returnValues: {
- lastEventID: number;
- lastEventTime: number;
- template?: string;
- };
-}
-
-class UiUserActivityRecent implements AjaxCallbackObject {
- private readonly containerId: string;
- private readonly list: HTMLUListElement;
- private readonly showMoreItem: HTMLLIElement;
-
- constructor(containerId: string) {
- this.containerId = containerId;
- const container = document.getElementById(this.containerId)!;
- this.list = container.querySelector(".recentActivityList") as HTMLUListElement;
-
- const showMoreItem = document.createElement("li");
- showMoreItem.className = "showMore";
- if (this.list.childElementCount) {
- showMoreItem.innerHTML = '<button class="small">' + Language.get("wcf.user.recentActivity.more") + "</button>";
-
- const button = showMoreItem.children[0] as HTMLButtonElement;
- button.addEventListener("click", (ev) => this.showMore(ev));
- } else {
- showMoreItem.innerHTML = "<small>" + Language.get("wcf.user.recentActivity.noMoreEntries") + "</small>";
- }
-
- this.list.appendChild(showMoreItem);
- this.showMoreItem = showMoreItem;
-
- container.querySelectorAll(".jsRecentActivitySwitchContext .button").forEach((button) => {
- button.addEventListener("click", (event) => {
- event.preventDefault();
-
- if (!button.classList.contains("active")) {
- this.switchContext();
- }
- });
- });
- }
-
- private showMore(event: MouseEvent): void {
- event.preventDefault();
-
- const button = this.showMoreItem.children[0] as HTMLButtonElement;
- button.disabled = true;
-
- Ajax.api(this, {
- actionName: "load",
- parameters: {
- boxID: ~~this.list.dataset.boxId!,
- filteredByFollowedUsers: Core.stringToBool(this.list.dataset.filteredByFollowedUsers || ""),
- lastEventId: this.list.dataset.lastEventId!,
- lastEventTime: this.list.dataset.lastEventTime!,
- userID: ~~this.list.dataset.userId!,
- },
- });
- }
-
- private switchContext(): void {
- Ajax.api(
- this,
- {
- actionName: "switchContext",
- },
- () => {
- window.location.hash = `#${this.containerId}`;
- window.location.reload();
- },
- );
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- if (data.returnValues.template) {
- DomUtil.insertHtml(data.returnValues.template, this.showMoreItem, "before");
-
- this.list.dataset.lastEventTime = data.returnValues.lastEventTime.toString();
- this.list.dataset.lastEventId = data.returnValues.lastEventID.toString();
-
- const button = this.showMoreItem.children[0] as HTMLButtonElement;
- button.disabled = false;
- } else {
- this.showMoreItem.innerHTML = "<small>" + Language.get("wcf.user.recentActivity.noMoreEntries") + "</small>";
- }
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- className: "wcf\\data\\user\\activity\\event\\UserActivityEventAction",
- },
- };
- }
-}
-
-Core.enableLegacyInheritance(UiUserActivityRecent);
-
-export = UiUserActivityRecent;
+++ /dev/null
-/**
- * Deletes the current user cover photo.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/User/CoverPhoto/Delete
- */
-
-import * as Ajax from "../../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "../../../Ajax/Data";
-import DomUtil from "../../../Dom/Util";
-import * as EventHandler from "../../../Event/Handler";
-import * as Language from "../../../Language";
-import * as UiConfirmation from "../../Confirmation";
-import * as UiNotification from "../../Notification";
-
-interface AjaxResponse extends ResponseData {
- returnValues: {
- url: string;
- };
-}
-
-class UiUserCoverPhotoDelete implements AjaxCallbackObject {
- private readonly button: HTMLAnchorElement;
- private readonly userId: number;
-
- /**
- * Initializes the delete handler and enables the delete button on upload.
- */
- constructor(userId: number) {
- this.button = document.querySelector(".jsButtonDeleteCoverPhoto") as HTMLAnchorElement;
- this.button.addEventListener("click", (ev) => this._click(ev));
- this.userId = userId;
-
- EventHandler.add("com.woltlab.wcf.user", "coverPhoto", (data) => {
- if (typeof data.url === "string" && data.url.length > 0) {
- DomUtil.show(this.button.parentElement!);
- }
- });
- }
-
- /**
- * Handles clicks on the delete button.
- */
- _click(event: MouseEvent): void {
- event.preventDefault();
-
- UiConfirmation.show({
- confirm: () => Ajax.api(this),
- message: Language.get("wcf.user.coverPhoto.delete.confirmMessage"),
- });
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- const photo = document.querySelector(".userProfileCoverPhoto") as HTMLElement;
- photo.style.setProperty("background-image", `url(${data.returnValues.url})`, "");
-
- DomUtil.hide(this.button.parentElement!);
-
- UiNotification.show();
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "deleteCoverPhoto",
- className: "wcf\\data\\user\\UserProfileAction",
- parameters: {
- userID: this.userId,
- },
- },
- };
- }
-}
-
-let uiUserCoverPhotoDelete: UiUserCoverPhotoDelete | undefined;
-
-/**
- * Initializes the delete handler and enables the delete button on upload.
- */
-export function init(userId: number): void {
- if (!uiUserCoverPhotoDelete) {
- uiUserCoverPhotoDelete = new UiUserCoverPhotoDelete(userId);
- }
-}
+++ /dev/null
-/**
- * Uploads the user cover photo via AJAX.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/User/CoverPhoto/Upload
- */
-
-import * as Core from "../../../Core";
-import DomUtil from "../../../Dom/Util";
-import * as EventHandler from "../../../Event/Handler";
-import { ResponseData } from "../../../Ajax/Data";
-import * as UiDialog from "../../Dialog";
-import * as UiNotification from "../../Notification";
-import Upload from "../../../Upload";
-
-interface AjaxResponse extends ResponseData {
- returnValues: {
- errorMessage?: string;
- url?: string;
- };
-}
-
-/**
- * @constructor
- */
-class UiUserCoverPhotoUpload extends Upload {
- private readonly userId: number;
-
- constructor(userId: number) {
- super("coverPhotoUploadButtonContainer", "coverPhotoUploadPreview", {
- action: "uploadCoverPhoto",
- className: "wcf\\data\\user\\UserProfileAction",
- });
-
- this.userId = userId;
- }
-
- protected _getParameters(): ArbitraryObject {
- return {
- userID: this.userId,
- };
- }
-
- protected _success(uploadId: number, data: AjaxResponse): void {
- // remove or display the error message
- DomUtil.innerError(this._button, data.returnValues.errorMessage);
-
- // remove the upload progress
- this._target.innerHTML = "";
-
- if (data.returnValues.url) {
- const photo = document.querySelector(".userProfileCoverPhoto") as HTMLElement;
- photo.style.setProperty("background-image", `url(${data.returnValues.url})`, "");
-
- UiDialog.close("userProfileCoverPhotoUpload");
- UiNotification.show();
-
- EventHandler.fire("com.woltlab.wcf.user", "coverPhoto", {
- url: data.returnValues.url,
- });
- }
- }
-}
-
-Core.enableLegacyInheritance(UiUserCoverPhotoUpload);
-
-export = UiUserCoverPhotoUpload;
+++ /dev/null
-/**
- * Simple notification overlay.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/User/Editor
- */
-
-import * as Ajax from "../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup } from "../../Ajax/Data";
-import * as Core from "../../Core";
-import { DialogCallbackObject, DialogCallbackSetup } from "../Dialog/Data";
-import DomUtil from "../../Dom/Util";
-import * as Language from "../../Language";
-import * as StringUtil from "../../StringUtil";
-import UiDialog from "../Dialog";
-import * as UiNotification from "../Notification";
-
-class UserEditor implements AjaxCallbackObject, DialogCallbackObject {
- private actionName = "";
- private readonly header: HTMLElement;
-
- constructor() {
- this.header = document.querySelector(".userProfileUser") as HTMLElement;
-
- ["ban", "disableAvatar", "disableCoverPhoto", "disableSignature", "enable"].forEach((action) => {
- const button = document.querySelector(
- ".userProfileButtonMenu .jsButtonUser" + StringUtil.ucfirst(action),
- ) as HTMLElement;
-
- // The button is missing if the current user lacks the permission.
- if (button) {
- button.dataset.action = action;
- button.addEventListener("click", (ev) => this._click(ev));
- }
- });
- }
-
- /**
- * Handles clicks on action buttons.
- */
- _click(event: MouseEvent): void {
- event.preventDefault();
-
- const target = event.currentTarget as HTMLElement;
- const action = target.dataset.action || "";
- let actionName = "";
- switch (action) {
- case "ban":
- if (Core.stringToBool(this.header.dataset.banned || "")) {
- actionName = "unban";
- }
- break;
-
- case "disableAvatar":
- if (Core.stringToBool(this.header.dataset.disableAvatar || "")) {
- actionName = "enableAvatar";
- }
- break;
-
- case "disableCoverPhoto":
- if (Core.stringToBool(this.header.dataset.disableCoverPhoto || "")) {
- actionName = "enableCoverPhoto";
- }
- break;
-
- case "disableSignature":
- if (Core.stringToBool(this.header.dataset.disableSignature || "")) {
- actionName = "enableSignature";
- }
- break;
-
- case "enable":
- actionName = Core.stringToBool(this.header.dataset.isDisabled || "") ? "enable" : "disable";
- break;
- }
-
- if (actionName === "") {
- this.actionName = action;
-
- UiDialog.open(this);
- } else {
- Ajax.api(this, {
- actionName: actionName,
- });
- }
- }
-
- /**
- * Handles form submit and input validation.
- */
- _submit(event: Event): void {
- event.preventDefault();
-
- const label = document.getElementById("wcfUiUserEditorExpiresLabel") as HTMLElement;
-
- let expires = "";
- let errorMessage = "";
- const neverExpires = document.getElementById("wcfUiUserEditorNeverExpires") as HTMLInputElement;
- if (!neverExpires.checked) {
- const expireValue = document.getElementById("wcfUiUserEditorExpiresDatePicker") as HTMLInputElement;
- expires = expireValue.value;
- if (expires === "") {
- errorMessage = Language.get("wcf.global.form.error.empty");
- }
- }
-
- DomUtil.innerError(label, errorMessage);
-
- const parameters = {};
- parameters[this.actionName + "Expires"] = expires;
- const reason = document.getElementById("wcfUiUserEditorReason") as HTMLTextAreaElement;
- parameters[this.actionName + "Reason"] = reason.value.trim();
-
- Ajax.api(this, {
- actionName: this.actionName,
- parameters: parameters,
- });
- }
-
- _ajaxSuccess(data): void {
- let button: HTMLElement;
- switch (data.actionName) {
- case "ban":
- case "unban": {
- this.header.dataset.banned = data.actionName === "ban" ? "true" : "false";
- button = document.querySelector(".userProfileButtonMenu .jsButtonUserBan") as HTMLElement;
- button.textContent = Language.get("wcf.user." + (data.actionName === "ban" ? "unban" : "ban"));
-
- const contentTitle = this.header.querySelector(".contentTitle") as HTMLElement;
- let banIcon = contentTitle.querySelector(".jsUserBanned") as HTMLElement;
- if (data.actionName === "ban") {
- banIcon = document.createElement("span");
- banIcon.className = "icon icon24 fa-lock jsUserBanned jsTooltip";
- banIcon.title = data.returnValues;
- contentTitle.appendChild(banIcon);
- } else if (banIcon) {
- banIcon.remove();
- }
- break;
- }
-
- case "disableAvatar":
- case "enableAvatar":
- this.header.dataset.disableAvatar = data.actionName === "disableAvatar" ? "true" : "false";
- button = document.querySelector(".userProfileButtonMenu .jsButtonUserDisableAvatar") as HTMLElement;
- button.textContent = Language.get(
- "wcf.user." + (data.actionName === "disableAvatar" ? "enable" : "disable") + "Avatar",
- );
- break;
-
- case "disableCoverPhoto":
- case "enableCoverPhoto":
- this.header.dataset.disableCoverPhoto = data.actionName === "disableCoverPhoto" ? "true" : "false";
- button = document.querySelector(".userProfileButtonMenu .jsButtonUserDisableCoverPhoto") as HTMLElement;
- button.textContent = Language.get(
- "wcf.user." + (data.actionName === "disableCoverPhoto" ? "enable" : "disable") + "CoverPhoto",
- );
- break;
-
- case "disableSignature":
- case "enableSignature":
- this.header.dataset.disableSignature = data.actionName === "disableSignature" ? "true" : "false";
- button = document.querySelector(".userProfileButtonMenu .jsButtonUserDisableSignature") as HTMLElement;
- button.textContent = Language.get(
- "wcf.user." + (data.actionName === "disableSignature" ? "enable" : "disable") + "Signature",
- );
- break;
-
- case "enable":
- case "disable":
- this.header.dataset.isDisabled = data.actionName === "disable" ? "true" : "false";
- button = document.querySelector(".userProfileButtonMenu .jsButtonUserEnable") as HTMLElement;
- button.textContent = Language.get("wcf.acp.user." + (data.actionName === "enable" ? "disable" : "enable"));
- break;
- }
-
- if (["ban", "disableAvatar", "disableCoverPhoto", "disableSignature"].indexOf(data.actionName) !== -1) {
- UiDialog.close(this);
- }
-
- UiNotification.show();
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- className: "wcf\\data\\user\\UserAction",
- objectIDs: [+this.header.dataset.objectId!],
- },
- };
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "wcfUiUserEditor",
- options: {
- onSetup: (content) => {
- const checkbox = document.getElementById("wcfUiUserEditorNeverExpires") as HTMLInputElement;
- checkbox.addEventListener("change", () => {
- const settings = document.getElementById("wcfUiUserEditorExpiresSettings") as HTMLElement;
- DomUtil[checkbox.checked ? "hide" : "show"](settings);
- });
-
- const submitButton = content.querySelector("button.buttonPrimary") as HTMLButtonElement;
- submitButton.addEventListener("click", this._submit.bind(this));
- },
- onShow: (content) => {
- UiDialog.setTitle("wcfUiUserEditor", Language.get("wcf.user." + this.actionName + ".confirmMessage"));
-
- const reason = document.getElementById("wcfUiUserEditorReason") as HTMLElement;
- let label = reason.nextElementSibling as HTMLElement;
- const phrase = "wcf.user." + this.actionName + ".reason.description";
- label.textContent = Language.get(phrase);
- if (label.textContent === phrase) {
- DomUtil.hide(label);
- } else {
- DomUtil.show(label);
- }
-
- label = document.getElementById("wcfUiUserEditorNeverExpires")!.nextElementSibling as HTMLElement;
- label.textContent = Language.get("wcf.user." + this.actionName + ".neverExpires");
-
- label = content.querySelector('label[for="wcfUiUserEditorExpires"]') as HTMLElement;
- label.textContent = Language.get("wcf.user." + this.actionName + ".expires");
-
- label = document.getElementById("wcfUiUserEditorExpiresLabel") as HTMLElement;
- label.textContent = Language.get("wcf.user." + this.actionName + ".expires.description");
- },
- },
- source: `<div class="section">
- <dl>
- <dt><label for="wcfUiUserEditorReason">${Language.get("wcf.global.reason")}</label></dt>
- <dd><textarea id="wcfUiUserEditorReason" cols="40" rows="3"></textarea><small></small></dd>
- </dl>
- <dl>
- <dt></dt>
- <dd><label><input type="checkbox" id="wcfUiUserEditorNeverExpires" checked> <span></span></label></dd>
- </dl>
- <dl id="wcfUiUserEditorExpiresSettings" style="display: none">
- <dt><label for="wcfUiUserEditorExpires"></label></dt>
- <dd>
- <input type="date" name="wcfUiUserEditorExpires" id="wcfUiUserEditorExpires" class="medium" min="${new Date(
- window.TIME_NOW * 1000,
- ).toISOString()}" data-ignore-timezone="true">
- <small id="wcfUiUserEditorExpiresLabel"></small>
- </dd>
- </dl>
- </div>
- <div class="formSubmit">
- <button class="buttonPrimary">${Language.get("wcf.global.button.submit")}</button>
- </div>`,
- };
- }
-}
-
-/**
- * Initializes the user editor.
- */
-export function init(): void {
- new UserEditor();
-}
+++ /dev/null
-/**
- * Provides global helper methods to interact with ignored content.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/User/Ignore
- */
-
-import DomChangeListener from "../../Dom/Change/Listener";
-
-const _availableMessages = document.getElementsByClassName("ignoredUserMessage");
-const _knownMessages = new Set<HTMLElement>();
-
-/**
- * Adds ignored messages to the collection.
- *
- * @protected
- */
-function rebuild() {
- for (let i = 0, length = _availableMessages.length; i < length; i++) {
- const message = _availableMessages[i] as HTMLElement;
-
- if (!_knownMessages.has(message)) {
- message.addEventListener("click", showMessage, { once: true });
-
- _knownMessages.add(message);
- }
- }
-}
-
-/**
- * Reveals a message on click/tap and disables the listener.
- */
-function showMessage(event: MouseEvent): void {
- event.preventDefault();
-
- const message = event.currentTarget as HTMLElement;
- message.classList.remove("ignoredUserMessage");
- _knownMessages.delete(message);
-
- // Firefox selects the entire message on click for no reason
- window.getSelection()!.removeAllRanges();
-}
-
-/**
- * Initializes the click handler for each ignored message and listens for
- * newly inserted messages.
- */
-export function init(): void {
- rebuild();
-
- DomChangeListener.add("WoltLabSuite/Core/Ui/User/Ignore", rebuild);
-}
+++ /dev/null
-/**
- * Object-based user list.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/User/List
- */
-
-import * as Ajax from "../../Ajax";
-import * as Core from "../../Core";
-import DomUtil from "../../Dom/Util";
-import UiDialog from "../Dialog";
-import UiPagination from "../Pagination";
-import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../Ajax/Data";
-import { DialogCallbackObject, DialogData, DialogCallbackSetup } from "../Dialog/Data";
-
-/**
- * @constructor
- */
-class UiUserList implements AjaxCallbackObject, DialogCallbackObject {
- private readonly cache = new Map<number, string>();
- private readonly options: AjaxRequestOptions;
- private pageCount = 0;
- private pageNo = 1;
-
- /**
- * Initializes the user list.
- *
- * @param {object} options list of initialization options
- */
- constructor(options: AjaxRequestOptions) {
- this.options = Core.extend(
- {
- className: "",
- dialogTitle: "",
- parameters: {},
- },
- options,
- ) as AjaxRequestOptions;
- }
-
- /**
- * Opens the user list.
- */
- open(): void {
- this.pageNo = 1;
- this.showPage();
- }
-
- /**
- * Shows the current or given page.
- */
- private showPage(pageNo?: number): void {
- if (typeof pageNo === "number") {
- this.pageNo = +pageNo;
- }
-
- if (this.pageCount !== 0 && (this.pageNo < 1 || this.pageNo > this.pageCount)) {
- throw new RangeError(`pageNo must be between 1 and ${this.pageCount} (${this.pageNo} given).`);
- }
-
- if (this.cache.has(this.pageNo)) {
- const dialog = UiDialog.open(this, this.cache.get(this.pageNo)) as DialogData;
-
- if (this.pageCount > 1) {
- const element = dialog.content.querySelector(".jsPagination") as HTMLElement;
- if (element !== null) {
- new UiPagination(element, {
- activePage: this.pageNo,
- maxPage: this.pageCount,
-
- callbackSwitch: this.showPage.bind(this),
- });
- }
-
- // scroll to the list start
- const container = dialog.content.parentElement!;
- if (container.scrollTop > 0) {
- container.scrollTop = 0;
- }
- }
- } else {
- this.options.parameters.pageNo = this.pageNo;
-
- Ajax.api(this, {
- parameters: this.options.parameters,
- });
- }
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- if (data.returnValues.pageCount !== undefined) {
- this.pageCount = ~~data.returnValues.pageCount;
- }
-
- this.cache.set(this.pageNo, data.returnValues.template);
- this.showPage();
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "getGroupedUserList",
- className: this.options.className,
- interfaceName: "wcf\\data\\IGroupedUserListAction",
- },
- };
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: DomUtil.getUniqueId(),
- options: {
- title: this.options.dialogTitle,
- },
- source: null,
- };
- }
-}
-
-Core.enableLegacyInheritance(UiUserList);
-
-export = UiUserList;
-
-interface AjaxRequestOptions {
- className: string;
- dialogTitle: string;
- parameters: {
- [key: string]: any;
- };
-}
-
-interface AjaxResponse extends DatabaseObjectActionResponse {
- returnValues: {
- pageCount?: number;
- template: string;
- };
-}
+++ /dev/null
-import QrCreator from "qr-creator";
-
-export function render(container: HTMLElement): void {
- const secret: HTMLElement | null = container.querySelector(".totpSecret");
- if (!secret) {
- return;
- }
-
- const accountName = secret.dataset.accountname;
- if (!accountName) {
- return;
- }
-
- const issuer = secret.dataset.issuer;
- const label = (issuer ? `${issuer}:` : "") + accountName;
-
- const canvas = container.querySelector("canvas");
- QrCreator.render(
- {
- text: `otpauth://totp/${encodeURIComponent(label)}?secret=${encodeURIComponent(secret.textContent!)}${
- issuer ? `&issuer=${encodeURIComponent(issuer)}` : ""
- }`,
- size: canvas && canvas.clientWidth ? canvas.clientWidth : 200,
- },
- canvas || container,
- );
-}
-
-export default render;
-
-export function renderAll(): void {
- document.querySelectorAll(".totpSecretContainer").forEach((el: HTMLElement) => render(el));
-}
+++ /dev/null
-/**
- * Adds a password strength meter to a password input and exposes
- * zxcbn's verdict as sibling input.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/User/PasswordStrength
- */
-
-import * as Language from "../../Language";
-import DomUtil from "../../Dom/Util";
-
-// zxcvbn is imported for the types only. It is loaded on demand, due to its size.
-import type zxcvbn from "zxcvbn";
-
-type StaticDictionary = string[];
-
-const STATIC_DICTIONARY: StaticDictionary = [];
-
-const siteName = document.querySelector('meta[property="og:site_name"]')?.getAttribute("content");
-if (siteName) {
- STATIC_DICTIONARY.push(siteName);
-}
-
-function flatMap<T, U>(array: T[], callback: (x: T) => U[]): U[] {
- return array.map(callback).reduce((carry, item) => {
- return carry.concat(item);
- }, [] as U[]);
-}
-
-function splitIntoWords(value: string): string[] {
- return ([] as string[]).concat(value, value.split(/\W+/));
-}
-
-function initializeFeedbacker(Feedback: typeof zxcvbn.Feedback): zxcvbn.Feedback {
- const localizedPhrases: typeof Feedback.default_phrases = {} as typeof Feedback.default_phrases;
-
- Object.entries(Feedback.default_phrases).forEach(([type, phrases]) => {
- localizedPhrases[type] = {};
- Object.entries(phrases).forEach(([identifier, phrase]) => {
- const languageItem = `wcf.user.password.zxcvbn.${type}.${identifier}`;
- const localizedValue = Language.get(languageItem);
- localizedPhrases[type][identifier] = localizedValue !== languageItem ? localizedValue : phrase;
- });
- });
-
- return new Feedback(localizedPhrases);
-}
-
-class PasswordStrength {
- private zxcvbn: typeof zxcvbn;
- private relatedInputs: HTMLInputElement[];
- private staticDictionary: StaticDictionary;
- private feedbacker: zxcvbn.Feedback;
-
- private readonly wrapper = document.createElement("div");
- private readonly score = document.createElement("span");
- private readonly verdictResult = document.createElement("input");
-
- constructor(private readonly input: HTMLInputElement, options: Partial<Options>) {
- void import("zxcvbn").then(({ default: zxcvbn }) => {
- this.zxcvbn = zxcvbn;
-
- if (options.relatedInputs) {
- this.relatedInputs = options.relatedInputs;
- }
- if (options.staticDictionary) {
- this.staticDictionary = options.staticDictionary;
- }
-
- this.feedbacker = initializeFeedbacker(zxcvbn.Feedback);
-
- this.wrapper.className = "inputAddon inputAddonPasswordStrength";
- this.input.parentNode!.insertBefore(this.wrapper, this.input);
- this.wrapper.appendChild(this.input);
-
- const rating = document.createElement("div");
- rating.className = "passwordStrengthRating";
-
- const ratingLabel = document.createElement("small");
- ratingLabel.textContent = Language.get("wcf.user.password.strength");
- rating.appendChild(ratingLabel);
-
- this.score.className = "passwordStrengthScore";
- this.score.dataset.score = "-1";
- rating.appendChild(this.score);
-
- this.wrapper.appendChild(rating);
-
- this.verdictResult.type = "hidden";
- this.verdictResult.name = `${this.input.name}_passwordStrengthVerdict`;
- this.wrapper.parentNode!.insertBefore(this.verdictResult, this.wrapper);
-
- this.input.addEventListener("input", (ev) => this.evaluate(ev));
- this.relatedInputs.forEach((input) => input.addEventListener("input", (ev) => this.evaluate(ev)));
- if (this.input.value.trim() !== "") {
- this.evaluate();
- }
- });
- }
-
- private evaluate(event?: Event) {
- const dictionary = flatMap(
- STATIC_DICTIONARY.concat(
- this.staticDictionary,
- this.relatedInputs.map((input) => input.value.trim()),
- ),
- splitIntoWords,
- ).filter((value) => value.length > 0);
-
- const value = this.input.value.trim();
-
- // To bound runtime latency for really long passwords, consider sending zxcvbn() only
- // the first 100 characters or so of user input.
- const verdict = this.zxcvbn(value.substr(0, 100), dictionary);
- verdict.feedback = this.feedbacker.from_result(verdict);
-
- this.score.dataset.score = value.length === 0 ? "-1" : verdict.score.toString();
-
- if (event !== undefined) {
- // Do not overwrite the value on page load.
- DomUtil.innerError(this.wrapper, verdict.feedback.warning);
- }
-
- this.verdictResult.value = JSON.stringify(verdict);
- }
-}
-
-export = PasswordStrength;
-
-interface Options {
- relatedInputs: PasswordStrength["relatedInputs"];
- staticDictionary: PasswordStrength["staticDictionary"];
- feedbacker: PasswordStrength["feedbacker"];
-}
+++ /dev/null
-/**
- * Default implementation for user interaction menu items used in the user profile.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Abstract
- */
-
-import * as Ajax from "../../../../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "../../../../../Ajax/Data";
-import * as Core from "../../../../../Core";
-
-abstract class UiUserProfileMenuItemAbstract implements AjaxCallbackObject {
- protected readonly _button = document.createElement("a");
- protected _isActive: boolean;
- protected readonly _listItem = document.createElement("li");
- protected readonly _userId: number;
-
- /**
- * Creates a new user profile menu item.
- */
- protected constructor(userId: number, isActive: boolean) {
- this._userId = userId;
- this._isActive = isActive;
-
- this._initButton();
- this._updateButton();
- }
-
- /**
- * Initializes the menu item.
- */
- protected _initButton(): void {
- this._button.href = "#";
- this._button.addEventListener("click", (ev) => this._toggle(ev));
- this._listItem.appendChild(this._button);
-
- const menu = document.querySelector(`.userProfileButtonMenu[data-menu="interaction"]`) as HTMLElement;
- menu.insertAdjacentElement("afterbegin", this._listItem);
- }
-
- /**
- * Handles clicks on the menu item button.
- */
- protected _toggle(event: MouseEvent): void {
- event.preventDefault();
-
- Ajax.api(this, {
- actionName: this._getAjaxActionName(),
- parameters: {
- data: {
- userID: this._userId,
- },
- },
- });
- }
-
- /**
- * Updates the button state and label.
- *
- * @protected
- */
- protected _updateButton(): void {
- this._button.textContent = this._getLabel();
- if (this._isActive) {
- this._listItem.classList.add("active");
- } else {
- this._listItem.classList.remove("active");
- }
- }
-
- /**
- * Returns the button label.
- */
- protected _getLabel(): string {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
-
- throw new Error("Implement me!");
- }
-
- /**
- * Returns the Ajax action name.
- */
- protected _getAjaxActionName(): string {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
-
- throw new Error("Implement me!");
- }
-
- /**
- * Handles successful Ajax requests.
- */
- _ajaxSuccess(_data: ResponseData): void {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
-
- throw new Error("Implement me!");
- }
-
- /**
- * Returns the default Ajax request data
- */
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
-
- throw new Error("Implement me!");
- }
-}
-
-Core.enableLegacyInheritance(UiUserProfileMenuItemAbstract);
-
-export = UiUserProfileMenuItemAbstract;
+++ /dev/null
-import * as Core from "../../../../../Core";
-import * as Language from "../../../../../Language";
-import { AjaxCallbackSetup, ResponseData } from "../../../../../Ajax/Data";
-import * as UiNotification from "../../../../Notification";
-import UiUserProfileMenuItemAbstract from "./Abstract";
-
-interface AjaxResponse extends ResponseData {
- returnValues: {
- following: 1 | 0;
- };
-}
-
-class UiUserProfileMenuItemFollow extends UiUserProfileMenuItemAbstract {
- constructor(userId: number, isActive: boolean) {
- super(userId, isActive);
- }
-
- protected _getLabel(): string {
- return Language.get("wcf.user.button." + (this._isActive ? "un" : "") + "follow");
- }
-
- protected _getAjaxActionName(): string {
- return this._isActive ? "unfollow" : "follow";
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- this._isActive = !!data.returnValues.following;
- this._updateButton();
-
- UiNotification.show();
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- className: "wcf\\data\\user\\follow\\UserFollowAction",
- },
- };
- }
-}
-
-Core.enableLegacyInheritance(UiUserProfileMenuItemFollow);
-
-export = UiUserProfileMenuItemFollow;
+++ /dev/null
-import * as Core from "../../../../../Core";
-import * as Language from "../../../../../Language";
-import { AjaxCallbackSetup, ResponseData } from "../../../../../Ajax/Data";
-import * as UiNotification from "../../../../Notification";
-import UiUserProfileMenuItemAbstract from "./Abstract";
-
-interface AjaxResponse extends ResponseData {
- returnValues: {
- isIgnoredUser: 1 | 0;
- };
-}
-
-class UiUserProfileMenuItemIgnore extends UiUserProfileMenuItemAbstract {
- constructor(userId: number, isActive: boolean) {
- super(userId, isActive);
- }
-
- _getLabel(): string {
- return Language.get("wcf.user.button." + (this._isActive ? "un" : "") + "ignore");
- }
-
- _getAjaxActionName(): string {
- return this._isActive ? "unignore" : "ignore";
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- this._isActive = !!data.returnValues.isIgnoredUser;
- this._updateButton();
-
- UiNotification.show();
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- className: "wcf\\data\\user\\ignore\\UserIgnoreAction",
- },
- };
- }
-}
-
-Core.enableLegacyInheritance(UiUserProfileMenuItemIgnore);
-
-export = UiUserProfileMenuItemIgnore;
+++ /dev/null
-/**
- * Provides suggestions for users, optionally supporting groups.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/User/Search/Input
- * @see module:WoltLabSuite/Core/Ui/Search/Input
- */
-
-import * as Core from "../../../Core";
-import { SearchInputOptions } from "../../Search/Data";
-import UiSearchInput from "../../Search/Input";
-
-class UiUserSearchInput extends UiSearchInput {
- constructor(element: HTMLInputElement, options: UserSearchInputOptions) {
- const includeUserGroups = Core.isPlainObject(options) && options.includeUserGroups === true;
-
- options = Core.extend(
- {
- ajax: {
- className: "wcf\\data\\user\\UserAction",
- parameters: {
- data: {
- includeUserGroups: includeUserGroups ? 1 : 0,
- },
- },
- },
- },
- options,
- );
-
- super(element, options);
- }
-
- protected createListItem(item: UserListItemData): HTMLLIElement {
- const listItem = super.createListItem(item);
- listItem.dataset.type = item.type;
-
- const box = document.createElement("div");
- box.className = "box16";
- box.innerHTML = item.type === "group" ? `<span class="icon icon16 fa-users"></span>` : item.icon;
- box.appendChild(listItem.children[0]);
- listItem.appendChild(box);
-
- return listItem;
- }
-}
-
-Core.enableLegacyInheritance(UiUserSearchInput);
-
-export = UiUserSearchInput;
-
-// https://stackoverflow.com/a/50677584/782822
-// This is a dirty hack, because the ListItemData cannot be exported for compatibility reasons.
-type FirstArgument<T> = T extends (arg1: infer U, ...args: any[]) => any ? U : never;
-
-interface UserListItemData extends FirstArgument<UiSearchInput["createListItem"]> {
- type: "user" | "group";
- icon: string;
-}
-
-interface UserSearchInputOptions extends SearchInputOptions {
- includeUserGroups?: boolean;
-}
+++ /dev/null
-/**
- * Handles the deletion of a user session.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/User/Session/Delete
- */
-
-import * as Ajax from "../../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../../Ajax/Data";
-import * as UiNotification from "../../Notification";
-import * as UiConfirmation from "../../Confirmation";
-import * as Language from "../../../Language";
-
-export class UiUserSessionDelete implements AjaxCallbackObject {
- private readonly knownElements = new Map<string, HTMLElement>();
-
- /**
- * Initializes the session delete buttons.
- */
- constructor() {
- document.querySelectorAll(".sessionDeleteButton").forEach((element: HTMLElement) => {
- if (!element.dataset.sessionId) {
- throw new Error(`No sessionId for session delete button given.`);
- }
-
- if (!this.knownElements.has(element.dataset.sessionId)) {
- element.addEventListener("click", (ev) => this.delete(element, ev));
-
- this.knownElements.set(element.dataset.sessionId, element);
- }
- });
- }
-
- /**
- * Opens the user trophy list for a specific user.
- */
- private delete(element: HTMLElement, event: MouseEvent): void {
- event.preventDefault();
-
- UiConfirmation.show({
- message: Language.get("wcf.user.security.deleteSession.confirmMessage"),
- confirm: (_parameters) => {
- Ajax.api(this, {
- sessionID: element.dataset.sessionId,
- });
- },
- });
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- const element = this.knownElements.get(data.sessionID);
-
- if (element !== undefined) {
- const sessionItem = element.closest("li");
-
- if (sessionItem !== null) {
- sessionItem.remove();
- }
- }
-
- UiNotification.show();
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- url: "index.php?delete-session/&t=" + window.SECURITY_TOKEN,
- };
- }
-}
-
-export default UiUserSessionDelete;
-
-interface AjaxResponse extends DatabaseObjectActionResponse {
- sessionID: string;
-}
+++ /dev/null
-/**
- * Handles the user trophy dialog.
- *
- * @author Joshua Ruesweg
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Ui/User/Trophy/List
- */
-
-import * as Ajax from "../../../Ajax";
-import { AjaxCallbackObject, AjaxCallbackSetup, DatabaseObjectActionResponse } from "../../../Ajax/Data";
-import * as Core from "../../../Core";
-import { DialogCallbackObject, DialogData, DialogCallbackSetup } from "../../Dialog/Data";
-import DomChangeListener from "../../../Dom/Change/Listener";
-import UiDialog from "../../Dialog";
-import UiPagination from "../../Pagination";
-
-class CacheData {
- private readonly cache = new Map<number, string>();
-
- constructor(readonly pageCount: number, readonly title: string) {}
-
- has(pageNo: number): boolean {
- return this.cache.has(pageNo);
- }
-
- get(pageNo: number): string | undefined {
- return this.cache.get(pageNo);
- }
-
- set(pageNo: number, template: string): void {
- this.cache.set(pageNo, template);
- }
-}
-
-class UiUserTrophyList implements AjaxCallbackObject, DialogCallbackObject {
- private readonly cache = new Map<number, CacheData>();
- private currentPageNo = 0;
- private currentUser = 0;
- private readonly knownElements = new WeakSet<HTMLElement>();
-
- /**
- * Initializes the user trophy list.
- */
- constructor() {
- DomChangeListener.add("WoltLabSuite/Core/Ui/User/Trophy/List", () => this.rebuild());
-
- this.rebuild();
- }
-
- /**
- * Adds event userTrophyOverlayList elements.
- */
- private rebuild(): void {
- document.querySelectorAll(".userTrophyOverlayList").forEach((element: HTMLElement) => {
- if (!this.knownElements.has(element)) {
- element.addEventListener("click", (ev) => this.open(element, ev));
-
- this.knownElements.add(element);
- }
- });
- }
-
- /**
- * Opens the user trophy list for a specific user.
- */
- private open(element: HTMLElement, event: MouseEvent): void {
- event.preventDefault();
-
- this.currentPageNo = 1;
- this.currentUser = +element.dataset.userId!;
- this.showPage();
- }
-
- /**
- * Shows the current or given page.
- */
- private showPage(pageNo?: number): void {
- if (pageNo !== undefined) {
- this.currentPageNo = pageNo;
- }
-
- const data = this.cache.get(this.currentUser);
- if (data) {
- // validate pageNo
- if (data.pageCount !== 0 && (this.currentPageNo < 1 || this.currentPageNo > data.pageCount)) {
- throw new RangeError(`pageNo must be between 1 and ${data.pageCount} (${this.currentPageNo} given).`);
- }
- }
-
- if (data && data.has(this.currentPageNo)) {
- const dialog = UiDialog.open(this, data.get(this.currentPageNo)) as DialogData;
- UiDialog.setTitle("userTrophyListOverlay", data.title);
-
- if (data.pageCount > 1) {
- const element = dialog.content.querySelector(".jsPagination") as HTMLElement;
- if (element !== null) {
- new UiPagination(element, {
- activePage: this.currentPageNo,
- maxPage: data.pageCount,
- callbackSwitch: this.showPage.bind(this),
- });
- }
- }
- } else {
- Ajax.api(this, {
- parameters: {
- pageNo: this.currentPageNo,
- userID: this.currentUser,
- },
- });
- }
- }
-
- _ajaxSuccess(data: AjaxResponse): void {
- let cache: CacheData;
- if (data.returnValues.pageCount !== undefined) {
- cache = new CacheData(+data.returnValues.pageCount, data.returnValues.title!);
- this.cache.set(this.currentUser, cache);
- } else {
- cache = this.cache.get(this.currentUser)!;
- }
-
- cache.set(this.currentPageNo, data.returnValues.template);
- this.showPage();
- }
-
- _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
- return {
- data: {
- actionName: "getGroupedUserTrophyList",
- className: "wcf\\data\\user\\trophy\\UserTrophyAction",
- },
- };
- }
-
- _dialogSetup(): ReturnType<DialogCallbackSetup> {
- return {
- id: "userTrophyListOverlay",
- options: {
- title: "",
- },
- source: null,
- };
- }
-}
-
-Core.enableLegacyInheritance(UiUserTrophyList);
-
-export = UiUserTrophyList;
-
-interface AjaxResponse extends DatabaseObjectActionResponse {
- returnValues: {
- pageCount?: number;
- template: string;
- title?: string;
- };
-}
+++ /dev/null
-/**
- * Uploads file via AJAX.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module Upload (alias)
- * @module WoltLabSuite/Core/Upload
- */
-
-import { RequestOptions, ResponseData } from "./Ajax/Data";
-import AjaxRequest from "./Ajax/Request";
-import * as Core from "./Core";
-import DomChangeListener from "./Dom/Change/Listener";
-import * as Language from "./Language";
-import { FileCollection, FileElements, FileLikeObject, UploadId, UploadOptions } from "./Upload/Data";
-
-abstract class Upload<TOptions extends UploadOptions = UploadOptions> {
- protected _button = document.createElement("p");
- protected readonly _buttonContainer: HTMLElement;
- protected readonly _fileElements: FileElements[] = [];
- protected _fileUpload = document.createElement("input");
- protected _internalFileId = 0;
- protected readonly _multiFileUploadIds: unknown[] = [];
- protected readonly _options: TOptions;
- protected readonly _target: HTMLElement;
-
- protected constructor(buttonContainerId: string, targetId: string, options: Partial<TOptions>) {
- options = options || {};
- if (!options.className) {
- throw new Error("Missing class name.");
- }
-
- // set default options
- this._options = Core.extend(
- {
- // name of the PHP action
- action: "upload",
- // is true if multiple files can be uploaded at once
- multiple: false,
- // array of acceptable file types, null if any file type is acceptable
- acceptableFiles: null,
- // name of the upload field
- name: "__files[]",
- // is true if every file from a multi-file selection is uploaded in its own request
- singleFileRequests: false,
- // url for uploading file
- url: `index.php?ajax-upload/&t=${window.SECURITY_TOKEN}`,
- },
- options,
- ) as TOptions;
-
- this._options.url = Core.convertLegacyUrl(this._options.url);
- if (this._options.url.indexOf("index.php") === 0) {
- this._options.url = window.WSC_API_URL + this._options.url;
- }
-
- const buttonContainer = document.getElementById(buttonContainerId);
- if (buttonContainer === null) {
- throw new Error(`Element id '${buttonContainerId}' is unknown.`);
- }
- this._buttonContainer = buttonContainer;
-
- const target = document.getElementById(targetId);
- if (target === null) {
- throw new Error(`Element id '${targetId}' is unknown.`);
- }
- this._target = target;
-
- if (
- options.multiple &&
- this._target.nodeName !== "UL" &&
- this._target.nodeName !== "OL" &&
- this._target.nodeName !== "TBODY"
- ) {
- throw new Error("Target element has to be list or table body if uploading multiple files is supported.");
- }
-
- this._createButton();
- }
-
- /**
- * Creates the upload button.
- */
- protected _createButton(): void {
- this._fileUpload = document.createElement("input");
- this._fileUpload.type = "file";
- this._fileUpload.name = this._options.name;
- if (this._options.multiple) {
- this._fileUpload.multiple = true;
- }
- if (this._options.acceptableFiles !== null) {
- this._fileUpload.accept = this._options.acceptableFiles.join(",");
- }
- this._fileUpload.addEventListener("change", (ev) => this._upload(ev));
-
- this._button = document.createElement("p");
- this._button.className = "button uploadButton";
- this._button.setAttribute("role", "button");
- this._fileUpload.addEventListener("focus", () => {
- if (this._fileUpload.classList.contains("focus-visible")) {
- this._button.classList.add("active");
- }
- });
- this._fileUpload.addEventListener("blur", () => {
- this._button.classList.remove("active");
- });
-
- const span = document.createElement("span");
- span.textContent = Language.get("wcf.global.button.upload");
- this._button.appendChild(span);
-
- this._button.insertAdjacentElement("afterbegin", this._fileUpload);
-
- this._insertButton();
-
- DomChangeListener.trigger();
- }
-
- /**
- * Creates the document element for an uploaded file.
- */
- protected _createFileElement(file: File | FileLikeObject): HTMLElement {
- const progress = document.createElement("progress");
- progress.max = 100;
-
- let element: HTMLElement;
- switch (this._target.nodeName) {
- case "OL":
- case "UL":
- element = document.createElement("li");
- element.innerText = file.name;
- element.appendChild(progress);
- this._target.appendChild(element);
-
- return element;
-
- case "TBODY":
- return this._createFileTableRow(file);
-
- default:
- element = document.createElement("p");
- element.appendChild(progress);
- this._target.appendChild(element);
-
- return element;
- }
- }
-
- /**
- * Creates the document elements for uploaded files.
- */
- protected _createFileElements(files: FileCollection): number | null {
- if (!files.length) {
- return null;
- }
-
- const elements: FileElements = [];
- Array.from(files).forEach((file) => {
- const fileElement = this._createFileElement(file);
- if (!fileElement.classList.contains("uploadFailed")) {
- fileElement.dataset.filename = file.name;
- fileElement.dataset.internalFileId = (this._internalFileId++).toString();
- elements.push(fileElement);
- }
- });
-
- const uploadId = this._fileElements.length;
- this._fileElements.push(elements);
-
- DomChangeListener.trigger();
- return uploadId;
- }
-
- protected _createFileTableRow(_file: File | FileLikeObject): HTMLTableRowElement {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
-
- throw new Error("Has to be implemented in subclass.");
- }
-
- /**
- * Handles a failed file upload.
- */
- protected _failure(
- _uploadId: number,
- _data: ResponseData,
- _responseText: string,
- _xhr: XMLHttpRequest,
- _requestOptions: RequestOptions,
- ): boolean {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
-
- return true;
- }
-
- /**
- * Return additional parameters for upload requests.
- */
- protected _getParameters(): ArbitraryObject {
- return {};
- }
-
- /**
- * Return additional form data for upload requests.
- *
- * @since 5.2
- */
- protected _getFormData(): ArbitraryObject {
- return {};
- }
-
- /**
- * Inserts the created button to upload files into the button container.
- */
- protected _insertButton(): void {
- this._buttonContainer.insertAdjacentElement("afterbegin", this._button);
- }
-
- /**
- * Updates the progress of an upload.
- */
- protected _progress(uploadId: number, event: ProgressEvent): void {
- const percentComplete = Math.round((event.loaded / event.total) * 100);
- this._fileElements[uploadId].forEach((element) => {
- const progress = element.querySelector("progress");
- if (progress) {
- progress.value = percentComplete;
- }
- });
- }
-
- /**
- * Removes the button to upload files.
- */
- protected _removeButton(): void {
- this._button.remove();
- DomChangeListener.trigger();
- }
-
- /**
- * Handles a successful file upload.
- */
- protected _success(
- _uploadId: number,
- _data: ResponseData,
- _responseText: string,
- _xhr: XMLHttpRequestEventTarget,
- _requestOptions: RequestOptions,
- ): void {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
- }
-
- /**
- * File input change callback to upload files.
- */
- protected _upload(event: Event): UploadId;
- protected _upload(event: null, file: File): UploadId;
- protected _upload(event: null, file: null, blob: Blob): UploadId;
- protected _upload(event: Event | null, file?: File | null, blob?: Blob | null): UploadId;
- // This duplication is on purpose, the signature below is implementation private.
- protected _upload(event: Event | null, file?: File | null, blob?: Blob | null): UploadId {
- // remove failed upload elements first
- this._target.querySelectorAll(".uploadFailed").forEach((el) => el.remove());
-
- let uploadId: UploadId = null;
- let files: (File | FileLikeObject)[] = [];
- if (file) {
- files.push(file);
- } else if (blob) {
- let fileExtension = "";
- switch (blob.type) {
- case "image/jpeg":
- fileExtension = "jpg";
- break;
- case "image/gif":
- fileExtension = "gif";
- break;
- case "image/png":
- fileExtension = "png";
- break;
- case "image/webp":
- fileExtension = "webp";
- break;
- }
- files.push({
- name: `pasted-from-clipboard.${fileExtension}`,
- });
- } else {
- files = Array.from(this._fileUpload.files!);
- }
-
- if (files.length && this.validateUpload(files)) {
- if (this._options.singleFileRequests) {
- uploadId = [];
- files.forEach((file) => {
- const localUploadId = this._uploadFiles([file], blob) as number;
- if (files.length !== 1) {
- this._multiFileUploadIds.push(localUploadId);
- }
-
- (uploadId as number[]).push(localUploadId);
- });
- } else {
- uploadId = this._uploadFiles(files, blob);
- }
- }
- // re-create upload button to effectively reset the 'files'
- // property of the input element
- this._removeButton();
- this._createButton();
-
- return uploadId;
- }
-
- /**
- * Validates the upload before uploading them.
- *
- * @since 5.2
- */
- validateUpload(_files: FileCollection): boolean {
- // This should be an abstract method, but cannot be marked as such for backwards compatibility.
-
- return true;
- }
-
- /**
- * Sends the request to upload files.
- */
- protected _uploadFiles(files: FileCollection, blob?: Blob | null): number | null {
- const uploadId = this._createFileElements(files)!;
-
- // no more files left, abort
- if (!this._fileElements[uploadId].length) {
- return null;
- }
-
- const formData = new FormData();
- for (let i = 0, length = files.length; i < length; i++) {
- if (this._fileElements[uploadId][i]) {
- const internalFileId = this._fileElements[uploadId][i].dataset.internalFileId!;
- if (blob) {
- formData.append(`__files[${internalFileId}]`, blob, files[i].name);
- } else {
- formData.append(`__files[${internalFileId}]`, files[i] as File);
- }
- }
- }
- formData.append("actionName", this._options.action);
- formData.append("className", this._options.className);
- if (this._options.action === "upload") {
- formData.append("interfaceName", "wcf\\data\\IUploadAction");
- }
-
- // recursively append additional parameters to form data
- function appendFormData(parameters: object | null, prefix?: string): void {
- if (parameters === null) {
- return;
- }
-
- prefix = prefix || "";
-
- Object.entries(parameters).forEach(([key, value]) => {
- if (typeof value === "object") {
- const newPrefix = prefix!.length === 0 ? key : `${prefix!}[${key}]`;
- appendFormData(value, newPrefix);
- } else {
- const dataName = prefix!.length === 0 ? key : `${prefix!}[${key}]`;
- formData.append(dataName, value);
- }
- });
- }
-
- appendFormData(this._getParameters(), "parameters");
- appendFormData(this._getFormData());
-
- const request = new AjaxRequest({
- data: formData,
- contentType: false,
- failure: this._failure.bind(this, uploadId),
- silent: true,
- success: this._success.bind(this, uploadId),
- uploadProgress: this._progress.bind(this, uploadId),
- url: this._options.url,
- withCredentials: true,
- });
- request.sendRequest();
-
- return uploadId;
- }
-
- /**
- * Returns true if there are any pending uploads handled by this
- * upload manager.
- *
- * @since 5.2
- */
- public hasPendingUploads(): boolean {
- return (
- this._fileElements.find((elements) => {
- return elements.find((el) => el.querySelector("progress") !== null);
- }) !== undefined
- );
- }
-
- /**
- * Uploads the given file blob.
- */
- uploadBlob(blob: Blob): number {
- return this._upload(null, null, blob) as number;
- }
-
- /**
- * Uploads the given file.
- */
- uploadFile(file: File): number {
- return this._upload(null, file) as number;
- }
-}
-
-Core.enableLegacyInheritance(Upload);
-
-export = Upload;
+++ /dev/null
-export interface UploadOptions {
- // name of the PHP action
- action: string;
- className: string;
- // is true if multiple files can be uploaded at once
- multiple: boolean;
- // array of acceptable file types, null if any file type is acceptable
- acceptableFiles: string[] | null;
- // name of the upload field
- name: string;
- // is true if every file from a multi-file selection is uploaded in its own request
- singleFileRequests: boolean;
- // url for uploading file
- url: string;
-}
-
-export type FileElements = HTMLElement[];
-
-export type FileLikeObject = { name: string };
-
-export type FileCollection = File[] | FileLikeObject[] | FileList;
-
-export type UploadId = number | number[] | null;
+++ /dev/null
-/**
- * Provides data of the active user.
- *
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module User (alias)
- * @module WoltLabSuite/Core/User
- */
-
-class User {
- constructor(readonly userId: number, readonly username: string, readonly link: string) {}
-}
-
-let user: User;
-
-export = {
- /**
- * Returns the link to the active user's profile or an empty string
- * if the active user is a guest.
- */
- getLink(): string {
- return user.link;
- },
-
- /**
- * Initializes the user object.
- */
- init(userId: number, username: string, link: string): void {
- if (user) {
- throw new Error("User has already been initialized.");
- }
-
- user = new User(userId, username, link);
- },
-
- get userId(): number {
- return user.userId;
- },
-
- get username(): string {
- return user.username;
- },
-};
+++ /dev/null
-/**
- * Handles loading and initialization of Facebook's JavaScript SDK.
- *
- * @author Tim Duesterhus
- * @copyright 2001-2020 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @module WoltLabSuite/Core/Wrapper/FacebookSdk
- */
-
-import "https://connect.facebook.net/en_US/sdk.js";
-
-// see: https://developers.facebook.com/docs/javascript/reference/FB.init/v7.0
-FB.init({
- version: "v7.0",
-});
-
-export = FB;
+++ /dev/null
-export interface LanguageData {
- title: string;
- file: string;
-}
-export type LanguageIdentifier = string;
-export type PrismMeta = Record<LanguageIdentifier, LanguageData>;
-// prettier-ignore
-/*!START*/ const metadata: PrismMeta = {"markup":{"title":"Markup","file":"markup"},"html":{"title":"HTML","file":"markup"},"xml":{"title":"XML","file":"markup"},"svg":{"title":"SVG","file":"markup"},"mathml":{"title":"MathML","file":"markup"},"ssml":{"title":"SSML","file":"markup"},"atom":{"title":"Atom","file":"markup"},"rss":{"title":"RSS","file":"markup"},"css":{"title":"CSS","file":"css"},"clike":{"title":"C-like","file":"clike"},"javascript":{"title":"JavaScript","file":"javascript"},"abap":{"title":"ABAP","file":"abap"},"abnf":{"title":"ABNF","file":"abnf"},"actionscript":{"title":"ActionScript","file":"actionscript"},"ada":{"title":"Ada","file":"ada"},"agda":{"title":"Agda","file":"agda"},"al":{"title":"AL","file":"al"},"antlr4":{"title":"ANTLR4","file":"antlr4"},"apacheconf":{"title":"Apache Configuration","file":"apacheconf"},"apl":{"title":"APL","file":"apl"},"applescript":{"title":"AppleScript","file":"applescript"},"aql":{"title":"AQL","file":"aql"},"arduino":{"title":"Arduino","file":"arduino"},"arff":{"title":"ARFF","file":"arff"},"asciidoc":{"title":"AsciiDoc","file":"asciidoc"},"aspnet":{"title":"ASP.NET (C#)","file":"aspnet"},"asm6502":{"title":"6502 Assembly","file":"asm6502"},"autohotkey":{"title":"AutoHotkey","file":"autohotkey"},"autoit":{"title":"AutoIt","file":"autoit"},"bash":{"title":"Bash","file":"bash"},"basic":{"title":"BASIC","file":"basic"},"batch":{"title":"Batch","file":"batch"},"bbcode":{"title":"BBcode","file":"bbcode"},"bison":{"title":"Bison","file":"bison"},"bnf":{"title":"BNF","file":"bnf"},"brainfuck":{"title":"Brainfuck","file":"brainfuck"},"brightscript":{"title":"BrightScript","file":"brightscript"},"bro":{"title":"Bro","file":"bro"},"c":{"title":"C","file":"c"},"csharp":{"title":"C#","file":"csharp"},"cpp":{"title":"C++","file":"cpp"},"cil":{"title":"CIL","file":"cil"},"clojure":{"title":"Clojure","file":"clojure"},"cmake":{"title":"CMake","file":"cmake"},"coffeescript":{"title":"CoffeeScript","file":"coffeescript"},"concurnas":{"title":"Concurnas","file":"concurnas"},"csp":{"title":"Content-Security-Policy","file":"csp"},"crystal":{"title":"Crystal","file":"crystal"},"css-extras":{"title":"CSS Extras","file":"css-extras"},"cypher":{"title":"Cypher","file":"cypher"},"d":{"title":"D","file":"d"},"dart":{"title":"Dart","file":"dart"},"dax":{"title":"DAX","file":"dax"},"dhall":{"title":"Dhall","file":"dhall"},"diff":{"title":"Diff","file":"diff"},"django":{"title":"Django/Jinja2","file":"django"},"dns-zone-file":{"title":"DNS zone file","file":"dns-zone-file"},"docker":{"title":"Docker","file":"docker"},"ebnf":{"title":"EBNF","file":"ebnf"},"editorconfig":{"title":"EditorConfig","file":"editorconfig"},"eiffel":{"title":"Eiffel","file":"eiffel"},"ejs":{"title":"EJS","file":"ejs"},"elixir":{"title":"Elixir","file":"elixir"},"elm":{"title":"Elm","file":"elm"},"etlua":{"title":"Embedded Lua templating","file":"etlua"},"erb":{"title":"ERB","file":"erb"},"erlang":{"title":"Erlang","file":"erlang"},"excel-formula":{"title":"Excel Formula","file":"excel-formula"},"fsharp":{"title":"F#","file":"fsharp"},"factor":{"title":"Factor","file":"factor"},"firestore-security-rules":{"title":"Firestore security rules","file":"firestore-security-rules"},"flow":{"title":"Flow","file":"flow"},"fortran":{"title":"Fortran","file":"fortran"},"ftl":{"title":"FreeMarker Template Language","file":"ftl"},"gml":{"title":"GameMaker Language","file":"gml"},"gcode":{"title":"G-code","file":"gcode"},"gdscript":{"title":"GDScript","file":"gdscript"},"gedcom":{"title":"GEDCOM","file":"gedcom"},"gherkin":{"title":"Gherkin","file":"gherkin"},"git":{"title":"Git","file":"git"},"glsl":{"title":"GLSL","file":"glsl"},"go":{"title":"Go","file":"go"},"graphql":{"title":"GraphQL","file":"graphql"},"groovy":{"title":"Groovy","file":"groovy"},"haml":{"title":"Haml","file":"haml"},"handlebars":{"title":"Handlebars","file":"handlebars"},"haskell":{"title":"Haskell","file":"haskell"},"haxe":{"title":"Haxe","file":"haxe"},"hcl":{"title":"HCL","file":"hcl"},"hlsl":{"title":"HLSL","file":"hlsl"},"http":{"title":"HTTP","file":"http"},"hpkp":{"title":"HTTP Public-Key-Pins","file":"hpkp"},"hsts":{"title":"HTTP Strict-Transport-Security","file":"hsts"},"ichigojam":{"title":"IchigoJam","file":"ichigojam"},"icon":{"title":"Icon","file":"icon"},"ignore":{"title":".ignore","file":"ignore"},"gitignore":{"title":".gitignore","file":"ignore"},"hgignore":{"title":".hgignore","file":"ignore"},"npmignore":{"title":".npmignore","file":"ignore"},"inform7":{"title":"Inform 7","file":"inform7"},"ini":{"title":"Ini","file":"ini"},"io":{"title":"Io","file":"io"},"j":{"title":"J","file":"j"},"java":{"title":"Java","file":"java"},"javadoc":{"title":"JavaDoc","file":"javadoc"},"javadoclike":{"title":"JavaDoc-like","file":"javadoclike"},"javastacktrace":{"title":"Java stack trace","file":"javastacktrace"},"jolie":{"title":"Jolie","file":"jolie"},"jq":{"title":"JQ","file":"jq"},"jsdoc":{"title":"JSDoc","file":"jsdoc"},"js-extras":{"title":"JS Extras","file":"js-extras"},"json":{"title":"JSON","file":"json"},"json5":{"title":"JSON5","file":"json5"},"jsonp":{"title":"JSONP","file":"jsonp"},"jsstacktrace":{"title":"JS stack trace","file":"jsstacktrace"},"js-templates":{"title":"JS Templates","file":"js-templates"},"julia":{"title":"Julia","file":"julia"},"keyman":{"title":"Keyman","file":"keyman"},"kotlin":{"title":"Kotlin","file":"kotlin"},"kts":{"title":"Kotlin Script","file":"kotlin"},"latex":{"title":"LaTeX","file":"latex"},"tex":{"title":"TeX","file":"latex"},"context":{"title":"ConTeXt","file":"latex"},"latte":{"title":"Latte","file":"latte"},"less":{"title":"Less","file":"less"},"lilypond":{"title":"LilyPond","file":"lilypond"},"liquid":{"title":"Liquid","file":"liquid"},"lisp":{"title":"Lisp","file":"lisp"},"livescript":{"title":"LiveScript","file":"livescript"},"llvm":{"title":"LLVM IR","file":"llvm"},"lolcode":{"title":"LOLCODE","file":"lolcode"},"lua":{"title":"Lua","file":"lua"},"makefile":{"title":"Makefile","file":"makefile"},"markdown":{"title":"Markdown","file":"markdown"},"markup-templating":{"title":"Markup templating","file":"markup-templating"},"matlab":{"title":"MATLAB","file":"matlab"},"mel":{"title":"MEL","file":"mel"},"mizar":{"title":"Mizar","file":"mizar"},"monkey":{"title":"Monkey","file":"monkey"},"moonscript":{"title":"MoonScript","file":"moonscript"},"n1ql":{"title":"N1QL","file":"n1ql"},"n4js":{"title":"N4JS","file":"n4js"},"nand2tetris-hdl":{"title":"Nand To Tetris HDL","file":"nand2tetris-hdl"},"nasm":{"title":"NASM","file":"nasm"},"neon":{"title":"NEON","file":"neon"},"nginx":{"title":"nginx","file":"nginx"},"nim":{"title":"Nim","file":"nim"},"nix":{"title":"Nix","file":"nix"},"nsis":{"title":"NSIS","file":"nsis"},"objectivec":{"title":"Objective-C","file":"objectivec"},"ocaml":{"title":"OCaml","file":"ocaml"},"opencl":{"title":"OpenCL","file":"opencl"},"oz":{"title":"Oz","file":"oz"},"parigp":{"title":"PARI/GP","file":"parigp"},"parser":{"title":"Parser","file":"parser"},"pascal":{"title":"Pascal","file":"pascal"},"pascaligo":{"title":"Pascaligo","file":"pascaligo"},"pcaxis":{"title":"PC-Axis","file":"pcaxis"},"peoplecode":{"title":"PeopleCode","file":"peoplecode"},"perl":{"title":"Perl","file":"perl"},"php":{"title":"PHP","file":"php"},"phpdoc":{"title":"PHPDoc","file":"phpdoc"},"php-extras":{"title":"PHP Extras","file":"php-extras"},"plsql":{"title":"PL/SQL","file":"plsql"},"powerquery":{"title":"PowerQuery","file":"powerquery"},"powershell":{"title":"PowerShell","file":"powershell"},"processing":{"title":"Processing","file":"processing"},"prolog":{"title":"Prolog","file":"prolog"},"properties":{"title":".properties","file":"properties"},"protobuf":{"title":"Protocol Buffers","file":"protobuf"},"pug":{"title":"Pug","file":"pug"},"puppet":{"title":"Puppet","file":"puppet"},"pure":{"title":"Pure","file":"pure"},"purebasic":{"title":"PureBasic","file":"purebasic"},"python":{"title":"Python","file":"python"},"q":{"title":"Q (kdb+ database)","file":"q"},"qml":{"title":"QML","file":"qml"},"qore":{"title":"Qore","file":"qore"},"r":{"title":"R","file":"r"},"racket":{"title":"Racket","file":"racket"},"jsx":{"title":"React JSX","file":"jsx"},"tsx":{"title":"React TSX","file":"tsx"},"reason":{"title":"Reason","file":"reason"},"regex":{"title":"Regex","file":"regex"},"renpy":{"title":"Ren'py","file":"renpy"},"rest":{"title":"reST (reStructuredText)","file":"rest"},"rip":{"title":"Rip","file":"rip"},"roboconf":{"title":"Roboconf","file":"roboconf"},"robotframework":{"title":"Robot Framework","file":"robotframework"},"ruby":{"title":"Ruby","file":"ruby"},"rust":{"title":"Rust","file":"rust"},"sas":{"title":"SAS","file":"sas"},"sass":{"title":"Sass (Sass)","file":"sass"},"scss":{"title":"Sass (Scss)","file":"scss"},"scala":{"title":"Scala","file":"scala"},"scheme":{"title":"Scheme","file":"scheme"},"shell-session":{"title":"Shell session","file":"shell-session"},"smali":{"title":"Smali","file":"smali"},"smalltalk":{"title":"Smalltalk","file":"smalltalk"},"smarty":{"title":"Smarty","file":"smarty"},"solidity":{"title":"Solidity (Ethereum)","file":"solidity"},"solution-file":{"title":"Solution file","file":"solution-file"},"soy":{"title":"Soy (Closure Template)","file":"soy"},"sparql":{"title":"SPARQL","file":"sparql"},"splunk-spl":{"title":"Splunk SPL","file":"splunk-spl"},"sqf":{"title":"SQF: Status Quo Function (Arma 3)","file":"sqf"},"sql":{"title":"SQL","file":"sql"},"iecst":{"title":"Structured Text (IEC 61131-3)","file":"iecst"},"stylus":{"title":"Stylus","file":"stylus"},"swift":{"title":"Swift","file":"swift"},"t4-templating":{"title":"T4 templating","file":"t4-templating"},"t4-cs":{"title":"T4 Text Templates (C#)","file":"t4-cs"},"t4-vb":{"title":"T4 Text Templates (VB)","file":"t4-vb"},"tap":{"title":"TAP","file":"tap"},"tcl":{"title":"Tcl","file":"tcl"},"tt2":{"title":"Template Toolkit 2","file":"tt2"},"textile":{"title":"Textile","file":"textile"},"toml":{"title":"TOML","file":"toml"},"turtle":{"title":"Turtle","file":"turtle"},"twig":{"title":"Twig","file":"twig"},"typescript":{"title":"TypeScript","file":"typescript"},"unrealscript":{"title":"UnrealScript","file":"unrealscript"},"vala":{"title":"Vala","file":"vala"},"vbnet":{"title":"VB.Net","file":"vbnet"},"velocity":{"title":"Velocity","file":"velocity"},"verilog":{"title":"Verilog","file":"verilog"},"vhdl":{"title":"VHDL","file":"vhdl"},"vim":{"title":"vim","file":"vim"},"visual-basic":{"title":"Visual Basic","file":"visual-basic"},"vba":{"title":"VBA","file":"visual-basic"},"warpscript":{"title":"WarpScript","file":"warpscript"},"wasm":{"title":"WebAssembly","file":"wasm"},"wiki":{"title":"Wiki markup","file":"wiki"},"xeora":{"title":"Xeora","file":"xeora"},"xml-doc":{"title":"XML doc (.net)","file":"xml-doc"},"xojo":{"title":"Xojo (REALbasic)","file":"xojo"},"xquery":{"title":"XQuery","file":"xquery"},"yaml":{"title":"YAML","file":"yaml"},"yang":{"title":"YANG","file":"yang"},"zig":{"title":"Zig","file":"zig"}} /*!END*/
-export default metadata;