@print-one/grapesjs
Version:
Free and Open Source Web Builder Framework
716 lines (637 loc) • 22.1 kB
text/typescript
import { bindAll, isNumber } from 'underscore';
import { ModuleView } from '../../abstract';
import { BoxRect, Coordinates, CoordinatesTypes, ElementRect } from '../../common';
import Component from '../../dom_components/model/Component';
import ComponentView from '../../dom_components/view/ComponentView';
import {
createEl,
getDocumentScroll,
getElRect,
getKeyChar,
hasModifierKey,
isTextNode,
off,
on,
} from '../../utils/dom';
import { getComponentView, getElement, getUiClass } from '../../utils/mixins';
import Canvas from '../model/Canvas';
import Frame from '../model/Frame';
import { GetBoxRectOptions, ToWorldOption } from '../types';
import FrameView from './FrameView';
import FramesView from './FramesView';
import { rotateCoordinate } from '../../utils/Rotator';
export interface MarginPaddingOffsets {
marginTop?: number;
marginRight?: number;
marginBottom?: number;
marginLeft?: number;
paddingTop?: number;
paddingRight?: number;
paddingBottom?: number;
paddingLeft?: number;
}
export type ElementPosOpts = {
avoidFrameOffset?: boolean;
avoidFrameZoom?: boolean;
noScroll?: boolean;
nativeBoundingRect?: boolean;
avoidRotate?: boolean;
overrideRect?: ElementRect;
};
export interface FitViewportOptions {
frame?: Frame;
gap?: number | { x: number; y: number };
ignoreHeight?: boolean;
el?: HTMLElement;
}
export default class CanvasView extends ModuleView<Canvas> {
template() {
const { pfx } = this;
return `
<div class="${pfx}canvas__frames" data-frames>
<div class="${pfx}canvas__spots" data-spots></div>
</div>
<div id="${pfx}tools" class="${pfx}canvas__tools" data-tools></div>
<style data-canvas-style></style>
`;
}
/*get className(){
return this.pfx + 'canvas':
}*/
hlEl?: HTMLElement;
badgeEl?: HTMLElement;
placerEl?: HTMLElement;
ghostEl?: HTMLElement;
toolbarEl?: HTMLElement;
resizerEl?: HTMLElement;
offsetEl?: HTMLElement;
fixedOffsetEl?: HTMLElement;
toolsGlobEl?: HTMLElement;
toolsEl?: HTMLElement;
framesArea?: HTMLElement;
toolsWrapper?: HTMLElement;
spotsEl?: HTMLElement;
cvStyle?: HTMLElement;
clsUnscale: string;
ready = false;
frames!: FramesView;
frame?: FrameView;
private timerZoom?: number;
private frmOff?: { top: number; left: number; width: number; height: number };
private cvsOff?: { top: number; left: number; width: number; height: number };
constructor(model: Canvas) {
super({ model });
bindAll(this, 'clearOff', 'onKeyPress', 'onWheel', 'onPointer');
const { em, pfx, ppfx } = this;
const { events } = this.module;
this.className = `${pfx}canvas ${ppfx}no-touch-actions${!em.config.customUI ? ` ${pfx}canvas-bg` : ''}`;
this.clsUnscale = `${pfx}unscale`;
this._initFrames();
this.listenTo(em, 'change:canvasOffset', this.clearOff);
this.listenTo(em, 'component:selected', this.checkSelected);
this.listenTo(em, `${events.coords} ${events.zoom} ${events.rotate}`, this.updateFrames);
this.listenTo(model, 'change:frames', this._onFramesUpdate);
this.toggleListeners(true);
}
_onFramesUpdate() {
this._initFrames();
this._renderFrames();
}
_initFrames() {
const { frames, model, config, em } = this;
const collection = model.frames;
em.set('readyCanvas', 0);
collection.once('loaded:all', () => em.set('readyCanvas', 1));
frames?.remove();
this.frames = new FramesView(
{ collection },
{
...config,
canvasView: this,
}
);
}
checkSelected(component: Component, opts: { scroll?: ScrollIntoViewOptions } = {}) {
const { scroll } = opts;
const currFrame = this.em.getCurrentFrame();
scroll &&
component.views?.forEach(view => {
view.frameView === currFrame && view.scrollIntoView(scroll);
});
}
remove(...args: any) {
this.frames?.remove();
//@ts-ignore
this.frames = undefined;
ModuleView.prototype.remove.apply(this, args);
this.toggleListeners(false);
return this;
}
preventDefault(ev: Event) {
if (ev) {
ev.preventDefault();
(ev as any)._parentEvent?.preventDefault();
}
}
toggleListeners(enable: boolean) {
const { el, config } = this;
const fn = enable ? on : off;
fn(document, 'keypress', this.onKeyPress);
fn(window, 'scroll resize', this.clearOff);
fn(el, 'wheel', this.onWheel, { passive: !config.infiniteCanvas });
fn(el, 'pointermove', this.onPointer);
}
screenToWorld(x: number, y: number): Coordinates {
const { module } = this;
const coords = module.getCoords();
const zoom = module.getZoomMultiplier();
const vwDelta = this.getViewportDelta();
return {
x: (x - coords.x - vwDelta.x) * zoom,
y: (y - coords.y - vwDelta.y) * zoom,
};
}
onPointer(ev: WheelEvent) {
if (!this.config.infiniteCanvas) return;
const canvasRect = this.getCanvasOffset();
const docScroll = getDocumentScroll();
const screenCoords: Coordinates = {
x: ev.clientX - canvasRect.left + docScroll.x,
y: ev.clientY - canvasRect.top + docScroll.y,
};
if ((ev as any)._parentEvent) {
// with _parentEvent means was triggered from the iframe
const frameRect = (ev.target as HTMLElement).getBoundingClientRect();
const zoom = this.module.getZoomDecimal();
screenCoords.x = frameRect.left - canvasRect.left + docScroll.x + ev.clientX * zoom;
screenCoords.y = frameRect.top - canvasRect.top + docScroll.y + ev.clientY * zoom;
}
this.model.set({
pointerScreen: screenCoords,
pointer: this.screenToWorld(screenCoords.x, screenCoords.y),
});
}
onKeyPress(ev: KeyboardEvent) {
const { em } = this;
const key = getKeyChar(ev);
if (key === ' ' && em.getZoomDecimal() !== 1 && !em.Canvas.isInputFocused()) {
this.preventDefault(ev);
em.Editor.runCommand('core:canvas-move');
}
}
onWheel(ev: WheelEvent) {
const { module, config } = this;
if (config.infiniteCanvas) {
this.preventDefault(ev);
const { deltaX, deltaY } = ev;
const zoom = module.getZoomDecimal();
const isZooming = hasModifierKey(ev);
const coords = module.getCoords();
if (isZooming) {
const newZoom = zoom - deltaY * zoom * 0.01;
module.setZoom(newZoom * 100);
// Update coordinates based on pointer
const pointer = this.model.getPointerCoords(CoordinatesTypes.Screen);
const canvasRect = this.getCanvasOffset();
const pointerX = pointer.x - canvasRect.width / 2;
const pointerY = pointer.y - canvasRect.height / 2;
const zoomDelta = newZoom / zoom;
const x = pointerX - (pointerX - coords.x) * zoomDelta;
const y = pointerY - (pointerY - coords.y) * zoomDelta;
module.setCoords(x, y);
} else {
this.onPointer(ev);
module.setCoords(coords.x - deltaX, coords.y - deltaY);
}
}
}
updateFrames(ev: Event) {
const { em } = this;
const toolsWrpEl = this.toolsWrapper!;
const defOpts = { preserveSelected: 1 };
this.updateFramesArea();
this.clearOff();
toolsWrpEl.style.display = 'none';
em.trigger('canvas:update', ev);
this.timerZoom && clearTimeout(this.timerZoom);
this.timerZoom = setTimeout(() => {
em.stopDefault(defOpts);
em.runDefault(defOpts);
toolsWrpEl.style.display = '';
}, 300) as any;
}
updateFramesArea() {
const { framesArea, model, module, cvStyle, clsUnscale } = this;
const mpl = module.getZoomMultiplier();
if (framesArea) {
const { x, y } = model.attributes;
const zoomDc = module.getZoomDecimal();
const rotation = module.getRotationAngle();
framesArea.style.transform = `scale(${zoomDc}) translate(${x * mpl}px, ${y * mpl}px) rotate(${rotation}deg)`;
}
if (cvStyle) {
cvStyle.innerHTML = `
.${clsUnscale} { scale: ${mpl} }
`;
}
}
fitViewport(opts: FitViewportOptions = {}) {
const { em, module, model } = this;
const canvasRect = this.getCanvasOffset();
const { el } = opts;
const elFrame = el && getComponentView(el)?.frameView;
const frame = elFrame ? elFrame.model : opts.frame || em.getCurrentFrameModel() || model.frames.at(0);
const { x, y } = frame.attributes;
const boxRect: BoxRect = {
x: x ?? 0,
y: y ?? 0,
width: frame.width,
height: frame.height,
};
if (el) {
const elRect = this.getElBoxRect(el);
boxRect.x = boxRect.x + elRect.x;
boxRect.y = boxRect.y + elRect.y;
boxRect.width = elRect.width;
boxRect.height = elRect.height;
}
const noHeight = opts.ignoreHeight;
const gap = opts.gap ?? 0;
const gapIsNum = isNumber(gap);
const gapX = gapIsNum ? gap : gap.x;
const gapY = gapIsNum ? gap : gap.y;
const boxWidth = boxRect.width + gapX * 2;
const boxHeight = boxRect.height + gapY * 2;
const canvasWidth = canvasRect.width;
const canvasHeight = canvasRect.height;
const widthRatio = canvasWidth / boxWidth;
const heightRatio = canvasHeight / boxHeight;
const zoomRatio = noHeight ? widthRatio : Math.min(widthRatio, heightRatio);
const zoom = zoomRatio * 100;
module.setZoom(zoom);
// check for the frame witdh is necessary as we're centering the frame via CSS
const coordX = -boxRect.x + (frame.width >= canvasWidth ? canvasWidth / 2 - boxWidth / 2 : -gapX);
const coordY = -boxRect.y + canvasHeight / 2 - boxHeight / 2;
const coords = {
x: (coordX + gapX) * zoomRatio,
y: (coordY + gapY) * zoomRatio,
};
if (noHeight) {
const zoomMltp = module.getZoomMultiplier();
const canvasWorldHeight = canvasHeight * zoomMltp;
const canvasHeightDiff = canvasWorldHeight - canvasHeight;
const yDelta = canvasHeightDiff / 2;
coords.y = (-boxRect.y + gapY) * zoomRatio - yDelta / zoomMltp;
}
module.setCoords(coords.x, coords.y);
}
/**
* Checks if the element is visible in the canvas's viewport
* @param {HTMLElement} el
* @return {Boolean}
*/
isElInViewport(el: HTMLElement) {
const elem = getElement(el);
const rect = getElRect(elem);
const frameRect = this.getFrameOffset(elem);
const rTop = rect.top;
const rLeft = rect.left;
return rTop >= 0 && rLeft >= 0 && rTop <= frameRect.height && rLeft <= frameRect.width;
}
/**
* Get the offset of the element
* @param {HTMLElement} el
* @return { {top: number, left: number, width: number, height: number} }
*/
offset(el?: HTMLElement, opts: ElementPosOpts = {}) {
const { noScroll, nativeBoundingRect } = opts;
const rect = getElRect(el, nativeBoundingRect);
const scroll = noScroll ? { x: 0, y: 0 } : getDocumentScroll(el);
return {
top: rect.top + scroll.y,
left: rect.left + scroll.x,
width: rect.width,
height: rect.height,
};
}
getRectToScreen(boxRect: Partial<BoxRect>): BoxRect {
const zoom = this.module.getZoomDecimal();
const coords = this.module.getCoords();
const vwDelta = this.getViewportDelta();
const x = (boxRect.x ?? 0) * zoom + coords.x + vwDelta.x || 0;
const y = (boxRect.y ?? 0) * zoom + coords.y + vwDelta.y || 0;
return {
x,
y,
width: (boxRect.width ?? 0) * zoom,
height: (boxRect.height ?? 0) * zoom,
};
}
getElBoxRect(el: HTMLElement, opts: GetBoxRectOptions = {}): BoxRect {
const { module } = this;
const { width, height, left, top } = getElRect(el);
const frameView = getComponentView(el)?.frameView;
const frameRect = frameView?.getBoxRect();
const zoomMlt = module.getZoomMultiplier();
const frameX = frameRect?.x ?? 0;
const frameY = frameRect?.y ?? 0;
const canvasEl = this.el;
const docScroll = getDocumentScroll();
const xWithFrame = left + frameX + (canvasEl.scrollLeft + docScroll.x) * zoomMlt;
const yWithFrame = top + frameY + (canvasEl.scrollTop + docScroll.y) * zoomMlt;
const boxRect = {
x: xWithFrame,
y: yWithFrame,
width,
height,
};
if (opts.local) {
boxRect.x = left;
boxRect.y = top;
}
return opts.toScreen ? this.getRectToScreen(boxRect) : boxRect;
}
getViewportRect(opts: ToWorldOption = {}): BoxRect {
const { top, left, width, height } = this.getCanvasOffset();
const { module } = this;
if (opts.toWorld) {
const zoom = module.getZoomMultiplier();
const coords = module.getCoords();
const vwDelta = this.getViewportDelta();
const x = -coords.x - vwDelta.x || 0;
const y = -coords.y - vwDelta.y || 0;
return {
x: x * zoom,
y: y * zoom,
width: width * zoom,
height: height * zoom,
};
} else {
return {
x: left,
y: top,
width,
height,
};
}
}
getViewportDelta(opts: { withZoom?: number } = {}): Coordinates {
const zoom = this.module.getZoomMultiplier();
const { width, height } = this.getCanvasOffset();
const worldWidth = width * zoom;
const worldHeight = height * zoom;
const widthDelta = worldWidth - width;
const heightDelta = worldHeight - height;
return {
x: widthDelta / 2 / zoom,
y: heightDelta / 2 / zoom,
};
}
/**
* Cleare cached offsets
* @private
*/
clearOff() {
this.frmOff = undefined;
this.cvsOff = undefined;
}
/**
* Return frame offset
* @return { {top: number, left: number, width: number, height: number} }
* @public
*/
getFrameOffset(el?: HTMLElement) {
if (!this.frmOff || el) {
const frame = this.frame?.el;
const winEl = el?.ownerDocument.defaultView;
const frEl = winEl ? (winEl.frameElement as HTMLElement) : frame;
const zoom = this.module.getZoomDecimal();
//Native offset has been transformed by the zoom and rotation
const nativeOffset = this.offset(frEl || frame, { nativeBoundingRect: true });
//Original offset of the element has not been transformed
const originalOffset = this.offset(frEl || frame, { nativeBoundingRect: false });
//We want to get the offset without the rotation
const middle = {
x: nativeOffset.left + nativeOffset.width / 2,
y: nativeOffset.top + nativeOffset.height / 2,
};
let width = originalOffset.width * zoom;
let height = originalOffset.height * zoom;
this.frmOff = {
width: width,
height: height,
top: middle.y - height / 2,
left: middle.x - width / 2,
};
}
return this.frmOff;
}
/**
* Return canvas offset
* @return { {top: number, left: number, width: number, height: number} }
* @public
*/
getCanvasOffset() {
if (!this.cvsOff) this.cvsOff = this.offset(this.el);
return this.cvsOff;
}
/**
* Returns element's rect info
* @param {HTMLElement} el
* @param {object} opts
* @return { {top: number, left: number, width: number, height: number, zoom: number, rect: any} }
* @public
*/
getElementPos(el: HTMLElement, opts: ElementPosOpts = {}) {
const zoom = this.module.getZoomDecimal();
const frameOffset = this.getFrameOffset(el);
const canvasEl = this.el;
const canvasOffset = this.getCanvasOffset();
const elRect = opts.overrideRect ?? this.offset(el, opts);
const frameTop = opts.avoidFrameOffset ? 0 : frameOffset.top;
const frameLeft = opts.avoidFrameOffset ? 0 : frameOffset.left;
const rotated = rotateCoordinate(
{
l: elRect.left + elRect.width / 2,
t: elRect.top + elRect.height / 2,
},
{
l: 0,
t: 0,
w: this.frame?.model?.width ?? 0,
h: this.frame?.model?.height ?? 0,
r: opts.avoidRotate ? 0 : this.module.getRotationAngle(),
}
);
rotated.l -= elRect.width / 2;
rotated.t -= elRect.height / 2;
const elTop = opts.avoidFrameZoom ? rotated.t : rotated.t * zoom;
const elLeft = opts.avoidFrameZoom ? rotated.l : rotated.l * zoom;
const top = opts.avoidFrameOffset ? elTop : elTop + frameTop - canvasOffset.top + canvasEl.scrollTop;
const left = opts.avoidFrameOffset ? elLeft : elLeft + frameLeft - canvasOffset.left + canvasEl.scrollLeft;
const height = opts.avoidFrameZoom ? elRect.height : elRect.height * zoom;
const width = opts.avoidFrameZoom ? elRect.width : elRect.width * zoom;
return { top, left, height, width, zoom, rect: elRect };
}
/**
* Returns element's offsets like margins and paddings
* @param {HTMLElement} el
* @return { MarginPaddingOffsets }
* @public
*/
getElementOffsets(el: HTMLElement) {
if (!el || isTextNode(el)) return {};
const result: MarginPaddingOffsets = {};
const styles = window.getComputedStyle(el);
const zoom = this.module.getZoomDecimal();
const marginPaddingOffsets: (keyof MarginPaddingOffsets)[] = [
'marginTop',
'marginRight',
'marginBottom',
'marginLeft',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
];
marginPaddingOffsets.forEach(offset => {
result[offset] = parseFloat(styles[offset]) * zoom;
});
return result;
}
/**
* Returns position data of the canvas element
* @return { {top: number, left: number, width: number, height: number} } obj Position object
* @public
*/
getPosition(opts: any = {}): ElementRect {
const doc = this.frame?.el.contentDocument;
if (!doc) {
return {
top: 0,
left: 0,
width: 0,
height: 0,
};
}
const bEl = doc.body;
const zoom = this.module.getZoomDecimal();
const fo = this.getFrameOffset();
const co = this.getCanvasOffset();
const { noScroll } = opts;
// console.table({
// cot: co.top,
// col: co.left,
// fot: fo.top,
// fol: fo.left
// });
return {
top: fo.top + (noScroll ? 0 : bEl.scrollTop) - co.top,
left: fo.left + (noScroll ? 0 : bEl.scrollLeft) - co.left,
width: co.width,
height: co.height,
};
}
/**
* Update javascript of a specific component passed by its View
* @param {ModuleView} view Component's View
* @private
*/
//TODO change type after the ComponentView was updated to ts
updateScript(view: any) {
const model = view.model;
const id = model.getId();
if (!view.scriptContainer) {
view.scriptContainer = createEl('div', { 'data-id': id });
const jsEl = this.getJsContainer();
jsEl?.appendChild(view.scriptContainer);
}
view.el.id = id;
view.scriptContainer.innerHTML = '';
// In editor, I make use of setTimeout as during the append process of elements
// those will not be available immediately, therefore 'item' variable
const script = document.createElement('script');
const scriptFn = model.getScriptString();
const scriptFnStr = model.get('script-props') ? scriptFn : `function(){\n${scriptFn}\n;}`;
const scriptProps = JSON.stringify(model.__getScriptProps());
script.innerHTML = `
setTimeout(function() {
var item = document.getElementById('${id}');
if (!item) return;
(${scriptFnStr}.bind(item))(${scriptProps})
}, 1);`;
// #873
// Adding setTimeout will make js components work on init of the editor
setTimeout(() => {
const scr = view.scriptContainer;
scr?.appendChild(script);
}, 0);
}
/**
* Get javascript container
* @private
*/
getJsContainer(view?: ComponentView) {
const frameView = this.getFrameView(view);
return frameView?.getJsContainer();
}
getFrameView(view?: ComponentView) {
return view?.frameView || this.em.getCurrentFrame();
}
_renderFrames() {
if (!this.ready) return;
const { model, frames, em, framesArea } = this;
const frms = model.frames;
frms.listenToLoad();
frames.render();
const mainFrame = frms.at(0);
const currFrame = mainFrame?.view;
em.setCurrentFrame(currFrame);
framesArea?.appendChild(frames.el);
this.frame = currFrame;
this.updateFramesArea();
}
renderFrames() {
this._renderFrames();
}
render() {
const { el, $el, ppfx, config, em } = this;
$el.html(this.template());
const $frames = $el.find('[data-frames]');
this.framesArea = $frames.get(0);
const toolsWrp = $el.find('[data-tools]');
this.toolsWrapper = toolsWrp.get(0);
toolsWrp.append(`
<div class="${ppfx}tools ${ppfx}tools-gl" style="pointer-events:none">
<div class="${ppfx}placeholder">
<div class="${ppfx}placeholder-int"></div>
</div>
</div>
<div id="${ppfx}tools" style="pointer-events:none">
${config.extHl ? `<div class="${ppfx}highlighter-sel"></div>` : ''}
<div class="${ppfx}badge"></div>
<div class="${ppfx}ghost"></div>
<div class="${ppfx}toolbar" style="pointer-events:all"></div>
<div class="${ppfx}resizer"></div>
<div class="${ppfx}offset-v"></div>
<div class="${ppfx}offset-fixed-v"></div>
</div>
`);
this.toolsEl = el.querySelector(`#${ppfx}tools`)!;
this.hlEl = el.querySelector(`.${ppfx}highlighter`)!;
this.badgeEl = el.querySelector(`.${ppfx}badge`)!;
this.placerEl = el.querySelector(`.${ppfx}placeholder`)!;
this.ghostEl = el.querySelector(`.${ppfx}ghost`)!;
this.toolbarEl = el.querySelector(`.${ppfx}toolbar`)!;
this.resizerEl = el.querySelector(`.${ppfx}resizer`)!;
this.offsetEl = el.querySelector(`.${ppfx}offset-v`)!;
this.fixedOffsetEl = el.querySelector(`.${ppfx}offset-fixed-v`)!;
this.toolsGlobEl = el.querySelector(`.${ppfx}tools-gl`)!;
this.spotsEl = el.querySelector('[data-spots]')!;
this.cvStyle = el.querySelector('[data-canvas-style]')!;
this.el.className = getUiClass(em, this.className);
this.ready = true;
this._renderFrames();
return this;
}
}