mirror of
https://github.com/alibaba/lowcode-engine.git
synced 2026-01-14 18:28:13 +00:00
225 lines
6.7 KiB
TypeScript
225 lines
6.7 KiB
TypeScript
import { obx, globalContext, Editor } from '@ali/lowcode-editor-core';
|
||
import { LiveTextEditingConfig } from '@ali/lowcode-types';
|
||
import { Node, Prop } from '../../document';
|
||
|
||
const EDITOR_KEY = 'data-setter-prop';
|
||
|
||
function getSetterPropElement(ele: HTMLElement, root: HTMLElement): HTMLElement | null {
|
||
const box = ele.closest(`[${EDITOR_KEY}]`);
|
||
if (!box || !root.contains(box)) {
|
||
return null;
|
||
}
|
||
return box as HTMLElement;
|
||
}
|
||
|
||
function defaultSaveContent(content: string, prop: Prop) {
|
||
prop.setValue(content);
|
||
}
|
||
|
||
export interface EditingTarget {
|
||
node: Node;
|
||
rootElement: HTMLElement;
|
||
event: MouseEvent;
|
||
}
|
||
|
||
const saveHandlers: SaveHandler[] = [];
|
||
function addLiveEditingSaveHandler(handler: SaveHandler) {
|
||
saveHandlers.push(handler);
|
||
}
|
||
|
||
const specificRules: SpecificRule[] = [];
|
||
function addLiveEditingSpecificRule(rule: SpecificRule) {
|
||
specificRules.push(rule);
|
||
}
|
||
|
||
export class LiveEditing {
|
||
static addLiveEditingSpecificRule = addLiveEditingSpecificRule;
|
||
|
||
static addLiveEditingSaveHandler = addLiveEditingSaveHandler;
|
||
|
||
@obx.ref private _editing: Prop | null = null;
|
||
|
||
apply(target: EditingTarget) {
|
||
const { node, event, rootElement } = target;
|
||
const targetElement = event.target as HTMLElement;
|
||
const { liveTextEditing } = node.componentMeta;
|
||
|
||
const editor = globalContext.get(Editor);
|
||
const npm = node?.componentMeta?.npm;
|
||
const selected =
|
||
[npm?.package, npm?.componentName].filter((item) => !!item).join('-') || node?.componentMeta?.componentName || '';
|
||
editor?.emit('designer.builtinSimulator.liveEditing', {
|
||
selected,
|
||
});
|
||
|
||
let setterPropElement = getSetterPropElement(targetElement, rootElement);
|
||
let propTarget = setterPropElement?.dataset.setterProp;
|
||
let matched: (LiveTextEditingConfig & { propElement?: HTMLElement; }) | undefined | null;
|
||
if (liveTextEditing) {
|
||
if (propTarget) {
|
||
// 已埋点命中 data-setter-prop="proptarget", 从 liveTextEditing 读取配置(mode|onSaveContent)
|
||
matched = liveTextEditing.find(config => config.propTarget == propTarget);
|
||
} else {
|
||
// 执行 embedTextEditing selector 规则,获得第一个节点 是否 contains e.target,若匹配,读取配置
|
||
matched = liveTextEditing.find(config => {
|
||
if (!config.selector) {
|
||
return false;
|
||
}
|
||
setterPropElement = queryPropElement(rootElement, targetElement, config.selector);
|
||
return !!setterPropElement;
|
||
});
|
||
propTarget = matched?.propTarget;
|
||
}
|
||
} else {
|
||
specificRules.some((rule) => {
|
||
matched = rule(target);
|
||
return !!matched;
|
||
});
|
||
if (matched) {
|
||
propTarget = matched.propTarget;
|
||
setterPropElement = matched.propElement || queryPropElement(rootElement, targetElement, matched.selector);
|
||
}
|
||
}
|
||
|
||
// if (!propTarget) {
|
||
// // 自动纯文本编辑满足一下情况:
|
||
// // 1. children 内容都是 Leaf 且都是文本(一期)
|
||
// // 2. DOM 节点是单层容器,子集都是文本节点 (已满足)
|
||
// const isAllText = node.children?.every(item => {
|
||
// return item.isLeaf() && item.getProp('children')?.type === 'literal';
|
||
// });
|
||
// // TODO:
|
||
// }
|
||
|
||
if (propTarget && setterPropElement) {
|
||
const prop = node.getProp(propTarget, true)!;
|
||
|
||
if (this._editing === prop) {
|
||
return;
|
||
}
|
||
|
||
// 进入编辑
|
||
// 1. 设置contentEditable="plaintext|..."
|
||
// 2. 添加类名
|
||
// 3. focus & cursor locate
|
||
// 4. 监听 blur 事件
|
||
// 5. 设置编辑锁定:disable hover | disable select | disable canvas drag
|
||
|
||
const onSaveContent = matched?.onSaveContent || saveHandlers.find(item => item.condition(prop))?.onSaveContent || defaultSaveContent;
|
||
|
||
setterPropElement.setAttribute('contenteditable', matched?.mode && matched.mode !== 'plaintext' ? 'true' : 'plaintext-only');
|
||
setterPropElement.classList.add('engine-live-editing');
|
||
// be sure
|
||
setterPropElement.focus();
|
||
setCaret(event);
|
||
|
||
this._save = () => {
|
||
onSaveContent(setterPropElement!.innerText, prop);
|
||
};
|
||
|
||
const keydown = (e: KeyboardEvent) => {
|
||
console.info(e.code);
|
||
switch (e.code) {
|
||
case 'Enter':
|
||
break;
|
||
// TODO: check is richtext?
|
||
case 'Escape':
|
||
break;
|
||
case 'Tab':
|
||
setterPropElement?.blur();
|
||
}
|
||
// esc
|
||
// enter
|
||
// tab
|
||
};
|
||
const focusout = (/* e: FocusEvent */) => {
|
||
this.saveAndDispose();
|
||
};
|
||
setterPropElement.addEventListener('focusout', focusout);
|
||
setterPropElement.addEventListener('keydown', keydown, true);
|
||
|
||
this._dispose = () => {
|
||
setterPropElement!.classList.remove('engine-live-editing');
|
||
setterPropElement!.removeAttribute('contenteditable');
|
||
setterPropElement!.removeEventListener('focusout', focusout);
|
||
setterPropElement!.removeEventListener('keydown', keydown, true);
|
||
};
|
||
|
||
this._editing = prop;
|
||
}
|
||
|
||
// TODO: process enter | esc events & joint the FocusTracker
|
||
|
||
// TODO: upward testing for b/i/a html elements
|
||
}
|
||
|
||
get editing() {
|
||
return this._editing;
|
||
}
|
||
|
||
private _dispose?: () => void;
|
||
|
||
private _save?: () => void;
|
||
|
||
saveAndDispose() {
|
||
if (this._save) {
|
||
this._save();
|
||
this._save = undefined;
|
||
}
|
||
this.dispose();
|
||
}
|
||
|
||
dispose() {
|
||
if (this._dispose) {
|
||
this._dispose();
|
||
this._dispose = undefined;
|
||
}
|
||
this._editing = null;
|
||
}
|
||
}
|
||
|
||
export type SpecificRule = (target: EditingTarget) => (LiveTextEditingConfig & {
|
||
propElement?: HTMLElement;
|
||
}) | null;
|
||
|
||
export interface SaveHandler {
|
||
condition: (prop: Prop) => boolean;
|
||
onSaveContent: (content: string, prop: Prop) => void;
|
||
}
|
||
|
||
function setCaret(event: MouseEvent) {
|
||
const doc = event.view?.document!;
|
||
const range = doc.caretRangeFromPoint(event.clientX, event.clientY);
|
||
if (range) {
|
||
selectRange(doc, range);
|
||
setTimeout(() => selectRange(doc, range), 1);
|
||
}
|
||
}
|
||
|
||
function selectRange(doc: Document, range: Range) {
|
||
const selection = doc.getSelection();
|
||
if (selection) {
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
}
|
||
}
|
||
|
||
|
||
function queryPropElement(rootElement: HTMLElement, targetElement: HTMLElement, selector?: string) {
|
||
if (!selector) {
|
||
return null;
|
||
}
|
||
let propElement = selector === ':root' ? rootElement : rootElement.querySelector(selector);
|
||
if (!propElement) {
|
||
return null;
|
||
}
|
||
if (!propElement.contains(targetElement)) {
|
||
// try selectorAll
|
||
propElement = Array.from(rootElement.querySelectorAll(selector)).find(item => item.contains(targetElement)) as HTMLElement;
|
||
if (!propElement) {
|
||
return null;
|
||
}
|
||
}
|
||
return propElement as HTMLElement;
|
||
}
|