Apply suggestions from code review
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / ts / WoltLabSuite / Core / Media / Editor.ts
1 /**
2 * Handles editing media files via dialog.
3 *
4 * @author Matthias Schmidt
5 * @copyright 2001-2021 WoltLab GmbH
6 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7 * @module WoltLabSuite/Core/Media/Editor
8 */
9
10 import * as Core from "../Core";
11 import { Media, MediaEditorCallbackObject } from "./Data";
12 import { AjaxCallbackObject, AjaxCallbackSetup } from "../Ajax/Data";
13 import * as UiNotification from "../Ui/Notification";
14 import * as UiDialog from "../Ui/Dialog";
15 import { DialogCallbackObject } from "../Ui/Dialog/Data";
16 import * as LanguageChooser from "../Language/Chooser";
17 import * as LanguageInput from "../Language/Input";
18 import * as DomUtil from "../Dom/Util";
19 import * as DomTraverse from "../Dom/Traverse";
20 import DomChangeListener from "../Dom/Change/Listener";
21 import * as Language from "../Language";
22 import * as Ajax from "../Ajax";
23 import MediaReplace from "./Replace";
24 import { I18nValues } from "../Language/Input";
25
26 interface InitEditorData {
27 returnValues: {
28 availableLanguageCount: number;
29 categoryIDs: number[];
30 mediaData?: Media;
31 };
32 }
33
34 class MediaEditor implements AjaxCallbackObject {
35 protected _availableLanguageCount = 1;
36 protected _categoryIds: number[] = [];
37 protected _dialogs = new Map<string, DialogCallbackObject>();
38 protected readonly _callbackObject: MediaEditorCallbackObject;
39 protected _media: Media | null = null;
40 protected _oldCategoryId = 0;
41
42 constructor(callbackObject: MediaEditorCallbackObject) {
43 this._callbackObject = callbackObject || {};
44
45 if (this._callbackObject._editorClose && typeof this._callbackObject._editorClose !== "function") {
46 throw new TypeError("Callback object has no function '_editorClose'.");
47 }
48 if (this._callbackObject._editorSuccess && typeof this._callbackObject._editorSuccess !== "function") {
49 throw new TypeError("Callback object has no function '_editorSuccess'.");
50 }
51 }
52
53 public _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
54 return {
55 data: {
56 actionName: "update",
57 className: "wcf\\data\\media\\MediaAction",
58 },
59 };
60 }
61
62 public _ajaxSuccess(): void {
63 UiNotification.show();
64
65 if (this._callbackObject._editorSuccess) {
66 this._callbackObject._editorSuccess(this._media, this._oldCategoryId);
67 this._oldCategoryId = 0;
68 }
69
70 UiDialog.close(`mediaEditor_${this._media!.mediaID}`);
71
72 this._media = null;
73 }
74
75 /**
76 * Is called if an editor is manually closed by the user.
77 */
78 protected _close(): void {
79 this._media = null;
80
81 if (this._callbackObject._editorClose) {
82 this._callbackObject._editorClose();
83 }
84 }
85
86 /**
87 * Initializes the editor dialog.
88 *
89 * @since 5.3
90 */
91 protected _initEditor(content: HTMLElement, data: InitEditorData): void {
92 this._availableLanguageCount = ~~data.returnValues.availableLanguageCount;
93 this._categoryIds = data.returnValues.categoryIDs.map((number) => ~~number);
94
95 if (data.returnValues.mediaData) {
96 this._media = data.returnValues.mediaData;
97 }
98 const mediaId = this._media!.mediaID;
99
100 // make sure that the language chooser is initialized first
101 setTimeout(() => {
102 if (this._availableLanguageCount > 1) {
103 LanguageChooser.setLanguageId(
104 `mediaEditor_${mediaId}_languageID`,
105 this._media!.languageID || window.LANGUAGE_ID,
106 );
107 }
108
109 if (this._categoryIds.length) {
110 const categoryID = content.querySelector("select[name=categoryID]") as HTMLSelectElement;
111 if (this._media!.categoryID) {
112 categoryID.value = this._media!.categoryID.toString();
113 } else {
114 categoryID.value = "0";
115 }
116 }
117
118 const title = content.querySelector("input[name=title]") as HTMLInputElement;
119 const altText = content.querySelector("input[name=altText]") as HTMLInputElement;
120 const caption = content.querySelector("textarea[name=caption]") as HTMLInputElement;
121
122 if (this._availableLanguageCount > 1 && this._media!.isMultilingual) {
123 if (document.getElementById(`altText_${mediaId}`)) {
124 LanguageInput.setValues(`altText_${mediaId}`, (this._media!.altText || {}) as I18nValues);
125 }
126
127 if (document.getElementById(`caption_${mediaId}`)) {
128 LanguageInput.setValues(`caption_${mediaId}`, (this._media!.caption || {}) as I18nValues);
129 }
130
131 LanguageInput.setValues(`title_${mediaId}`, (this._media!.title || {}) as I18nValues);
132 } else {
133 title.value = this._media?.title[this._media!.languageID || window.LANGUAGE_ID] || "";
134 if (altText) {
135 altText.value = this._media?.altText[this._media!.languageID || window.LANGUAGE_ID] || "";
136 }
137 if (caption) {
138 caption.value = this._media?.caption[this._media!.languageID || window.LANGUAGE_ID] || "";
139 }
140 }
141
142 if (this._availableLanguageCount > 1) {
143 const isMultilingual = content.querySelector("input[name=isMultilingual]") as HTMLInputElement;
144 isMultilingual.addEventListener("change", (ev) => this._updateLanguageFields(ev));
145
146 this._updateLanguageFields(null, isMultilingual);
147 }
148
149 if (altText) {
150 altText.addEventListener("keypress", (ev) => this._keyPress(ev));
151 }
152 title.addEventListener("keypress", (ev) => this._keyPress(ev));
153
154 content.querySelector("button[data-type=submit]")!.addEventListener("click", () => this._saveData());
155
156 // remove focus from input elements and scroll dialog to top
157 (document.activeElement! as HTMLElement).blur();
158 (document.getElementById(`mediaEditor_${mediaId}`)!.parentNode as HTMLElement).scrollTop = 0;
159
160 // Initialize button to replace media file.
161 const uploadButton = content.querySelector(".mediaManagerMediaReplaceButton")!;
162 let target = content.querySelector(".mediaThumbnail");
163 if (!target) {
164 target = document.createElement("div");
165 content.appendChild(target);
166 }
167 new MediaReplace(
168 mediaId,
169 DomUtil.identify(uploadButton),
170 // Pass an anonymous element for non-images which is required internally
171 // but not needed in this case.
172 DomUtil.identify(target),
173 {
174 mediaEditor: this,
175 },
176 );
177
178 DomChangeListener.trigger();
179 }, 200);
180 }
181
182 /**
183 * Handles the `[ENTER]` key to submit the form.
184 */
185 protected _keyPress(event: KeyboardEvent): void {
186 if (event.key === "Enter") {
187 event.preventDefault();
188
189 this._saveData();
190 }
191 }
192
193 /**
194 * Saves the data of the currently edited media.
195 */
196 protected _saveData(): void {
197 const content = UiDialog.getDialog(`mediaEditor_${this._media!.mediaID}`)!.content;
198
199 const categoryId = content.querySelector("select[name=categoryID]") as HTMLSelectElement;
200 const altText = content.querySelector("input[name=altText]") as HTMLInputElement;
201 const caption = content.querySelector("textarea[name=caption]") as HTMLTextAreaElement;
202 const captionEnableHtml = content.querySelector("input[name=captionEnableHtml]") as HTMLInputElement;
203 const title = content.querySelector("input[name=title]") as HTMLInputElement;
204
205 let hasError = false;
206 const altTextError = altText ? DomTraverse.childByClass(altText.parentNode! as HTMLElement, "innerError") : false;
207 const captionError = caption ? DomTraverse.childByClass(caption.parentNode! as HTMLElement, "innerError") : false;
208 const titleError = DomTraverse.childByClass(title.parentNode! as HTMLElement, "innerError");
209
210 // category
211 this._oldCategoryId = this._media!.categoryID;
212 if (this._categoryIds.length) {
213 this._media!.categoryID = ~~categoryId.value;
214
215 // if the selected category id not valid (manipulated DOM), ignore
216 if (this._categoryIds.indexOf(this._media!.categoryID) === -1) {
217 this._media!.categoryID = 0;
218 }
219 }
220
221 // language and multilingualism
222 if (this._availableLanguageCount > 1) {
223 const isMultilingual = content.querySelector("input[name=isMultilingual]") as HTMLInputElement;
224 this._media!.isMultilingual = ~~isMultilingual.checked;
225 this._media!.languageID = this._media!.isMultilingual
226 ? null
227 : LanguageChooser.getLanguageId(`mediaEditor_${this._media!.mediaID}_languageID`);
228 } else {
229 this._media!.languageID = window.LANGUAGE_ID;
230 }
231
232 // altText, caption and title
233 this._media!.altText = {};
234 this._media!.caption = {};
235 this._media!.title = {};
236 if (this._availableLanguageCount > 1 && this._media!.isMultilingual) {
237 if (altText && !LanguageInput.validate(altText.id, true)) {
238 hasError = true;
239 if (!altTextError) {
240 DomUtil.innerError(altText, Language.get("wcf.global.form.error.multilingual"));
241 }
242 }
243 if (caption && !LanguageInput.validate(caption.id, true)) {
244 hasError = true;
245 if (!captionError) {
246 DomUtil.innerError(caption, Language.get("wcf.global.form.error.multilingual"));
247 }
248 }
249 if (!LanguageInput.validate(title.id, true)) {
250 hasError = true;
251 if (!titleError) {
252 DomUtil.innerError(title, Language.get("wcf.global.form.error.multilingual"));
253 }
254 }
255
256 this._media!.altText = altText ? this.mapToI18nValues(LanguageInput.getValues(altText.id)) : "";
257 this._media!.caption = caption ? this.mapToI18nValues(LanguageInput.getValues(caption.id)) : "";
258 this._media!.title = this.mapToI18nValues(LanguageInput.getValues(title.id));
259 } else {
260 this._media!.altText[this._media!.languageID!] = altText ? altText.value : "";
261 this._media!.caption[this._media!.languageID!] = caption ? caption.value : "";
262 this._media!.title[this._media!.languageID!] = title.value;
263 }
264
265 // captionEnableHtml
266 if (captionEnableHtml) {
267 this._media!.captionEnableHtml = ~~captionEnableHtml.checked;
268 } else {
269 this._media!.captionEnableHtml = 0;
270 }
271
272 const aclValues = {
273 allowAll: ~~(document.getElementById(`mediaEditor_${this._media!.mediaID}_aclAllowAll`)! as HTMLInputElement)
274 .checked,
275 group: Array.from(
276 content.querySelectorAll(`input[name="mediaEditor_${this._media!.mediaID}_aclValues[group][]"]`),
277 ).map((aclGroup: HTMLInputElement) => ~~aclGroup.value),
278 user: Array.from(
279 content.querySelectorAll(`input[name="mediaEditor_${this._media!.mediaID}_aclValues[user][]"]`),
280 ).map((aclUser: HTMLInputElement) => ~~aclUser.value),
281 };
282
283 if (!hasError) {
284 if (altTextError) {
285 altTextError.remove();
286 }
287 if (captionError) {
288 captionError.remove();
289 }
290 if (titleError) {
291 titleError.remove();
292 }
293
294 Ajax.api(this, {
295 actionName: "update",
296 objectIDs: [this._media!.mediaID],
297 parameters: {
298 aclValues: aclValues,
299 altText: this._media!.altText,
300 caption: this._media!.caption,
301 data: {
302 captionEnableHtml: this._media!.captionEnableHtml,
303 categoryID: this._media!.categoryID,
304 isMultilingual: this._media!.isMultilingual,
305 languageID: this._media!.languageID,
306 },
307 title: this._media!.title,
308 },
309 });
310 }
311 }
312
313 private mapToI18nValues(values: Map<number, string>): I18nValues {
314 const obj = {};
315 values.forEach((value, key) => (obj[key] = value));
316
317 return obj;
318 }
319
320 /**
321 * Updates language-related input fields depending on whether multilingualis is enabled.
322 */
323 protected _updateLanguageFields(event: Event | null, element?: HTMLInputElement): void {
324 if (event) {
325 element = event.currentTarget as HTMLInputElement;
326 }
327
328 const mediaId = this._media!.mediaID;
329 const languageChooserContainer = document.getElementById(`mediaEditor_${mediaId}_languageIDContainer`)!
330 .parentNode! as HTMLElement;
331
332 if (element!.checked) {
333 LanguageInput.enable(`title_${mediaId}`);
334 if (document.getElementById(`caption_${mediaId}`)) {
335 LanguageInput.enable(`caption_${mediaId}`);
336 }
337 if (document.getElementById(`altText_${mediaId}`)) {
338 LanguageInput.enable(`altText_${mediaId}`);
339 }
340
341 DomUtil.hide(languageChooserContainer);
342 } else {
343 LanguageInput.disable(`title_${mediaId}`);
344 if (document.getElementById(`caption_${mediaId}`)) {
345 LanguageInput.disable(`caption_${mediaId}`);
346 }
347 if (document.getElementById(`altText_${mediaId}`)) {
348 LanguageInput.disable(`altText_${mediaId}`);
349 }
350
351 DomUtil.show(languageChooserContainer);
352 }
353 }
354
355 /**
356 * Edits the media with the given data or id.
357 */
358 public edit(editedMedia: Media | number): void {
359 let media: Media;
360 let mediaId = 0;
361 if (typeof editedMedia === "object") {
362 media = editedMedia;
363 mediaId = media.mediaID;
364 } else {
365 media = {
366 mediaID: editedMedia,
367 } as Media;
368 mediaId = editedMedia;
369 }
370
371 if (this._media !== null) {
372 throw new Error(`Cannot edit media with id ${mediaId} while editing media with id '${this._media.mediaID}'.`);
373 }
374
375 this._media = media;
376
377 if (!this._dialogs.has(`mediaEditor_${mediaId}`)) {
378 this._dialogs.set(`mediaEditor_${mediaId}`, {
379 _dialogSetup: () => {
380 return {
381 id: `mediaEditor_${mediaId}`,
382 options: {
383 backdropCloseOnClick: false,
384 onClose: () => this._close(),
385 title: Language.get("wcf.media.edit"),
386 },
387 source: {
388 after: (content: HTMLElement, responseData: InitEditorData) => this._initEditor(content, responseData),
389 data: {
390 actionName: "getEditorDialog",
391 className: "wcf\\data\\media\\MediaAction",
392 objectIDs: [mediaId],
393 },
394 },
395 };
396 },
397 });
398 }
399
400 UiDialog.open(this._dialogs.get(`mediaEditor_${mediaId}`)!);
401 }
402
403 /**
404 * Updates the data of the currently edited media file.
405 */
406 public updateData(media: Media): void {
407 if (this._callbackObject._editorSuccess) {
408 this._callbackObject._editorSuccess(media);
409 }
410 }
411 }
412
413 Core.enableLegacyInheritance(MediaEditor);
414
415 export = MediaEditor;