UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

915 lines (808 loc) 31.7 kB
import { PluginModal } from '../../interfaces'; import { Modal, Figure } from '../../modules/contract'; import { dom, numbers, env, keyCodeMap } from '../../helper'; const { _w, NO_EVENT } = env; /** * @typedef {Object} EmbedPluginOptions * @property {boolean} [canResize=true] - Whether the embed element can be resized. * @property {boolean} [showHeightInput=true] - Whether to display the height input field. * @property {string} [defaultWidth] - The default width of the embed element (numeric value or with unit). * @property {string} [defaultHeight] - The default height of the embed element (numeric value or with unit). * @property {boolean} [percentageOnlySize=false] - Whether to allow only percentage-based sizing. * @property {string} [uploadUrl] - The URL for file uploads. * - The server must return: * ```js * { * "result": [ * { * "url": "https://example.com/embed.html", * "name": "embed.html", * "size": 2048 * } * ] * } * ``` * @property {Object<string, string>} [uploadHeaders] - Headers to include in file upload requests. * @property {number} [uploadSizeLimit] - The total file upload size limit in bytes. * @property {number} [uploadSingleSizeLimit] - The single file upload size limit in bytes. * @property {Object<string, string>} [iframeTagAttributes] - Additional attributes to set on the `IFRAME` tag. * ```js * { iframeTagAttributes: { allowfullscreen: 'true', loading: 'lazy' } } * ``` * @property {string} [query_youtube] - YouTube query parameter appended to the embed URL. * ```js * { query_youtube: 'autoplay=1&mute=1' } * ``` * @property {string} [query_vimeo] - Vimeo query parameter appended to the embed URL. * ```js * { query_vimeo: 'autoplay=1' } * ``` * @property {Array<RegExp>} [urlPatterns] - Additional URL patterns to recognize as embeddable content. * @property {Object<string, {pattern: RegExp, action: (url: string) => string, tag: string}>} [embedQuery] - Custom embed service definitions. * Each key is a service name, with `pattern` to match the URL, `action` to transform it into an embed URL, and `tag` for the output element. * ```js * { * embedQuery: { * facebook: { * pattern: /(?:https?:\/\/)?(?:www\.)?facebook\.com\/(.+)/i, * action: (url) => `https://www.facebook.com/plugins/post.php?href=${encodeURIComponent(url)}`, * tag: 'iframe' * } * } * } * ``` * @property {SunEditor.Module.Figure.Controls} [controls] - Figure controls. * @property {SunEditor.ComponentInsertType} [insertBehavior] - Component insertion behavior for selection and cursor placement. * - [default: `options.get('componentInsertBehavior')`] * - `auto`: Move cursor to the next line if possible, otherwise select the component. * - `select`: Always select the inserted component. * - `line`: Move cursor to the next line if possible, or create a new line and move there. * - `none`: Do nothing. */ /** * @class * @description Embed modal plugin. * - This plugin provides a modal interface for embedding external content * - (e.g., videos, `IFRAME` elements) into the editor. */ class Embed extends PluginModal { static key = 'embed'; static className = ''; /** * @param {HTMLElement} node - The node to check. * @returns {HTMLElement|null} Returns a node if the node is a valid component. */ static component(node) { const children = node.children; let src = ''; let target = null; if (dom.check.isIFrame(node)) { target = node; src = node.src; } if (!src && /^DIV$/i.test(node?.nodeName) && dom.check.isIFrame(children[0])) { target = children[0]; src = target.src; } if (!src && dom.utils.hasClass(node, 'se-embed-container')) { /** @type {*} */ const srcNode = dom.query.getChildNode(node, (current) => current.src || current.href); target = srcNode; src = target?.src || target?.href; } if (/^BLOCKQUOTE$/i.test(node?.nodeName)) { const link = node.querySelector('a'); if (link && link.href) { target = node; src = link.href; return this.#checkContentType(src) ? target : null; } } if (src) { return this.#checkContentType(src) ? target : null; } return target; } /** * @description Checks if the given URL matches any of the defined URL patterns. * @param {string} url - The URL to check. * @returns {boolean} `true` if the URL matches a known pattern; otherwise, `false`. */ static #checkContentType(url) { url = url?.toLowerCase() || ''; if (this.#urlPatterns.some((pattern) => pattern.test(url))) { return true; } return false; } /** @type {Array<RegExp>} */ static #urlPatterns = null; #defaultSizeX; #defaultSizeY; #origin_w; #origin_h; #resizing; #onlyPercentage; #nonResizing; #linkValue = ''; #align = 'none'; #element = null; #cover = null; #container = null; #ratio = { w: 0, h: 0 }; /** * @constructor * @param {SunEditor.Kernel} kernel - The Kernel instance * @param {EmbedPluginOptions} pluginOptions */ constructor(kernel, pluginOptions) { // plugin basic properties super(kernel); this.title = this.$.lang.embed; this.icon = 'embed'; // define plugin options this.pluginOptions = { canResize: pluginOptions.canResize === undefined ? true : pluginOptions.canResize, showHeightInput: pluginOptions.showHeightInput === undefined ? true : !!pluginOptions.showHeightInput, defaultWidth: !pluginOptions.defaultWidth || !numbers.get(pluginOptions.defaultWidth, 0) ? '' : numbers.is(pluginOptions.defaultWidth) ? pluginOptions.defaultWidth + 'px' : pluginOptions.defaultWidth, defaultHeight: !pluginOptions.defaultHeight || !numbers.get(pluginOptions.defaultHeight, 0) ? '' : numbers.is(pluginOptions.defaultHeight) ? pluginOptions.defaultHeight + 'px' : pluginOptions.defaultHeight, percentageOnlySize: !!pluginOptions.percentageOnlySize, uploadUrl: typeof pluginOptions.uploadUrl === 'string' ? pluginOptions.uploadUrl : null, uploadHeaders: pluginOptions.uploadHeaders || null, uploadSizeLimit: numbers.get(pluginOptions.uploadSizeLimit, 0), uploadSingleSizeLimit: numbers.get(pluginOptions.uploadSingleSizeLimit, 0), iframeTagAttributes: pluginOptions.iframeTagAttributes || null, query_youtube: pluginOptions.query_youtube || '', query_vimeo: pluginOptions.query_vimeo || '', insertBehavior: pluginOptions.insertBehavior, }; // create HTML const sizeUnit = this.pluginOptions.percentageOnlySize ? '%' : 'px'; const modalEl = CreateHTML_modal(this.$, this.pluginOptions); const figureControls = pluginOptions.controls || (!this.pluginOptions.canResize ? [['align', 'edit', 'copy', 'remove']] : [['resize_auto,75,50', 'align', 'edit', 'revert', 'copy', 'remove']]); // show align if (!figureControls.some((subArray) => subArray.includes('align'))) modalEl.figureAlignBtn.style.display = 'none'; // modules this.modal = new Modal(this, this.$, modalEl.html); this.figure = new Figure(this, this.$, figureControls, { sizeUnit: sizeUnit }); // members this.fileModalWrapper = modalEl.fileModalWrapper; this.embedInput = modalEl.embedInput; this.focusElement = this.embedInput; this.previewSrc = modalEl.previewSrc; this.sizeUnit = sizeUnit; this.proportion = null; this.inputX = null; this.inputY = null; this.#defaultSizeX = this.pluginOptions.defaultWidth; this.#defaultSizeY = this.pluginOptions.defaultHeight; this.#origin_w = this.pluginOptions.defaultWidth === 'auto' ? '' : this.pluginOptions.defaultWidth; this.#origin_h = this.pluginOptions.defaultHeight === 'auto' ? '' : this.pluginOptions.defaultHeight; this.#resizing = this.pluginOptions.canResize; this.#onlyPercentage = this.pluginOptions.percentageOnlySize; this.#nonResizing = !this.#resizing || !this.pluginOptions.showHeightInput || this.#onlyPercentage; this.query = { facebook: { pattern: /(?:https?:\/\/)?(?:www\.)?(?:facebook\.com)\/(.+)/i, action: (url) => { return `https://www.facebook.com/plugins/post.php?href=${encodeURIComponent(url)}&show_text=true&width=500`; }, tag: 'iframe', }, twitter: { pattern: /^(?:https?:\/\/)?(?:(?:www\.)?(?:twitter\.com|x\.com)\/(?:[^/?#]+\/)?status\/\d+(?:[/?#]|$)|platform\.twitter\.com\/embed\/Tweet\.html(?:[?#].*)?$)/i, action: (url) => { return `https://platform.twitter.com/embed/Tweet.html?url=${encodeURIComponent(url)}`; }, tag: 'iframe', }, instagram: { pattern: /(?:https?:\/\/)?(?:www\.)?(?:instagram\.com)\/p\/(.+)/i, action: (url) => { const postId = url.match(this.query.instagram.pattern)[1]; return `https://www.instagram.com/p/${postId}/embed`; }, tag: 'iframe', }, linkedin: { pattern: /(?:https?:\/\/)?(?:www\.)?(?:linkedin\.com)\/(.+)\/(.+)/i, action: (url) => { return `https://www.linkedin.com/embed/feed/update/${encodeURIComponent(url.split('/').pop())}`; }, tag: 'iframe', }, pinterest: { pattern: /(?:https?:\/\/)?(?:www\.)?(?:pinterest\.com)\/pin\/(.+)/i, action: (url) => { const pinId = url.match(this.query.pinterest.pattern)[1]; return `https://assets.pinterest.com/ext/embed.html?id=${pinId}`; }, tag: 'iframe', }, spotify: { pattern: /(?:https?:\/\/)?(?:open\.)?(?:spotify\.com)\/(track|album|playlist|show|episode)\/(.+)/i, action: (url) => { const match = url.match(this.query.spotify.pattern); const type = match[1]; const id = match[2]; return `https://open.spotify.com/embed/${type}/${id}`; }, tag: 'iframe', }, codepen: { pattern: /(?:https?:\/\/)?(?:www\.)?(?:codepen\.io)\/(.+)\/pen\/(.+)/i, action: (url) => { const [, user, penId] = url.match(this.query.codepen.pattern); return `https://codepen.io/${user}/embed/${penId}`; }, tag: 'iframe', }, ...pluginOptions.embedQuery, }; const urlPatterns = []; for (const key in this.query) { urlPatterns.push(this.query[key].pattern); } Embed.#urlPatterns = urlPatterns.concat(pluginOptions.urlPatterns || []); // init this.$.eventManager.addEvent(this.embedInput, 'input', this.#OnLinkPreview.bind(this)); if (this.#resizing) { this.proportion = modalEl.proportion; this.inputX = modalEl.inputX; this.inputY = modalEl.inputY; this.inputX.value = this.pluginOptions.defaultWidth; this.inputY.value = this.pluginOptions.defaultHeight; this.$.eventManager.addEvent(this.inputX, 'keyup', this.#OnInputSize.bind(this, 'x')); this.$.eventManager.addEvent(this.inputY, 'keyup', this.#OnInputSize.bind(this, 'y')); this.$.eventManager.addEvent(modalEl.revertBtn, 'click', this.#OnClickRevert.bind(this)); } } /** * @override * @type {PluginModal['open']} */ open() { this.modal.open(); } /** * @hook Editor.Core * @type {SunEditor.Hook.Core.RetainFormat} */ retainFormat() { return { query: 'iframe', /** @param {HTMLIFrameElement} element */ method: async (element) => { if (!Embed.#checkContentType(element.src)) return; const figureInfo = Figure.GetContainer(element); if (figureInfo && figureInfo.container && figureInfo.cover) return; this.#ready(element, true); const line = this.$.format.getLine(element); if (line) this.#align = line.style.textAlign || line.style.float; this.#fixTagStructure(element); }, }; } /** * @hook Modules.Modal * @type {SunEditor.Hook.Modal.On} */ modalOn(isUpdate) { if (!isUpdate && this.#resizing) { this.inputX.value = this.#origin_w = this.pluginOptions.defaultWidth === 'auto' ? '' : this.pluginOptions.defaultWidth; this.inputY.value = this.#origin_h = this.pluginOptions.defaultHeight === 'auto' ? '' : this.pluginOptions.defaultHeight; this.proportion.disabled = true; } else if (isUpdate) { this.#linkValue = this.previewSrc.textContent = this.embedInput.value = this.#cover.getAttribute('data-se-origin') || ''; } } /** * @hook Modules.Modal * @type {SunEditor.Hook.Modal.Action} */ async modalAction() { this.#align = /** @type {HTMLInputElement} */ (this.modal.form.querySelector('input[name="suneditor_embed_radio"]:checked')).value; let result = false; if (this.#linkValue.length > 0) { result = await this.submitSRC(this.#linkValue); } if (result) _w.setTimeout(this.$.component.select.bind(this.$.component, this.#element, Embed.key), 0); return result; } /** * @hook Modules.Modal * @type {SunEditor.Hook.Modal.Init} */ modalInit() { Modal.OnChangeFile(this.fileModalWrapper, []); this.#linkValue = this.previewSrc.textContent = this.embedInput.value = ''; /** @type {HTMLInputElement} */ (this.modal.form.querySelector('input[name="suneditor_embed_radio"][value="none"]')).checked = true; this.#ratio = { w: 0, h: 0 }; this.#nonResizing = false; if (this.#resizing) { this.inputX.value = this.pluginOptions.defaultWidth === this.#defaultSizeX ? '' : this.pluginOptions.defaultWidth; this.inputY.value = this.pluginOptions.defaultHeight === this.#defaultSizeY ? '' : this.pluginOptions.defaultHeight; this.proportion.checked = false; this.proportion.disabled = true; } } /** * @hook Editor.Component * @type {SunEditor.Hook.Component.Select} */ componentSelect(target) { this.#ready(target); } /** * @hook Editor.Component * @type {SunEditor.Hook.Component.Edit} */ componentEdit() { this.modal.open(); } /** * @hook Editor.Component * @type {SunEditor.Hook.Component.Destroy} */ async componentDestroy(target) { const targetEl = target || this.#element; const container = dom.query.getParentElement(targetEl, Figure.is) || targetEl; const focusEl = container.previousElementSibling || container.nextElementSibling; const emptyDiv = container.parentNode; const message = await this.$.eventManager.triggerEvent('onEmbedDeleteBefore', { element: targetEl, container, align: this.#align, url: this.#linkValue }); if (message === false) return; dom.utils.removeItem(container); this.modalInit(); if (emptyDiv !== this.$.frameContext.get('wysiwyg')) { this.$.nodeTransform.removeAllParents( emptyDiv, function (current) { return current.childNodes.length === 0; }, null, ); } // focus this.$.focusManager.focusEdge(focusEl); this.$.history.push(false); } /** * @description Finds and processes the URL for embedding by matching it against known service patterns. * @param {string} url - The original URL. * @returns {{origin: string, url: string, tag: string}|null} An object containing the original URL, the processed URL, and the tag type (e.g., `iframe`), * or `null` if no matching pattern is found. */ findProcessUrl(url) { const query = this.query; for (const key in query) { const service = query[key]; if (service.pattern.test(url)) { return { origin: url, url: service.action(url), tag: service.tag, }; } } return null; } /** * @description Processes the provided source (URL or embed code) and submits it for embedding. * - It parses the input, triggers any necessary events, and creates or updates the embed component. * @param {string} [src] - The embed source. If not provided, uses the internally stored link value. * @returns {Promise<boolean>} A promise that resolves to `true` on success or `false` on failure. */ async submitSRC(src) { if (!(src ||= this.#linkValue)) return false; let embedInfo = null; if (/^<iframe\s|^<blockquote\s/i.test(src)) { const embedDOM = new DOMParser().parseFromString(src, 'text/html').body.children; if (embedDOM.length === 0) return false; embedInfo = { children: embedDOM, ...this.#getInfo(), process: null }; } else { const processUrl = this.findProcessUrl(src); if (!processUrl) return false; src = processUrl.url; embedInfo = { ...this.#getInfo(), url: src, process: processUrl, }; } const handler = function (uploadCallback, infos, newInfos) { infos = newInfos || infos; uploadCallback(src, infos.process, infos.url, infos.children, infos.inputWidth, infos.inputHeight, infos.align, infos.isUpdate); }.bind(this, this.#create.bind(this), embedInfo); const result = await this.$.eventManager.triggerEvent('onEmbedInputBefore', { ...embedInfo, handler, }); if (result === undefined) return true; if (result === false) return false; if (result !== null && typeof result === 'object') handler(result); if (result === true || result === NO_EVENT) handler(null); return true; } /** * @description Prepares the component for selection. * - Ensures that the controller is properly positioned and initialized. * - Prevents duplicate event handling if the component is already selected. * @param {HTMLElement} target - The selected element. * @param {boolean} [infoOnly=false] - If `true`, only retrieves information without opening the controller. */ #ready(target, infoOnly = false) { if (!target) return; const figureInfo = this.figure.open(target, { nonResizing: this.#nonResizing, nonSizeInfo: false, nonBorder: false, figureTarget: false, infoOnly }); this.#element = target; this.#cover = figureInfo.cover; this.#container = figureInfo.container; this._caption = figureInfo.caption; this.#align = figureInfo.align; if (!this.#cover?.getAttribute?.('data-se-origin')) { const src = target?.getAttribute?.('src') || target?.querySelector?.('a')?.href; if (src && Embed.#checkContentType(src)) { this.#cover.setAttribute('data-se-origin', src); } } target.style.float = ''; this.#origin_w = String(figureInfo.originWidth || figureInfo.w || ''); this.#origin_h = String(figureInfo.originHeight || figureInfo.h || ''); /** @type {HTMLInputElement} */ const activeAlign = this.modal.form.querySelector('input[name="suneditor_embed_radio"][value="' + this.#align + '"]') || this.modal.form.querySelector('input[name="suneditor_embed_radio"][value="none"]'); activeAlign.checked = true; if (!this.#resizing) return; const percentageRotation = this.#onlyPercentage && this.figure.isVertical; const { dw, dh } = this.figure.getSize(target); this.inputX.value = dw === 'auto' ? '' : dw; this.inputY.value = dh === 'auto' ? '' : dh; this.proportion.checked = true; this.inputX.disabled = percentageRotation ? true : false; this.inputY.disabled = percentageRotation ? true : false; this.proportion.disabled = percentageRotation ? true : false; this.#ratio = this.proportion.checked ? figureInfo.ratio : { w: 0, h: 0, }; } /** * @description Creates an `IFRAME` element for embedding external content. * @returns {HTMLIFrameElement} The created `IFRAME` element. */ #createIframeTag() { /** @type {HTMLIFrameElement} */ const iframeTag = dom.utils.createElement('IFRAME'); this.#setIframeAttrs(iframeTag); return iframeTag; } /** * @description Creates a `BLOCKQUOTE` element for embedding external content. * @returns {HTMLElement} The created `BLOCKQUOTE` element. */ #createEmbedTag() { const quoteTag = dom.utils.createElement('BLOCKQUOTE'); return quoteTag; } /** * @description Creates an embed component (`IFRAME` or `BLOCKQUOTE`) and inserts it into the editor. * @param {string} originSrc - The origin input source. * @param {SunEditor.EventParams.ProcessInfo} process - Processed embed information. * @param {string} src - The source URL. * @param {Node[]} children - The embed elements. * @param {string} width - The width of the embed component. * @param {string} height - The height of the embed component. * @param {string} align - The alignment of the embed component. * @param {boolean} isUpdate - Whether this is an update to an existing embed component. */ #create(originSrc, process, src, children, width, height, align, isUpdate) { let oFrame = null; let cover = null; let container = null; let scriptTag = null; /** update */ if (isUpdate) { oFrame = this.#element; if (oFrame.src !== src) { const processUrl = this.findProcessUrl(src); if (/^iframe$/i.test(processUrl?.tag) && !/^iframe$/i.test(oFrame.nodeName)) { const newTag = this.#createIframeTag(); newTag.src = src; oFrame.replaceWith(newTag); oFrame = newTag; } else if (/^blockquote$/i.test(processUrl?.tag) && !/^blockquote$/i.test(oFrame.nodeName)) { const newTag = this.#createEmbedTag(); newTag.setAttribute('src', src); oFrame.replaceWith(newTag); oFrame = newTag; } else { oFrame.src = src; } } container = this.#container; cover = dom.query.getParentElement(oFrame, 'FIGURE'); } else { /** create */ if (process) { oFrame = this.#createIframeTag(); oFrame.src = src; const figure = Figure.CreateContainer(oFrame, 'se-embed-container'); cover = figure.cover; container = figure.container; } else { oFrame = children[0]; const figure = Figure.CreateContainer(oFrame, 'se-embed-container'); cover = figure.cover; container = figure.container; const childNodes = Array.from(children); for (const chd of childNodes) { if (/^script$/i.test(chd.nodeName)) { scriptTag = dom.utils.createElement('script', { src: /** @type {Element} */ (chd).getAttribute('src'), async: 'true' }, null); continue; } cover.appendChild(chd); } } } /** rendering */ this.#element = oFrame; this.#cover = cover; this.#container = container; this.figure.open(oFrame, { nonResizing: this.#nonResizing, nonSizeInfo: false, nonBorder: false, figureTarget: false, infoOnly: true }); width ||= this.#defaultSizeX; height ||= this.#defaultSizeY; const size = this.figure.getSize(oFrame); const inputUpdate = size.w !== width || size.h !== height; const changeSize = !isUpdate || inputUpdate; // set size if (changeSize) { this.#applySize(width, height); } // align this.figure.setAlign(oFrame, align); // origin src cover.setAttribute('data-se-origin', originSrc); if (!isUpdate) { this.$.component.insert(container, { skipHistory: true, scrollTo: false, insertBehavior: this.pluginOptions.insertBehavior }); if (scriptTag) { try { this.$.history.pause(); scriptTag.onload = () => { dom.utils.removeItem(scriptTag); scriptTag = null; }; cover.appendChild(scriptTag); const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'childList') { if (!oFrame.parentElement) { this.$.history.resume(); this.$.history.push(false); observer.disconnect(); break; } } } }); observer.observe(this.$.frameContext.get('wysiwyg'), { subtree: true, childList: true, }); } catch (e) { this.$.history.resume(); console.warn('[SUNEDITOR] Embed tag script load error.', e); } } if (!this.$.options.get('componentInsertBehavior')) { const line = this.$.format.addLine(container, null); if (line) this.$.selection.setRange(line, 0, line, 0); } return; } if (!this.#resizing || !changeSize || !this.figure.isVertical) this.figure.setTransform(oFrame, width, height, 0); if (!scriptTag) this.$.history.push(false); } /** * @description Updates an existing embed component within the editor. * @param {HTMLIFrameElement} oFrame - The existing embed element to be updated. */ #fixTagStructure(oFrame) { if (!oFrame) return; this.#setIframeAttrs(oFrame); const prevFrame = oFrame; oFrame = /** @type {HTMLIFrameElement} */ (oFrame.cloneNode(true)); const figure = Figure.CreateContainer(oFrame, 'se-embed-container'); const container = figure.container; // size this.figure.open(oFrame, { nonResizing: this.#nonResizing, nonSizeInfo: false, nonBorder: false, figureTarget: false, infoOnly: true }); const size = (oFrame.getAttribute('data-se-size') || ',').split(','); const width = size[0] || prevFrame.width || ''; const height = size[1] || prevFrame.height || ''; this.#applySize(width, height); // align const format = this.$.format.getLine(prevFrame); if (format) this.#align = format.style.textAlign || format.style.float; this.figure.setAlign(oFrame, this.#align); this.figure.retainFigureFormat(container, this.#element, null, null); return oFrame; } /** * @description Applies width and height to the embed component. * @param {string|number} w - The width to apply. * @param {string|number} h - The height to apply. */ #applySize(w, h) { w ||= this.inputX?.value || this.pluginOptions.defaultWidth; h ||= this.inputY?.value || this.pluginOptions.defaultHeight; if (this.#onlyPercentage) { if (!w) w = '100%'; else if (!/%$/.test(w + '')) w += '%'; } this.figure.setSize(w, h); } /** * @description Retrieves embed component size and alignment information. * @returns {{inputWidth: string, inputHeight: string, align: string, isUpdate: boolean, element: Element}} An object containing * - inputWidth : The width of the embed component. * - inputHeight : The height of the embed component. * - align : The alignment of the embed component. * - isUpdate : Whether the component is being updated. * - element : The target element. */ #getInfo() { return { inputWidth: this.inputX?.value || '', inputHeight: this.inputY?.value || '', align: this.#align, isUpdate: this.modal.isUpdate, element: this.#element, }; } /** * @description Sets default attributes for an `IFRAME` element. * @param {HTMLIFrameElement} element - The `IFRAME` element to modify. */ #setIframeAttrs(element) { element.frameBorder = '0'; element.allowFullscreen = true; const attrs = this.pluginOptions.iframeTagAttributes; if (!attrs) return; for (const key in attrs) { element.setAttribute(key, attrs[key]); } } /** * @description Handles link preview input changes. * @param {InputEvent} e - Event object */ #OnLinkPreview(e) { /** @type {HTMLInputElement} */ const eventTarget = dom.query.getEventTarget(e); const value = eventTarget.value.trim(); if (/^<iframe.*\/iframe>$/.test(value)) { this.#linkValue = value; this.previewSrc.textContent = '<IFrame :src=".."></IFrame>'; } else { this.#linkValue = this.previewSrc.textContent = !value ? '' : this.$.options.get('defaultUrlProtocol') && !value.includes('://') && value.indexOf('#') !== 0 ? this.$.options.get('defaultUrlProtocol') + value : !value.includes('://') ? '/' + value : value; } } #OnClickRevert() { if (this.#onlyPercentage) { this.inputX.value = Number(this.#origin_w) > 100 ? '100' : this.#origin_w; } else { this.inputX.value = this.#origin_w; this.inputY.value = this.#origin_h; } } /** * @param {"x"|"y"} xy - x or y * @param {KeyboardEvent} e - Event object */ #OnInputSize(xy, e) { if (keyCodeMap.isSpace(e.code)) { e.preventDefault(); return; } /** @type {HTMLInputElement} */ const eventTarget = dom.query.getEventTarget(e); if (xy === 'x' && this.#onlyPercentage && Number(eventTarget.value) > 100) { eventTarget.value = '100'; } else if (this.proportion.checked) { const ratioSize = Figure.CalcRatio(this.inputX.value, this.inputY.value, this.sizeUnit, this.#ratio); if (xy === 'x') { this.inputY.value = String(ratioSize.h); } else { this.inputX.value = String(ratioSize.w); } } } } /** * @param {SunEditor.Deps} $ Kernel deps * @param {*} pluginOptions * @returns {{ * html: HTMLElement, * figureAlignBtn: HTMLButtonElement, * fileModalWrapper: HTMLElement, * embedInput: HTMLInputElement, * previewSrc: HTMLElement, * proportion: HTMLInputElement, * inputX: HTMLInputElement, * inputY: HTMLInputElement, * revertBtn: HTMLButtonElement * }} */ function CreateHTML_modal({ lang, icons }, pluginOptions) { let html = /*html*/ ` <form method="post" enctype="multipart/form-data"> <div class="se-modal-header"> <button type="button" data-command="close" class="se-btn se-close-btn" title="${lang.close}" aria-label="${lang.close}"> ${icons.cancel} </button> <span class="se-modal-title">${lang.embed_modal_title}</span> </div> <div class="se-modal-body"> <div class='se-modal-form'> <label>${lang.embed_modal_source}</label> <input class='se-input-form se-input-url' type='text' data-focus /> <pre class='se-link-preview'></pre> </div>`; if (pluginOptions.canResize) { const onlyPercentage = pluginOptions.percentageOnlySize; const onlyPercentDisplay = onlyPercentage ? ' style="display: none !important;"' : ''; const heightDisplay = !pluginOptions.showHeightInput ? ' style="display: none !important;"' : ''; const ratioDisplay = !pluginOptions.showRatioOption ? ' style="display: none !important;"' : ''; const onlyWidthDisplay = !onlyPercentage && !pluginOptions.showHeightInput && !pluginOptions.showRatioOption ? ' style="display: none !important;"' : ''; html += /*html*/ ` <div class="se-modal-form"> <div class="se-modal-size-text"> <label class="size-w">${lang.width}</label> <label class="se-modal-size-x">&nbsp;</label> <label class="size-h"${heightDisplay}>${lang.height}</label> <label class="size-h"${ratioDisplay}>(${lang.ratio})</label> </div> <input class="se-input-control _se_size_x" placeholder="auto"${onlyPercentage ? ' type="number" min="1"' : 'type="text"'}${onlyPercentage ? ' max="100"' : ''}/> <label class="se-modal-size-x"${onlyWidthDisplay}>${onlyPercentage ? '%' : 'x'}</label> <input class="se-input-control _se_size_y" placeholder="auto" ${onlyPercentage ? ' type="number" min="1"' : 'type="text"'}${onlyPercentage ? ' max="100"' : ''}${heightDisplay}/> <button type="button" title="${lang.revert}" aria-label="${lang.revert}" class="se-btn se-modal-btn-revert">${icons.revert}</button> </div> <div class="se-modal-form se-modal-form-footer"${onlyPercentDisplay}${onlyWidthDisplay}> <label> <input type="checkbox" class="se-modal-btn-check _se_check_proportion" />&nbsp; <span>${lang.proportion}</span> </label> </div>`; } html += /*html*/ ` </div> <div class="se-modal-footer"> <div class="se-figure-align"> <label><input type="radio" name="suneditor_embed_radio" class="se-modal-btn-radio" value="none" checked>${lang.basic}</label> <label><input type="radio" name="suneditor_embed_radio" class="se-modal-btn-radio" value="left">${lang.left}</label> <label><input type="radio" name="suneditor_embed_radio" class="se-modal-btn-radio" value="center">${lang.center}</label> <label><input type="radio" name="suneditor_embed_radio" class="se-modal-btn-radio" value="right">${lang.right}</label> </div> <button type="submit" class="se-btn-primary" title="${lang.submitButton}" aria-label="${lang.submitButton}"><span>${lang.submitButton}</span></button> </div> </form>`; const content = dom.utils.createElement('DIV', { class: 'se-modal-content' }, html); return { html: content, figureAlignBtn: content.querySelector('.se-figure-align'), fileModalWrapper: content.querySelector('.se-flex-input-wrapper'), embedInput: content.querySelector('.se-input-url'), previewSrc: content.querySelector('.se-link-preview'), proportion: content.querySelector('._se_check_proportion'), inputX: content.querySelector('._se_size_x'), inputY: content.querySelector('._se_size_y'), revertBtn: content.querySelector('.se-modal-btn-revert'), }; } export default Embed;