初始化模版工程
This commit is contained in:
167
components/html-editor/lib/core/moveableManager/events.ts
Normal file
167
components/html-editor/lib/core/moveableManager/events.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Moveable Events Handler
|
||||
* 拖拽与缩放事件处理逻辑
|
||||
*/
|
||||
import type Moveable from 'moveable';
|
||||
import type { HTMLEditor } from '../editor';
|
||||
import { createStyleChangeCommand } from '../historyManager/commands';
|
||||
|
||||
export class MoveableEventsHandler {
|
||||
private editor: HTMLEditor;
|
||||
|
||||
constructor(editor: HTMLEditor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定拖拽事件
|
||||
*/
|
||||
bindDragEvents(instance: Moveable) {
|
||||
let originalTransform: string | null = null;
|
||||
let originTransition : string | null = null;
|
||||
|
||||
instance.on('dragStart', ({ target, inputEvent }) => {
|
||||
const el = target as HTMLElement;
|
||||
originalTransform = el.style.transform || '';
|
||||
originTransition = el.style.transition || '';
|
||||
try {
|
||||
inputEvent?.preventDefault();
|
||||
} catch {}
|
||||
el.style.userSelect = 'none';
|
||||
el.style.transition = 'none';
|
||||
this.editor.setDragging(true);
|
||||
});
|
||||
|
||||
instance.on('drag', ({ target, transform }) => {
|
||||
const el = target as HTMLElement;
|
||||
el.style.transform = transform;
|
||||
this.editor.emit('styleChange', el, { transform });
|
||||
});
|
||||
|
||||
instance.on('dragEnd', ({ target }) => {
|
||||
const el = target as HTMLElement;
|
||||
// 记录历史
|
||||
if (this.editor.historyManager && originalTransform !== null) {
|
||||
const newTransform = el.style.transform || '';
|
||||
|
||||
if (originalTransform !== newTransform) {
|
||||
const command = createStyleChangeCommand(el, 'transform', originalTransform, newTransform);
|
||||
command.execute();
|
||||
this.editor.historyManager.push(command);
|
||||
}
|
||||
}
|
||||
el.style.transition = originTransition || '';
|
||||
this.editor.setDragging(false);
|
||||
this.editor.emit('contentChange');
|
||||
originalTransform = null;
|
||||
originTransition = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定缩放事件
|
||||
*/
|
||||
bindScaleEvents(instance: Moveable) {
|
||||
let originalTransform: string | null = null;
|
||||
|
||||
instance.on('scaleStart', (e) => {
|
||||
this.editor.setResizing(true);
|
||||
e.target.blur();
|
||||
// 记录初始状态
|
||||
const el = e.target as HTMLElement;
|
||||
originalTransform = el.style.transform || '';
|
||||
});
|
||||
|
||||
instance.on('scale', ({ target, transform, drag }) => {
|
||||
const el = target as HTMLElement;
|
||||
el.style.transform = drag.transform;
|
||||
this.editor.emit('styleChange', el, {
|
||||
transform: drag && drag.transform ? drag.transform : transform,
|
||||
});
|
||||
});
|
||||
|
||||
instance.on('scaleEnd', ({ target }) => {
|
||||
const el = target as HTMLElement;
|
||||
// 记录历史
|
||||
if (this.editor.historyManager && originalTransform !== null) {
|
||||
const newTransform = el.style.transform || '';
|
||||
|
||||
if (originalTransform !== newTransform) {
|
||||
const command = createStyleChangeCommand(el, 'transform', originalTransform, newTransform);
|
||||
command.execute();
|
||||
this.editor.historyManager.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
this.editor.setResizing(false);
|
||||
this.editor.emit('contentChange');
|
||||
originalTransform = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定缩放事件
|
||||
*/
|
||||
bindResizeEvents(instance: Moveable) {
|
||||
let originalSize = { width: '0px', height: '0px' };
|
||||
let originalTransform: string | null = '';
|
||||
let originTransition : string | null = null;
|
||||
instance.on('resizeStart', (e) => {
|
||||
const ele = e.target as HTMLElement;
|
||||
const style = window.getComputedStyle(ele);
|
||||
this.editor.setResizing(true);
|
||||
e.target.blur();
|
||||
// 记录初始状态
|
||||
originalTransform = e.target.style.transform || '';
|
||||
originTransition = e.target.style.transition || '';
|
||||
// 记录初始大小
|
||||
originalSize = {
|
||||
width: style.width,
|
||||
height: style.height,
|
||||
};
|
||||
})
|
||||
instance.on('resize', (e) => {
|
||||
const el = e.target as HTMLElement;
|
||||
el.style.width = e.width + 'px'
|
||||
el.style.height = e.height + 'px';
|
||||
el.style.transform = e.transform;
|
||||
this.editor.emit('styleChange', el, {
|
||||
transform: e && e.transform ? e.transform : '',
|
||||
});
|
||||
});
|
||||
|
||||
instance.on('resizeEnd', (e) => {
|
||||
const el = e.target as HTMLElement;
|
||||
this.editor.setResizing(false);
|
||||
this.editor.emit('contentChange');
|
||||
const newTransform = el.style.transform
|
||||
if (originalTransform !== newTransform) {
|
||||
//
|
||||
this.editor.historyManager?.beginBatch();
|
||||
const command = createStyleChangeCommand(el, 'transform', originalTransform, newTransform);
|
||||
command.execute();
|
||||
this.editor.historyManager?.push(command);
|
||||
const sizeCommand = createStyleChangeCommand(el, 'width', originalSize.width, el.style.width);
|
||||
sizeCommand.execute();
|
||||
this.editor.historyManager?.push(sizeCommand);
|
||||
const heightCommand = createStyleChangeCommand(el, 'height', originalSize.height, el.style.height);
|
||||
heightCommand.execute();
|
||||
this.editor.historyManager?.push(heightCommand);
|
||||
this.editor.historyManager?.endBatch();
|
||||
}
|
||||
|
||||
el.style.transition = originTransition || '';
|
||||
originalTransform = null;
|
||||
originTransition = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定所有事件
|
||||
*/
|
||||
bindAllEvents(instance: Moveable) {
|
||||
this.bindDragEvents(instance);
|
||||
this.bindScaleEvents(instance);
|
||||
this.bindResizeEvents(instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export class MoveableGuidelinesHandler {
|
||||
/**
|
||||
* 计算自动对齐参考线
|
||||
*/
|
||||
static calculateAutoGuidelines(
|
||||
element: HTMLElement,
|
||||
container: HTMLElement,
|
||||
elementGuidelinesOption: HTMLElement[] | undefined
|
||||
): HTMLElement[] {
|
||||
if (elementGuidelinesOption) {
|
||||
return elementGuidelinesOption;
|
||||
}
|
||||
|
||||
return Array.from(container.querySelectorAll<HTMLElement>('*')).filter((el) => {
|
||||
if (el === element) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
const visible =
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0;
|
||||
return visible;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算水平标尺线
|
||||
*/
|
||||
static calculateHorizontalGuidelines(
|
||||
container: HTMLElement,
|
||||
horizontalGuidelinesOption: number[] | undefined
|
||||
): number[] {
|
||||
if (horizontalGuidelinesOption) {
|
||||
return horizontalGuidelinesOption;
|
||||
}
|
||||
|
||||
return [
|
||||
0,
|
||||
Math.round(container.clientHeight / 2),
|
||||
container.clientHeight,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算垂直标尺线
|
||||
*/
|
||||
static calculateVerticalGuidelines(
|
||||
container: HTMLElement,
|
||||
verticalGuidelinesOption: number[] | undefined
|
||||
): number[] {
|
||||
if (verticalGuidelinesOption) {
|
||||
return verticalGuidelinesOption;
|
||||
}
|
||||
|
||||
return [
|
||||
0,
|
||||
Math.round(container.clientWidth / 2),
|
||||
container.clientWidth,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取容器元素
|
||||
*/
|
||||
static getContainer(
|
||||
element: HTMLElement,
|
||||
editorContainer: HTMLElement | null,
|
||||
snapContainerOption: HTMLElement | null
|
||||
): HTMLElement {
|
||||
const root = element.ownerDocument?.body || document.body;
|
||||
return snapContainerOption ?? (editorContainer || element.parentElement || root);
|
||||
}
|
||||
}
|
||||
193
components/html-editor/lib/core/moveableManager/index.ts
Normal file
193
components/html-editor/lib/core/moveableManager/index.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Moveable Manager
|
||||
* 实现选中元素的拖拽与四角缩放
|
||||
*/
|
||||
import Moveable from 'moveable';
|
||||
import { type HTMLEditor } from '../editor';
|
||||
import type { MoveableOptions } from '../../types';
|
||||
import { MoveableEventsHandler } from './events';
|
||||
import { MoveableGuidelinesHandler } from './guidelines';
|
||||
|
||||
export class MoveableManager {
|
||||
private editor: HTMLEditor;
|
||||
private instance: Moveable | null = null;
|
||||
private options: MoveableOptions;
|
||||
private eventsHandler: MoveableEventsHandler;
|
||||
|
||||
// 记录启用前的属性,便于恢复
|
||||
private originalState: {
|
||||
contenteditable?: string | null;
|
||||
userSelect?: string | null;
|
||||
transformOrigin?: string | null;
|
||||
} = {};
|
||||
|
||||
constructor(editor: HTMLEditor, options: MoveableOptions = {}) {
|
||||
this.editor = editor;
|
||||
this.eventsHandler = new MoveableEventsHandler(editor);
|
||||
this.options = {
|
||||
draggable: true,
|
||||
scalable: false,
|
||||
resizable: true,
|
||||
renderDirections: ['nw', 'ne', 'sw', 'se', 'n', 's', 'w', 'e'],
|
||||
keepRatio: false,
|
||||
throttleDrag: 0,
|
||||
throttleResize: 0,
|
||||
throttleScale: 0,
|
||||
// 默认开启吸附与标尺线
|
||||
snappable: true,
|
||||
snapCenter: true,
|
||||
snapThreshold: 5,
|
||||
snapGridWidth: undefined,
|
||||
snapGridHeight: undefined,
|
||||
snapContainer: null,
|
||||
elementGuidelines: undefined,
|
||||
horizontalGuidelines: undefined,
|
||||
verticalGuidelines: undefined,
|
||||
snapDirections: {
|
||||
left: true,
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
center: true,
|
||||
middle: true,
|
||||
},
|
||||
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
enableFor(element: HTMLElement, options?: Partial<MoveableOptions>): void {
|
||||
this.destroy();
|
||||
|
||||
// 启用前准备:禁用 contenteditable 与选择,避免拖拽被当作文本选择
|
||||
this.prepareElement(element);
|
||||
const mergedOptions: MoveableOptions = { ...this.options, ...(options || {}) };
|
||||
|
||||
// 获取容器元素
|
||||
const container = MoveableGuidelinesHandler.getContainer(
|
||||
element,
|
||||
this.editor.container,
|
||||
mergedOptions.snapContainer ?? null
|
||||
);
|
||||
|
||||
// 计算自动对齐参考线
|
||||
const autoGuidelines = MoveableGuidelinesHandler.calculateAutoGuidelines(
|
||||
element,
|
||||
container,
|
||||
mergedOptions.elementGuidelines
|
||||
);
|
||||
|
||||
// 计算水平标尺线
|
||||
const hGuides = MoveableGuidelinesHandler.calculateHorizontalGuidelines(
|
||||
container,
|
||||
mergedOptions.horizontalGuidelines
|
||||
);
|
||||
|
||||
// 计算垂直标尺线
|
||||
const vGuides = MoveableGuidelinesHandler.calculateVerticalGuidelines(
|
||||
container,
|
||||
mergedOptions.verticalGuidelines
|
||||
);
|
||||
|
||||
const root = element.ownerDocument?.body || document.body;
|
||||
|
||||
this.instance = new Moveable(root, {
|
||||
target: element,
|
||||
draggable: mergedOptions.draggable,
|
||||
scalable: mergedOptions.scalable,
|
||||
resizable: mergedOptions.resizable,
|
||||
edgeDraggable: true,
|
||||
checkInput: true,
|
||||
origin: false,
|
||||
|
||||
// 缩放手柄
|
||||
renderDirections: mergedOptions.renderDirections,
|
||||
keepRatio: mergedOptions.keepRatio,
|
||||
|
||||
// 性能相关
|
||||
throttleDrag: mergedOptions.throttleDrag,
|
||||
throttleScale: mergedOptions.throttleScale,
|
||||
|
||||
// 吸附与对齐线
|
||||
snappable: mergedOptions.snappable,
|
||||
snapContainer: container,
|
||||
elementGuidelines: autoGuidelines,
|
||||
horizontalGuidelines: hGuides,
|
||||
verticalGuidelines: vGuides,
|
||||
// 提高阈值,避免吸附过强导致"拖不动"的感觉
|
||||
snapThreshold: mergedOptions.snapThreshold ?? 10,
|
||||
snapGridWidth: mergedOptions.snapGridWidth,
|
||||
snapGridHeight: mergedOptions.snapGridHeight,
|
||||
snapDirections: mergedOptions.snapDirections,
|
||||
});
|
||||
|
||||
// 绑定拖拽和缩放事件
|
||||
this.eventsHandler.bindAllEvents(this.instance);
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.instance) {
|
||||
this.instance.updateRect();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.instance) {
|
||||
this.instance.destroy();
|
||||
this.instance = null;
|
||||
}
|
||||
// 恢复元素的原始状态
|
||||
const el = this.editor.selectedElement;
|
||||
if (el) {
|
||||
this.restoreElement(el);
|
||||
}
|
||||
}
|
||||
|
||||
private prepareElement(element: HTMLElement) {
|
||||
// 保存原始状态
|
||||
this.originalState.contenteditable =
|
||||
element.getAttribute('contenteditable');
|
||||
this.originalState.userSelect = element.style.userSelect || null;
|
||||
this.originalState.transformOrigin = element.style.transformOrigin || null;
|
||||
|
||||
element.setAttribute('contenteditable', 'false');
|
||||
element.style.userSelect = 'none';
|
||||
(element.style as any).touchAction = 'none';
|
||||
}
|
||||
|
||||
private restoreElement(element: HTMLElement) {
|
||||
// 恢复 contenteditable
|
||||
if (this.originalState.contenteditable != null) {
|
||||
if (this.originalState.contenteditable === '') {
|
||||
element.removeAttribute('contenteditable');
|
||||
} else {
|
||||
element.setAttribute(
|
||||
'contenteditable',
|
||||
this.originalState.contenteditable
|
||||
);
|
||||
}
|
||||
} else {
|
||||
element.removeAttribute('contenteditable');
|
||||
}
|
||||
|
||||
// 恢复 user-select
|
||||
if (this.originalState.userSelect != null) {
|
||||
element.style.userSelect = this.originalState.userSelect || '';
|
||||
} else {
|
||||
element.style.removeProperty('user-select');
|
||||
}
|
||||
|
||||
// 恢复 transform-origin
|
||||
if (this.originalState.transformOrigin != null) {
|
||||
element.style.transformOrigin = this.originalState.transformOrigin || '';
|
||||
} else {
|
||||
element.style.removeProperty('transform-origin');
|
||||
}
|
||||
|
||||
// 清理 will-change
|
||||
element.style.removeProperty('will-change');
|
||||
|
||||
// 清空记录
|
||||
this.originalState = {};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user