Browse Source

Merge branch 'dev' into docs-update-studio

docs-update-studio
Artur Arseniev 10 months ago
committed by GitHub
parent
commit
f8ece953a4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      packages/cli/package.json
  2. 2
      packages/core/src/canvas/index.ts
  3. 35
      packages/core/src/commands/config/config.ts
  4. 4
      packages/core/src/commands/index.ts
  5. 623
      packages/core/src/commands/view/ComponentDrag.ts
  6. 2
      packages/core/src/utils/Dragger.ts
  7. 34
      packages/core/test/specs/commands/index.ts
  8. 574
      pnpm-lock.yaml

2
packages/cli/package.json

@ -29,7 +29,7 @@
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@babel/core": "7.25.2", "@babel/core": "7.25.2",
"@babel/plugin-transform-runtime": "7.25.4", "@babel/plugin-transform-runtime": "7.26.10",
"@babel/preset-env": "7.25.4", "@babel/preset-env": "7.25.4",
"@babel/runtime": "7.25.6", "@babel/runtime": "7.25.6",
"babel-loader": "9.1.3", "babel-loader": "9.1.3",

2
packages/core/src/canvas/index.ts

@ -515,7 +515,7 @@ export default class CanvasModule extends Module<CanvasConfig> {
* @return {Object} * @return {Object}
* @private * @private
*/ */
getMouseRelativeCanvas(ev: MouseEvent | { clientX: number; clientY: number }, opts: any) { getMouseRelativeCanvas(ev: MouseEvent | { clientX: number; clientY: number }, opts?: Record<string, unknown>) {
const zoom = this.getZoomDecimal(); const zoom = this.getZoomDecimal();
const canvasView = this.getCanvasView(); const canvasView = this.getCanvasView();
const canvasPos = canvasView.getPosition(opts) ?? { top: 0, left: 0 }; const canvasPos = canvasView.getPosition(opts) ?? { top: 0, left: 0 };

35
packages/core/src/commands/config/config.ts

@ -1,4 +1,9 @@
import { CommandObject } from '../view/CommandAbstract'; import type { CommandObject, CommandOptions } from '../view/CommandAbstract';
interface CommandConfigDefaultOptions {
run?: (options: CommandOptions) => CommandOptions;
stop?: (options: CommandOptions) => CommandOptions;
}
export interface CommandsConfig { export interface CommandsConfig {
/** /**
@ -19,12 +24,40 @@ export interface CommandsConfig {
* @default true * @default true
*/ */
strict?: boolean; strict?: boolean;
/**
* Default options for commands
* These options will be merged with the options passed when the command is run.
* This allows you to define common behavior for commands in one place.
* @default {}
* @example
* defaultOptions: {
* 'core:component-drag': {
* run: (options: Record<string, unknown>) => ({
* ...options,
* skipGuidesRender: true,
* addStyle({ component, styles, partial }) {
* component.addStyle(styles, { partial });
* },
* }),
* stop: (options: Record<string, unknown>) => ({
* ...options,
* * skipGuidesRender: true,
* addStyle({ component, styles, partial }) {
* component.addStyle(styles, { partial });
* },
* }),
* }
* }
*/
defaultOptions?: Record<string, CommandConfigDefaultOptions>;
} }
const config: () => CommandsConfig = () => ({ const config: () => CommandsConfig = () => ({
stylePrefix: 'com-', stylePrefix: 'com-',
defaults: {}, defaults: {},
strict: true, strict: true,
defaultOptions: {},
}); });
export default config; export default config;

4
packages/core/src/commands/index.ts

@ -389,6 +389,8 @@ export default class CommandsModule extends Module<CommandsConfig & { pStylePref
const editor = em.Editor; const editor = em.Editor;
if (!this.isActive(id) || options.force || !config.strict) { if (!this.isActive(id) || options.force || !config.strict) {
const defaultOptionsRunFn = config.defaultOptions?.[id]?.run;
isFunction(defaultOptionsRunFn) && (options = defaultOptionsRunFn(options));
result = editor && (command as any).callRun(editor, options); result = editor && (command as any).callRun(editor, options);
} }
} }
@ -412,6 +414,8 @@ export default class CommandsModule extends Module<CommandsConfig & { pStylePref
const editor = em.Editor; const editor = em.Editor;
if (this.isActive(id) || options.force || !config.strict) { if (this.isActive(id) || options.force || !config.strict) {
const defaultOptionsStopFn = config.defaultOptions?.[id]?.stop;
isFunction(defaultOptionsStopFn) && (options = defaultOptionsStopFn(options));
result = (command as any).callStop(editor, options); result = (command as any).callStop(editor, options);
} }
} }

623
packages/core/src/commands/view/ComponentDrag.ts

@ -1,23 +1,18 @@
import { keys, bindAll, each, isUndefined, debounce } from 'underscore'; import { keys, bindAll, each, isUndefined, debounce } from 'underscore';
import Dragger from '../../utils/Dragger'; import Dragger, { DraggerOptions } from '../../utils/Dragger';
import { CommandObject } from './CommandAbstract'; import type { CommandObject } from './CommandAbstract';
import type Editor from '../../editor';
type Rect = { left: number; width: number; top: number; height: number }; import type Component from '../../dom_components/model/Component';
type OrigRect = { left: number; width: number; top: number; height: number; rect: Rect }; import type EditorModel from '../../editor/model/Editor';
import { getComponentModel, getComponentView } from '../../utils/mixins';
type Guide = { import type ComponentView from '../../dom_components/view/ComponentView';
type: string;
y: number;
x: number;
origin: HTMLElement;
originRect: OrigRect;
guide: HTMLElement;
};
const evName = 'dmode'; const evName = 'dmode';
// TODO: check setZoom, setCoords
export default { export default {
run(editor, sender, opts = {}) { run(editor, _sender, opts = {} as ComponentDragOpts) {
bindAll( bindAll(
this, this,
'setPosition', 'setPosition',
@ -29,28 +24,30 @@ export default {
'renderGuide', 'renderGuide',
'getGuidesTarget', 'getGuidesTarget',
); );
const { target, event, mode, dragger = {} } = opts;
const el = target.getEl(); if (!opts.target) throw new Error('Target option is required');
const config = { const config = {
doc: el.ownerDocument, doc: opts.target.getEl()?.ownerDocument,
onStart: this.onStart, onStart: this.onStart,
onEnd: this.onEnd, onEnd: this.onEnd,
onDrag: this.onDrag, onDrag: this.onDrag,
getPosition: this.getPosition, getPosition: this.getPosition,
setPosition: this.setPosition, setPosition: this.setPosition,
guidesStatic: () => this.guidesStatic, guidesStatic: () => this.guidesStatic ?? [],
guidesTarget: () => this.guidesTarget, guidesTarget: () => this.guidesTarget ?? [],
...dragger, ...(opts.dragger ?? {}),
}; };
this.setupGuides(); this.setupGuides();
this.opts = opts; this.opts = opts;
this.editor = editor; this.editor = editor;
this.em = editor.getModel(); this.em = editor.getModel();
this.target = target; this.target = opts.target;
this.isTran = mode == 'translate'; this.isTran = opts.mode == 'translate';
this.guidesContainer = this.getGuidesContainer(); this.guidesContainer = this.getGuidesContainer();
this.guidesTarget = this.getGuidesTarget(); this.guidesTarget = this.getGuidesTarget();
this.guidesStatic = this.getGuidesStatic(); this.guidesStatic = this.getGuidesStatic();
let drg = this.dragger; let drg = this.dragger;
if (!drg) { if (!drg) {
@ -60,19 +57,22 @@ export default {
drg.setOptions(config); drg.setOptions(config);
} }
event && drg.start(event); opts.event && drg.start(opts.event);
this.toggleDrag(1); this.toggleDrag(true);
this.em.trigger(`${evName}:start`, this.getEventOpts()); this.em.trigger(`${evName}:start`, this.getEventOpts());
return drg; return drg;
}, },
getEventOpts() { getEventOpts() {
const guidesActive = this.guidesTarget?.filter((item) => item.active) ?? [];
return { return {
mode: this.opts.mode, mode: this.opts.mode,
component: this.target,
target: this.target, target: this.target,
guidesTarget: this.guidesTarget, guidesTarget: this.guidesTarget,
guidesStatic: this.guidesStatic, guidesStatic: this.guidesStatic,
guidesMatched: this.getGuidesMatched(guidesActive),
}; };
}, },
@ -81,9 +81,9 @@ export default {
}, },
setupGuides() { setupGuides() {
(this.guides || []).forEach((item: any) => { (this.guides ?? []).forEach((item) => {
const { guide } = item; const { guide } = item;
guide && guide.parentNode.removeChild(guide); guide?.parentNode?.removeChild(guide);
}); });
this.guides = []; this.guides = [];
}, },
@ -93,7 +93,7 @@ export default {
if (!guidesEl) { if (!guidesEl) {
const { editor, em, opts } = this; const { editor, em, opts } = this;
const pfx = editor.getConfig().stylePrefix; const pfx = editor.getConfig().stylePrefix ?? '';
const elInfoX = document.createElement('div'); const elInfoX = document.createElement('div');
const elInfoY = document.createElement('div'); const elInfoY = document.createElement('div');
const guideContent = `<div class="${pfx}guide-info__line ${pfx}danger-bg"> const guideContent = `<div class="${pfx}guide-info__line ${pfx}danger-bg">
@ -107,18 +107,18 @@ export default {
elInfoY.innerHTML = guideContent; elInfoY.innerHTML = guideContent;
guidesEl.appendChild(elInfoX); guidesEl.appendChild(elInfoX);
guidesEl.appendChild(elInfoY); guidesEl.appendChild(elInfoY);
editor.Canvas.getGlobalToolsEl().appendChild(guidesEl); editor.Canvas.getGlobalToolsEl()?.appendChild(guidesEl);
this.guidesEl = guidesEl; this.guidesEl = guidesEl;
this.elGuideInfoX = elInfoX; this.elGuideInfoX = elInfoX;
this.elGuideInfoY = elInfoY; this.elGuideInfoY = elInfoY;
this.elGuideInfoContentX = elInfoX.querySelector(`.${pfx}guide-info__content`); this.elGuideInfoContentX = elInfoX.querySelector(`.${pfx}guide-info__content`) ?? undefined;
this.elGuideInfoContentY = elInfoY.querySelector(`.${pfx}guide-info__content`); this.elGuideInfoContentY = elInfoY.querySelector(`.${pfx}guide-info__content`) ?? undefined;
em.on( em.on(
'canvas:update frame:scroll', 'canvas:update frame:scroll',
debounce(() => { debounce(() => {
this.updateGuides(); this.updateGuides();
opts.debug && this.guides?.forEach((item: any) => this.renderGuide(item)); opts.debug && this.guides?.forEach((item) => this.renderGuide(item));
}, 200), }, 200),
); );
} }
@ -127,32 +127,39 @@ export default {
}, },
getGuidesStatic() { getGuidesStatic() {
let result: any = []; let result: Guide[] = [];
const el = this.target.getEl(); const el = this.target.getEl();
const { parentNode = {} } = el; const parentNode = el?.parentElement;
each(parentNode.children, (item) => (result = result.concat(el !== item ? this.getElementGuides(item) : []))); if (!parentNode) return [];
each(
parentNode.children,
(item) => (result = result.concat(el !== item ? this.getElementGuides(item as HTMLElement) : [])),
);
return result.concat(this.getElementGuides(parentNode)); return result.concat(this.getElementGuides(parentNode));
}, },
getGuidesTarget() { getGuidesTarget() {
return this.getElementGuides(this.target.getEl()); return this.getElementGuides(this.target.getEl()!);
}, },
updateGuides(guides: any) { updateGuides(guides) {
let lastEl: any; let lastEl: HTMLElement;
let lastPos: any; let lastPos: ComponentOrigRect;
(guides || this.guides).forEach((item: any) => { const guidesToUpdate = guides ?? this.guides ?? [];
guidesToUpdate.forEach((item) => {
const { origin } = item; const { origin } = item;
const pos = lastEl === origin ? lastPos : this.getElementPos(origin); const pos = lastEl === origin ? lastPos : this.getElementPos(origin);
lastEl = origin; lastEl = origin;
lastPos = pos; lastPos = pos;
each(this.getGuidePosUpdate(item, pos), (val, key) => (item[key] = val)); each(this.getGuidePosUpdate(item, pos), (val, key) => {
(item as Record<string, unknown>)[key] = val;
});
item.originRect = pos; item.originRect = pos;
}); });
}, },
getGuidePosUpdate(item: any, rect: any) { getGuidePosUpdate(item, rect) {
const result: { x?: number; y?: number } = {}; const result: { x?: number; y?: number } = {};
const { top, height, left, width } = rect; const { top, height, left, width } = rect;
@ -180,16 +187,17 @@ export default {
return result; return result;
}, },
renderGuide(item: any = {}) { renderGuide(item) {
const el = item.guide || document.createElement('div'); if (this.opts.skipGuidesRender) return;
const el = item.guide ?? document.createElement('div');
const un = 'px'; const un = 'px';
const guideSize = item.active ? 2 : 1; const guideSize = item.active ? 2 : 1;
let numEl = el.children[0];
el.style = `position: absolute; background-color: ${item.active ? 'green' : 'red'};`; el.style.cssText = `position: absolute; background-color: ${item.active ? 'green' : 'red'};`;
if (!el.children.length) { if (!el.children.length) {
numEl = document.createElement('div'); const numEl = document.createElement('div');
numEl.style = 'position: absolute; color: red; padding: 5px; top: 0; left: 0;'; numEl.style.cssText = 'position: absolute; color: red; padding: 5px; top: 0; left: 0;';
el.appendChild(numEl); el.appendChild(numEl);
} }
@ -197,7 +205,7 @@ export default {
el.style.width = '100%'; el.style.width = '100%';
el.style.height = `${guideSize}${un}`; el.style.height = `${guideSize}${un}`;
el.style.top = `${item.y}${un}`; el.style.top = `${item.y}${un}`;
el.style.left = 0; el.style.left = '0';
} else { } else {
el.style.width = `${guideSize}${un}`; el.style.width = `${guideSize}${un}`;
el.style.height = '100%'; el.style.height = '100%';
@ -205,38 +213,52 @@ export default {
el.style.top = `0${un}`; el.style.top = `0${un}`;
} }
!item.guide && this.guidesContainer.appendChild(el); !item.guide && this.guidesContainer?.appendChild(el);
return el; return el;
}, },
getElementPos(el: HTMLElement) { getElementPos(el) {
return this.editor.Canvas.getElementPos(el, { noScroll: 1 }); return this.editor.Canvas.getElementPos(el, { noScroll: 1 });
}, },
getElementGuides(el: HTMLElement) { getElementGuides(el) {
const { opts } = this; const { opts } = this;
const origin = el;
const originRect = this.getElementPos(el); const originRect = this.getElementPos(el);
const component = getComponentModel(el);
const componentView = getComponentView(el);
const { top, height, left, width } = originRect; const { top, height, left, width } = originRect;
// @ts-ignore const guidePoints: { type: string; x?: number; y?: number }[] = [
const guides: Guide[] = [
{ type: 't', y: top }, // Top { type: 't', y: top }, // Top
{ type: 'b', y: top + height }, // Bottom { type: 'b', y: top + height }, // Bottom
{ type: 'l', x: left }, // Left { type: 'l', x: left }, // Left
{ type: 'r', x: left + width }, // Right { type: 'r', x: left + width }, // Right
{ type: 'x', x: left + width / 2 }, // Mid x { type: 'x', x: left + width / 2 }, // Mid x
{ type: 'y', y: top + height / 2 }, // Mid y { type: 'y', y: top + height / 2 }, // Mid y
].map((item) => ({ ];
...item,
origin: el, const guides = guidePoints.map((guidePoint) => {
originRect, const guide = opts.debug ? this.renderGuide(guidePoint) : undefined;
guide: opts.debug && this.renderGuide(item), return {
})); ...guidePoint,
guides.forEach((item) => this.guides?.push(item)); component,
componentView,
componentEl: origin,
origin,
componentElRect: originRect,
originRect,
guideEl: guide,
guide,
};
}) as Guide[];
guides.forEach((guidePoint) => this.guides?.push(guidePoint));
return guides; return guides;
}, },
getTranslate(transform: string, axis = 'x') { getTranslate(transform, axis = 'x') {
let result = 0; let result = 0;
(transform || '').split(' ').forEach((item) => { (transform || '').split(' ').forEach((item) => {
const itemStr = item.trim(); const itemStr = item.trim();
@ -246,7 +268,7 @@ export default {
return result; return result;
}, },
setTranslate(transform: string, axis: string, value: string) { setTranslate(transform, axis, value) {
const fn = `translate${axis.toUpperCase()}(`; const fn = `translate${axis.toUpperCase()}(`;
const val = `${fn}${value})`; const val = `${fn}${value})`;
let result = (transform || '') let result = (transform || '')
@ -264,35 +286,39 @@ export default {
getPosition() { getPosition() {
const { target, isTran } = this; const { target, isTran } = this;
const { left, top, transform } = target.getStyle(); const targetStyle = target.getStyle();
const transform = targetStyle.transform as string | undefined;
const left = targetStyle.left as string | undefined;
const top = targetStyle.top as string | undefined;
let x = 0; let x = 0;
let y = 0; let y = 0;
if (isTran) { if (isTran && transform) {
x = this.getTranslate(transform); x = this.getTranslate(transform);
y = this.getTranslate(transform, 'y'); y = this.getTranslate(transform, 'y');
} else { } else {
x = parseFloat(left || 0); x = parseFloat(left ?? '0');
y = parseFloat(top || 0); y = parseFloat(top ?? '0');
} }
return { x, y }; return { x, y };
}, },
setPosition({ x, y, end, position, width, height }: any) { setPosition({ x, y, end, position, width, height }) {
const { target, isTran, em } = this; const { target, isTran, em, opts } = this;
const unit = 'px'; const unit = 'px';
const __p = !end; // Indicate if partial change const __p = !end; // Indicate if partial change
const left = `${parseInt(x, 10)}${unit}`; const left = `${parseInt(`${x}`, 10)}${unit}`;
const top = `${parseInt(y, 10)}${unit}`; const top = `${parseInt(`${y}`, 10)}${unit}`;
let styleUp = {}; let styleUp = {};
if (isTran) { if (isTran) {
let transform = target.getStyle()['transform'] || ''; let transform = (target.getStyle()?.transform ?? '') as string;
transform = this.setTranslate(transform, 'x', left); transform = this.setTranslate(transform, 'x', left);
transform = this.setTranslate(transform, 'y', top); transform = this.setTranslate(transform, 'y', top);
styleUp = { transform, __p }; styleUp = { transform, __p };
target.addStyle(styleUp, { avoidStore: !end });
} else { } else {
const adds: any = { position, width, height }; const adds: any = { position, width, height };
const style: any = { left, top, __p }; const style: any = { left, top, __p };
@ -301,10 +327,15 @@ export default {
if (prop) style[add] = prop; if (prop) style[add] = prop;
}); });
styleUp = style; styleUp = style;
}
if (opts.addStyle) {
opts.addStyle({ component: target, styles: styleUp, partial: !end });
} else {
target.addStyle(styleUp, { avoidStore: !end }); target.addStyle(styleUp, { avoidStore: !end });
} }
em?.Styles.__emitCmpStyleUpdate(styleUp, { components: em.getSelected() }); em.Styles.__emitCmpStyleUpdate(styleUp, { components: em.getSelected() });
}, },
_getDragData() { _getDragData() {
@ -316,35 +347,37 @@ export default {
}; };
}, },
onStart(event: Event) { onStart(event) {
const { target, editor, isTran, opts } = this; const { target, editor, isTran, opts } = this;
const { center, onStart } = opts;
const { Canvas } = editor; const { Canvas } = editor;
const style = target.getStyle(); const style = target.getStyle();
const position = 'absolute'; const position = 'absolute';
const relPos = [position, 'relative']; const relPos = [position, 'relative'];
onStart && onStart(this._getDragData()); opts.onStart?.(this._getDragData());
if (isTran) return; if (isTran) return;
if (style.position !== position) { if (style.position !== position) {
let { left, top, width, height } = Canvas.offset(target.getEl()); let { left, top, width, height } = Canvas.offset(target.getEl()!);
let parent = target.parent(); let parent = target.parent();
let parentRel; let parentRel = null;
// Check for the relative parent // Check for the relative parent
do { do {
const pStyle = parent.getStyle(); const pStyle = parent?.getStyle();
parentRel = relPos.indexOf(pStyle.position) >= 0 ? parent : null; const position = pStyle?.position as string | undefined;
parent = parent.parent(); if (position) {
parentRel = relPos.indexOf(position) >= 0 ? parent : null;
}
parent = parent?.parent();
} while (parent && !parentRel); } while (parent && !parentRel);
// Center the target to the pointer position (used in Droppable for Blocks) // Center the target to the pointer position (used in Droppable for Blocks)
if (center) { if (opts.center) {
const { x, y } = Canvas.getMouseRelativeCanvas(event); const { x, y } = Canvas.getMouseRelativeCanvas(event as MouseEvent);
left = x; left = x;
top = y; top = y;
} else if (parentRel) { } else if (parentRel) {
const offsetP = Canvas.offset(parentRel.getEl()); const offsetP = Canvas.offset(parentRel.getEl()!);
left = left - offsetP.left; left = left - offsetP.left;
top = top - offsetP.top; top = top - offsetP.top;
} }
@ -357,102 +390,167 @@ export default {
position, position,
}); });
} }
// Recalculate guides to avoid issues with the new position durin the first drag
this.guidesStatic = this.getGuidesStatic();
}, },
onDrag(...args: any) { onDrag() {
const { guidesTarget, opts } = this; const { guidesTarget, opts } = this;
const { onDrag } = opts;
this.updateGuides(guidesTarget); this.updateGuides(guidesTarget);
opts.debug && guidesTarget.forEach((item: any) => this.renderGuide(item)); opts.debug && guidesTarget?.forEach((item) => this.renderGuide(item));
opts.guidesInfo && this.renderGuideInfo(guidesTarget.filter((item: any) => item.active)); opts.guidesInfo && this.renderGuideInfo(guidesTarget?.filter((item) => item.active) ?? []);
onDrag && onDrag(this._getDragData()); opts.onDrag?.(this._getDragData());
this.em.trigger(`${evName}:move`, this.getEventOpts());
}, },
onEnd(ev: Event, dragger: any, opt = {}) { onEnd(ev, _dragger, opt) {
const { editor, opts, id } = this; const { editor, opts, id } = this;
const { onEnd } = opts; opts.onEnd?.(ev, opt, { event: ev, ...opt, ...this._getDragData() });
onEnd && onEnd(ev, opt, { event: ev, ...opt, ...this._getDragData() }); editor.stopCommand(`${id}`);
editor.stopCommand(id);
this.hideGuidesInfo(); this.hideGuidesInfo();
this.em.trigger(`${evName}:end`, this.getEventOpts()); this.em.trigger(`${evName}:end`, this.getEventOpts());
}, },
hideGuidesInfo() { hideGuidesInfo() {
['X', 'Y'].forEach((item) => { ['X', 'Y'].forEach((item) => {
const guide = this[`elGuideInfo${item}`]; const guide = this[`elGuideInfo${item}` as ElGuideInfoKey];
if (guide) guide.style.display = 'none'; if (guide) guide.style.display = 'none';
}); });
}, },
/** renderGuideInfo(guides = []) {
* Render guides with spacing information
*/
renderGuideInfo(guides: Guide[] = []) {
const { guidesStatic } = this;
this.hideGuidesInfo(); this.hideGuidesInfo();
guides.forEach((item) => {
const { origin, x } = item; const guidesMatched = this.getGuidesMatched(guides);
const rectOrigin = this.getElementPos(origin);
const axis = isUndefined(x) ? 'y' : 'x'; guidesMatched.forEach((guideMatched) => {
const isY = axis === 'y'; if (!this.opts.skipGuidesRender) {
const origEdge1 = rectOrigin[isY ? 'left' : 'top']; this.renderSingleGuideInfo(guideMatched);
const origEdge1Raw = rectOrigin.rect[isY ? 'left' : 'top']; }
const origEdge2 = isY ? origEdge1 + rectOrigin.width : origEdge1 + rectOrigin.height;
const origEdge2Raw = isY ? origEdge1Raw + rectOrigin.rect.width : origEdge1Raw + rectOrigin.rect.height; this.em.trigger(`${evName}:active`, {
const elGuideInfo = this[`elGuideInfo${axis.toUpperCase()}`]; ...this.getEventOpts(),
const elGuideInfoCnt = this[`elGuideInfoContent${axis.toUpperCase()}`]; ...guideMatched,
const guideInfoStyle = elGuideInfo.style; });
});
// Find the nearest element },
const res = guidesStatic
?.filter((stat) => stat.type === item.type) renderSingleGuideInfo(guideMatched) {
.map((stat) => { const { posFirst, posSecond, size, sizeRaw, guide, elGuideInfo, elGuideInfoCnt } = guideMatched;
const { left, width, top, height } = stat.originRect;
const axis = isUndefined(guide.x) ? 'y' : 'x';
const isY = axis === 'y';
const guideInfoStyle = elGuideInfo.style;
guideInfoStyle.display = '';
guideInfoStyle[isY ? 'top' : 'left'] = `${posFirst}px`;
guideInfoStyle[isY ? 'left' : 'top'] = `${posSecond}px`;
guideInfoStyle[isY ? 'width' : 'height'] = `${size}px`;
elGuideInfoCnt.innerHTML = `${Math.round(sizeRaw)}px`;
},
getGuidesMatched(guides = []) {
const { guidesStatic = [] } = this;
return guides
.map((guide) => {
const { origin, x } = guide;
const rectOrigin = this.getElementPos(origin);
const axis = isUndefined(x) ? 'y' : 'x';
const isY = axis === 'y';
// Calculate the edges of the element
const origEdge1 = rectOrigin[isY ? 'left' : 'top'];
const origEdge1Raw = rectOrigin.rect[isY ? 'left' : 'top'];
const origEdge2 = isY ? origEdge1 + rectOrigin.width : origEdge1 + rectOrigin.height;
const origEdge2Raw = isY ? origEdge1Raw + rectOrigin.rect.width : origEdge1Raw + rectOrigin.rect.height;
// Find the nearest element
const guidesMatched = guidesStatic
.filter((guideStatic) => {
// Define complementary guide types
const complementaryTypes: Record<string, string[]> = {
l: ['r', 'x'], // Left can match with Right or Middle (horizontal)
r: ['l', 'x'], // Right can match with Left or Middle (horizontal)
x: ['l', 'r'], // Middle (horizontal) can match with Left or Right
t: ['b', 'y'], // Top can match with Bottom or Middle (vertical)
b: ['t', 'y'], // Bottom can match with Top or Middle (vertical)
y: ['t', 'b'], // Middle (vertical) can match with Top or Bottom
};
// Check if the guide type matches or is complementary
return guideStatic.type === guide.type || complementaryTypes[guide.type]?.includes(guideStatic.type);
})
.map((guideStatic) => {
const { left, width, top, height } = guideStatic.originRect;
const statEdge1 = isY ? left : top;
const statEdge2 = isY ? left + width : top + height;
return {
gap: statEdge2 < origEdge1 ? origEdge1 - statEdge2 : statEdge1 - origEdge2,
guide: guideStatic,
};
})
.filter((item) => item.gap > 0)
.sort((a, b) => a.gap - b.gap)
.map((item) => item.guide)
// Filter the guides that don't match the position of the dragged element
.filter((item) => {
switch (guide.type) {
case 'l':
case 'r':
case 'x':
return Math.abs(item.x - guide.x) < 1;
case 't':
case 'b':
case 'y':
return Math.abs(item.y - guide.y) < 1;
default:
return false;
}
});
// TODO: consider supporting multiple guides
const firstGuideMatched = guidesMatched[0];
if (firstGuideMatched) {
const { left, width, top, height, rect } = firstGuideMatched.originRect;
const isEdge1 = isY ? left < rectOrigin.left : top < rectOrigin.top;
const statEdge1 = isY ? left : top; const statEdge1 = isY ? left : top;
const statEdge1Raw = isY ? rect.left : rect.top;
const statEdge2 = isY ? left + width : top + height; const statEdge2 = isY ? left + width : top + height;
const statEdge2Raw = isY ? rect.left + rect.width : rect.top + rect.height;
const posFirst = isY ? guide.y : guide.x;
const posSecond = isEdge1 ? statEdge2 : origEdge2;
const size = isEdge1 ? origEdge1 - statEdge2 : statEdge1 - origEdge2;
const sizeRaw = isEdge1 ? origEdge1Raw - statEdge2Raw : statEdge1Raw - origEdge2Raw;
const elGuideInfo = this[`elGuideInfo${axis.toUpperCase()}` as ElGuideInfoKey]!;
const elGuideInfoCnt = this[`elGuideInfoContent${axis.toUpperCase()}` as ElGuideInfoContentKey]!;
return { return {
gap: statEdge2 < origEdge1 ? origEdge1 - statEdge2 : statEdge1 - origEdge2, guide,
guide: stat, guidesStatic,
matched: firstGuideMatched,
posFirst,
posSecond,
size,
sizeRaw,
elGuideInfo,
elGuideInfoCnt,
}; };
}) } else {
.filter((item) => item.gap > 0) return null;
.sort((a, b) => a.gap - b.gap) }
.map((item) => item.guide)[0]; })
.filter(Boolean) as GuideMatched[];
if (res) {
const { left, width, top, height, rect } = res.originRect;
const isEdge1 = isY ? left < rectOrigin.left : top < rectOrigin.top;
const statEdge1 = isY ? left : top;
const statEdge1Raw = isY ? rect.left : rect.top;
const statEdge2 = isY ? left + width : top + height;
const statEdge2Raw = isY ? rect.left + rect.width : rect.top + rect.height;
const posFirst = isY ? item.y : item.x;
const posSecond = isEdge1 ? statEdge2 : origEdge2;
const pos2 = `${posFirst}px`;
const size = isEdge1 ? origEdge1 - statEdge2 : statEdge1 - origEdge2;
const sizeRaw = isEdge1 ? origEdge1Raw - statEdge2Raw : statEdge1Raw - origEdge2Raw;
guideInfoStyle.display = '';
guideInfoStyle[isY ? 'top' : 'left'] = pos2;
guideInfoStyle[isY ? 'left' : 'top'] = `${posSecond}px`;
guideInfoStyle[isY ? 'width' : 'height'] = `${size}px`;
elGuideInfoCnt.innerHTML = `${Math.round(sizeRaw)}px`;
this.em.trigger(`${evName}:active`, {
...this.getEventOpts(),
guide: item,
guidesStatic,
matched: res,
posFirst,
posSecond,
size,
sizeRaw,
elGuideInfo,
elGuideInfoCnt,
});
}
});
}, },
toggleDrag(enable: boolean) { toggleDrag(enable) {
const { ppfx, editor } = this; const { ppfx, editor } = this;
const methodCls = enable ? 'add' : 'remove'; const methodCls = enable ? 'add' : 'remove';
const classes = [`${ppfx}is__grabbing`]; const classes = [`${ppfx}is__grabbing`];
@ -461,11 +559,206 @@ export default {
classes.forEach((cls) => body.classList[methodCls](cls)); classes.forEach((cls) => body.classList[methodCls](cls));
Canvas[enable ? 'startAutoscroll' : 'stopAutoscroll'](); Canvas[enable ? 'startAutoscroll' : 'stopAutoscroll']();
}, },
} as CommandObject<
any, // These properties values are set in the run method, they need to be initialized here to avoid TS errors
{ editor: undefined as unknown as Editor,
guidesStatic?: Guide[]; em: undefined as unknown as EditorModel,
guides?: Guide[]; opts: undefined as unknown as ComponentDragOpts,
[k: string]: any; target: undefined as unknown as Component,
} } as CommandObject<ComponentDragOpts, ComponentDragProps>;
>;
interface ComponentDragProps {
editor: Editor;
em?: EditorModel;
guides?: Guide[];
guidesContainer?: HTMLElement;
guidesEl?: HTMLElement;
guidesStatic?: Guide[];
guidesTarget?: Guide[];
isTran?: boolean;
opts: ComponentDragOpts;
target: Component;
elGuideInfoX?: HTMLElement;
elGuideInfoY?: HTMLElement;
elGuideInfoContentX?: HTMLElement;
elGuideInfoContentY?: HTMLElement;
dragger?: Dragger;
getEventOpts: () => ComponentDragEventProps;
stop: () => void;
setupGuides: () => void;
getGuidesContainer: () => HTMLElement;
getGuidesStatic: () => Guide[];
getGuidesTarget: () => Guide[];
updateGuides: (guides?: Guide[]) => void;
getGuidePosUpdate: (item: Guide, rect: ComponentOrigRect) => { x?: number; y?: number };
renderGuide: (item: { active?: boolean; guide?: HTMLElement; x?: number; y?: number }) => HTMLElement;
getElementPos: (el: HTMLElement) => ComponentOrigRect;
getElementGuides: (el: HTMLElement) => Guide[];
getTranslate: (transform: string, axis?: string) => number;
setTranslate: (transform: string, axis: string, value: string) => string;
getPosition: DraggerOptions['getPosition'];
setPosition: (data: any) => void; // TODO: fix any
_getDragData: () => { target: Component; parent?: Component; index?: number };
onStart: DraggerOptions['onStart'];
onDrag: DraggerOptions['onDrag'];
onEnd: DraggerOptions['onEnd'];
hideGuidesInfo: () => void;
renderGuideInfo: (guides?: Guide[]) => void;
renderSingleGuideInfo: (guideMatched: GuideMatched) => void;
getGuidesMatched: (guides?: Guide[]) => GuideMatched[];
toggleDrag: (enable?: boolean) => void;
}
type ComponentDragOpts = {
target: Component;
center?: number;
debug?: boolean;
dragger?: DraggerOptions;
event?: Event;
guidesInfo?: number;
mode?: 'absolute' | 'translate';
skipGuidesRender?: boolean;
addStyle?: (data: { component?: Component; styles?: Record<string, unknown>; partial?: boolean }) => void;
onStart?: (data: any) => Editor;
onDrag?: (data: any) => Editor;
onEnd?: (ev: Event, opt: any, data: any) => void;
};
/**
* Represents the properties of the drag events.
*/
type ComponentDragEventProps = {
/**
* The mode of the drag (absolute or translate).
*/
mode: ComponentDragOpts['mode'];
/**
* The component being dragged.
* @deprecated Use `component` instead.
*/
target: Component;
/**
* The component being dragged.
*/
component: Component;
/**
* The guides of the component being dragged.
* @deprecated Use `guidesMatched` instead.
*/
guidesTarget: Guide[];
/**
* All the guides except the ones of the component being dragged.
* @deprecated Use `guidesMatched` instead.
*/
guidesStatic: Guide[];
/**
* The guides that are being matched.
*/
guidesMatched: GuideMatched[];
};
/**
* Represents a guide used during component dragging.
*/
type Guide = {
/**
* The type of the guide (e.g., 't', 'b', 'l', 'r', 'x', 'y').
*/
type: string;
/**
* The vertical position of the guide.
*/
y: number;
/**
* The horizontal position of the guide.
*/
x: number;
/**
* The component associated with the guide.
*/
component: Component;
/**
* The view of the component associated with the guide.
*/
componentView: ComponentView;
/**
* The HTML element associated with the guide.
* @deprecated Use `componentEl` instead.
*/
origin: HTMLElement;
/**
* The HTML element associated with the guide.
*/
componentEl: HTMLElement;
/**
* The rectangle (position and dimensions) of the guide's element.
* @deprecated Use `componentElRect` instead.
*/
originRect: ComponentOrigRect;
/**
* The rectangle (position and dimensions) of the guide's element.
*/
componentElRect: ComponentOrigRect;
/**
* The HTML element representing the guide.
* @deprecated Use `guideEl` instead.
*/
guide?: HTMLElement;
/**
* The HTML element representing the guide.
*/
guideEl?: HTMLElement;
/**
* Indicates whether the guide is active.
* @todo The `active` property is not set in the code, but the value is changing.
*/
active?: boolean;
};
/**
* Represents a matched guide during component dragging.
*/
type GuideMatched = {
/**
* The static guides used for matching.
*/
guidesStatic: Guide[];
/**
* The origin component guide.
*/
guide: Guide;
/**
* The matched component guide.
*/
matched: Guide;
/**
* The primary position of the guide (either x or y depending on the axis).
*/
posFirst: number;
/**
* The secondary position of the guide (the opposite axis of posFirst).
*/
posSecond: number;
/**
* The distance between the two matched guides in pixels.
*/
size: number;
/**
* The raw distance between the two matched guides in pixels.
*/
sizeRaw: number;
/**
* The HTML element representing the guide info (line between the guides).
*/
elGuideInfo: HTMLElement;
/**
* The container element for the guide info (text content of the line).
*/
elGuideInfoCnt: HTMLElement;
};
type ComponentRect = { left: number; width: number; top: number; height: number };
type ComponentOrigRect = ComponentRect & { rect: ComponentRect };
type ElGuideInfoKey = 'elGuideInfoX' | 'elGuideInfoY';
type ElGuideInfoContentKey = 'elGuideInfoContentX' | 'elGuideInfoContentY';

2
packages/core/src/utils/Dragger.ts

@ -13,7 +13,7 @@ type Guide = {
active?: boolean; active?: boolean;
}; };
interface DraggerOptions { export interface DraggerOptions {
/** /**
* Element on which the drag will be executed. By default, the document will be used * Element on which the drag will be executed. By default, the document will be used
*/ */

34
packages/core/test/specs/commands/index.ts

@ -1,6 +1,6 @@
import Commands from '../../../src/commands';
import EditorModel from '../../../src/editor/model/Editor'; import EditorModel from '../../../src/editor/model/Editor';
import { Command, CommandFunction } from '../../../src/commands/view/CommandAbstract'; import type Commands from '../../../src/commands';
import type { Command, CommandFunction, CommandOptions } from '../../../src/commands/view/CommandAbstract';
describe('Commands', () => { describe('Commands', () => {
describe('Main', () => { describe('Main', () => {
@ -94,5 +94,35 @@ describe('Commands', () => {
expect(obj.isActive(commName)).toBe(false); expect(obj.isActive(commName)).toBe(false);
expect(Object.keys(obj.getActive()).length).toBe(0); expect(Object.keys(obj.getActive()).length).toBe(0);
}); });
test('Run command and check if none, custom, and default options are passed', () => {
const customOptions = { customValue: 'customValue' };
const defaultOptions = { defaultValue: 'defaultValue' };
// Create a function that returns the options
const runFn = (_editor: any, _sender: any, options: any) => options;
// Add the command
obj.add(commName, { run: runFn });
// Run the command without custom options
let result = obj.run(commName);
expect(result).toEqual({});
// Run the command with custom options
result = obj.run(commName, customOptions);
expect(result).toEqual(customOptions);
// Set default options for the command
obj.config.defaultOptions = {
[commName]: {
run: (options: CommandOptions) => ({ ...options, ...defaultOptions }),
},
};
// Run the command with default options
result = obj.run(commName, customOptions);
expect(result).toEqual({ ...customOptions, ...defaultOptions });
});
}); });
}); });

574
pnpm-lock.yaml

File diff suppressed because it is too large
Loading…
Cancel
Save