Fix inserting multiple media files via clipboard
[GitHub/WoltLab/WCF.git] / ts / WoltLabSuite / Core / Media / Manager / Editor.ts
1 /**
2 * Provides the media manager dialog for selecting media for Redactor editors.
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/Manager/Editor
8 */
9
10 import MediaManager from "./Base";
11 import * as Core from "../../Core";
12 import { Media, MediaInsertType, MediaManagerEditorOptions, MediaUploadSuccessEventData } from "../Data";
13 import * as EventHandler from "../../Event/Handler";
14 import * as DomTraverse from "../../Dom/Traverse";
15 import * as Language from "../../Language";
16 import * as UiDialog from "../../Ui/Dialog";
17 import * as Clipboard from "../../Controller/Clipboard";
18 import { OnDropPayload } from "../../Ui/Redactor/DragAndDrop";
19 import DomUtil from "../../Dom/Util";
20
21 interface PasteFromClipboard {
22 blob: Blob;
23 }
24
25 class MediaManagerEditor extends MediaManager<MediaManagerEditorOptions> {
26 protected _activeButton;
27 protected readonly _buttons: HTMLCollectionOf<HTMLElement>;
28 protected _mediaToInsert: Map<number, Media>;
29 protected _mediaToInsertByClipboard: boolean;
30 protected _uploadData: OnDropPayload | PasteFromClipboard | null;
31 protected _uploadId: number | null;
32
33 constructor(options: Partial<MediaManagerEditorOptions>) {
34 options = Core.extend(
35 {
36 callbackInsert: null,
37 },
38 options,
39 );
40
41 super(options);
42
43 this._forceClipboard = true;
44 this._activeButton = null;
45 const context = this._options.editor ? this._options.editor.core.toolbar()[0] : undefined;
46 this._buttons = (context || window.document).getElementsByClassName(
47 this._options.buttonClass || "jsMediaEditorButton",
48 ) as HTMLCollectionOf<HTMLElement>;
49 Array.from(this._buttons).forEach((button) => {
50 button.addEventListener("click", (ev) => this._click(ev));
51 });
52 this._mediaToInsert = new Map<number, Media>();
53 this._mediaToInsertByClipboard = false;
54 this._uploadData = null;
55 this._uploadId = null;
56
57 if (this._options.editor && !this._options.editor.opts.woltlab.attachments) {
58 const editorId = this._options.editor.$editor[0].dataset.elementId as string;
59
60 const uuid1 = EventHandler.add("com.woltlab.wcf.redactor2", `dragAndDrop_${editorId}`, (data: OnDropPayload) =>
61 this._editorUpload(data),
62 );
63 const uuid2 = EventHandler.add(
64 "com.woltlab.wcf.redactor2",
65 `pasteFromClipboard_${editorId}`,
66 (data: OnDropPayload) => this._editorUpload(data),
67 );
68
69 EventHandler.add("com.woltlab.wcf.redactor2", `destroy_${editorId}`, () => {
70 EventHandler.remove("com.woltlab.wcf.redactor2", `dragAndDrop_${editorId}`, uuid1);
71 EventHandler.remove("com.woltlab.wcf.redactor2", `dragAndDrop_${editorId}`, uuid2);
72 });
73
74 EventHandler.add("com.woltlab.wcf.media.upload", "success", (data) => this._mediaUploaded(data));
75 }
76 }
77
78 protected _addButtonEventListeners(): void {
79 super._addButtonEventListeners();
80
81 if (!this._mediaManagerMediaList) {
82 return;
83 }
84
85 DomTraverse.childrenByTag(this._mediaManagerMediaList, "LI").forEach((listItem) => {
86 const insertIcon = listItem.querySelector(".jsMediaInsertButton");
87 if (insertIcon) {
88 insertIcon.classList.remove("jsMediaInsertButton");
89 insertIcon.addEventListener("click", (ev) => this._openInsertDialog(ev));
90 }
91 });
92 }
93
94 /**
95 * Builds the dialog to setup inserting media files.
96 */
97 protected _buildInsertDialog(): void {
98 let thumbnailOptions = "";
99
100 this._getThumbnailSizes().forEach((thumbnailSize) => {
101 thumbnailOptions +=
102 '<option value="' +
103 thumbnailSize +
104 '">' +
105 Language.get("wcf.media.insert.imageSize." + thumbnailSize) +
106 "</option>";
107 });
108 thumbnailOptions += '<option value="original">' + Language.get("wcf.media.insert.imageSize.original") + "</option>";
109
110 const dialog = `
111 <div class="section">
112 <dl class="thumbnailSizeSelection">
113 <dt>${Language.get("wcf.media.insert.imageSize")}</dt>
114 <dd>
115 <select name="thumbnailSize">
116 ${thumbnailOptions}
117 </select>
118 </dd>
119 </dl>
120 </div>
121 <div class="formSubmit">
122 <button class="buttonPrimary">${Language.get("wcf.global.button.insert")}</button>
123 </div>`;
124
125 UiDialog.open({
126 _dialogSetup: () => {
127 return {
128 id: this._getInsertDialogId(),
129 options: {
130 onClose: () => this._editorClose(),
131 onSetup: (content) => {
132 content.querySelector(".buttonPrimary")!.addEventListener("click", (ev) => this._insertMedia(ev));
133
134 DomUtil.show(content.querySelector(".thumbnailSizeSelection") as HTMLElement);
135 },
136 title: Language.get("wcf.media.insert"),
137 },
138 source: dialog,
139 };
140 },
141 });
142 }
143
144 protected _click(event: Event): void {
145 this._activeButton = event.currentTarget;
146
147 super._click(event);
148 }
149
150 protected _dialogShow(): void {
151 super._dialogShow();
152
153 // check if data needs to be uploaded
154 if (this._uploadData) {
155 const fileUploadData = this._uploadData as OnDropPayload;
156 if (fileUploadData.file) {
157 this._upload.uploadFile(fileUploadData.file);
158 } else {
159 const blobUploadData = this._uploadData as PasteFromClipboard;
160 this._uploadId = this._upload.uploadBlob(blobUploadData.blob);
161 }
162
163 this._uploadData = null;
164 }
165 }
166
167 /**
168 * Handles pasting and dragging and dropping files into the editor.
169 */
170 protected _editorUpload(data: OnDropPayload): void {
171 this._uploadData = data;
172
173 UiDialog.open(this);
174 }
175
176 /**
177 * Returns the id of the insert dialog based on the media files to be inserted.
178 */
179 protected _getInsertDialogId(): string {
180 return ["mediaInsert", ...this._mediaToInsert.keys()].join("-");
181 }
182
183 /**
184 * Returns the supported thumbnail sizes (excluding `original`) for all media images to be inserted.
185 */
186 protected _getThumbnailSizes(): string[] {
187 return ["small", "medium", "large"]
188 .map((size) => {
189 const sizeSupported = Array.from(this._mediaToInsert.values()).every((media) => {
190 return media[size + "ThumbnailType"] !== null;
191 });
192
193 if (sizeSupported) {
194 return size;
195 }
196
197 return null;
198 })
199 .filter((s) => s !== null) as string[];
200 }
201
202 /**
203 * Inserts media files into the editor.
204 */
205 protected _insertMedia(event?: Event | null, thumbnailSize?: string, closeEditor = false): void {
206 if (closeEditor === undefined) closeEditor = true;
207
208 // update insert options with selected values if method is called by clicking on 'insert' button
209 // in dialog
210 if (event) {
211 UiDialog.close(this._getInsertDialogId());
212
213 const dialogContent = (event.currentTarget as HTMLElement).closest(".dialogContent")!;
214 const thumbnailSizeSelect = dialogContent.querySelector("select[name=thumbnailSize]") as HTMLSelectElement;
215 thumbnailSize = thumbnailSizeSelect.value;
216 }
217
218 if (this._options.callbackInsert !== null) {
219 this._options.callbackInsert(this._mediaToInsert, MediaInsertType.Separate, thumbnailSize);
220 } else {
221 this._options.editor!.buffer.set();
222
223 this._mediaToInsert.forEach((media) => this._insertMediaItem(thumbnailSize, media));
224 }
225
226 if (this._mediaToInsertByClipboard) {
227 Clipboard.unmark("com.woltlab.wcf.media", Array.from(this._mediaToInsert.keys()));
228 }
229
230 this._mediaToInsert = new Map<number, Media>();
231 this._mediaToInsertByClipboard = false;
232
233 // close manager dialog
234 if (closeEditor) {
235 UiDialog.close(this);
236 }
237 }
238
239 /**
240 * Inserts a single media item into the editor.
241 */
242 protected _insertMediaItem(thumbnailSize: string | undefined, media: Media): void {
243 if (media.isImage) {
244 let available = "";
245 ["small", "medium", "large", "original"].some((size) => {
246 if (media[size + "ThumbnailHeight"] != 0) {
247 available = size;
248
249 if (thumbnailSize == size) {
250 return true;
251 }
252 }
253
254 return false;
255 });
256
257 thumbnailSize = available;
258
259 if (!thumbnailSize) {
260 thumbnailSize = "original";
261 }
262
263 let link = media.link;
264 if (thumbnailSize !== "original") {
265 link = media[thumbnailSize + "ThumbnailLink"];
266 }
267
268 this._options.editor!.insert.html(
269 `<img src="${link}" class="woltlabSuiteMedia" data-media-id="${media.mediaID}" data-media-size="${thumbnailSize}">`,
270 );
271 } else {
272 this._options.editor!.insert.text(`[wsm='${media.mediaID}'][/wsm]`);
273 }
274 }
275
276 /**
277 * Is called after media files are successfully uploaded to insert copied media.
278 */
279 protected _mediaUploaded(data: MediaUploadSuccessEventData): void {
280 if (this._uploadId !== null && this._upload === data.upload) {
281 if (
282 this._uploadId === data.uploadId ||
283 (Array.isArray(this._uploadId) && this._uploadId.indexOf(data.uploadId) !== -1)
284 ) {
285 this._mediaToInsert = new Map<number, Media>(data.media.entries());
286 this._insertMedia(null, "medium", false);
287
288 this._uploadId = null;
289 }
290 }
291 }
292
293 /**
294 * Handles clicking on the insert button.
295 */
296 protected _openInsertDialog(event: Event): void {
297 const target = event.currentTarget as HTMLElement;
298
299 this.insertMedia([~~target.dataset.objectId!]);
300 }
301
302 /**
303 * Is called to insert the media files with the given ids into an editor.
304 */
305 public clipboardInsertMedia(mediaIds: number[]): void {
306 this.insertMedia(mediaIds, true);
307 }
308
309 /**
310 * Prepares insertion of the media files with the given ids.
311 */
312 public insertMedia(mediaIds: number[], insertedByClipboard?: boolean): void {
313 this._mediaToInsert = new Map<number, Media>();
314 this._mediaToInsertByClipboard = insertedByClipboard || false;
315
316 // open the insert dialog if all media files are images
317 let imagesOnly = true;
318 mediaIds.forEach((mediaId) => {
319 const media = this._media.get(mediaId)!;
320 this._mediaToInsert.set(media.mediaID, media);
321
322 if (!media.isImage) {
323 imagesOnly = false;
324 }
325 });
326
327 if (imagesOnly) {
328 const thumbnailSizes = this._getThumbnailSizes();
329 if (thumbnailSizes.length) {
330 UiDialog.close(this);
331 const dialogId = this._getInsertDialogId();
332 if (UiDialog.getDialog(dialogId)) {
333 UiDialog.openStatic(dialogId, null);
334 } else {
335 this._buildInsertDialog();
336 }
337 } else {
338 this._insertMedia(undefined, "original");
339 }
340 } else {
341 this._insertMedia();
342 }
343 }
344
345 public getMode(): string {
346 return "editor";
347 }
348
349 public setupMediaElement(media: Media, mediaElement: HTMLElement): void {
350 super.setupMediaElement(media, mediaElement);
351
352 // add media insertion icon
353 const buttons = mediaElement.querySelector("nav.buttonGroupNavigation > ul")!;
354
355 const listItem = document.createElement("li");
356 listItem.className = "jsMediaInsertButton";
357 listItem.dataset.objectId = media.mediaID.toString();
358 buttons.appendChild(listItem);
359
360 listItem.innerHTML = `
361 <a>
362 <span class="icon icon16 fa-plus jsTooltip" title="${Language.get("wcf.global.button.insert")}"></span>
363 <span class="invisible">${Language.get("wcf.global.button.insert")}</span>
364 </a>`;
365 }
366 }
367
368 Core.enableLegacyInheritance(MediaManagerEditor);
369
370 export = MediaManagerEditor;