import { Arr, Cell, Obj, Optional, Type } from '@ephox/katamari'; import Editor from 'tinymce/core/api/Editor'; import { Dialog } from 'tinymce/core/api/ui/Ui'; import * as Options from '../api/Options'; import { dataToHtml } from '../core/DataToHtml'; import * as HtmlToData from '../core/HtmlToData'; import { isMediaElement } from '../core/Selection'; import * as Service from '../core/Service'; import { DialogSubData, MediaData, MediaDialogData } from '../core/Types'; import * as UpdateHtml from '../core/UpdateHtml'; type SourceInput = 'source' | 'altsource' | 'poster' | 'dimensions'; const extractMeta = (sourceInput: Exclude, data: MediaDialogData): Optional> => Obj.get(data, sourceInput).bind((mainData) => Obj.get(mainData, 'meta')); const getValue = (data: MediaDialogData, metaData: Record, sourceInput?: SourceInput) => (prop: keyof MediaDialogData): Record => { // Cases: // 1. Get the nested value prop (component is the executed urlinput) // 2. Get from metadata (a urlinput was executed but urlinput != this component) // 3. Not a urlinput so just get string // If prop === sourceInput do 1, 2 then 3, else do 2 then 1 or 3 // ASSUMPTION: we only want to get values for props that already exist in data const getFromData = (): Optional | DialogSubData> => Obj.get(data, prop); const getFromMetaData = (): Optional => Obj.get(metaData, prop); const getNonEmptyValue = (c: Record): Optional => Obj.get(c, 'value').bind((v: string) => v.length > 0 ? Optional.some(v) : Optional.none()); const getFromValueFirst = () => getFromData().bind((child) => Type.isObject(child) ? getNonEmptyValue(child as Record).orThunk(getFromMetaData) : getFromMetaData().orThunk(() => Optional.from(child as string))); const getFromMetaFirst = () => getFromMetaData().orThunk(() => getFromData().bind((child) => Type.isObject(child) ? getNonEmptyValue(child as Record) : Optional.from(child as string))); return { [prop]: (prop === sourceInput ? getFromValueFirst() : getFromMetaFirst()).getOr('') }; }; const getDimensions = (data: MediaDialogData, metaData: Record): MediaDialogData['dimensions'] => { const dimensions: MediaDialogData['dimensions'] = {}; Obj.get(data, 'dimensions').each((dims) => { Arr.each([ 'width', 'height' ] as ('width' | 'height')[], (prop) => { Obj.get(metaData, prop).orThunk(() => Obj.get(dims, prop)).each((value) => dimensions[prop] = value); }); }); return dimensions; }; const unwrap = (data: MediaDialogData, sourceInput?: SourceInput): MediaData => { const metaData = sourceInput && sourceInput !== 'dimensions' ? extractMeta(sourceInput, data).getOr({}) : {}; const get = getValue(data, metaData, sourceInput); return { ...get('source'), ...get('altsource'), ...get('poster'), ...get('embed'), ...getDimensions(data, metaData) } as MediaData; }; const wrap = (data: MediaData): MediaDialogData => { const wrapped: MediaDialogData = { ...data, source: { value: Obj.get(data, 'source').getOr('') }, altsource: { value: Obj.get(data, 'altsource').getOr('') }, poster: { value: Obj.get(data, 'poster').getOr('') } }; // Add additional size values that may or may not have been in the html Arr.each([ 'width', 'height' ] as const, (prop) => { Obj.get(data, prop).each((value) => { const dimensions: MediaDialogData['dimensions'] = wrapped.dimensions || {}; dimensions[prop] = value; wrapped.dimensions = dimensions; }); }); return wrapped; }; const handleError = (editor: Editor) => (error?: { msg: string }): void => { const errorMessage = error && error.msg ? 'Media embed handler error: ' + error.msg : 'Media embed handler threw unknown error.'; editor.notificationManager.open({ type: 'error', text: errorMessage }); }; const getEditorData = (editor: Editor): MediaData => { const element = editor.selection.getNode(); const snippet = isMediaElement(element) ? editor.serializer.serialize(element, { selection: true }) : ''; return { embed: snippet, ...HtmlToData.htmlToData(snippet, editor.schema) }; }; const addEmbedHtml = (api: Dialog.DialogInstanceApi, editor: Editor) => (response: Service.EmbedResult): void => { // Only set values if a URL has been defined if (Type.isString(response.url) && response.url.trim().length > 0) { const html = response.html; const snippetData = HtmlToData.htmlToData(html, editor.schema); const nuData: MediaData = { ...snippetData, source: response.url, embed: html }; api.setData(wrap(nuData)); } }; const selectPlaceholder = (editor: Editor, beforeObjects: HTMLElement[]): void => { const afterObjects = editor.dom.select('*[data-mce-object]'); // Find new image placeholder so we can select it for (let i = 0; i < beforeObjects.length; i++) { for (let y = afterObjects.length - 1; y >= 0; y--) { if (beforeObjects[i] === afterObjects[y]) { afterObjects.splice(y, 1); } } } editor.selection.select(afterObjects[0]); }; const handleInsert = (editor: Editor, html: string): void => { const beforeObjects = editor.dom.select('*[data-mce-object]'); editor.insertContent(html); selectPlaceholder(editor, beforeObjects); editor.nodeChanged(); }; const submitForm = (prevData: MediaData, newData: MediaData, editor: Editor): void => { newData.embed = UpdateHtml.updateHtml(newData.embed ?? '', newData, false, editor.schema); // Only fetch the embed HTML content if the URL has changed from what it previously was if (newData.embed && (prevData.source === newData.source || Service.isCached(newData.source))) { handleInsert(editor, newData.embed); } else { Service.getEmbedHtml(editor, newData) .then((response) => { handleInsert(editor, response.html); }).catch(handleError(editor)); } }; const showDialog = (editor: Editor): void => { const editorData = getEditorData(editor); const currentData = Cell(editorData); const initialData = wrap(editorData); const handleSource = (prevData: MediaData, api: Dialog.DialogInstanceApi): void => { const serviceData = unwrap(api.getData(), 'source'); // If a new URL is entered, then clear the embed html and fetch the new data if (prevData.source !== serviceData.source) { addEmbedHtml(win, editor)({ url: serviceData.source, html: '' }); Service.getEmbedHtml(editor, serviceData) .then(addEmbedHtml(win, editor)) .catch(handleError(editor)); } }; const handleEmbed = (api: Dialog.DialogInstanceApi): void => { const data = unwrap(api.getData()); const dataFromEmbed = HtmlToData.htmlToData(data.embed ?? '', editor.schema); api.setData(wrap(dataFromEmbed)); }; const handleUpdate = (api: Dialog.DialogInstanceApi, sourceInput: SourceInput): void => { const data = unwrap(api.getData(), sourceInput); const embed = dataToHtml(editor, data); api.setData(wrap({ ...data, embed })); }; const mediaInput: Dialog.UrlInputSpec[] = [{ name: 'source', type: 'urlinput', filetype: 'media', label: 'Source' }]; const sizeInput: Dialog.SizeInputSpec[] = !Options.hasDimensions(editor) ? [] : [{ type: 'sizeinput', name: 'dimensions', label: 'Constrain proportions', constrain: true }]; const generalTab = { title: 'General', name: 'general', items: Arr.flatten([ mediaInput, sizeInput ]) }; const embedTextarea: Dialog.TextAreaSpec = { type: 'textarea', name: 'embed', label: 'Paste your embed code below:' }; const embedTab = { title: 'Embed', items: [ embedTextarea ] }; const advancedFormItems: Dialog.BodyComponentSpec[] = []; if (Options.hasAltSource(editor)) { advancedFormItems.push({ name: 'altsource', type: 'urlinput', filetype: 'media', label: 'Alternative source URL' } ); } if (Options.hasPoster(editor)) { advancedFormItems.push({ name: 'poster', type: 'urlinput', filetype: 'image', label: 'Media poster (Image URL)' }); } const advancedTab = { title: 'Advanced', name: 'advanced', items: advancedFormItems }; const tabs = [ generalTab, embedTab ]; if (advancedFormItems.length > 0) { tabs.push(advancedTab); } const body: Dialog.TabPanelSpec = { type: 'tabpanel', tabs }; const win = editor.windowManager.open({ title: 'Insert/Edit Media', size: 'normal', body, buttons: [ { type: 'cancel', name: 'cancel', text: 'Cancel' }, { type: 'submit', name: 'save', text: 'Save', primary: true } ], onSubmit: (api) => { const serviceData = unwrap(api.getData()); submitForm(currentData.get(), serviceData, editor); api.close(); }, onChange: (api, detail) => { switch (detail.name) { case 'source': handleSource(currentData.get(), api); break; case 'embed': handleEmbed(api); break; case 'dimensions': case 'altsource': case 'poster': handleUpdate(api, detail.name); break; default: break; } currentData.set(unwrap(api.getData())); }, initialData }); }; export { showDialog, unwrap };