suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
915 lines (808 loc) • 31.7 kB
JavaScript
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"> </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" />
<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;