UNPKG

@print-one/grapesjs

Version:

Free and Open Source Web Builder Framework

881 lines (768 loc) 25.3 kB
import { bindAll, debounce, isElement } from 'underscore'; import Component from '../../dom_components/model/Component'; import Toolbar from '../../dom_components/model/Toolbar'; import ToolbarView from '../../dom_components/view/ToolbarView'; import { isDoc, isTaggableNode, isVisible, off, on } from '../../utils/dom'; import { getComponentModel, getComponentView, getUnitFromValue, getViewEl, hasWin, isObject } from '../../utils/mixins'; import { CommandObject } from './CommandAbstract'; import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot'; import { ResizerOptions } from '../../utils/Resizer'; let showOffsets: boolean; /** * This command is responsible for show selecting components and displaying * all the necessary tools around (component toolbar, badge, highlight box, etc.) * * The command manages different boxes to display tools and when something in * the canvas is updated, the command triggers the appropriate method to update * their position (across multiple frames/components): * - Global Tools (updateToolsGlobal/updateGlobalPos) * This box contains tools intended to be displayed only on ONE component per time, * like Component Toolbar (updated by updateToolbar/updateToolbarPos), this means * you won't be able to see more than one Component Toolbar (even with multiple * frames or multiple selected components) * - Local Tools (updateToolsLocal/updateLocalPos) * Each frame in the canvas has its own local box, so we're able to see more than * one active container at the same time. When you put a mouse over an element * you can see stuff like the highlight box, badge, margins/paddings offsets, etc. * so those elements are inside the Local Tools box * * */ export default { init() { this.onSelect = debounce(this.onSelect, 0); bindAll( this, 'onHover', 'onOut', 'onClick', 'onFrameScroll', 'onFrameResize', 'onFrameUpdated', 'onContainerChange' ); }, enable() { this.frameOff = this.canvasOff = this.adjScroll = null; this.startSelectComponent(); showOffsets = true; }, /** * Start select component event * @private * */ startSelectComponent() { this.toggleSelectComponent(1); this.em.getSelected() && this.onSelect(); }, /** * Stop select component event * @private * */ stopSelectComponent() { this.toggleSelectComponent(); }, /** * Toggle select component event * @private * */ toggleSelectComponent(enable: boolean) { const { em } = this; const listenToEl = em.getConfig().listenToEl!; const { parentNode } = em.getContainer()!; const method = enable ? 'on' : 'off'; const methods = { on, off }; !listenToEl.length && parentNode && listenToEl.push(parentNode as HTMLElement); const trigger = (win: Window, body: HTMLBodyElement) => { methods[method](body, 'mouseover', this.onHover); methods[method](body, 'mouseleave', this.onOut); methods[method](body, 'click', this.onClick); methods[method](win, 'scroll', this.onFrameScroll, true); methods[method](win, 'resize', this.onFrameResize); }; methods[method](window, 'resize', this.onFrameUpdated); methods[method](window, 'rotate', this.onFrameUpdated); methods[method](listenToEl, 'scroll', this.onContainerChange); em[method]('component:toggled component:update undo redo', this.onSelect, this); em[method]('change:componentHovered', this.onHovered, this); em[method]( 'component:resize component:rotate styleable:change component:input', // component:styleUpdate this.updateGlobalPos, this ); em[method]('component:update:toolbar', this._upToolbar, this); em[method]('change:canvasOffset', this.updateAttached, this); em[method]('frame:updated', this.onFrameUpdated, this); em[method]('canvas:updateTools', this.onFrameUpdated, this); em.Canvas.getFrames().forEach(frame => { const { view } = frame; const win = view?.getWindow(); win && trigger(win, view?.getBody()!); }); }, /** * Hover command * @param {Object} e * @private */ onHover(ev: Event) { ev.stopPropagation(); const { em } = this; const el = ev.target as HTMLElement; const view = getComponentView(el); const frameView = view?.frameView; let model = view?.model; // Get first valid model if (!model) { let parentEl = el.parentNode; while (!model && parentEl && !isDoc(parentEl)) { model = getComponentModel(parentEl); parentEl = parentEl.parentNode; } } this.currentDoc = el.ownerDocument; em.setHovered(model, { useValid: true }); frameView && em.setCurrentFrame(frameView); }, onFrameUpdated() { this.updateLocalPos(); this.updateGlobalPos(); }, onHovered(em: any, component: Component) { let result = {}; if (component) { component.views?.forEach(view => { const el = view.el; const pos = this.getElementPos(el); result = { el, pos, component, view: getViewEl(el) }; if (el.ownerDocument === this.currentDoc) { this.elHovered = result; } this.updateToolsLocal(result); }); } else { this.currentDoc = null; this.elHovered = 0; this.updateToolsLocal(); this.canvas.getFrames().forEach(frame => { const { view } = frame; const el = view && view.getToolsEl(); el && this.toggleToolsEl(0, 0, { el }); }); } }, /** * Say what to do after the component was selected * @param {Object} e * @param {Object} el * @private * */ onSelect() { const { em } = this; const component = em.getSelected(); const currentFrame = em.getCurrentFrame(); const view = component && component.getView(currentFrame?.model); let el = view && view.el; let result = {}; if (el && isVisible(el)) { const pos = this.getElementPos(el); result = { el, pos, component, view: getViewEl(el) }; } this.elSelected = result; this.updateToolsGlobal(); // This will hide some elements from the select component this.updateLocalPos(result); this.initResize(component); // @ts-ignore this.initRotate(component); }, updateGlobalPos() { const sel = this.getElSelected(); if (!sel.el) return; sel.pos = this.getElementPos(sel.el); this.updateToolsGlobal(); }, updateLocalPos(data: any) { const sel = this.getElHovered(); if (!sel.el) return; sel.pos = this.getElementPos(sel.el); this.updateToolsLocal(data); }, getElHovered() { return this.elHovered || {}; }, getElSelected() { return this.elSelected || {}; }, onOut() { this.em.setHovered(); }, toggleToolsEl(on: boolean, view: any, opts: any = {}) { const el = opts.el || this.canvas.getToolsEl(view); el && (el.style.display = on ? '' : 'none'); return el || {}; }, /** * Show element offset viewer * @param {HTMLElement} el * @param {Object} pos */ showElementOffset(el: HTMLElement, pos: any, opts: any = {}) { if (!showOffsets) return; this.editor.runCommand('show-offset', { el, elPos: pos, view: opts.view, force: 1, top: 0, left: 0, }); }, /** * Hide element offset viewer * @param {HTMLElement} el * @param {Object} pos */ hideElementOffset(view: any) { this.editor.stopCommand('show-offset', { view, }); }, /** * Show fixed element offset viewer * @param {HTMLElement} el * @param {Object} pos */ showFixedElementOffset(el: HTMLElement, pos: any) { this.editor.runCommand('show-offset', { el, elPos: pos, state: 'Fixed', }); }, /** * Hide fixed element offset viewer * @param {HTMLElement} el * @param {Object} pos */ hideFixedElementOffset() { if (this.editor) this.editor.stopCommand('show-offset', { state: 'Fixed' }); }, /** * Hide Highlighter element */ hideHighlighter(view: any) { this.canvas.getHighlighter(view).style.opacity = 0; }, /** * On element click * @param {Event} e * @private */ onClick(ev: Event) { ev.stopPropagation(); ev.preventDefault(); const { em } = this; if (em.get('_cmpDrag')) return em.set('_cmpDrag'); const el = ev.target as HTMLElement; let model = getComponentModel(el); if (!model) { let parentEl = el.parentNode; while (!model && parentEl && !isDoc(parentEl)) { model = getComponentModel(parentEl); parentEl = parentEl.parentNode; } } if (model) { // Avoid selection of inner text components during editing if (em.isEditing() && !model.get('textable') && model.isChildOf('text')) { return; } this.select(model, ev); } }, /** * Select component * @param {Component} model * @param {Event} event */ select(model: Component, event = {}) { if (!model) return; this.editor.select(model, { event, useValid: true }); this.initResize(model); this.initRotate(model); }, /** * Update badge for the component * @param {Object} Component * @param {Object} pos Position object * @private * */ updateBadge(el: HTMLElement, pos: any, opts: any = {}) { const { canvas } = this; const model = getComponentModel(el); const badge = this.getBadge(opts); const bStyle = badge.style; if (!model || !model.get('badgable')) { bStyle.display = 'none'; return; } if (!opts.posOnly) { const config = this.canvas.getConfig(); const icon = model.getIcon(); const ppfx = config.pStylePrefix || ''; const clsBadge = `${ppfx}badge`; const customeLabel = config.customBadgeLabel; const badgeLabel = `${icon ? `<div class="${clsBadge}__icon">${icon}</div>` : ''} <div class="${clsBadge}__name">${model.getName()}</div>`; badge.innerHTML = customeLabel ? customeLabel(model) : badgeLabel; } const un = 'px'; bStyle.display = 'block'; const targetToElem = canvas.getTargetToElementFixed(el, badge, { pos: pos, }); const top = targetToElem.top; //opts.topOff - badgeH < 0 ? -opts.topOff : posTop; const left = opts.leftOff < 0 ? -opts.leftOff : 0; bStyle.top = top + un; bStyle.left = left + un; }, /** * Update highlighter element * @param {HTMLElement} el * @param {Object} pos Position object * @private */ showHighlighter(view: any) { this.canvas.getHighlighter(view).style.opacity = ''; }, /** * Init resizer on the element if possible * @param {HTMLElement|Component} elem * @private */ 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 spotTypeResize = CanvasSpotBuiltInTypes.Resize; const hasCustomResize = canvas.hasCustomSpot(spotTypeResize); canvas.removeSpots({ type: spotTypeResize }); if (model && resizable) { canvas.addSpot({ type: spotTypeResize, component: model }); if (hasCustomResize) return; let modelToStyle: any; const { config } = em; const pfx = config.stylePrefix || ''; const resizeClass = `${pfx}resizing`; 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 el = isElement(elem) ? elem : model.getEl(); const options: ResizerOptions = { // Here the resizer is updated with the current element height and width onStart(e: Event, opts: any = {}) { const { el, config, resizer } = opts; const { keyHeight, keyWidth, currentUnit, keepAutoHeight, keepAutoWidth } = config; toggleBodyClass('add', e, opts); modelToStyle = em.Styles.getModelToStyle(model); canvas.toggleFramesEvents(false); 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); showOffsets = false; if (currentUnit) { config.unitHeight = getUnitFromValue(currentHeight); config.unitWidth = getUnitFromValue(currentWidth); } }, // Update all positioned elements (eg. component toolbar) onMove() { editor.trigger('component:resize'); }, onEnd(e: Event, opts: any) { toggleBodyClass('remove', e, opts); editor.trigger('component:resize'); canvas.toggleFramesEvents(true); showOffsets = true; }, updateTarget(el: any, rect: any, options: any = {}) { 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; 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() }); }, ...(isObject(resizable) ? resizable : {}), }; this.resizer = editor.runCommand('resize', { el, options, force: 1 }); } else { if (hasCustomResize) return; editor.stopCommand('resize'); this.resizer = null; } }, /** * Init rotator on the element if possible * @param {HTMLElement|Component} elem * @private */ initRotate(elem: HTMLElement) { const { em, canvas } = this; const editor = em?.Editor; const config = em?.config; const pfx = config.stylePrefix || ''; const rotateClass = `${pfx}rotating`; const model = !isElement(elem) && isTaggableNode(elem) ? elem : em.getSelected(); const rotatable = model && model.get('rotatable'); let options = {}; let modelToStyle: any; var 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} ${rotateClass}` : cls.replace(rotateClass, '')).trim(); }); }; if (editor && rotatable) { const el = isElement(elem) ? elem : model.getEl(); options = { // Here the rotator is updated with the current element height and width onStart(e: Event, opts: any = {}) { const { el, rotator } = opts; toggleBodyClass('add', e, opts); modelToStyle = em.Styles.getModelToStyle(model); canvas.toggleFramesEvents(false); const computedStyle = getComputedStyle(el); const modelStyle = modelToStyle.getStyle(); let currentWidth = modelStyle['width']; if (isNaN(parseFloat(currentWidth))) { currentWidth = computedStyle['width']; } let currentHeight = modelStyle['height']; if (isNaN(parseFloat(currentHeight))) { currentHeight = computedStyle['height']; } let currentRotation = computedStyle.getPropertyValue('rotate')?.replace('deg', '') ?? '0'; rotator.startDim.r = parseFloat(currentRotation); showOffsets = false; }, // Update all positioned elements (eg. component toolbar) onMove() { editor.trigger('component:rotate'); }, onEnd(e: Event, opts: any) { toggleBodyClass('remove', e, opts); editor.trigger('component:rotate'); canvas.toggleFramesEvents(true); showOffsets = true; }, updateTarget(el: any, rect: any, options: any = {}) { if (!modelToStyle) { return; } const { store, config } = options; const { keyHeight, keyWidth } = config; const style: any = {}; if (em.getDragMode(model)) { style.rotate = `${rect.r}deg`; } modelToStyle.addStyle( { ...style, // value for the partial update __p: !store ? 1 : '', }, { avoidStore: !store } ); const updateEvent = 'update:component:style'; const eventToListen = `${updateEvent}:${keyHeight} ${updateEvent}:${keyWidth}`; em && em.trigger(eventToListen, null, null, { noEmit: 1 }); }, }; if (typeof rotatable == 'object') { options = { ...options, ...rotatable, parent: options }; } this.rotator = editor.runCommand('rotate', { el, options, force: 1 }); } else { editor.stopCommand('rotate'); this.rotator = null; } }, /** * Update toolbar if the component has one * @param {Object} mod */ updateToolbar(mod: Component) { const { canvas } = this; const { em } = this.config; const model = mod === em ? em.getSelected() : mod; const toolbarEl = canvas.getToolbarEl()!; const toolbarStyle = toolbarEl.style; const toolbar = model.get('toolbar'); const showToolbar = em.config.showToolbar; const noCustomSpotSelect = !canvas.hasCustomSpot(CanvasSpotBuiltInTypes.Select); if (model && showToolbar && toolbar && toolbar.length && noCustomSpotSelect) { toolbarStyle.display = ''; if (!this.toolbar) { toolbarEl.innerHTML = ''; this.toolbar = new Toolbar(toolbar); // @ts-ignore const toolbarView = new ToolbarView({ collection: this.toolbar, em }); toolbarEl.appendChild(toolbarView.render().el); } this.toolbar.reset(toolbar); toolbarStyle.top = '-100px'; toolbarStyle.left = '0'; } else { toolbarStyle.display = 'none'; } }, /** * Update toolbar positions * @param {HTMLElement} el * @param {Object} pos */ updateToolbarPos(pos: any) { const unit = 'px'; const { style } = this.canvas.getToolbarEl()!; style.top = `${pos.top}${unit}`; style.left = `${pos.left}${unit}`; style.opacity = ''; }, /** * Return canvas dimensions and positions * @return {Object} */ getCanvasPosition() { return this.canvas.getCanvasView().getPosition(); }, /** * Returns badge element * @return {HTMLElement} * @private */ getBadge(opts: any = {}) { return this.canvas.getBadgeEl(opts.view); }, /** * On frame scroll callback * @private */ onFrameScroll() { this.updateTools(); this.canvas.refreshSpots(); }, onFrameResize() { this.canvas.refreshSpots(); }, updateTools() { this.updateLocalPos(); this.updateGlobalPos(); }, isCompSelected(comp: Component) { return comp && comp.get('status') === 'selected'; }, /** * Update tools visible on hover * @param {HTMLElement} el * @param {Object} pos */ updateToolsLocal(data: any) { const config = this.em.getConfig(); const { el, pos, view, component } = data || this.getElHovered(); if (!el) { this.lastHovered = 0; return; } const isHoverEn = component.get('hoverable'); const isNewEl = this.lastHovered !== el; const badgeOpts = isNewEl ? {} : { posOnly: 1 }; const customHoverSpot = this.canvas.hasCustomSpot(CanvasSpotBuiltInTypes.Hover); if (isNewEl && isHoverEn) { this.lastHovered = el; customHoverSpot ? this.hideHighlighter(view) : this.showHighlighter(view); this.showElementOffset(el, pos, { view }); } if (this.isCompSelected(component)) { this.hideHighlighter(view); !config.showOffsetsSelected && this.hideElementOffset(view); } const unit = 'px'; const toolsEl = this.toggleToolsEl(1, view); const { style } = toolsEl; const frameOff = this.canvas.canvasRectOffset(el, pos); const topOff = frameOff.top; const leftOff = frameOff.left; !customHoverSpot && this.updateBadge(el, pos, { ...badgeOpts, view, topOff, leftOff, }); style.top = topOff + unit; style.left = leftOff + unit; style.width = pos.width + unit; style.height = pos.height + unit; style.rotate = window.getComputedStyle(el).getPropertyValue('rotate'); style.transform = `rotate(${this.canvas.getRotationAngle()}deg)`; this._trgToolUp('local', { component, el: toolsEl, top: topOff, left: leftOff, width: pos.width, height: pos.height, }); }, _upToolbar: debounce(function () { // @ts-ignore this.updateToolsGlobal({ force: 1 }); }, 0), _trgToolUp(type: string, opts = {}) { this.em.trigger('canvas:tools:update', { type, ...opts, }); }, updateToolsGlobal(opts: any = {}) { const { el, pos, component } = this.getElSelected(); if (!el) { this.toggleToolsEl(); // Hides toolbar this.lastSelected = 0; return; } const { canvas } = this; const isNewEl = this.lastSelected !== el; if (isNewEl || opts.force) { this.lastSelected = el; this.updateToolbar(component); } const unit = 'px'; const toolsEl = this.toggleToolsEl(1); const { style } = toolsEl; const targetToElem = canvas.getTargetToElementFixed(el, canvas.getToolbarEl()!, { pos }); const topOff = targetToElem.canvasOffsetTop; const leftOff = targetToElem.canvasOffsetLeft; style.top = topOff + unit; style.left = leftOff + unit; style.width = pos.width + unit; style.height = pos.height + unit; style.rotate = window.getComputedStyle(el).getPropertyValue('rotate'); style.transform = `rotate(${this.canvas.getRotationAngle()}deg)`; this.updateToolbarPos({ top: targetToElem.top, left: targetToElem.left }); this._trgToolUp('global', { component, el: toolsEl, top: topOff, left: leftOff, width: pos.width, height: pos.height, }); }, /** * Update attached elements, eg. component toolbar */ updateAttached: debounce(function () { // @ts-ignore this.updateGlobalPos(); }, 0), onContainerChange: debounce(function () { // @ts-ignore this.em.refreshCanvas(); }, 150), /** * Returns element's data info * @param {HTMLElement} el * @return {Object} * @private */ getElementPos(el: HTMLElement) { return this.canvas.getCanvasView().getElementPos(el, { noScroll: true, nativeBoundingRect: false }); }, /** * Hide badge * @private * */ hideBadge() { this.getBadge().style.display = 'none'; }, /** * Clean previous model from different states * @param {Component} model * @private */ cleanPrevious(model: Component) { model && model.set({ status: '', state: '', }); }, /** * Returns content window * @private */ getContentWindow() { return this.canvas.getWindow(); }, run(editor) { if (!hasWin()) return; // @ts-ignore this.editor = editor && editor.get('Editor'); this.enable(); }, stop(ed, sender, opts = {}) { if (!hasWin()) return; const { em, editor } = this; this.onHovered(); // force to hide toolbar this.stopSelectComponent(); !opts.preserveSelected && em.setSelected(); this.toggleToolsEl(); editor && editor.stopCommand('resize'); editor && editor.stopCommand('rotate'); }, } as CommandObject<any, { [k: string]: any }>;