suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
1,412 lines (1,238 loc) • 62.8 kB
JavaScript
import Controller from './Controller';
import SelectMenu from '../ui/SelectMenu';
import { _DragHandle } from '../ui/_DragHandle';
import { dom, numbers, env, converter, keyCodeMap } from '../../helper';
const { _w, ON_OVER_COMPONENT } = env;
const DIRECTION_CURSOR_MAP = { tl: 'nwse-resize', tr: 'nesw-resize', bl: 'nesw-resize', br: 'nwse-resize', lw: 'ew-resize', th: 'ns-resize', rw: 'ew-resize', bh: 'ns-resize' };
const DIR_DIAGONAL = 'tl|bl|tr|br';
const DIR_W = 'lw|rw';
let __resizing_p_wh = false;
let __resizing_p_ow = false;
let __resizing_cw = 0;
let __resizing_sw = 0;
/**
* Figure information object
* @typedef {Object} FigureInfo
* @property {HTMLElement} target - Target element (`img`, `iframe`, `video`, `audio`, `table`, etc.)
* @property {HTMLElement} container - Container element (`div.se-component`|`span.se-component.se-inline-component`)
* @property {?HTMLElement} cover - Cover element (`FIGURE`|`null`)
* @property {?HTMLElement} inlineCover - Inline cover element (`span.se-inline-component`)
* @property {?HTMLElement} caption - Caption element (`FIGCAPTION`)
* @property {boolean} isVertical - Whether to rotate vertically
*/
/**
* Figure target information object (for resize/align operations)
* @typedef {Object} FigureTargetInfo
* @property {HTMLElement} container - Container element (`div.se-component`|`span.se-component.se-inline-component`)
* @property {?HTMLElement} [cover] - Cover element (`FIGURE`|`null`)
* @property {?HTMLElement} [caption] - Caption element (`FIGCAPTION`)
* @property {string} [align] - Alignment of the element.
* @property {{w:number, h:number}} [ratio] - The aspect ratio of the element.
* @property {boolean} isVertical - Whether to rotate vertically
* @property {string|number} [w] - Width of the element.
* @property {string|number} [h] - Height of the element.
* @property {number} [t] - Top position.
* @property {number} [l] - Left position.
* @property {string|number} width - Width, can be a number or `auto`.
* @property {string|number} height - Height, can be a number or `auto`.
* @property {number} [originWidth] - Original width from `naturalWidth` or `offsetWidth`.
* @property {number} [originHeight] - Original height from `naturalHeight` or `offsetHeight`.
*/
/**
* Figure control button type
* @typedef {"mirror_h" | "mirror_v" | "rotate_l" | "rotate_r" | "caption" | "revert" | "edit" | "copy" | "remove" | "as" | "align" | "onalign" | "onresize"} FigureControlButton
*/
/**
* Figure control resize value type (auto, or percentage numbers)
* @typedef {`resize_auto,${number}` | `resize_auto,${number},${number}` | `resize_auto,${number},${number},${number}` | `resize_auto,${number},${number},${number},${number}`} FigureControlResize
*/
/**
* Figure control custom action object
* @typedef {{
* action: (element: Node, value: string, target: Node) => void,
* command: string,
* value: string,
* title: string,
* icon: string
* }} ControlCustomAction
*/
/**
* Figure controls configuration
* 2D array of control buttons for the figure component toolbar
*
* **Available control buttons**:
* - `"mirror_h"`: Mirror horizontally
* - `"mirror_v"`: Mirror vertically
* - `"rotate_l"`: Rotate left (-90°)
* - `"rotate_r"`: Rotate right (90°)
* - `"caption"`: Toggle caption (`FIGCAPTION`)
* - `"revert"`: Revert to original size
* - `"edit"`: Open edit modal
* - `"copy"`: Copy component
* - `"remove"`: Remove component
* - `"as"`: Format type (`block`/`inline`) - requires `useFormatType` option
* - `"align"`: Alignment (`none`/`left`/`center`/`right`)
* - `"onalign"`: Alignment button (opens alignment menu)
* - `"onresize"`: Resize button (opens resize menu)
* - `"resize_auto,50,75,100"`: Auto-resize with percentage values (e.g., `"resize_auto,100,75,50"`)
* - Custom action object with action, command, value, title, icon
*
* @example
* // Basic controls
* [['mirror_h', 'mirror_v', 'align', 'caption', 'edit', 'copy', 'remove']]
*
* @example
* // Multi-row controls with resize options
* [
* ['as', 'resize_auto,100,75,50', 'rotate_l', 'rotate_r', 'mirror_h', 'mirror_v'],
* ['edit', 'align', 'caption', 'revert', 'copy', 'remove']
* ]
*
* @typedef {Array<Array<FigureControlButton | FigureControlResize | ControlCustomAction | string>>} FigureControls
*/
// -------------------------------------------------------------------------------------------------------------------------
/**
* @typedef {Object} FigureParams
* @property {string} [sizeUnit="px"] Size unit
* @property {{ current: string, default: string }} [autoRatio=null] Auto ratio `{ current: '00%', default: '00%' }`
*/
/**
* @class
* @description Figure module class for handling resizable/alignable components (images, videos, iframes, etc.)
* @see EditorComponent for `inst._element` requirement
*/
class Figure {
#$;
#offContainer;
#containerResizingESC;
#width = '';
#height = '';
#resize_w = 0;
#resize_h = 0;
#element_w = 0;
#element_h = 0;
#floatClassStr = '__se__float-none|__se__float-left|__se__float-center|__se__float-right';
#preventSizechange = false;
#revertSize = { w: '', h: '' };
#onResizeESCEvent = null;
/**
* @constructor
* @param {*} inst The instance object that called the constructor.
* @param {SunEditor.Deps} $ Kernel dependencies
* @param {FigureControls} controls Controller button array
* @param {FigureParams} params Figure options
*/
constructor(inst, $, controls, params) {
this.#$ = $;
this.kind = inst.constructor.key || inst.constructor.name;
this._alignIcons = {
none: this.#$.icons.format_float_none,
left: this.#$.icons.format_float_left,
right: this.#$.icons.format_float_right,
center: this.#$.icons.format_float_center,
};
// modules
/** @type {Object<string, *>} */
this._action = {};
const controllerEl = CreateHTML_controller(this, $, controls || []);
this.controller = controllerEl ? new Controller(this, $, controllerEl, { position: 'bottom', disabled: true }, this.kind) : null;
if (controllerEl) {
// align selectmenu
this.alignButton = controllerEl.querySelector('[data-command="onalign"]');
const alignMenus = CreateAlign(this, $, this.alignButton);
if (alignMenus) {
this.selectMenu_align = new SelectMenu($, { checkList: false, position: 'bottom-center' });
this.selectMenu_align.on(this.alignButton, this.#SetMenuAlign.bind(this), { class: 'se-figure-select-list' });
this.selectMenu_align.create(alignMenus.items, alignMenus.html);
}
// as [block, inline] selectmenu
this.asButton = controllerEl.querySelector('[data-command="onas"]');
const asMenus = CreateAs($, this.asButton);
if (asMenus) {
this.selectMenu_as = new SelectMenu($, { checkList: false, position: 'bottom-center' });
this.selectMenu_as.on(this.asButton, this.#SetMenuAs.bind(this), { class: 'se-figure-select-list' });
this.selectMenu_as.create(asMenus.items, asMenus.html);
}
// resize selectmenu
this.resizeButton = controllerEl.querySelector('[data-command="onresize"]');
const resizeMenus = CreateResize($, this.resizeButton);
if (resizeMenus) {
this.selectMenu_resize = new SelectMenu($, { checkList: false, position: 'bottom-left', dir: 'ltr' });
this.selectMenu_resize.on(this.resizeButton, this.#SetResize.bind(this));
this.selectMenu_resize.create(resizeMenus.items, resizeMenus.html);
}
}
// members
this.inst = inst;
this.sizeUnit = params.sizeUnit || 'px';
this.autoRatio = params.autoRatio;
this.isVertical = false;
this.percentageButtons = controllerEl?.querySelectorAll('[data-command="resize_percent"]') || [];
this.captionButton = controllerEl?.querySelector('[data-command="caption"]');
this.align = 'none';
this.as = 'block';
/** @type {{left?: number, top?: number}} */
this.__offset = {};
this._element = null;
this._cover = null;
this._inlineCover = null;
this._container = null;
this._caption = null;
this._resizeClientX = 0;
this._resizeClientY = 0;
this._resize_direction = '';
this.__containerResizingOff = this.#ContainerResizingOff.bind(this);
this.__containerResizing = this.#ContainerResizing.bind(this);
this.__onContainerEvent = null;
this.__offContainerEvent = null;
this.#offContainer = this.#OffFigureContainer.bind(this);
this.#containerResizingESC = this.#ContainerResizingESC.bind(this);
// init
this.#$.eventManager.addEvent(this.alignButton, 'click', this.#OnClick_alignButton.bind(this));
this.#$.eventManager.addEvent(this.asButton, 'click', this.#OnClick_asButton.bind(this));
this.#$.eventManager.addEvent(this.resizeButton, 'click', this.#OnClick_resizeButton.bind(this));
this.#$.contextProvider.applyToRoots((e) => {
if (!e.get('wrapper').querySelector('.se-controller.se-resizing-container')) {
// resizing
const main = CreateHTML_resizeDot();
const handles = main.querySelectorAll('.se-resize-dot > span');
e.set('_figure', {
main: main,
border: /** @type {HTMLElement} */ (main.querySelector('.se-resize-dot')),
display: /** @type {HTMLElement} */ (main.querySelector('.se-resize-display')),
handles: /** @type {*} */ (handles),
});
e.get('wrapper').appendChild(main);
this.#$.eventManager.addEvent(handles, 'mousedown', this.#OnResizeContainer.bind(this));
}
});
}
/**
* @description Create a container for the resizing component and insert the element.
* @param {Node} element Target element
* @param {string} [className] Class name of container (fixed: `se-component`)
* @returns {FigureInfo} {target, container, cover, inlineCover, caption}
* @example
* const imgEl = document.createElement('IMG');
* imgEl.src = imageUrl;
* const figureInfo = Figure.CreateContainer(imgEl, 'se-image-container');
* // figureInfo.container → <div class="se-component se-image-container">
* // figureInfo.cover → <figure> wrapping the imgEl
*/
static CreateContainer(element, className) {
dom.utils.createElement('DIV', { class: 'se-component' + (className ? ' ' + className : '') }, dom.utils.createElement('FIGURE', null, element));
return Figure.GetContainer(element);
}
/**
* @description Create a container for the inline resizing component and insert the element.
* @param {Node} element Target element
* @param {string} [className] Class name of container (fixed: `se-component` `se-inline-component`)
* @returns {FigureInfo} {target, container, cover, inlineCover, caption}
* @example
* const imgEl = document.createElement('IMG');
* imgEl.src = imageUrl;
* const figureInfo = Figure.CreateInlineContainer(imgEl, 'se-image-container');
* // figureInfo.container → <span class="se-component se-inline-component se-image-container">
* // figureInfo.inlineCover → same as container (inline mode)
*/
static CreateInlineContainer(element, className) {
dom.utils.createElement('SPAN', { class: 'se-component se-inline-component' + (className ? ' ' + className : '') }, element);
return Figure.GetContainer(element);
}
/**
* @description Return HTML string of caption(`FIGCAPTION`) element
* @param {Node} cover Cover element(`FIGURE`). `CreateContainer().cover`
* @returns {HTMLElement} caption element
*/
static CreateCaption(cover, text) {
const caption = dom.utils.createElement('FIGCAPTION', null, '<div>' + text + '</div>');
cover.appendChild(caption);
return caption;
}
/**
* @description Get the element's container(`.se-component`) info.
* @param {Node} element Target element
* @returns {FigureInfo} {target, container, cover, inlineCover, caption}
*/
static GetContainer(element) {
const cover = dom.query.getParentElement(element, 'FIGURE', 2);
const inlineCover = dom.query.getParentElement(element, 'SPAN', 2);
const anyCover = cover || inlineCover;
const target = dom.query.getParentElement(element, (current) => current.parentElement === anyCover, 0) || /** @type {HTMLElement} */ (element);
return {
target,
container: dom.query.getParentElement(target, Figure.is, 3) || cover,
cover: cover,
inlineCover: dom.utils.hasClass(inlineCover, 'se-inline-component') ? /** @type {HTMLElement} */ (inlineCover) : null,
caption: dom.query.getEdgeChild(target.parentElement, 'FIGCAPTION', false),
isVertical: IsVertical(target),
};
}
/**
* @description Ratio calculation
* @param {string|number} w Width size
* @param {string|number} h Height size
* @param {?string} [defaultSizeUnit="px"] Default size unit (default: `"px"`)
* @return {{w: number, h: number}}
* @example
* const ratio = Figure.GetRatio(200, 100, 'px');
* // ratio → { w: 2, h: 0.5 }
*
* // Used with proportion-locked resizing
* const ratio = Figure.GetRatio(inputX.value, inputY.value, sizeUnit);
* const adjusted = Figure.CalcRatio(newWidth, newHeight, sizeUnit, ratio);
*/
static GetRatio(w, h, defaultSizeUnit) {
let rw = 0,
rh = 0;
if (/\d+/.test(w + '') && /\d+/.test(h + '')) {
const xUnit = (!numbers.is(w) && String(w).replace(/\d+|\./g, '')) || defaultSizeUnit || 'px';
const yUnit = (!numbers.is(h) && String(h).replace(/\d+|\./g, '')) || defaultSizeUnit || 'px';
if (xUnit === yUnit) {
const w_number = numbers.get(w, 4);
const h_number = numbers.get(h, 4);
rw = w_number / h_number;
rh = h_number / w_number;
}
}
return {
w: numbers.get(rw, 4),
h: numbers.get(rh, 4),
};
}
/**
* @description Ratio calculation
* @param {string|number} w Width size
* @param {string|number} h Height size
* @param {string} defaultSizeUnit Default size unit (default: `"px"`)
* @param {?{w: number, h: number}} [ratio] Ratio size (Figure.GetRatio)
* @return {{w: string|number, h: string|number}}
* @example
* const ratio = Figure.GetRatio(200, 100, 'px');
* // When width changes, recalculate height to maintain aspect ratio
* const result = Figure.CalcRatio(inputX.value, inputY.value, 'px', ratio);
* inputY.value = result.h; // adjusted height preserving ratio
*/
static CalcRatio(w, h, defaultSizeUnit, ratio) {
if (ratio?.w && ratio?.h && /\d+/.test(w + '') && /\d+/.test(h + '')) {
const xUnit = (!numbers.is(w) && String(w).replace(/\d+|\./g, '')) || defaultSizeUnit || 'px';
const yUnit = (!numbers.is(h) && String(h).replace(/\d+|\./g, '')) || defaultSizeUnit || 'px';
if (xUnit === yUnit) {
const dec = xUnit === '%' ? 2 : 0;
const ow = w;
const oh = h;
h = numbers.get(ratio.h * numbers.get(ow, dec), dec) + yUnit;
w = numbers.get(ratio.w * numbers.get(oh, dec), dec) + xUnit;
}
}
return {
w: w,
h: h,
};
}
/**
* @description It is judged whether it is the component[`img`, `iframe`, `video`, `audio`, `table`] cover(class=`"se-component"`) and `table`, `hr`
* @param {Node} element Target element
* @returns {boolean}
*/
static is(element) {
return dom.check.isComponentContainer(element) || /^(HR)$/.test(element?.nodeName);
}
/**
* @description Close the figure's controller
*/
close() {
this.#$.store.set('_preventBlur', false);
dom.utils.removeClass(this._cover, 'se-figure-selected');
this.#$.component.__removeDragEvent();
this.#removeGlobalEvents();
this.controller?.close();
}
/**
* @description Open the figure's controller
* @param {Node} targetNode Target element
* @param {Object} params params
* @param {boolean} [params.nonResizing=false] Do not display the resizing button
* @param {boolean} [params.nonSizeInfo=false] Do not display the size information
* @param {boolean} [params.nonBorder=false] Do not display the selected style line
* @param {boolean} [params.figureTarget=false] If `true`, the target is a figure element
* @param {boolean} [params.infoOnly=false] If `true`, returns only the figure target info without opening the controller
* @returns {FigureTargetInfo|undefined} figure target info
* @example
* // Open controller with full UI (resize handles, size info, border)
* const info = this.figure.open(imgElement, {
* nonResizing: false, nonSizeInfo: false, nonBorder: false,
* figureTarget: false, infoOnly: false
* });
*
* // Get figure info without opening the controller UI
* const info = this.figure.open(oFrame, {
* nonResizing: false, nonSizeInfo: false, nonBorder: false,
* figureTarget: false, infoOnly: true
* });
* // info.width, info.height, info.ratio are available
*/
open(targetNode, { nonResizing, nonSizeInfo, nonBorder, figureTarget, infoOnly }) {
if (!targetNode) {
console.warn('[SUNEDITOR.modules.Figure.open] The "targetNode" is null.');
return;
}
if (_DragHandle.get('__overInfo') === ON_OVER_COMPONENT) {
nonBorder = true;
}
const figureInfo = Figure.GetContainer(targetNode);
const target = figureInfo.target;
let exceptionFormat = false;
if (!figureInfo.container) {
if (!this.#$.options.get('strictMode').formatFilter) {
figureInfo.container = target;
figureInfo.cover = target;
exceptionFormat = true;
} else {
return {
container: null,
cover: null,
width: target.style.width || /** @type {HTMLImageElement} */ (target).width || '',
height: target.style.height || /** @type {HTMLImageElement} */ (target).height || '',
isVertical: this.isVertical,
};
}
}
_DragHandle.set('__figureInst', this);
this.#setFigureInfo(figureInfo);
const sizeTarget = /** @type {HTMLElement} */ (figureTarget ? this._cover || this._container || target : target);
const w = sizeTarget.offsetWidth || null;
const h = sizeTarget.offsetHeight || null;
const { top, left, scrollX, scrollY } = this.#$.offset.getLocal(sizeTarget);
const dataSize = (target.getAttribute('data-se-size') || '').split(',');
let rw = numbers.get(target.style.width, 2) || w;
let rh = numbers.get(target.style.height, 2) || h;
if (this.isVertical) [rw, rh] = [rh, rw];
const ratio = Figure.GetRatio(rw, rh, this.sizeUnit);
const targetInfo = {
container: figureInfo.container,
cover: figureInfo.cover,
caption: figureInfo.caption,
align: this.align,
ratio: ratio,
isVertical: this.isVertical,
w: w || '',
h: h || '',
t: top,
l: left,
width: dataSize[0] || 'auto',
height: dataSize[1] || 'auto',
originWidth: /** @type {HTMLImageElement} */ (target).naturalWidth || target.offsetWidth,
originHeight: /** @type {HTMLImageElement} */ (target).naturalHeight || target.offsetHeight,
};
this.#width = targetInfo.width;
this.#height = targetInfo.height;
if (infoOnly) return targetInfo;
const _figure = this.#$.frameContext.get('_figure');
this.#$.ui.setFigureContainer(_figure.main);
this.#$.ui.opendControllers = this.#$.ui.opendControllers.filter((c) => c.form !== _figure.main);
_figure.main.style.top = top + 'px';
_figure.main.style.left = left + 'px';
_figure.main.style.width = (this.isVertical ? h : w) + 'px';
_figure.main.style.height = (this.isVertical ? w : h) + 'px';
_figure.border.style.top = '0px';
_figure.border.style.left = '0px';
_figure.border.style.width = (this.isVertical ? h : w) + 'px';
_figure.border.style.height = (this.isVertical ? w : h) + 'px';
this.__offset = { left: left + scrollX, top: top + scrollY };
this.#$.ui.opendControllers.push({
position: 'none',
form: _figure.main,
target: sizeTarget,
inst: this,
notInCarrier: true,
});
// percentage active
const value = /%$/.test(target.style.width) && /%$/.test(figureInfo.container.style.width) ? numbers.get(figureInfo.container.style.width, 0) / 100 + '' : '';
for (let i = 0, len = this.percentageButtons.length; i < len; i++) {
if (this.percentageButtons[i].getAttribute('data-value') === value) {
dom.utils.addClass(this.percentageButtons[i], 'active');
} else {
dom.utils.removeClass(this.percentageButtons[i], 'active');
}
}
// caption active
if (this.captionButton) {
if (figureInfo.caption) {
dom.utils.addClass(this.captionButton, 'active');
} else {
dom.utils.removeClass(this.captionButton, 'active');
}
}
_figure.display.style.display = nonSizeInfo || this._inlineCover ? 'none' : '';
_figure.border.style.display = nonBorder ? 'none' : '';
_figure.main.style.display = 'block';
if (_DragHandle.get('__overInfo') !== ON_OVER_COMPONENT) {
// resize handles
const displayType = nonResizing ? 'none' : 'flex';
const resizeHandles = _figure.handles;
for (let i = 0, len = resizeHandles.length; i < len; i++) {
resizeHandles[i].style.display = displayType;
}
if (this.controller) {
// size display
const size = this.getSize(target);
dom.utils.changeTxt(_figure.display, this.#$.lang[this.align === 'none' ? 'basic' : this.align] + ' (' + size.dw + ', ' + size.dh + ')');
// align button
this.#setAlignIcon();
// as button
this.#setAsIcon();
this.#$.ui._visibleControllers(true, true);
// rotate, aption, align, onresize - display;
const transformButtons = this.controller.form.querySelectorAll(
'[data-command="rotate"][data-value="90"], [data-command="rotate"][data-value="-90"], [data-command="caption"], [data-command="onalign"], [data-command="onresize"]',
);
const display = this._inlineCover || exceptionFormat ? 'none' : '';
transformButtons?.forEach((button) => {
/** @type {HTMLButtonElement} */ (button).style.display = display;
});
// onas
const onas = this.controller.form.querySelector('[data-command="onas"]');
if (onas) {
/** @type {HTMLButtonElement} */ (onas).style.display = exceptionFormat ? 'none' : '';
}
}
// selecte
dom.utils.removeClass(this._cover, 'se-figure-over-selected');
this.controller?.open(_figure.main, null, { initMethod: this.#offContainer, isWWTarget: false, addOffset: null });
_w.setTimeout(() => _DragHandle.set('__overInfo', false), 0);
} else {
dom.utils.addClass(this._cover, 'se-figure-over-selected');
}
// set members
dom.utils.addClass(this._cover, 'se-figure-selected');
this.#element_w = this.#resize_w = w;
this.#element_h = this.#resize_h = h;
// drag
if (!this._inlineCover && (_DragHandle.get('__overInfo') !== ON_OVER_COMPONENT || dom.utils.hasClass(figureInfo.container, 'se-input-component'))) {
this.#setDragEvent(_figure.main);
}
return targetInfo;
}
/**
* @description Hide the controller
*/
controllerHide() {
this.controller?.hide();
}
/**
* @description Hide the controller
*/
controllerShow() {
this.controller?.show();
}
/**
* @description Open the figure's controller
* @param {Node} target Target element
* @param {Object} [params={}] params
* @param {boolean} [params.isWWTarget] If the controller is in the WYSIWYG area, set it to `true`.
* @param {() => void} [params.initMethod] Method to be called when the controller is closed.
* @param {boolean} [params.disabled] If `true`, the controller is disabled.
* @param {{left: number, top: number}} [params.addOffset] Additional offset values
*/
controllerOpen(target, params) {
this._element = /** @type {HTMLElement} */ (target);
this.controller?.open(target, null, params);
}
/**
* @description Set the element's container size
* @param {string|number} w Width size
* @param {string|number} h Height size
*/
setFigureSize(w, h) {
if (/%$/.test(w + '')) {
this._setPercentSize(w, h);
} else if ((!w || w === 'auto') && (!h || h === 'auto')) {
if (this.autoRatio) this._setPercentSize(100, this.autoRatio.default || this.autoRatio.current);
else this._setAutoSize();
} else {
this._applySize(w, h, '');
}
}
/**
* @description Set the element's container size from plugins input value
* @param {string|number} w Width size
* @param {string|number} h Height size
*/
setSize(w, h) {
const v = this.isVertical;
if (v) [w, h] = [h, w];
this.setFigureSize(w, h);
if (v) this.setTransform(this._element, w, h, 0);
}
/**
* @description Gets the Figure size
* @param {?Node} [targetNode] Target element, default is the current element
* @returns {{w: string, h: string, dw: string, dh: string}}
*/
getSize(targetNode) {
targetNode ||= this._element;
const v = IsVertical(targetNode);
let w = '';
let h = '';
let dw = '';
let dh = '';
if (!targetNode) return { w, h, dw, dh };
const figure = Figure.GetContainer(targetNode);
const target = figure.target;
if (!figure.container) {
// exceptionFormat
if (!this.#$.options.get('strictMode').formatFilter) {
w = target.style.width || 'auto';
h = target.style.height || 'auto';
} else {
w = '';
h = target.style.height;
}
} else {
w = !/%$/.test(target.style.width) ? target.style.width : ((figure.container && numbers.get(figure.container.style.width, 2)) || 100) + '%';
h = figure.inlineCover
? figure.inlineCover.style.height || /** @type {HTMLElement} */ (targetNode).style.height || String(/** @type {HTMLImageElement} */ (targetNode).height || '')
: numbers.get(figure.cover?.style.paddingBottom, 0) > 0 && !v
? figure.cover?.style.height
: !/%$/.test(target.style.height) || !/%$/.test(target.style.width)
? target.style.height
: ((figure.container && numbers.get(figure.container.style.height, 2)) || 100) + '%';
w ||= 'auto';
h ||= 'auto';
}
dw = v ? h : w;
dh = v ? w : h;
return {
w,
h,
dw,
dh,
};
}
/**
* @description Align the container.
* @param {?Node} targetNode Target element
* @param {string} align `"none"`|`"left"`|`"center"`|`"right"`
*/
setAlign(targetNode, align) {
targetNode ||= this._element;
this.align = align ||= 'none';
const figure = Figure.GetContainer(targetNode);
if (!figure.cover) return;
const target = figure.target;
const container = figure.container;
const cover = figure.cover;
if (/%$/.test(target.style.width) && align === 'center' && !this.#$.component.isInline(container)) {
container.style.minWidth = '100%';
cover.style.width = container.style.width;
} else {
container.style.minWidth = '';
cover.style.width = this.isVertical ? target.style.height || target.offsetHeight + 'px' : !target.style.width || target.style.width === 'auto' ? '' : target.style.width || '100%';
}
if (!dom.utils.hasClass(container, '__se__float-' + align)) {
dom.utils.removeClass(container, this.#floatClassStr);
dom.utils.addClass(container, '__se__float-' + align);
}
if (this.autoRatio) {
const { w, h } = this.getSize(this._element);
this.__setCoverPaddingBottom(w, h);
}
this.#setAlignIcon();
}
/**
* @description As style[block, inline] the component
* @param {?Node} targetNode Target element
* @param {"block"|"inline"} formatStyle Format style
* @returns {HTMLElement} New target element after conversion
* @example
* // Convert a block image to inline format
* const newImgEl = this.figure.convertAsFormat(imgElement, 'inline');
* // newImgEl is a cloned element inside a new inline container
*
* // Convert an inline image back to block format
* const newImgEl = this.figure.convertAsFormat(imgElement, 'block');
*/
convertAsFormat(targetNode, formatStyle) {
targetNode ||= this._element;
this.as = formatStyle || 'block';
const { container, inlineCover, target } = Figure.GetContainer(targetNode);
const { w, h } = this.getSize(target);
const newTarget = /** @type {HTMLElement} */ (target.cloneNode(false));
newTarget.style.width = '';
newTarget.style.height = '';
newTarget.removeAttribute('width');
newTarget.removeAttribute('height');
switch (formatStyle) {
case 'inline': {
if (inlineCover) break;
this.#$.component.deselect();
const next = container.nextElementSibling;
const parent = container.parentElement;
const figure = Figure.CreateInlineContainer(newTarget);
dom.utils.addClass(
figure.container,
container.className
.split(' ')
.filter((v) => v !== 'se-figure-selected' && v !== 'se-component-selected')
.join('|'),
);
this.#asFormatChange(figure, w, h);
const line = dom.utils.createElement(this.#$.options.get('defaultLine'), null, figure.container);
parent.insertBefore(line, next);
dom.utils.removeItem(container);
break;
}
case 'block': {
if (!inlineCover) break;
this.#$.component.deselect();
this.#$.selection.setRange(container, 0, container, 1);
const r = this.#$.html.remove();
const s = this.#$.nodeTransform.split(r.container, r.offset, 0);
if (s?.previousElementSibling && dom.check.isZeroWidth(s.previousElementSibling)) {
dom.utils.removeItem(s.previousElementSibling);
}
const figure = Figure.CreateContainer(newTarget);
dom.utils.addClass(
figure.container,
container.className
.split(' ')
.filter((v) => v !== 'se-inline-component' && v !== 'se-figure-selected' && v !== 'se-component-selected')
.join('|'),
);
this.#asFormatChange(figure, w, h);
(s || r.container).parentElement.insertBefore(figure.container, s);
break;
}
}
return newTarget;
}
/**
* @hook Module.Controller
* @type {SunEditor.Hook.Controller.Action}
*/
controllerAction(target) {
const command = target.getAttribute('data-command');
const value = target.getAttribute('data-value');
const type = target.getAttribute('data-type');
const element = this._element;
if (/^on.+/.test(command) || type === 'selectMenu') return;
switch (command) {
case 'mirror': {
const info = GetRotateValue(element);
let x = info.x;
let y = info.y;
if ((value === 'h' && !this.isVertical) || (value === 'v' && this.isVertical)) {
y = y ? '' : '180';
} else {
x = x ? '' : '180';
}
this._setRotate(element, info.r, x, y);
break;
}
case 'rotate': {
this.setTransform(element, null, null, Number(value));
break;
}
case 'caption': {
if (!this._caption) {
const caption = Figure.CreateCaption(this._cover, this.#$.lang.caption);
const captionText = dom.query.getEdgeChild(caption, (current) => current.nodeType === 3, false);
if (!captionText) {
caption.focus();
} else {
this.#$.selection.setRange(captionText, 0, captionText, captionText.textContent.length);
}
this._caption = caption;
this.controller.close();
} else {
dom.utils.removeItem(this._caption);
_w.setTimeout(this.#$.component.select.bind(this.#$.component, element, this.kind), 0);
this._caption = null;
}
if (/\d+/.test(element.style.height) || (this.isVertical && this._caption)) {
if (/%$/.test(element.style.width) || /auto/.test(element.style.height)) {
this.deleteTransform();
} else {
this.setTransform(element, element.style.width, element.style.height, 0);
}
}
break;
}
case 'revert': {
this._setRevert();
break;
}
case 'edit': {
this.inst.componentEdit(element);
break;
}
case 'copy': {
this.#$.component.copy(this._container);
break;
}
case 'remove': {
this.inst.componentDestroy(element);
this.controller.close();
break;
}
}
if (/^__c__/.test(command)) {
this._action[command](element, value, target);
return;
}
if (command === 'edit') return;
this.#$.history.push(false);
if (!/^remove|caption$/.test(command)) {
this.#$.component.select(element, this.kind);
}
}
/**
* @description Inspect the figure component format and change it to the correct format.
* @param {Node} container - The container element of the figure component.
* @param {Node} originEl - The original element of the figure component.
* @param {Node} anchorCover - The anchor cover element of the figure component.
* @param {import('../manager/FileManager').default} [fileManagerInst=null] - FileManager module instance, if used.
* @example
* // Insert a new image figure, replacing the original element in the DOM
* const figureInfo = Figure.CreateContainer(imgElement, 'se-image-container');
* this.figure.retainFigureFormat(figureInfo.container, this.#element, null, this.fileManager);
*
* // Replace with anchor cover (e.g., image wrapped in a link)
* this.figure.retainFigureFormat(container, this.#element, anchorEl, this.fileManager);
*/
retainFigureFormat(container, originEl, anchorCover, fileManagerInst) {
const isInline = this.#$.component.isInline(container);
const originParent = originEl.parentNode;
let existElement = this.#$.format.isBlock(originParent) || dom.check.isWysiwygFrame(originParent) || originParent.nodeType >= 9 ? originEl : Figure.GetContainer(originEl)?.container || originParent || originEl;
if (dom.query.getParentElement(originEl, dom.check.isExcludeFormat)) {
existElement = anchorCover && anchorCover !== originEl ? anchorCover : originEl;
existElement.parentNode.replaceChild(container, existElement);
} else if (isInline && this.#$.format.isLine(existElement)) {
const refer = isInline && /^SPAN$/i.test(originEl.parentElement.nodeName) ? originEl.parentElement : originEl;
refer.parentElement.replaceChild(container, refer);
} else if (dom.check.isListCell(existElement)) {
const refer = dom.query.getParentElement(originEl, (current) => current.parentNode === existElement);
existElement.insertBefore(container, refer);
dom.utils.removeItem(originEl);
this.#$.nodeTransform.removeEmptyNode(refer, null, true);
} else if (this.#$.format.isLine(existElement)) {
const refer = dom.query.getParentElement(originEl, (current) => current.parentNode === existElement);
existElement = this.#$.nodeTransform.split(existElement, refer);
existElement.parentNode.insertBefore(container, existElement);
dom.utils.removeItem(originEl);
this.#$.nodeTransform.removeEmptyNode(existElement, null, true);
} else {
if (this.#$.format.isLine(existElement.parentNode)) {
const formats = existElement.parentNode;
formats.parentNode.insertBefore(container, existElement.previousSibling ? formats.nextElementSibling : formats);
if (fileManagerInst?.__updateTags.map((current) => existElement.contains(current)).length === 0) dom.utils.removeItem(existElement);
else if (dom.check.isZeroWidth(existElement)) dom.utils.removeItem(existElement);
} else {
existElement = dom.check.isFigure(existElement.parentNode) ? dom.query.getParentElement(existElement.parentNode, Figure.is) : existElement;
existElement.parentNode.replaceChild(container, existElement);
}
}
}
/**
* @description Initialize the transform style (rotation) of the element.
* @param {?Node} [node] Target element, default is the current element
*/
deleteTransform(node) {
node ||= this._element;
const element = /** @type {HTMLElement} */ (node);
const size = (element.getAttribute('data-se-size') || '').split(',');
this.isVertical = false;
element.style.maxWidth = '';
element.style.transform = '';
element.style.transformOrigin = '';
this.#deleteCaptionPosition(element);
this._applySize(size[0] || 'auto', size[1] || '', '');
}
/**
* @description Set the transform style (rotation) of the element.
* @param {Node} node Target element
* @param {?string|number} width Element's width size
* @param {?string|number} height Element's height size
* @param {?number} deg rotate value
* @example
* // Rotate element 90 degrees clockwise
* this.figure.setTransform(imgElement, 100, 50, 90);
*
* // Apply size without additional rotation (deg=0 preserves current rotation)
* this.figure.setTransform(oFrame, width, height, 0);
*/
setTransform(node, width, height, deg) {
try {
this.#preventSizechange = true;
const info = GetRotateValue(node);
const slope = info.r + (deg || 0) * 1;
deg = Math.abs(slope) >= 360 ? 0 : slope;
const isVertical = (this.isVertical = IsVertical(deg));
width = numbers.get(width, 0);
height = numbers.get(height, 0);
const element = /** @type {HTMLElement} */ (node);
const dataSize = (element.getAttribute('data-se-size') || 'auto,auto').split(',');
if (isVertical) this._setRotate(element, deg, info.x, info.y);
let transOrigin = '';
if (/auto|%$/.test(dataSize[0]) && !isVertical) {
if (dataSize[0] === 'auto' && dataSize[1] === 'auto') {
this._setAutoSize();
} else {
this._setPercentSize(dataSize[0], dataSize[1]);
}
} else {
const figureInfo = Figure.GetContainer(element);
const cover = figureInfo.cover || figureInfo.inlineCover;
const offsetW = width || element.offsetWidth;
const offsetH = height || element.offsetHeight;
const w = (isVertical ? offsetH : offsetW) + 'px';
const h = (isVertical ? offsetW : offsetH) + 'px';
this.#deletePercentSize();
this._applySize(offsetW + 'px', offsetH + 'px', '');
if (cover) {
cover.style.width = w;
cover.style.height = figureInfo.caption || figureInfo.inlineCover ? '' : h;
}
if (isVertical) {
const transW = offsetW / 2 + 'px ' + offsetW / 2 + 'px 0';
const transH = offsetH / 2 + 'px ' + offsetH / 2 + 'px 0';
transOrigin = deg === 90 || deg === -270 ? transH : transW;
}
}
element.style.transformOrigin = transOrigin;
if (!isVertical) this._setRotate(element, deg, info.x, info.y);
if (isVertical) element.style.maxWidth = 'none';
else element.style.maxWidth = '';
this.#setCaptionPosition(element);
} finally {
this.#preventSizechange = false;
}
}
/**
* @internal
* @description Displays or hides the resize handles of the figure component.
* @param {boolean} display Whether to display resize handles.
*/
_displayResizeHandles(display) {
const type = !display ? 'none' : 'flex';
if (this.controller) this.controller.form.style.display = type;
const _figure = this.#$.frameContext.get('_figure');
const resizeHandles = _figure.handles;
for (let i = 0, len = resizeHandles.length; i < len; i++) {
resizeHandles[i].style.display = type;
}
if (type === 'none') {
dom.utils.addClass(_figure.main, 'se-resize-ing');
this.#onResizeESCEvent = this.#$.eventManager.addGlobalEvent('keydown', this.#containerResizingESC);
} else {
dom.utils.removeClass(_figure.main, 'se-resize-ing');
}
}
/**
* @description Handles format conversion (`block`/`inline`) for the figure component and applies size changes.
* @param {FigureInfo} figureinfo `{target, container, cover, inlineCover, caption}`
* @param {string|number} w Width value.
* @param {string|number} h Height value.
*/
#asFormatChange(figureinfo, w, h) {
const kind = this.kind;
figureinfo.target.onload = () => this.#$.component.select(figureinfo.target, kind);
this.#setFigureInfo(figureinfo);
if (figureinfo.inlineCover) {
this.setAlign(figureinfo.target, 'none');
this.deleteTransform();
}
this.setFigureSize(w, h);
}
/**
* @description Sets figure component properties such as cover, container, caption, and alignment.
* @param {FigureInfo} figureInfo - `{target, container, cover, inlineCover, caption, isVertical}`
*/
#setFigureInfo(figureInfo) {
this._inlineCover = figureInfo.inlineCover;
this._cover = figureInfo.cover || this._inlineCover;
this._container = figureInfo.container;
this._caption = figureInfo.caption;
this._element = figureInfo.target;
this.align = (this._container.className.match(/(?:^|\s)__se__float-(none|left|center|right)(?:$|\s)/) || [])[1] || figureInfo.target.style.float || 'none';
this.as = this._inlineCover ? 'inline' : 'block';
this.isVertical = IsVertical(figureInfo.target);
}
/**
* @internal
* @description Applies rotation transformation to the target element.
* @param {HTMLElement} element Target element.
* @param {number} r Rotation degree.
* @param {number} x X-axis rotation value.
* @param {number} y Y-axis rotation value.
*/
_setRotate(element, r, x, y) {
let width = (element.offsetWidth - element.offsetHeight) * (/^-/.test(r + '') ? 1 : -1);
let translate = '';
if (/[1-9]/.test(r + '') && (x || y)) {
translate = x ? 'Y' : 'X';
switch (r + '') {
case '90':
translate = x && y ? 'X' : y ? translate : '';
break;
case '270':
width *= -1;
translate = x && y ? 'Y' : x ? translate : '';
break;
case '-90':
translate = x && y ? 'Y' : x ? translate : '';
break;
case '-270':
width *= -1;
translate = x && y ? 'X' : y ? translate : '';
break;
default:
translate = '';
}
}
if (r % 180 === 0) {
element.style.maxWidth = '';
}
element.style.transform = 'rotate(' + r + 'deg)' + (x ? ' rotateX(' + x + 'deg)' : '') + (y ? ' rotateY(' + y + 'deg)' : '') + (translate ? ' translate' + translate + '(' + width + 'px)' : '');
}
/**
* @internal
* @description Applies size adjustments to the figure element.
* @param {string|number} w Width value.
* @param {string|number} h Height value.
* @param {string} direction Resize direction.
*/
_applySize(w, h, direction) {
const onlyW = /^(rw|lw)$/.test(direction) && /\d+/.test(this._element.style.height);
const onlyH = /^(th|bh)$/.test(direction) && /\d+/.test(this._element.style.width);
h = /** @type {string} */ (h || (this.autoRatio ? this.autoRatio.current || this.autoRatio.default : h));
w = /** @type {string} */ (numbers.is(w) ? w + this.sizeUnit : w);
if (!/%$/.test(w) && !/%$/.test(h) && !onlyW && !onlyH) this.#deletePercentSize();
const sizeTarget = this._cover || this._element;
if (this.autoRatio && this._cover) this._cover.style.width = w;
if (!onlyH) {
sizeTarget.style.width = this._element.style.width = w;
}
if (!onlyW) {
h = numbers.is(h) ? h + this.sizeUnit : h;
sizeTarget.style.height = this._element.style.height = this.autoRatio && !this.isVertical ? '100%' : h;
if (this.autoRatio && this._cover) {
this._cover.style.height = h;
this.__setCoverPaddingBottom(w, h);
}
}
if (this.align === 'center') this.setAlign(this._element, this.align);
// save current size
this.#saveCurrentSize();
}
/**
* @internal
* @description Sets padding-bottom for cover elements based on width and height.
* @param {string} w Width value.
* @param {string} h Height value.
*/
__setCoverPaddingBottom(w, h) {
if (this._inlineCover === this._cover) return;
if (this.isVertical) {
[w, h] = [h, w];
}
this._cover.style.height = h;
if (/%$/.test(w) && this.align === 'center') {
this._cover.style.paddingBottom = !/%$/.test(h) ? h : numbers.get((numbers.get(h, 2) / 100) * numbers.get(w, 2), 2) + '%';
} else {
this._cover.style.paddingBottom = h;
}
}
/**
* @internal
* @description Sets the figure element to its auto size.
*/
_setAutoSize() {
if (this._caption) this._caption.style.marginTop = '';
this.deleteTransform();
this.#deletePercentSize();
if (this.autoRatio) {
this._setPercentSize('100%', this.autoRatio.current || this.autoRatio.default);
} else {
this._element.style.maxWidth = '';
this._element.style.width = '';
this._element.style.height = '';
if (this._cover) {
this._cover.style.width = '';
this._cover.style.height = '';
}
}
this.setAlign(this._element, this.align);
// save current size
this.#saveCurrentSize();
}
/**
* @internal
* @description Sets the figure element's size in percentage.
* @param {string|number} w Width percentage.
* @param {string|number} h Height percentage.
*/
_setPercentSize(w, h) {
h ||= this.autoRatio ? (/%$/.test(this.autoRatio.current) ? this.autoRatio.current : this.autoRatio.default) : h;
h = h && !/%$/.test(h + '') && !numbers.get(h, 0) ? (numbers.is(h) ? h + '%' : h) : numbers.is(h) ? h + this.sizeUnit : h || (this.autoRatio ? this.autoRatio.default : '');
const heightPercentage = /%$/.test(h + '');
this._container.style.width = String(numbers.is(w) ? w + '%' : w);
this._container.style.height = '';
// exceptionFormat
if (this._element === this._cover && !this.#$.options.get('strictMode').formatFilter) {
this.#saveCurrentSize();
return;
}
if (this._inlineCover !== this._cover && this._cover) {
this._cover.style.width = '100%';
this._cover.style.height = String(h);
}
this._element.style.width = '100%';
this._element.style.maxWidth = '';
this._element.style.height = String(this.autoRatio ? '100%' : heightPercentage ? '' : h);
if (this.align === 'center') this.setAlign(this._element, this.align);
if (this.autoRatio) {
this.__setCoverPaddingBottom(String(w), String(h));
}
this.#setCaptionPosition(this._element);
// save current size
this.#saveCurrentSize();
}
/**
* @description Deletes percentage-based sizing from the figure element.
*/
#deletePercentSize() {
if (this._cover) {
this._cover.style.width = '';
this._cover.style.height = '';
}
this._container.style.width = '';
this._container.style.height = '';
dom.utils.removeClass(this._container, this.#floatClassStr);
dom.utils.addClass(this._container, '__se__float-' + this.align);
if (this.align === 'center') this.setAlign(this._element, this.align);
}
/**
* @internal
* @description Reverts the figure element to its previously saved size.
*/
_setRevert() {
this.setFigureSize(this.#revertSize.w, this.#revertSize.h);
}
/**
* @description Updates the figure's alignment icon.
*/
#setAlignIcon() {
if (!this.alignButton) return;
dom.utils.changeElement(this.alignButton.firstElementChild, this._alignIcons[this.align]);
}
/**
* @description Updates the figure's block/inline format icon.
*/
#setAsIcon() {
if (!this.asButton) return;
dom.utils.changeElement(this.asButton.firstElementChild, this.#$.icons[`as_${this.as}`]);
}
/**
* @description Saves the current size of the figure component.
*/
#saveCurrentSize() {
if (this.#preventSizechange) return;
const dataSize = (this._element.getAttribute('data-se-size') || ',').split(',');
this.#revertSize.w = dataSize[0];
this.#revertSize.h = dataSize[1];
const size = this.getSize(this._element);
// add too width, height attribute
this._element.setAttribute('width', size.w.replace('px', ''));
this._element.setAttribute('height', size.h.replace('px', ''));
this._element.setAttribute('data-se-size', size.w + ',' + size.h);
if (this.autoRatio) {
this.autoRatio.current = /%$/.test(size.h) ? size.h : '';
}
}
/**
* @description Adjusts the position of the caption within the figure.
* @param {HTMLElement} element Target element.
*/
#setCaptionPosition(element) {
const figcaption = /** @type {HTMLElement} */ (dom.query.getEdgeChild(dom.query.getParentElement(element, 'FIGURE'), 'FIGCAPTION', false));
if (figcaption) {
figcaption.style.marginTop = (this.isVertical && !this.autoRatio ? element.offsetWidth - element.offsetHeight : 0) + 'px';
if (this.isVertical && this.autoRatio) {
element.style.marginTop = figcaption.offsetHeight + 'px';
} else {
element.style.marginTop = '';
}
}
}
/**
* @description Removes the margin top property from the figure caption.
* @param {HTMLElement} element Target element.
*/
#deleteCaptionPosition(element) {
const figcaption = /** @type {HTMLElement} */ (dom.query.getEdgeChild(dom.query.getParentElement(element, 'FIGURE'), 'FIGCAPTION', false));
if (figcaption) {
figcaption.style.marginTop = '';
}
}
/**
* @description Removes the resize event listeners.
*/
#offResizeEvent() {
this.#$.component.__removeDragEvent();
this.#removeGlobalEvents();
this._displayResizeHandles(true);
this.#$.ui.offCurrentController();
this.#$.ui.disableBackWrapper();
}
/**
* @description Removes global event listeners related to resizing.
*/
#removeGlobalEvents() {
this.#$.eventManager.removeGlobalEvent(this.__onContainerEvent);
this.#$.eventManager.removeGlobalEvent(this.__offContainerEvent);
this.#$.eventManager.removeGlobalEvent(this.#onResizeESCEvent);
}
/**
* @description Sets up drag event handling for the figure component.
* @param {Node} figureMain The main figure container element.
*/
#setDragEvent(figureMain) {
const dragHandle = /** @type {HTMLElement} */ (this.#$.frameContext.get('wrapper').querySelector('.se-drag-handle'));
dom.utils.removeClass(dragHandle, 'se-drag-handle-full');
dragHandle.style.opacity = '';
dragHandle.style.width = '';
dragHandle.style.height = '';
_DragHandle.set('__dragHandler', dragHandle);
_DragHandle.set('__dragContainer', this._container);
_DragHandle.set('__dragCover', this._cover);
_DragHandle.set('__dragMove', this.#OnScrollDragHandler.bind(this, dragHandle, figureMain));
_DragHandle.get('__dragMove')();
dragHandle.style.display = 'block';
}
/**
* @param {HTMLElement} dragHandle Drag handle element
* @param {HTMLElement} figureMain Figure container element
*/
#OnScrollDragHandler(dragHandle, figureMain) {
dragHandle.style.display = 'block';
dragHandle.style.left = figureMain.offsetLeft + (this.#$.options.get('_rtl') ? dragHandle.offsetWidth : figureMain.offsetWidth - dragHandle.offsetWidth * 1.5) + 'px';
dragHandle.style.top = figureMain.offsetTop - dragHandle.offsetHeight + 0.5 + 'px';
}
/**
* @param {MouseEvent} e Event object
*/
#OnResizeContainer(e) {
e.stopPropagation();
e.preventDefault();
const inst = _DragHandle.get('__figureInst');
if (!inst) return;
const eventTarget = dom.query.getEventTarget(e);
const direction = (inst._resize_direction = eventTarget.classList[0]);
inst._resizeClientX = e.clientX;
inst._resizeClientY = e.clientY;
this.#$.frameContext.get('_figure').main.style.float = /l/.test(direction) ? 'right' : /r/.test(direction) ? 'left' : 'none';
this.#$.ui.enableBackWrapper(DIRECTION_CURSOR_MAP[direction]);
const { w, h, dw, dh } = this.getSize(inst._element);
__resizing_p_wh = __resizing