From 97cac56796ce1a66e8003a17339350c36d352cbe Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Tue, 15 Jul 2025 11:10:48 +0400 Subject: [PATCH] Move resize options from SelectComponent to Resize command --- packages/core/src/commands/view/Resize.ts | 240 +++++++++++++++++- .../core/src/commands/view/SelectComponent.ts | 141 ++-------- packages/core/src/dom_components/types.ts | 31 +++ packages/core/src/style_manager/index.ts | 2 +- packages/core/src/utils/Resizer.ts | 92 ++++--- 5 files changed, 332 insertions(+), 174 deletions(-) diff --git a/packages/core/src/commands/view/Resize.ts b/packages/core/src/commands/view/Resize.ts index 07f429d2b..c5ced0821 100644 --- a/packages/core/src/commands/view/Resize.ts +++ b/packages/core/src/commands/view/Resize.ts @@ -1,33 +1,245 @@ -import Resizer, { ResizerOptions } from '../../utils/Resizer'; +import { Position } from '../../common'; +import Component from '../../dom_components/model/Component'; +import { ComponentsEvents } from '../../dom_components/types'; +import ComponentView from '../../dom_components/view/ComponentView'; +import StyleableModel from '../../domain_abstract/model/StyleableModel'; +import { getUnitFromValue } from '../../utils/mixins'; +import Resizer, { RectDim, ResizerOptions } from '../../utils/Resizer'; import { CommandObject } from './CommandAbstract'; +export interface ComponentResizeOptions extends ResizerOptions { + component: Component; + componentView?: ComponentView; + el?: HTMLElement; + afterStart?: () => void; + afterEnd?: () => void; + /** + * @deprecated + */ + options?: ResizerOptions; +} + +export interface ComponentResizeModelProperty { + value: string; + property: string; + number: number; + unit: string; +} + +export interface ComponentResizeEventProps { + component: Component; + event: PointerEvent; + el: HTMLElement; + rect: RectDim; +} + +export interface ComponentResizeEventStartProps extends ComponentResizeEventProps { + model: StyleableModel; + modelWidth: ComponentResizeModelProperty; + modelHeight: ComponentResizeModelProperty; +} + +export interface ComponentResizeEventMoveProps extends ComponentResizeEventProps { + delta: Position; + pointer: Position; +} + +export interface ComponentResizeEventEndProps extends ComponentResizeEventProps { + moved: boolean; +} + +export interface ComponentResizeEventUpdateProps extends Omit { + partial: boolean; + delta: Position; + pointer: Position; +} + export default { - run(editor, sender, opts) { - const opt = opts || {}; - const canvas = editor.Canvas; - const canvasView = canvas.getCanvasView(); - const options: ResizerOptions = { - appendTo: canvas.getResizerEl(), + run(editor, _, options: ComponentResizeOptions) { + const { Canvas, Utils, em } = editor; + const canvasView = Canvas.getCanvasView(); + const pfx = em.config.stylePrefix || ''; + const resizeClass = `${pfx}resizing`; + const { + onStart = () => {}, + onMove = () => {}, + onEnd = () => {}, + updateTarget = () => {}, + el: elOpts, + componentView, + component, + ...resizableOpts + } = options; + const el = elOpts || componentView?.el || component.getEl()!; + const resizeEventOpts = { component, el }; + let modelToStyle: StyleableModel; + + const toggleBodyClass = (method: string, e: any, opts: any) => { + const docs = opts.docs; + docs && + docs.forEach((doc: Document) => { + const body = doc.body; + const cls = body.className || ''; + body.className = (method == 'add' ? `${cls} ${resizeClass}` : cls.replace(resizeClass, '')).trim(); + }); + }; + + const resizeOptions: ResizerOptions = { + appendTo: Canvas.getResizerEl(), prefix: editor.getConfig().stylePrefix, posFetcher: canvasView.getElementPos.bind(canvasView), - mousePosFetcher: canvas.getMouseRelativePos.bind(canvas), - ...(opt.options || {}), + mousePosFetcher: Canvas.getMouseRelativePos.bind(Canvas), + onStart(ev, opts) { + onStart(ev, opts); + const { el, config, resizer } = opts; + const { keyHeight, keyWidth, currentUnit, keepAutoHeight, keepAutoWidth } = config; + toggleBodyClass('add', ev, opts); + modelToStyle = em.Styles.getModelToStyle(component); + const computedStyle = getComputedStyle(el); + const modelStyle = modelToStyle.getStyle(); + const rectStart = { ...resizer.startDim! }; + + let currentWidth = modelStyle[keyWidth!] as string; + config.autoWidth = keepAutoWidth && currentWidth === 'auto'; + if (isNaN(parseFloat(currentWidth))) { + currentWidth = computedStyle[keyWidth as any]; + } + + let currentHeight = modelStyle[keyHeight!] as string; + config.autoHeight = keepAutoHeight && currentHeight === 'auto'; + if (isNaN(parseFloat(currentHeight))) { + currentHeight = computedStyle[keyHeight as any]; + } + + const valueWidth = parseFloat(currentWidth); + const valueHeight = parseFloat(currentHeight); + const unitWidth = getUnitFromValue(currentWidth); + const unitHeight = getUnitFromValue(currentHeight); + + if (currentUnit) { + config.unitWidth = unitWidth; + config.unitHeight = unitHeight; + } + + const eventProps: ComponentResizeEventStartProps = { + ...resizeEventOpts, + event: ev, + rect: rectStart, + model: modelToStyle, + modelWidth: { + value: currentWidth, + property: keyWidth!, + number: valueWidth, + unit: unitWidth, + }, + modelHeight: { + value: currentHeight, + property: keyHeight!, + number: valueHeight, + unit: unitHeight, + }, + }; + console.log('resize onStart', eventProps); + editor.trigger(ComponentsEvents.resizeStart, eventProps); + editor.trigger(ComponentsEvents.resize, { ...eventProps, type: 'start' }); + options.afterStart?.(); + }, + + // Update all positioned elements (eg. component toolbar) + onMove(event, opts) { + onMove(event, opts); + const { resizer } = opts; + const eventProps: ComponentResizeEventMoveProps = { + ...resizeEventOpts, + event, + delta: resizer.delta!, + pointer: resizer.currentPos!, + rect: resizer.rectDim!, + }; + editor.trigger(ComponentsEvents.resizeStart, eventProps); + editor.trigger(ComponentsEvents.resize, { ...eventProps, type: 'move' }); + }, + + onEnd(event, opts) { + onEnd(event, opts); + toggleBodyClass('remove', event, opts); + const { resizer } = opts; + const eventProps: ComponentResizeEventEndProps = { + ...resizeEventOpts, + event, + rect: resizer.rectDim!, + moved: resizer.moved, + }; + console.log('resize onEnd', eventProps); + editor.trigger(ComponentsEvents.resizeEnd, eventProps); + editor.trigger(ComponentsEvents.resize, { ...resizeEventOpts, type: 'end' }); + options.afterEnd?.(); + }, + + updateTarget(el, rect, options) { + updateTarget(el, rect, options); + if (!modelToStyle) { + return; + } + + const { store, selectedHandler, config, resizer } = options; + const { keyHeight, keyWidth, autoHeight, autoWidth, unitWidth, unitHeight } = config; + const onlyHeight = ['tc', 'bc'].indexOf(selectedHandler!) >= 0; + const onlyWidth = ['cl', 'cr'].indexOf(selectedHandler!) >= 0; + const partial = !store; + const style: any = {}; + + if (!onlyHeight) { + const bodyw = Canvas.getBody()?.offsetWidth || 0; + const width = rect.w < bodyw ? rect.w : bodyw; + style[keyWidth!] = autoWidth ? 'auto' : `${width}${unitWidth}`; + } + + if (!onlyWidth) { + style[keyHeight!] = autoHeight ? 'auto' : `${rect.h}${unitHeight}`; + } + + if (em.getDragMode(component)) { + style.top = `${rect.t}${unitHeight}`; + style.left = `${rect.l}${unitWidth}`; + } + + const finalStyle = { + ...style, + __p: partial, + }; + modelToStyle.addStyle(finalStyle, { avoidStore: partial }); + em.Styles.__emitCmpStyleUpdate(finalStyle, { components: em.getSelected() }); + + const eventProps: ComponentResizeEventUpdateProps = { + ...resizeEventOpts, + rect, + partial, + delta: resizer.delta!, + pointer: resizer.currentPos!, + }; + console.log('resize onUpdate', eventProps); + editor.trigger(ComponentsEvents.resizeEnd, eventProps); + }, + ...resizableOpts, + ...options.options, }; + let { canvasResizer } = this; // Create the resizer for the canvas if not yet created - if (!canvasResizer || opt.forceNew) { - this.canvasResizer = new editor.Utils.Resizer(options); + if (!canvasResizer) { + this.canvasResizer = new Utils.Resizer(resizeOptions); canvasResizer = this.canvasResizer; } - canvasResizer.setOptions(options, true); + canvasResizer.setOptions(resizeOptions, true); canvasResizer.blur(); - canvasResizer.focus(opt.el); + canvasResizer.focus(el); return canvasResizer; }, stop() { this.canvasResizer?.blur(); }, -} as CommandObject<{ options?: {}; forceNew?: boolean; el: HTMLElement }, { canvasResizer?: Resizer }>; +} as CommandObject; diff --git a/packages/core/src/commands/view/SelectComponent.ts b/packages/core/src/commands/view/SelectComponent.ts index 7b96ea6fd..4682109f0 100644 --- a/packages/core/src/commands/view/SelectComponent.ts +++ b/packages/core/src/commands/view/SelectComponent.ts @@ -1,13 +1,12 @@ import { bindAll, debounce, isElement } from 'underscore'; +import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot'; import Component from '../../dom_components/model/Component'; import Toolbar from '../../dom_components/model/Toolbar'; +import { ComponentsEvents } from '../../dom_components/types'; import ToolbarView from '../../dom_components/view/ToolbarView'; import { isDoc, isTaggableNode, isVisible, off, on } from '../../utils/dom'; -import { getComponentModel, getComponentView, getUnitFromValue, hasWin, isObject } from '../../utils/mixins'; +import { getComponentModel, getComponentView, hasWin, isObject } from '../../utils/mixins'; import { CommandObject } from './CommandAbstract'; -import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot'; -import { ResizerOptions } from '../../utils/Resizer'; -import { ComponentsEvents } from '../../dom_components/types'; let showOffsets: boolean; /** @@ -395,141 +394,41 @@ export default { initResize(elem: HTMLElement) { const { em, canvas } = this; const editor = em.Editor; - const model = !isElement(elem) && isTaggableNode(elem) ? elem : em.getSelected(); - const resizable = model?.get('resizable'); + const component = !isElement(elem) && isTaggableNode(elem) ? elem : em.getSelected(); + const resizable = component?.get?.('resizable'); const spotTypeResize = CanvasSpotBuiltInTypes.Resize; const hasCustomResize = canvas.hasCustomSpot(spotTypeResize); canvas.removeSpots({ type: spotTypeResize }); const initEventOpts = { - component: model, + component, hasCustomResize, resizable, }; - model && em.trigger(ComponentsEvents.resizeInit, initEventOpts); + component && em.trigger(ComponentsEvents.resizeInit, initEventOpts); const resizableResult = initEventOpts.resizable; - if (model && resizableResult) { - canvas.addSpot({ type: spotTypeResize, component: model }); - const el = isElement(elem) ? elem : model.getEl(); - const { - onStart = () => {}, - onMove = () => {}, - onEnd = () => {}, - updateTarget = () => {}, - ...resizableOpts - } = isObject(resizableResult) ? resizableResult : {}; + if (component && resizableResult) { + canvas.addSpot({ type: spotTypeResize, component }); + const el = isElement(elem) ? elem : component.getEl(); + const resizableOpts = isObject(resizableResult) ? resizableResult : {}; if (hasCustomResize || !el || this.activeResizer) return; - let modelToStyle: any; - const { config } = em; - const pfx = config.stylePrefix || ''; - const resizeClass = `${pfx}resizing`; - const self = this; - const resizeEventOpts = { - component: model, + this.resizer = editor.runCommand('resize', { + ...resizableOpts, el, - }; - - const toggleBodyClass = (method: string, e: any, opts: any) => { - const docs = opts.docs; - docs && - docs.forEach((doc: Document) => { - const body = doc.body; - const cls = body.className || ''; - body.className = (method == 'add' ? `${cls} ${resizeClass}` : cls.replace(resizeClass, '')).trim(); - }); - }; - - const options: ResizerOptions = { - // Here the resizer is updated with the current element height and width - onStart(ev, opts) { - onStart(ev, opts); - const { el, config, resizer } = opts; - const { keyHeight, keyWidth, currentUnit, keepAutoHeight, keepAutoWidth } = config; - toggleBodyClass('add', ev, opts); - modelToStyle = em.Styles.getModelToStyle(model); - const computedStyle = getComputedStyle(el); - const modelStyle = modelToStyle.getStyle(); - - let currentWidth = modelStyle[keyWidth]; - config.autoWidth = keepAutoWidth && currentWidth === 'auto'; - if (isNaN(parseFloat(currentWidth))) { - currentWidth = computedStyle[keyWidth]; - } - - let currentHeight = modelStyle[keyHeight]; - config.autoHeight = keepAutoHeight && currentHeight === 'auto'; - if (isNaN(parseFloat(currentHeight))) { - currentHeight = computedStyle[keyHeight]; - } - - resizer.startDim!.w = parseFloat(currentWidth); - resizer.startDim!.h = parseFloat(currentHeight); + component, + force: true, + afterStart: () => { showOffsets = false; - - if (currentUnit) { - config.unitHeight = getUnitFromValue(currentHeight); - config.unitWidth = getUnitFromValue(currentWidth); - } - self.activeResizer = true; - editor.trigger(ComponentsEvents.resize, { ...resizeEventOpts, type: 'start' }); + this.activeResizer = true; }, - - // Update all positioned elements (eg. component toolbar) - onMove(ev) { - onMove(ev); - editor.trigger(ComponentsEvents.resize, { ...resizeEventOpts, type: 'move' }); - }, - - onEnd(ev, opts) { - onEnd(ev, opts); - toggleBodyClass('remove', ev, opts); - editor.trigger(ComponentsEvents.resize, { ...resizeEventOpts, type: 'end' }); + afterEnd: () => { showOffsets = true; - self.activeResizer = false; - }, - - updateTarget(el, rect, options) { - updateTarget(el, rect, options); - if (!modelToStyle) { - return; - } - - const { store, selectedHandler, config } = options; - const { keyHeight, keyWidth, autoHeight, autoWidth, unitWidth, unitHeight } = config; - const onlyHeight = ['tc', 'bc'].indexOf(selectedHandler!) >= 0; - const onlyWidth = ['cl', 'cr'].indexOf(selectedHandler!) >= 0; - const style: any = {}; - - if (!onlyHeight) { - const bodyw = canvas.getBody()?.offsetWidth || 0; - const width = rect.w < bodyw ? rect.w : bodyw; - style[keyWidth!] = autoWidth ? 'auto' : `${width}${unitWidth}`; - } - - if (!onlyWidth) { - style[keyHeight!] = autoHeight ? 'auto' : `${rect.h}${unitHeight}`; - } - - if (em.getDragMode(model)) { - style.top = `${rect.t}${unitHeight}`; - style.left = `${rect.l}${unitWidth}`; - } - - const finalStyle = { - ...style, - // value for the partial update - __p: !store, - }; - modelToStyle.addStyle(finalStyle, { avoidStore: !store }); - em.Styles.__emitCmpStyleUpdate(finalStyle, { components: em.getSelected() }); + this.activeResizer = false; }, - ...resizableOpts, - }; - - this.resizer = editor.runCommand('resize', { el, options, force: 1 }); + }); } else { if (hasCustomResize) return; diff --git a/packages/core/src/dom_components/types.ts b/packages/core/src/dom_components/types.ts index dc367099a..4f62ecfe8 100644 --- a/packages/core/src/dom_components/types.ts +++ b/packages/core/src/dom_components/types.ts @@ -114,6 +114,37 @@ export enum ComponentsEvents { */ resize = 'component:resize', + /** + * @event `component:resize:start` Component resize started. This event is triggered when the component starts being resized in the canvas. + * @example + * editor.on('component:resize:start', ({ component, event, ... }) => {}) + */ + resizeStart = 'component:resize:start', + + /** + * @event `component:resize:move` Component resize in progress. This event is triggered while the component is being resized in the canvas. + * @example + * editor.on('component:resize:move', ({ component, event, ... }) => {}) + */ + resizeMove = 'component:resize:move', + + /** + * @event `component:resize:end` Component resize ended. This event is triggered when the component stops being resized in the canvas. + * @example + * editor.on('component:resize:end', ({ component, event, ... }) => {}) + */ + resizeEnd = 'component:resize:end', + + /** + * @event `component:resize:update` Component resize style update. This event is triggered when the component is resized in the canvas and the size is updated. + * @example + * editor.on('component:resize:update', ({ component, style, updateStyle, ... }) => { + * // If updateStyle is triggered during the event, the default style update will be skipped. + * updateStyle({ ...style, width: '...' }) + * }) + */ + resizeUpdate = 'component:resize:update', + /** * @event `component:resize:init` Component resize init. This event allows you to control the resizer options dinamically. * @example diff --git a/packages/core/src/style_manager/index.ts b/packages/core/src/style_manager/index.ts index 34c531c4a..f0d93c1ef 100644 --- a/packages/core/src/style_manager/index.ts +++ b/packages/core/src/style_manager/index.ts @@ -539,7 +539,7 @@ export default class StyleManager extends ItemManagerModule< * @return {Model} * @private */ - getModelToStyle(model: any, options: { skipAdd?: boolean; useClasses?: boolean } = {}) { + getModelToStyle(model: any, options: { skipAdd?: boolean; useClasses?: boolean } = {}): StyleableModel { const { em } = this; const { skipAdd } = options; diff --git a/packages/core/src/utils/Resizer.ts b/packages/core/src/utils/Resizer.ts index e239fab4e..e927696b1 100644 --- a/packages/core/src/utils/Resizer.ts +++ b/packages/core/src/utils/Resizer.ts @@ -4,7 +4,7 @@ import { Position } from '../common'; import { off, on } from './dom'; import { normalizeFloat } from './mixins'; -type RectDim = { +export type RectDim = { t: number; l: number; w: number; @@ -19,8 +19,8 @@ type BoundingRect = { }; type CallbackOptions = { - docs: any; - config: any; + docs: Document[]; + config: ResizerOptions; el: HTMLElement; resizer: Resizer; }; @@ -63,17 +63,17 @@ export interface ResizerOptions { /** * On resize start callback. */ - onStart?: (ev: Event, opts: CallbackOptions) => void; + onStart?: (ev: PointerEvent, opts: CallbackOptions) => void; /** * On resize move callback. */ - onMove?: (ev: Event) => void; + onMove?: (ev: PointerEvent, opts: CallbackOptions) => void; /** * On resize end callback. */ - onEnd?: (ev: Event, opts: CallbackOptions) => void; + onEnd?: (ev: PointerEvent, opts: CallbackOptions) => void; /** * On container update callback. @@ -222,6 +222,18 @@ export interface ResizerOptions { * Where to append resize container (default body element). */ appendTo?: HTMLElement; + + /** + * When enabled, the resizer will emit updates only if the size of the element + * changes during a drag operation. + * + * By default, the resizer triggers update callbacks even if the pointer + * doesn’t move (e.g., on click or tap without dragging). Set this option to `true` + * to suppress those "no-op" updates and emit only meaningful changes. + * + * @default false + */ + updateOnMove?: boolean; } type Handlers = Record; @@ -261,6 +273,7 @@ export default class Resizer { delta?: Position; currentPos?: Position; docs?: Document[]; + moved = false; keys?: { shift: boolean; ctrl: boolean; alt: boolean }; mousePosFetcher?: ResizerOptions['mousePosFetcher']; updateTarget?: ResizerOptions['updateTarget']; @@ -460,19 +473,20 @@ export default class Resizer { * Start resizing * @param {Event} e */ - start(ev: Event) { - const e = ev as PointerEvent; - // @ts-ignore Right or middel click - if (e.button !== 0) return; + start(e: PointerEvent) { + const { el, opts = {} } = this; + this.moved = false; + + if (e.button !== 0 || !el) return; + e.preventDefault(); e.stopPropagation(); - const el = this.el!; const parentEl = this.getParentEl(); const resizer = this; - const config = this.opts || {}; + const config = opts; const mouseFetch = this.mousePosFetcher; const attrName = 'data-' + config.prefix + 'handler'; - const rect = this.getElementPos(el!, { avoidFrameZoom: true, avoidFrameOffset: true }); + const rect = this.getElementPos(el, { avoidFrameZoom: true, avoidFrameOffset: true }); const parentRect = this.getElementPos(parentEl!); const target = e.target as HTMLElement; this.handlerAttr = target.getAttribute(attrName)!; @@ -510,55 +524,56 @@ export default class Resizer { on(docs, 'pointerup', this.stop); isFunction(this.onStart) && this.onStart(e, { docs, config, el, resizer }); this.toggleFrames(true); - this.move(e); + !config.updateOnMove && this.move(e); } /** * While resizing * @param {Event} e */ - move(ev: PointerEvent | Event) { - const e = ev as PointerEvent; - const onMove = this.onMove; - const mouseFetch = this.mousePosFetcher; - const currentPos = mouseFetch - ? mouseFetch(e) - : { - x: e.clientX, - y: e.clientY, - }; + move(ev: PointerEvent) { + this.moved = true; + const el = this.el!; + const config = this.opts; + const docs = this.docs || this.getDocumentEl(); + const currentPos = this.mousePosFetcher?.(ev) || { + x: ev.clientX, + y: ev.clientY, + }; this.currentPos = currentPos; this.delta = { x: currentPos.x - this.startPos!.x, y: currentPos.y - this.startPos!.y, }; this.keys = { - shift: e.shiftKey, - ctrl: e.ctrlKey, - alt: e.altKey, + shift: ev.shiftKey, + ctrl: ev.ctrlKey, + alt: ev.altKey, }; - this.rectDim = this.calc(this); this.updateRect(false); - - // Move callback - onMove && onMove(e); + this.onMove?.(ev, { docs, config, el, resizer: this }); } /** * Stop resizing - * @param {Event} e + * @param {Event} ev */ - stop(e: Event) { + stop(ev: PointerEvent) { const el = this.el!; const config = this.opts; const docs = this.docs || this.getDocumentEl(); off(docs, 'pointermove', this.move); off(docs, 'keydown', this.handleKeyDown); off(docs, 'pointerup', this.stop); - this.updateRect(true); + + if (this.moved || !config.updateOnMove) { + this.updateRect(true); + } + this.toggleFrames(); - isFunction(this.onEnd) && this.onEnd(e, { docs, config, el, resizer: this }); + this.onEnd?.(ev, { docs, config, el, resizer: this }); + this.moved = false; delete this.docs; } @@ -634,7 +649,7 @@ export default class Resizer { * Handle ESC key * @param {Event} e */ - handleKeyDown(e: Event) { + handleKeyDown(e: PointerEvent) { // @ts-ignore if (e.keyCode === 27) { // Rollback to initial dimensions @@ -645,15 +660,16 @@ export default class Resizer { /** * Handle mousedown to check if it's possible to start resizing - * @param {Event} e */ - handleMouseDown(e: Event) { + handleMouseDown(e: PointerEvent) { const el = e.target as HTMLElement; if (this.isHandler(el)) { + // el.setPointerCapture(e.pointerId); this.selectedHandler = el; this.start(e); } else if (el !== this.el) { + // el.releasePointerCapture(e.pointerId); delete this.selectedHandler; this.blur(); }