2022-06-29 09:22:06 +08:00

1615 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
obx,
autorun,
reaction,
computed,
getPublicPath,
hotkey,
focusTracker,
engineConfig,
IReactionPublic,
IReactionOptions,
IReactionDisposer,
makeObservable,
} from '@alilc/lowcode-editor-core';
import { EventEmitter } from 'events';
import {
ISimulatorHost,
Component,
NodeInstance,
ComponentInstance,
DropContainer,
} from '../simulator';
import Viewport from './viewport';
import { createSimulator } from './create-simulator';
import { Node, ParentalNode, contains, isRootNode, isLowCodeComponent } from '../document';
import ResourceConsumer from './resource-consumer';
import {
AssetLevel,
Asset,
AssetList,
assetBundle,
assetItem,
AssetType,
isElement,
isFormEvent,
hasOwnProperty,
UtilsMetadata,
getClosestNode,
} from '@alilc/lowcode-utils';
import {
DragObjectType,
DragNodeObject,
isShaken,
LocateEvent,
isDragAnyObject,
isDragNodeObject,
isLocationData,
LocationChildrenDetail,
LocationDetailType,
isChildInline,
isRowContainer,
getRectTarget,
Rect,
CanvasPoint,
Designer,
} from '../designer';
import { parseMetadata } from './utils/parse-metadata';
import { getClosestClickableNode } from './utils/clickable';
import {
ComponentMetadata,
ComponentSchema,
TransformStage,
ActivityData,
Package,
} from '@alilc/lowcode-types';
import { BuiltinSimulatorRenderer } from './renderer';
import clipboard from '../designer/clipboard';
import { LiveEditing } from './live-editing/live-editing';
import { Project } from '../project';
import { Scroller } from '../designer/scroller';
import { isElementNode, isDOMNodeVisible } from '../utils/misc';
export interface LibraryItem extends Package{
package: string;
library: string;
urls?: Asset;
editUrls?: Asset;
}
export interface DeviceStyleProps {
canvas?: object;
viewport?: object;
}
export interface BuiltinSimulatorProps {
// 从 documentModel 上获取
// suspended?: boolean;
designMode?: 'live' | 'design' | 'preview' | 'extend' | 'border';
device?: 'mobile' | 'iphone' | string;
deviceClassName?: string;
environment?: Asset;
// @TODO 补充类型
/** @property 请求处理器配置 */
requestHandlersMap?: any;
extraEnvironment?: Asset;
library?: LibraryItem[];
utilsMetadata?: UtilsMetadata;
simulatorUrl?: Asset;
theme?: Asset;
componentsAsset?: Asset;
[key: string]: any;
}
const defaultSimulatorUrl = (() => {
const publicPath = getPublicPath();
let urls;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, prefix = '', dev] = /^(.+?)(\/js)?\/?$/.exec(publicPath) || [];
if (dev) {
urls = [
`${prefix}/css/react-simulator-renderer.css`,
`${prefix}/js/react-simulator-renderer.js`,
];
} else if (process.env.NODE_ENV === 'production') {
urls = [`${prefix}/react-simulator-renderer.css`, `${prefix}/react-simulator-renderer.js`];
} else {
urls = [`${prefix}/react-simulator-renderer.css`, `${prefix}/react-simulator-renderer.js`];
}
return urls;
})();
const defaultRaxSimulatorUrl = (() => {
const publicPath = getPublicPath();
let urls;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, prefix = '', dev] = /^(.+?)(\/js)?\/?$/.exec(publicPath) || [];
if (dev) {
urls = [`${prefix}/css/rax-simulator-renderer.css`, `${prefix}/js/rax-simulator-renderer.js`];
} else if (process.env.NODE_ENV === 'production') {
urls = [`${prefix}/rax-simulator-renderer.css`, `${prefix}/rax-simulator-renderer.js`];
} else {
urls = [`${prefix}/rax-simulator-renderer.css`, `${prefix}/rax-simulator-renderer.js`];
}
return urls;
})();
const defaultEnvironment = [
// https://g.alicdn.com/mylib/??react/16.11.0/umd/react.production.min.js,react-dom/16.8.6/umd/react-dom.production.min.js,prop-types/15.7.2/prop-types.min.js
assetItem(
AssetType.JSText,
'window.React=parent.React;window.ReactDOM=parent.ReactDOM;window.__is_simulator_env__=true;',
undefined,
'react',
),
assetItem(
AssetType.JSText,
'window.PropTypes=parent.PropTypes;React.PropTypes=parent.PropTypes; window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.parent.__REACT_DEVTOOLS_GLOBAL_HOOK__;',
),
];
const defaultRaxEnvironment = [
assetItem(
AssetType.JSText,
'window.Rax=parent.Rax;window.React=parent.React;window.ReactDOM=parent.ReactDOM;window.VisualEngineUtils=parent.VisualEngineUtils;window.VisualEngine=parent.VisualEngine',
),
assetItem(
AssetType.JSText,
'window.PropTypes=parent.PropTypes;React.PropTypes=parent.PropTypes; window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.parent.__REACT_DEVTOOLS_GLOBAL_HOOK__;',
),
];
export class BuiltinSimulatorHost implements ISimulatorHost<BuiltinSimulatorProps> {
readonly isSimulator = true;
readonly project: Project;
readonly designer: Designer;
readonly viewport = new Viewport();
readonly scroller: Scroller;
readonly emitter: EventEmitter = new EventEmitter();
readonly componentsConsumer: ResourceConsumer;
readonly injectionConsumer: ResourceConsumer;
/**
* 是否为画布自动渲染
*/
autoRender = true;
constructor(project: Project) {
makeObservable(this);
this.project = project;
this.designer = project?.designer;
this.scroller = this.designer.createScroller(this.viewport);
this.autoRender = !engineConfig.get('disableAutoRender', false);
this.componentsConsumer = new ResourceConsumer<Asset | undefined>(() => this.componentsAsset);
this.injectionConsumer = new ResourceConsumer(() => {
return {
appHelper: engineConfig.get('appHelper'),
i18n: this.project.i18n,
};
});
}
get currentDocument() {
return this.project.currentDocument;
}
@computed get renderEnv(): string {
return this.get('renderEnv') || 'default';
}
@computed get device(): string {
return this.get('device') || 'default';
}
@computed get locale(): string {
return this.get('locale');
}
@computed get deviceClassName(): string | undefined {
return this.get('deviceClassName');
}
@computed get designMode(): 'live' | 'design' | 'preview' {
// renderer 依赖
// TODO: 需要根据 design mode 不同切换鼠标响应情况
return this.get('designMode') || 'design';
}
@computed get requestHandlersMap(): any {
// renderer 依赖
// TODO: 需要根据 design mode 不同切换鼠标响应情况
return this.get('requestHandlersMap') || null;
}
get thisRequiredInJSE(): any {
return engineConfig.get('thisRequiredInJSE') ?? true;
}
@computed get componentsAsset(): Asset | undefined {
return this.get('componentsAsset');
}
@computed get theme(): Asset | undefined {
return this.get('theme');
}
@computed get componentsMap() {
// renderer 依赖
return this.designer.componentsMap;
}
@computed get deviceStyle(): DeviceStyleProps | undefined {
return this.get('deviceStyle');
}
@obx.ref _props: BuiltinSimulatorProps = {};
/**
* @see ISimulator
*/
setProps(props: BuiltinSimulatorProps) {
this._props = props;
}
set(key: string, value: any) {
this._props = {
...this._props,
[key]: value,
};
}
get(key: string): any {
if (key === 'device') {
return (
this.designer?.editor?.get('deviceMapper')?.transform?.(this._props.device) ||
this._props.device
);
}
return this._props[key];
}
/**
* 有 Renderer 进程连接进来,设置同步机制
*/
connect(
renderer: BuiltinSimulatorRenderer,
effect: (reaction: IReactionPublic) => void, options?: IReactionOptions,
) {
this._renderer = renderer;
return autorun(effect, options);
}
reaction(expression: (reaction: IReactionPublic) => unknown, effect: (value: unknown, prev: unknown, reaction: IReactionPublic) => void,
opts?: IReactionOptions | undefined): IReactionDisposer {
return reaction(expression, effect, opts);
}
autorun(effect: (reaction: IReactionPublic) => void, options?: IReactionOptions): IReactionDisposer {
return autorun(effect, options);
}
purge(): void {
// todo
}
mountViewport(viewport: Element | null) {
this.viewport.mount(viewport);
}
@obx.ref private _contentWindow?: Window;
get contentWindow() {
return this._contentWindow;
}
@obx.ref private _contentDocument?: Document;
get contentDocument() {
return this._contentDocument;
}
private _renderer?: BuiltinSimulatorRenderer;
get renderer() {
return this._renderer;
}
readonly asyncLibraryMap: { [key: string]: {} } = {};
readonly libraryMap: { [key: string]: string } = {};
private _iframe?: HTMLIFrameElement;
/**
* {
* "title":"BizCharts",
* "package":"bizcharts",
* "exportName":"bizcharts",
* "version":"4.0.14",
* "urls":[
* "https://g.alicdn.com/code/lib/bizcharts/4.0.14/BizCharts.js"
* ],
* "library":"BizCharts"
* }
* packageString 资源npm包名
* exportNameString umd包导出名字用于适配部分物料包define name不一致的问题例如把BizCharts改成bizcharts用来兼容物料用define声明的bizcharts
* versionString 版本号
* urlsArray 资源cdn地址必须是umd类型可以是.js或者.css
* libraryString umd包直接导出的name
*/
buildLibrary(library?: LibraryItem[]) {
const _library = library || (this.get('library') as LibraryItem[]);
const libraryAsset: AssetList = [];
const libraryExportList: string[] = [];
if (_library && _library.length) {
_library.forEach((item) => {
this.libraryMap[item.package] = item.library;
if (item.async) {
this.asyncLibraryMap[item.package] = item;
}
if (item.exportName && item.library) {
libraryExportList.push(
`Object.defineProperty(window,'${item.exportName}',{get:()=>window.${item.library}});`,
);
}
if (item.editUrls) {
libraryAsset.push(item.editUrls);
} else if (item.urls) {
libraryAsset.push(item.urls);
}
});
}
libraryAsset.unshift(assetItem(AssetType.JSText, libraryExportList.join('')));
return libraryAsset;
}
rerender() {
this.designer.refreshComponentMetasMap();
this.renderer?.rerender?.();
}
async mountContentFrame(iframe: HTMLIFrameElement | null) {
if (!iframe || this._iframe === iframe) {
return;
}
this._iframe = iframe;
this._contentWindow = iframe.contentWindow!;
this._contentDocument = this._contentWindow.document;
const libraryAsset: AssetList = this.buildLibrary();
const vendors = [
// required & use once
assetBundle(
this.get('environment') ||
(this.renderEnv === 'rax' ? defaultRaxEnvironment : defaultEnvironment),
AssetLevel.Environment,
),
// required & use once
assetBundle(this.get('extraEnvironment'), AssetLevel.Environment),
// required & use once
assetBundle(libraryAsset, AssetLevel.Library),
// required & TODO: think of update
assetBundle(this.theme, AssetLevel.Theme),
// required & use once
assetBundle(
this.get('simulatorUrl') ||
(this.renderEnv === 'rax' ? defaultRaxSimulatorUrl : defaultSimulatorUrl),
AssetLevel.Runtime,
),
];
// wait 准备 iframe 内容、依赖库注入
const renderer = await createSimulator(this, iframe, vendors);
// TODO: !!! thinkof reload onloa
// wait 业务组件被第一次消费,否则会渲染出错
await this.componentsConsumer.waitFirstConsume();
// wait 运行时上下文
await this.injectionConsumer.waitFirstConsume();
if (Object.keys(this.asyncLibraryMap).length > 0) {
// 加载异步Library
await renderer.loadAsyncLibrary(this.asyncLibraryMap);
}
// step 5 ready & render
renderer.run();
// init events, overlays
this.viewport.setScrollTarget(this._contentWindow);
this.setupEvents();
// bind hotkey & clipboard
hotkey.mount(this._contentWindow);
focusTracker.mount(this._contentWindow);
clipboard.injectCopyPaster(this._contentDocument);
// TODO: dispose the bindings
}
async setupComponents(library) {
const libraryAsset: AssetList = this.buildLibrary(library);
await this.renderer.load(libraryAsset);
}
setupEvents() {
// TODO: Thinkof move events control to simulator renderer
// just listen special callback
// because iframe maybe reload
this.setupDragAndClick();
this.setupDetecting();
this.setupLiveEditing();
this.setupContextMenu();
}
postEvent(eventName: string, ...data: any[]) {
this.emitter.emit(eventName, ...data);
}
setupDragAndClick() {
const { designer } = this;
const doc = this.contentDocument!;
// TODO: think of lock when edit a node
// 事件路由
doc.addEventListener(
'mousedown',
(downEvent: MouseEvent) => {
// fix for popups close logic
document.dispatchEvent(new Event('mousedown'));
const documentModel = this.project.currentDocument;
if (this.liveEditing.editing || !documentModel) {
return;
}
const { selection } = documentModel;
let isMulti = false;
if (this.designMode === 'design') {
isMulti = downEvent.metaKey || downEvent.ctrlKey;
} else if (!downEvent.metaKey) {
return;
}
// FIXME: dirty fix remove label-for fro liveEditing
(downEvent.target as HTMLElement).removeAttribute('for');
const nodeInst = this.getNodeInstanceFromElement(downEvent.target as Element);
const focusNode = documentModel.focusNode;
const node = getClosestClickableNode(nodeInst?.node || focusNode, downEvent);
// 如果找不到可点击的节点, 直接返回
if (!node) {
return;
}
const rglNode = node?.getParent();
const isRGLNode = rglNode?.isRGLContainer;
if (isRGLNode) {
// 如果拖拽的是磁铁块的右下角handle则直接跳过
if (downEvent.target.classList.contains('react-resizable-handle')) return;
// 禁止多选
isMulti = false;
designer.dragon.emitter.emit('rgl.switch', {
action: 'start',
rglNode,
});
} else {
// stop response document focus event
// 禁止原生拖拽
downEvent.stopPropagation();
downEvent.preventDefault();
}
// if (!node?.isValidComponent()) {
// // 对于未注册组件直接返回
// return;
// }
const isLeftButton = downEvent.which === 1 || downEvent.button === 0;
const checkSelect = (e: MouseEvent) => {
doc.removeEventListener('mouseup', checkSelect, true);
// 取消移动;
designer.dragon.emitter.emit('rgl.switch', {
action: 'end',
rglNode,
});
// 鼠标是否移动 ? - 鼠标抖动应该也需要支持选中事件偶尔点击不能选中磁帖块移除shaken检测
if (!isShaken(downEvent, e) || isRGLNode) {
let { id } = node;
designer.activeTracker.track({ node, instance: nodeInst?.instance });
if (isMulti && !node.contains(focusNode) && selection.has(id)) {
selection.remove(id);
} else {
// TODO: 避免选中 Page 组件,默认选中第一个子节点;新增规则 或 判断 Live 模式
if (node.isPage() && node.getChildren()?.notEmpty() && this.designMode === 'live') {
const firstChildId = node.getChildren()?.get(0)?.getId();
if (firstChildId) id = firstChildId;
}
selection.select(node.contains(focusNode) ? focusNode.id : id);
// dirty code should refector
const editor = this.designer?.editor;
const npm = node?.componentMeta?.npm;
const selected =
[npm?.package, npm?.componentName].filter((item) => !!item).join('-') ||
node?.componentMeta?.componentName ||
'';
editor?.emit('designer.builtinSimulator.select', {
selected,
});
}
}
};
if (isLeftButton && !node.contains(focusNode)) {
let nodes: Node[] = [node];
let ignoreUpSelected = false;
if (isMulti) {
// multi select mode, directily add
if (!selection.has(node.id)) {
designer.activeTracker.track({ node, instance: nodeInst?.instance });
selection.add(node.id);
ignoreUpSelected = true;
}
selection.remove(focusNode.id);
// 获得顶层 nodes
nodes = selection.getTopNodes();
} else if (selection.containsNode(node, true)) {
nodes = selection.getTopNodes();
} else {
// will clear current selection & select dragment in dragstart
}
designer.dragon.boost(
{
type: DragObjectType.Node,
nodes,
},
downEvent,
isRGLNode ? rglNode : undefined,
);
if (ignoreUpSelected) {
// multi select mode has add selected, should return
return;
}
}
doc.addEventListener('mouseup', checkSelect, true);
},
true,
);
doc.addEventListener(
'click',
(e) => {
// fix for popups close logic
const x = new Event('click');
x.initEvent('click', true);
this._iframe?.dispatchEvent(x);
const target = e.target as HTMLElement;
const customizeIgnoreSelectors = engineConfig.get('customizeIgnoreSelectors');
// TODO: need more elegant solution to ignore click events of components in designer
const defaultIgnoreSelectors: any = [
'.next-input-group',
'.next-checkbox-group',
'.next-checkbox-wrapper',
'.next-date-picker',
'.next-input',
'.next-month-picker',
'.next-number-picker',
'.next-radio-group',
'.next-range',
'.next-range-picker',
'.next-rating',
'.next-select',
'.next-switch',
'.next-time-picker',
'.next-upload',
'.next-year-picker',
'.next-breadcrumb-item',
'.next-calendar-header',
'.next-calendar-table',
'.editor-container', // 富文本组件
];
const ignoreSelectors = customizeIgnoreSelectors?.(defaultIgnoreSelectors, e) || defaultIgnoreSelectors;
const ignoreSelectorsString = ignoreSelectors.join(',');
// 提供了 customizeIgnoreSelectors 的情况下,忽略 isFormEvent() 判断
if ((!customizeIgnoreSelectors && isFormEvent(e)) || target?.closest(ignoreSelectorsString)) {
e.preventDefault();
e.stopPropagation();
}
// stop response document click event
// todo: catch link redirect
},
true,
);
}
private disableHovering?: () => void;
private disableDetecting?: () => void;
/**
* 设置悬停处理
*/
setupDetecting() {
const doc = this.contentDocument!;
const { detecting, dragon } = this.designer;
const hover = (e: MouseEvent) => {
if (!detecting.enable || this.designMode !== 'design') {
return;
}
const nodeInst = this.getNodeInstanceFromElement(e.target as Element);
if (nodeInst?.node) {
let node = nodeInst.node;
const focusNode = node.document?.focusNode;
if (node.contains(focusNode)) {
node = focusNode;
}
detecting.capture(node);
} else {
detecting.capture(null);
}
if (!engineConfig.get('enableMouseEventPropagationInCanvas', false) || dragon.dragging) {
e.stopPropagation();
}
};
const leave = () => detecting.leave(this.project.currentDocument);
doc.addEventListener('mouseover', hover, true);
doc.addEventListener('mouseleave', leave, false);
// TODO: refactor this line, contains click, mousedown, mousemove
doc.addEventListener(
'mousemove',
(e: Event) => {
if (!engineConfig.get('enableMouseEventPropagationInCanvas', false) || dragon.dragging) {
e.stopPropagation();
}
},
true,
);
// this.disableDetecting = () => {
// detecting.leave(this.project.currentDocument);
// doc.removeEventListener('mouseover', hover, true);
// doc.removeEventListener('mouseleave', leave, false);
// this.disableDetecting = undefined;
// };
}
readonly liveEditing = new LiveEditing();
setupLiveEditing() {
const doc = this.contentDocument!;
// cause edit
doc.addEventListener(
'dblclick',
(e: MouseEvent) => {
// stop response document dblclick event
e.stopPropagation();
e.preventDefault();
const targetElement = e.target as HTMLElement;
const nodeInst = this.getNodeInstanceFromElement(targetElement);
if (!nodeInst) {
return;
}
const focusNode = this.project.currentDocument?.focusNode;
const node = nodeInst.node || focusNode;
if (!node || isLowCodeComponent(node)) {
return;
}
const rootElement = this.findDOMNodes(
nodeInst.instance,
node.componentMeta.rootSelector,
)?.find(
(item) =>
// 可能是 [null];
item && item.contains(targetElement),
) as HTMLElement;
if (!rootElement) {
return;
}
this.liveEditing.apply({
node,
rootElement,
event: e,
});
},
true,
);
}
/**
* @see ISimulator
*/
setSuspense(suspended: boolean) {
return false;
// if (suspended) {
// /*
// if (this.disableDetecting) {
// this.disableDetecting();
// }
// */
// // sleep some autorun reaction
// } else {
// // weekup some autorun reaction
// /*
// if (!this.disableDetecting) {
// this.setupDetecting();
// }
// */
// }
}
setupContextMenu() {
const doc = this.contentDocument!;
doc.addEventListener('contextmenu', (e: MouseEvent) => {
const targetElement = e.target as HTMLElement;
const nodeInst = this.getNodeInstanceFromElement(targetElement);
if (!nodeInst) {
return;
}
const node = nodeInst.node || this.project.currentDocument?.focusNode;
if (!node) {
return;
}
// dirty code should refector
const editor = this.designer?.editor;
const npm = node?.componentMeta?.npm;
const selected =
[npm?.package, npm?.componentName].filter((item) => !!item).join('-') ||
node?.componentMeta?.componentName ||
'';
editor?.emit('designer.builtinSimulator.contextmenu', {
selected,
...nodeInst,
instanceRect: this.computeComponentInstanceRect(nodeInst.instance),
originalEvent: e,
});
});
}
/**
* @see ISimulator
*/
generateComponentMetadata(componentName: string): ComponentMetadata {
// if html tags
if (isHTMLTag(componentName)) {
return {
componentName,
// TODO: read builtins html metadata
};
}
const component = this.getComponent(componentName);
if (!component) {
return {
componentName,
};
}
// TODO:
// 1. generate builtin div/p/h1/h2
// 2. read propTypes
return {
componentName,
...parseMetadata(component),
};
}
/**
* @see ISimulator
*/
getComponent(componentName: string): Component | null {
return this.renderer?.getComponent(componentName) || null;
}
createComponent(schema: ComponentSchema): Component | null {
return null;
// return this.renderer?.createComponent(schema) || null;
}
@obx private instancesMap: {
[docId: string]: Map<string, ComponentInstance[]>;
} = {};
setInstance(docId: string, id: string, instances: ComponentInstance[] | null) {
if (!hasOwnProperty(this.instancesMap, docId)) {
this.instancesMap[docId] = new Map();
}
if (instances == null) {
this.instancesMap[docId].delete(id);
} else {
this.instancesMap[docId].set(id, instances.slice());
}
}
/**
* @see ISimulator
*/
getComponentInstances(node: Node, context?: NodeInstance): ComponentInstance[] | null {
const docId = node.document.id;
const instances = this.instancesMap[docId]?.get(node.id) || null;
if (!instances || !context) {
return instances;
}
// filter with context
return instances.filter((instance) => {
return this.getClosestNodeInstance(instance, context.nodeId)?.instance === context.instance;
});
}
/**
* @see ISimulator
*/
getComponentContext(/* node: Node */): any {
throw new Error('Method not implemented.');
}
/**
* @see ISimulator
*/
getClosestNodeInstance(
from: ComponentInstance,
specId?: string,
): NodeInstance<ComponentInstance> | null {
return this.renderer?.getClosestNodeInstance(from, specId) || null;
}
/**
* @see ISimulator
*/
computeRect(node: Node): Rect | null {
const instances = this.getComponentInstances(node);
if (!instances) {
return null;
}
return this.computeComponentInstanceRect(instances[0], node.componentMeta.rootSelector);
}
/**
* @see ISimulator
*/
computeComponentInstanceRect(instance: ComponentInstance, selector?: string): Rect | null {
const renderer = this.renderer!;
const elements = this.findDOMNodes(instance, selector);
if (!elements) {
return null;
}
const elems = elements.slice();
let rects: DOMRect[] | undefined;
let last: { x: number; y: number; r: number; b: number } | undefined;
let _computed = false;
while (true) {
if (!rects || rects.length < 1) {
const elem = elems.pop();
if (!elem) {
break;
}
rects = renderer.getClientRects(elem);
}
const rect = rects.pop();
if (!rect) {
break;
}
if (rect.width === 0 && rect.height === 0) {
continue;
}
if (!last) {
last = {
x: rect.left,
y: rect.top,
r: rect.right,
b: rect.bottom,
};
continue;
}
if (rect.left < last.x) {
last.x = rect.left;
_computed = true;
}
if (rect.top < last.y) {
last.y = rect.top;
_computed = true;
}
if (rect.right > last.r) {
last.r = rect.right;
_computed = true;
}
if (rect.bottom > last.b) {
last.b = rect.bottom;
_computed = true;
}
}
if (last) {
const r: any = new DOMRect(last.x, last.y, last.r - last.x, last.b - last.y);
r.elements = elements;
r.computed = _computed;
return r;
}
return null;
}
/**
* @see ISimulator
*/
findDOMNodes(instance: ComponentInstance, selector?: string): Array<Element | Text> | null {
const elements = this._renderer?.findDOMNodes(instance);
if (!elements) {
return null;
}
if (selector) {
const matched = getMatched(elements, selector);
if (!matched) {
return null;
}
return [matched];
}
return elements;
}
/**
* 通过 DOM 节点获取节点,依赖 simulator 的接口
*/
getNodeInstanceFromElement(target: Element | null): NodeInstance<ComponentInstance> | null {
if (!target) {
return null;
}
const nodeIntance = this.getClosestNodeInstance(target);
if (!nodeIntance) {
return null;
}
const { docId } = nodeIntance;
const doc = this.project.getDocument(docId)!;
const node = doc.getNode(nodeIntance.nodeId);
return {
...nodeIntance,
node,
};
}
private tryScrollAgain: number | null = null;
/**
* @see ISimulator
*/
/* istanbul ignore next */
scrollToNode(node: Node, detail?: any /* , tryTimes = 0 */) {
this.tryScrollAgain = null;
if (this.sensing) {
// active sensor
return;
}
const opt: any = {};
let scroll = false;
const componentInstance = this.getComponentInstances(detail?.near?.node || node)?.[0];
if (!componentInstance) return;
const domNode = this.findDOMNodes(componentInstance)?.[0] as Element;
if (!domNode) return;
if (isElementNode(domNode) && !isDOMNodeVisible(domNode, this.viewport)) {
const { left, top } = domNode.getBoundingClientRect();
const { scrollTop = 0, scrollLeft = 0 } = this.contentDocument?.documentElement || {};
opt.left = left + scrollLeft;
opt.top = top + scrollTop;
scroll = true;
}
/*
const rect = this.document.computeRect(node);
if (!rect || rect.width === 0 || rect.height === 0) {
if (!this.tryScrollAgain && tryTimes < 3) {
this.tryScrollAgain = requestAnimationFrame(() => this.scrollToNode(node, null, tryTimes + 1));
}
return;
}
const scrollTarget = this.viewport.scrollTarget!;
const st = scrollTarget.top;
const sl = scrollTarget.left;
const { scrollHeight, scrollWidth } = scrollTarget;
const { height, width, top, bottom, left, right } = this.viewport.contentBounds;
if (rect.height > height ? rect.top > bottom || rect.bottom < top : rect.top < top || rect.bottom > bottom) {
opt.top = Math.min(rect.top + rect.height / 2 + st - top - height / 2, scrollHeight - height);
scroll = true;
}
if (rect.width > width ? rect.left > right || rect.right < left : rect.left < left || rect.right > right) {
opt.left = Math.min(rect.left + rect.width / 2 + sl - left - width / 2, scrollWidth - width);
scroll = true;
} */
if (scroll && this.scroller) {
this.scroller.scrollTo(opt);
}
}
// #region ========= drag and drop helpers =============
/**
* @see ISimulator
*/
setNativeSelection(enableFlag: boolean) {
this.renderer?.setNativeSelection(enableFlag);
}
/**
* @see ISimulator
*/
setDraggingState(state: boolean) {
this.renderer?.setDraggingState(state);
}
/**
* @see ISimulator
*/
setCopyState(state: boolean) {
this.renderer?.setCopyState(state);
}
/**
* @see ISimulator
*/
clearState() {
this.renderer?.clearState();
}
private _sensorAvailable = true;
/**
* @see ISensor
*/
get sensorAvailable(): boolean {
return this._sensorAvailable;
}
/**
* @see ISensor
*/
fixEvent(e: LocateEvent): LocateEvent {
if (e.fixed) {
return e;
}
const notMyEvent = e.originalEvent.view?.document !== this.contentDocument;
// fix canvasX canvasY : 当前激活文档画布坐标系
if (notMyEvent || !('canvasX' in e) || !('canvasY' in e)) {
const l = this.viewport.toLocalPoint({
clientX: e.globalX,
clientY: e.globalY,
});
e.canvasX = l.clientX;
e.canvasY = l.clientY;
}
// fix target : 浏览器事件响应目标
if (!e.target || notMyEvent) {
if (!isNaN(e.canvasX!) && !isNaN(e.canvasY!)) {
e.target = this.contentDocument?.elementFromPoint(e.canvasX!, e.canvasY!);
}
}
// 事件已订正
e.fixed = true;
return e;
}
/**
* @see ISensor
*/
isEnter(e: LocateEvent): boolean {
const rect = this.viewport.bounds;
return (
e.globalY >= rect.top &&
e.globalY <= rect.bottom &&
e.globalX >= rect.left &&
e.globalX <= rect.right
);
}
private sensing = false;
/**
* @see ISensor
*/
deactiveSensor() {
this.sensing = false;
this.scroller.cancel();
}
// ========= drag location logic: helper for locate ==========
/**
* @see ISensor
*/
locate(e: LocateEvent): any {
const { dragObject } = e;
const { nodes } = dragObject as DragNodeObject;
const operationalNodes = nodes?.filter((node) => {
const onMoveHook = node.componentMeta?.getMetadata()?.configure.advanced?.callbacks?.onMoveHook;
const canMove = onMoveHook && typeof onMoveHook === 'function' ? onMoveHook(node.internalToShellNode()) : true;
return canMove;
});
if (nodes && (!operationalNodes || operationalNodes.length === 0)) {
return;
}
this.sensing = true;
this.scroller.scrolling(e);
const document = this.project.currentDocument;
if (!document) {
return null;
}
const dropContainer = this.getDropContainer(e);
const childWhitelist = dropContainer?.container?.componentMeta?.childWhitelist;
const lockedNode = getClosestNode(dropContainer?.container as Node, (node) => node.isLocked);
if (lockedNode) return null;
if (
!dropContainer ||
(nodes && typeof childWhitelist === 'function' && !childWhitelist(operationalNodes[0]))
) {
return null;
}
if (isLocationData(dropContainer)) {
return this.designer.createLocation(dropContainer);
}
const { container, instance: containerInstance } = dropContainer;
const edge = this.computeComponentInstanceRect(
containerInstance,
container.componentMeta.rootSelector,
);
if (!edge) {
return null;
}
const { children } = container;
const detail: LocationChildrenDetail = {
type: LocationDetailType.Children,
index: 0,
edge,
};
const locationData = {
target: container as ParentalNode,
detail,
source: `simulator${document.id}`,
event: e,
};
if (
e.dragObject &&
e.dragObject.nodes &&
e.dragObject.nodes.length &&
e.dragObject.nodes[0].componentMeta.isModal
) {
return this.designer.createLocation({
target: document.focusNode,
detail,
source: `simulator${document.id}`,
event: e,
});
}
if (!children || children.size < 1 || !edge) {
return this.designer.createLocation(locationData);
}
let nearRect = null;
let nearIndex = 0;
let nearNode = null;
let nearDistance = null;
let minTop = null;
let maxBottom = null;
for (let i = 0, l = children.size; i < l; i++) {
const node = children.get(i)!;
const index = i;
const instances = this.getComponentInstances(node);
const inst = instances
? instances.length > 1
? instances.find(
(_inst) => this.getClosestNodeInstance(_inst, container.id)?.instance === containerInstance,
)
: instances[0]
: null;
const rect = inst
? this.computeComponentInstanceRect(inst, node.componentMeta.rootSelector)
: null;
if (!rect) {
continue;
}
const distance = isPointInRect(e as any, rect) ? 0 : distanceToRect(e as any, rect);
if (distance === 0) {
nearDistance = distance;
nearNode = node;
nearIndex = index;
nearRect = rect;
break;
}
// 标记子节点最顶
if (minTop === null || rect.top < minTop) {
minTop = rect.top;
}
// 标记子节点最底
if (maxBottom === null || rect.bottom > maxBottom) {
maxBottom = rect.bottom;
}
if (nearDistance === null || distance < nearDistance) {
nearDistance = distance;
nearNode = node;
nearIndex = index;
nearRect = rect;
}
}
detail.index = nearIndex;
if (nearNode && nearRect) {
const el = getRectTarget(nearRect);
const inline = el ? isChildInline(el) : false;
const row = el ? isRowContainer(el.parentElement!) : false;
const vertical = inline || row;
// TODO: fix type
const near: any = {
node: nearNode,
pos: 'before',
align: vertical ? 'V' : 'H',
};
detail.near = near;
if (isNearAfter(e as any, nearRect, vertical)) {
near.pos = 'after';
detail.index = nearIndex + 1;
}
if (!row && nearDistance !== 0) {
const edgeDistance = distanceToEdge(e as any, edge);
if (edgeDistance.distance < nearDistance!) {
const { nearAfter } = edgeDistance;
if (minTop == null) {
minTop = edge.top;
}
if (maxBottom == null) {
maxBottom = edge.bottom;
}
near.rect = new DOMRect(edge.left, minTop, edge.width, maxBottom - minTop);
near.align = 'H';
near.pos = nearAfter ? 'after' : 'before';
detail.index = nearAfter ? children.size : 0;
}
}
}
return this.designer.createLocation(locationData);
}
/**
* 查找合适的投放容器
*/
getDropContainer(e: LocateEvent): DropContainer | null {
const { target, dragObject } = e;
const isAny = isDragAnyObject(dragObject);
const document = this.project.currentDocument!;
const { currentRoot } = document;
let container: Node;
let nodeInstance: NodeInstance<ComponentInstance> | undefined;
if (target) {
const ref = this.getNodeInstanceFromElement(target);
if (ref?.node) {
nodeInstance = ref;
container = ref.node;
} else if (isAny) {
return null;
} else {
container = currentRoot;
}
} else if (isAny) {
return null;
} else {
container = currentRoot;
}
if (!container.isParental()) {
container = container.parent || currentRoot;
}
// TODO: use spec container to accept specialData
if (isAny) {
// will return locationData
return null;
}
// get common parent, avoid drop container contains by dragObject
const drillDownExcludes = new Set<Node>();
if (isDragNodeObject(dragObject)) {
const { nodes } = dragObject;
let i = nodes.length;
let p: any = container;
while (i-- > 0) {
if (contains(nodes[i], p)) {
p = nodes[i].parent;
}
}
if (p !== container) {
container = p || document.focusNode;
drillDownExcludes.add(container);
}
}
let instance: any;
if (nodeInstance) {
if (nodeInstance.node === container) {
instance = nodeInstance.instance;
} else {
instance = this.getClosestNodeInstance(
nodeInstance.instance as any,
container.id,
)?.instance;
}
} else {
instance = this.getComponentInstances(container)?.[0];
}
let dropContainer: DropContainer = {
container: container as any,
instance,
};
let res: any;
let upward: DropContainer | null = null;
while (container) {
res = this.handleAccept(dropContainer, e);
// if (isLocationData(res)) {
// return res;
// }
if (res === true) {
return dropContainer;
}
if (!res) {
drillDownExcludes.add(container);
if (upward) {
dropContainer = upward;
container = dropContainer.container;
upward = null;
} else if (container.parent) {
container = container.parent;
instance = this.getClosestNodeInstance(dropContainer.instance, container.id)?.instance;
dropContainer = {
container: container as ParentalNode,
instance,
};
} else {
return null;
}
} /* else if (res === DRILL_DOWN) {
if (!upward) {
container = container.parent;
instance = this.getClosestNodeInstance(dropContainer.instance, container.id)?.instance;
upward = {
container,
instance
};
}
dropContainer = this.getNearByContainer(dropContainer, drillDownExcludes, e);
if (!dropContainer) {
dropContainer = upward;
upward = null;
}
} else if (isNode(res)) {
// TODO:
} */
}
return null;
}
isAcceptable(/* container: ParentalNode */): boolean {
return false;
/*
const meta = container.componentMeta;
const instance: any = this.document.getView(container);
if (instance && '$accept' in instance) {
return true;
}
return meta.acceptable;
*/
}
/**
* 控制接受
*/
handleAccept({ container, instance }: DropContainer, e: LocateEvent): boolean {
const { dragObject } = e;
const document = this.currentDocument!;
const focusNode = document.focusNode;
if (isRootNode(container) || container.contains(focusNode)) {
return document.checkDropTarget(focusNode, dragObject as any);
}
const meta = (container as Node).componentMeta;
// FIXME: get containerInstance for accept logic use
const acceptable: boolean = this.isAcceptable(container);
if (!meta.isContainer && !acceptable) {
return false;
}
// first use accept
if (acceptable) {
/*
const view: any = this.document.getView(container);
if (view && '$accept' in view) {
if (view.$accept === false) {
return false;
}
if (view.$accept === AT_CHILD || view.$accept === '@CHILD') {
return AT_CHILD;
}
if (typeof view.$accept === 'function') {
const ret = view.$accept(container, e);
if (ret || ret === false) {
return ret;
}
}
}
if (proto.acceptable) {
const ret = proto.accept(container, e);
if (ret || ret === false) {
return ret;
}
}
*/
}
// check nesting
return document.checkNesting(container, dragObject as any);
}
/**
* 查找邻近容器
*/
getNearByContainer(
{ container, instance }: DropContainer,
drillDownExcludes: Set<Node>,
e: LocateEvent,
) {
const { children } = container;
const document = this.project.currentDocument!;
if (!children || children.isEmpty()) {
return null;
}
const nearDistance: any = null;
const nearBy: any = null;
for (let i = 0, l = children.size; i < l; i++) {
let child = children.get(i);
if (!child) {
continue;
}
if (child.conditionGroup) {
const bn = child.conditionGroup;
i = bn.index + bn.length - 1;
child = bn.visibleNode;
}
if (!child.isParental() || drillDownExcludes.has(child)) {
continue;
}
// TODO:
this.findDOMNodes(instance);
this.getComponentInstances(child);
const rect = this.computeRect(child);
if (!rect) {
continue;
}
/*
if (isPointInRect(e, rect)) {
return child;
}
const distance = distanceToRect(e, rect);
if (nearDistance === null || distance < nearDistance) {
nearDistance = distance;
nearBy = child;
} */
}
return nearBy;
}
// #endregion
}
function isHTMLTag(name: string) {
return /^[a-z]\w*$/.test(name);
}
function isPointInRect(point: CanvasPoint, rect: Rect) {
return (
point.canvasY >= rect.top &&
point.canvasY <= rect.bottom &&
point.canvasX >= rect.left &&
point.canvasX <= rect.right
);
}
function distanceToRect(point: CanvasPoint, rect: Rect) {
let minX = Math.min(Math.abs(point.canvasX - rect.left), Math.abs(point.canvasX - rect.right));
let minY = Math.min(Math.abs(point.canvasY - rect.top), Math.abs(point.canvasY - rect.bottom));
if (point.canvasX >= rect.left && point.canvasX <= rect.right) {
minX = 0;
}
if (point.canvasY >= rect.top && point.canvasY <= rect.bottom) {
minY = 0;
}
return Math.sqrt(minX ** 2 + minY ** 2);
}
function distanceToEdge(point: CanvasPoint, rect: Rect) {
const distanceTop = Math.abs(point.canvasY - rect.top);
const distanceBottom = Math.abs(point.canvasY - rect.bottom);
return {
distance: Math.min(distanceTop, distanceBottom),
nearAfter: distanceBottom < distanceTop,
};
}
function isNearAfter(point: CanvasPoint, rect: Rect, inline: boolean) {
if (inline) {
return (
Math.abs(point.canvasX - rect.left) + Math.abs(point.canvasY - rect.top) >
Math.abs(point.canvasX - rect.right) + Math.abs(point.canvasY - rect.bottom)
);
}
return Math.abs(point.canvasY - rect.top) > Math.abs(point.canvasY - rect.bottom);
}
function getMatched(elements: Array<Element | Text>, selector: string): Element | null {
let firstQueried: Element | null = null;
for (const elem of elements) {
if (isElement(elem)) {
if (elem.matches(selector)) {
return elem;
}
if (!firstQueried) {
firstQueried = elem.querySelector(selector);
}
}
}
return firstQueried;
}