@print-one/grapesjs
Version:
Free and Open Source Web Builder Framework
583 lines (505 loc) • 13.9 kB
text/typescript
import { bindAll, isFunction, each } from 'underscore';
import { Position } from '../common';
import { on, off, normalizeFloat } from './mixins';
type RectDimRotator = {
w: number;
h: number;
t: number;
l: number;
r: number;
};
type BoundingRectRotator = {
left: number;
top: number;
width: number;
height: number;
};
type CallbackOptionsRotator = {
docs: any;
config: any;
el: HTMLElement;
rotator: Rotator;
};
export function rotateCoordinate(
coordinate: Pick<RectDimRotator, 'l' | 't'>,
rect: RectDimRotator
): Pick<RectDimRotator, 'l' | 't'> {
const cx = rect.l + rect.w / 2;
const cy = rect.t + rect.h / 2;
const a = rect.r;
const theta = a * (Math.PI / 180);
const x = coordinate.l;
const y = coordinate.t;
const rx = (x - cx) * Math.cos(theta) - (y - cy) * Math.sin(theta) + cx;
const ry = (x - cx) * Math.sin(theta) + (y - cy) * Math.cos(theta) + cy;
return { l: rx, t: ry };
}
export interface RotatorOptions {
/**
* Function which returns custom X and Y coordinates of the mouse.
*/
mousePosFetcher?: (ev: MouseEvent) => Position;
/**
* Indicates custom target updating strategy.
*/
updateTarget?: (el: HTMLElement, rect: RectDimRotator, opts: any) => void;
/**
* Function which gets HTMLElement as an arg and returns it relative position
*/
posFetcher?: (el: HTMLElement, opts: any) => BoundingRectRotator;
/**
* On rotate start callback.
*/
onStart?: (ev: Event, opts: CallbackOptionsRotator) => void;
/**
* On rotate move callback.
*/
onMove?: (ev: Event) => void;
/**
* On rotate end callback.
*/
onEnd?: (ev: Event, opts: CallbackOptionsRotator) => void;
/**
* On container update callback.
*/
onUpdateContainer?: (opts: any) => void;
/**
* Rotate unit step.
* @default 1
*/
step?: number;
/**
* Minimum dimension.
* @default 10
*/
minDim?: number;
/**
* Maximum dimension.
* @default Infinity
*/
maxDim?: number;
/**
* 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;
/**
* Class prefix.
*/
prefix?: string;
/**
* Where to append rotate container (default body element).
*/
appendTo?: HTMLElement;
/**
* Offset before snap to guides.
* @default 5
*/
snapOffset?: number;
/**
* Offset before snap to guides.
* @default 45
*/
snapPoints?: number;
rotationAngle?: number;
}
const getBoundingRect = (el: HTMLElement, win?: Window): BoundingRectRotator => {
var w = win || window;
return {
left: el.offsetLeft + w.pageXOffset,
top: el.offsetTop + w.pageYOffset,
width: el.offsetWidth,
height: el.offsetHeight,
};
};
export default class Rotator {
defOpts: RotatorOptions;
opts: RotatorOptions;
container?: HTMLElement;
handler?: HTMLElement;
el?: HTMLElement;
selectedHandler?: HTMLElement;
handlerAttr?: string;
center?: Position;
startDim?: RectDimRotator;
rectDim?: RectDimRotator;
delta?: Position;
startPos?: Position;
currentPos?: Position;
docs?: Document[];
keys?: { shift: boolean; ctrl: boolean; alt: boolean };
snapOffset?: number;
snapPoints?: number;
mousePosFetcher?: RotatorOptions['mousePosFetcher'];
updateTarget?: RotatorOptions['updateTarget'];
posFetcher?: RotatorOptions['posFetcher'];
onStart?: RotatorOptions['onStart'];
onMove?: RotatorOptions['onMove'];
onEnd?: RotatorOptions['onEnd'];
onUpdateContainer?: RotatorOptions['onUpdateContainer'];
/**
* Init the Rotator with options
* @param {Object} options
*/
constructor(opts: RotatorOptions = {}) {
this.defOpts = {
onUpdateContainer: () => {},
step: 1,
minDim: 10,
maxDim: Infinity,
currentUnit: true,
silentFrames: false,
avoidContainerUpdate: false,
snapOffset: 5,
snapPoints: 45,
};
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<RotatorOptions> = {}, reset?: boolean) {
this.opts = {
...(reset ? this.defOpts : this.opts),
...options,
};
this.setup();
}
/**
* Setup rotator
*/
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}rotator-c`;
appendTo.appendChild(container);
this.container = container;
}
while (container.firstChild) {
container.removeChild(container.firstChild);
}
this.handler = container;
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 rotate handler
* @param {HTMLElement} el
* @return {Boolean}
*/
isHandler(el: HTMLElement) {
const { handler } = this;
return handler === el;
}
/**
* 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 = {}) {
const { posFetcher } = this;
return posFetcher ? posFetcher(el, opts) : getBoundingRect(el);
}
/**
* Return element rotation
* @param {HTMLElement} el
* @returns {number} rotation
*/
getElementRotation(el: HTMLElement): number {
var rotation = window.getComputedStyle(el, null).getPropertyValue('rotate') ?? null;
return rotation ? Number(rotation.replace('deg', '')) : 0;
}
/**
* Focus rotator 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;
}
}
/**
* Start rotating
* @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 rotator = this;
const config = this.opts || {};
const attrName = 'data-' + config.prefix + 'handler';
const rectRotation = this.getElementRotation(el!);
const rect = this.getElementPos(el!, { target: 'el', avoidFrameZoom: true });
const target = e.target as HTMLElement;
this.handlerAttr = target.getAttribute(attrName)!;
const mouseFetch = this.mousePosFetcher;
this.startPos = mouseFetch
? mouseFetch(e)
: {
x: e.clientX,
y: e.clientY,
};
this.startDim = {
t: rect.top,
l: rect.left,
w: rect.width,
h: rect.height,
r: rectRotation - (this.opts.rotationAngle ?? 0),
};
this.rectDim = {
t: rect.top,
l: rect.left,
w: rect.width,
h: rect.height,
r: rectRotation,
};
const dims = this.getElementPos(el!, { target: 'el', avoidFrameZoom: true, avoidFrameOffset: true });
this.center = {
x: dims.left + dims.width / 2,
y: dims.top + dims.height / 2,
};
// 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, rotator });
this.toggleFrames(true);
this.move(e);
}
/**
* While rotating
* @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;
const dX = this.startPos!.x - this.center!.x;
const dY = this.startPos!.y - this.center!.y;
const R = Math.sqrt(dX * dX + dY * dY);
const vX = currentPos.x - this.center!.x;
const vY = currentPos.y - this.center!.y;
const magV = Math.sqrt(vX * vX + vY * vY);
const aX = this.center!.x + (vX / magV) * R;
const aY = this.center!.y + (vY / magV) * R;
this.delta = {
x: aX - this.center!.x,
y: aY - this.center!.y,
};
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 rotating
* @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, rotator: this });
delete this.docs;
}
/**
* Update rect
*/
updateRect(store: boolean) {
const el = this.el!;
const rotator = this;
const config = this.opts;
const rect = this.rectDim!;
const updateTarget = this.updateTarget;
// Use custom updating strategy if requested
if (isFunction(updateTarget)) {
updateTarget(el, rect, {
store,
rotator,
config,
});
} else {
const elStyle = el.style as Record<string, any>;
elStyle.rotate = rect.r + 'deg';
}
this.updateContainer();
}
updateContainer(opt: { forceShow?: boolean } = {}) {
const { opts, container, el } = this;
const { style } = container!;
if (!opts.avoidContainerUpdate && el) {
// On component rotate container fits the tool,
// to check if this update is required somewhere else point
// const toUpdate = ['rotate'];
// 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!,
rotator: this,
opts: {
...opts,
...opt,
},
});
}
/**
* Handle ESC key
* @param {Event} e
*/
handleKeyDown(e: Event) {
// @ts-ignore
if (e.keyCode === 27) {
// Rollback to initial rotation
this.rectDim = this.startDim;
this.stop(e);
}
}
/**
* Handle mousedown to check if it's possible to start rotating
* @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: Rotator): RectDimRotator | undefined {
const startDim = this.startDim!;
const deltaX = data.delta!.x;
const deltaY = data.delta!.y;
const box: RectDimRotator = {
l: startDim.l,
t: startDim.t,
w: startDim.w,
h: startDim.h,
r: startDim.r,
};
if (!data) return;
const angle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI + 90 - (this.opts.rotationAngle ?? 0);
box.r = angle;
const snappingPoints = this.defOpts.snapPoints!;
const snappingOffset = this.defOpts.snapOffset!;
const prevSnappingPoint = Math.round(angle / snappingPoints) * snappingPoints;
const nextSnappingPoint = prevSnappingPoint + snappingPoints;
const closestSnappingPoint =
angle - prevSnappingPoint > nextSnappingPoint - angle ? nextSnappingPoint : prevSnappingPoint;
const isWithinSnapRange = Math.abs(angle - closestSnappingPoint) < snappingOffset;
// Enforce rotation snapping (unless shift key is being held)
const shouldSnap = !data.keys!.shift && isWithinSnapRange;
// Override the rotation when the element is supposed to snap
if (shouldSnap) {
box.r = closestSnappingPoint;
}
return box;
}
}