UNPKG

@print-one/grapesjs

Version:

Free and Open Source Web Builder Framework

832 lines (730 loc) 20.5 kB
import { bindAll, each, isFunction } from 'underscore'; import { ElementPosOpts } from '../canvas/view/CanvasView'; import { Position } from '../common'; import { off, on } from './dom'; import { normalizeFloat } from './mixins'; import { rotateCoordinate } from './Rotator'; type RectDim = { t: number; l: number; w: number; h: number; r: number; }; type BoundingRect = { left: number; top: number; width: number; height: number; }; type CallbackOptions = { docs: any; config: any; el: HTMLElement; resizer: Resizer; }; type Coordinate = Pick<RectDim, 't' | 'l'>; export interface ResizerOptions { /** * Function which returns custom X and Y coordinates of the mouse. */ mousePosFetcher?: (ev: Event) => Position; /** * Indicates custom target updating strategy. */ updateTarget?: (el: HTMLElement, rect: RectDim, opts: any) => void; /** * Function which gets HTMLElement as an arg and returns it relative position */ posFetcher?: (el: HTMLElement, opts: any) => BoundingRect; /** * Indicate if the resizer should keep the default ratio. * @default false */ ratioDefault?: boolean; /** * On resize start callback. */ onStart?: (ev: Event, opts: CallbackOptions) => void; /** * On resize move callback. */ onMove?: (ev: Event) => void; /** * On resize end callback. */ onEnd?: (ev: Event, opts: CallbackOptions) => void; /** * On container update callback. */ onUpdateContainer?: (opts: any) => void; /** * Resize unit step. * @default 1 */ step?: number; /** * Minimum dimension. * @default 10 */ minDim?: number; /** * Maximum dimension. * @default Infinity */ maxDim?: number; /** * Unit used for height resizing. * @default 'px' */ unitHeight?: string; /** * Unit used for width resizing. * @default 'px' */ unitWidth?: string; /** * The key used for height resizing. * @default 'height' */ keyHeight?: string; /** * The key used for width resizing. * @default 'width' */ keyWidth?: string; /** * If true, will override unitHeight and unitWidth, on start, with units * from the current focused element (currently used only in SelectComponent). * @default true */ currentUnit?: boolean; /** * With this option enabled the mousemove event won't be altered when the pointer comes over iframes. * @default false */ silentFrames?: boolean; /** * If true the container of handlers won't be updated. * @default false */ avoidContainerUpdate?: boolean; /** * If height is 'auto', this setting will preserve it and only update the width. * @default false */ keepAutoHeight?: boolean; /** * If width is 'auto', this setting will preserve it and only update the height. * @default false */ keepAutoWidth?: boolean; /** * When keepAutoHeight is true and the height has the value 'auto', this is set to true and height isn't updated. * @default false */ autoHeight?: boolean; /** * When keepAutoWidth is true and the width has the value 'auto', this is set to true and width isn't updated. * @default false */ autoWidth?: boolean; /** * Enable top left handler. * @default true */ tl?: boolean; /** * Enable top center handler. * @default true */ tc?: boolean; /** * Enable top right handler. * @default true */ tr?: boolean; /** * Enable center left handler. * @default true */ cl?: boolean; /** * Enable center right handler. * @default true */ cr?: boolean; /** * Enable bottom left handler. * @default true */ bl?: boolean; /** * Enable bottom center handler. * @default true */ bc?: boolean; /** * Enable bottom right handler. * @default true */ br?: boolean; /** * Class prefix. */ prefix?: string; /** * Where to append resize container (default body element). */ appendTo?: HTMLElement; rotationAngle?: number; } const cursors = { 0: 'nwse-resize', 45: 'ns-resize', 90: 'nesw-resize', 135: 'ew-resize', 180: 'nwse-resize', 225: 'ns-resize', 270: 'nesw-resize', 315: 'ew-resize', } as Record<number, string>; const rotations = { tl: 0, tc: 45, tr: 90, cl: 315, cr: 135, bl: 270, bc: 225, br: 180, } as const; type Handlers = Record<string, HTMLElement | null>; const getBoundingRect = (el: HTMLElement, win?: Window): BoundingRect => { var w = win || window; var rect = el.getBoundingClientRect(); return { left: rect.left + w.pageXOffset, top: rect.top + w.pageYOffset, width: rect.width, height: rect.height, }; }; export default class Resizer { defOpts: ResizerOptions; opts: ResizerOptions; container?: HTMLElement; handlers?: Handlers; el?: HTMLElement; clickedHandler?: HTMLElement; selectedHandler?: HTMLElement; handlerAttr?: string; startDim?: RectDim; rectDim?: RectDim; parentDim?: RectDim; startPos?: Position; delta?: Position; currentPos?: Position; docs?: Document[]; keys?: { shift: boolean; ctrl: boolean; alt: boolean }; mousePosFetcher?: ResizerOptions['mousePosFetcher']; updateTarget?: ResizerOptions['updateTarget']; posFetcher?: ResizerOptions['posFetcher']; onStart?: ResizerOptions['onStart']; onMove?: ResizerOptions['onMove']; onEnd?: ResizerOptions['onEnd']; onUpdateContainer?: ResizerOptions['onUpdateContainer']; private createHandler(name: string, opts: { prefix?: string } = {}) { var pfx = opts.prefix || ''; var el = document.createElement('i'); el.className = pfx + 'resizer-h ' + pfx + 'resizer-h-' + name; el.setAttribute('data-' + pfx + 'handler', name); let rot = rotations[name as keyof typeof rotations]; rot += Math.round(this.totalRotation / 45) * 45 + 3600; rot %= 360; el.style.cursor = cursors[rot]; return el; } /** * Init the Resizer with options * @param {Object} options */ constructor(opts: ResizerOptions = {}) { this.defOpts = { ratioDefault: false, onUpdateContainer: () => {}, step: 1, minDim: 10, maxDim: Infinity, unitHeight: 'px', unitWidth: 'px', keyHeight: 'height', keyWidth: 'width', currentUnit: true, silentFrames: false, avoidContainerUpdate: false, keepAutoHeight: false, keepAutoWidth: false, autoHeight: false, autoWidth: false, tl: true, tc: true, tr: true, cl: true, cr: true, bl: true, bc: true, br: true, }; this.opts = { ...this.defOpts }; this.setOptions(opts); bindAll(this, 'handleKeyDown', 'handleMouseDown', 'move', 'stop'); } /** * Get current connfiguration options * @return {Object} */ getConfig() { return this.opts; } /** * Setup options * @param {Object} options */ setOptions(options: Partial<ResizerOptions> = {}, reset?: boolean) { this.opts = { ...(reset ? this.defOpts : this.opts), ...options, }; this.setup(); } get totalRotation() { let r = 0; for (let el = this.container; el; el = el?.parentElement ?? undefined) { const _rotate = getComputedStyle(el).rotate; const rotate = (Number((_rotate === 'none' ? '0deg' : _rotate).replace('deg', '')) + 360) % 360; r += rotate; } return r + (this.opts.rotationAngle ?? 0); } /** * Setup resizer */ setup() { const opts = this.opts; const pfx = opts.prefix || ''; const appendTo = opts.appendTo || document.body; let container = this.container; // Create container if not yet exist if (!container) { container = document.createElement('div'); container.className = `${pfx}resizer-c`; appendTo.appendChild(container); this.container = container; } while (container.firstChild) { container.removeChild(container.firstChild); } // Create handlers const handlers: Handlers = {}; ['tl', 'tc', 'tr', 'cl', 'cr', 'bl', 'bc', 'br'].forEach( // @ts-ignore hdl => (handlers[hdl] = opts[hdl] ? this.createHandler(hdl, opts) : null) ); for (let n in handlers) { const handler = handlers[n]; handler && container.appendChild(handler); } this.handlers = handlers; this.mousePosFetcher = opts.mousePosFetcher; this.updateTarget = opts.updateTarget; this.posFetcher = opts.posFetcher; this.onStart = opts.onStart; this.onMove = opts.onMove; this.onEnd = opts.onEnd; this.onUpdateContainer = opts.onUpdateContainer; } /** * Toggle iframes pointer event * @param {Boolean} silent If true, iframes will be silented */ toggleFrames(silent?: boolean) { if (this.opts.silentFrames) { const frames = document.querySelectorAll('iframe'); each(frames, frame => (frame.style.pointerEvents = silent ? 'none' : '')); } } /** * Detects if the passed element is a resize handler * @param {HTMLElement} el * @return {Boolean} */ isHandler(el: HTMLElement) { const { handlers } = this; for (var n in handlers) { if (handlers[n] === el) return true; } return false; } /** * Returns the focused element * @return {HTMLElement} */ getFocusedEl() { return this.el; } /** * Returns the parent of the focused element * @return {HTMLElement} */ getParentEl() { return this.el?.parentElement; } /** * Returns documents */ getDocumentEl() { return [this.el!.ownerDocument, document]; } /** * Return element position * @param {HTMLElement} el * @param {Object} opts Custom options * @return {Object} */ getElementPos(el: HTMLElement, opts: ElementPosOpts = {}) { const { posFetcher } = this; return posFetcher ? posFetcher(el, opts) : getBoundingRect(el); } /** * Focus resizer on the element, attaches handlers to it * @param {HTMLElement} el */ focus(el: HTMLElement) { // Avoid focusing on already focused element if (el && el === this.el) { return; } this.el = el; this.updateContainer({ forceShow: true }); on(this.getDocumentEl(), 'pointerdown', this.handleMouseDown); } /** * Blur from element */ blur() { this.container!.style.display = 'none'; if (this.el) { off(this.getDocumentEl(), 'pointerdown', this.handleMouseDown); delete this.el; } } /** * Get any of the 8 handlers from the rectangle, * and get it's coordinates based on zero degrees rotation. */ private getRectCoordiante(handler: string, rect: RectDim): Coordinate { switch (handler) { case 'tl': return { t: rect.t, l: rect.l }; case 'tr': return { t: rect.t, l: rect.l + rect.w }; case 'bl': return { t: rect.t + rect.h, l: rect.l }; case 'br': return { t: rect.t + rect.h, l: rect.l + rect.w }; case 'tc': return { t: rect.t, l: rect.l + rect.w / 2 }; case 'cr': return { t: rect.t + rect.h / 2, l: rect.l + rect.w }; case 'bc': return { t: rect.t + rect.h, l: rect.l + rect.w / 2 }; case 'cl': return { t: rect.t + rect.h / 2, l: rect.l }; default: throw new Error('Invalid handler ' + handler); } } /** * Get opposite coordinate on rectangle based on distance to center */ private getOppositeRectCoordinate(coordinate: Coordinate, rect: RectDim): Coordinate { const cx = rect.l + rect.w / 2; const cy = rect.t + rect.h / 2; const dx = cx - coordinate.l; const dy = cy - coordinate.t; const nx = cx + dx; const ny = cy + dy; return { l: nx, t: ny }; } /** * Start resizing * @param {Event} e */ start(ev: Event) { const e = ev as PointerEvent; // @ts-ignore Right or middel click if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const el = this.el!; const parentEl = this.getParentEl(); const resizer = this; const config = this.opts || {}; const mouseFetch = this.mousePosFetcher; const attrName = 'data-' + config.prefix + 'handler'; const rect = this.getElementPos(el!, { avoidFrameZoom: true, avoidFrameOffset: true }); const parentRect = this.getElementPos(parentEl!); const target = e.target as HTMLElement; const _rotation = getComputedStyle(el).rotate; const rotation = (Number((_rotation === 'none' ? '0deg' : _rotation).replace('deg', '')) + 360) % 360; this.handlerAttr = target.getAttribute(attrName)!; this.clickedHandler = target; this.startDim = { t: Number.parseFloat(el?.computedStyleMap().get('top')?.toString() ?? '0'), l: Number.parseFloat(el?.computedStyleMap().get('left')?.toString() ?? '0'), w: rect.width, h: rect.height, r: rotation, }; this.rectDim = { ...this.startDim, }; this.startPos = mouseFetch ? mouseFetch(e) : { x: e.clientX, y: e.clientY, }; this.parentDim = { t: parentRect.top, l: parentRect.left, w: parentRect.width, h: parentRect.height, r: 0, }; // Listen events const docs = this.getDocumentEl(); this.docs = docs; on(docs, 'pointermove', this.move); on(docs, 'keydown', this.handleKeyDown); on(docs, 'pointerup', this.stop); isFunction(this.onStart) && this.onStart(e, { docs, config, el, resizer }); this.toggleFrames(true); 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, }; this.currentPos = currentPos; // Calculate delta based on rotation and x,y shift const theta = (Math.PI / 180) * -this.startDim!.r; const sx = this.startPos!.x * Math.cos(theta) - this.startPos!.y * Math.sin(theta); const sy = this.startPos!.x * Math.sin(theta) + this.startPos!.y * Math.cos(theta); const cx = currentPos.x * Math.cos(theta) - currentPos.y * Math.sin(theta); const cy = currentPos.x * Math.sin(theta) + currentPos.y * Math.cos(theta); this.delta = { x: cx - sx, y: cy - sy, }; this.keys = { shift: e.shiftKey, ctrl: e.ctrlKey, alt: e.altKey, }; this.rectDim = this.calc(this); this.updateRect(false); // Move callback onMove && onMove(e); } /** * Stop resizing * @param {Event} e */ stop(e: Event) { 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); this.toggleFrames(); isFunction(this.onEnd) && this.onEnd(e, { docs, config, el, resizer: this }); delete this.docs; } /** * Update rect */ updateRect(store: boolean) { const el = this.el!; const resizer = this; const config = this.opts; const rect = this.rectDim!; const updateTarget = this.updateTarget; const { unitHeight, unitWidth, keyWidth, keyHeight } = config; // Calculate difference between locking point after new dimensions const coordiantes = [this.startDim!, rect].map(rect => { const handlerCoordinate = this.getRectCoordiante(this.handlerAttr!, rect); const oppositeCoordinate = this.getOppositeRectCoordinate(handlerCoordinate, rect); return rotateCoordinate(oppositeCoordinate, rect); }); const diffX = coordiantes[0].l - coordiantes[1].l; const diffY = coordiantes[0].t - coordiantes[1].t; rect.t += diffY; rect.l += diffX; // Use custom updating strategy if requested if (isFunction(updateTarget)) { updateTarget(el, rect, { store, selectedHandler: this.handlerAttr, resizer, config, }); } else { const elStyle = el.style as Record<string, any>; elStyle[keyWidth!] = rect.w + unitWidth!; elStyle[keyHeight!] = rect.h + unitHeight!; elStyle.top = rect.t + unitHeight!; elStyle.left = rect.l + unitWidth!; } this.updateContainer(); } updateContainer(opt: { forceShow?: boolean } = {}) { const { opts, container, el } = this; const { style } = container!; if (!opts.avoidContainerUpdate && el) { // On component resize container fits the tool, // to check if this update is required somewhere else point // const toUpdate = ['left', 'top', 'width', 'height']; // const rectEl = this.getElementPos(el, { target: 'container' }); // toUpdate.forEach(pos => (style[pos] = `${rectEl[pos]}px`)); if (opt.forceShow) style.display = 'block'; } this.onUpdateContainer?.({ el: container!, resizer: this, opts: { ...opts, ...opt, }, }); } /** * Handle ESC key * @param {Event} e */ handleKeyDown(e: Event) { // @ts-ignore if (e.keyCode === 27) { // Rollback to initial dimensions this.rectDim = this.startDim; this.stop(e); } } /** * Handle mousedown to check if it's possible to start resizing * @param {Event} e */ handleMouseDown(e: Event) { const el = e.target as HTMLElement; if (this.isHandler(el)) { this.selectedHandler = el; this.start(e); } else if (el !== this.el) { delete this.selectedHandler; this.blur(); } } /** * All positioning logic * @return {Object} */ calc(data: Resizer): RectDim | undefined { let value; const opts = this.opts || {}; const step = opts.step!; const startDim = this.startDim!; const minDim = opts.minDim!; const maxDim = opts.maxDim; const deltaX = data.delta!.x; const deltaY = data.delta!.y; const parentW = this.parentDim!.w; const parentH = this.parentDim!.h; const unitWidth = this.opts.unitWidth; const unitHeight = this.opts.unitHeight; const startW = unitWidth === '%' ? (startDim.w / 100) * parentW : startDim.w; const startH = unitHeight === '%' ? (startDim.h / 100) * parentH : startDim.h; const box: RectDim = { t: startDim.t, l: startDim.l, w: startW, h: startH, r: startDim.r, }; if (!data) return; var attr = data.handlerAttr!; if (~attr.indexOf('r')) { value = unitWidth === '%' ? normalizeFloat(((startW + deltaX * step) / parentW) * 100, 0.01) : normalizeFloat(startW + deltaX * step, step); value = Math.max(minDim, value); maxDim && (value = Math.min(maxDim, value)); box.w = value; } if (~attr.indexOf('b')) { value = unitHeight === '%' ? normalizeFloat(((startH + deltaY * step) / parentH) * 100, 0.01) : normalizeFloat(startH + deltaY * step, step); value = Math.max(minDim, value); maxDim && (value = Math.min(maxDim, value)); box.h = value; } if (~attr.indexOf('l')) { value = unitWidth === '%' ? normalizeFloat(((startW - deltaX * step) / parentW) * 100, 0.01) : normalizeFloat(startW - deltaX * step, step); value = Math.max(minDim, value); maxDim && (value = Math.min(maxDim, value)); box.w = value; } if (~attr.indexOf('t')) { value = unitHeight === '%' ? normalizeFloat(((startH - deltaY * step) / parentH) * 100, 0.01) : normalizeFloat(startH - deltaY * step, step); value = Math.max(minDim, value); maxDim && (value = Math.min(maxDim, value)); box.h = value; } // Enforce aspect ratio (unless shift key is being held) var ratioActive = opts.ratioDefault ? !data.keys!.shift : data.keys!.shift; if (attr.indexOf('c') < 0 && ratioActive) { var ratio = startDim.w / startDim.h; if (box.w / box.h > ratio) { box.h = Math.round(box.w / ratio); } else { box.w = Math.round(box.h * ratio); } } for (const key in box) { const i = key as keyof RectDim; box[i] = parseInt(`${box[i]}`, 10); } return box; } }