Move TypeScript sources into ts/ from wcfsetup/install/files/ts/
authorTim Düsterhus <duesterhus@woltlab.com>
Fri, 15 Jan 2021 08:46:42 +0000 (09:46 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Fri, 15 Jan 2021 08:49:24 +0000 (09:49 +0100)
480 files changed:
.github/workflows/codestyle.yml
global.d.ts
ts/WoltLabSuite/Core/Acp/Bootstrap.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Data.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/ExcludedPackages.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/OptionalPackages.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/RequiredPackages.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Article/Add.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Article/InlineEditor.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Box/Add.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Box/Controller/Handler.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Box/Copy.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Box/Handler.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Media.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Page.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Devtools/Notification/Test.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Installation/Confirmation.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Pip/Entry/List.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/QuickSetup.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Menu/Item/Handler.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Option/EmailSmtpTest.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteGenerator.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteTest.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Package/PrepareInstallation.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Package/Search.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Page/Add.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Page/BoxOrder.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Page/Copy.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Page/Menu.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Template/Group/Copy.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/User/Editor.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Acp/Ui/Worker.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ajax.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ajax/Data.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ajax/Jsonp.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ajax/Request.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ajax/Status.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/BackgroundQueue.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Bbcode/Code.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Bbcode/Collapsible.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Bbcode/Spoiler.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Bootstrap.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/BootstrapFrontend.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/CallbackList.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Clipboard.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/ColorUtil.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Controller/Captcha.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Controller/Clipboard.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Controller/Condition/Page/Dependence.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Controller/Map/Route/Planner.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Controller/Media/List.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Controller/Notice/Dismiss.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Controller/Popover.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Controller/Style/Changer.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Controller/User/Notification/Settings.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Core.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Date/Picker.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Date/Time/Relative.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Date/Util.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Devtools.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Dictionary.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Dom/Change/Listener.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Dom/Traverse.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Dom/Util.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Environment.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Event/Handler.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Event/Key.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/FileUtil.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Container/SuffixFormField.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Data.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Dialog.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Acl.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Button.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Captcha.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Checkboxes.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Checked.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Label.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Rating.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Date.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Abstract.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Default.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Tab.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/TabMenu.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Empty.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/IsNotClicked.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Manager.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/NonEmpty.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Value.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Field.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/ItemList.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Language/ContentLanguage.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/RadioButton.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/SimpleAcl.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Tag.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/User.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Value.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/ValueI18n.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Attachment.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Poll.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Form/Builder/Manager.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/I18n/Plural.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Image/ExifUtil.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Image/ImageUtil.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Image/Resizer.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Language.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Language/Chooser.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Language/Input.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Language/Text.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/List.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Media/Clipboard.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Media/Data.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Media/Editor.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Media/List/Upload.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Media/Manager/Base.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Media/Manager/Editor.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Media/Manager/Search.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Media/Manager/Select.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Media/Replace.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Media/Upload.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Notification/Handler.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/NumberUtil.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/ObjectMap.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Permission.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Prism.d.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Prism.js [new file with mode: 0644]
ts/WoltLabSuite/Core/Prism/Helper.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/StringUtil.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Template.grammar.d.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Template.grammar.jison [new file with mode: 0644]
ts/WoltLabSuite/Core/Template.grammar.js [new file with mode: 0644]
ts/WoltLabSuite/Core/Template.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Timer/Repeating.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Acl/Simple.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Alignment.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Article/MarkAllAsRead.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Article/Search.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/CloseOverlay.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Color/Picker.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Comment/Add.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Comment/Edit.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Comment/Response/Add.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Comment/Response/Edit.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Confirmation.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Dialog.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Dialog/Data.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/DragAndDrop.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Dropdown/Builder.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Dropdown/Data.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Dropdown/Reusable.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/File/Data.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/File/Delete.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/File/Upload.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/FlexibleMenu.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/ItemList.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/ItemList/Filter.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/ItemList/Static.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/ItemList/User.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Like/Handler.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Message/InlineEditor.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Message/Manager.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Message/Quote.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Message/Reply.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Message/Share.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Message/TwitterEmbed.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Message/UserConsent.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Mobile.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Notification.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Page/Action.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Page/Header/Fixed.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Page/Header/Menu.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Page/JumpTo.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Page/Menu/Abstract.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Page/Search.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Page/Search/Handler.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Page/Search/Input.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Pagination.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Poll/Editor.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Reaction/Data.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Reaction/Profile/Loader.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Article.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Autosave.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Code.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/DragAndDrop.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Editor.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Format.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Html.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Link.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Mention.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Metacode.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Page.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/PseudoHeader.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Quote.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Spoiler.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Redactor/Table.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Screen.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Scroll.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Search/Data.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Search/Input.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Search/Page.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Smiley/Insert.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Sortable/List.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Style/FontAwesome.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Suggestion.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/TabMenu.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/TabMenu/Simple.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Toggle/Input.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/Tooltip.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/Activity/Recent.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Delete.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Upload.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/Editor.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/Ignore.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/List.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/Multifactor/Totp/Qr.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/PasswordStrength.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Abstract.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Follow.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Ignore.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/Search/Input.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/Session/Delete.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Ui/User/Trophy/List.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Upload.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Upload/Data.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/User.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Wrapper/FacebookSdk.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/prism-meta.ts [new file with mode: 0644]
tsconfig.json
wcfsetup/install/files/js/3rdParty/prism/build.js
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Bootstrap.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Data.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/ExcludedPackages.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/OptionalPackages.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/RequiredPackages.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Article/Add.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Article/InlineEditor.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Add.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Controller/Handler.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Copy.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Handler.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Media.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Page.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Notification/Test.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Installation/Confirmation.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Pip/Entry/List.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/QuickSetup.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Menu/Item/Handler.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Option/EmailSmtpTest.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteGenerator.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteTest.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Package/PrepareInstallation.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Package/Search.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/Add.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/BoxOrder.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/Copy.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/Menu.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Template/Group/Copy.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/User/Editor.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Worker.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Data.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Jsonp.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Request.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Status.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/BackgroundQueue.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Bbcode/Code.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Bbcode/Collapsible.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Bbcode/Spoiler.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Bootstrap.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/BootstrapFrontend.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/CallbackList.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Clipboard.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/ColorUtil.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Captcha.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Clipboard.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Condition/Page/Dependence.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Map/Route/Planner.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Media/List.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Notice/Dismiss.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Popover.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Style/Changer.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/User/Notification/Settings.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Core.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Time/Relative.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Util.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Devtools.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Dictionary.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Change/Listener.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Traverse.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Environment.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Event/Handler.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Event/Key.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/FileUtil.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Container/SuffixFormField.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Data.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Dialog.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Acl.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Button.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Captcha.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Checkboxes.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Checked.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Label.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Rating.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Date.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Abstract.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Default.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Tab.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/TabMenu.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Empty.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/IsNotClicked.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Manager.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/NonEmpty.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Value.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Field.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/ItemList.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Language/ContentLanguage.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/RadioButton.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/SimpleAcl.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Tag.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/User.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Value.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/ValueI18n.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Attachment.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Poll.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Manager.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/I18n/Plural.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Image/ExifUtil.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Image/ImageUtil.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Image/Resizer.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Language.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Chooser.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Input.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Text.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/List.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Clipboard.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Data.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Editor.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Media/List/Upload.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Base.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Editor.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Search.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Select.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Replace.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Upload.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Notification/Handler.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/NumberUtil.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/ObjectMap.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Permission.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Prism.d.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Prism.js [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Prism/Helper.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/StringUtil.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Template.grammar.d.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Template.grammar.jison [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Template.grammar.js [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Template.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Timer/Repeating.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Acl/Simple.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Alignment.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Article/MarkAllAsRead.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Article/Search.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/CloseOverlay.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Color/Picker.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Add.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Edit.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Response/Add.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Response/Edit.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Confirmation.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog/Data.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/DragAndDrop.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Builder.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Data.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Reusable.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/File/Data.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/File/Delete.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/File/Upload.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/FlexibleMenu.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList/Filter.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList/Static.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList/User.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Like/Handler.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/InlineEditor.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Manager.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Quote.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Reply.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Share.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/TwitterEmbed.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/UserConsent.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Mobile.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Notification.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Action.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Header/Fixed.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Header/Menu.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/JumpTo.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Menu/Abstract.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Search.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Search/Handler.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Search/Input.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Pagination.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Poll/Editor.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Data.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Profile/Loader.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Article.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Code.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/DragAndDrop.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Editor.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Format.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Html.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Link.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Mention.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Metacode.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Page.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/PseudoHeader.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Quote.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Spoiler.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Table.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Screen.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Scroll.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Data.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Input.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Page.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Smiley/Insert.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Sortable/List.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Style/FontAwesome.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Suggestion.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/TabMenu.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/TabMenu/Simple.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Toggle/Input.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Tooltip.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Activity/Recent.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Delete.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Upload.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Editor.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Ignore.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/List.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Multifactor/Totp/Qr.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/PasswordStrength.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Abstract.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Follow.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Ignore.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Search/Input.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Session/Delete.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Trophy/List.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Upload.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Upload/Data.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/User.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/Wrapper/FacebookSdk.ts [deleted file]
wcfsetup/install/files/ts/WoltLabSuite/Core/prism-meta.ts [deleted file]

index 61ecf78d3f880a8531e8d92b1676aa0f35bd53d0..b038cd84122aac1fa11b5a3baf1a1c5cfa611394 100644 (file)
@@ -24,7 +24,7 @@ jobs:
     - 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: |
index 666f2cd854df735c828def4c6e6828ef00105244..ed6b3fb0291b7808df8f3312f80846b3748b52a2 100644 (file)
@@ -1,11 +1,11 @@
-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 {
diff --git a/ts/WoltLabSuite/Core/Acp/Bootstrap.ts b/ts/WoltLabSuite/Core/Acp/Bootstrap.ts
new file mode 100644 (file)
index 0000000..f6b594f
--- /dev/null
@@ -0,0 +1,35 @@
+/**
+ * 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();
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList.ts b/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList.ts
new file mode 100644 (file)
index 0000000..d068933
--- /dev/null
@@ -0,0 +1,263 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Data.ts b/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Data.ts
new file mode 100644 (file)
index 0000000..d636a8c
--- /dev/null
@@ -0,0 +1,12 @@
+export interface PackageData {
+  packageIdentifier: string;
+}
+
+export interface ExcludedPackageData extends PackageData {
+  version: string;
+}
+
+export interface RequiredPackageData extends PackageData {
+  file: boolean;
+  minVersion: string;
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/ExcludedPackages.ts b/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/ExcludedPackages.ts
new file mode 100644 (file)
index 0000000..503a259
--- /dev/null
@@ -0,0 +1,72 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.ts b/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.ts
new file mode 100644 (file)
index 0000000..eafe905
--- /dev/null
@@ -0,0 +1,714 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/OptionalPackages.ts b/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/OptionalPackages.ts
new file mode 100644 (file)
index 0000000..2a22c25
--- /dev/null
@@ -0,0 +1,29 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/RequiredPackages.ts b/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/RequiredPackages.ts
new file mode 100644 (file)
index 0000000..8e6fea9
--- /dev/null
@@ -0,0 +1,84 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Article/Add.ts b/ts/WoltLabSuite/Core/Acp/Ui/Article/Add.ts
new file mode 100644 (file)
index 0000000..21cd03b
--- /dev/null
@@ -0,0 +1,65 @@
+/**
+ * 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();
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Article/InlineEditor.ts b/ts/WoltLabSuite/Core/Acp/Ui/Article/InlineEditor.ts
new file mode 100644 (file)
index 0000000..d8c13f9
--- /dev/null
@@ -0,0 +1,412 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Box/Add.ts b/ts/WoltLabSuite/Core/Acp/Ui/Box/Add.ts
new file mode 100644 (file)
index 0000000..60f494e
--- /dev/null
@@ -0,0 +1,100 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Box/Controller/Handler.ts b/ts/WoltLabSuite/Core/Acp/Ui/Box/Controller/Handler.ts
new file mode 100644 (file)
index 0000000..7aa68ec
--- /dev/null
@@ -0,0 +1,79 @@
+/**
+ * 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);
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Box/Copy.ts b/ts/WoltLabSuite/Core/Acp/Ui/Box/Copy.ts
new file mode 100644 (file)
index 0000000..1ecf5fe
--- /dev/null
@@ -0,0 +1,34 @@
+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();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Box/Handler.ts b/ts/WoltLabSuite/Core/Acp/Ui/Box/Handler.ts
new file mode 100644 (file)
index 0000000..13e2958
--- /dev/null
@@ -0,0 +1,200 @@
+/**
+ * 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);
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Media.ts b/ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Media.ts
new file mode 100644 (file)
index 0000000..5559b12
--- /dev/null
@@ -0,0 +1,35 @@
+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;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Page.ts b/ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Page.ts
new file mode 100644 (file)
index 0000000..90f753f
--- /dev/null
@@ -0,0 +1,27 @@
+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;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Notification/Test.ts b/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Notification/Test.ts
new file mode 100644 (file)
index 0000000..72b2cf6
--- /dev/null
@@ -0,0 +1,147 @@
+/**
+ * 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();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Installation/Confirmation.ts b/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Installation/Confirmation.ts
new file mode 100644 (file)
index 0000000..eb6d245
--- /dev/null
@@ -0,0 +1,66 @@
+/**
+ * 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));
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Pip/Entry/List.ts b/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Pip/Entry/List.ts
new file mode 100644 (file)
index 0000000..625acd2
--- /dev/null
@@ -0,0 +1,142 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/QuickSetup.ts b/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/QuickSetup.ts
new file mode 100644 (file)
index 0000000..99e3ccd
--- /dev/null
@@ -0,0 +1,150 @@
+/**
+ * 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();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.ts b/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.ts
new file mode 100644 (file)
index 0000000..ec74288
--- /dev/null
@@ -0,0 +1,260 @@
+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);
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Menu/Item/Handler.ts b/ts/WoltLabSuite/Core/Acp/Ui/Menu/Item/Handler.ts
new file mode 100644 (file)
index 0000000..09e312b
--- /dev/null
@@ -0,0 +1,158 @@
+/**
+ * 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);
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Option/EmailSmtpTest.ts b/ts/WoltLabSuite/Core/Acp/Ui/Option/EmailSmtpTest.ts
new file mode 100644 (file)
index 0000000..8c1bc4a
--- /dev/null
@@ -0,0 +1,155 @@
+/**
+ * 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();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteGenerator.ts b/ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteGenerator.ts
new file mode 100644 (file)
index 0000000..7e28c0e
--- /dev/null
@@ -0,0 +1,93 @@
+/**
+ * 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();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteTest.ts b/ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteTest.ts
new file mode 100644 (file)
index 0000000..6dcc6ee
--- /dev/null
@@ -0,0 +1,189 @@
+/**
+ * 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);
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Package/PrepareInstallation.ts b/ts/WoltLabSuite/Core/Acp/Ui/Package/PrepareInstallation.ts
new file mode 100644 (file)
index 0000000..52e8b5d
--- /dev/null
@@ -0,0 +1,120 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Package/Search.ts b/ts/WoltLabSuite/Core/Acp/Ui/Package/Search.ts
new file mode 100644 (file)
index 0000000..cf28606
--- /dev/null
@@ -0,0 +1,153 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Page/Add.ts b/ts/WoltLabSuite/Core/Acp/Ui/Page/Add.ts
new file mode 100644 (file)
index 0000000..cb0f857
--- /dev/null
@@ -0,0 +1,81 @@
+/**
+ * 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();
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Page/BoxOrder.ts b/ts/WoltLabSuite/Core/Acp/Ui/Page/BoxOrder.ts
new file mode 100644 (file)
index 0000000..b2378c3
--- /dev/null
@@ -0,0 +1,152 @@
+/**
+ * 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);
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Page/Copy.ts b/ts/WoltLabSuite/Core/Acp/Ui/Page/Copy.ts
new file mode 100644 (file)
index 0000000..7e25611
--- /dev/null
@@ -0,0 +1,34 @@
+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();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Page/Menu.ts b/ts/WoltLabSuite/Core/Acp/Ui/Page/Menu.ts
new file mode 100644 (file)
index 0000000..175d350
--- /dev/null
@@ -0,0 +1,122 @@
+/**
+ * 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");
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts b/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts
new file mode 100644 (file)
index 0000000..507d713
--- /dev/null
@@ -0,0 +1,346 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Template/Group/Copy.ts b/ts/WoltLabSuite/Core/Acp/Ui/Template/Group/Copy.ts
new file mode 100644 (file)
index 0000000..185928a
--- /dev/null
@@ -0,0 +1,142 @@
+/**
+ * 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);
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.ts b/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.ts
new file mode 100644 (file)
index 0000000..bedccd0
--- /dev/null
@@ -0,0 +1,202 @@
+/**
+ * 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();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.ts b/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.ts
new file mode 100644 (file)
index 0000000..2889637
--- /dev/null
@@ -0,0 +1,76 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.ts
new file mode 100644 (file)
index 0000000..b0b596d
--- /dev/null
@@ -0,0 +1,126 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.ts
new file mode 100644 (file)
index 0000000..fa87386
--- /dev/null
@@ -0,0 +1,118 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/User/Editor.ts b/ts/WoltLabSuite/Core/Acp/Ui/User/Editor.ts
new file mode 100644 (file)
index 0000000..e5c0d20
--- /dev/null
@@ -0,0 +1,243 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Worker.ts b/ts/WoltLabSuite/Core/Acp/Ui/Worker.ts
new file mode 100644 (file)
index 0000000..41098cb
--- /dev/null
@@ -0,0 +1,177 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ajax.ts b/ts/WoltLabSuite/Core/Ajax.ts
new file mode 100644 (file)
index 0000000..74ec86a
--- /dev/null
@@ -0,0 +1,98 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Ajax/Data.ts b/ts/WoltLabSuite/Core/Ajax/Data.ts
new file mode 100644 (file)
index 0000000..c46fa2a
--- /dev/null
@@ -0,0 +1,99 @@
+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;
+}
diff --git a/ts/WoltLabSuite/Core/Ajax/Jsonp.ts b/ts/WoltLabSuite/Core/Ajax/Jsonp.ts
new file mode 100644 (file)
index 0000000..9bbf7ce
--- /dev/null
@@ -0,0 +1,72 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ajax/Request.ts b/ts/WoltLabSuite/Core/Ajax/Request.ts
new file mode 100644 (file)
index 0000000..366393e
--- /dev/null
@@ -0,0 +1,369 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ajax/Status.ts b/ts/WoltLabSuite/Core/Ajax/Status.ts
new file mode 100644 (file)
index 0000000..10f162f
--- /dev/null
@@ -0,0 +1,79 @@
+/**
+ * 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();
+}
diff --git a/ts/WoltLabSuite/Core/BackgroundQueue.ts b/ts/WoltLabSuite/Core/BackgroundQueue.ts
new file mode 100644 (file)
index 0000000..1199abb
--- /dev/null
@@ -0,0 +1,75 @@
+/**
+ * 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();
+}
diff --git a/ts/WoltLabSuite/Core/Bbcode/Code.ts b/ts/WoltLabSuite/Core/Bbcode/Code.ts
new file mode 100644 (file)
index 0000000..65c73a6
--- /dev/null
@@ -0,0 +1,125 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Bbcode/Collapsible.ts b/ts/WoltLabSuite/Core/Bbcode/Collapsible.ts
new file mode 100644 (file)
index 0000000..f1323f4
--- /dev/null
@@ -0,0 +1,85 @@
+/**
+ * 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");
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Bbcode/Spoiler.ts b/ts/WoltLabSuite/Core/Bbcode/Spoiler.ts
new file mode 100644 (file)
index 0000000..0877dfd
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * 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));
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Bootstrap.ts b/ts/WoltLabSuite/Core/Bootstrap.ts
new file mode 100644 (file)
index 0000000..28fedec
--- /dev/null
@@ -0,0 +1,128 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/BootstrapFrontend.ts b/ts/WoltLabSuite/Core/BootstrapFrontend.ts
new file mode 100644 (file)
index 0000000..07c2e56
--- /dev/null
@@ -0,0 +1,75 @@
+/**
+ * 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();
+}
diff --git a/ts/WoltLabSuite/Core/CallbackList.ts b/ts/WoltLabSuite/Core/CallbackList.ts
new file mode 100644 (file)
index 0000000..7cebbb5
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Clipboard.ts b/ts/WoltLabSuite/Core/Clipboard.ts
new file mode 100644 (file)
index 0000000..c56b6a5
--- /dev/null
@@ -0,0 +1,20 @@
+/**
+ * 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!);
+}
diff --git a/ts/WoltLabSuite/Core/ColorUtil.ts b/ts/WoltLabSuite/Core/ColorUtil.ts
new file mode 100644 (file)
index 0000000..d58c57c
--- /dev/null
@@ -0,0 +1,206 @@
+/**
+ * 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,
+};
diff --git a/ts/WoltLabSuite/Core/Controller/Captcha.ts b/ts/WoltLabSuite/Core/Controller/Captcha.ts
new file mode 100644 (file)
index 0000000..054e0ce
--- /dev/null
@@ -0,0 +1,63 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Controller/Clipboard.ts b/ts/WoltLabSuite/Core/Controller/Clipboard.ts
new file mode 100644 (file)
index 0000000..7d09992
--- /dev/null
@@ -0,0 +1,706 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Controller/Condition/Page/Dependence.ts b/ts/WoltLabSuite/Core/Controller/Condition/Page/Dependence.ts
new file mode 100644 (file)
index 0000000..24fdde3
--- /dev/null
@@ -0,0 +1,97 @@
+/**
+ * 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();
+}
diff --git a/ts/WoltLabSuite/Core/Controller/Map/Route/Planner.ts b/ts/WoltLabSuite/Core/Controller/Map/Route/Planner.ts
new file mode 100644 (file)
index 0000000..6630979
--- /dev/null
@@ -0,0 +1,215 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Controller/Media/List.ts b/ts/WoltLabSuite/Core/Controller/Media/List.ts
new file mode 100644 (file)
index 0000000..c296837
--- /dev/null
@@ -0,0 +1,126 @@
+/**
+ * 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();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Controller/Notice/Dismiss.ts b/ts/WoltLabSuite/Core/Controller/Notice/Dismiss.ts
new file mode 100644 (file)
index 0000000..3e9244e
--- /dev/null
@@ -0,0 +1,37 @@
+/**
+ * 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();
+    },
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Controller/Popover.ts b/ts/WoltLabSuite/Core/Controller/Popover.ts
new file mode 100644 (file)
index 0000000..6bd30fd
--- /dev/null
@@ -0,0 +1,488 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Controller/Style/Changer.ts b/ts/WoltLabSuite/Core/Controller/Style/Changer.ts
new file mode 100644 (file)
index 0000000..8fd8264
--- /dev/null
@@ -0,0 +1,93 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Controller/User/Notification/Settings.ts b/ts/WoltLabSuite/Core/Controller/User/Notification/Settings.ts
new file mode 100644 (file)
index 0000000..adf7d1c
--- /dev/null
@@ -0,0 +1,133 @@
+/**
+ * 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));
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Core.ts b/ts/WoltLabSuite/Core/Core.ts
new file mode 100644 (file)
index 0000000..d563596
--- /dev/null
@@ -0,0 +1,280 @@
+/**
+ * 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;
+    });
+  };
+}
diff --git a/ts/WoltLabSuite/Core/Date/Picker.ts b/ts/WoltLabSuite/Core/Date/Picker.ts
new file mode 100644 (file)
index 0000000..a4fe0b6
--- /dev/null
@@ -0,0 +1,1008 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Date/Time/Relative.ts b/ts/WoltLabSuite/Core/Date/Time/Relative.ts
new file mode 100644 (file)
index 0000000..507dbd4
--- /dev/null
@@ -0,0 +1,112 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Date/Util.ts b/ts/WoltLabSuite/Core/Date/Util.ts
new file mode 100644 (file)
index 0000000..84fdd6f
--- /dev/null
@@ -0,0 +1,260 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Devtools.ts b/ts/WoltLabSuite/Core/Devtools.ts
new file mode 100644 (file)
index 0000000..5054807
--- /dev/null
@@ -0,0 +1,106 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Dictionary.ts b/ts/WoltLabSuite/Core/Dictionary.ts
new file mode 100644 (file)
index 0000000..861b5f5
--- /dev/null
@@ -0,0 +1,105 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Dom/Change/Listener.ts b/ts/WoltLabSuite/Core/Dom/Change/Listener.ts
new file mode 100644 (file)
index 0000000..6cce555
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Dom/Traverse.ts b/ts/WoltLabSuite/Core/Dom/Traverse.ts
new file mode 100644 (file)
index 0000000..6d19d48
--- /dev/null
@@ -0,0 +1,208 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Dom/Util.ts b/ts/WoltLabSuite/Core/Dom/Util.ts
new file mode 100644 (file)
index 0000000..ae6c35a
--- /dev/null
@@ -0,0 +1,516 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Environment.ts b/ts/WoltLabSuite/Core/Environment.ts
new file mode 100644 (file)
index 0000000..fbfba80
--- /dev/null
@@ -0,0 +1,112 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Event/Handler.ts b/ts/WoltLabSuite/Core/Event/Handler.ts
new file mode 100644 (file)
index 0000000..3caf413
--- /dev/null
@@ -0,0 +1,104 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Event/Key.ts b/ts/WoltLabSuite/Core/Event/Key.ts
new file mode 100644 (file)
index 0000000..e6f1000
--- /dev/null
@@ -0,0 +1,117 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/FileUtil.ts b/ts/WoltLabSuite/Core/FileUtil.ts
new file mode 100644 (file)
index 0000000..46edb19
--- /dev/null
@@ -0,0 +1,198 @@
+/**
+ * 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 });
+}
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Container/SuffixFormField.ts b/ts/WoltLabSuite/Core/Form/Builder/Container/SuffixFormField.ts
new file mode 100644 (file)
index 0000000..4bb18d9
--- /dev/null
@@ -0,0 +1,76 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Data.ts b/ts/WoltLabSuite/Core/Form/Builder/Data.ts
new file mode 100644 (file)
index 0000000..e84e1d2
--- /dev/null
@@ -0,0 +1,30 @@
+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;
+}
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Dialog.ts b/ts/WoltLabSuite/Core/Form/Builder/Dialog.ts
new file mode 100644 (file)
index 0000000..f37e787
--- /dev/null
@@ -0,0 +1,240 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Acl.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Acl.ts
new file mode 100644 (file)
index 0000000..88e1391
--- /dev/null
@@ -0,0 +1,41 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Button.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Button.ts
new file mode 100644 (file)
index 0000000..c88f198
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Captcha.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Captcha.ts
new file mode 100644 (file)
index 0000000..74e9c8d
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Checkboxes.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Checkboxes.ts
new file mode 100644 (file)
index 0000000..33fdfd0
--- /dev/null
@@ -0,0 +1,41 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Checked.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Checked.ts
new file mode 100644 (file)
index 0000000..4010527
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Label.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Label.ts
new file mode 100644 (file)
index 0000000..91096fa
--- /dev/null
@@ -0,0 +1,132 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Rating.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Rating.ts
new file mode 100644 (file)
index 0000000..ec5e8da
--- /dev/null
@@ -0,0 +1,133 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Date.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Date.ts
new file mode 100644 (file)
index 0000000..7ceb95d
--- /dev/null
@@ -0,0 +1,25 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract.ts
new file mode 100644 (file)
index 0000000..a47318e
--- /dev/null
@@ -0,0 +1,105 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Abstract.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Abstract.ts
new file mode 100644 (file)
index 0000000..e829f75
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Default.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Default.ts
new file mode 100644 (file)
index 0000000..91e7188
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Tab.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Tab.ts
new file mode 100644 (file)
index 0000000..a6dd045
--- /dev/null
@@ -0,0 +1,69 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/TabMenu.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/TabMenu.ts
new file mode 100644 (file)
index 0000000..d4d4d04
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Empty.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Empty.ts
new file mode 100644 (file)
index 0000000..04c9171
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/IsNotClicked.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/IsNotClicked.ts
new file mode 100644 (file)
index 0000000..f7bb415
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Manager.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Manager.ts
new file mode 100644 (file)
index 0000000..7effcac
--- /dev/null
@@ -0,0 +1,297 @@
+/**
+ * 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);
+      });
+    });
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/NonEmpty.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/NonEmpty.ts
new file mode 100644 (file)
index 0000000..03faca5
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Value.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Value.ts
new file mode 100644 (file)
index 0000000..9ecf5ac
--- /dev/null
@@ -0,0 +1,87 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Field.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Field.ts
new file mode 100644 (file)
index 0000000..5ae819d
--- /dev/null
@@ -0,0 +1,78 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/ItemList.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/ItemList.ts
new file mode 100644 (file)
index 0000000..e83c501
--- /dev/null
@@ -0,0 +1,35 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Language/ContentLanguage.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Language/ContentLanguage.ts
new file mode 100644 (file)
index 0000000..bccb45e
--- /dev/null
@@ -0,0 +1,23 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/RadioButton.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/RadioButton.ts
new file mode 100644 (file)
index 0000000..cd5ff2b
--- /dev/null
@@ -0,0 +1,40 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/SimpleAcl.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/SimpleAcl.ts
new file mode 100644 (file)
index 0000000..0eb2226
--- /dev/null
@@ -0,0 +1,30 @@
+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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Tag.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Tag.ts
new file mode 100644 (file)
index 0000000..a56b322
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/User.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/User.ts
new file mode 100644 (file)
index 0000000..70f419a
--- /dev/null
@@ -0,0 +1,36 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Value.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Value.ts
new file mode 100644 (file)
index 0000000..89fb2ed
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/ValueI18n.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/ValueI18n.ts
new file mode 100644 (file)
index 0000000..8cafa4b
--- /dev/null
@@ -0,0 +1,40 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Attachment.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Attachment.ts
new file mode 100644 (file)
index 0000000..76f8cba
--- /dev/null
@@ -0,0 +1,22 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Poll.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Poll.ts
new file mode 100644 (file)
index 0000000..a7541cd
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Manager.ts b/ts/WoltLabSuite/Core/Form/Builder/Manager.ts
new file mode 100644 (file)
index 0000000..4f030aa
--- /dev/null
@@ -0,0 +1,166 @@
+/**
+ * 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,
+  });
+}
diff --git a/ts/WoltLabSuite/Core/I18n/Plural.ts b/ts/WoltLabSuite/Core/I18n/Plural.ts
new file mode 100644 (file)
index 0000000..2f104d8
--- /dev/null
@@ -0,0 +1,759 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Image/ExifUtil.ts b/ts/WoltLabSuite/Core/Image/ExifUtil.ts
new file mode 100644 (file)
index 0000000..867ff17
--- /dev/null
@@ -0,0 +1,179 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Image/ImageUtil.ts b/ts/WoltLabSuite/Core/Image/ImageUtil.ts
new file mode 100644 (file)
index 0000000..5d09090
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Image/Resizer.ts b/ts/WoltLabSuite/Core/Image/Resizer.ts
new file mode 100644 (file)
index 0000000..2ce812b
--- /dev/null
@@ -0,0 +1,203 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Language.ts b/ts/WoltLabSuite/Core/Language.ts
new file mode 100644 (file)
index 0000000..9b34920
--- /dev/null
@@ -0,0 +1,75 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Language/Chooser.ts b/ts/WoltLabSuite/Core/Language/Chooser.ts
new file mode 100644 (file)
index 0000000..f971a73
--- /dev/null
@@ -0,0 +1,298 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Language/Input.ts b/ts/WoltLabSuite/Core/Language/Input.ts
new file mode 100644 (file)
index 0000000..7c41431
--- /dev/null
@@ -0,0 +1,508 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Language/Text.ts b/ts/WoltLabSuite/Core/Language/Text.ts
new file mode 100644 (file)
index 0000000..3070681
--- /dev/null
@@ -0,0 +1,49 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/List.ts b/ts/WoltLabSuite/Core/List.ts
new file mode 100644 (file)
index 0000000..6863b5f
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Media/Clipboard.ts b/ts/WoltLabSuite/Core/Media/Clipboard.ts
new file mode 100644 (file)
index 0000000..7286e28
--- /dev/null
@@ -0,0 +1,141 @@
+/**
+ * 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));
+}
diff --git a/ts/WoltLabSuite/Core/Media/Data.ts b/ts/WoltLabSuite/Core/Media/Data.ts
new file mode 100644 (file)
index 0000000..5484d7e
--- /dev/null
@@ -0,0 +1,88 @@
+/**
+ * @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;
+}
diff --git a/ts/WoltLabSuite/Core/Media/Editor.ts b/ts/WoltLabSuite/Core/Media/Editor.ts
new file mode 100644 (file)
index 0000000..89168e0
--- /dev/null
@@ -0,0 +1,415 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Media/List/Upload.ts b/ts/WoltLabSuite/Core/Media/List/Upload.ts
new file mode 100644 (file)
index 0000000..7ac7a98
--- /dev/null
@@ -0,0 +1,44 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Media/Manager/Base.ts b/ts/WoltLabSuite/Core/Media/Manager/Base.ts
new file mode 100644 (file)
index 0000000..ad0212e
--- /dev/null
@@ -0,0 +1,549 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Media/Manager/Editor.ts b/ts/WoltLabSuite/Core/Media/Manager/Editor.ts
new file mode 100644 (file)
index 0000000..1c5817c
--- /dev/null
@@ -0,0 +1,368 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Media/Manager/Search.ts b/ts/WoltLabSuite/Core/Media/Manager/Search.ts
new file mode 100644 (file)
index 0000000..00fce33
--- /dev/null
@@ -0,0 +1,184 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Media/Manager/Select.ts b/ts/WoltLabSuite/Core/Media/Manager/Select.ts
new file mode 100644 (file)
index 0000000..31a8332
--- /dev/null
@@ -0,0 +1,192 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Media/Replace.ts b/ts/WoltLabSuite/Core/Media/Replace.ts
new file mode 100644 (file)
index 0000000..17ad8fc
--- /dev/null
@@ -0,0 +1,99 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Media/Upload.ts b/ts/WoltLabSuite/Core/Media/Upload.ts
new file mode 100644 (file)
index 0000000..b0c13b1
--- /dev/null
@@ -0,0 +1,311 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Notification/Handler.ts b/ts/WoltLabSuite/Core/Notification/Handler.ts
new file mode 100644 (file)
index 0000000..2652304
--- /dev/null
@@ -0,0 +1,260 @@
+/**
+ * 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);
+  }
+}
diff --git a/ts/WoltLabSuite/Core/NumberUtil.ts b/ts/WoltLabSuite/Core/NumberUtil.ts
new file mode 100644 (file)
index 0000000..f9ed77c
--- /dev/null
@@ -0,0 +1,37 @@
+/**
+ * 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}`;
+}
diff --git a/ts/WoltLabSuite/Core/ObjectMap.ts b/ts/WoltLabSuite/Core/ObjectMap.ts
new file mode 100644 (file)
index 0000000..cbfd259
--- /dev/null
@@ -0,0 +1,58 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Permission.ts b/ts/WoltLabSuite/Core/Permission.ts
new file mode 100644 (file)
index 0000000..9115ab1
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Prism.d.ts b/ts/WoltLabSuite/Core/Prism.d.ts
new file mode 100644 (file)
index 0000000..10e5166
--- /dev/null
@@ -0,0 +1,3 @@
+import Prism from "prismjs";
+
+export default Prism;
diff --git a/ts/WoltLabSuite/Core/Prism.js b/ts/WoltLabSuite/Core/Prism.js
new file mode 100644 (file)
index 0000000..68b0954
--- /dev/null
@@ -0,0 +1,53 @@
+/**
+ * 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;
+});
diff --git a/ts/WoltLabSuite/Core/Prism/Helper.ts b/ts/WoltLabSuite/Core/Prism/Helper.ts
new file mode 100644 (file)
index 0000000..83d71ac
--- /dev/null
@@ -0,0 +1,45 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/StringUtil.ts b/ts/WoltLabSuite/Core/StringUtil.ts
new file mode 100644 (file)
index 0000000..9c7c3b4
--- /dev/null
@@ -0,0 +1,143 @@
+/**
+ * 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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
+}
+
+/**
+ * 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(/&amp;/g, "&")
+    .replace(/&quot;/g, '"')
+    .replace(/&lt;/g, "<")
+    .replace(/&gt;/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;
+}
diff --git a/ts/WoltLabSuite/Core/Template.grammar.d.ts b/ts/WoltLabSuite/Core/Template.grammar.d.ts
new file mode 100644 (file)
index 0000000..c855d67
--- /dev/null
@@ -0,0 +1 @@
+export function parse(input: string): unknown;
diff --git a/ts/WoltLabSuite/Core/Template.grammar.jison b/ts/WoltLabSuite/Core/Template.grammar.jison
new file mode 100644 (file)
index 0000000..2087181
--- /dev/null
@@ -0,0 +1,184 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Template.grammar.js b/ts/WoltLabSuite/Core/Template.grammar.js
new file mode 100644 (file)
index 0000000..17a0c79
--- /dev/null
@@ -0,0 +1,748 @@
+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;
+});
diff --git a/ts/WoltLabSuite/Core/Template.ts b/ts/WoltLabSuite/Core/Template.ts
new file mode 100644 (file)
index 0000000..b43694b
--- /dev/null
@@ -0,0 +1,85 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Timer/Repeating.ts b/ts/WoltLabSuite/Core/Timer/Repeating.ts
new file mode 100644 (file)
index 0000000..82fde69
--- /dev/null
@@ -0,0 +1,69 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Acl/Simple.ts b/ts/WoltLabSuite/Core/Ui/Acl/Simple.ts
new file mode 100644 (file)
index 0000000..47abca9
--- /dev/null
@@ -0,0 +1,102 @@
+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;
diff --git a/ts/WoltLabSuite/Core/Ui/Alignment.ts b/ts/WoltLabSuite/Core/Ui/Alignment.ts
new file mode 100644 (file)
index 0000000..864f025
--- /dev/null
@@ -0,0 +1,316 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Article/MarkAllAsRead.ts b/ts/WoltLabSuite/Core/Ui/Article/MarkAllAsRead.ts
new file mode 100644 (file)
index 0000000..4a7c8ad
--- /dev/null
@@ -0,0 +1,48 @@
+/**
+ * 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();
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Article/Search.ts b/ts/WoltLabSuite/Core/Ui/Article/Search.ts
new file mode 100644 (file)
index 0000000..6fca7bb
--- /dev/null
@@ -0,0 +1,159 @@
+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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/CloseOverlay.ts b/ts/WoltLabSuite/Core/Ui/CloseOverlay.ts
new file mode 100644 (file)
index 0000000..d01867f
--- /dev/null
@@ -0,0 +1,36 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Color/Picker.ts b/ts/WoltLabSuite/Core/Ui/Color/Picker.ts
new file mode 100644 (file)
index 0000000..53ebccc
--- /dev/null
@@ -0,0 +1,91 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Comment/Add.ts b/ts/WoltLabSuite/Core/Ui/Comment/Add.ts
new file mode 100644 (file)
index 0000000..aee0293
--- /dev/null
@@ -0,0 +1,348 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Comment/Edit.ts b/ts/WoltLabSuite/Core/Ui/Comment/Edit.ts
new file mode 100644 (file)
index 0000000..4646f8a
--- /dev/null
@@ -0,0 +1,329 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Comment/Response/Add.ts b/ts/WoltLabSuite/Core/Ui/Comment/Response/Add.ts
new file mode 100644 (file)
index 0000000..31791ca
--- /dev/null
@@ -0,0 +1,126 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Comment/Response/Edit.ts b/ts/WoltLabSuite/Core/Ui/Comment/Response/Edit.ts
new file mode 100644 (file)
index 0000000..95d5b37
--- /dev/null
@@ -0,0 +1,143 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Confirmation.ts b/ts/WoltLabSuite/Core/Ui/Confirmation.ts
new file mode 100644 (file)
index 0000000..846e54b
--- /dev/null
@@ -0,0 +1,216 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Dialog.ts b/ts/WoltLabSuite/Core/Ui/Dialog.ts
new file mode 100644 (file)
index 0000000..d09f55f
--- /dev/null
@@ -0,0 +1,920 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Dialog/Data.ts b/ts/WoltLabSuite/Core/Ui/Dialog/Data.ts
new file mode 100644 (file)
index 0000000..02eb403
--- /dev/null
@@ -0,0 +1,59 @@
+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>;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/DragAndDrop.ts b/ts/WoltLabSuite/Core/Ui/DragAndDrop.ts
new file mode 100644 (file)
index 0000000..e3f2e58
--- /dev/null
@@ -0,0 +1,42 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Dropdown/Builder.ts b/ts/WoltLabSuite/Core/Ui/Dropdown/Builder.ts
new file mode 100644 (file)
index 0000000..53e65c3
--- /dev/null
@@ -0,0 +1,221 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Dropdown/Data.ts b/ts/WoltLabSuite/Core/Ui/Dropdown/Data.ts
new file mode 100644 (file)
index 0000000..bd494c2
--- /dev/null
@@ -0,0 +1,2 @@
+export type NotificationAction = "close" | "open";
+export type NotificationCallback = (containerId: string, action: NotificationAction) => void;
diff --git a/ts/WoltLabSuite/Core/Ui/Dropdown/Reusable.ts b/ts/WoltLabSuite/Core/Ui/Dropdown/Reusable.ts
new file mode 100644 (file)
index 0000000..f6c9c92
--- /dev/null
@@ -0,0 +1,63 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts b/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts
new file mode 100644 (file)
index 0000000..52b6dd2
--- /dev/null
@@ -0,0 +1,618 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/File/Data.ts b/ts/WoltLabSuite/Core/Ui/File/Data.ts
new file mode 100644 (file)
index 0000000..c13af33
--- /dev/null
@@ -0,0 +1,6 @@
+// This helper interface exists to prevent a circular dependency
+// between `./Delete` and `./Upload`
+
+export interface FileUploadHandler {
+  checkMaxFiles(): void;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/File/Delete.ts b/ts/WoltLabSuite/Core/Ui/File/Delete.ts
new file mode 100644 (file)
index 0000000..e973743
--- /dev/null
@@ -0,0 +1,179 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/File/Upload.ts b/ts/WoltLabSuite/Core/Ui/File/Upload.ts
new file mode 100644 (file)
index 0000000..24cb0b1
--- /dev/null
@@ -0,0 +1,264 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/FlexibleMenu.ts b/ts/WoltLabSuite/Core/Ui/FlexibleMenu.ts
new file mode 100644 (file)
index 0000000..134ba44
--- /dev/null
@@ -0,0 +1,195 @@
+/**
+ * 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();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Ui/ItemList.ts b/ts/WoltLabSuite/Core/Ui/ItemList.ts
new file mode 100644 (file)
index 0000000..8d3de3b
--- /dev/null
@@ -0,0 +1,580 @@
+/**
+ * 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[];
+}
diff --git a/ts/WoltLabSuite/Core/Ui/ItemList/Filter.ts b/ts/WoltLabSuite/Core/Ui/ItemList/Filter.ts
new file mode 100644 (file)
index 0000000..539e5e8
--- /dev/null
@@ -0,0 +1,374 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/ItemList/Static.ts b/ts/WoltLabSuite/Core/Ui/ItemList/Static.ts
new file mode 100644 (file)
index 0000000..1b45ab2
--- /dev/null
@@ -0,0 +1,443 @@
+/**
+ * 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));
+}
diff --git a/ts/WoltLabSuite/Core/Ui/ItemList/User.ts b/ts/WoltLabSuite/Core/Ui/ItemList/User.ts
new file mode 100644 (file)
index 0000000..b4d1fe0
--- /dev/null
@@ -0,0 +1,85 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Like/Handler.ts b/ts/WoltLabSuite/Core/Ui/Like/Handler.ts
new file mode 100644 (file)
index 0000000..db4d845
--- /dev/null
@@ -0,0 +1,307 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Message/InlineEditor.ts b/ts/WoltLabSuite/Core/Ui/Message/InlineEditor.ts
new file mode 100644 (file)
index 0000000..90d0e37
--- /dev/null
@@ -0,0 +1,737 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Message/Manager.ts b/ts/WoltLabSuite/Core/Ui/Message/Manager.ts
new file mode 100644 (file)
index 0000000..f4403ef
--- /dev/null
@@ -0,0 +1,287 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Message/Quote.ts b/ts/WoltLabSuite/Core/Ui/Message/Quote.ts
new file mode 100644 (file)
index 0000000..f4c29aa
--- /dev/null
@@ -0,0 +1,553 @@
+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 === &hellip;
+        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;
diff --git a/ts/WoltLabSuite/Core/Ui/Message/Reply.ts b/ts/WoltLabSuite/Core/Ui/Message/Reply.ts
new file mode 100644 (file)
index 0000000..79870f6
--- /dev/null
@@ -0,0 +1,426 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Message/Share.ts b/ts/WoltLabSuite/Core/Ui/Message/Share.ts
new file mode 100644 (file)
index 0000000..5325366
--- /dev/null
@@ -0,0 +1,129 @@
+/**
+ * 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));
+      }
+    });
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Message/TwitterEmbed.ts b/ts/WoltLabSuite/Core/Ui/Message/TwitterEmbed.ts
new file mode 100644 (file)
index 0000000..cdfb4be
--- /dev/null
@@ -0,0 +1,61 @@
+/**
+ * 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);
+    }
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Message/UserConsent.ts b/ts/WoltLabSuite/Core/Ui/Message/UserConsent.ts
new file mode 100644 (file)
index 0000000..723df19
--- /dev/null
@@ -0,0 +1,82 @@
+/**
+ * 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();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Mobile.ts b/ts/WoltLabSuite/Core/Ui/Mobile.ts
new file mode 100644 (file)
index 0000000..a82d20c
--- /dev/null
@@ -0,0 +1,446 @@
+/**
+ * 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");
+    }
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Notification.ts b/ts/WoltLabSuite/Core/Ui/Notification.ts
new file mode 100644 (file)
index 0000000..47d7f59
--- /dev/null
@@ -0,0 +1,70 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Page/Action.ts b/ts/WoltLabSuite/Core/Ui/Page/Action.ts
new file mode 100644 (file)
index 0000000..b5cdada
--- /dev/null
@@ -0,0 +1,266 @@
+/**
+ * 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();
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Page/Header/Fixed.ts b/ts/WoltLabSuite/Core/Ui/Page/Header/Fixed.ts
new file mode 100644 (file)
index 0000000..207f62b
--- /dev/null
@@ -0,0 +1,128 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Page/Header/Menu.ts b/ts/WoltLabSuite/Core/Ui/Page/Header/Menu.ts
new file mode 100644 (file)
index 0000000..e72e5bb
--- /dev/null
@@ -0,0 +1,217 @@
+/**
+ * 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,
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Page/JumpTo.ts b/ts/WoltLabSuite/Core/Ui/Page/JumpTo.ts
new file mode 100644 (file)
index 0000000..44f4dda
--- /dev/null
@@ -0,0 +1,139 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Page/Menu/Abstract.ts b/ts/WoltLabSuite/Core/Ui/Page/Menu/Abstract.ts
new file mode 100644 (file)
index 0000000..53b759d
--- /dev/null
@@ -0,0 +1,586 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts b/ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts
new file mode 100644 (file)
index 0000000..68194d3
--- /dev/null
@@ -0,0 +1,111 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts b/ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts
new file mode 100644 (file)
index 0000000..dd26e3b
--- /dev/null
@@ -0,0 +1,86 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Page/Search.ts b/ts/WoltLabSuite/Core/Ui/Page/Search.ts
new file mode 100644 (file)
index 0000000..cfc95d6
--- /dev/null
@@ -0,0 +1,156 @@
+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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Page/Search/Handler.ts b/ts/WoltLabSuite/Core/Ui/Page/Search/Handler.ts
new file mode 100644 (file)
index 0000000..063b4a7
--- /dev/null
@@ -0,0 +1,198 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Page/Search/Input.ts b/ts/WoltLabSuite/Core/Ui/Page/Search/Input.ts
new file mode 100644 (file)
index 0000000..4d00dcf
--- /dev/null
@@ -0,0 +1,68 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Pagination.ts b/ts/WoltLabSuite/Core/Ui/Pagination.ts
new file mode 100644 (file)
index 0000000..7aee1af
--- /dev/null
@@ -0,0 +1,288 @@
+/**
+ * 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") + '">&hellip;</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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Poll/Editor.ts b/ts/WoltLabSuite/Core/Ui/Poll/Editor.ts
new file mode 100644 (file)
index 0000000..c41251b
--- /dev/null
@@ -0,0 +1,393 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.ts b/ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.ts
new file mode 100644 (file)
index 0000000..77d7b8b
--- /dev/null
@@ -0,0 +1,261 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Reaction/Data.ts b/ts/WoltLabSuite/Core/Ui/Reaction/Data.ts
new file mode 100644 (file)
index 0000000..f27516c
--- /dev/null
@@ -0,0 +1,12 @@
+export interface Reaction {
+  title: string;
+  renderedIcon: string;
+  iconPath: string;
+  showOrder: number;
+  reactionTypeID: number;
+  isAssignable: 1 | 0;
+}
+
+export interface ReactionStats {
+  [key: string]: number;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts b/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts
new file mode 100644 (file)
index 0000000..dff07fe
--- /dev/null
@@ -0,0 +1,446 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Reaction/Profile/Loader.ts b/ts/WoltLabSuite/Core/Ui/Reaction/Profile/Loader.ts
new file mode 100644 (file)
index 0000000..26d709c
--- /dev/null
@@ -0,0 +1,192 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Article.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Article.ts
new file mode 100644 (file)
index 0000000..f245141
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.ts
new file mode 100644 (file)
index 0000000..1bb6e3c
--- /dev/null
@@ -0,0 +1,354 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Code.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Code.ts
new file mode 100644 (file)
index 0000000..324531f
--- /dev/null
@@ -0,0 +1,268 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/DragAndDrop.ts b/ts/WoltLabSuite/Core/Ui/Redactor/DragAndDrop.ts
new file mode 100644 (file)
index 0000000..343b3d7
--- /dev/null
@@ -0,0 +1,212 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Editor.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Editor.ts
new file mode 100644 (file)
index 0000000..a1b0228
--- /dev/null
@@ -0,0 +1,74 @@
+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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Format.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Format.ts
new file mode 100644 (file)
index 0000000..d4891a4
--- /dev/null
@@ -0,0 +1,472 @@
+/**
+ * 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(/&nbsp;/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();
+      }
+    }
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Html.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Html.ts
new file mode 100644 (file)
index 0000000..a31d89b
--- /dev/null
@@ -0,0 +1,94 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Link.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Link.ts
new file mode 100644 (file)
index 0000000..3ca2631
--- /dev/null
@@ -0,0 +1,106 @@
+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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Mention.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Mention.ts
new file mode 100644 (file)
index 0000000..371f8b7
--- /dev/null
@@ -0,0 +1,448 @@
+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;
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Metacode.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Metacode.ts
new file mode 100644 (file)
index 0000000..94d30bb
--- /dev/null
@@ -0,0 +1,147 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Page.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Page.ts
new file mode 100644 (file)
index 0000000..8a8c5be
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/PseudoHeader.ts b/ts/WoltLabSuite/Core/Ui/Redactor/PseudoHeader.ts
new file mode 100644 (file)
index 0000000..b6d7023
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Quote.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Quote.ts
new file mode 100644 (file)
index 0000000..78090e7
--- /dev/null
@@ -0,0 +1,297 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Spoiler.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Spoiler.ts
new file mode 100644 (file)
index 0000000..f6e760c
--- /dev/null
@@ -0,0 +1,201 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Redactor/Table.ts b/ts/WoltLabSuite/Core/Ui/Redactor/Table.ts
new file mode 100644 (file)
index 0000000..5089a83
--- /dev/null
@@ -0,0 +1,86 @@
+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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Screen.ts b/ts/WoltLabSuite/Core/Ui/Screen.ts
new file mode 100644 (file)
index 0000000..9e330b8
--- /dev/null
@@ -0,0 +1,266 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Scroll.ts b/ts/WoltLabSuite/Core/Ui/Scroll.ts
new file mode 100644 (file)
index 0000000..afee101
--- /dev/null
@@ -0,0 +1,95 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Search/Data.ts b/ts/WoltLabSuite/Core/Ui/Search/Data.ts
new file mode 100644 (file)
index 0000000..dc0d309
--- /dev/null
@@ -0,0 +1,17 @@
+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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Search/Input.ts b/ts/WoltLabSuite/Core/Ui/Search/Input.ts
new file mode 100644 (file)
index 0000000..934ef2f
--- /dev/null
@@ -0,0 +1,376 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Search/Page.ts b/ts/WoltLabSuite/Core/Ui/Search/Page.ts
new file mode 100644 (file)
index 0000000..ac90849
--- /dev/null
@@ -0,0 +1,109 @@
+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();
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Smiley/Insert.ts b/ts/WoltLabSuite/Core/Ui/Smiley/Insert.ts
new file mode 100644 (file)
index 0000000..d088bd2
--- /dev/null
@@ -0,0 +1,95 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Sortable/List.ts b/ts/WoltLabSuite/Core/Ui/Sortable/List.ts
new file mode 100644 (file)
index 0000000..9742409
--- /dev/null
@@ -0,0 +1,89 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Style/FontAwesome.ts b/ts/WoltLabSuite/Core/Ui/Style/FontAwesome.ts
new file mode 100644 (file)
index 0000000..39b0aec
--- /dev/null
@@ -0,0 +1,111 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Suggestion.ts b/ts/WoltLabSuite/Core/Ui/Suggestion.ts
new file mode 100644 (file)
index 0000000..177572f
--- /dev/null
@@ -0,0 +1,276 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/TabMenu.ts b/ts/WoltLabSuite/Core/Ui/TabMenu.ts
new file mode 100644 (file)
index 0000000..21fdedd
--- /dev/null
@@ -0,0 +1,354 @@
+/**
+ * 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);
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Ui/TabMenu/Simple.ts b/ts/WoltLabSuite/Core/Ui/TabMenu/Simple.ts
new file mode 100644 (file)
index 0000000..b2eb37e
--- /dev/null
@@ -0,0 +1,444 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/Toggle/Input.ts b/ts/WoltLabSuite/Core/Ui/Toggle/Input.ts
new file mode 100644 (file)
index 0000000..b203f10
--- /dev/null
@@ -0,0 +1,108 @@
+/**
+ * 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[];
+}
diff --git a/ts/WoltLabSuite/Core/Ui/Tooltip.ts b/ts/WoltLabSuite/Core/Ui/Tooltip.ts
new file mode 100644 (file)
index 0000000..ee91a07
--- /dev/null
@@ -0,0 +1,118 @@
+/**
+ * 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);
+    }
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Ui/User/Activity/Recent.ts b/ts/WoltLabSuite/Core/Ui/User/Activity/Recent.ts
new file mode 100644 (file)
index 0000000..41f196f
--- /dev/null
@@ -0,0 +1,106 @@
+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;
diff --git a/ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Delete.ts b/ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Delete.ts
new file mode 100644 (file)
index 0000000..d3bd4b5
--- /dev/null
@@ -0,0 +1,86 @@
+/**
+ * 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);
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Upload.ts b/ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Upload.ts
new file mode 100644 (file)
index 0000000..af4319e
--- /dev/null
@@ -0,0 +1,69 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/User/Editor.ts b/ts/WoltLabSuite/Core/Ui/User/Editor.ts
new file mode 100644 (file)
index 0000000..d845083
--- /dev/null
@@ -0,0 +1,263 @@
+/**
+ * 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();
+}
diff --git a/ts/WoltLabSuite/Core/Ui/User/Ignore.ts b/ts/WoltLabSuite/Core/Ui/User/Ignore.ts
new file mode 100644 (file)
index 0000000..4903116
--- /dev/null
@@ -0,0 +1,54 @@
+/**
+ * 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);
+}
diff --git a/ts/WoltLabSuite/Core/Ui/User/List.ts b/ts/WoltLabSuite/Core/Ui/User/List.ts
new file mode 100644 (file)
index 0000000..cd4c0c0
--- /dev/null
@@ -0,0 +1,139 @@
+/**
+ * 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;
+  };
+}
diff --git a/ts/WoltLabSuite/Core/Ui/User/Multifactor/Totp/Qr.ts b/ts/WoltLabSuite/Core/Ui/User/Multifactor/Totp/Qr.ts
new file mode 100644 (file)
index 0000000..f00dd0f
--- /dev/null
@@ -0,0 +1,33 @@
+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));
+}
diff --git a/ts/WoltLabSuite/Core/Ui/User/PasswordStrength.ts b/ts/WoltLabSuite/Core/Ui/User/PasswordStrength.ts
new file mode 100644 (file)
index 0000000..ac5323d
--- /dev/null
@@ -0,0 +1,136 @@
+/**
+ * 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"];
+}
diff --git a/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Abstract.ts b/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Abstract.ts
new file mode 100644 (file)
index 0000000..21fa444
--- /dev/null
@@ -0,0 +1,112 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Follow.ts b/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Follow.ts
new file mode 100644 (file)
index 0000000..e41aeb2
--- /dev/null
@@ -0,0 +1,44 @@
+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;
diff --git a/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Ignore.ts b/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Ignore.ts
new file mode 100644 (file)
index 0000000..debe11b
--- /dev/null
@@ -0,0 +1,44 @@
+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;
diff --git a/ts/WoltLabSuite/Core/Ui/User/Search/Input.ts b/ts/WoltLabSuite/Core/Ui/User/Search/Input.ts
new file mode 100644 (file)
index 0000000..bd8dd6e
--- /dev/null
@@ -0,0 +1,65 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/User/Session/Delete.ts b/ts/WoltLabSuite/Core/Ui/User/Session/Delete.ts
new file mode 100644 (file)
index 0000000..dc05dfd
--- /dev/null
@@ -0,0 +1,77 @@
+/**
+ * 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;
+}
diff --git a/ts/WoltLabSuite/Core/Ui/User/Trophy/List.ts b/ts/WoltLabSuite/Core/Ui/User/Trophy/List.ts
new file mode 100644 (file)
index 0000000..798756f
--- /dev/null
@@ -0,0 +1,158 @@
+/**
+ * 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;
+  };
+}
diff --git a/ts/WoltLabSuite/Core/Upload.ts b/ts/WoltLabSuite/Core/Upload.ts
new file mode 100644 (file)
index 0000000..b7b6cd3
--- /dev/null
@@ -0,0 +1,422 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/Upload/Data.ts b/ts/WoltLabSuite/Core/Upload/Data.ts
new file mode 100644 (file)
index 0000000..7a641a6
--- /dev/null
@@ -0,0 +1,23 @@
+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;
diff --git a/ts/WoltLabSuite/Core/User.ts b/ts/WoltLabSuite/Core/User.ts
new file mode 100644 (file)
index 0000000..54dfeb3
--- /dev/null
@@ -0,0 +1,44 @@
+/**
+ * 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;
+  },
+};
diff --git a/ts/WoltLabSuite/Core/Wrapper/FacebookSdk.ts b/ts/WoltLabSuite/Core/Wrapper/FacebookSdk.ts
new file mode 100644 (file)
index 0000000..9f58338
--- /dev/null
@@ -0,0 +1,17 @@
+/**
+ * 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;
diff --git a/ts/WoltLabSuite/Core/prism-meta.ts b/ts/WoltLabSuite/Core/prism-meta.ts
new file mode 100644 (file)
index 0000000..e54a153
--- /dev/null
@@ -0,0 +1,9 @@
+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;
index 43ea08f90cd6af640abba55d75f7d9f52a1771ad..a40399002204665e4736ba35591859f4d8e05e5a 100644 (file)
@@ -1,13 +1,13 @@
 {
   "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",
index 6253e03e9d7aa44d2aa3a2ccc918c865c4e8f920..af3296365e8a12decf56651eaee5b1d6798fc153 100644 (file)
@@ -52,5 +52,5 @@ export type PrismMeta = Record<LanguageIdentifier, LanguageData>;
        )} /*!END*/
 export default metadata;
 `;
-       fs.writeFileSync("../../../ts/WoltLabSuite/Core/prism-meta.ts", contents, "utf8");
+       fs.writeFileSync("../../../../../../ts/WoltLabSuite/Core/prism-meta.ts", contents, "utf8");
 }
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Bootstrap.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Bootstrap.ts
deleted file mode 100644 (file)
index f6b594f..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * 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();
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/AbstractPackageList.ts
deleted file mode 100644 (file)
index d068933..0000000
+++ /dev/null
@@ -1,263 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Data.ts
deleted file mode 100644 (file)
index d636a8c..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-export interface PackageData {
-  packageIdentifier: string;
-}
-
-export interface ExcludedPackageData extends PackageData {
-  version: string;
-}
-
-export interface RequiredPackageData extends PackageData {
-  file: boolean;
-  minVersion: string;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/ExcludedPackages.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/ExcludedPackages.ts
deleted file mode 100644 (file)
index 503a259..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/Instructions.ts
deleted file mode 100644 (file)
index eafe905..0000000
+++ /dev/null
@@ -1,714 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/OptionalPackages.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/OptionalPackages.ts
deleted file mode 100644 (file)
index 2a22c25..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/RequiredPackages.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Form/Builder/Field/Devtools/Project/RequiredPackages.ts
deleted file mode 100644 (file)
index 8e6fea9..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Article/Add.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Article/Add.ts
deleted file mode 100644 (file)
index 21cd03b..0000000
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * 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();
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Article/InlineEditor.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Article/InlineEditor.ts
deleted file mode 100644 (file)
index d8c13f9..0000000
+++ /dev/null
@@ -1,412 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Add.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Add.ts
deleted file mode 100644 (file)
index 60f494e..0000000
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Controller/Handler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Controller/Handler.ts
deleted file mode 100644 (file)
index 7aa68ec..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * 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);
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Copy.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Copy.ts
deleted file mode 100644 (file)
index 1ecf5fe..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-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();
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Handler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Box/Handler.ts
deleted file mode 100644 (file)
index 13e2958..0000000
+++ /dev/null
@@ -1,200 +0,0 @@
-/**
- * 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);
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Media.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Media.ts
deleted file mode 100644 (file)
index 5559b12..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Page.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/CodeMirror/Page.ts
deleted file mode 100644 (file)
index 90f753f..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Notification/Test.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Notification/Test.ts
deleted file mode 100644 (file)
index 72b2cf6..0000000
+++ /dev/null
@@ -1,147 +0,0 @@
-/**
- * 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();
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Installation/Confirmation.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Installation/Confirmation.ts
deleted file mode 100644 (file)
index eb6d245..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-/**
- * 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));
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Pip/Entry/List.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Pip/Entry/List.ts
deleted file mode 100644 (file)
index 625acd2..0000000
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/QuickSetup.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/QuickSetup.ts
deleted file mode 100644 (file)
index 99e3ccd..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * 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();
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Devtools/Project/Sync.ts
deleted file mode 100644 (file)
index ec74288..0000000
+++ /dev/null
@@ -1,260 +0,0 @@
-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);
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Menu/Item/Handler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Menu/Item/Handler.ts
deleted file mode 100644 (file)
index 09e312b..0000000
+++ /dev/null
@@ -1,158 +0,0 @@
-/**
- * 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);
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Option/EmailSmtpTest.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Option/EmailSmtpTest.ts
deleted file mode 100644 (file)
index 8c1bc4a..0000000
+++ /dev/null
@@ -1,155 +0,0 @@
-/**
- * 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();
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteGenerator.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteGenerator.ts
deleted file mode 100644 (file)
index 7e28c0e..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * 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();
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteTest.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Option/RewriteTest.ts
deleted file mode 100644 (file)
index 6dcc6ee..0000000
+++ /dev/null
@@ -1,189 +0,0 @@
-/**
- * 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);
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Package/PrepareInstallation.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Package/PrepareInstallation.ts
deleted file mode 100644 (file)
index 52e8b5d..0000000
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Package/Search.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Package/Search.ts
deleted file mode 100644 (file)
index cf28606..0000000
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/Add.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/Add.ts
deleted file mode 100644 (file)
index cb0f857..0000000
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * 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();
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/BoxOrder.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/BoxOrder.ts
deleted file mode 100644 (file)
index b2378c3..0000000
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * 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);
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/Copy.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/Copy.ts
deleted file mode 100644 (file)
index 7e25611..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-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();
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/Menu.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Page/Menu.ts
deleted file mode 100644 (file)
index 175d350..0000000
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * 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");
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Style/Editor.ts
deleted file mode 100644 (file)
index 507d713..0000000
+++ /dev/null
@@ -1,346 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Template/Group/Copy.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Template/Group/Copy.ts
deleted file mode 100644 (file)
index 185928a..0000000
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * 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);
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.ts
deleted file mode 100644 (file)
index bedccd0..0000000
+++ /dev/null
@@ -1,202 +0,0 @@
-/**
- * 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();
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.ts
deleted file mode 100644 (file)
index 2889637..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Clipboard.ts
deleted file mode 100644 (file)
index b0b596d..0000000
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.ts
deleted file mode 100644 (file)
index fa87386..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/User/Editor.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/User/Editor.ts
deleted file mode 100644 (file)
index e5c0d20..0000000
+++ /dev/null
@@ -1,243 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Worker.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Acp/Ui/Worker.ts
deleted file mode 100644 (file)
index 41098cb..0000000
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax.ts
deleted file mode 100644 (file)
index 74ec86a..0000000
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Data.ts
deleted file mode 100644 (file)
index c46fa2a..0000000
+++ /dev/null
@@ -1,99 +0,0 @@
-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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Jsonp.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Jsonp.ts
deleted file mode 100644 (file)
index 9bbf7ce..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Request.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Request.ts
deleted file mode 100644 (file)
index 366393e..0000000
+++ /dev/null
@@ -1,369 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Status.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ajax/Status.ts
deleted file mode 100644 (file)
index 10f162f..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * 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();
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/BackgroundQueue.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/BackgroundQueue.ts
deleted file mode 100644 (file)
index 1199abb..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * 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();
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Bbcode/Code.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Bbcode/Code.ts
deleted file mode 100644 (file)
index 65c73a6..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Bbcode/Collapsible.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Bbcode/Collapsible.ts
deleted file mode 100644 (file)
index f1323f4..0000000
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * 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");
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Bbcode/Spoiler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Bbcode/Spoiler.ts
deleted file mode 100644 (file)
index 0877dfd..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * 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));
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Bootstrap.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Bootstrap.ts
deleted file mode 100644 (file)
index 28fedec..0000000
+++ /dev/null
@@ -1,128 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/BootstrapFrontend.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/BootstrapFrontend.ts
deleted file mode 100644 (file)
index 07c2e56..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * 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();
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/CallbackList.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/CallbackList.ts
deleted file mode 100644 (file)
index 7cebbb5..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Clipboard.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Clipboard.ts
deleted file mode 100644 (file)
index c56b6a5..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * 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!);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/ColorUtil.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/ColorUtil.ts
deleted file mode 100644 (file)
index d58c57c..0000000
+++ /dev/null
@@ -1,206 +0,0 @@
-/**
- * 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,
-};
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Captcha.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Captcha.ts
deleted file mode 100644 (file)
index 054e0ce..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Clipboard.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Clipboard.ts
deleted file mode 100644 (file)
index 7d09992..0000000
+++ /dev/null
@@ -1,706 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Condition/Page/Dependence.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Condition/Page/Dependence.ts
deleted file mode 100644 (file)
index 24fdde3..0000000
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * 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();
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Map/Route/Planner.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Map/Route/Planner.ts
deleted file mode 100644 (file)
index 6630979..0000000
+++ /dev/null
@@ -1,215 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Media/List.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Media/List.ts
deleted file mode 100644 (file)
index c296837..0000000
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * 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();
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Notice/Dismiss.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Notice/Dismiss.ts
deleted file mode 100644 (file)
index 3e9244e..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * 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();
-    },
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Popover.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Popover.ts
deleted file mode 100644 (file)
index 6bd30fd..0000000
+++ /dev/null
@@ -1,488 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Style/Changer.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/Style/Changer.ts
deleted file mode 100644 (file)
index 8fd8264..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/User/Notification/Settings.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Controller/User/Notification/Settings.ts
deleted file mode 100644 (file)
index adf7d1c..0000000
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * 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));
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Core.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Core.ts
deleted file mode 100644 (file)
index d563596..0000000
+++ /dev/null
@@ -1,280 +0,0 @@
-/**
- * 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;
-    });
-  };
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Picker.ts
deleted file mode 100644 (file)
index a4fe0b6..0000000
+++ /dev/null
@@ -1,1008 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Time/Relative.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Time/Relative.ts
deleted file mode 100644 (file)
index 507dbd4..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Util.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Date/Util.ts
deleted file mode 100644 (file)
index 84fdd6f..0000000
+++ /dev/null
@@ -1,260 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Devtools.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Devtools.ts
deleted file mode 100644 (file)
index 5054807..0000000
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Dictionary.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Dictionary.ts
deleted file mode 100644 (file)
index 861b5f5..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Change/Listener.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Change/Listener.ts
deleted file mode 100644 (file)
index 6cce555..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Traverse.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Traverse.ts
deleted file mode 100644 (file)
index 6d19d48..0000000
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util.ts
deleted file mode 100644 (file)
index ae6c35a..0000000
+++ /dev/null
@@ -1,516 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Environment.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Environment.ts
deleted file mode 100644 (file)
index fbfba80..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Event/Handler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Event/Handler.ts
deleted file mode 100644 (file)
index 3caf413..0000000
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Event/Key.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Event/Key.ts
deleted file mode 100644 (file)
index e6f1000..0000000
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/FileUtil.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/FileUtil.ts
deleted file mode 100644 (file)
index 46edb19..0000000
+++ /dev/null
@@ -1,198 +0,0 @@
-/**
- * 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 });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Container/SuffixFormField.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Container/SuffixFormField.ts
deleted file mode 100644 (file)
index 4bb18d9..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Data.ts
deleted file mode 100644 (file)
index e84e1d2..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Dialog.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Dialog.ts
deleted file mode 100644 (file)
index f37e787..0000000
+++ /dev/null
@@ -1,240 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Acl.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Acl.ts
deleted file mode 100644 (file)
index 88e1391..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Button.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Button.ts
deleted file mode 100644 (file)
index c88f198..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Captcha.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Captcha.ts
deleted file mode 100644 (file)
index 74e9c8d..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Checkboxes.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Checkboxes.ts
deleted file mode 100644 (file)
index 33fdfd0..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Checked.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Checked.ts
deleted file mode 100644 (file)
index 4010527..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Label.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Label.ts
deleted file mode 100644 (file)
index 91096fa..0000000
+++ /dev/null
@@ -1,132 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Rating.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/Rating.ts
deleted file mode 100644 (file)
index ec5e8da..0000000
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Date.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Date.ts
deleted file mode 100644 (file)
index 7ceb95d..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Abstract.ts
deleted file mode 100644 (file)
index a47318e..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Abstract.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Abstract.ts
deleted file mode 100644 (file)
index e829f75..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Default.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Default.ts
deleted file mode 100644 (file)
index 91e7188..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Tab.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/Tab.ts
deleted file mode 100644 (file)
index a6dd045..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/TabMenu.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Container/TabMenu.ts
deleted file mode 100644 (file)
index d4d4d04..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Empty.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Empty.ts
deleted file mode 100644 (file)
index 04c9171..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/IsNotClicked.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/IsNotClicked.ts
deleted file mode 100644 (file)
index f7bb415..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Manager.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Manager.ts
deleted file mode 100644 (file)
index 7effcac..0000000
+++ /dev/null
@@ -1,297 +0,0 @@
-/**
- * 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);
-      });
-    });
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/NonEmpty.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/NonEmpty.ts
deleted file mode 100644 (file)
index 03faca5..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Value.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Dependency/Value.ts
deleted file mode 100644 (file)
index 9ecf5ac..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Field.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Field.ts
deleted file mode 100644 (file)
index 5ae819d..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/ItemList.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/ItemList.ts
deleted file mode 100644 (file)
index e83c501..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Language/ContentLanguage.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Language/ContentLanguage.ts
deleted file mode 100644 (file)
index bccb45e..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/RadioButton.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/RadioButton.ts
deleted file mode 100644 (file)
index cd5ff2b..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/SimpleAcl.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/SimpleAcl.ts
deleted file mode 100644 (file)
index 0eb2226..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Tag.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Tag.ts
deleted file mode 100644 (file)
index a56b322..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/User.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/User.ts
deleted file mode 100644 (file)
index 70f419a..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Value.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Value.ts
deleted file mode 100644 (file)
index 89fb2ed..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/ValueI18n.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/ValueI18n.ts
deleted file mode 100644 (file)
index 8cafa4b..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Attachment.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Attachment.ts
deleted file mode 100644 (file)
index 76f8cba..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Poll.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Poll.ts
deleted file mode 100644 (file)
index a7541cd..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Manager.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Form/Builder/Manager.ts
deleted file mode 100644 (file)
index 4f030aa..0000000
+++ /dev/null
@@ -1,166 +0,0 @@
-/**
- * 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,
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/I18n/Plural.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/I18n/Plural.ts
deleted file mode 100644 (file)
index 2f104d8..0000000
+++ /dev/null
@@ -1,759 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Image/ExifUtil.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Image/ExifUtil.ts
deleted file mode 100644 (file)
index 867ff17..0000000
+++ /dev/null
@@ -1,179 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Image/ImageUtil.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Image/ImageUtil.ts
deleted file mode 100644 (file)
index 5d09090..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Image/Resizer.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Image/Resizer.ts
deleted file mode 100644 (file)
index 2ce812b..0000000
+++ /dev/null
@@ -1,203 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Language.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Language.ts
deleted file mode 100644 (file)
index 9b34920..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Chooser.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Chooser.ts
deleted file mode 100644 (file)
index f971a73..0000000
+++ /dev/null
@@ -1,298 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Input.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Input.ts
deleted file mode 100644 (file)
index 7c41431..0000000
+++ /dev/null
@@ -1,508 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Text.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Language/Text.ts
deleted file mode 100644 (file)
index 3070681..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/List.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/List.ts
deleted file mode 100644 (file)
index 6863b5f..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Clipboard.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Clipboard.ts
deleted file mode 100644 (file)
index 7286e28..0000000
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * 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));
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Data.ts
deleted file mode 100644 (file)
index 5484d7e..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * @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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Editor.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Editor.ts
deleted file mode 100644 (file)
index 89168e0..0000000
+++ /dev/null
@@ -1,415 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/List/Upload.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/List/Upload.ts
deleted file mode 100644 (file)
index 7ac7a98..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Base.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Base.ts
deleted file mode 100644 (file)
index ad0212e..0000000
+++ /dev/null
@@ -1,549 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Editor.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Editor.ts
deleted file mode 100644 (file)
index 1c5817c..0000000
+++ /dev/null
@@ -1,368 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Search.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Search.ts
deleted file mode 100644 (file)
index 00fce33..0000000
+++ /dev/null
@@ -1,184 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Select.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Manager/Select.ts
deleted file mode 100644 (file)
index 31a8332..0000000
+++ /dev/null
@@ -1,192 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Replace.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Replace.ts
deleted file mode 100644 (file)
index 17ad8fc..0000000
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Upload.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Media/Upload.ts
deleted file mode 100644 (file)
index b0c13b1..0000000
+++ /dev/null
@@ -1,311 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Notification/Handler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Notification/Handler.ts
deleted file mode 100644 (file)
index 2652304..0000000
+++ /dev/null
@@ -1,260 +0,0 @@
-/**
- * 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);
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/NumberUtil.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/NumberUtil.ts
deleted file mode 100644 (file)
index f9ed77c..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * 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}`;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/ObjectMap.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/ObjectMap.ts
deleted file mode 100644 (file)
index cbfd259..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Permission.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Permission.ts
deleted file mode 100644 (file)
index 9115ab1..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Prism.d.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Prism.d.ts
deleted file mode 100644 (file)
index 10e5166..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-import Prism from "prismjs";
-
-export default Prism;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Prism.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Prism.js
deleted file mode 100644 (file)
index 68b0954..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * 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;
-});
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Prism/Helper.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Prism/Helper.ts
deleted file mode 100644 (file)
index 83d71ac..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/StringUtil.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/StringUtil.ts
deleted file mode 100644 (file)
index 9c7c3b4..0000000
+++ /dev/null
@@ -1,143 +0,0 @@
-/**
- * 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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
-}
-
-/**
- * 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(/&amp;/g, "&")
-    .replace(/&quot;/g, '"')
-    .replace(/&lt;/g, "<")
-    .replace(/&gt;/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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Template.grammar.d.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Template.grammar.d.ts
deleted file mode 100644 (file)
index c855d67..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export function parse(input: string): unknown;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Template.grammar.jison b/wcfsetup/install/files/ts/WoltLabSuite/Core/Template.grammar.jison
deleted file mode 100644 (file)
index 2087181..0000000
+++ /dev/null
@@ -1,184 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Template.grammar.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Template.grammar.js
deleted file mode 100644 (file)
index 17a0c79..0000000
+++ /dev/null
@@ -1,748 +0,0 @@
-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;
-});
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Template.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Template.ts
deleted file mode 100644 (file)
index b43694b..0000000
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Timer/Repeating.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Timer/Repeating.ts
deleted file mode 100644 (file)
index 82fde69..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Acl/Simple.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Acl/Simple.ts
deleted file mode 100644 (file)
index 47abca9..0000000
+++ /dev/null
@@ -1,102 +0,0 @@
-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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Alignment.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Alignment.ts
deleted file mode 100644 (file)
index 864f025..0000000
+++ /dev/null
@@ -1,316 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Article/MarkAllAsRead.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Article/MarkAllAsRead.ts
deleted file mode 100644 (file)
index 4a7c8ad..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * 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();
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Article/Search.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Article/Search.ts
deleted file mode 100644 (file)
index 6fca7bb..0000000
+++ /dev/null
@@ -1,159 +0,0 @@
-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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/CloseOverlay.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/CloseOverlay.ts
deleted file mode 100644 (file)
index d01867f..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Color/Picker.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Color/Picker.ts
deleted file mode 100644 (file)
index 53ebccc..0000000
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Add.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Add.ts
deleted file mode 100644 (file)
index aee0293..0000000
+++ /dev/null
@@ -1,348 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Edit.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Edit.ts
deleted file mode 100644 (file)
index 4646f8a..0000000
+++ /dev/null
@@ -1,329 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Response/Add.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Response/Add.ts
deleted file mode 100644 (file)
index 31791ca..0000000
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Response/Edit.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Comment/Response/Edit.ts
deleted file mode 100644 (file)
index 95d5b37..0000000
+++ /dev/null
@@ -1,143 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Confirmation.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Confirmation.ts
deleted file mode 100644 (file)
index 846e54b..0000000
+++ /dev/null
@@ -1,216 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog.ts
deleted file mode 100644 (file)
index d09f55f..0000000
+++ /dev/null
@@ -1,920 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dialog/Data.ts
deleted file mode 100644 (file)
index 02eb403..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-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>;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/DragAndDrop.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/DragAndDrop.ts
deleted file mode 100644 (file)
index e3f2e58..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Builder.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Builder.ts
deleted file mode 100644 (file)
index 53e65c3..0000000
+++ /dev/null
@@ -1,221 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Data.ts
deleted file mode 100644 (file)
index bd494c2..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-export type NotificationAction = "close" | "open";
-export type NotificationCallback = (containerId: string, action: NotificationAction) => void;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Reusable.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Reusable.ts
deleted file mode 100644 (file)
index f6c9c92..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts
deleted file mode 100644 (file)
index 52b6dd2..0000000
+++ /dev/null
@@ -1,618 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/File/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/File/Data.ts
deleted file mode 100644 (file)
index c13af33..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-// This helper interface exists to prevent a circular dependency
-// between `./Delete` and `./Upload`
-
-export interface FileUploadHandler {
-  checkMaxFiles(): void;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/File/Delete.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/File/Delete.ts
deleted file mode 100644 (file)
index e973743..0000000
+++ /dev/null
@@ -1,179 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/File/Upload.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/File/Upload.ts
deleted file mode 100644 (file)
index 24cb0b1..0000000
+++ /dev/null
@@ -1,264 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/FlexibleMenu.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/FlexibleMenu.ts
deleted file mode 100644 (file)
index 134ba44..0000000
+++ /dev/null
@@ -1,195 +0,0 @@
-/**
- * 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();
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList.ts
deleted file mode 100644 (file)
index 8d3de3b..0000000
+++ /dev/null
@@ -1,580 +0,0 @@
-/**
- * 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[];
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList/Filter.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList/Filter.ts
deleted file mode 100644 (file)
index 539e5e8..0000000
+++ /dev/null
@@ -1,374 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList/Static.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList/Static.ts
deleted file mode 100644 (file)
index 1b45ab2..0000000
+++ /dev/null
@@ -1,443 +0,0 @@
-/**
- * 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));
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList/User.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/ItemList/User.ts
deleted file mode 100644 (file)
index b4d1fe0..0000000
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Like/Handler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Like/Handler.ts
deleted file mode 100644 (file)
index db4d845..0000000
+++ /dev/null
@@ -1,307 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/InlineEditor.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/InlineEditor.ts
deleted file mode 100644 (file)
index 90d0e37..0000000
+++ /dev/null
@@ -1,737 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Manager.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Manager.ts
deleted file mode 100644 (file)
index f4403ef..0000000
+++ /dev/null
@@ -1,287 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Quote.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Quote.ts
deleted file mode 100644 (file)
index f4c29aa..0000000
+++ /dev/null
@@ -1,553 +0,0 @@
-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 === &hellip;
-        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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Reply.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Reply.ts
deleted file mode 100644 (file)
index 79870f6..0000000
+++ /dev/null
@@ -1,426 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Share.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Share.ts
deleted file mode 100644 (file)
index 5325366..0000000
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * 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));
-      }
-    });
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/TwitterEmbed.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/TwitterEmbed.ts
deleted file mode 100644 (file)
index cdfb4be..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * 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);
-    }
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/UserConsent.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/UserConsent.ts
deleted file mode 100644 (file)
index 723df19..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * 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();
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Mobile.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Mobile.ts
deleted file mode 100644 (file)
index a82d20c..0000000
+++ /dev/null
@@ -1,446 +0,0 @@
-/**
- * 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");
-    }
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Notification.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Notification.ts
deleted file mode 100644 (file)
index 47d7f59..0000000
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Action.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Action.ts
deleted file mode 100644 (file)
index b5cdada..0000000
+++ /dev/null
@@ -1,266 +0,0 @@
-/**
- * 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();
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Header/Fixed.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Header/Fixed.ts
deleted file mode 100644 (file)
index 207f62b..0000000
+++ /dev/null
@@ -1,128 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Header/Menu.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Header/Menu.ts
deleted file mode 100644 (file)
index e72e5bb..0000000
+++ /dev/null
@@ -1,217 +0,0 @@
-/**
- * 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,
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/JumpTo.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/JumpTo.ts
deleted file mode 100644 (file)
index 44f4dda..0000000
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Menu/Abstract.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Menu/Abstract.ts
deleted file mode 100644 (file)
index 53b759d..0000000
+++ /dev/null
@@ -1,586 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Menu/Main.ts
deleted file mode 100644 (file)
index 68194d3..0000000
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Menu/User.ts
deleted file mode 100644 (file)
index dd26e3b..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Search.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Search.ts
deleted file mode 100644 (file)
index cfc95d6..0000000
+++ /dev/null
@@ -1,156 +0,0 @@
-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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Search/Handler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Search/Handler.ts
deleted file mode 100644 (file)
index 063b4a7..0000000
+++ /dev/null
@@ -1,198 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Search/Input.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Page/Search/Input.ts
deleted file mode 100644 (file)
index 4d00dcf..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Pagination.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Pagination.ts
deleted file mode 100644 (file)
index 7aee1af..0000000
+++ /dev/null
@@ -1,288 +0,0 @@
-/**
- * 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") + '">&hellip;</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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Poll/Editor.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Poll/Editor.ts
deleted file mode 100644 (file)
index c41251b..0000000
+++ /dev/null
@@ -1,393 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/CountButtons.ts
deleted file mode 100644 (file)
index 77d7b8b..0000000
+++ /dev/null
@@ -1,261 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Data.ts
deleted file mode 100644 (file)
index f27516c..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-export interface Reaction {
-  title: string;
-  renderedIcon: string;
-  iconPath: string;
-  showOrder: number;
-  reactionTypeID: number;
-  isAssignable: 1 | 0;
-}
-
-export interface ReactionStats {
-  [key: string]: number;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts
deleted file mode 100644 (file)
index dff07fe..0000000
+++ /dev/null
@@ -1,446 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Profile/Loader.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Profile/Loader.ts
deleted file mode 100644 (file)
index 26d709c..0000000
+++ /dev/null
@@ -1,192 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Article.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Article.ts
deleted file mode 100644 (file)
index f245141..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Autosave.ts
deleted file mode 100644 (file)
index 1bb6e3c..0000000
+++ /dev/null
@@ -1,354 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Code.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Code.ts
deleted file mode 100644 (file)
index 324531f..0000000
+++ /dev/null
@@ -1,268 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/DragAndDrop.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/DragAndDrop.ts
deleted file mode 100644 (file)
index 343b3d7..0000000
+++ /dev/null
@@ -1,212 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Editor.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Editor.ts
deleted file mode 100644 (file)
index a1b0228..0000000
+++ /dev/null
@@ -1,74 +0,0 @@
-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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Format.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Format.ts
deleted file mode 100644 (file)
index d4891a4..0000000
+++ /dev/null
@@ -1,472 +0,0 @@
-/**
- * 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(/&nbsp;/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();
-      }
-    }
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Html.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Html.ts
deleted file mode 100644 (file)
index a31d89b..0000000
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Link.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Link.ts
deleted file mode 100644 (file)
index 3ca2631..0000000
+++ /dev/null
@@ -1,106 +0,0 @@
-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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Mention.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Mention.ts
deleted file mode 100644 (file)
index 371f8b7..0000000
+++ /dev/null
@@ -1,448 +0,0 @@
-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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Metacode.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Metacode.ts
deleted file mode 100644 (file)
index 94d30bb..0000000
+++ /dev/null
@@ -1,147 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Page.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Page.ts
deleted file mode 100644 (file)
index 8a8c5be..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/PseudoHeader.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/PseudoHeader.ts
deleted file mode 100644 (file)
index b6d7023..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Quote.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Quote.ts
deleted file mode 100644 (file)
index 78090e7..0000000
+++ /dev/null
@@ -1,297 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Spoiler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Spoiler.ts
deleted file mode 100644 (file)
index f6e760c..0000000
+++ /dev/null
@@ -1,201 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Table.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Redactor/Table.ts
deleted file mode 100644 (file)
index 5089a83..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Screen.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Screen.ts
deleted file mode 100644 (file)
index 9e330b8..0000000
+++ /dev/null
@@ -1,266 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Scroll.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Scroll.ts
deleted file mode 100644 (file)
index afee101..0000000
+++ /dev/null
@@ -1,95 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Data.ts
deleted file mode 100644 (file)
index dc0d309..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Input.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Input.ts
deleted file mode 100644 (file)
index 934ef2f..0000000
+++ /dev/null
@@ -1,376 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Page.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Search/Page.ts
deleted file mode 100644 (file)
index ac90849..0000000
+++ /dev/null
@@ -1,109 +0,0 @@
-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();
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Smiley/Insert.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Smiley/Insert.ts
deleted file mode 100644 (file)
index d088bd2..0000000
+++ /dev/null
@@ -1,95 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Sortable/List.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Sortable/List.ts
deleted file mode 100644 (file)
index 9742409..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Style/FontAwesome.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Style/FontAwesome.ts
deleted file mode 100644 (file)
index 39b0aec..0000000
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Suggestion.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Suggestion.ts
deleted file mode 100644 (file)
index 177572f..0000000
+++ /dev/null
@@ -1,276 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/TabMenu.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/TabMenu.ts
deleted file mode 100644 (file)
index 21fdedd..0000000
+++ /dev/null
@@ -1,354 +0,0 @@
-/**
- * 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);
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/TabMenu/Simple.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/TabMenu/Simple.ts
deleted file mode 100644 (file)
index b2eb37e..0000000
+++ /dev/null
@@ -1,444 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Toggle/Input.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Toggle/Input.ts
deleted file mode 100644 (file)
index b203f10..0000000
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * 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[];
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Tooltip.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Tooltip.ts
deleted file mode 100644 (file)
index ee91a07..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * 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);
-    }
-  });
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Activity/Recent.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Activity/Recent.ts
deleted file mode 100644 (file)
index 41f196f..0000000
+++ /dev/null
@@ -1,106 +0,0 @@
-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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Delete.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Delete.ts
deleted file mode 100644 (file)
index d3bd4b5..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * 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);
-  }
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Upload.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/CoverPhoto/Upload.ts
deleted file mode 100644 (file)
index af4319e..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Editor.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Editor.ts
deleted file mode 100644 (file)
index d845083..0000000
+++ /dev/null
@@ -1,263 +0,0 @@
-/**
- * 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();
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Ignore.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Ignore.ts
deleted file mode 100644 (file)
index 4903116..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * 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);
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/List.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/List.ts
deleted file mode 100644 (file)
index cd4c0c0..0000000
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * 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;
-  };
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Multifactor/Totp/Qr.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Multifactor/Totp/Qr.ts
deleted file mode 100644 (file)
index f00dd0f..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-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));
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/PasswordStrength.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/PasswordStrength.ts
deleted file mode 100644 (file)
index ac5323d..0000000
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * 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"];
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Abstract.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Abstract.ts
deleted file mode 100644 (file)
index 21fa444..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Follow.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Follow.ts
deleted file mode 100644 (file)
index e41aeb2..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Ignore.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Profile/Menu/Item/Ignore.ts
deleted file mode 100644 (file)
index debe11b..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Search/Input.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Search/Input.ts
deleted file mode 100644 (file)
index bd8dd6e..0000000
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Session/Delete.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Session/Delete.ts
deleted file mode 100644 (file)
index dc05dfd..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * 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;
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Trophy/List.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/User/Trophy/List.ts
deleted file mode 100644 (file)
index 798756f..0000000
+++ /dev/null
@@ -1,158 +0,0 @@
-/**
- * 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;
-  };
-}
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Upload.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Upload.ts
deleted file mode 100644 (file)
index b7b6cd3..0000000
+++ /dev/null
@@ -1,422 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Upload/Data.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Upload/Data.ts
deleted file mode 100644 (file)
index 7a641a6..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/User.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/User.ts
deleted file mode 100644 (file)
index 54dfeb3..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * 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;
-  },
-};
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Wrapper/FacebookSdk.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Wrapper/FacebookSdk.ts
deleted file mode 100644 (file)
index 9f58338..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * 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;
diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/prism-meta.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/prism-meta.ts
deleted file mode 100644 (file)
index e54a153..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-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;