@print-one/grapesjs
Version:
Free and Open Source Web Builder Framework
832 lines (730 loc) • 20.5 kB
text/typescript
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;
}
}