初始化模版工程
This commit is contained in:
754
components/html-editor/lib/core/editor/index.ts
Normal file
754
components/html-editor/lib/core/editor/index.ts
Normal file
@@ -0,0 +1,754 @@
|
||||
/**
|
||||
* HTML Visual Editor Core Class
|
||||
* 核心编辑器类,提供基础的编辑功能
|
||||
*/
|
||||
import { EventManager } from '../eventManager'
|
||||
import StyleManager from '../styleManager';
|
||||
import { MoveableManager } from '../moveableManager';
|
||||
import { HistoryManager } from '../historyManager';
|
||||
import {
|
||||
createElementAddCommand,
|
||||
createElementDeleteCommand,
|
||||
createAttributeChangeCommand,
|
||||
createStyleChangeCommand,
|
||||
createContentChangeCommand
|
||||
} from '../historyManager/commands';
|
||||
import { defaultStyleConfig, generateEditorCSS } from '../../config/styles';
|
||||
import type { HTMLEditorOptions, Position, EditorStyleConfig } from '../../types';
|
||||
import { createElement, elementWatcher, getElementType, isImageElement, isInlineElement, isTableElement } from '../utils';
|
||||
import EditorRegistry from '../editorRegistry';
|
||||
import { HelperBoxManager } from '../helperBoxManager';
|
||||
import GlobalEditable from '../globalEditable';
|
||||
|
||||
|
||||
|
||||
export class HTMLEditor {
|
||||
id: string;
|
||||
options: HTMLEditorOptions;
|
||||
selectedElement: HTMLElement | null;
|
||||
|
||||
eventManager: EventManager | null;
|
||||
styleManager: StyleManager | null;
|
||||
moveableManager: MoveableManager | null;
|
||||
historyManager: HistoryManager | null;
|
||||
helperBoxManager: HelperBoxManager | null;
|
||||
container: HTMLElement | null;
|
||||
EditorRegistry: typeof EditorRegistry;
|
||||
elementWatcher!: ReturnType<typeof elementWatcher>;
|
||||
globalEditable: GlobalEditable | null;
|
||||
|
||||
// 操作状态
|
||||
isDragging: boolean = false;
|
||||
isResizing: boolean = false;
|
||||
isChangingBackground: boolean = false;
|
||||
isInsertMode: boolean = false;
|
||||
isChangingColor: boolean = false;
|
||||
isIframe: boolean;
|
||||
__globalEditHandlers: { handleInput?: (e: Event) => void; handleKeyDown?: (e: KeyboardEvent) => void } | null = null;
|
||||
suppressBodyInputRecord: boolean = false;
|
||||
|
||||
constructor(options: HTMLEditorOptions) {
|
||||
this.options = {
|
||||
container: null,
|
||||
theme: 'default',
|
||||
autoSave: false,
|
||||
styleConfig: defaultStyleConfig,
|
||||
enableContentEditable: true, // 默认启用
|
||||
enableMoveable: false, // 默认启用拖拽与缩放
|
||||
enableHistory: true, // 默认启用历史记录
|
||||
historyOptions: {
|
||||
maxHistorySize: 100,
|
||||
mergeInterval: 1000,
|
||||
},
|
||||
onElementSelect: null,
|
||||
onStyleChange: null,
|
||||
onContentChange: null,
|
||||
onReady: null,
|
||||
onHistoryChange: null,
|
||||
ignoreSelectTags: ['body', 'html'],
|
||||
enableGlobalContentEditable: false,
|
||||
...options
|
||||
};
|
||||
|
||||
// 合并用户自定义样式配置
|
||||
if (options.styleConfig) {
|
||||
this.options.styleConfig = {
|
||||
...defaultStyleConfig,
|
||||
hover: { ...defaultStyleConfig.hover, ...options.styleConfig.hover },
|
||||
selected: { ...defaultStyleConfig.selected, ...options.styleConfig.selected },
|
||||
badge: { ...defaultStyleConfig.badge, ...options.styleConfig.badge },
|
||||
};
|
||||
}
|
||||
|
||||
// 合并历史记录配置
|
||||
if (options.historyOptions) {
|
||||
this.options.historyOptions = {
|
||||
...this.options.historyOptions,
|
||||
...options.historyOptions,
|
||||
};
|
||||
}
|
||||
|
||||
this.id = this.options.id;
|
||||
this.selectedElement = null;
|
||||
|
||||
this.eventManager = null;
|
||||
this.styleManager = null;
|
||||
this.moveableManager = null;
|
||||
this.historyManager = null;
|
||||
this.helperBoxManager = null;
|
||||
this.container = null;
|
||||
this.EditorRegistry = EditorRegistry;
|
||||
this.isIframe = false;
|
||||
this.__globalEditHandlers = null as any;
|
||||
this.globalEditable = null;
|
||||
}
|
||||
|
||||
init(container?: HTMLElement | string): void {
|
||||
if (container) {
|
||||
this.options.container = container;
|
||||
}
|
||||
|
||||
this.setupContainer();
|
||||
// 注册到全局编辑器注册表
|
||||
this.EditorRegistry.register(this);
|
||||
this.initializeManagers();
|
||||
this.globalEditable = new GlobalEditable(this);
|
||||
this.setGlobalContentEditableEnabled(!!this.options.enableGlobalContentEditable);
|
||||
this.bindEvents();
|
||||
this.elementWatcher = elementWatcher(this);
|
||||
if (this.options.helperBox) {
|
||||
this.helperBoxManager?.init();
|
||||
}
|
||||
this.emit('ready');
|
||||
}
|
||||
|
||||
setupContainer(): void {
|
||||
const container = typeof this.options.container === 'string'
|
||||
? document.querySelector<HTMLElement>(this.options.container)
|
||||
: this.options.container;
|
||||
|
||||
if (!container) {
|
||||
throw new Error('Container not found');
|
||||
}
|
||||
|
||||
this.container = container;
|
||||
this.container.classList.add('html-visual-editor');
|
||||
// 检测container是否是iframe
|
||||
this.detectIframe();
|
||||
|
||||
// 注入编辑器样式
|
||||
this.injectStyles();
|
||||
}
|
||||
|
||||
// 获取当前的window/document
|
||||
getDoc() {
|
||||
const container = this.container;
|
||||
return {
|
||||
view: container ? container.ownerDocument.defaultView : window,
|
||||
document: container ? container.ownerDocument : document
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查container是否是iframe中
|
||||
*/
|
||||
detectIframe(): void {
|
||||
// 检查container是否在iframe中
|
||||
if (this.container && this.getDoc().document !== document) {
|
||||
this.isIframe = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入编辑器样式到文档中
|
||||
*/
|
||||
injectStyles(): void {
|
||||
if (!this.container) return;
|
||||
|
||||
const doc = this.getDoc().document;
|
||||
const styleId = 'html-editor-styles';
|
||||
|
||||
// 检查是否已经注入过样式
|
||||
const oldStyleElement = doc.getElementById(styleId);
|
||||
if (oldStyleElement) {
|
||||
doc.head.removeChild(doc.getElementById(styleId) as Node);
|
||||
};
|
||||
|
||||
const styleElement = doc.createElement('style');
|
||||
styleElement.id = styleId;
|
||||
styleElement.textContent = generateEditorCSS(this.options.styleConfig as EditorStyleConfig, this.options.enableMoveable, this.options.helperBox);
|
||||
doc.head.appendChild(styleElement);
|
||||
}
|
||||
|
||||
initializeManagers(): void {
|
||||
this.eventManager = new EventManager(this);
|
||||
this.styleManager = new StyleManager(this);
|
||||
this.moveableManager = new MoveableManager(this, (this.options as any).moveableOptions ?? {});
|
||||
this.helperBoxManager = new HelperBoxManager(this);
|
||||
|
||||
// 初始化历史管理器
|
||||
if (this.options.enableHistory !== false) {
|
||||
this.historyManager = new HistoryManager(this, this.options.historyOptions);
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents(): void {
|
||||
if (this.eventManager) {
|
||||
this.eventManager.bindAll();
|
||||
}
|
||||
}
|
||||
|
||||
// 元素选择
|
||||
selectElement(element: HTMLElement): void {
|
||||
if (this.isInsertMode) return;
|
||||
const lastSelectedElement = this.selectedElement;
|
||||
// 清除上一个选择
|
||||
this.clearSelection();
|
||||
|
||||
element.classList.add('selected-element');
|
||||
element.setAttribute('data-element-type', getElementType(element));
|
||||
if (isInlineElement(element)) {
|
||||
// 如果元素是内联元素,设置为 inline-block,解决moveable无法拖拽的问题
|
||||
element.style.display = 'inline-block';
|
||||
element.setAttribute('original-display', 'inline');
|
||||
}
|
||||
this.selectedElement = element;
|
||||
const isTable = isTableElement(element);
|
||||
// 启用 moveable
|
||||
if (this.options.enableMoveable && this.moveableManager) {
|
||||
const defaultMoveableOptions = (this.options as any).moveableOptions ?? {};
|
||||
const keepRatio = isImageElement(element) ? true : (defaultMoveableOptions.keepRatio ?? false);
|
||||
if (!isTable) {
|
||||
element.style.cursor = 'move';
|
||||
this.moveableManager.enableFor(element, { keepRatio });
|
||||
}
|
||||
}
|
||||
|
||||
// 如果元素不再是 contenteditable,重新启用编辑
|
||||
if (this.options.enableContentEditable && element.getAttribute('contenteditable') !== 'true') {
|
||||
if (isTable) {
|
||||
this.enableElementEditing(element);
|
||||
}
|
||||
// 如果是同一个元素,检查是否需要重新启用编辑
|
||||
if (element === lastSelectedElement) {
|
||||
this.enableElementEditing(element);
|
||||
}
|
||||
}
|
||||
|
||||
const position = this.getBoundPostion(element);
|
||||
this.emit('elementSelect', element, position);
|
||||
this.elementWatcher.start(element, () => {
|
||||
this.emit('styleChange', element);
|
||||
this.moveableManager?.update();
|
||||
});
|
||||
|
||||
// 如果启用了 helperBox,则更新其位置
|
||||
if (this.options.helperBox && this.helperBoxManager) {
|
||||
this.helperBoxManager.updatePostion(position);
|
||||
this.helperBoxManager.visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素的边界相对位置
|
||||
*/
|
||||
getBoundPostion(target: HTMLElement) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const containerRect = this.container!.getBoundingClientRect();
|
||||
const position: Position = {
|
||||
top: this.isIframe ? rect.top : rect.top - containerRect.top,
|
||||
left: this.isIframe ? rect.left : rect.left - containerRect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
bottom: this.isIframe ? rect.bottom : rect.bottom - containerRect.top,
|
||||
right: this.isIframe ? rect.right : rect.right - containerRect.left
|
||||
};
|
||||
return position
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用元素编辑
|
||||
*/
|
||||
enableElementEditing(element: HTMLElement): void {
|
||||
const existingHandlers = (element as any).__editHandlers;
|
||||
if (existingHandlers) {
|
||||
this.removeEditListeners(element);
|
||||
}
|
||||
|
||||
// 保存原始的contenteditable状态
|
||||
if (!element.hasAttribute('data-original-contenteditable')) {
|
||||
const originalValue = element.getAttribute('contenteditable') || 'inherit';
|
||||
element.setAttribute('data-original-contenteditable', originalValue);
|
||||
}
|
||||
|
||||
document.execCommand('defaultParagraphSeparator', false, 'br');
|
||||
// 设置为可编辑
|
||||
element.setAttribute('contenteditable', 'true');
|
||||
|
||||
element.focus();
|
||||
|
||||
const initialContent = element.innerHTML;
|
||||
let lastRecordedContent = initialContent;
|
||||
const handleInput = () => {
|
||||
const newContent = element.innerHTML;
|
||||
if (this.historyManager && newContent !== lastRecordedContent) {
|
||||
const cmd = createContentChangeCommand(element, lastRecordedContent, newContent);
|
||||
this.historyManager.push(cmd);
|
||||
lastRecordedContent = newContent;
|
||||
}
|
||||
this.emit('contentChange');
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// 失去焦点时禁用contenteditable
|
||||
this.disableElementEditing(element);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const selection = this.isIframe ? this.getDoc().document.getSelection() : window.getSelection()
|
||||
if (!selection || !selection.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
const br = document.createElement('br');
|
||||
range.insertNode(br);
|
||||
// 光标移动到 <br> 之后
|
||||
range.setStartAfter(br);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
const newContent = element.innerHTML;
|
||||
if (this.historyManager && newContent !== lastRecordedContent) {
|
||||
const cmd = createContentChangeCommand(element, lastRecordedContent, newContent);
|
||||
this.historyManager.push(cmd);
|
||||
lastRecordedContent = newContent;
|
||||
}
|
||||
this.emit('contentChange');
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener('input', handleInput);
|
||||
element.addEventListener('blur', handleBlur);
|
||||
element.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// 保存事件处理器引用,便于后续清理
|
||||
(element as any).__editHandlers = { handleInput, handleBlur, handleKeyDown };
|
||||
}
|
||||
|
||||
private removeEditListeners(element: HTMLElement): void {
|
||||
const handlers = (element as any).__editHandlers;
|
||||
if (!handlers) return;
|
||||
const pairs: Array<[string, EventListener | undefined]> = [
|
||||
['input', handlers.handleInput],
|
||||
['blur', handlers.handleBlur],
|
||||
['keydown', handlers.handleKeyDown],
|
||||
];
|
||||
for (const [type, fn] of pairs) {
|
||||
if (fn) element.removeEventListener(type, fn as EventListener);
|
||||
}
|
||||
delete (element as any).__editHandlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用元素编辑
|
||||
*/
|
||||
disableElementEditing(element: HTMLElement): void {
|
||||
// 恢复原始的contenteditable状态
|
||||
const originalValue = element.getAttribute('data-original-contenteditable');
|
||||
if (originalValue) {
|
||||
if (originalValue === 'inherit') {
|
||||
element.removeAttribute('contenteditable');
|
||||
} else {
|
||||
element.setAttribute('contenteditable', originalValue);
|
||||
}
|
||||
element.removeAttribute('data-original-contenteditable');
|
||||
} else {
|
||||
element.removeAttribute('contenteditable');
|
||||
}
|
||||
|
||||
this.removeEditListeners(element);
|
||||
}
|
||||
|
||||
setInsertMode(value: boolean): void {
|
||||
this.isInsertMode = value;
|
||||
if (this.container) {
|
||||
if (value) {
|
||||
this.container.style.cursor = this.isIframe ? 'crosshair' : 'text';
|
||||
} else {
|
||||
this.container.style.cursor = '';
|
||||
}
|
||||
if (value) {
|
||||
const doc = this.getDoc().document;
|
||||
doc.querySelectorAll('.hover-highlight').forEach((el: Element) => {
|
||||
(el as HTMLElement).classList.remove('hover-highlight');
|
||||
(el as HTMLElement).removeAttribute('data-element-type');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enableInsertMode(): void {
|
||||
this.EditorRegistry.enableInsertMode(this);
|
||||
}
|
||||
|
||||
disableInsertMode(): void {
|
||||
this.EditorRegistry.disableAllInsertMode();
|
||||
}
|
||||
|
||||
insertTextAtPosition(clientX: number, clientY: number): HTMLElement | null {
|
||||
if (!this.container) return null;
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.textContent = '请输入文字';
|
||||
element.style.position = 'absolute';
|
||||
element.style.left = `${Math.max(0, Math.round(x))}px`;
|
||||
element.style.top = `${Math.max(0, Math.round(y))}px`;
|
||||
element.style.fontSize = '32px';
|
||||
element.style.lineHeight = '1.2';
|
||||
element.style.backgroundColor = 'transparent';
|
||||
element.style.border = 'none';
|
||||
element.style.padding = '0';
|
||||
element.style.margin = '0';
|
||||
|
||||
if (this.historyManager) {
|
||||
const command = createElementAddCommand(element, (this.selectedElement ?? this.container!) as HTMLElement, this.container!, null);
|
||||
command.execute();
|
||||
this.historyManager.push(command);
|
||||
} else {
|
||||
this.container.appendChild(element);
|
||||
}
|
||||
|
||||
this.disableInsertMode();
|
||||
this.selectElement(element);
|
||||
// this.enableElementEditing(element);
|
||||
this.emit('contentChange');
|
||||
return element;
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
if (this.selectedElement) {
|
||||
this.elementWatcher.stop(this.selectedElement);
|
||||
this.selectedElement.classList.remove('selected-element');
|
||||
this.selectedElement.removeAttribute('data-element-type');
|
||||
if (this.selectedElement.getAttribute('original-display') === 'inline') {
|
||||
// 如果元素是内联元素,设置为 inline-block,解决moveable无法拖拽的问题
|
||||
this.selectedElement.style.display = 'inline';
|
||||
this.selectedElement.removeAttribute('original-display');
|
||||
}
|
||||
// 禁用编辑功能
|
||||
if (this.options.enableContentEditable) {
|
||||
this.disableElementEditing(this.selectedElement);
|
||||
}
|
||||
// 销毁 moveable
|
||||
if (this.moveableManager) {
|
||||
this.moveableManager.destroy();
|
||||
this.selectedElement.style.cursor = '';
|
||||
}
|
||||
this.selectedElement = null;
|
||||
this.emit('elementSelect', null);
|
||||
}
|
||||
// 清除主文档中的hover样式
|
||||
document.querySelectorAll('.hover-highlight').forEach(el => {
|
||||
el.classList.remove('hover-highlight');
|
||||
el.removeAttribute('data-element-type');
|
||||
});
|
||||
// 如果在iframe中,也清除iframe文档中的样式
|
||||
if (this.container) {
|
||||
const ownerDoc = this.getDoc().document
|
||||
if (ownerDoc !== document) {
|
||||
ownerDoc.querySelectorAll('.hover-highlight').forEach(el => {
|
||||
el.classList.remove('hover-highlight');
|
||||
el.removeAttribute('data-element-type');
|
||||
});
|
||||
ownerDoc.querySelectorAll('.selected-element').forEach(el => {
|
||||
el.classList.remove('selected-element');
|
||||
el.removeAttribute('data-element-type');
|
||||
});
|
||||
}
|
||||
}
|
||||
// 如果启用了 helperBox,则隐藏
|
||||
if (this.options.helperBox && this.helperBoxManager) {
|
||||
this.helperBoxManager.visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启/关闭全局 contenteditable 模式
|
||||
*/
|
||||
setGlobalContentEditableEnabled(enabled: boolean): void {
|
||||
this.globalEditable?.setEnabled(enabled);
|
||||
}
|
||||
|
||||
// 样式应用
|
||||
applyTextStyle(property: string, value: string): boolean {
|
||||
if (!this.styleManager) return false;
|
||||
return this.styleManager.applyTextStyle(property, value);
|
||||
}
|
||||
|
||||
applyBlockStyle(property: string, value: string): boolean {
|
||||
if (!this.styleManager) return false;
|
||||
return this.styleManager.applyBlockStyle(property, value);
|
||||
}
|
||||
|
||||
// 元素操作
|
||||
addElement(selectedElement: HTMLElement, type: string, content: string = ''): HTMLElement {
|
||||
const element = createElement(type, content);
|
||||
if (this.container) {
|
||||
// 记录添加操作
|
||||
if (this.historyManager) {
|
||||
const command = createElementAddCommand(element, selectedElement, this.container, null);
|
||||
command.execute();
|
||||
this.historyManager.push(command);
|
||||
} else {
|
||||
this.container.appendChild(element);
|
||||
}
|
||||
}
|
||||
this.selectElement(element);
|
||||
this.emit('contentChange');
|
||||
return element;
|
||||
}
|
||||
|
||||
deleteElement(element: HTMLElement | null = this.selectedElement): boolean {
|
||||
if (!element || element === this.container) return false;
|
||||
|
||||
const parent = element.parentElement;
|
||||
const nextSibling = element.nextSibling as HTMLElement | null;
|
||||
|
||||
if (!parent) return false;
|
||||
|
||||
// 记录删除操作
|
||||
if (this.historyManager) {
|
||||
const command = createElementDeleteCommand(element, parent, nextSibling);
|
||||
command.execute();
|
||||
this.historyManager.push(command);
|
||||
} else {
|
||||
element.remove();
|
||||
}
|
||||
|
||||
this.clearSelection();
|
||||
this.emit('contentChange');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 复制元素并插入到当前元素的同级下方
|
||||
copyElement(element: HTMLElement | null = this.selectedElement): HTMLElement | null {
|
||||
if (!element || element === this.container) return null;
|
||||
|
||||
const parent = element.parentElement;
|
||||
const nextSibling = element.nextSibling as HTMLElement | null;
|
||||
if (!parent) return null;
|
||||
|
||||
// 深拷贝节点,包括子元素与样式
|
||||
const cloned = element.cloneNode(true) as HTMLElement;
|
||||
|
||||
// 清理编辑器相关状态类与属性
|
||||
cloned.classList.remove('selected-element', 'hover-highlight');
|
||||
cloned.removeAttribute('data-element-type');
|
||||
|
||||
if (this.historyManager) {
|
||||
const command = createElementAddCommand(cloned, element, parent, nextSibling);
|
||||
command.execute();
|
||||
this.historyManager.push(command);
|
||||
} else {
|
||||
parent.insertBefore(cloned, nextSibling);
|
||||
}
|
||||
|
||||
// 选中新复制的元素
|
||||
this.selectElement(cloned);
|
||||
this.emit('contentChange');
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换图片/背景图为远程 URL
|
||||
*/
|
||||
replaceImage(element: HTMLElement | null = this.selectedElement, url: string): boolean {
|
||||
if (!element || !url) return false;
|
||||
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (tag === 'img') {
|
||||
const oldSrc = element.getAttribute('src');
|
||||
const newSrc = url;
|
||||
if (this.historyManager) {
|
||||
const cmd = createAttributeChangeCommand(element, 'src', oldSrc, newSrc);
|
||||
cmd.execute();
|
||||
this.historyManager.push(cmd);
|
||||
} else {
|
||||
element.setAttribute('src', newSrc);
|
||||
}
|
||||
} else {
|
||||
const oldBg = element.style.backgroundImage || '';
|
||||
const newBg = `url(${url})`;
|
||||
if (this.historyManager) {
|
||||
const cmd = createStyleChangeCommand(element, 'background-image', oldBg, newBg);
|
||||
cmd.execute();
|
||||
this.historyManager.push(cmd);
|
||||
} else {
|
||||
element.style.backgroundImage = newBg;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('contentChange');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 事件系统
|
||||
emit(eventName: string, ...args: any[]): void {
|
||||
const callbackName = `on${eventName.charAt(0).toUpperCase() + eventName.slice(1)}` as keyof HTMLEditorOptions;
|
||||
const callback = this.options[callbackName];
|
||||
if (typeof callback === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
(callback as Function).apply(this, args);
|
||||
}
|
||||
|
||||
// 触发自定义事件
|
||||
const event = new CustomEvent(`htmleditor:${eventName}`, {
|
||||
detail: { editor: this, args }
|
||||
});
|
||||
if (this.container) {
|
||||
this.container.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// 公共API
|
||||
getContent(): string {
|
||||
return this.container ? this.container.innerHTML : '';
|
||||
}
|
||||
|
||||
setContent(html: string): void {
|
||||
if (this.container) {
|
||||
this.container.innerHTML = html;
|
||||
this.emit('contentChange');
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedElement(): HTMLElement | null {
|
||||
return this.selectedElement;
|
||||
}
|
||||
|
||||
// 操作状态管理
|
||||
setDragging(value: boolean): void {
|
||||
this.isDragging = value;
|
||||
}
|
||||
|
||||
setResizing(value: boolean): void {
|
||||
this.isResizing = value;
|
||||
}
|
||||
|
||||
setChangingBackground(value: boolean): void {
|
||||
this.isChangingBackground = value;
|
||||
}
|
||||
|
||||
setChangingColor(value: boolean): void {
|
||||
this.isChangingColor = value;
|
||||
}
|
||||
/**
|
||||
* 检查是否有任何操作正在进行
|
||||
*/
|
||||
isOperating(): boolean {
|
||||
return (
|
||||
this.isDragging ||
|
||||
this.isResizing ||
|
||||
this.isChangingBackground ||
|
||||
this.isChangingColor
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有操作状态
|
||||
*/
|
||||
resetOperationStates(): void {
|
||||
this.isDragging = false;
|
||||
this.isResizing = false;
|
||||
this.isChangingBackground = false;
|
||||
this.isChangingColor = false;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 历史记录相关 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 撤销上一个操作
|
||||
*/
|
||||
undo(): boolean {
|
||||
return this.historyManager?.undo() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做已撤销的操作
|
||||
*/
|
||||
redo(): boolean {
|
||||
return this.historyManager?.redo() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以撤销
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
return this.historyManager?.canUndo() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以重做
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
return this.historyManager?.canRedo() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始批量操作
|
||||
*/
|
||||
beginBatch(): void {
|
||||
this.historyManager?.beginBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束批量操作
|
||||
*/
|
||||
endBatch(): void {
|
||||
this.historyManager?.endBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消批量操作
|
||||
*/
|
||||
cancelBatch(): void {
|
||||
this.historyManager?.cancelBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空历史记录
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.historyManager?.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史状态
|
||||
*/
|
||||
getHistoryState() {
|
||||
return this.historyManager?.getState();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.eventManager) {
|
||||
this.eventManager.unbindAll();
|
||||
}
|
||||
if (this.moveableManager) {
|
||||
this.moveableManager.destroy();
|
||||
}
|
||||
if (this.historyManager) {
|
||||
this.historyManager.destroy();
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.classList.remove('html-visual-editor');
|
||||
}
|
||||
this.container = null;
|
||||
// 从注册表中移除
|
||||
this.EditorRegistry.unregister(this);
|
||||
}
|
||||
}
|
||||
122
components/html-editor/lib/core/editorRegistry/index.ts
Normal file
122
components/html-editor/lib/core/editorRegistry/index.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { HTMLEditor } from '../editor';
|
||||
|
||||
const editors = new Set<HTMLEditor>();
|
||||
let lastActiveEditor: HTMLEditor | null = null;
|
||||
|
||||
export const EditorRegistry = {
|
||||
register(editor: HTMLEditor) {
|
||||
editors.add(editor);
|
||||
if (!lastActiveEditor) {
|
||||
lastActiveEditor = editor;
|
||||
}
|
||||
},
|
||||
unregister(editor: HTMLEditor) {
|
||||
editors.delete(editor);
|
||||
if (lastActiveEditor === editor) {
|
||||
lastActiveEditor = null;
|
||||
}
|
||||
},
|
||||
clearOthers(current: HTMLEditor) {
|
||||
editors.forEach(ed => {
|
||||
if (ed !== current) {
|
||||
ed.clearSelection();
|
||||
if (ed.isInsertMode) {
|
||||
ed.disableInsertMode();
|
||||
}
|
||||
}
|
||||
});
|
||||
lastActiveEditor = current;
|
||||
},
|
||||
getAll() {
|
||||
return Array.from(editors);
|
||||
},
|
||||
|
||||
hasActiveEditor() {
|
||||
return Array.from(editors).some(ed => ed.selectedElement);
|
||||
},
|
||||
getActiveEditor(): HTMLEditor | null {
|
||||
const activeBySelection = Array.from(editors).find(ed => ed.selectedElement);
|
||||
if (activeBySelection) return activeBySelection;
|
||||
return lastActiveEditor;
|
||||
},
|
||||
getGlobalUndoTarget(): HTMLEditor | null {
|
||||
let target: HTMLEditor | null = null;
|
||||
let maxTs = -1;
|
||||
editors.forEach(ed => {
|
||||
const hm = ed.historyManager;
|
||||
const ts = hm?.getTopUndoTimestamp?.();
|
||||
if (typeof ts === 'number' && hm?.canUndo?.()) {
|
||||
if (ts > maxTs) {
|
||||
maxTs = ts;
|
||||
target = ed;
|
||||
}
|
||||
}
|
||||
});
|
||||
return target;
|
||||
},
|
||||
getGlobalRedoTarget(): HTMLEditor | null {
|
||||
let target: HTMLEditor | null = null;
|
||||
let maxTs = -1;
|
||||
editors.forEach(ed => {
|
||||
const hm = ed.historyManager;
|
||||
const ts = hm?.getTopRedoTimestamp?.();
|
||||
if (typeof ts === 'number' && hm?.canRedo?.()) {
|
||||
if (ts > maxTs) {
|
||||
maxTs = ts;
|
||||
target = ed;
|
||||
}
|
||||
}
|
||||
});
|
||||
return target;
|
||||
},
|
||||
undo(): boolean {
|
||||
const target = this.getGlobalUndoTarget();
|
||||
return target ? target.undo() : false;
|
||||
},
|
||||
redo(): boolean {
|
||||
const target = this.getGlobalRedoTarget();
|
||||
return target ? target.redo() : false;
|
||||
},
|
||||
canUndo(): boolean {
|
||||
return Array.from(editors).some(ed => ed.historyManager?.canUndo());
|
||||
},
|
||||
canRedo(): boolean {
|
||||
return Array.from(editors).some(ed => ed.historyManager?.canRedo());
|
||||
},
|
||||
|
||||
enableInsertMode(target: HTMLEditor): void {
|
||||
editors.forEach(ed => {
|
||||
if (ed === target) {
|
||||
ed.setInsertMode(true);
|
||||
} else if (ed.isInsertMode) {
|
||||
ed.setInsertMode(false);
|
||||
}
|
||||
});
|
||||
lastActiveEditor = target;
|
||||
},
|
||||
|
||||
disableAllInsertMode(): void {
|
||||
editors.forEach(ed => {
|
||||
if (ed.isInsertMode) ed.setInsertMode(false);
|
||||
});
|
||||
},
|
||||
|
||||
isAnyInInsertMode(): boolean {
|
||||
return Array.from(editors).some(ed => ed.isInsertMode);
|
||||
},
|
||||
|
||||
getInsertModeEditors(): HTMLEditor[] {
|
||||
return Array.from(editors).filter(ed => ed.isInsertMode);
|
||||
}
|
||||
,
|
||||
setGlobalContentEditable(enabled: boolean): void {
|
||||
editors.forEach(ed => {
|
||||
ed.setGlobalContentEditableEnabled(enabled);
|
||||
if (enabled) {
|
||||
ed.clearSelection();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default EditorRegistry;
|
||||
202
components/html-editor/lib/core/eventManager/index.ts
Normal file
202
components/html-editor/lib/core/eventManager/index.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Event Manager
|
||||
* 事件管理器,处理所有DOM事件
|
||||
*/
|
||||
|
||||
import { type HTMLEditor } from '../editor';
|
||||
import { getElementType } from '../utils';
|
||||
|
||||
type EventHandler = (e: Event) => void;
|
||||
|
||||
export class EventManager {
|
||||
private editor: HTMLEditor;
|
||||
private boundHandlers: Map<string, EventHandler>;
|
||||
|
||||
|
||||
constructor(editor: HTMLEditor) {
|
||||
this.editor = editor;
|
||||
this.boundHandlers = new Map<string, EventHandler>();
|
||||
}
|
||||
|
||||
bindAll(): void {
|
||||
this.bindHoverEvents();
|
||||
this.bindClickEvents();
|
||||
this.bindDocumentEvents();
|
||||
}
|
||||
|
||||
|
||||
|
||||
bindHoverEvents(): void {
|
||||
const highlightTracker = this.editor.helperBoxManager!.createHighlightTracker();
|
||||
const handleMouseOver = (e: Event) => {
|
||||
if (this.editor.globalEditable?.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (this.editor.isInsertMode) {
|
||||
return;
|
||||
}
|
||||
// 如果正在进行拖动、缩放等操作,不处理 hover
|
||||
if (this.editor.isOperating()) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
const target = e.target as HTMLElement;
|
||||
const targetTagName = target.tagName.toLowerCase();
|
||||
// 如果包含在忽略的标签中,不处理
|
||||
if ((this.editor.options.ignoreSelectTags || []).includes(targetTagName)) return;
|
||||
|
||||
if (target.classList.contains('selected-element') || target.classList.contains('moveable-line')) return;
|
||||
|
||||
// 先清除容器内所有非选中元素的hover样式
|
||||
if (this.editor.container) {
|
||||
const doc = this.editor.getDoc().document;
|
||||
doc.querySelectorAll('.hover-highlight').forEach((el: Element) => {
|
||||
if (!el.classList.contains('selected-element')) {
|
||||
el.classList.remove('hover-highlight');
|
||||
el.removeAttribute('data-element-type');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
target.classList.add('hover-highlight');
|
||||
target.setAttribute('data-element-type', getElementType(target));
|
||||
|
||||
if (this.editor.options.helperBox && this.editor.helperBoxManager && this.editor.container) {
|
||||
highlightTracker.start(target);
|
||||
// const position = this.editor.getBoundPostion(target);
|
||||
// this.editor.helperBoxManager.updatePostion(position);
|
||||
this.editor.helperBoxManager.visible(!(this.editor.selectedElement === target));
|
||||
}
|
||||
|
||||
this.editor.emit('hover', target);
|
||||
};
|
||||
|
||||
const handleMouseOut = (e: Event) => {
|
||||
if (this.editor.globalEditable?.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (this.editor.isInsertMode) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.classList.contains('selected-element')) {
|
||||
target.classList.remove('hover-highlight');
|
||||
target.removeAttribute('data-element-type');
|
||||
}
|
||||
|
||||
// 如果启用了 helperBox,则隐藏
|
||||
if (this.editor.options.helperBox && this.editor.helperBoxManager) {
|
||||
highlightTracker.stop(target);
|
||||
this.editor.helperBoxManager.visible(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (this.editor.container) {
|
||||
this.editor.container.addEventListener('mouseover', handleMouseOver);
|
||||
this.editor.container.addEventListener('mouseout', handleMouseOut);
|
||||
|
||||
this.boundHandlers.set('mouseover', handleMouseOver);
|
||||
this.boundHandlers.set('mouseout', handleMouseOut);
|
||||
}
|
||||
}
|
||||
|
||||
bindClickEvents(): void {
|
||||
const handleClick = (e: Event) => {
|
||||
if (this.editor.globalEditable?.isEnabled()) {
|
||||
// 在全局模式下不进行元素选择,让浏览器原生选择/caret工作
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
if (this.editor.isInsertMode) {
|
||||
e.stopPropagation();
|
||||
const me = e as MouseEvent;
|
||||
this.editor.insertTextAtPosition(me.clientX, me.clientY);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTagName = target.tagName.toLowerCase();
|
||||
// 如果包含在忽略的标签中,不处理
|
||||
if ((this.editor.options.ignoreSelectTags || []).includes(targetTagName)) return;
|
||||
|
||||
// 如果正在进行拖动或缩放操作,不处理点击
|
||||
if (this.editor.isDragging || this.editor.isResizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 当点击当前容器时,清空其他编辑器的选中样式
|
||||
this.editor.EditorRegistry.clearOthers(this.editor);
|
||||
|
||||
// 如果点击的元素已经被选中且可编辑,不要stopPropagation,让contenteditable正常工作
|
||||
if (target === this.editor.selectedElement && target.getAttribute('contenteditable') === 'true') {
|
||||
// 不阻止事件,让用户可以在元素内部点击定位光标
|
||||
return;
|
||||
}
|
||||
|
||||
// 选中元素
|
||||
e.stopPropagation();
|
||||
this.editor.selectElement(target);
|
||||
};
|
||||
|
||||
if (this.editor.container) {
|
||||
this.editor.container.addEventListener('click', handleClick);
|
||||
this.boundHandlers.set('click', handleClick);
|
||||
}
|
||||
}
|
||||
|
||||
bindDocumentEvents(): void {
|
||||
const handleDocumentMouseDown = (e: Event) => {
|
||||
// 使用 composedPath 处理可能的 DOM 节点在事件处理过程中被移除或在 portal 中的情况
|
||||
const path = e.composedPath?.() || [];
|
||||
const target = (path[0] || e.target) as HTMLElement;
|
||||
|
||||
if (!target || !target.closest) return;
|
||||
|
||||
// 如果正在进行操作,不清除选择
|
||||
if (this.editor.isOperating()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInEditorUI = path.some(node => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
return (
|
||||
node.classList.contains('html-editor-toolbar') ||
|
||||
node.classList.contains('html-editor-heading-dropdown') ||
|
||||
node.classList.contains('html-editor-popover') ||
|
||||
node.classList.contains('ant-color-picker-inner') ||
|
||||
node.hasAttribute('data-radix-popper-content-wrapper') ||
|
||||
node.hasAttribute('data-radix-portal') ||
|
||||
node.hasAttribute('data-html-editor-ui')
|
||||
);
|
||||
});
|
||||
|
||||
if (this.editor.container &&
|
||||
!this.editor.container.contains(target) &&
|
||||
!isInEditorUI
|
||||
) {
|
||||
this.editor.clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 mousedown 且开启 capture: true 确保在任何组件阻止冒泡前进行检查
|
||||
document.addEventListener('mousedown', handleDocumentMouseDown, true);
|
||||
this.boundHandlers.set('documentMouseDown', handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
unbindAll(): void {
|
||||
this.boundHandlers.forEach((handler, event) => {
|
||||
if (event === 'documentMouseDown') {
|
||||
document.removeEventListener('mousedown', handler, true);
|
||||
} else if (event === 'documentClick') {
|
||||
document.removeEventListener('click', handler);
|
||||
} else if (this.editor.container) {
|
||||
this.editor.container.removeEventListener(event, handler);
|
||||
}
|
||||
});
|
||||
this.boundHandlers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default EventManager;
|
||||
243
components/html-editor/lib/core/globalEditable/index.ts
Normal file
243
components/html-editor/lib/core/globalEditable/index.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { HTMLEditor } from '../editor';
|
||||
import { createContentChangeCommand } from '../historyManager/commands';
|
||||
import { Editor } from './markEngine'
|
||||
import type { MarkSpec } from './markEngine/type';
|
||||
|
||||
export class GlobalEditable {
|
||||
private editor: HTMLEditor;
|
||||
private enabled: boolean = false;
|
||||
private lastRecorded: string = '';
|
||||
private handlers: { input?: (e: Event) => void; keydown?: (e: KeyboardEvent) => void; selectionchange?: () => void } = {};
|
||||
|
||||
constructor(editor: HTMLEditor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
if (enabled === this.enabled) return;
|
||||
if (enabled) this.enable(); else this.disable();
|
||||
}
|
||||
|
||||
private getTarget(): HTMLElement {
|
||||
const doc = this.editor.getDoc();
|
||||
return doc.document.body as HTMLElement;
|
||||
}
|
||||
|
||||
private attachBodyEditable(): void {
|
||||
const doc = this.editor.getDoc();
|
||||
const body = doc.document.body;
|
||||
if (!body) return;
|
||||
if (!body.hasAttribute('data-original-contenteditable')) {
|
||||
const original = body.getAttribute('contenteditable') || 'inherit';
|
||||
body.setAttribute('data-original-contenteditable', original);
|
||||
}
|
||||
doc.document.execCommand('defaultParagraphSeparator', false, 'br');
|
||||
body.setAttribute('contenteditable', 'true');
|
||||
body.focus();
|
||||
}
|
||||
|
||||
private detachBodyEditable(): void {
|
||||
const doc = this.editor.getDoc();
|
||||
const body = doc.document.body;
|
||||
const original = body.getAttribute('data-original-contenteditable');
|
||||
if (original) {
|
||||
if (original === 'inherit') body.removeAttribute('contenteditable');
|
||||
else body.setAttribute('contenteditable', original);
|
||||
body.removeAttribute('data-original-contenteditable');
|
||||
} else {
|
||||
body.removeAttribute('contenteditable');
|
||||
}
|
||||
}
|
||||
|
||||
private bindListeners(): void {
|
||||
const doc = this.editor.getDoc();
|
||||
const target = this.getTarget();
|
||||
this.lastRecorded = target.innerHTML;
|
||||
const onInput = () => {
|
||||
const after = target.innerHTML;
|
||||
if (this.editor.suppressBodyInputRecord) {
|
||||
this.lastRecorded = after;
|
||||
return;
|
||||
}
|
||||
if (this.editor.historyManager && after !== this.lastRecorded) {
|
||||
const cmd = createContentChangeCommand(target, this.lastRecorded, after);
|
||||
this.editor.historyManager.push(cmd);
|
||||
this.lastRecorded = after;
|
||||
}
|
||||
this.editor.emit('contentChange');
|
||||
};
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const selection = doc.document.getSelection();
|
||||
if (!selection || !selection.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
const br = doc.document.createElement('br');
|
||||
range.insertNode(br);
|
||||
range.setStartAfter(br);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
onInput();
|
||||
}
|
||||
};
|
||||
|
||||
doc.document.body.addEventListener('input', onInput);
|
||||
doc.document.body.addEventListener('keydown', onKeydown);
|
||||
this.handlers = { input: onInput, keydown: onKeydown };
|
||||
}
|
||||
|
||||
private unbindListeners(): void {
|
||||
const doc = this.editor.getDoc();
|
||||
if (this.handlers.input) doc.document.body.removeEventListener('input', this.handlers.input);
|
||||
if (this.handlers.keydown) doc.document.body.removeEventListener('keydown', this.handlers.keydown);
|
||||
if (this.handlers.selectionchange) doc.document.removeEventListener('selectionchange', this.handlers.selectionchange);
|
||||
this.handlers = {};
|
||||
}
|
||||
|
||||
private enable(): void {
|
||||
this.editor.clearSelection();
|
||||
this.editor.eventManager?.unbindAll();
|
||||
this.editor.moveableManager?.destroy();
|
||||
if (this.editor.options.helperBox && this.editor.helperBoxManager) {
|
||||
this.editor.helperBoxManager.visible(false);
|
||||
}
|
||||
this.attachBodyEditable();
|
||||
this.bindListeners();
|
||||
this.enabled = true;
|
||||
}
|
||||
|
||||
private disable(): void {
|
||||
this.detachBodyEditable();
|
||||
this.unbindListeners();
|
||||
this.enabled = false;
|
||||
this.editor.eventManager?.bindAll();
|
||||
}
|
||||
|
||||
private withContentHistory(fn: () => void): boolean {
|
||||
const target = this.getTarget();
|
||||
const before = target.innerHTML;
|
||||
this.editor.suppressBodyInputRecord = true;
|
||||
fn();
|
||||
const after = target.innerHTML;
|
||||
this.editor.suppressBodyInputRecord = false;
|
||||
if (this.editor.historyManager && before !== after) {
|
||||
const cmd = createContentChangeCommand(target, before, after);
|
||||
this.editor.historyManager.push(cmd);
|
||||
}
|
||||
this.lastRecorded = after;
|
||||
this.editor.emit('contentChange');
|
||||
return true;
|
||||
}
|
||||
|
||||
private toggleMark(spec: MarkSpec): boolean {
|
||||
const ctx = this.editor.getDoc();
|
||||
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||
const action = () => engine.toggle(spec);
|
||||
return this.withContentHistory(action);
|
||||
}
|
||||
|
||||
applySelectionBold(): boolean {
|
||||
return this.toggleMark({ type: 'bold' });
|
||||
}
|
||||
applySelectionItalic(): boolean {
|
||||
return this.toggleMark({ type: 'italic' });
|
||||
}
|
||||
applySelectionUnderline(): boolean {
|
||||
return this.toggleMark({ type: 'underline' });
|
||||
}
|
||||
applySelectionStrikeThrough(): boolean {
|
||||
return this.toggleMark({ type: 'strike' });
|
||||
}
|
||||
applySelectionFontSize(px: string): boolean {
|
||||
return this.toggleMark({ type: 'fontSize', value: px });
|
||||
}
|
||||
applySelectionFontFamily(name: string): boolean {
|
||||
return this.toggleMark({ type: 'fontFamily', value: name });
|
||||
}
|
||||
applySelectionColor(color: string): boolean {
|
||||
return this.toggleMark({ type: 'color', value: color });
|
||||
}
|
||||
applySelectionBackground(color: string): boolean {
|
||||
return this.toggleMark({ type: 'background', value: color });
|
||||
}
|
||||
applySelectionHighlight(color?: string): boolean {
|
||||
return this.toggleMark({ type: 'highlight', value: color });
|
||||
}
|
||||
applySelectionCode(): boolean {
|
||||
return this.toggleMark({ type: 'code' });
|
||||
}
|
||||
applySelectionLink(href: string): boolean {
|
||||
return this.toggleMark({ type: 'link', attrs: { href } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文本对齐方式
|
||||
* @param alignment - 'left' | 'center' | 'right'
|
||||
*/
|
||||
applySelectionAlign(alignment: 'left' | 'center' | 'right'): boolean {
|
||||
const ctx = this.editor.getDoc();
|
||||
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||
const action = () => engine.align(alignment);
|
||||
return this.withContentHistory(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左对齐
|
||||
*/
|
||||
applySelectionAlignLeft(): boolean {
|
||||
return this.applySelectionAlign('left');
|
||||
}
|
||||
|
||||
/**
|
||||
* 居中对齐
|
||||
*/
|
||||
applySelectionAlignCenter(): boolean {
|
||||
return this.applySelectionAlign('center');
|
||||
}
|
||||
|
||||
/**
|
||||
* 右对齐
|
||||
*/
|
||||
applySelectionAlignRight(): boolean {
|
||||
return this.applySelectionAlign('right');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前段落的对齐方式
|
||||
* @returns 'left' | 'center' | 'right' | null
|
||||
*/
|
||||
queryAlign(): 'left' | 'center' | 'right' | null {
|
||||
const ctx = this.editor.getDoc();
|
||||
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||
return engine.queryAlign();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置标题级别
|
||||
* @param level - 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' (普通段落)
|
||||
*/
|
||||
setHeading(level: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p'): boolean {
|
||||
const ctx = this.editor.getDoc();
|
||||
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||
const action = () => engine.setHeading(level);
|
||||
return this.withContentHistory(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前标题级别
|
||||
* @returns 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | null
|
||||
*/
|
||||
queryHeading(): 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | null {
|
||||
const ctx = this.editor.getDoc();
|
||||
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||
return engine.queryHeading();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default GlobalEditable;
|
||||
1378
components/html-editor/lib/core/globalEditable/markEngine/index.ts
Normal file
1378
components/html-editor/lib/core/globalEditable/markEngine/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
export type MarkType =
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'underline'
|
||||
| 'strike'
|
||||
| 'color'
|
||||
| 'background'
|
||||
| 'fontSize'
|
||||
| 'fontFamily'
|
||||
| 'highlight'
|
||||
| 'code'
|
||||
| 'link'
|
||||
|
||||
export type MarkSpec = {
|
||||
type: MarkType
|
||||
value?: string
|
||||
attrs?: Record<string, string> // 用于 link 等需要额外属性的标记
|
||||
}
|
||||
|
||||
export type DocCtx = {
|
||||
view: Window
|
||||
document: Document
|
||||
}
|
||||
|
||||
/** 将元素按 range 拆分为三段 */
|
||||
export interface SplitResult {
|
||||
pre: DocumentFragment
|
||||
mid: DocumentFragment
|
||||
post: DocumentFragment
|
||||
}
|
||||
82
components/html-editor/lib/core/helperBoxManager/index.ts
Normal file
82
components/html-editor/lib/core/helperBoxManager/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { type Position } from '../../types';
|
||||
import { HTMLEditor } from '../editor';
|
||||
import { elementWatcher } from '../utils';
|
||||
|
||||
export class HelperBoxManager {
|
||||
editor: HTMLEditor;
|
||||
element: HTMLElement | null;
|
||||
|
||||
constructor(editor: HTMLEditor) {
|
||||
this.editor = editor;
|
||||
this.element = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.editor.container) return;
|
||||
const doc = this.editor.getDoc().document;
|
||||
const helperBox = doc.getElementById('html-editor-helper-box') || doc.createElement('div');
|
||||
this.element = helperBox;
|
||||
this.element.id = 'html-editor-helper-box';
|
||||
this.element.style.position = 'absolute';
|
||||
this.element.style.zIndex = '9999';
|
||||
this.element.style.display = 'none';
|
||||
this.element.style.pointerEvents = 'none';
|
||||
this.element.style.border = '1px dashed var(--editor-accent)';
|
||||
this.element.style.backgroundColor = 'color-mix(in srgb, var(--editor-accent) 8%, transparent)';
|
||||
|
||||
if (this.editor.isIframe) {
|
||||
doc.body.appendChild(this.element);
|
||||
} else {
|
||||
this.editor.container.style.position = 'relative';
|
||||
this.editor.container.appendChild(this.element);
|
||||
}
|
||||
}
|
||||
|
||||
updatePostion(position: Position) {
|
||||
if (!this.element) return;
|
||||
const { document, view } = this.editor.getDoc();
|
||||
if (!view || !document) return;
|
||||
const scrollTop = view.scrollY || this.editor.container?.scrollTop || 0;
|
||||
const scrollLeft = view.scrollX || this.editor.container?.scrollLeft || 0;
|
||||
|
||||
const doc = this.editor.container?.ownerDocument || document;
|
||||
let offsetTop = position.top;
|
||||
let offsetLeft = position.left;
|
||||
if (this.editor.isIframe && document?.body) {
|
||||
const cs = view?.getComputedStyle(doc.body);
|
||||
const mt = cs ? parseFloat(cs.marginTop || '0') : 0;
|
||||
const ml = cs ? parseFloat(cs.marginLeft || '0') : 0;
|
||||
offsetTop -= mt;
|
||||
offsetLeft -= ml;
|
||||
}
|
||||
this.element.style.width = `${position.width}px`;
|
||||
this.element.style.height = `${position.height}px`;
|
||||
this.element.style.top = `${offsetTop + scrollTop}px`;
|
||||
this.element.style.left = `${offsetLeft + scrollLeft}px`;
|
||||
}
|
||||
|
||||
// 创建高亮框,根据渲染帧刷新位置(解决dom有动画的case)
|
||||
createHighlightTracker() {
|
||||
const watcher = elementWatcher(this.editor);
|
||||
return {
|
||||
start: (element: HTMLElement) => {
|
||||
watcher.start(element, (postion) => {
|
||||
this.updatePostion(postion);
|
||||
this.element!.setAttribute('data-element-type', element.getAttribute('data-element-type') || '')
|
||||
});
|
||||
},
|
||||
stop: watcher.stop,
|
||||
};
|
||||
}
|
||||
|
||||
visible(visible: boolean) {
|
||||
if (!this.element) return;
|
||||
this.element.style.display = visible ? 'block' : 'none';
|
||||
}
|
||||
|
||||
delete() {
|
||||
if (!this.element) return;
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
293
components/html-editor/lib/core/historyManager/commands.ts
Normal file
293
components/html-editor/lib/core/historyManager/commands.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Command Implementations
|
||||
* 操作命令的具体实现
|
||||
*/
|
||||
|
||||
import {
|
||||
type Command,
|
||||
OperationType,
|
||||
type StyleChangeCommand,
|
||||
type ContentChangeCommand,
|
||||
type ElementAddCommand,
|
||||
type ElementDeleteCommand,
|
||||
type ElementTagChangeCommand,
|
||||
type BatchCommand,
|
||||
type AttributeChangeCommand
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 创建元素标签变更命令
|
||||
*/
|
||||
export function createElementTagChangeCommand(
|
||||
element: HTMLElement,
|
||||
newTag: string
|
||||
): ElementTagChangeCommand {
|
||||
const oldTag = element.tagName;
|
||||
const newElement = document.createElement(newTag);
|
||||
|
||||
// Copy attributes
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
newElement.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
|
||||
const headingLevels: { [key: string]: string } = {
|
||||
H1: '28px',
|
||||
H2: '26px',
|
||||
H3: '24px',
|
||||
H4: '22px',
|
||||
H5: '20px',
|
||||
H6: '18px',
|
||||
};
|
||||
const upperCaseNewTag = newTag.toUpperCase();
|
||||
if (headingLevels[upperCaseNewTag]) {
|
||||
newElement.style.fontSize = headingLevels[upperCaseNewTag];
|
||||
} else {
|
||||
newElement.style.fontSize = '18px';
|
||||
}
|
||||
|
||||
// Copy content
|
||||
newElement.innerHTML = element.innerHTML;
|
||||
|
||||
return {
|
||||
type: OperationType.ELEMENT_TAG_CHANGE,
|
||||
timestamp: Date.now(),
|
||||
element,
|
||||
oldTag,
|
||||
newTag,
|
||||
newElement,
|
||||
|
||||
execute() {
|
||||
if (element.parentNode) {
|
||||
element.parentNode.replaceChild(newElement, element);
|
||||
this.element = newElement;
|
||||
}
|
||||
},
|
||||
|
||||
undo() {
|
||||
if (newElement.parentNode) {
|
||||
newElement.parentNode.replaceChild(element, newElement);
|
||||
this.element = element;
|
||||
}
|
||||
},
|
||||
|
||||
merge(): boolean {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量操作命令
|
||||
*/
|
||||
export function createStyleChangeCommand(
|
||||
element: HTMLElement,
|
||||
property: string,
|
||||
oldValue: string,
|
||||
newValue: string
|
||||
): StyleChangeCommand {
|
||||
const time = Date.now();
|
||||
return {
|
||||
type: OperationType.STYLE_CHANGE,
|
||||
timestamp: time,
|
||||
element,
|
||||
property,
|
||||
oldValue,
|
||||
newValue,
|
||||
|
||||
execute() {
|
||||
element.style.setProperty(property, newValue);
|
||||
},
|
||||
|
||||
undo() {
|
||||
if (oldValue) {
|
||||
element.style.setProperty(property, oldValue);
|
||||
} else {
|
||||
element.style.removeProperty(property);
|
||||
}
|
||||
},
|
||||
|
||||
merge(command: Command): boolean {
|
||||
if (
|
||||
command.type === OperationType.STYLE_CHANGE &&
|
||||
(command as StyleChangeCommand).element === element &&
|
||||
(command as StyleChangeCommand).property === property &&
|
||||
command.timestamp - time < 1000
|
||||
) {
|
||||
this.newValue = (command as StyleChangeCommand).newValue;
|
||||
this.timestamp = command.timestamp;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建属性变更命令
|
||||
*/
|
||||
export function createAttributeChangeCommand(
|
||||
element: HTMLElement,
|
||||
attrName: string,
|
||||
oldValue: string | null,
|
||||
newValue: string | null
|
||||
): AttributeChangeCommand {
|
||||
const time = Date.now();
|
||||
return {
|
||||
type: OperationType.ATTRIBUTE_CHANGE,
|
||||
timestamp: time,
|
||||
element,
|
||||
attrName,
|
||||
oldValue,
|
||||
newValue,
|
||||
|
||||
execute() {
|
||||
if (newValue === null || newValue === undefined) {
|
||||
element.removeAttribute(attrName);
|
||||
} else {
|
||||
element.setAttribute(attrName, newValue);
|
||||
}
|
||||
},
|
||||
|
||||
undo() {
|
||||
if (oldValue === null || oldValue === undefined) {
|
||||
element.removeAttribute(attrName);
|
||||
} else {
|
||||
element.setAttribute(attrName, oldValue);
|
||||
}
|
||||
},
|
||||
|
||||
merge(command: Command): boolean {
|
||||
if (
|
||||
command.type === OperationType.ATTRIBUTE_CHANGE &&
|
||||
(command as AttributeChangeCommand).element === element &&
|
||||
(command as AttributeChangeCommand).attrName === attrName &&
|
||||
command.timestamp - time < 1000
|
||||
) {
|
||||
this.newValue = (command as AttributeChangeCommand).newValue;
|
||||
this.timestamp = command.timestamp;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建内容变更命令
|
||||
*/
|
||||
export function createContentChangeCommand(
|
||||
element: HTMLElement,
|
||||
oldContent: string,
|
||||
newContent: string
|
||||
): ContentChangeCommand {
|
||||
const time = Date.now();
|
||||
return {
|
||||
type: OperationType.CONTENT_CHANGE,
|
||||
timestamp: time,
|
||||
element,
|
||||
oldContent,
|
||||
newContent,
|
||||
|
||||
execute() {
|
||||
element.innerHTML = newContent;
|
||||
},
|
||||
|
||||
undo() {
|
||||
element.innerHTML = oldContent;
|
||||
},
|
||||
|
||||
merge(command: Command): boolean {
|
||||
if (
|
||||
command.type === OperationType.CONTENT_CHANGE &&
|
||||
(command as ContentChangeCommand).element === element &&
|
||||
command.timestamp - time < 2000
|
||||
) {
|
||||
this.newContent = (command as ContentChangeCommand).newContent;
|
||||
this.timestamp = command.timestamp;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建元素添加命令
|
||||
*/
|
||||
export function createElementAddCommand(
|
||||
element: HTMLElement,
|
||||
selectedElement: HTMLElement,
|
||||
parent: HTMLElement,
|
||||
nextSibling: HTMLElement | null
|
||||
): ElementAddCommand {
|
||||
return {
|
||||
type: OperationType.ELEMENT_ADD,
|
||||
timestamp: Date.now(),
|
||||
element,
|
||||
parent,
|
||||
nextSibling,
|
||||
selectedElement,
|
||||
execute() {
|
||||
if (nextSibling) {
|
||||
parent.insertBefore(element, nextSibling);
|
||||
} else {
|
||||
parent.appendChild(element);
|
||||
}
|
||||
},
|
||||
|
||||
undo() {
|
||||
parent.removeChild(element);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建元素删除命令
|
||||
*/
|
||||
export function createElementDeleteCommand(
|
||||
element: HTMLElement,
|
||||
parent: HTMLElement,
|
||||
nextSibling: Node | null
|
||||
): ElementDeleteCommand {
|
||||
return {
|
||||
type: OperationType.ELEMENT_DELETE,
|
||||
timestamp: Date.now(),
|
||||
element,
|
||||
parent,
|
||||
nextSibling,
|
||||
|
||||
execute() {
|
||||
if (element.parentNode) {
|
||||
parent.removeChild(element);
|
||||
}
|
||||
},
|
||||
|
||||
undo() {
|
||||
// 插回原节点对象
|
||||
if (!element.parentNode) {
|
||||
parent.insertBefore(element, nextSibling);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量操作命令
|
||||
*/
|
||||
export function createBatchCommand(commands: Command[]): BatchCommand {
|
||||
return {
|
||||
type: OperationType.BATCH,
|
||||
timestamp: Date.now(),
|
||||
commands,
|
||||
|
||||
execute() {
|
||||
commands.forEach((cmd) => cmd.execute());
|
||||
},
|
||||
|
||||
undo() {
|
||||
for (let i = commands.length - 1; i >= 0; i--) {
|
||||
commands[i].undo();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
233
components/html-editor/lib/core/historyManager/index.ts
Normal file
233
components/html-editor/lib/core/historyManager/index.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* History Manager 历史记录管理
|
||||
*/
|
||||
|
||||
import { type Command, type HistoryManagerOptions, type HistoryState, OperationType, type BatchCommand } from './types';
|
||||
import type { HTMLEditor } from '../editor';
|
||||
|
||||
export class HistoryManager {
|
||||
private editor: HTMLEditor;
|
||||
private undoStack: Command[] = [];
|
||||
private redoStack: Command[] = [];
|
||||
private options: Required<HistoryManagerOptions>;
|
||||
private isExecuting: boolean = false;
|
||||
private batchCommands: Command[] | null = null;
|
||||
|
||||
constructor(editor: HTMLEditor, options: HistoryManagerOptions = {}) {
|
||||
this.editor = editor;
|
||||
this.options = {
|
||||
maxHistorySize: options.maxHistorySize ?? 100,
|
||||
mergeInterval: options.mergeInterval ?? 1000,
|
||||
enableAutoSnapshot: options.enableAutoSnapshot ?? false,
|
||||
snapshotInterval: options.snapshotInterval ?? 10,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一个操作
|
||||
*/
|
||||
push(command: Command): void {
|
||||
if (this.isExecuting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果在批量操作中,暂存命令
|
||||
if (this.batchCommands) {
|
||||
this.batchCommands.push(command);
|
||||
return;
|
||||
}
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand?.merge && lastCommand.merge(command)) {
|
||||
this.notifyStateChange();
|
||||
return;
|
||||
}
|
||||
this.undoStack.push(command);
|
||||
|
||||
// 清空重做栈
|
||||
this.redoStack = [];
|
||||
if (this.undoStack.length > this.options.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销操作
|
||||
*/
|
||||
undo(): boolean {
|
||||
if (!this.canUndo()) return false;
|
||||
|
||||
const command = this.undoStack.pop()!;
|
||||
this.isExecuting = true;
|
||||
try {
|
||||
command.undo();
|
||||
if (command.type === OperationType.ELEMENT_ADD) {
|
||||
// 如果是添加元素操作,撤销时选中添加前的元素
|
||||
this.editor.selectElement(command.selectedElement || null);
|
||||
}
|
||||
this.redoStack.push(command);
|
||||
this.notifyStateChange(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.undoStack.push(command);
|
||||
return false;
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做操作
|
||||
*/
|
||||
redo(): boolean {
|
||||
if (!this.canRedo()) return false;
|
||||
|
||||
const command = this.redoStack.pop()!;
|
||||
this.isExecuting = true;
|
||||
try {
|
||||
command.execute();
|
||||
this.undoStack.push(command);
|
||||
this.notifyStateChange(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.redoStack.push(command);
|
||||
return false;
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以撤销
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
const result = this.undoStack.length > 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以重做
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
const result = this.redoStack.length > 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始批量操作
|
||||
*/
|
||||
beginBatch(): void {
|
||||
this.batchCommands = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束批量操作
|
||||
*/
|
||||
endBatch(): void {
|
||||
if (!this.batchCommands || this.batchCommands.length === 0) {
|
||||
this.batchCommands = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果只有一个命令,直接添加
|
||||
if (this.batchCommands.length === 1) {
|
||||
const command = this.batchCommands[0];
|
||||
this.batchCommands = null;
|
||||
this.push(command);
|
||||
} else {
|
||||
const timestamp = Date.now();
|
||||
// 创建批量命令
|
||||
const batchCommand: BatchCommand = {
|
||||
type: OperationType.BATCH,
|
||||
timestamp,
|
||||
commands: this.batchCommands,
|
||||
|
||||
execute() {
|
||||
this.commands.forEach((cmd) => cmd.execute());
|
||||
},
|
||||
|
||||
undo() {
|
||||
// 反向执行撤销
|
||||
for (let i = this.commands.length - 1; i >= 0; i--) {
|
||||
this.commands[i].undo();
|
||||
}
|
||||
},
|
||||
};
|
||||
this.batchCommands = null;
|
||||
this.push(batchCommand);
|
||||
}
|
||||
|
||||
this.batchCommands = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消批量操作
|
||||
*/
|
||||
cancelBatch(): void {
|
||||
this.batchCommands = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空历史记录
|
||||
*/
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史状态
|
||||
*/
|
||||
getState(): HistoryState {
|
||||
const state = {
|
||||
canUndo: this.canUndo(),
|
||||
canRedo: this.canRedo(),
|
||||
historySize: this.undoStack.length,
|
||||
currentIndex: this.undoStack.length,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取撤销栈大小
|
||||
*/
|
||||
getUndoStackSize(): number {
|
||||
return this.undoStack.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取重做栈大小
|
||||
*/
|
||||
getRedoStackSize(): number {
|
||||
return this.redoStack.length;
|
||||
}
|
||||
|
||||
getTopUndoTimestamp(): number | null {
|
||||
const cmd = this.undoStack[this.undoStack.length - 1];
|
||||
return cmd ? cmd.timestamp : null;
|
||||
}
|
||||
|
||||
getTopRedoTimestamp(): number | null {
|
||||
const cmd = this.redoStack[this.redoStack.length - 1];
|
||||
return cmd ? cmd.timestamp : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知状态变化
|
||||
*/
|
||||
private notifyStateChange(contentChange?: boolean): void {
|
||||
this.editor.emit('historyChange', this.getState());
|
||||
if (contentChange) {
|
||||
// 临时处理,之后所有的操作都需要通知
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁
|
||||
*/
|
||||
destroy(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
119
components/html-editor/lib/core/historyManager/types.ts
Normal file
119
components/html-editor/lib/core/historyManager/types.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* History Manager Types
|
||||
* 历史记录管理相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 操作类型枚举
|
||||
*/
|
||||
export enum OperationType {
|
||||
STYLE_CHANGE = 'style_change',
|
||||
CONTENT_CHANGE = 'content_change',
|
||||
ELEMENT_ADD = 'element_add',
|
||||
ELEMENT_DELETE = 'element_delete',
|
||||
ELEMENT_MOVE = 'element_move',
|
||||
ELEMENT_TAG_CHANGE = 'element_tag_change',
|
||||
ATTRIBUTE_CHANGE = 'attribute_change',
|
||||
BATCH = 'batch',
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作命令基础接口
|
||||
*/
|
||||
export interface Command {
|
||||
type: OperationType;
|
||||
timestamp: number;
|
||||
execute: () => void;
|
||||
undo: () => void;
|
||||
merge?: (command: Command) => boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 样式变更命令
|
||||
*/
|
||||
export interface StyleChangeCommand extends Command {
|
||||
type: OperationType.STYLE_CHANGE;
|
||||
element: HTMLElement;
|
||||
property: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容变更命令
|
||||
*/
|
||||
export interface ContentChangeCommand extends Command {
|
||||
type: OperationType.CONTENT_CHANGE;
|
||||
element: HTMLElement;
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性变更命令
|
||||
*/
|
||||
export interface AttributeChangeCommand extends Command {
|
||||
type: OperationType.ATTRIBUTE_CHANGE;
|
||||
element: HTMLElement;
|
||||
attrName: string;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 元素添加命令
|
||||
*/
|
||||
export interface ElementAddCommand extends Command {
|
||||
type: OperationType.ELEMENT_ADD;
|
||||
element: HTMLElement;
|
||||
selectedElement: HTMLElement;
|
||||
parent: HTMLElement;
|
||||
nextSibling: HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 元素删除命令
|
||||
*/
|
||||
export interface ElementDeleteCommand extends Command {
|
||||
type: OperationType.ELEMENT_DELETE;
|
||||
element: HTMLElement;
|
||||
parent: HTMLElement;
|
||||
nextSibling: Node | null;
|
||||
}
|
||||
|
||||
export interface ElementTagChangeCommand extends Command {
|
||||
type: OperationType.ELEMENT_TAG_CHANGE;
|
||||
element: HTMLElement;
|
||||
oldTag: string;
|
||||
newTag: string;
|
||||
newElement?: HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作命令
|
||||
*/
|
||||
export interface BatchCommand extends Command {
|
||||
type: OperationType.BATCH;
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史记录管理器配置
|
||||
*/
|
||||
export interface HistoryManagerOptions {
|
||||
maxHistorySize?: number;
|
||||
mergeInterval?: number;
|
||||
enableAutoSnapshot?: boolean;
|
||||
snapshotInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史状态
|
||||
*/
|
||||
export interface HistoryState {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
historySize: number;
|
||||
currentIndex: number;
|
||||
}
|
||||
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 = {};
|
||||
}
|
||||
}
|
||||
281
components/html-editor/lib/core/styleManager/index.ts
Normal file
281
components/html-editor/lib/core/styleManager/index.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Style Manager
|
||||
* 样式管理器,处理元素样式的应用和获取
|
||||
*/
|
||||
|
||||
import { type HTMLEditor } from '../editor';
|
||||
import type { ElementStyles } from '../../types';
|
||||
import { createStyleChangeCommand, createElementTagChangeCommand, createContentChangeCommand } from '../historyManager/commands';
|
||||
|
||||
export class StyleManager {
|
||||
private editor: HTMLEditor;
|
||||
|
||||
constructor(editor: HTMLEditor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用样式并记录历史
|
||||
*/
|
||||
private applyStyleWithHistory(element: HTMLElement, property: string, value: string): void {
|
||||
const oldValue = element.style.getPropertyValue(property) || window.getComputedStyle(element).getPropertyValue(property);
|
||||
|
||||
// 创建命令并执行
|
||||
if (this.editor.historyManager) {
|
||||
const command = createStyleChangeCommand(element, property, oldValue, value);
|
||||
command.execute();
|
||||
this.editor.historyManager.push(command);
|
||||
} else {
|
||||
// 如果没有历史管理器,直接应用样式
|
||||
element.style.setProperty(property, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 字体相关方法
|
||||
changeFont(element: HTMLElement | null, fontFamily: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'font-family', fontFamily);
|
||||
this.editor.emit('styleChange', element, { fontFamily });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeFontSize(element: HTMLElement | null, fontSize: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'font-size', fontSize);
|
||||
this.editor.emit('styleChange', element, { fontSize });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeFontWeight(element: HTMLElement | null, fontWeight: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'font-weight', fontWeight);
|
||||
this.editor.emit('styleChange', element, { fontWeight });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeFontStyle(element: HTMLElement | null, fontStyle: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'font-style', fontStyle);
|
||||
this.editor.emit('styleChange', element, { fontStyle });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeTextDecoration(element: HTMLElement | null, textDecoration: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'text-decoration', textDecoration);
|
||||
this.editor.emit('styleChange', element, { textDecoration });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeTextAlign(element: HTMLElement | null, textAlign: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'text-align', textAlign);
|
||||
this.editor.emit('styleChange', element, { textAlign });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 边距相关方法
|
||||
changeMargin(element: HTMLElement | null, margin: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'margin', margin);
|
||||
this.editor.emit('styleChange', element, { margin });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changePadding(element: HTMLElement | null, padding: string,triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'padding', padding);
|
||||
this.editor.emit('styleChange', element, { padding });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 颜色相关方法
|
||||
changeColor(element: HTMLElement | null, color: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'color', color);
|
||||
this.editor.emit('styleChange', element, { color });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeBackground(element: HTMLElement | null, backgroundColor: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
// 使用批量操作记录 background 和 backgroundColor
|
||||
// this.editor.beginBatch();
|
||||
this.applyStyleWithHistory(element, 'background', backgroundColor);
|
||||
// this.editor.endBatch();
|
||||
this.editor.emit('styleChange', element, { backgroundColor, background: backgroundColor });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 边框相关方法
|
||||
changeBorder(element: HTMLElement | null, border: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'border', border);
|
||||
this.editor.emit('styleChange', element, { border });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeBorderRadius(element: HTMLElement | null, borderRadius: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'border-radius', borderRadius);
|
||||
this.editor.emit('styleChange', element, { borderRadius });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
applyTextStyle(property: string, value: string, triggerContentChange = true): boolean {
|
||||
if (!this.editor.selectedElement) return false;
|
||||
this.applyStyleWithHistory(this.editor.selectedElement, property, value);
|
||||
this.editor.emit('styleChange', this.editor.selectedElement, { [property]: value });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
applyBlockStyle(property: string, value: string, triggerContentChange = true): boolean {
|
||||
if (!this.editor.selectedElement) return false;
|
||||
|
||||
const el = this.editor.selectedElement;
|
||||
|
||||
// 兼容传入 'background-color' 字符串
|
||||
const prop = property === 'background-color' ? 'background-color' : property;
|
||||
|
||||
// 当设置背景相关属性时,同时更新 background 与 backgroundColor
|
||||
if (prop === 'background' || prop === 'background-color') {
|
||||
this.editor.beginBatch();
|
||||
this.applyStyleWithHistory(el, 'background-color', value);
|
||||
this.applyStyleWithHistory(el, 'background', value);
|
||||
this.editor.endBatch();
|
||||
this.editor.emit('styleChange', el, { backgroundColor: value, background: value });
|
||||
return true;
|
||||
}
|
||||
|
||||
this.applyStyleWithHistory(el, prop, value);
|
||||
this.editor.emit('styleChange', el, { [prop]: value });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getComputedStyle(element: HTMLElement, property: string): string {
|
||||
return window.getComputedStyle(element)[property as any];
|
||||
}
|
||||
|
||||
getElementStyles(element: HTMLElement): ElementStyles {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
return {
|
||||
fontSize: computedStyle.fontSize,
|
||||
color: computedStyle.color,
|
||||
fontWeight: computedStyle.fontWeight,
|
||||
backgroundColor: computedStyle.backgroundColor,
|
||||
borderWidth: computedStyle.borderWidth,
|
||||
padding: computedStyle.padding,
|
||||
margin: computedStyle.margin,
|
||||
borderRadius: computedStyle.borderRadius
|
||||
};
|
||||
}
|
||||
|
||||
changeElementTag(element: HTMLElement, newTag: string, triggerContentChange = true): HTMLElement | null {
|
||||
if (!element || !element.parentNode || !newTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = createElementTagChangeCommand(element, newTag);
|
||||
command.execute();
|
||||
|
||||
const newElement = (command as any).newElement as HTMLElement;
|
||||
if (!newElement) return null;
|
||||
|
||||
if (this.editor.historyManager) {
|
||||
this.editor.historyManager.push(command);
|
||||
}
|
||||
|
||||
this.editor.selectElement(newElement);
|
||||
|
||||
this.editor.emit('contentChange');
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}
|
||||
|
||||
rgbToHex(rgb: string): string {
|
||||
if (!rgb || rgb === 'rgba(0, 0, 0, 0)' || rgb === 'transparent') {
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
const result = rgb.match(/\d+/g);
|
||||
if (!result) return '#000000';
|
||||
|
||||
const r = parseInt(result[0]);
|
||||
const g = parseInt(result[1]);
|
||||
const b = parseInt(result[2]);
|
||||
|
||||
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
||||
}
|
||||
|
||||
// =============================
|
||||
// 选区样式操作(全局contenteditable模式)
|
||||
// =============================
|
||||
applySelectionBold(): boolean { return this.editor.globalEditable?.applySelectionBold() ?? false; }
|
||||
applySelectionItalic(): boolean { return this.editor.globalEditable?.applySelectionItalic() ?? false; }
|
||||
applySelectionUnderline(): boolean { return this.editor.globalEditable?.applySelectionUnderline() ?? false; }
|
||||
applySelectionStrikeThrough(): boolean { return this.editor.globalEditable?.applySelectionStrikeThrough() ?? false; }
|
||||
applySelectionFontSize(px: string): boolean { return this.editor.globalEditable?.applySelectionFontSize(px) ?? false; }
|
||||
applySelectionFontFamily(name: string): boolean { return this.editor.globalEditable?.applySelectionFontFamily(name) ?? false; }
|
||||
applySelectionColor(color: string): boolean { return this.editor.globalEditable?.applySelectionColor(color) ?? false; }
|
||||
applySelectionBackground(color: string): boolean { return this.editor.globalEditable?.applySelectionBackground(color) ?? false; }
|
||||
applySelectionAlign(align: 'left' | 'center' | 'right'): boolean { return this.editor.globalEditable?.applySelectionAlign(align as any) ?? false; }
|
||||
}
|
||||
|
||||
export default StyleManager;
|
||||
160
components/html-editor/lib/core/utils.ts
Normal file
160
components/html-editor/lib/core/utils.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 通用工具函数集合:与 HTMLEditor 实例 (this) 无关的逻辑
|
||||
*/
|
||||
|
||||
import { type Position } from '../types';
|
||||
import { type HTMLEditor } from './editor';
|
||||
|
||||
export function getElementType(element: HTMLElement): string {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const typeMap: Record<string, string> = {
|
||||
'h1': '标题1', 'h2': '标题2', 'h3': '标题3',
|
||||
'h4': '标题4', 'h5': '标题5', 'h6': '标题6',
|
||||
'p': '段落', 'div': '区块', 'span': '文本',
|
||||
'a': '链接', 'img': '图片',
|
||||
'ul': '无序列表', 'ol': '有序列表', 'li': '列表项'
|
||||
};
|
||||
return typeMap[tagName] || tagName.toUpperCase();
|
||||
}
|
||||
|
||||
export function isDivWithText(element: HTMLElement): boolean {
|
||||
const elementWithText = !!element.textContent?.trim() && element.children.length === 0;
|
||||
return element.tagName.toLowerCase() === 'div' && elementWithText
|
||||
}
|
||||
|
||||
export function isTextElement(element: HTMLElement): boolean {
|
||||
const textTags = ['p', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'strong', 'em'];
|
||||
return textTags.includes(element.tagName.toLowerCase()) || isDivWithText(element);
|
||||
}
|
||||
|
||||
export function isDivWithImage(element: HTMLElement): boolean {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
const { backgroundImage, background } = computedStyle
|
||||
const elementWithBgImage = !!backgroundImage && backgroundImage !== 'none' && backgroundImage.includes('url(')
|
||||
const elementBgWithUrl = !!background && background.includes('url(')
|
||||
const divWithbg = element.tagName.toLowerCase() === 'div' && element.children.length === 0 && (elementWithBgImage || elementBgWithUrl);
|
||||
return divWithbg
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function isBlockElement(element: HTMLElement): boolean {
|
||||
const blockTags = ['div', 'section', 'article', 'header', 'footer', 'main', 'body', 'ol', 'ul', 'li', 'button', 'i'];
|
||||
return blockTags.includes(element.tagName.toLowerCase()) && !isDivWithImage(element) && !isDivWithText(element);
|
||||
}
|
||||
|
||||
export const isInlineElement = (element: HTMLElement): boolean => {
|
||||
const display = getComputedStyle(element).display;
|
||||
return display.startsWith('inline') && display !== 'inline-block';
|
||||
}
|
||||
|
||||
export const isTableElement = (element: HTMLElement): boolean => {
|
||||
const tableTags = ['tr', 'td', 'th', 'tbody', 'thead', 'tfoot', 'caption'];
|
||||
const tagName = element.tagName.toLowerCase().toLowerCase();
|
||||
return tableTags.includes(tagName);
|
||||
}
|
||||
|
||||
export function isImageElement(element: HTMLElement): boolean {
|
||||
return isDivWithImage(element) || element.tagName.toLowerCase() === 'img';
|
||||
}
|
||||
|
||||
export function createElement(type: string, content: string = ''): HTMLElement {
|
||||
const element = document.createElement(type);
|
||||
|
||||
if (content) {
|
||||
element.textContent = content;
|
||||
} else {
|
||||
element.textContent = type === 'div' ? '新区块' : '新文本';
|
||||
}
|
||||
|
||||
// 添加基础样式
|
||||
element.style.padding = '10px';
|
||||
element.style.margin = '5px';
|
||||
element.style.backgroundColor = '#f8f9fa';
|
||||
element.style.border = '1px dashed #dee2e6';
|
||||
element.style.borderRadius = '4px';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
export const elementWatcher = (editor: HTMLEditor) => {
|
||||
let ele: HTMLElement | null = null;
|
||||
let running = false;
|
||||
let frameId: number | null = null;
|
||||
let lastRect: Position | null = null;
|
||||
const update = (element: HTMLElement, callback?: (postition: Position) => void) => {
|
||||
if (!running || !element.isConnected) return;
|
||||
|
||||
const postition = editor.getBoundPostion(element);
|
||||
|
||||
const hasChanged =
|
||||
!lastRect ||
|
||||
postition.left !== lastRect.left ||
|
||||
postition.top !== lastRect.top ||
|
||||
postition.width !== lastRect.width ||
|
||||
postition.height !== lastRect.height ||
|
||||
postition.right !== lastRect.right ||
|
||||
postition.bottom !== lastRect.bottom;
|
||||
|
||||
if (hasChanged) {
|
||||
lastRect = postition;
|
||||
callback?.(postition);
|
||||
}
|
||||
|
||||
// 下一帧继续
|
||||
frameId = requestAnimationFrame(() => update(element, callback));
|
||||
}
|
||||
return {
|
||||
start: (element: HTMLElement, callback?: (position: Position) => void) => {
|
||||
if (!running) {
|
||||
ele = element;
|
||||
running = true;
|
||||
update(element, callback);
|
||||
}
|
||||
},
|
||||
stop: (element: HTMLElement) => {
|
||||
if (ele !== element) return;
|
||||
running = false;
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const cleanDom = (doc: HTMLElement): string => {
|
||||
// 克隆一份
|
||||
const ele = doc.cloneNode(true) as HTMLElement
|
||||
// 移除所有hover以及selected样式
|
||||
ele.querySelectorAll('*').forEach(node => {
|
||||
const tagName = node.tagName.toLowerCase()
|
||||
const classList = node.classList
|
||||
const nodeStyle = (node as HTMLElement).style
|
||||
if (classList.contains('hover-highlight')) {
|
||||
node.classList.remove('hover-highlight')
|
||||
node.removeAttribute('data-element-type')
|
||||
}
|
||||
if (classList.contains('selected-element')) {
|
||||
node.classList.remove('selected-element')
|
||||
node.removeAttribute('data-element-type')
|
||||
}
|
||||
if (tagName === 'style' && node.hasAttribute('data-styled-id')) {
|
||||
node.parentNode?.removeChild(node)
|
||||
}
|
||||
if (
|
||||
node.getAttribute('id') === 'html-editor-helper-box' ||
|
||||
classList.contains('moveable-control-box')
|
||||
) {
|
||||
node.parentNode?.removeChild(node)
|
||||
}
|
||||
if (nodeStyle && nodeStyle.cursor === 'move') {
|
||||
nodeStyle.cursor = 'auto'
|
||||
}
|
||||
if (node.hasAttribute('contenteditable')) {
|
||||
node.removeAttribute('contenteditable')
|
||||
}
|
||||
if (nodeStyle.userSelect === 'none') {
|
||||
nodeStyle.userSelect = 'auto'
|
||||
}
|
||||
})
|
||||
return ele.outerHTML
|
||||
}
|
||||
Reference in New Issue
Block a user