Free and Open source Web Builder Framework. Next generation tool for building templates without coding
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

762 lines
20 KiB

/**
* 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(...);
* ```
* ## Available Events
* * `canvas:dragenter` - When something is dragged inside the canvas, `DataTransfer` instance passed as an argument
* * `canvas:dragover` - When something is dragging on canvas, `DataTransfer` instance passed as an argument
* * `canvas:drop` - Something is dropped in canvas, `DataTransfer` instance and the dropped model are passed as arguments
* * `canvas:dragend` - When a drag operation is ended, `DataTransfer` instance passed as an argument
* * `canvas:dragdata` - On any dataTransfer parse, `DataTransfer` instance and the `result` are passed as arguments.
* By changing `result.content` you're able to customize what is dropped
*
* ## Methods
* * [getConfig](#getconfig)
* * [getElement](#getelement)
* * [getFrameEl](#getframeel)
* * [getWindow](#getwindow)
* * [getDocument](#getdocument)
* * [getBody](#getbody)
* * [setCustomBadgeLabel](#setcustombadgelabel)
* * [hasFocus](#hasfocus)
* * [scrollTo](#scrollto)
* * [setZoom](#setzoom)
* * [getZoom](#getzoom)
* * [getCoords](#getcoords)
* * [setCoords](#setcoords)
*
* [Component]: component.html
* [Frame]: frame.html
*
* @module Canvas
*/
import { isUndefined } from 'underscore';
import { Module } from '../abstract';
import { AddOptions, Coordinates } 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, { CanvasSpotProps } from './model/CanvasSpot';
import CanvasSpots from './model/CanvasSpots';
import Frame from './model/Frame';
import { CanvasEvents } from './types';
import CanvasView, { FitViewportOptions } from './view/CanvasView';
import FrameView from './view/FrameView';
export type CanvasEvent = `${CanvasEvents}`;
export interface ToWorldOption {
toWorld?: boolean;
}
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;
}
//name = 'Canvas';
canvas: Canvas;
model: Canvas;
spots: CanvasSpots;
events = CanvasEvents;
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;
}
return {
y: e.clientY + addTop - yOffset,
x: e.clientX + addLeft - xOffset,
};
}
/**
* X and Y mouse position relative to the canvas
* @param {Event} ev
* @return {Object}
* @private
*/
getMouseRelativeCanvas(ev: MouseEvent, opts: any) {
const zoom = this.getZoomDecimal();
const { top = 0, left = 0 } = this.getCanvasView().getPosition(opts) ?? {};
return {
y: ev.clientY * zoom + top,
x: ev.clientX * zoom + left,
};
}
/**
* 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'));
}
/**
* 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 };
}
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]}
* @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');
}
destroy() {
this.canvas.stopListening();
this.canvasView?.remove();
//[this.canvas, this.canvasView].forEach(i => (i = {}));
//@ts-ignore
['model', 'droppable'].forEach(i => (this[i] = {}));
}
setSpot<T extends CanvasSpotProps>(props: Omit<T, 'id'>, 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_${cmpView?.cid}`,
type: spotProps.type || '',
} as T);
this.spots.add(spot, opts);
return spot;
}
getSpots<T extends CanvasSpotProps>(spotProps: Partial<T> = {}) {
return this.spots.where(spotProps.id ? { id: spotProps.id } : spotProps) as CanvasSpot<T>[];
}
removeSpot<T extends CanvasSpotProps>(spotProps: Partial<T> = {}) {
const spots = this.getSpots(spotProps);
const removed = this.spots.remove(spots);
return removed as unknown as CanvasSpot<T>[];
}
refreshSpots() {
this.spots.refresh();
}
}