UNPKG

@print-one/grapesjs

Version:

Free and Open Source Web Builder Framework

878 lines (779 loc) 24.1 kB
/** * You can customize the initial state of the module from the editor initialization, by passing the following [Configuration Object](https://github.com/GrapesJS/grapesjs/blob/master/src/canvas/config/config.ts) * ```js * const editor = grapesjs.init({ * canvas: { * // options * } * }) * ``` * * Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance. * * ```js * // Listen to events * editor.on('canvas:drop', () => { ... }); * * // Use the API * const canvas = editor.Canvas; * canvas.setCoords(...); * ``` * * {REPLACE_EVENTS} * * [Component]: component.html * [Frame]: frame.html * [CanvasSpot]: canvas_spot.html * * @module Canvas */ import { isArray, isUndefined } from 'underscore'; import { Module } from '../abstract'; import { AddOptions, Coordinates, Position } from '../common'; import Component from '../dom_components/model/Component'; import ComponentView from '../dom_components/view/ComponentView'; import EditorModel from '../editor/model/Editor'; import { getElement, getViewEl } from '../utils/mixins'; import defaults, { CanvasConfig } from './config/config'; import Canvas from './model/Canvas'; import CanvasSpot, { CanvasSpotBuiltInTypes, CanvasSpotProps } from './model/CanvasSpot'; import CanvasSpots from './model/CanvasSpots'; import Frame from './model/Frame'; import { CanvasEvents, ToWorldOption } from './types'; import CanvasView, { FitViewportOptions } from './view/CanvasView'; import FrameView from './view/FrameView'; import { rotateCoordinate } from '../utils/Rotator'; export type CanvasEvent = `${CanvasEvents}`; export default class CanvasModule extends Module<CanvasConfig> { /** * Get configuration object * @name getConfig * @function * @return {Object} */ /** * Used inside RTE * @private */ getCanvasView(): CanvasView { return this.canvasView as any; } canvas: Canvas; model: Canvas; spots: CanvasSpots; events = CanvasEvents; framesById: Record<string, Frame | undefined> = {}; private canvasView?: CanvasView; /** * Initialize module. Automatically called with a new instance of the editor * @param {Object} config Configurations * @private */ constructor(em: EditorModel) { super(em, 'Canvas', defaults); this.canvas = new Canvas(this); this.spots = new CanvasSpots(this); this.model = this.canvas; this.startAutoscroll = this.startAutoscroll.bind(this); this.stopAutoscroll = this.stopAutoscroll.bind(this); return this; } postLoad() { this.model.init(); } getModel() { return this.canvas; } /** * Get the canvas element * @returns {HTMLElement} */ getElement() { return this.getCanvasView().el; } getFrame(index?: number) { return this.getFrames()[index || 0]; } /** * Get the main frame element of the canvas * @returns {HTMLIFrameElement} */ getFrameEl() { const { frame } = this.canvasView || {}; return frame?.el as HTMLIFrameElement; } getFramesEl() { return this.canvasView?.framesArea as HTMLElement; } /** * Get the main frame window instance * @returns {Window} */ getWindow() { const { frame } = this.canvasView || {}; return frame?.getWindow() as Window; } /** * Get the main frame document element * @returns {HTMLDocument} */ getDocument() { const frame = this.getFrameEl(); return frame?.contentDocument as Document; } /** * Get the main frame body element * @return {HTMLBodyElement} */ getBody() { const doc = this.getDocument(); return doc?.body as HTMLBodyElement; } _getLocalEl(globalEl: any, compView: ComponentView, method: keyof FrameView) { let result = globalEl; const frameView = compView?.frameView; result = frameView ? (frameView as any)[method]() : result; return result; } /** * Returns element containing all global canvas tools * @returns {HTMLElement} * @private */ getGlobalToolsEl() { return this.canvasView?.toolsGlobEl; } /** * Returns element containing all canvas tools * @returns {HTMLElement} * @private */ getToolsEl(compView?: any) { return this._getLocalEl(this.getCanvasView().toolsEl, compView, 'getToolsEl'); } /** * Returns highlighter element * @returns {HTMLElement} * @private */ getHighlighter(compView?: any) { return this._getLocalEl(this.getCanvasView().hlEl, compView, 'getHighlighter'); } /** * Returns badge element * @returns {HTMLElement} * @private */ getBadgeEl(compView: any) { return this._getLocalEl(this.getCanvasView().badgeEl, compView, 'getBadgeEl'); } /** * Returns placer element * @returns {HTMLElement} * @private */ getPlacerEl() { return this.getCanvasView().placerEl; } /** * Returns ghost element * @returns {HTMLElement} * @private */ getGhostEl() { return this.getCanvasView().ghostEl; } /** * Returns toolbar element * @returns {HTMLElement} * @private */ getToolbarEl() { return this.getCanvasView().toolbarEl; } /** * Returns resizer element * @returns {HTMLElement} * @private */ getResizerEl() { return this.getCanvasView().resizerEl; } /** * Returns offset viewer element * @returns {HTMLElement} * @private */ getOffsetViewerEl(compView: any) { return this._getLocalEl(this.getCanvasView().offsetEl, compView, 'getOffsetViewerEl'); } /** * Returns fixed offset viewer element * @returns {HTMLElement} * @private */ getFixedOffsetViewerEl() { return this.getCanvasView().fixedOffsetEl; } getSpotsEl() { return this.canvasView?.spotsEl; } render(): HTMLElement { this.canvasView?.remove(); this.canvasView = new CanvasView(this.canvas); return this.canvasView.render().el; } /** * Get frame position * @returns {Object} * @private */ getOffset() { var frameOff = this.offset(this.getFrameEl()); var canvasOff = this.offset(this.getElement()); return { top: frameOff.top - canvasOff.top, left: frameOff.left - canvasOff.left, }; } /** * Get the offset of the passed component element * @param {HTMLElement} el * @returns {Object} * @private */ offset(el: HTMLElement) { return this.getCanvasView().offset(el); } /** * Set custom badge naming strategy * @param {Function} f * @example * canvas.setCustomBadgeLabel(function(component){ * return component.getName(); * }); */ setCustomBadgeLabel(f: Function) { //@ts-ignore this.config.customBadgeLabel = f; } /** * Get element position relative to the canvas * @param {HTMLElement} el * @returns {Object} * @private */ getElementPos(el: HTMLElement, opts?: any) { return this.getCanvasView().getElementPos(el, opts); } /** * Returns element's offsets like margins and paddings * @param {HTMLElement} el * @returns {Object} * @private */ getElementOffsets(el: HTMLElement) { return this.getCanvasView().getElementOffsets(el); } /** * Get canvas rectangular data * @returns {Object} */ getRect() { const { top = 0, left = 0 } = this.getCanvasView().getPosition() ?? {}; return { ...this.getCanvasView().getCanvasOffset(), topScroll: top, leftScroll: left, }; } /** * This method comes handy when you need to attach something like toolbars * to elements inside the canvas, dealing with all relative position, * offsets, etc. and returning as result the object with positions which are * viewable by the user (when the canvas is scrolled the top edge of the element * is not viewable by the user anymore so the new top edge is the one of the canvas) * * The target should be visible before being passed here as invisible elements * return empty string as width * @param {HTMLElement} target The target in this case could be the toolbar * @param {HTMLElement} element The element on which I'd attach the toolbar * @param {Object} options Custom options * @param {Boolean} options.toRight Set to true if you want the toolbar attached to the right * @return {Object} * @private */ getTargetToElementDim(target: HTMLElement, element: HTMLElement, options: any = {}) { var opts = options || {}; var canvasPos = this.getCanvasView().getPosition(); if (!canvasPos) return; var pos = opts.elPos || this.getCanvasView().getElementPos(element); var toRight = options.toRight || 0; var targetHeight = opts.targetHeight || target.offsetHeight; var targetWidth = opts.targetWidth || target.offsetWidth; var eventToTrigger = opts.event || null; var elTop = pos.top - targetHeight; var elLeft = pos.left; elLeft += toRight ? pos.width : 0; elLeft = toRight ? elLeft - targetWidth : elLeft; var leftPos = elLeft < canvasPos.left ? canvasPos.left : elLeft; var topPos = elTop < canvasPos.top ? canvasPos.top : elTop; topPos = topPos > pos.top + pos.height ? pos.top + pos.height : topPos; var result = { top: topPos, left: leftPos, elementTop: pos.top, elementLeft: pos.left, elementWidth: pos.width, elementHeight: pos.height, targetWidth: target.offsetWidth, targetHeight: target.offsetHeight, canvasTop: canvasPos.top, canvasLeft: canvasPos.left, canvasWidth: canvasPos.width, canvasHeight: canvasPos.height, }; // In this way I can catch data and also change the position strategy if (eventToTrigger && this.em) { this.em.trigger(eventToTrigger, result); } return result; } canvasRectOffset(el: HTMLElement, pos: { top: number; left: number }, opts: any = {}) { const getFrameElFromDoc = (doc: Document) => { const { defaultView } = doc; return defaultView?.frameElement as HTMLElement; }; const rectOff = (el: HTMLElement, top = 1, pos: { top: number; left: number }) => { const zoom = this.em.getZoomDecimal(); const side = top ? 'top' : 'left'; const doc = el.ownerDocument; const { offsetTop = 0, offsetLeft = 0 } = opts.offset ? getFrameElFromDoc(doc) : {}; const { scrollTop = 0, scrollLeft = 0 } = doc.body || {}; const scroll = top ? scrollTop : scrollLeft; const offset = top ? offsetTop : offsetLeft; return pos[side] - (scroll - offset) * zoom; }; return { top: rectOff(el, 1, pos), left: rectOff(el, 0, pos), }; } /** * * @param {HTMLElement} el The component element in the canvas * @param {HTMLElement} targetEl The target element to position (eg. toolbar) * @param {Object} opts * @private */ getTargetToElementFixed(el: HTMLElement, targetEl: HTMLElement, opts: any = {}) { const elRect = opts.pos || this.getElementPos(el, { noScroll: true }); const canvasOffset = opts.canvasOff || this.canvasRectOffset(el, elRect); const targetHeight = targetEl.offsetHeight || 0; const targetWidth = targetEl.offsetWidth || 0; const elRight = elRect.left + elRect.width; const canvasView = this.getCanvasView(); const canvasRect = canvasView.getPosition(); const frameOffset = canvasView.getFrameOffset(el); const { event } = opts; let top = -targetHeight; let left = !isUndefined(opts.left) ? opts.left : elRect.width - targetWidth; left = elRect.left < -left ? -elRect.left : left; left = elRight > canvasRect.width ? left - (elRight - canvasRect.width) : left; // Check when the target top edge reaches the top of the viewable canvas if (canvasOffset.top < targetHeight) { const fullHeight = elRect.height + targetHeight; const elIsShort = fullHeight < frameOffset.height; // Scroll with the window if the top edge is reached and the // element is bigger than the canvas if (elIsShort) { top = top + fullHeight; } else { top = -canvasOffset.top < elRect.height ? -canvasOffset.top : elRect.height; } } const result = { top, left, canvasOffsetTop: canvasOffset.top, canvasOffsetLeft: canvasOffset.left, elRect, canvasOffset, canvasRect, targetWidth, targetHeight, }; // In this way I can catch data and also change the position strategy event && this.em.trigger(event, result); return result; } /** * Instead of simply returning e.clientX and e.clientY this function * calculates also the offset based on the canvas. This is helpful when you * need to get X and Y position while moving between the editor area and * canvas area, which is in the iframe * @param {Event} e * @return {Object} * @private */ getMouseRelativePos(e: any, opts: any = {}) { var addTop = 0; var addLeft = 0; var subWinOffset = opts.subWinOffset; var doc = e.target.ownerDocument; var win = doc.defaultView || doc.parentWindow; var frame = win.frameElement; var yOffset = subWinOffset ? win.pageYOffset : 0; var xOffset = subWinOffset ? win.pageXOffset : 0; if (frame) { var frameRect = frame.getBoundingClientRect(); addTop = frameRect.top || 0; addLeft = frameRect.left || 0; } const zoom = this.getZoomDecimal(); const zoomOffset = 1 / zoom; let y = (e.clientY + addTop - yOffset) * zoomOffset; let x = (e.clientX + addLeft - xOffset) * zoomOffset; const rotated = rotateCoordinate( { l: x, t: y, }, { l: 0, t: 0, w: frame?.model?.width ?? 0, h: frame?.model?.height ?? 0, r: -this.getRotationAngle(), } ); return { y: rotated.t, x: rotated.l, }; } /** * X and Y mouse position relative to the canvas * @param {Event} ev * @return {Object} * @private */ getMouseRelativeCanvas(ev: MouseEvent, opts: any): Position { const zoom = this.getZoomDecimal(); const zoomOffset = 1 / zoom; const { top = 0, left = 0 } = this.getCanvasView().getPosition(opts) ?? {}; return { y: (ev.clientY - top) * zoomOffset, x: (ev.clientX - left) * zoomOffset, }; } /** * Check if the canvas is focused * @returns {Boolean} */ hasFocus() { return this.getDocument().hasFocus(); } /** * Detects if some input is focused (input elements, text components, etc.) * @return {Boolean} * @private */ isInputFocused() { const doc = this.getDocument(); const frame = this.getFrameEl(); const toIgnore = ['body', ...this.config.notTextable!]; const docActive = frame && document.activeElement === frame; const focused = docActive ? doc && doc.activeElement : document.activeElement; return focused && !toIgnore.some(item => focused.matches(item)); } /** * Scroll canvas to the element if it's not visible. The scrolling is * executed via `scrollIntoView` API and options of this method are * passed to it. For instance, you can scroll smoothly by using * `{ behavior: 'smooth' }`. * @param {HTMLElement|[Component]} el * @param {Object} [opts={}] Options, same as options for `scrollIntoView` * @param {Boolean} [opts.force=false] Force the scroll, even if the element is already visible * @example * const selected = editor.getSelected(); * // Scroll smoothly (this behavior can be polyfilled) * canvas.scrollTo(selected, { behavior: 'smooth' }); * // Force the scroll, even if the element is alredy visible * canvas.scrollTo(selected, { force: true }); */ scrollTo(el: any, opts = {}) { const elem = getElement(el); const view = elem && getViewEl(elem); view && view.scrollIntoView(opts); } /** * Start autoscroll * @private */ startAutoscroll(frame?: Frame) { const fr = (frame && frame.view) || this.em.getCurrentFrame(); fr && fr.startAutoscroll(); } /** * Stop autoscroll * @private */ stopAutoscroll(frame?: Frame) { const fr = (frame && frame.view) || this.em.getCurrentFrame(); fr && fr.stopAutoscroll(); } /** * Set canvas zoom value * @param {Number} value The zoom value, from 0 to 100 * @returns {this} * @example * canvas.setZoom(50); // set zoom to 50% */ setZoom(value: number | string) { this.canvas.set('zoom', typeof value === 'string' ? parseFloat(value) : value); return this; } /** * Get canvas zoom value * @returns {Number} * @example * canvas.setZoom(50); // set zoom to 50% * const zoom = canvas.getZoom(); // 50 */ getZoom() { return parseFloat(this.canvas.get('zoom')); } getRotationAngle() { return this.canvas.get('rotationAngle'); } setRotationAngle(value: number) { this.canvas.set('rotationAngle', value); } /** * Set canvas position coordinates * @param {Number} x Horizontal position * @param {Number} y Vertical position * @returns {this} * @example * canvas.setCoords(100, 100); */ setCoords(x?: string | number, y?: string | number, opts: ToWorldOption = {}) { const hasX = x || x === 0; const hasY = y || y === 0; const coords = { x: this.canvas.get('x'), y: this.canvas.get('y'), }; if (hasX) coords.x = parseFloat(`${x}`); if (hasY) coords.y = parseFloat(`${y}`); if (opts.toWorld) { const delta = this.canvasView?.getViewportDelta(); if (delta) { if (hasX) coords.x = coords.x - delta.x; if (hasY) coords.y = coords.y - delta.y; } } this.canvas.set(coords); return this; } /** * Get canvas position coordinates * @returns {Object} Object containing coordinates * @example * canvas.setCoords(100, 100); * const coords = canvas.getCoords(); * // { x: 100, y: 100 } */ getCoords(): Coordinates { const { x, y } = this.canvas.attributes; return { x, y }; } /** * Get canvas pointer position coordinates. * @returns {Object} Object containing pointer coordinates * @private * @example * const worldPointer = canvas.getPointer(); * const screenPointer = canvas.getPointer(true); */ getPointer(screen?: boolean): Coordinates { const { pointer, pointerScreen } = this.canvas.attributes; return screen ? pointerScreen : pointer; } getZoomDecimal() { return this.getZoom() / 100; } getZoomMultiplier() { const zoom = this.getZoomDecimal(); return zoom ? 1 / zoom : 1; } fitViewport(opts?: FitViewportOptions) { this.canvasView?.fitViewport(opts); } toggleFramesEvents(on: boolean) { const { style } = this.getFramesEl(); style.pointerEvents = on ? '' : 'none'; } getFrames() { return this.canvas.frames.map(item => item); } /** * Add new frame to the canvas * @param {Object} props Frame properties * @returns {[Frame]} * @private * @example * canvas.addFrame({ * name: 'Mobile home page', * x: 100, // Position in canvas * y: 100, * width: 500, // Frame dimensions * height: 600, * // device: 'DEVICE-ID', * components: [ * '<h1 class="testh">Title frame</h1>', * '<p class="testp">Paragraph frame</p>', * ], * styles: ` * .testh { color: red; } * .testp { color: blue; } * `, * }); */ addFrame(props = {}, opts = {}) { return this.canvas.frames.add(new Frame(this, { ...props }), opts); } /** * Get the last created Component from a drag & drop to the canvas. * @returns {[Component]|undefined} */ getLastDragResult(): Component | undefined { return this.em.get('dragResult'); } /** * Add or update canvas spot. * @param {Object} props Canvas spot properties. * @param opts * @returns {[CanvasSpot]} * @example * // Add new canvas spot * const spot = canvas.addSpot({ * type: 'select', // 'select' is one of the built-in spots * component: editor.getSelected(), * }); * * // Add custom canvas spot * const spot = canvas.addSpot({ * type: 'my-custom-spot', * component: editor.getSelected(), * }); * // Update the same spot by reusing its ID * canvas.addSpot({ * id: spot.id, * component: anotherComponent, * }); */ addSpot<T extends CanvasSpotProps>(props: Omit<T, 'id'> & { id?: string }, opts: AddOptions = {}) { const spotProps = props as T; const spots = this.getSpots<T>(spotProps); if (spots.length) { const spot = spots[0]; spot.set(spotProps); return spot; } const cmpView = spotProps.componentView || spotProps.component?.view; const spot = new CanvasSpot<T>(this, { ...spotProps, id: spotProps.id || `cs_${spotProps.type}_${cmpView?.cid}`, type: spotProps.type || '', } as T); this.spots.add(spot, opts); return spot; } /** * Get canvas spots. * @param {Object} [spotProps] Canvas spot properties for filtering the result. With no properties, all available spots will be returned. * @returns {[CanvasSpot][]} * @example * canvas.addSpot({ type: 'select', component: cmp1 }); * canvas.addSpot({ type: 'select', component: cmp2 }); * canvas.addSpot({ type: 'target', component: cmp3 }); * * // Get all spots * const allSpots = canvas.getSpots(); * allSpots.length; // 3 * * // Get all 'select' spots * const allSelectSpots = canvas.getSpots({ type: 'select' }); * allSelectSpots.length; // 2 */ getSpots<T extends CanvasSpotProps>(spotProps: Partial<T> = {}) { return this.spots.where(spotProps.id ? { id: spotProps.id } : spotProps) as CanvasSpot<T>[]; } /** * Remove canvas spots. * @param {Object|[CanvasSpot][]} [spotProps] Canvas spot properties for filtering spots to remove or an array of spots to remove. With no properties, all available spots will be removed. * @returns {[CanvasSpot][]} * @example * canvas.addSpot({ type: 'select', component: cmp1 }); * canvas.addSpot({ type: 'select', component: cmp2 }); * canvas.addSpot({ type: 'target', component: cmp3 }); * * // Remove all 'select' spots * canvas.removeSpots({ type: 'select' }); * * // Remove spots by an array of canvas spots * const filteredSpots = canvas.getSpots().filter(spot => myCustomCondition); * canvas.removeSpots(filteredSpots); * * // Remove all spots * canvas.removeSpots(); */ removeSpots<T extends CanvasSpotProps>(spotProps: Partial<T> | CanvasSpot[] = {}) { const spots = isArray(spotProps) ? spotProps : this.getSpots(spotProps); const removed = this.spots.remove(spots); return removed as unknown as CanvasSpot<T>[]; } /** * Check if the built-in canvas spot has a declared custom rendering. * @param {String} type Built-in canvas spot type * @returns {Boolean} * @example * grapesjs.init({ * // ... * canvas: { * // avoid rendering the built-in 'target' canvas spot * customSpots: { target: true } * } * }); * // ... * canvas.hasCustomSpot('select'); // false * canvas.hasCustomSpot('target'); // true */ hasCustomSpot(type?: CanvasSpotBuiltInTypes) { const { customSpots } = this.config; if (customSpots === true || (customSpots && type && customSpots[type])) { return true; } return false; } /** * Transform a box rect from the world coordinate system to the screen one. * @param {Object} boxRect * @returns {Object} */ getWorldRectToScreen(boxRect: Parameters<CanvasView['getRectToScreen']>[0]) { return this.canvasView?.getRectToScreen(boxRect); } refreshSpots() { this.spots.refresh(); } destroy() { this.canvas.stopListening(); this.canvasView?.remove(); //[this.canvas, this.canvasView].forEach(i => (i = {})); //@ts-ignore ['model', 'droppable'].forEach(i => (this[i] = {})); } }