suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
851 lines (740 loc) • 26 kB
JavaScript
import { dom, env, numbers } from '../../../helper';
import { resolveBlock } from './blockResolver';
import { ResolveButton } from '../../section/constructor';
import CommandMenu from '../../../modules/ui/CommandMenu.js';
const { _w, _d } = env;
/**
* @description Resolve a CSS length value (`px`, `em`, `rem`, or unitless) to pixels.
* Falls back to `0` when the value is empty or unparseable.
* @param {string} value Raw CSS length string (e.g. `"1.5em"`)
* @param {CSSStyleDeclaration} contextStyle Computed style of the element using the value (for `em`)
* @returns {number} Resolved pixel length
*/
function _resolveLengthPx(value, contextStyle) {
const trimmed = (value || '').trim();
const num = numbers.get(trimmed, -1);
if (!num) return 0;
if (/rem$/.test(trimmed)) {
return num * (numbers.get(_w.getComputedStyle(_d.documentElement).fontSize, -1) || 16);
}
if (/em$/.test(trimmed)) {
return num * (numbers.get(contextStyle.fontSize, -1) || 16);
}
return num;
}
/**
* @class
* @description Block handle UI — appears in the left gutter on block hover.
*/
class BlockHandle {
#$;
#area;
#handle;
#plusBtn;
#dragBtn;
#menuConfig;
/** @type {CommandMenu|null} */
#actionMenu = null;
/** @type {HTMLElement|null} */
#currentBlock = null;
/** @type {number|null} */
#rafId = null;
/** @type {Node|null} */
#pendingTarget = null;
/** @type {number|undefined} */
#pendingMouseY = undefined;
/** @type {number|null} */
#hideTimer = null;
/** @type {HTMLElement[]} */
#hoverLines = [];
// Drag state
/** @type {boolean} */
#isDragging = false;
/** @type {HTMLElement|null} */
#dragIndicator = null;
/** @type {HTMLElement|null} */
#dragTarget = null;
/** @type {{element: HTMLElement, position: 'before'|'after'}|null} */
#dropTarget = null;
/** @type {HTMLElement|null} */
#dragWysiwyg = null;
/** @type {HTMLElement[]|null} */
#dragChildren = null;
/**
* @constructor
* @param {SunEditor.Deps} $ - Kernel dependencies
* @param {HTMLElement} blockHandleArea - Container (.se-block-handle-area)
* @param {HTMLElement} blockHandle - Handle group (.se-block-handle)
* @param {HTMLElement} blockHandlePlus - Plus button
* @param {HTMLElement} blockHandleDrag - Drag button
* @param {Array<string | { title: string, icon?: string, action: function(SunEditor.Deps, { block: HTMLElement }): void }>|null} menuConfig
* Menu entries. Strings resolve via `ResolveButton` (plugin names, built-in commands). Objects
* define a custom row whose `action` is invoked with the Deps bag and the current block element.
*/
constructor($, blockHandleArea, blockHandle, blockHandlePlus, blockHandleDrag, menuConfig) {
this.#$ = $;
this.#area = blockHandleArea;
this.#handle = blockHandle;
this.#plusBtn = blockHandlePlus;
this.#dragBtn = blockHandleDrag;
this.#menuConfig = menuConfig || null;
this.#$.contextProvider.carrierWrapper.appendChild(this.#handle);
const em = this.#$.eventManager;
// Prevent editor blur on handle interaction — preserves selection range (same as toolbar)
em.addEvent(this.#handle, 'mousedown', this.#onHandleMouseDown.bind(this));
// Create drag indicator
this.#dragIndicator = dom.utils.createElement('DIV', { class: 'se-block-drag-indicator' });
this.#dragIndicator.style.display = 'none';
this.#area.parentElement?.appendChild(this.#dragIndicator);
em.addEvent(this.#plusBtn, 'click', this.#onPlusClick.bind(this));
// HTML5 drag-and-drop: native drag image, native event flow.
this.#dragBtn.draggable = true;
em.addEvent(this.#dragBtn, 'dragstart', this.#onDragStart.bind(this));
em.addEvent(this.#dragBtn, 'dragend', this.#onDragEnd.bind(this));
em.addEvent(this.#dragBtn, 'click', this.#onDragClick.bind(this));
em.addEvent(this.#area, 'mousemove', this.#onAreaMouseMove.bind(this));
em.addEvent(this.#area, 'mouseleave', this.#onAreaMouseLeave.bind(this));
// Wrapper (parent of area + wysiwyg) — catches mouse leaving the entire editor
// zone, plus the dragover/drop targets for the in-flight HTML5 drag.
const wrapper = this.#area.parentElement;
if (wrapper) {
em.addEvent(wrapper, 'mouseleave', this.#onWrapperMouseLeave.bind(this));
em.addEvent(wrapper, 'dragover', this.#onDragOver.bind(this));
em.addEvent(wrapper, 'drop', this.#onDrop.bind(this));
}
em.addEvent(this.#$.frameContext.get('eventWysiwyg'), 'keydown', this.#onWrapperKeyDown.bind(this), true);
}
/**
* @description Show the handle.
*/
#showHandle() {
this.#handle.style.display = 'flex';
try {
this.#handle.showPopover?.();
} catch {
// already open
}
}
/**
* @description Hide the handle and drop it out of the top layer.
*/
#hideHandle() {
try {
this.#handle.hidePopover?.();
} catch {
// already hidden
}
this.#handle.style.display = 'none';
}
/**
* @description Position the block handle for the given mouse target. Uses rAF throttle.
* Called from wysiwyg mousemove.
* @param {Node} eventTarget - The element under the mouse cursor
* @param {number} [mouseY] - Mouse clientY for nested list resolution
*/
positionForTarget(eventTarget, mouseY) {
this.#cancelHide();
// Lock handle while action menu is open or dragging
if (this.#actionMenu?.isOpen || this.#isDragging) return;
this.#pendingTarget = eventTarget;
this.#pendingMouseY = mouseY;
if (this.#rafId) return;
this.#rafId = _w.requestAnimationFrame(() => {
this.#rafId = null;
if (this.#pendingTarget) {
this.#doPosition(this.#pendingTarget, this.#pendingMouseY);
}
});
}
/**
* @description Schedule hiding the block handle with a short delay.
* @param {MouseEvent} e - Mouse event
*/
hide(e) {
if (this.#actionMenu?.isOpen) return;
const related = /** @type {Node} */ (e?.relatedTarget);
if (related && (this.#area.contains(related) || this.#handle?.contains(related))) return;
this.#scheduleHide();
}
/**
* @description Immediately hide the block handle (no delay).
*/
hideNow() {
this.#cancelHide();
this.#hideHandle();
this.#actionMenu?.close();
this.#setCurrentBlock(null);
}
/**
* @description Sync handle position on editor scroll. Closes menu if open.
*/
syncScroll() {
if (!this.#currentBlock) return;
// Skip transition during scroll — handle should track scroll instantly
dom.utils.addClass(this.#handle, 'se-no-transition');
this.#updatePosition(this.#currentBlock);
void this.#handle.offsetHeight;
dom.utils.removeClass(this.#handle, 'se-no-transition');
if (this.#actionMenu?.isOpen) {
this.#actionMenu.setHidden(this.#handle.style.display === 'none');
}
}
/**
* @description Cleanup — remove listeners, destroy menus, null references.
*/
destroy() {
if (this.#rafId) {
_w.cancelAnimationFrame(this.#rafId);
this.#rafId = null;
}
this.#isDragging = false;
this.#cancelHide();
if (this.#dragBtn) this.#dragBtn.draggable = false;
this.#dragIndicator?.remove();
this.#handle?.remove();
// CommandMenu's closeMethod tears down its own flyout when the menu closes.
this.#actionMenu?.close();
this.#actionMenu = null;
this.#setCurrentBlock(null);
this.#pendingTarget = null;
this.#pendingMouseY = undefined;
this.#$ = null;
this.#area = null;
this.#handle = null;
this.#plusBtn = null;
this.#dragBtn = null;
}
#setCurrentBlock(block) {
this.#clearHoverLines();
this.#currentBlock = block;
if (!block) return;
// If a non-collapsed selection covers multiple lines including this block,
// highlight all lines in the selection (action menu will affect all of them).
const rangeLines = this.#getSelectionLinesContaining(block);
if (rangeLines) {
this.#hoverLines = rangeLines;
for (let i = 0; i < rangeLines.length; i++) dom.utils.addClass(rangeLines[i], 'se-block-hover');
} else {
dom.utils.addClass(block, 'se-block-hover');
}
}
/**
* @description Return all lines covered by the current selection if it spans
* multiple lines and includes `block`. Otherwise null.
* @param {HTMLElement} block
* @returns {?Array<HTMLElement>}
*/
#getSelectionLinesContaining(block) {
try {
const range = this.#$.selection.getRange();
if (!range || range.collapsed) return null;
const startLine = this.#$.format.getLine(range.startContainer, null);
const endLine = this.#$.format.getLine(range.endContainer, null);
if (!startLine || !endLine || startLine === endLine) return null;
const all = dom.query.getListChildren(range.commonAncestorContainer, (n) => this.#$.format.isLine(n), null);
const sIdx = all.indexOf(startLine);
const eIdx = all.indexOf(endLine);
if (sIdx === -1 || eIdx === -1) return null;
const lines = all.slice(Math.min(sIdx, eIdx), Math.max(sIdx, eIdx) + 1);
if (lines.indexOf(block) === -1) return null;
return lines;
} catch {
// No valid selection
}
return null;
}
#setHoverLines(lines) {
this.#clearHoverLines();
this.#hoverLines = lines || [];
for (let i = 0; i < this.#hoverLines.length; i++) {
dom.utils.addClass(this.#hoverLines[i], 'se-block-hover');
}
}
#clearHoverLines() {
if (this.#currentBlock) dom.utils.removeClass(this.#currentBlock, 'se-block-hover');
for (let i = 0; i < this.#hoverLines.length; i++) {
dom.utils.removeClass(this.#hoverLines[i], 'se-block-hover');
}
this.#hoverLines = [];
}
#scheduleHide() {
if (this.#hideTimer) return;
this.#hideTimer = _w.setTimeout(() => {
this.#hideTimer = null;
this.#hideHandle();
this.#actionMenu?.close();
this.#setCurrentBlock(null);
}, 200);
}
#cancelHide() {
if (this.#hideTimer) {
_w.clearTimeout(this.#hideTimer);
this.#hideTimer = null;
}
}
/**
* @description Mouse moves inside the block handle area.
* Probes the wysiwyg at the same Y level to find which block to show the handle for.
* @param {MouseEvent} e
*/
#onAreaMouseMove(e) {
if (!this.#$) return;
if (this.#isDragging) return;
this.#cancelHide();
// Don't reposition while action menu is open
if (this.#actionMenu?.isOpen) return;
const wysiwygEl = this.#$.frameContext.get('wysiwyg');
if (!wysiwygEl) return;
const frameEl = this.#$.frameContext.get('wysiwygFrame');
const isIframe = frameEl && frameEl.nodeName === 'IFRAME';
let target;
if (isIframe) {
// iframe mode: convert to iframe-local coordinates
const iframeRect = frameEl.getBoundingClientRect();
const doc = /** @type {HTMLIFrameElement} */ (frameEl).contentDocument;
if (doc) target = doc.elementFromPoint(iframeRect.width / 2, e.clientY - iframeRect.top);
} else {
// Normal mode: probe the wysiwyg center at the mouse's Y
const rect = wysiwygEl.getBoundingClientRect();
target = document.elementFromPoint(rect.left + rect.width / 2, e.clientY);
}
if (target && wysiwygEl.contains(target)) {
this.#doPosition(target, e.clientY);
}
}
/**
* @description Mouse leaves the block handle area.
* @param {MouseEvent} e
*/
#onAreaMouseLeave(e) {
if (this.#actionMenu?.isOpen) return;
if (this.#stayAlive(/** @type {Node} */ (e?.relatedTarget))) return;
this.hideNow();
}
/**
* @description Mouse leaves the entire wrapper (area + wysiwyg).
*/
#onWrapperMouseLeave(e) {
if (this.#actionMenu?.isOpen) return;
if (this.#stayAlive(/** @type {Node} */ (e?.relatedTarget))) return;
this.hideNow();
}
/**
* @description Whether a mouseleave's `relatedTarget` is still within the editor interaction zone
* @param {Node|null} related - `relatedTarget` of the mouseleave event
* @returns {boolean}
*/
#stayAlive(related) {
if (!related) return false;
const wrapper = this.#area?.parentElement;
return !!(this.#handle?.contains(related) || wrapper?.contains(related));
}
/**
* @param {Node} eventTarget
* @param {number} [mouseY]
*/
#doPosition(eventTarget, mouseY) {
if (!this.#$) return; // destroyed
const format = this.#$.format;
const wysiwygFrame = this.#$.frameContext.get('wysiwyg');
if (!wysiwygFrame) return;
let target = eventTarget;
if (target === wysiwygFrame && mouseY !== undefined) {
const rect = wysiwygFrame.getBoundingClientRect();
const probed = document.elementFromPoint(rect.left + rect.width / 2, mouseY);
if (probed && wysiwygFrame.contains(probed) && probed !== wysiwygFrame) {
target = probed;
}
}
const block = resolveBlock(
target,
{
getLine: (node, validation) => format.getLine(node, validation),
getBlock: (el, validation) => format.getBlock(el, validation),
isLine: (el) => format.isLine(el),
isBlock: (el) => format.isBlock(el),
},
wysiwygFrame,
mouseY,
);
if (!block) {
this.#scheduleHide();
return;
}
// Same block and handle already visible — skip
if (block.element === this.#currentBlock && this.#handle.style.display === 'flex') return;
if (this.#currentBlock?.isConnected && this.#handle.style.display === 'flex' && mouseY !== undefined) {
if (!this.#currentBlock.contains(block.element)) {
const r = this.#currentBlock.getBoundingClientRect();
if (mouseY >= r.top && mouseY <= r.bottom) return;
}
}
this.#setCurrentBlock(block.element);
this.#updatePosition(block.element);
}
/**
* @param {HTMLElement} blockElement
*/
#updatePosition(blockElement) {
if (!blockElement.isConnected) {
this.hideNow();
return;
}
const blockRect = blockElement.getBoundingClientRect();
const areaRect = this.#area.getBoundingClientRect();
const wysiwygFrameEl = this.#$.frameContext.get('wysiwygFrame');
const isIframe = /^iframe$/i.test(wysiwygFrameEl.nodeName);
const iframeRect = isIframe ? wysiwygFrameEl.getBoundingClientRect() : null;
if (isIframe) {
const iframeH = wysiwygFrameEl.clientHeight || 0;
if (blockRect.bottom <= 0 || blockRect.top >= iframeH) {
this.#hideHandle();
return;
}
} else {
const wwFrameRect = wysiwygFrameEl.getBoundingClientRect();
if (blockRect.bottom <= wwFrameRect.top || blockRect.top >= wwFrameRect.bottom) {
this.#hideHandle();
return;
}
}
const scrollX = _w.scrollX;
const scrollY = _w.scrollY;
// First appearance after being hidden — skip transition. Make the handle
// measurable (display) before reading its height so we can center it.
const wasHidden = this.#handle.style.display !== 'flex';
if (wasHidden) {
dom.utils.addClass(this.#handle, 'se-no-transition');
this.#handle.style.display = 'flex';
}
const handleHeight = this.#handle.offsetHeight || 0;
const firstLineCenter = this.#getFirstLineCenter(blockElement, blockRect);
const firstLineCenterVP = isIframe ? firstLineCenter + iframeRect.top : firstLineCenter;
const top = firstLineCenterVP + scrollY - handleHeight / 2;
// Handle inline offset
const isRtl = !!this.#$.options.get('_rtl');
const indent = this.#getBlockIndent(blockElement, isRtl);
// innerWidth
let centeringExtra = 0;
if (this.#$.frameOptions.get('innerWidth')) {
const wysiwygFrame = this.#$.frameContext.get('wysiwyg');
if (wysiwygFrame) {
const cs = _w.getComputedStyle(wysiwygFrame);
const padStart = numbers.get(isRtl ? cs.paddingRight : cs.paddingLeft, -1) || 0;
const baseline = _resolveLengthPx(cs.getPropertyValue('--se-edit-inner-padding'), cs);
centeringExtra = Math.max(0, padStart - baseline);
}
}
const totalOffset = indent + centeringExtra;
this.#handle.style.top = top + 'px';
if (isRtl) {
this.#handle.style.left = '';
this.#handle.style.right = _w.innerWidth - areaRect.right - scrollX + totalOffset + 'px';
} else {
this.#handle.style.right = '';
this.#handle.style.left = areaRect.left + scrollX + totalOffset + 'px';
}
this.#showHandle();
if (wasHidden) {
void this.#handle.offsetHeight;
dom.utils.removeClass(this.#handle, 'se-no-transition');
}
}
/**
* @description Viewport-Y center of the block's first line box (in the block's own document coordinates).
* @param {HTMLElement} blockElement
* @param {DOMRect} blockRect - `blockElement.getBoundingClientRect()`
* @returns {number} Viewport-Y coordinate of the first line's vertical center
*/
#getFirstLineCenter(blockElement, blockRect) {
try {
const doc = blockElement.ownerDocument;
const walker = doc.createTreeWalker(blockElement, NodeFilter.SHOW_TEXT, null);
let textNode = /** @type {Text|null} */ (null);
while ((textNode = /** @type {Text} */ (walker.nextNode()))) {
if (textNode.textContent?.trim()) break;
}
if (textNode) {
const range = doc.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, Math.min(1, textNode.length));
const rects = range.getClientRects();
if (rects.length) return (rects[0].top + rects[0].bottom) / 2;
}
} catch {
// Fall through to the line-height estimate below
}
const lineHeight = numbers.get(_w.getComputedStyle(blockElement).lineHeight, -1) || blockRect.height;
return blockRect.top + Math.min(lineHeight, blockRect.height) / 2;
}
/**
* @description Calculate the handle's inline indent.
* @param {HTMLElement} blockElement
* @param {boolean} isRtl
* @returns {number} Indent in pixels (0 = default gutter position)
*/
#getBlockIndent(blockElement, isRtl) {
const wysiwyg = this.#$.frameContext.get('wysiwyg');
if (!wysiwyg) return 0;
const format = this.#$.format;
const marginKey = isRtl ? 'marginRight' : 'marginLeft';
const paddingKey = isRtl ? 'paddingRight' : 'paddingLeft';
let indent = 0;
indent += numbers.get(_w.getComputedStyle(blockElement)[marginKey], -1) || 0;
let el = blockElement.parentElement;
while (el && el !== wysiwyg) {
if (format.isBlock(el)) {
const s = _w.getComputedStyle(el);
indent += (numbers.get(s[paddingKey], -1) || 0) + (numbers.get(s[marginKey], -1) || 0);
}
el = el.parentElement;
}
return indent;
}
/**
* @description Expand the selection range so it covers full lines.
*/
#expandRangeToFullLines() {
if (!this.#currentBlock) return;
const block = this.#currentBlock;
const rangeLines = this.#getSelectionLinesContaining(block);
const firstLine = rangeLines ? rangeLines[0] : block;
const lastLine = rangeLines ? rangeLines[rangeLines.length - 1] : block;
this.#$.selection.setRange(firstLine, 0, lastLine, lastLine.childNodes.length);
}
/**
* @description Plus button click — insert new line after current block.
* Mirrors Enter-at-end-of-line behavior from keydown.rule.enter.
* @param {MouseEvent} e
*/
#onPlusClick(e) {
e.preventDefault();
e.stopPropagation();
if (!this.#currentBlock) return;
const newLine = this.#$.format.addLineAfter(this.#currentBlock);
if (newLine) {
this.#$.selection.setRange(newLine, 1, newLine, 1);
this.#$.history.push(false);
}
}
/**
* @description Handle group mousedown — prevents editor blur on click so the selection survives.
* @param {MouseEvent} e
*/
#onHandleMouseDown(e) {
const onDragBtn =
this.#dragBtn && (e.target === this.#dragBtn || this.#dragBtn.contains(/** @type {Node} */ (e.target)));
if (env.isMobile) {
this.#$.store.set('_preventBlur', true);
return;
}
if (!onDragBtn) e.preventDefault();
}
/**
* @description Editor keyboard activity (typing, Enter, etc.)
*/
#onWrapperKeyDown() {
this.#clearHoverLines();
}
/**
* @description Dragstart on the drag handle.
* @param {DragEvent} e
*/
#onDragStart(e) {
if (!this.#currentBlock) {
e.preventDefault();
return;
}
this.#dragTarget = this.#currentBlock;
this.#isDragging = true;
this.#actionMenu?.close();
dom.utils.addClass(this.#dragTarget, 'se-block-dragging');
// Cache wysiwyg + children for the duration of the drag — DOM is stable until drop.
const wysiwygEl = /** @type {HTMLElement} */ (this.#$.frameContext.get('wysiwyg'));
this.#dragWysiwyg = wysiwygEl || null;
this.#dragChildren = wysiwygEl ? /** @type {HTMLElement[]} */ (Array.from(wysiwygEl.children)) : null;
const isRtl = !!this.#$.options.get('_rtl');
const cover = /** @type {HTMLElement} */ (this.#dragTarget);
e.dataTransfer.setDragImage(cover, isRtl ? cover.offsetWidth : -5, -5);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-suneditor-block-drag', '1');
}
/**
* @description Dragover on the wrapper.
* @param {DragEvent} e
*/
#onDragOver(e) {
if (!this.#isDragging) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
this.#updateDragIndicator(e.clientY);
}
/**
* @description Native HTML5 drop on the wrapper. Commits the move.
* @param {DragEvent} e
*/
#onDrop(e) {
if (!this.#isDragging) return;
e.preventDefault();
e.stopPropagation();
this.#executeDrop();
}
/**
* @description Dragend on the drag handle.
*/
#onDragEnd() {
if (this.#dragTarget) dom.utils.removeClass(this.#dragTarget, 'se-block-dragging');
this.#dragIndicator.style.display = 'none';
this.#isDragging = false;
this.#dragTarget = null;
this.#dropTarget = null;
this.#dragWysiwyg = null;
this.#dragChildren = null;
if (this.#dragBtn?.parentNode) {
const parent = this.#dragBtn.parentNode;
const next = this.#dragBtn.nextSibling;
parent.removeChild(this.#dragBtn);
parent.insertBefore(this.#dragBtn, next);
}
}
/**
* @description Update drag indicator position based on mouse Y.
* @param {number} clientY
*/
#updateDragIndicator(clientY) {
if (!this.#$ || !this.#dragTarget) return;
const wysiwygEl = this.#dragWysiwyg;
const children = this.#dragChildren;
if (!wysiwygEl || !children) return;
const wysiwygRect = wysiwygEl.getBoundingClientRect();
const wrapperRect = this.#area.parentElement.getBoundingClientRect();
let closest = null;
/** @type {"after"|"before"} */
let closestPos = 'after';
let closestY = 0;
let minDist = Infinity;
for (let i = 0; i < children.length; i++) {
const child = /** @type {HTMLElement} */ (children[i]);
const r = child.getBoundingClientRect();
// Check top edge
const topDist = Math.abs(clientY - r.top);
if (topDist < minDist) {
minDist = topDist;
closest = child;
closestPos = 'before';
closestY = r.top;
}
// Check bottom edge
const bottomDist = Math.abs(clientY - r.bottom);
if (bottomDist < minDist) {
minDist = bottomDist;
closest = child;
closestPos = 'after';
closestY = r.bottom;
}
}
if (closest && closest !== this.#dragTarget) {
this.#dropTarget = { element: closest, position: closestPos };
const ind = this.#dragIndicator;
ind.style.display = 'block';
ind.style.top = closestY - wrapperRect.top + 'px';
ind.style.left = wysiwygRect.left - wrapperRect.left + 'px';
ind.style.width = wysiwygRect.width + 'px';
} else {
this.#dropTarget = null;
this.#dragIndicator.style.display = 'none';
}
}
/**
* @description Execute the drop — move the dragged block to the drop position.
*/
#executeDrop() {
const target = this.#dragTarget;
const drop = this.#dropTarget;
if (!target || !drop || drop.element === target) {
return;
}
const parent = target.parentNode;
if (!parent) return;
if (drop.position === 'before') {
drop.element.parentNode.insertBefore(target, drop.element);
} else {
drop.element.parentNode.insertBefore(target, drop.element.nextSibling);
}
this.#setCurrentBlock(target);
this.#updatePosition(target);
this.#$.history.push(false);
}
/**
* @description Drag button click — open action menu.
* @param {MouseEvent} e
*/
#onDragClick(e) {
e.preventDefault();
e.stopPropagation();
// Skip if this click was actually a drag
if (this.#isDragging) return;
if (!this.#menuConfig) return;
// Lazy build — plugins are not yet instantiated when BlockHandle is constructed
if (!this.#actionMenu) {
this.#actionMenu = this.#buildActionMenu();
}
if (this.#actionMenu.isOpen) {
this.#actionMenu.close();
} else {
this.#expandRangeToFullLines();
// Highlight selected range lines
const lines = this.#$.format.getLines(null);
if (lines.length > 0) {
this.#setHoverLines(lines);
}
// Choose open direction based on available space.
const btnGlobal = this.#$.offset.getGlobal(this.#dragBtn);
const spaceBelow = dom.utils.getClientSize().h - (btnGlobal.top - _w.scrollY + btnGlobal.height);
const spaceAbove = btnGlobal.top - _w.scrollY;
const horiz = this.#$.options.get('_rtl') ? 'left' : 'right';
const dir = `${horiz}-${spaceBelow >= spaceAbove ? 'bottom' : 'top'}`;
this.#actionMenu.open(dir);
}
}
/**
* @description Build the action menu via {@link CommandMenu}.
* @returns {CommandMenu}
*/
#buildActionMenu() {
const menu = new CommandMenu(this, this.#$, {
items: this.#menuConfig,
resolveButton: ResolveButton,
selectMenuParams: {
position: 'right-top',
dir: this.#$.options.get('_rtl') ? 'rtl' : 'ltr',
minWidth: '200px',
keydownTarget: _w,
closeMethod: () => {
dom.utils.removeClass(this.#dragBtn, 'on');
this.#clearHoverLines();
},
openMethod: () => {
dom.utils.addClass(this.#dragBtn, 'on');
},
},
});
menu.attach(this.#dragBtn, this.#onActionSelect.bind(this), { class: 'se-block-action-menu' });
menu.createRows(menu.getItems());
return menu;
}
/**
* @description Action menu click — delegates to CommandMenu.dispatch with `{block}` as the
* custom-action context. CommandMenu handles plugin dispatch (modal/browser/popup/command,
* built-ins via commandDispatcher) and dropdown-free toggling internally.
* @param {*} item
*/
#onActionSelect(item) {
if (!this.#currentBlock || !this.#$ || !item) return;
this.#actionMenu?.dispatch(item, { block: this.#currentBlock });
// Dropdown-free items keep the menu open (the flyout is the actual UI); other dispatches close.
if (item.kind !== 'dropdownFree') this.#actionMenu?.close();
}
}
export default BlockHandle;