diff --git a/packages/designer/src/builtins/simulator/auxilary/README.md b/packages/designer/auxilary/README.md
similarity index 100%
rename from packages/designer/src/builtins/simulator/auxilary/README.md
rename to packages/designer/auxilary/README.md
diff --git a/packages/designer/src/builtins/simulator/auxilary/auxiliary.less b/packages/designer/auxilary/auxiliary.less
similarity index 100%
rename from packages/designer/src/builtins/simulator/auxilary/auxiliary.less
rename to packages/designer/auxilary/auxiliary.less
diff --git a/packages/designer/src/builtins/simulator/auxilary/auxiliary.tsx b/packages/designer/auxilary/auxiliary.tsx
similarity index 100%
rename from packages/designer/src/builtins/simulator/auxilary/auxiliary.tsx
rename to packages/designer/auxilary/auxiliary.tsx
diff --git a/packages/designer/src/builtins/simulator/auxilary/embed-editor-toolbar.tsx b/packages/designer/auxilary/embed-editor-toolbar.tsx
similarity index 100%
rename from packages/designer/src/builtins/simulator/auxilary/embed-editor-toolbar.tsx
rename to packages/designer/auxilary/embed-editor-toolbar.tsx
diff --git a/packages/designer/auxilary/gliding.less b/packages/designer/auxilary/gliding.less
new file mode 100644
index 000000000..733ae746d
--- /dev/null
+++ b/packages/designer/auxilary/gliding.less
@@ -0,0 +1,39 @@
+.my-edging {
+ box-sizing: border-box;
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ left: 0;
+ border: 1px dashed var(--color-brand-light);
+ z-index: 1;
+ background: rgba(95, 240, 114, 0.04);
+ will-change: transform, width, height;
+ transition-property: transform, width, height;
+ transition-duration: 60ms;
+ transition-timing-function: linear;
+ overflow: visible;
+ >.title {
+ position: absolute;
+ color: var(--color-brand-light);
+ top: -20px;
+ left: 0;
+ font-weight: lighter;
+ }
+
+ &.x-shadow {
+ border-color: rgba(138, 93, 226, 0.8);
+ background: rgba(138, 93, 226, 0.04);
+
+ >.title {
+ color: rgba(138, 93, 226, 1.0);
+ }
+ }
+
+ &.x-flow {
+ border-color: rgba(255, 99, 8, 0.8);
+ background: rgba(255, 99, 8, 0.04);
+ >.title {
+ color: rgb(255, 99, 8);
+ }
+ }
+}
diff --git a/packages/designer/auxilary/gliding.tsx b/packages/designer/auxilary/gliding.tsx
new file mode 100644
index 000000000..a6b063ddc
--- /dev/null
+++ b/packages/designer/auxilary/gliding.tsx
@@ -0,0 +1,47 @@
+import { observer } from '@recore/core-obx';
+import { Component } from 'react';
+import './edging.less';
+
+@observer
+export class GlidingView extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ const node = edging.watching;
+ if (!node || !edging.enable || (current.selection && current.selection.has(node.id))) {
+ return null;
+ }
+
+ // TODO: think of multi ReactInstance
+ // TODO: findDOMNode cause a render bug
+ const rect = node.document.computeRect(node);
+ if (!rect) {
+ return null;
+ }
+
+ const { scale, scrollTarget } = node.document.viewport;
+
+ const sx = scrollTarget!.left;
+ const sy = scrollTarget!.top;
+
+ const style = {
+ width: rect.width * scale,
+ height: rect.height * scale,
+ transform: `translate(${(sx + rect.left) * scale}px, ${(sy + rect.top) * scale}px)`,
+ } as any;
+
+ let className = 'my-edging';
+
+ // TODO:
+ // 1. thinkof icon
+ // 2. thinkof top|bottom|inner space
+
+ return (
+
+ );
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/auxilary/index.ts b/packages/designer/auxilary/index.ts
similarity index 100%
rename from packages/designer/src/builtins/simulator/auxilary/index.ts
rename to packages/designer/auxilary/index.ts
diff --git a/packages/designer/src/builtins/simulator/auxilary/insertion.less b/packages/designer/auxilary/insertion.less
similarity index 100%
rename from packages/designer/src/builtins/simulator/auxilary/insertion.less
rename to packages/designer/auxilary/insertion.less
diff --git a/packages/designer/src/builtins/simulator/auxilary/insertion.tsx b/packages/designer/auxilary/insertion.tsx
similarity index 100%
rename from packages/designer/src/builtins/simulator/auxilary/insertion.tsx
rename to packages/designer/auxilary/insertion.tsx
diff --git a/packages/designer/src/builtins/simulator/auxilary/offset-observer.ts b/packages/designer/auxilary/offset-observer.ts
similarity index 100%
rename from packages/designer/src/builtins/simulator/auxilary/offset-observer.ts
rename to packages/designer/auxilary/offset-observer.ts
diff --git a/packages/designer/src/builtins/simulator/auxilary/selecting.less b/packages/designer/auxilary/selecting.less
similarity index 100%
rename from packages/designer/src/builtins/simulator/auxilary/selecting.less
rename to packages/designer/auxilary/selecting.less
diff --git a/packages/designer/src/builtins/simulator/auxilary/selecting.tsx b/packages/designer/auxilary/selecting.tsx
similarity index 100%
rename from packages/designer/src/builtins/simulator/auxilary/selecting.tsx
rename to packages/designer/auxilary/selecting.tsx
diff --git a/packages/designer/package.json b/packages/designer/package.json
index a553eb221..fa25e054d 100644
--- a/packages/designer/package.json
+++ b/packages/designer/package.json
@@ -3,16 +3,13 @@
"version": "0.9.0",
"description": "alibaba lowcode designer",
"main": "index.js",
- "scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
- },
"author": "",
"license": "MIT",
"dependencies": {
+ "@ali/iceluna-sdk": "^1.0.5-beta.12",
"@recore/core-obx": "^1.0.4",
"@recore/obx": "^1.0.5",
"@types/medium-editor": "^5.0.3",
- "@ali/lowcode-renderer": "0.9.0",
"classnames": "^2.2.6",
"react": "^16",
"react-dom": "^16.7.0"
diff --git a/packages/designer/src/builtins/drag-ghost/index.tsx b/packages/designer/src/builtins/drag-ghost/index.tsx
new file mode 100644
index 000000000..a42793a55
--- /dev/null
+++ b/packages/designer/src/builtins/drag-ghost/index.tsx
@@ -0,0 +1,82 @@
+import { Component } from 'react';
+import { obx } from '@recore/obx';
+import { observer } from '@recore/core-obx';
+import Designer from '../../designer/designer';
+import './ghost.less';
+
+type offBinding = () => any;
+
+@observer
+export default class Ghost extends Component<{ designer: Designer }> {
+ private dispose: offBinding[] = [];
+ @obx.ref private dragment: any = null;
+ @obx.ref private x = 0;
+ @obx.ref private y = 0;
+ private dragon = this.props.designer.dragon;
+
+ componentWillMount() {
+ this.dispose = [
+ this.dragon.onDragstart((e) => {
+ this.dragment = e.dragObject;
+ this.x = e.globalX;
+ this.y = e.globalY;
+ }),
+ this.dragon.onDrag(e => {
+ this.x = e.globalX;
+ this.y = e.globalY;
+ }),
+ this.dragon.onDragend(() => {
+ this.dragment = null;
+ this.x = 0;
+ this.y = 0;
+ }),
+ ];
+ }
+
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ componentWillUnmount() {
+ if (this.dispose) {
+ this.dispose.forEach(off => off());
+ }
+ }
+
+ renderGhostGroup() {
+ const dragment = this.dragment;
+ if (Array.isArray(dragment)) {
+ return dragment.map((node: any, index: number) => {
+ const ghost = (
+
+ );
+ return ghost;
+ });
+ } else {
+ return (
+
+ );
+ }
+ }
+
+ render() {
+ if (!this.dragment) {
+ return null;
+ }
+
+ return (
+
+ {this.renderGhostGroup()}
+
+ );
+ }
+}
diff --git a/packages/designer/src/builtins/embed-editor.ts b/packages/designer/src/builtins/embed-editor.ts
deleted file mode 100644
index 89368a8e8..000000000
--- a/packages/designer/src/builtins/embed-editor.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import MediumEditor from 'medium-editor';
-import { computed, obx } from '@ali/recore';
-import { current } from './current';
-import ElementNode from '../document/node/element-node';
-
-class EmbedEditor {
- @obx container?: HTMLDivElement | null;
- private _editor?: any;
- @computed getEditor(): any | null {
- if (this._editor) {
- this._editor.destroy();
- this._editor = null;
- }
- const win = current.document!.contentWindow;
- const doc = current.document!.ownerDocument;
- if (!win || !doc || !this.container) {
- return null;
- }
-
- const rect = this.container.getBoundingClientRect();
-
- this._editor = new MediumEditor([], {
- contentWindow: win,
- ownerDocument: doc,
- toolbar: {
- diffLeft: rect.left,
- diffTop: rect.top - 10,
- },
- elementsContainer: this.container,
- });
- return this._editor;
- }
-
- @obx.ref editing?: [ElementNode, string, HTMLElement];
-
- edit(node: ElementNode, prop: string, el: HTMLElement) {
- const ed = this.getEditor();
- if (!ed) {
- return;
- }
- this.exitAndSave();
- console.info(el);
- this.editing = [node, prop, el];
- ed.origElements = el;
- ed.setup();
- }
-
- exitAndSave() {
- this.editing = undefined;
- // removeElements
- // get content save to
- }
-
- mount(container?: HTMLDivElement | null) {
- this.container = container;
- }
-}
-
-export default new EmbedEditor();
diff --git a/packages/designer/src/builtins/simulator/host/README.md b/packages/designer/src/builtins/simulator/host/README.md
new file mode 100644
index 000000000..a2da06fa3
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/host/README.md
@@ -0,0 +1 @@
+主进程
diff --git a/packages/designer/src/builtins/simulator/host/create-simulator.ts b/packages/designer/src/builtins/simulator/host/create-simulator.ts
new file mode 100644
index 000000000..5928a055a
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/host/create-simulator.ts
@@ -0,0 +1,77 @@
+// NOTE: 仅用作类型标注,切勿作为实体使用
+import { SimulatorRenderer } from '../renderer/renderer';
+import { SimulatorHost } from './host';
+import { AssetLevel, AssetList, isAssetBundle, isAssetItem, isCSSUrl, AssetType, assetItem } from '../utils/asset';
+
+export function createSimulator(host: SimulatorHost, iframe: HTMLIFrameElement, vendors: AssetList = []): Promise {
+ const win: any = iframe.contentWindow;
+ const doc = iframe.contentDocument!;
+
+ win.LCSimulatorHost = host;
+
+ const styles: any = {};
+ const scripts: any = {};
+ Object.keys(AssetLevel).forEach((key) => {
+ const v = (AssetLevel as any)[key];
+ styles[v] = [];
+ scripts[v] = [];
+ });
+
+ function parseAssetList(assets: AssetList, level?: AssetLevel) {
+ for (let asset of assets) {
+ if (!asset) {
+ continue;
+ }
+ if (isAssetBundle(asset)) {
+ if (asset.assets) {
+ parseAssetList(Array.isArray(asset.assets) ? asset.assets : [asset.assets], asset.level || level);
+ }
+ continue;
+ }
+ if (Array.isArray(asset)) {
+ parseAssetList(asset, level);
+ continue;
+ }
+ if (!isAssetItem(asset)) {
+ asset = assetItem(isCSSUrl(asset) ? AssetType.CSSUrl : AssetType.JSUrl, asset, level)!;
+ }
+ const id = asset.id ? ` data-id="${asset.id}"` : '';
+ const lv = asset.level || level || AssetLevel.BaseDepends;
+ if (asset.type === 'jsUrl') {
+ (scripts[lv] || scripts[AssetLevel.App]).push(``)
+ } else if (asset.type === 'jsText') {
+ (scripts[lv] || scripts[AssetLevel.App]).push(``);
+ } else if (asset.type === 'cssUrl') {
+ (styles[lv] || styles[AssetLevel.App]).push(``);
+ } else if (asset.type === 'cssText') {
+ (styles[lv] || styles[AssetLevel.App]).push(``);
+ }
+ }
+ }
+
+ parseAssetList(vendors);
+
+ const styleFrags = Object.keys(styles).map(key => {
+ return styles[key].join('\n') + ``;
+ });
+ const scriptFrags = Object.keys(scripts).map(key => {
+ return styles[key].join('\n') + ``;
+ });
+
+ doc.open();
+ doc.write(`
+ ${styleFrags}
+${scriptFrags}`);
+ doc.close();
+
+ return new Promise(resolve => {
+ if (win.SimulatorRenderer || host.renderer) {
+ return resolve(win.SimulatorRenderer || host.renderer);
+ }
+ const loaded = () => {
+ resolve(win.SimulatorRenderer || host.renderer);
+ win.removeEventListener('load', loaded);
+ };
+ win.addEventListener('load', loaded);
+ });
+}
diff --git a/packages/designer/src/builtins/simulator/host/host-view.tsx b/packages/designer/src/builtins/simulator/host/host-view.tsx
new file mode 100644
index 000000000..dddf33338
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/host/host-view.tsx
@@ -0,0 +1,95 @@
+import { Component, createContext } from 'react';
+import { observer } from '@recore/core-obx';
+// import { AuxiliaryView } from '../auxilary';
+import { SimulatorHost, SimulatorProps } from './host';
+import DocumentModel from '../../../designer/document/document-model';
+import './host.less';
+
+/*
+ Simulator 模拟器,可替换部件,有协议约束, 包含画布的容器,使用场景:当 Canvas 大小变化时,用来居中处理 或 定位 Canvas
+ Canvas(DeviceShell) 设备壳层,通过背景图片来模拟,通过设备预设样式改变宽度、高度及定位 CanvasViewport
+ CanvasViewport 页面编排场景中宽高不可溢出 Canvas 区
+ Content(Shell) 内容外层,宽高紧贴 CanvasViewport,禁用边框,禁用 margin
+ ContentFrame 可设置宽高,在页面场景一般只设置框,高度拉伸贴合 Content
+ Auxiliary 辅助显示层,初始相对 Content 位置 0,0,紧贴 Canvas, 根据 Content 滚动位置,改变相对位置
+*/
+
+export const SimulatorContext = createContext({} as any);
+
+export class SimulatorHostView extends Component void;
+}> {
+ readonly host: SimulatorHost;
+ constructor(props: any) {
+ super(props);
+ const { documentContext } = this.props;
+ this.host = (documentContext.simulator as SimulatorHost) || new SimulatorHost(documentContext);
+ }
+ shouldComponentUpdate(nextProps: SimulatorProps) {
+ this.host.setProps(nextProps);
+ return false;
+ }
+ componentDidMount() {
+ if (this.props.onMount) {
+ this.props.onMount(this.host);
+ }
+ }
+ render() {
+ const { Provider } = SimulatorContext;
+ return (
+
+
+ {/*progressing.visible ? : null*/}
+
+
+
+ );
+ }
+}
+
+@observer
+class Canvas extends Component {
+ static contextType = SimulatorContext;
+ render() {
+ const sim = this.context as SimulatorHost;
+ let className = 'lc-simulator-canvas';
+ if (sim.deviceClassName) {
+ className += ` ${sim.deviceClassName}`;
+ } else if (sim.device) {
+ className += ` lc-simulator-device-${sim.device}`;
+ }
+
+ return (
+
+
sim.mountViewport(elmt)} className="lc-simulator-canvas-viewport">
+ {/*
*/}
+
+
+
+ );
+ }
+}
+
+@observer
+class Content extends Component {
+ static contextType = SimulatorContext;
+ render() {
+ const sim = this.context as SimulatorHost;
+ const viewport = sim.viewport;
+ let frameStyle = {};
+ if (viewport.scale < 1) {
+ frameStyle = {
+ transform: `scale(${viewport.scale})`,
+ height: viewport.contentHeight,
+ width: viewport.contentWidth,
+ };
+ }
+
+ return (
+
+
+ );
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/host/host.less b/packages/designer/src/builtins/simulator/host/host.less
new file mode 100644
index 000000000..e6e4f9626
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/host/host.less
@@ -0,0 +1,67 @@
+@scope: lc-simulator;
+
+.@{scope} {
+ position: relative;
+ height: 100%;
+ width: 100%;
+
+ &-canvas {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ overflow: hidden;
+
+ &-viewport {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ }
+ }
+
+ &-device-mobile {
+ left: 50%;
+ width: 460px;
+ transform: translateX(-50%);
+ box-shadow: 0 2px 10px 0 rgba(31,56,88,.15);
+ }
+
+ &-device-iphone6 {
+ left: 50%;
+ width: 368px;
+ transform: translateX(-50%);
+ background: url(https://img.alicdn.com/tps/TB12GetLpXXXXXhXFXXXXXXXXXX-756-1544.png) no-repeat top;
+ background-size: 378px 772px;
+ top: 8px;
+ .@{scope}-canvas-viewport {
+ top: 114px;
+ left: 25px;
+ right: 25px;
+ max-height: 561px;
+ border-radius: 0 0 2px 2px;
+ }
+ }
+
+ &-device-legao {
+ margin: 15px;
+ box-shadow: 0 2px 10px 0 rgba(31,56,88,.15);
+ }
+
+ &-content {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ overflow: hidden;
+ &-frame {
+ border: none;
+ transform-origin: 0 0;
+ height: 100%;
+ width: 100%;
+ }
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/host/host.ts b/packages/designer/src/builtins/simulator/host/host.ts
new file mode 100644
index 000000000..ca8a5620f
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/host/host.ts
@@ -0,0 +1,799 @@
+import { obx, autorun, computed } from '@recore/obx';
+import { ISimulator, ComponentInstance, Component } from '../../../designer/simulator';
+import Viewport from './viewport';
+import { createSimulator } from './create-simulator';
+import { SimulatorRenderer } from '../renderer/renderer';
+import Node, { NodeParent } from '../../../designer/document/node/node';
+import DocumentModel from '../../../designer/document/document-model';
+import ResourceConsumer from './resource-consumer';
+import { AssetLevel, Asset, assetBundle } from '../utils/asset';
+import { DragObjectType, isShaken, LocateEvent, DragNodeObject, DragNodeDataObject } from '../../../designer/dragon';
+import { LocationData } from '../../../designer/location';
+import { NodeData } from '../../../designer/schema';
+import { ComponentDescriptionSpec } from '../../../designer/document/node/component-config';
+
+export interface SimulatorProps {
+ // 从 documentModel 上获取
+ // suspended?: boolean;
+ designMode?: 'live' | 'design' | 'extend' | 'border' | 'preview';
+ device?: 'mobile' | 'iphone' | string;
+ deviceClassName?: string;
+ simulatorUrl?: Asset;
+ dependsAsset?: Asset;
+ themesAsset?: Asset;
+ componentsAsset?: Asset;
+ [key: string]: any;
+}
+
+const publicPath = (document.currentScript as HTMLScriptElement).src.replace(/^(.*\/)[^/]+$/, '$1');
+
+const defaultSimulatorUrl = (() => {
+ let urls;
+ if (process.env.NODE_ENV === 'production') {
+ urls = [`${publicPath}simulator-renderer.min.css`, `${publicPath}simulator-renderer.min.js`];
+ } else {
+ urls = [`${publicPath}simulator-renderer.js`];
+ }
+ return urls;
+})();
+
+const defaultDepends = [
+ {
+ type: 'jsUrl',
+ content:
+ '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',
+ id: 'rect',
+ },
+ {
+ type: 'jsText',
+ content:
+ 'React.PropTypes=window.PropTypes;window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.parent.__REACT_DEVTOOLS_GLOBAL_HOOK__;',
+ },
+];
+
+export class SimulatorHost implements ISimulator {
+ constructor(readonly document: DocumentModel) {}
+
+ readonly designer = this.document.designer;
+
+ private _sensorAvailable: boolean = true;
+ get sensorAvailable(): boolean {
+ return this._sensorAvailable;
+ }
+
+ @computed get device(): string | undefined {
+ // 根据 device 不同来做画布外框样式变化 渲染时可选择不同组件
+ // renderer 依赖
+ return this.get('device');
+ }
+
+ @computed get deviceClassName(): string | undefined {
+ return this.get('deviceClassName');
+ }
+
+ @computed get designMode(): 'live' | 'design' | 'extend' | 'border' | 'preview' {
+ // renderer 依赖
+ // TODO: 需要根据 design mode 不同切换鼠标响应情况
+ return this.get('designMode') || 'design';
+ }
+
+ @computed get componentsAsset(): Asset | undefined {
+ return this.get('componentsAsset');
+ }
+
+ @computed get themesAsset(): Asset | undefined {
+ return this.get('themesAsset');
+ }
+
+ @computed get componentsMap() {
+ // renderer 依赖
+ return this.designer.componentsMap;
+ }
+
+ @obx.ref _props: SimulatorProps = {};
+ setProps(props: SimulatorProps) {
+ this._props = props;
+ }
+ set(key: string, value: any) {
+ this._props = {
+ ...this._props,
+ [key]: value,
+ };
+ }
+ get(key: string): any {
+ return this._props[key];
+ }
+
+ /**
+ * 有 Renderer 进程连接进来,设置同步机制
+ */
+ connect(renderer: SimulatorRenderer, fn: (context: { dispose: () => void; firstRun: boolean }) => void) {
+ this._renderer = renderer;
+ return autorun(fn as any, true);
+ }
+
+ purge(): void {}
+
+ readonly viewport = new Viewport();
+ readonly scroller = this.designer.createScroller(this.viewport);
+
+ mountViewport(viewport: Element | null) {
+ if (!viewport) {
+ return;
+ }
+ 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?: SimulatorRenderer;
+ get renderer() {
+ return this._renderer;
+ }
+
+ readonly componentsConsumer = new ResourceConsumer<{
+ componentsAsset?: Asset;
+ componentsMap: object;
+ }>(() => {
+ return {
+ componentsAsset: this.componentsAsset,
+ componentsMap: this.componentsMap,
+ };
+ });
+
+ readonly injectionConsumer = new ResourceConsumer(() => {
+ return {};
+ });
+
+ async mountContentFrame(iframe: HTMLIFrameElement | null) {
+ if (!iframe) {
+ return;
+ }
+
+ this._contentWindow = iframe.contentWindow!;
+
+ const vendors = [
+ // required & use once
+ assetBundle(this.get('dependsAsset') || defaultDepends, AssetLevel.BaseDepends),
+ // required & TODO: think of update
+ assetBundle(this.themesAsset, AssetLevel.Theme),
+ // required & use once
+ assetBundle(this.get('simulatorUrl') || defaultSimulatorUrl, AssetLevel.Runtime),
+ ];
+
+ // wait 准备 iframe 内容、依赖库注入
+ const renderer = await createSimulator(this, iframe, vendors);
+
+ // wait 业务组件被第一次消费,否则会渲染出错
+ await this.componentsConsumer.waitFirstConsume();
+
+ // wait 运行时上下文
+ await this.injectionConsumer.waitFirstConsume();
+
+ // step 5 ready & render
+ renderer.run();
+
+ // init events, overlays
+ this._contentDocument = this._contentWindow.document;
+ this.viewport.setScrollTarget(this._contentWindow);
+ this.setupEvents();
+ // hotkey.mount(this.contentWindow);
+ // clipboard.injectCopyPaster(this.ownerDocument);
+ }
+
+ setupEvents() {
+ this.setupDragAndClick();
+ this.setupHovering();
+ }
+
+ setupDragAndClick() {
+ const documentModel = this.document;
+ const selection = documentModel.selection;
+ const designer = documentModel.designer;
+ const doc = this.contentDocument!;
+
+ // TODO: think of lock when edit a node
+ // 事件路由
+ doc.addEventListener('mousedown', (downEvent: MouseEvent) => {
+ const target = documentModel.getNodeFromElement(downEvent.target as Element);
+ if (!target) {
+ selection.clear();
+ return;
+ }
+
+ const isMulti = downEvent.metaKey || downEvent.ctrlKey;
+ const isLeftButton = downEvent.which === 1 || downEvent.button === 0;
+
+ if (isLeftButton) {
+ let node: Node = target;
+ let nodes: Node[] = [node];
+ let ignoreUpSelected = false;
+ if (isMulti) {
+ // multi select mode, directily add
+ if (!selection.has(node.id)) {
+ // activeTracker.track(node);
+ selection.add(node.id);
+ ignoreUpSelected = true;
+ }
+ // 获得顶层 nodes
+ nodes = selection.getTopNodes();
+ } else if (selection.containsNode(target)) {
+ nodes = selection.getTopNodes();
+ } else {
+ // will clear current selection & select dragment in dragstart
+ }
+ designer.dragon.boost(
+ {
+ type: DragObjectType.Node,
+ nodes,
+ },
+ downEvent,
+ );
+ if (ignoreUpSelected) {
+ // multi select mode has add selected, should return
+ return;
+ }
+ }
+
+ const checkSelect = (e: MouseEvent) => {
+ doc.removeEventListener('mouseup', checkSelect, true);
+ if (!isShaken(downEvent, e)) {
+ // const node = hasConditionFlow(target) ? target.conditionFlow : target;
+ const node = target;
+ const id = node.id;
+ designer.activeTracker.track(node);
+ if (isMulti && selection.has(id)) {
+ selection.remove(id);
+ } else {
+ selection.select(id);
+ }
+ }
+ };
+ doc.addEventListener('mouseup', checkSelect, true);
+ });
+
+ // cause edit
+ doc.addEventListener('dblclick', (e: MouseEvent) => {
+ // TODO:
+ });
+ }
+
+ private disableHovering?: () => void;
+ /**
+ * 设置悬停处理
+ */
+ setupHovering() {
+ const doc = this.contentDocument!;
+ const hovering = this.document.designer.hovering;
+ const hover = (e: MouseEvent) => {
+ if (!hovering.enable) {
+ return;
+ }
+ const node = this.document.getNodeFromElement(e.target as Element);
+ hovering.hover(node, e);
+ e.stopPropagation();
+ };
+ const leave = () => hovering.leave(this.document);
+
+ doc.addEventListener('mouseover', hover, true);
+ doc.addEventListener('mouseleave', leave, false);
+
+ // TODO: refactor this line, contains click, mousedown, mousemove
+ doc.addEventListener(
+ 'mousemove',
+ (e: Event) => {
+ e.stopPropagation();
+ },
+ true,
+ );
+
+ this.disableHovering = () => {
+ hovering.leave(this.document);
+ doc.removeEventListener('mouseover', hover, true);
+ doc.removeEventListener('mouseleave', leave, false);
+ this.disableHovering = undefined;
+ };
+ }
+
+ setDraggingState(state: boolean): void {
+ throw new Error('Method not implemented.');
+ }
+ isDraggingState(): boolean {
+ throw new Error('Method not implemented.');
+ }
+ setCopyState(state: boolean): void {
+ throw new Error('Method not implemented.');
+ }
+ setSuspense(suspended: boolean) {
+ if (suspended) {
+ if (this.disableHovering) {
+ this.disableHovering();
+ }
+ // sleep some autorun reaction
+ } else {
+ // weekup some autorun reaction
+ if (!this.disableHovering) {
+ this.setupHovering();
+ }
+ }
+ }
+ setDesignMode(mode: string): void {
+ throw new Error('Method not implemented.');
+ }
+ isCopyState(): boolean {
+ throw new Error('Method not implemented.');
+ }
+ clearState(): void {
+ throw new Error('Method not implemented.');
+ }
+
+ describeComponent(component: Component): ComponentDescriptionSpec {
+ throw new Error('Method not implemented.');
+ }
+
+ getComponent(componentName: string): Component {
+ throw new Error('Method not implemented.');
+ }
+
+ getComponentInstance(node: Node): ComponentInstance[] | null {
+ throw new Error('Method not implemented.');
+ }
+
+ getComponentContext(node: Node): object {
+ throw new Error('Method not implemented.');
+ }
+
+ getClosestNodeId(elem: Element): string {
+ throw new Error('Method not implemented.');
+ }
+
+ findDOMNodes(instance: ComponentInstance): (Element | Text)[] | null {
+ throw new Error('Method not implemented.');
+ }
+
+ private tryScrollAgain: number | null = null;
+ scrollToNode(node: Node, detail?: any, tryTimes = 0) {
+ this.tryScrollAgain = null;
+ if (this.sensing) {
+ // actived sensor
+ return;
+ }
+
+ const opt: any = {};
+ let scroll = false;
+
+ if (detail) {
+ // TODO:
+ /*
+ const rect = insertion ? insertion.getNearRect() : node.getRect();
+ let y;
+ let scroll = false;
+ if (insertion && rect) {
+ y = insertion.isNearAfter() ? rect.bottom : rect.top;
+
+ if (y < bounds.top || y > bounds.bottom) {
+ scroll = true;
+ }
+ }*/
+ } else {
+ /*
+ 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);
+ }
+ }
+
+ fixEvent(e: LocateEvent): LocateEvent {
+ /*
+ if (e.fixed) {
+ return e;
+ }
+ if (!e.target || e.originalEvent.view!.document !== this.contentDocument) {
+ e.target = this.contentDocument!.elementFromPoint(e.canvasX, e.canvasY);
+ }*/
+ return e;
+ }
+
+ isEnter(e: LocateEvent): boolean {
+ return false; /*
+ const rect = this.bounds;
+ return e.globalY >= rect.top && e.globalY <= rect.bottom && e.globalX >= rect.left && e.globalX <= rect.right;
+ */
+ }
+
+ private sensing: boolean = false;
+ deactiveSensor() {
+ this.sensing = false;
+ this.scroller.cancel();
+ }
+
+ //#region drag locate logic
+ getDropTarget(e: LocateEvent): NodeParent | LocationData | null {
+ /*
+ const { target, dragTarget } = e;
+ const isAny = isAnyDragTarget(dragTarget);
+ let container: any;
+ if (target) {
+ const ref = this.document.getNodeFromElement(target as Element);
+ if (ref) {
+ container = ref;
+ } else if (isAny) {
+ return null;
+ } else {
+ container = this.document.view;
+ }
+ } else if (isAny) {
+ return null;
+ } else {
+ container = this.document.view;
+ }
+
+ if (!isElementNode(container) && !isRootNode(container)) {
+ container = container.parent;
+ }
+
+ // use spec container to accept specialData
+ if (isAny) {
+ while (container) {
+ if (isRootNode(container)) {
+ return null;
+ }
+ const locationData = this.acceptAnyData(container, e);
+ if (locationData) {
+ return locationData;
+ }
+ container = container.parent;
+ }
+ return null;
+ }
+
+ let res: any;
+ let upward: any;
+ // TODO: improve AT_CHILD logic, mark has checked
+ while (container) {
+ res = this.acceptNodes(container, e);
+ if (isLocationData(res)) {
+ return res;
+ }
+ if (res === true) {
+ return container;
+ }
+ if (!res) {
+ if (upward) {
+ container = upward;
+ upward = null;
+ } else {
+ container = container.parent;
+ }
+ } else if (res === AT_CHILD) {
+ if (!upward) {
+ upward = container.parent;
+ }
+ container = this.getNearByContainer(container, e);
+ if (!container) {
+ container = upward;
+ upward = null;
+ }
+ } else if (isNode(res)) {
+ container = res;
+ upward = null;
+ }
+ }*/
+ return null;
+ }
+
+ acceptNodes(container: Node, e: LocateEvent) {
+ /*
+ const { dragTarget } = e;
+ if (isRootNode(container)) {
+ return this.checkDropTarget(container, dragTarget as any);
+ }
+
+ const proto = container.prototype;
+
+ const acceptable: boolean = this.isAcceptable(container);
+ if (!proto.isContainer && !acceptable) {
+ return false;
+ }
+
+ // check is contains, get common parent
+ if (isNodesDragTarget(dragTarget)) {
+ const nodes = dragTarget.nodes;
+ let i = nodes.length;
+ let p: any = container;
+ while (i-- > 0) {
+ if (contains(nodes[i], p)) {
+ p = nodes[i].parent;
+ }
+ }
+ if (p !== container) {
+ return p || this.document.view;
+ }
+ }
+
+ // 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;
+ }
+ }
+ }
+
+ return this.checkNesting(container, dragTarget as any);
+ */
+ }
+
+ getNearByContainer(container: NodeParent, e: LocateEvent) {
+ /*
+ const children = container.children;
+ if (!children || children.length < 1) {
+ return null;
+ }
+
+ let nearDistance: any = null;
+ let nearBy: any = null;
+ for (let i = 0, l = children.length; i < l; i++) {
+ let child: any = children[i];
+ if (!isElementNode(child)) {
+ continue;
+ }
+ if (hasConditionFlow(child)) {
+ const bn = child.conditionFlow;
+ i = bn.index + bn.length - 1;
+ child = bn.visibleNode;
+ }
+ const rect = this.document.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;*/
+ }
+
+ locate(e: LocateEvent): any {
+ /*
+ this.sensing = true;
+ this.scroller.scrolling(e);
+ const dropTarget = this.getDropTarget(e);
+ if (!dropTarget) {
+ return null;
+ }
+
+ if (isLocationData(dropTarget)) {
+ return this.document.createLocation(dropTarget);
+ }
+
+ const target = dropTarget;
+
+ const edge = this.document.computeRect(target);
+
+ const children = target.children;
+
+ const detail: LocationChildrenDetail = {
+ type: LocationDetailType.Children,
+ index: 0,
+ };
+
+ const locationData = {
+ target,
+ detail,
+ };
+
+ if (!children || children.length < 1 || !edge) {
+ return this.document.createLocation(locationData);
+ }
+
+ let nearRect = null;
+ let nearIndex = 0;
+ let nearNode = null;
+ let nearDistance = null;
+ let top = null;
+ let bottom = null;
+
+ for (let i = 0, l = children.length; i < l; i++) {
+ let node = children[i];
+ let index = i;
+ if (hasConditionFlow(node)) {
+ node = node.conditionFlow;
+ index = node.index;
+ // skip flow items
+ i = index + (node as any).length - 1;
+ }
+ const rect = this.document.computeRect(node);
+
+ if (!rect) {
+ continue;
+ }
+
+ const distance = isPointInRect(e, rect) ? 0 : distanceToRect(e, rect);
+
+ if (distance === 0) {
+ nearDistance = distance;
+ nearNode = node;
+ nearIndex = index;
+ nearRect = rect;
+ break;
+ }
+
+ // TODO: 忘记为什么这么处理了,记得添加注释
+ if (top === null || rect.top < top) {
+ top = rect.top;
+ }
+ if (bottom === null || rect.bottom > bottom) {
+ bottom = 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, nearRect, vertical)) {
+ near.pos = 'after';
+ detail.index = nearIndex + (isConditionFlow(nearNode) ? nearNode.length : 1);
+ }
+ if (!row && nearDistance !== 0) {
+ const edgeDistance = distanceToEdge(e, edge);
+ if (edgeDistance.distance < nearDistance!) {
+ const nearAfter = edgeDistance.nearAfter;
+ if (top == null) {
+ top = edge.top;
+ }
+ if (bottom == null) {
+ bottom = edge.bottom;
+ }
+ near.rect = new DOMRect(edge.left, top, edge.width, bottom - top);
+ near.align = 'H';
+ near.pos = nearAfter ? 'after' : 'before';
+ detail.index = nearAfter ? children.length : 0;
+ }
+ }
+ }
+
+ return this.document.createLocation(locationData);
+ */
+ }
+
+ isAcceptable(container: NodeParent): boolean {
+ return false;
+ /*
+ const proto = container.prototype;
+ const view: any = this.getComponentInstance(container);
+ if (view && '$accept' in view) {
+ return true;
+ }
+ return proto.acceptable;*/
+ }
+
+ acceptAnyData(container: Node, e: LocateEvent | MouseEvent | KeyboardEvent) {
+ /*
+ const proto = container.prototype;
+ const view: any = this.document.getView(container);
+ // use view instance method: $accept
+ if (view && typeof view.$accept === 'function') {
+ // should return LocationData
+ return view.$accept(container, e);
+ }
+ // use prototype method: accept
+ return proto.accept(container, e);*/
+ }
+
+ checkNesting(dropTarget: Node, dragTarget: DragNodeObject | DragNodeDataObject): boolean {
+ return false;
+ /*
+ const items: Array = dragTarget.nodes || (dragTarget as NodeDatasDragTarget).data;
+ return items.every(item => this.checkNestingDown(dropTarget, item));
+ */
+ }
+
+ checkDropTarget(dropTarget: Node, dragTarget: DragNodeObject | DragNodeDataObject): boolean {
+ return false;
+ /*
+ const items: Array = dragTarget.nodes || (dragTarget as NodeDatasDragTarget).data;
+ return items.every(item => this.checkNestingUp(dropTarget, item));
+ */
+ }
+
+ checkNestingUp(parent: NodeParent, target: NodeData | Node): boolean {
+ /*
+ if (isElementNode(target) || isElementData(target)) {
+ const proto = isElementNode(target)
+ ? target.prototype
+ : this.document.getPrototypeByTagNameOrURI(target.tagName, target.uri);
+ if (proto) {
+ return proto.checkNestingUp(target, parent);
+ }
+ }*/
+
+ return true;
+ }
+
+ checkNestingDown(parent: NodeParent, target: NodeData | Node): boolean {
+ /*
+ const proto = parent.prototype;
+ if (isConditionFlow(parent)) {
+ return parent.children.every(
+ child => proto.checkNestingDown(parent, child) && this.checkNestingUp(parent, child),
+ );
+ } else {
+ return proto.checkNestingDown(parent, target) && this.checkNestingUp(parent, target);
+ }*/
+ return false;
+ }
+ //#endregion
+}
diff --git a/packages/designer/src/builtins/simulator/host/index.ts b/packages/designer/src/builtins/simulator/host/index.ts
new file mode 100644
index 000000000..81e73b1e1
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/host/index.ts
@@ -0,0 +1,2 @@
+export * from './host';
+export * from './host-view';
diff --git a/packages/designer/src/builtins/simulator/host/resource-consumer.ts b/packages/designer/src/builtins/simulator/host/resource-consumer.ts
new file mode 100644
index 000000000..f53fa088c
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/host/resource-consumer.ts
@@ -0,0 +1,80 @@
+import { SimulatorRenderer } from '../renderer/renderer';
+import { autorun, obx } from '@recore/obx';
+import { SimulatorHost } from './host';
+import { EventEmitter } from 'events';
+
+const UNSET = Symbol('unset');
+export type MasterProvider = (master: SimulatorHost) => any;
+export type RendererConsumer = (renderer: SimulatorRenderer, data: T) => Promise;
+
+// master 进程
+// 0. 初始化该对象,因为需要响应变更发生在 master 进程
+// 1. 提供消费数据或数据提供器,比如 Asset 资源,如果不是数据提供器,会持续提供
+// 2. 收到成功通知
+// renderer 进程
+// 1. 持续消费,并持续监听数据
+// 2. 消费
+
+// 这里涉及俩个自定义项
+// 1. 被消费数据协议
+// 2. 消费机制(渲染进程自定 + 传递进入)
+
+function isSimulatorRenderer(obj: any): obj is SimulatorRenderer {
+ return obj && obj.isSimulatorRenderer;
+}
+
+export default class ResourceConsumer {
+ private emitter = new EventEmitter();
+ @obx.ref private _data: T | typeof UNSET = UNSET;
+
+ private _providing?: () => void;
+ constructor(provider: () => T, private consumer?: RendererConsumer) {
+ this._providing = autorun(() => {
+ this._data = provider();
+ });
+ }
+
+
+ private _consuming?: () => void;
+ consume(consumerOrRenderer: SimulatorRenderer | ((data: T) => any)) {
+ if (this._consuming) {
+ return;
+ }
+ let consumer: (data: T) => any;
+ if (isSimulatorRenderer(consumerOrRenderer)) {
+ if (!this.consumer) {
+ // TODO: throw error
+ return;
+ }
+ const rendererConsumer = this.consumer!;
+
+ consumer = (data) => rendererConsumer(consumerOrRenderer, data);
+ } else {
+ consumer = consumerOrRenderer;
+ }
+ this._consuming = autorun(async () => {
+ if (this._data === UNSET) {
+ return;
+ }
+ await consumer(this._data);
+ // TODO: catch error and report
+ this.emitter.emit('consume');
+ });
+ }
+
+ dispose() {
+ if (this._providing) {
+ this._providing();
+ }
+ if (this._consuming) {
+ this._consuming();
+ }
+ this.emitter.removeAllListeners();
+ }
+
+ waitFirstConsume(): Promise {
+ return new Promise((resolve) => {
+ this.emitter.once('consume', resolve);
+ });
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/host/viewport.ts b/packages/designer/src/builtins/simulator/host/viewport.ts
new file mode 100644
index 000000000..0c1af7ea6
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/host/viewport.ts
@@ -0,0 +1,135 @@
+import { obx, computed } from '@recore/obx';
+import { Point } from '../../../designer/location';
+import { ScrollTarget } from '../../../designer/scroller';
+import { AutoFit, IViewport } from '../../../designer/simulator';
+
+export default class Viewport implements IViewport {
+ @obx.ref private rect?: DOMRect;
+ private _bounds?: DOMRect;
+ get bounds(): DOMRect {
+ if (this._bounds) {
+ return this._bounds;
+ }
+ this._bounds = this.viewportElement!.getBoundingClientRect();
+ requestAnimationFrame(() => {
+ this._bounds = undefined;
+ });
+ return this._bounds;
+ }
+
+ get contentBounds(): DOMRect {
+ const bounds = this.bounds;
+ const scale = this.scale;
+ return new DOMRect(0, 0, bounds.width / scale, bounds.height / scale);
+ }
+
+ private viewportElement?: Element;
+ mount(viewportElement: Element | null) {
+ if (!viewportElement) {
+ return;
+ }
+ this.viewportElement = viewportElement;
+ this.touch();
+ }
+
+ touch() {
+ if (this.viewportElement) {
+ this.rect = this.bounds;
+ }
+ }
+
+ @computed get height(): number {
+ if (!this.rect) {
+ return 600;
+ }
+ return this.rect.height;
+ }
+ @computed get width(): number {
+ if (!this.rect) {
+ return 1000;
+ }
+ return this.rect.width;
+ }
+
+ /**
+ * 缩放比例
+ */
+ get scale(): number {
+ if (!this.rect || this.contentWidth === AutoFit) {
+ return 1;
+ }
+ return this.width / this.contentWidth;
+ }
+
+ @obx.ref private _contentWidth: number | AutoFit = AutoFit;
+
+ get contentHeight(): number | AutoFit {
+ if (!this.rect || this.scale === 1) {
+ return AutoFit;
+ }
+ return this.height / this.scale;
+ }
+
+ get contentWidth(): number | AutoFit {
+ if (!this.rect || (this._contentWidth !== AutoFit && this._contentWidth <= this.width)) {
+ return AutoFit;
+ }
+ return this._contentWidth;
+ }
+
+ set contentWidth(val: number | AutoFit) {
+ this._contentWidth = val;
+ }
+
+ @obx.ref private _scrollX = 0;
+ @obx.ref private _scrollY = 0;
+ get scrollX() {
+ return this._scrollX;
+ }
+ get scrollY() {
+ return this._scrollY;
+ }
+
+ private _scrollTarget?: ScrollTarget;
+ /**
+ * 滚动对象
+ */
+ get scrollTarget(): ScrollTarget | undefined {
+ return this._scrollTarget;
+ }
+
+ setScrollTarget(target: Window) {
+ const scrollTarget = new ScrollTarget(target);
+ this._scrollX = scrollTarget.left;
+ this._scrollY = scrollTarget.top;
+ target.onscroll = () => {
+ this._scrollX = scrollTarget.left;
+ this._scrollY = scrollTarget.top;
+ };
+ this._scrollTarget = scrollTarget;
+ }
+
+ toGlobalPoint(point: Point): Point {
+ if (!this.viewportElement) {
+ return point;
+ }
+
+ const rect = this.bounds;
+ return {
+ clientX: point.clientX * this.scale + rect.left,
+ clientY: point.clientY * this.scale + rect.top,
+ };
+ }
+
+ toLocalPoint(point: Point): Point {
+ if (!this.viewportElement) {
+ return point;
+ }
+
+ const rect = this.bounds;
+ return {
+ clientX: (point.clientX - rect.left) / this.scale,
+ clientY: (point.clientY - rect.top) / this.scale,
+ };
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/index.ts b/packages/designer/src/builtins/simulator/index.ts
new file mode 100644
index 000000000..ea46f0d31
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/index.ts
@@ -0,0 +1,5 @@
+import { SimulatorHostView } from './host/host-view';
+
+export * from './host/host';
+export * from './host/host-view';
+export default SimulatorHostView;
diff --git a/packages/designer/src/builtins/simulator/renderer/README.md b/packages/designer/src/builtins/simulator/renderer/README.md
new file mode 100644
index 000000000..d218758d5
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/renderer/README.md
@@ -0,0 +1 @@
+沙箱环境
diff --git a/packages/designer/src/builtins/simulator/renderer/host.ts b/packages/designer/src/builtins/simulator/renderer/host.ts
new file mode 100644
index 000000000..43dd7be1d
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/renderer/host.ts
@@ -0,0 +1,4 @@
+// NOTE: 仅做类型标注,切勿做其它用途
+import { SimulatorHost } from '../host';
+
+export const host: SimulatorHost = (window as any).LCSimulatorHost;
diff --git a/packages/designer/src/builtins/simulator/renderer/index.ts b/packages/designer/src/builtins/simulator/renderer/index.ts
new file mode 100644
index 000000000..3a8872665
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/renderer/index.ts
@@ -0,0 +1,7 @@
+import renderer from './renderer';
+
+if (typeof window !== 'undefined') {
+ (window as any).SimulatorRenderer = renderer;
+}
+
+export default renderer;
diff --git a/packages/designer/src/builtins/simulator/renderer/renderer-view.tsx b/packages/designer/src/builtins/simulator/renderer/renderer-view.tsx
new file mode 100644
index 000000000..86974f3c9
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/renderer/renderer-view.tsx
@@ -0,0 +1,69 @@
+// import { Engine as LowCodeRenderer } from '@ali/iceluna-sdk';
+import { ReactInstance, Fragment, Component } from 'react';
+import { observer } from '@recore/core-obx';
+import { SimulatorRenderer } from './renderer';
+import './renderer.less';
+
+export default class SimulatorRendererView extends Component<{ renderer: SimulatorRenderer }> {
+ render() {
+ const { renderer } = this.props;
+ return (
+
+
+
+ );
+ }
+}
+
+@observer
+class Layout extends Component<{ renderer: SimulatorRenderer; }> {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ const { renderer, children } = this.props;
+ const layout = renderer.layout;
+
+ if (layout) {
+ const { Component, props } = layout;
+ return {children};
+ }
+
+ return {children};
+ }
+}
+
+@observer
+class Renderer extends Component<{ renderer: SimulatorRenderer }> {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ const { renderer } = this.props;
+ return (
+ {
+ renderer.mountInstance(schema.id, ref);
+ }}
+ onComponentGetCtx={(schema: any, ctx: object) => {
+ renderer.mountContext(schema.id, ctx);
+ }}
+ />
+ );
+ }
+}
+
+
+class LowCodeRenderer extends Component {
+ render() {
+ return {JSON.stringify(this.props.schema)}
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/renderer/renderer.less b/packages/designer/src/builtins/simulator/renderer/renderer.less
new file mode 100644
index 000000000..337b719c7
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/renderer/renderer.less
@@ -0,0 +1,10 @@
+html {
+ padding-bottom: 30px;
+ background: transparent !important;
+}
+
+body, html {
+ display: block;
+ min-height: 100%;
+ background: white;
+}
diff --git a/packages/designer/src/builtins/simulator/renderer/renderer.ts b/packages/designer/src/builtins/simulator/renderer/renderer.ts
new file mode 100644
index 000000000..541be81da
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/renderer/renderer.ts
@@ -0,0 +1,259 @@
+import { createElement, ReactInstance } from 'react';
+import { render as reactRender } from 'react-dom';
+import { host } from './host';
+import SimulatorRendererView from './renderer-view';
+import { computed, obx } from '@recore/obx';
+import { RootSchema, NpmInfo } from '../../../designer/schema';
+import { isElement } from '../../../utils/dom';
+import { Asset } from '../utils/asset';
+import loader from '../utils/loader';
+import { ComponentDescriptionSpec } from '../../../designer/document/node/component-config';
+
+let REACT_KEY = '';
+function cacheReactKey(el: Element): Element {
+ if (REACT_KEY !== '') {
+ return el;
+ }
+ REACT_KEY = Object.keys(el).find(key => key.startsWith('__reactInternalInstance$')) || '';
+ if (!REACT_KEY && (el as HTMLElement).parentElement) {
+ return cacheReactKey((el as HTMLElement).parentElement!);
+ }
+ return el;
+}
+
+const SYMBOL_VNID = Symbol('_LCNodeId');
+
+function getClosestNodeId(element: Element): string | null {
+ let el: any = element;
+ if (el) {
+ el = cacheReactKey(el);
+ }
+ while (el) {
+ if (SYMBOL_VNID in el) {
+ return el[SYMBOL_VNID];
+ }
+ if (el[REACT_KEY]) {
+ return getNodeId(el[REACT_KEY]);
+ }
+ el = el.parentElement;
+ }
+ return null;
+}
+
+function getNodeId(instance: any): string {
+ if (instance.stateNode && SYMBOL_VNID in instance.stateNode) {
+ return instance.stateNode[SYMBOL_VNID];
+ }
+ return getNodeId(instance.return);
+}
+
+function checkInstanceMounted(instance: any): boolean {
+ if (isElement(instance)) {
+ return instance.parentElement != null;
+ }
+ return true;
+}
+
+export class SimulatorRenderer {
+ readonly isSimulatorRenderer = true;
+ private dispose?: () => void;
+ constructor() {
+ if (!host) {
+ return;
+ }
+ this.dispose = host.connect(this, () => {
+ // sync layout config
+
+ // sync schema
+ this._schema = host.document.schema;
+
+ // sync designMode
+
+ // sync suspended
+
+ // sync scope
+
+ // sync device
+ });
+ host.componentsConsumer.consume(async (data) => {
+ if (data.componentsAsset) {
+ await this.load(data.componentsAsset);
+ }
+
+ // sync componetsMap
+ this._componentsMap = data.componentsMap;
+ });
+ host.injectionConsumer.consume((data) => {
+ // sync utils, i18n, contants,... config
+ this._appContext = {
+ utils: {},
+ constants: {
+ name: 'demo',
+ },
+ };
+ });
+ }
+
+ @computed get layout(): any {
+ // TODO: parse layout Component
+ return null;
+ }
+
+ @obx.ref private _schema?: RootSchema;
+ @computed get schema(): any {
+ return this._schema;
+ }
+ @obx.ref private _componentsMap = {};
+ @computed get components(): object {
+ // 根据 device 选择不同组件,进行响应式
+ // 更好的做法是,根据 device 选择加载不同的组件资源,甚至是 simulatorUrl
+ return buildComponents(this._componentsMap);
+ }
+ // context from: utils、constants、history、location、match
+ @obx.ref private _appContext = {};
+ @computed get context(): any {
+ return this._appContext;
+ }
+
+ @computed get designMode(): any {
+ return 'border';
+ }
+ @computed get componentsMap(): any {
+ return this._componentsMap;
+ }
+ @computed get suspended(): any {
+ return false;
+ }
+ @computed get scope(): any {
+ return null;
+ }
+ /**
+ * 加载资源
+ */
+ load(asset: Asset): Promise {
+ return loader.load(asset);
+ }
+ private instancesMap = new Map();
+ mountInstance(id: string, instance: ReactInstance | null) {
+ const instancesMap = this.instancesMap;
+ if (instance == null) {
+ let instances = this.instancesMap.get(id);
+ if (instances) {
+ instances = instances.filter(checkInstanceMounted);
+ if (instances.length > 0) {
+ instancesMap.set(id, instances);
+ } else {
+ instancesMap.delete(id);
+ }
+ }
+ return;
+ }
+ if (isElement(instance)) {
+ cacheReactKey(instance);
+ } else if (!(instance as any)[SYMBOL_VNID]) {
+ const origUnmout = instance.componentWillUnmount;
+ // hack! delete instance from map
+ instance.componentWillUnmount = function() {
+ const instances = instancesMap.get(id);
+ if (instances) {
+ const i = instances.indexOf(instance);
+ if (i > -1) {
+ instances.splice(i, 1);
+ }
+ }
+ origUnmout && origUnmout.call(this);
+ };
+ }
+
+ (instance as any)[SYMBOL_VNID] = id;
+ let instances = this.instancesMap.get(id);
+ if (instances) {
+ instances = instances.filter(checkInstanceMounted);
+ instances.push(instance);
+ instancesMap.set(id, instances);
+ } else {
+ instancesMap.set(id, [instance]);
+ }
+ }
+ private ctxMap = new Map();
+ mountContext(id: string, ctx: object) {
+ this.ctxMap.set(id, ctx);
+ }
+
+ getComponentInstance(id: string): ReactInstance[] | null {
+ return this.instancesMap.get(id) || null;
+ }
+
+ getClosestNodeId(element: Element): string | null {
+ return getClosestNodeId(element);
+ }
+
+ private _running: boolean = false;
+ run() {
+ if (this._running) {
+ return;
+ }
+ this._running = true;
+ const containerId = 'app';
+ let container = document.getElementById(containerId);
+ if (!container) {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ container.id = containerId;
+ }
+
+ reactRender(createElement(SimulatorRendererView, { renderer: this }), container);
+ }
+}
+
+function accessLibrary(library: string | object) {
+ if (typeof library !== 'string') {
+ return library;
+ }
+
+ return (window as any)[library];
+}
+
+function getSubComponent(component: any, paths: string[]) {
+ const l = paths.length;
+ if (l < 1) {
+ return component;
+ }
+ let i = 0;
+ while (i < l) {
+ const key = paths[i]!;
+ try {
+ component = (component as any)[key];
+ } catch (e) {
+ return null;
+ }
+ if (!component) {
+ return null;
+ }
+ i++;
+ }
+ return component;
+}
+
+function findComponent(componentName: string, npm?: NpmInfo) {
+ if (!npm) {
+ return accessLibrary(componentName);
+ }
+ const libraryName = npm.exportName || npm.componentName || componentName;
+ const component = accessLibrary(libraryName);
+ const paths = npm.subName ? npm.subName.split('.') : [];
+ if (npm.destructuring) {
+ paths.unshift(libraryName);
+ }
+ return getSubComponent(component, paths);
+}
+
+function buildComponents(componentsMap: { [componentName: string]: ComponentDescriptionSpec }) {
+ const components: any = {};
+ Object.keys(componentsMap).forEach(componentName => {
+ components[componentName] = findComponent(componentName, componentsMap[componentName].npm);
+ });
+ return components;
+}
+
+export default new SimulatorRenderer();
diff --git a/packages/designer/src/builtins/simulator/utils/asset.ts b/packages/designer/src/builtins/simulator/utils/asset.ts
new file mode 100644
index 000000000..e37bcf831
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/utils/asset.ts
@@ -0,0 +1,76 @@
+export interface AssetItem {
+ type: AssetType;
+ content?: string | null;
+ level?: AssetLevel;
+ id?: string;
+}
+
+export enum AssetLevel {
+ // 基础依赖库
+ BaseDepends = 1,
+ // 基础组件库
+ BaseComponents = 2,
+ // 主题包
+ Theme = 3,
+ // 运行时
+ Runtime = 4,
+ // 业务组件
+ Components = 5,
+ // 应用 & 页面
+ App = 6,
+}
+
+export type URL = string;
+
+export enum AssetType {
+ JSUrl = 'jsUrl',
+ CSSUrl = 'cssUrl',
+ CSSText = 'cssText',
+ JSText = 'jsText',
+ Bundle = 'bundel',
+}
+
+export interface AssetBundle {
+ type: AssetType.Bundle;
+ level?: AssetLevel;
+ assets?: Asset | AssetList | null;
+}
+
+export type Asset = AssetList | AssetBundle | AssetItem | URL;
+
+export type AssetList = Array;
+
+export function isAssetItem(obj: any): obj is AssetItem {
+ return obj && obj.type;
+}
+
+export function isAssetBundle(obj: any): obj is AssetBundle {
+ return obj && obj.type === AssetType.Bundle;
+}
+
+export function isCSSUrl(url: string): boolean {
+ return /\.css$/.test(url);
+}
+
+export function assetBundle(assets?: Asset | AssetList | null, level?: AssetLevel): AssetBundle | null {
+ if (!assets) {
+ return null;
+ }
+ return {
+ type: AssetType.Bundle,
+ assets,
+ level,
+ };
+}
+
+export function assetItem(type: AssetType, content?: string | null, level?: AssetLevel, id?: string): AssetItem | null {
+ if (content) {
+ return null;
+ }
+ return {
+ type,
+ content,
+ level,
+ id,
+ };
+}
diff --git a/packages/designer/src/utils/cursor.less b/packages/designer/src/builtins/simulator/utils/cursor.less
similarity index 100%
rename from packages/designer/src/utils/cursor.less
rename to packages/designer/src/builtins/simulator/utils/cursor.less
diff --git a/packages/designer/src/utils/cursor.ts b/packages/designer/src/builtins/simulator/utils/cursor.ts
similarity index 100%
rename from packages/designer/src/utils/cursor.ts
rename to packages/designer/src/builtins/simulator/utils/cursor.ts
diff --git a/packages/designer/src/builtins/simulator/utils/intrinsic-mocks.ts b/packages/designer/src/builtins/simulator/utils/intrinsic-mocks.ts
new file mode 100644
index 000000000..a269fde2c
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/utils/intrinsic-mocks.ts
@@ -0,0 +1,284 @@
+import { ReactElement, createElement, ReactType } from 'react';
+import classNames from 'classnames';
+
+const mocksCache = new Map ReactElement>();
+// endpoint element: input,select,video,audio,canvas,textarea
+//
+function getBlockElement(tag: string): (props: any) => ReactElement {
+ if (mocksCache.has(tag)) {
+ return mocksCache.get(tag)!;
+ }
+ const mock = ({ className, children, ...rest }: any = {}) => {
+ const props = {
+ ...rest,
+ className: classNames('my-intrinsic-container', className),
+ };
+ return createElement(tag, props, children);
+ };
+
+ mock.prototypeConfig = {
+ uri: `@html:${tag}`,
+ selfControlled: true,
+ ...(prototypeMap as any)[tag],
+ };
+
+ mocksCache.set(tag, mock);
+ return mock;
+}
+
+const HTMLBlock = [
+ 'div',
+ 'p',
+ 'article',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'aside',
+ 'blockquote',
+ 'footer',
+ 'form',
+ 'header',
+ 'table',
+ 'tbody',
+ 'section',
+ 'ul',
+ 'li',
+ 'span',
+];
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const HTMLInlineBlock = ['a', 'b', 'span', 'em'];
+export function getIntrinsicMock(tag: string): ReactType {
+ if (HTMLBlock.indexOf(tag) > -1) {
+ return getBlockElement(tag);
+ }
+
+ return tag as any;
+}
+
+const prototypeMap = {
+ div: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: 'p',
+ },
+ },
+ ul: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ childWhitelist: 'li',
+ },
+ },
+ p: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: 'button,p',
+ },
+ },
+ li: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ parentWhitelist: 'ui,ol',
+ },
+ },
+ span: {
+ isContainer: true,
+ selfControlled: true,
+ },
+ a: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!a',
+ },
+ },
+ b: {
+ isContainer: true,
+ selfControlled: true,
+ },
+ strong: {
+ isContainer: true,
+ selfControlled: true,
+ },
+ em: {
+ isContainer: true,
+ selfControlled: true,
+ },
+ i: {
+ isContainer: true,
+ selfControlled: true,
+ },
+ form: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!form,!button',
+ },
+ },
+ table: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ caption: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ select: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ button: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ input: {
+ isContainer: false,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button,!h1,!h2,!h3,!h4,!h5',
+ },
+ },
+ textarea: {
+ isContainer: false,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ image: {
+ isContainer: false,
+ selfControlled: true,
+ },
+ canvas: {
+ isContainer: false,
+ selfControlled: true,
+ },
+ br: {
+ isContainer: false,
+ selfControlled: true,
+ },
+ h1: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!p,!h1,!h2,!h3,!h4,!h5,!h6,!button',
+ },
+ },
+ h2: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!p,!h1,!h2,!h3,!h4,!h5,!h6,!button',
+ },
+ },
+ h3: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!p,!h1,!h2,!h3,!h4,!h5,!h6,!button',
+ },
+ },
+ h4: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!p,!h1,!h2,!h3,!h4,!h5,!h6,!button',
+ },
+ },
+ h5: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!p,!h1,!h2,!h3,!h4,!h5,!h6,!button',
+ },
+ },
+ h6: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!p,!h1,!h2,!h3,!h4,!h5,!h6,!button',
+ },
+ },
+ article: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ aside: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ footer: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ header: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ blockquote: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ address: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ section: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!p,!h1,!h2,!h3,!h4,!h5,!h6,!button',
+ },
+ },
+ summary: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+ nav: {
+ isContainer: true,
+ selfControlled: true,
+ nesting: {
+ ancestorBlacklist: '!button',
+ },
+ },
+};
diff --git a/packages/designer/src/builtins/simulator/utils/loader.ts b/packages/designer/src/builtins/simulator/utils/loader.ts
new file mode 100644
index 000000000..097912697
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/utils/loader.ts
@@ -0,0 +1,105 @@
+import { load, evaluate } from './script';
+import StylePoint from './style';
+import { Asset, AssetLevel, AssetType, AssetList, isAssetBundle, isAssetItem, assetItem, isCSSUrl, AssetItem } from './asset';
+
+function parseAssetList(scripts: any, styles: any, assets: AssetList, level?: AssetLevel) {
+ for (let asset of assets) {
+ parseAsset(scripts, styles, asset, level);
+ }
+}
+
+function parseAsset(scripts: any, styles: any, asset: Asset | undefined | null, level?: AssetLevel) {
+ if (!asset) {
+ return;
+ }
+ if (Array.isArray(asset)) {
+ return parseAssetList(scripts, styles, asset, level);
+ }
+
+ if (isAssetBundle(asset)) {
+ if (asset.assets) {
+ if (Array.isArray(asset.assets)) {
+ parseAssetList(scripts, styles, asset.assets, asset.level || level);
+ } else {
+ parseAsset(scripts, styles, asset.assets, asset.level || level);
+ }
+ return;
+ }
+ return;
+ }
+
+ if (!isAssetItem(asset)) {
+ asset = assetItem(isCSSUrl(asset) ? AssetType.CSSUrl : AssetType.JSUrl, asset, level)!;
+ }
+
+ let lv = asset.level || level;
+
+ if (!lv || AssetLevel[lv] == null) {
+ lv = AssetLevel.App
+ }
+
+ asset.level = lv;
+ if (asset.type === AssetType.CSSUrl || asset.type == AssetType.CSSText) {
+ styles[lv].push(asset);
+ } else {
+ scripts[lv].push(asset);
+ }
+}
+
+export class AssetLoader {
+ async load(asset: Asset) {
+ const styles: any = {};
+ const scripts: any = {};
+ Object.keys(AssetLevel).forEach((key) => {
+ const v = (AssetLevel as any)[key];
+ styles[v] = [];
+ scripts[v] = [];
+ });
+ parseAsset(scripts, styles, asset);
+ const styleQueue: AssetItem[] = styles[AssetLevel.BaseDepends].concat(
+ styles[AssetLevel.BaseComponents],
+ styles[AssetLevel.Theme],
+ styles[AssetLevel.Runtime],
+ styles[AssetLevel.App],
+ );
+ const scriptQueue: AssetItem[] = scripts[AssetLevel.BaseDepends].concat(
+ scripts[AssetLevel.BaseComponents],
+ scripts[AssetLevel.Theme],
+ scripts[AssetLevel.Runtime],
+ scripts[AssetLevel.App],
+ );
+ await Promise.all(
+ styleQueue.map(({ content, level, type, id }) => this.loadStyle(content, level!, type === AssetType.CSSUrl, id)),
+ );
+ await Promise.all(
+ scriptQueue.map(({ content, type }) => this.loadScript(content, type === AssetType.JSUrl)),
+ );
+ }
+
+ private stylePoints = new Map();
+ private loadStyle(content: string | undefined | null, level: AssetLevel, isUrl?: boolean, id?: string) {
+ if (!content) {
+ return;
+ }
+ let point: StylePoint | undefined;
+ if (id) {
+ point = this.stylePoints.get(id);
+ if (!point) {
+ point = new StylePoint(level, id);
+ this.stylePoints.set(id, point);
+ }
+ } else {
+ point = new StylePoint(level);
+ }
+ return isUrl ? point.applyUrl(content) : point.applyText(content);
+ }
+
+ private loadScript(content: string | undefined | null, isUrl?: boolean) {
+ if (!content) {
+ return;
+ }
+ return isUrl ? load(content) : evaluate(content);
+ }
+}
+
+export default new AssetLoader();
diff --git a/packages/designer/src/utils/react.ts b/packages/designer/src/builtins/simulator/utils/react.ts
similarity index 93%
rename from packages/designer/src/utils/react.ts
rename to packages/designer/src/builtins/simulator/utils/react.ts
index 33feb4c70..3623c8e0d 100644
--- a/packages/designer/src/utils/react.ts
+++ b/packages/designer/src/builtins/simulator/utils/react.ts
@@ -1,5 +1,5 @@
import { ReactInstance } from 'react';
-import { isDOMNode, isElement } from './dom';
+import { isDOMNode, isElement } from '../../../utils/dom';
const FIBER_KEY = '_reactInternalFiber';
diff --git a/packages/designer/src/utils/script.ts b/packages/designer/src/builtins/simulator/utils/script.ts
similarity index 63%
rename from packages/designer/src/utils/script.ts
rename to packages/designer/src/builtins/simulator/utils/script.ts
index 9577946c3..97e1001aa 100644
--- a/packages/designer/src/utils/script.ts
+++ b/packages/designer/src/builtins/simulator/utils/script.ts
@@ -1,4 +1,4 @@
-import { createDefer } from './create-defer';
+import { createDefer } from '../../../utils/create-defer';
export function evaluate(script: string) {
const scriptEl = document.createElement('script');
@@ -36,3 +36,19 @@ export function load(url: string) {
return i.promise();
}
+
+export function evaluateExpression(expr: string) {
+ // eslint-disable-next-line no-new-func
+ const fn = new Function(expr);
+ return fn();
+}
+
+export function newFunction(args: string, code: string) {
+ try {
+ // eslint-disable-next-line no-new-func
+ return new Function(args, code);
+ } catch (e) {
+ console.warn('Caught error, Cant init func');
+ return null;
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/utils/style.ts b/packages/designer/src/builtins/simulator/utils/style.ts
new file mode 100644
index 000000000..cd29c793a
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/utils/style.ts
@@ -0,0 +1,73 @@
+import { createDefer } from '../../../utils/create-defer';
+
+export default class StylePoint {
+ private lastContent: string | undefined;
+ private lastUrl: string | undefined;
+ private placeholder: Element | Text;
+
+ constructor(readonly level: number, readonly id?: string) {
+ let placeholder: any;
+ if (id) {
+ placeholder = document.head.querySelector(`style[data-id="${id}"]`);
+ }
+ if (!placeholder) {
+ placeholder = document.createTextNode('');
+ const meta = document.head.querySelector(`meta[level="${level}"]`);
+ if (meta) {
+ document.head.insertBefore(placeholder, meta);
+ } else {
+ document.head.appendChild(placeholder);
+ }
+ }
+ this.placeholder = placeholder;
+ }
+
+ applyText(content: string) {
+ if (this.lastContent === content) {
+ return;
+ }
+ this.lastContent = content;
+ this.lastUrl = undefined;
+ const element = document.createElement('style');
+ element.setAttribute('type', 'text/css');
+ if (this.id) {
+ element.setAttribute('data-id', this.id);
+ }
+ element.appendChild(document.createTextNode(content));
+ document.head.insertBefore(element, this.placeholder.parentNode === document.head ? this.placeholder.nextSibling : null);
+ document.head.removeChild(this.placeholder);
+ this.placeholder = element;
+ }
+
+ applyUrl(url: string) {
+ if (this.lastUrl === url) {
+ return;
+ }
+ this.lastContent = undefined;
+ this.lastUrl = url;
+ const element = document.createElement('link');
+ element.onload = onload;
+ element.onerror = onload;
+
+ const i = createDefer();
+ function onload(e: any) {
+ element.onload = null;
+ element.onerror = null;
+ if (e.type === 'load') {
+ i.resolve();
+ } else {
+ i.reject();
+ }
+ }
+
+ element.href = url;
+ element.rel = 'stylesheet';
+ if (this.id) {
+ element.setAttribute('data-id', this.id);
+ }
+ document.head.insertBefore(element, this.placeholder.parentNode === document.head ? this.placeholder.nextSibling : null);
+ document.head.removeChild(this.placeholder);
+ this.placeholder = element;
+ return i.promise();
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/utils/vision-types.js b/packages/designer/src/builtins/simulator/utils/vision-types.js
new file mode 100644
index 000000000..86a8a42b5
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/utils/vision-types.js
@@ -0,0 +1,198 @@
+import PropTypes from 'prop-types';
+
+export const primitiveTypeMaps = {
+ string: {
+ defaultValue: '',
+ display: 'inline',
+ setter: 'TextSetter',
+ },
+ number: {
+ display: 'inline',
+ setter: 'NumberSetter' // extends TextSetter
+ },
+ array: {
+ defaultValue: [],
+ display: 'inline',
+ // itemType: any
+ setter: 'ArraySetter' // extends ExpressionSetter
+ },
+ bool: {
+ defaultValue: false,
+ display: 'inline',
+ setter: 'BoolSetter'
+ },
+ func: {
+ defaultValue: () => {},
+ display: 'inline',
+ setter: 'FunctionSetter' // extends ExpressionSetter
+ },
+ object: {
+ defaultValue: {},
+ display: 'inline',
+ // itemType: any
+ setter: 'ObjectSetter' // extends ExpressionSetter
+ },
+ // Anything that can be rendered: numbers, strings, elements or an array
+ // (or fragment) containing these types.
+ node: {
+ defaultValue: '',
+ display: 'inline',
+ setter: 'FragmentSetter',
+ },
+ // A React element.
+ element: {
+ display: 'inline',
+ setter: 'JSXSetter', // extends ExpressionSetter
+ },
+ symbol: {
+ display: 'inline',
+ setter: 'ExpressionSetter',
+ },
+ any: {
+ display: 'inline',
+ setter: 'ExpressionSetter',
+ }
+};
+
+function makeRequired(propType, visionType) {
+ function visionCheckTypeIsRequired(...rest) {
+ return propType.isRequired(...rest);
+ }
+ visionCheckTypeIsRequired.visionType = {
+ ...visionType,
+ required: true,
+ };
+ return visionCheckTypeIsRequired;
+}
+
+function define(propType = PropTypes.any, visionType = {}) {
+ if (!propType._inner && propType.name !== 'visionCheckType') {
+ propType.visionType = visionType;
+ }
+ function visionCheckType(...rest) {
+ return propType(...rest);
+ }
+ visionCheckType.visionType = visionType;
+ visionCheckType.isRequired = makeRequired(propType, visionType);
+ return visionCheckType;
+}
+
+const VisionTypes = {
+ ...PropTypes,
+ define,
+};
+
+export default VisionTypes;
+
+// override primitive type chechers
+Object.keys(primitiveTypeMaps).forEach((type) => {
+ const propType = PropTypes[type];
+ if (!propType) {
+ return;
+ }
+ propType._inner = true;
+ VisionTypes[type] = define(propType, primitiveTypeMaps[type]);
+});
+
+// You can ensure that your prop is limited to specific values by treating
+// it as an enum.
+VisionTypes.oneOf = (list) => {
+ return define(PropTypes.oneOf(list), {
+ defaultValue: list && list[0],
+ display: 'inline',
+ setter: {
+ type: 'SelectSetter',
+ options: list,
+ },
+ });
+};
+
+// An array of a certain type
+VisionTypes.arrayOf = (type) => {
+ return define(PropTypes.arrayOf(type), {
+ defaultValue: [],
+ display: 'inline',
+ setter: {
+ type: 'ArraySetter', // list
+ itemType: type.visionType || primitiveTypeMaps.any, // addable type
+ }
+ });
+};
+
+// An object with property values of a certain type
+VisionTypes.objectOf = (type) => {
+ return define(PropTypes.objectOf(type), {
+ defaultValue: {},
+ display: 'inline',
+ setter: {
+ type: 'ObjectSetter', // all itemType
+ itemType: type.visionType || primitiveTypeMaps.any, // addable type
+ }
+ });
+};
+
+// An object that could be one of many types
+VisionTypes.oneOfType = (types) => {
+ const itemType = types.map(type => type.visionType || primitiveTypeMaps.any);
+ return define(PropTypes.oneOfType(types), {
+ defaultValue: itemType[0] && itemType[0].defaultValue,
+ display: 'inline',
+ setter: {
+ type: 'OneOfTypeSetter',
+ itemType, // addable type
+ },
+ });
+};
+
+
+// You can also declare that a prop is an instance of a class. This uses
+// JS's instanceof operator.
+VisionTypes.instanceOf = (classType) => {
+ return define(PropTypes.instanceOf(classType), {
+ display: 'inline',
+ setter: 'ExpressionSetter',
+ });
+};
+
+// An object with warnings on extra properties
+VisionTypes.exact = (typesMap) => {
+ const exactTypes = {};
+ const defaultValue = {};
+ Object.keys(typesMap).forEach(key => {
+ exactTypes[key] = typesMap[key].visionType || primitiveTypeMaps.any;
+ defaultValue[key] = exactTypes[key].defaultValue;
+ });
+ return define(PropTypes.exact(typesMap), {
+ defaultValue,
+ display: 'inline',
+ setter: {
+ type: 'ObjectSetter', // all itemType
+ exactTypes,
+ },
+ });
+}
+
+// An object taking on a particular shape
+VisionTypes.shape = (typesMap) => {
+ const exactTypes = {};
+ const defaultValue = {};
+ Object.keys(typesMap).forEach(key => {
+ exactTypes[key] = typesMap[key].visionType || primitiveTypeMaps.any;
+ defaultValue[key] = exactTypes[key].defaultValue;
+ });
+ return define(PropTypes.shape(typesMap), {
+ defaultValue,
+ display: 'inline',
+ setter: {
+ type: 'ObjectSetter', // all itemType
+ exactTypes,
+ itemType: primitiveTypeMaps.any, // addable type
+ },
+ });
+};
+
+
+// color
+// time
+// date
+// range
diff --git a/packages/designer/src/designer/active-tracker.ts b/packages/designer/src/designer/active-tracker.ts
new file mode 100644
index 000000000..3bcdc7b7e
--- /dev/null
+++ b/packages/designer/src/designer/active-tracker.ts
@@ -0,0 +1,23 @@
+import { EventEmitter } from 'events';
+import { LocationDetail } from './location';
+import Node, { isNode } from './document/node/node';
+
+interface ActiveTarget {
+ node: Node;
+ detail?: LocationDetail;
+}
+
+export default class ActiveTracker {
+ private emitter = new EventEmitter();
+
+ track(target: ActiveTarget | Node) {
+ this.emitter.emit('change', isNode(target) ? { node: target } : target);
+ }
+
+ onChange(fn: (target: ActiveTarget) => void): () => void {
+ this.emitter.addListener('change', fn);
+ return () => {
+ this.emitter.removeListener('change', fn);
+ };
+ }
+}
diff --git a/packages/designer/src/designer/designer-view.tsx b/packages/designer/src/designer/designer-view.tsx
new file mode 100644
index 000000000..6611fb1f3
--- /dev/null
+++ b/packages/designer/src/designer/designer-view.tsx
@@ -0,0 +1,51 @@
+import { Component } from 'react';
+import classNames from 'classnames';
+import Designer, { DesignerProps } from './designer';
+import BuiltinDragGhostComponent from '../builtins/drag-ghost';
+import ProjectView from './project-view';
+import './designer.less';
+
+export default class DesignerView extends Component {
+ readonly designer: Designer;
+
+ constructor(props: any) {
+ super(props);
+ this.designer = new Designer(props);
+ }
+
+ shouldComponentUpdate(nextProps: DesignerProps) {
+ this.designer.setProps(nextProps);
+ const props = this.props;
+ if (
+ nextProps.className !== props.className ||
+ nextProps.style != props.style ||
+ nextProps.dragGhostComponent !== props.dragGhostComponent
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ componentDidMount() {
+ const { onMount } = this.props;
+ if (onMount) {
+ onMount(this.designer);
+ }
+ }
+
+ componentWillMount() {
+ this.designer.purge();
+ }
+
+ render() {
+ const { className, style, dragGhostComponent } = this.props;
+ const DragGhost = dragGhostComponent || BuiltinDragGhostComponent;
+
+ return (
+
+ );
+ }
+}
diff --git a/packages/designer/src/designer/designer.less b/packages/designer/src/designer/designer.less
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/designer/src/designer/designer.ts b/packages/designer/src/designer/designer.ts
index 9d09844c7..b2ec25c2d 100644
--- a/packages/designer/src/designer/designer.ts
+++ b/packages/designer/src/designer/designer.ts
@@ -1,6 +1,6 @@
import { ComponentType } from 'react';
import { obx, computed } from '@recore/obx';
-import { SimulatorView as BuiltinSimulatorView } from '../builtins/simulator';
+import BuiltinSimulatorView from '../builtins/simulator';
import Project from './project';
import { ProjectSchema } from './schema';
import Dragon, { isDragNodeObject, isDragNodeDataObject, LocateEvent, DragObject } from './dragon';
diff --git a/packages/designer/src/designer/document/document-model.ts b/packages/designer/src/designer/document/document-model.ts
new file mode 100644
index 000000000..ee72f214c
--- /dev/null
+++ b/packages/designer/src/designer/document/document-model.ts
@@ -0,0 +1,349 @@
+import Project from '../project';
+import { RootSchema, NodeData, isDOMText, isJSExpression, NodeSchema } from '../schema';
+import Node, { isNodeParent, insertChildren, insertChild, NodeParent } from './node/node';
+import { Selection } from './selection';
+import RootNode from './node/root-node';
+import { ISimulator, ComponentInstance, Component } from '../simulator';
+import { computed, obx } from '@recore/obx';
+import Location from '../location';
+import { ComponentConfig } from './node/component-config';
+import { isElement } from '../../utils/dom';
+
+export default class DocumentModel {
+ /**
+ * 根节点 类型有:Page/Component/Block
+ */
+ readonly rootNode: RootNode;
+ /**
+ * 文档编号
+ */
+ readonly id: string;
+ /**
+ * 选区控制
+ */
+ readonly selection: Selection = new Selection(this);
+ /**
+ * 操作记录控制
+ */
+ // TODO
+ // readonly history: History = new History(this);
+
+ private nodesMap = new Map();
+ private nodes = new Set();
+ private seqId = 0;
+ private _simulator?: ISimulator;
+
+ /**
+ * 模拟器
+ */
+ get simulator(): ISimulator | null {
+ return this._simulator || null;
+ }
+
+ get fileName(): string {
+ return (this.rootNode.extras.get('fileName')?.value as string) || this.id;
+ }
+
+ set fileName(fileName: string) {
+ this.rootNode.extras.get('fileName', true).value = fileName;
+ }
+
+ constructor(readonly project: Project, schema: RootSchema) {
+ this.rootNode = new RootNode(this, schema);
+ this.id = this.rootNode.id;
+ }
+
+ readonly designer = this.project.designer;
+
+ /**
+ * 生成唯一id
+ */
+ nextId() {
+ return (++this.seqId).toString(36).toLocaleLowerCase();
+ }
+
+ /**
+ * 根据 id 获取节点
+ */
+ getNode(id: string): Node | null {
+ return this.nodesMap.get(id) || null;
+ }
+
+ /**
+ * 是否存在节点
+ */
+ hasNode(id: string): boolean {
+ const node = this.getNode(id);
+ return node ? !node.isPurged : false;
+ }
+
+ /**
+ * 根据 schema 创建一个节点
+ */
+ createNode(data: NodeData): Node {
+ let schema: any;
+ if (isDOMText(data) || isJSExpression(data)) {
+ schema = {
+ componentName: '#frag',
+ children: data,
+ };
+ } else {
+ schema = data;
+ }
+ const node = new Node(this, schema);
+ this.nodesMap.set(node.id, node);
+ this.nodes.add(node);
+ return node;
+ }
+
+ /**
+ * 插入一个节点
+ */
+ insertNode(parent: NodeParent, thing: Node | NodeData, at?: number | null, copy?: boolean): Node {
+ return insertChild(parent, thing, at, copy);
+ }
+
+ /**
+ * 插入多个节点
+ */
+ insertNodes(parent: NodeParent, thing: Node[] | NodeData[], at?: number | null, copy?: boolean) {
+ return insertChildren(parent, thing, at, copy);
+ }
+
+ /**
+ * 移除一个节点
+ */
+ removeNode(idOrNode: string | Node) {
+ let id: string;
+ let node: Node | null;
+ if (typeof idOrNode === 'string') {
+ id = idOrNode;
+ node = this.getNode(id);
+ } else {
+ node = idOrNode;
+ id = node.id;
+ }
+ if (!node) {
+ return;
+ }
+ this.internalRemoveAndPurgeNode(node);
+ }
+
+ /**
+ * 内部方法,请勿调用
+ */
+ internalRemoveAndPurgeNode(node: Node) {
+ if (!this.nodes.has(node)) {
+ return;
+ }
+ this.nodesMap.delete(node.id);
+ this.nodes.delete(node);
+ node.remove();
+ }
+
+ @obx.ref private _dropLocation: Location | null = null;
+ /**
+ * 内部方法,请勿调用
+ */
+ internalSetDropLocation(loc: Location | null) {
+ this._dropLocation = loc;
+ }
+
+ /**
+ * 投放插入位置标记
+ */
+ get dropLocation() {
+ return this._dropLocation;
+ }
+
+ /**
+ * 包裹当前选区中的节点
+ */
+ wrapWith(schema: NodeSchema): Node | null {
+ const nodes = this.selection.getTopNodes();
+ if (nodes.length < 1) {
+ return null;
+ }
+ const wrapper = this.createNode(schema);
+ if (isNodeParent(wrapper)) {
+ const first = nodes[0];
+ // TODO: check nesting rules x 2
+ insertChild(first.parent!, wrapper, first.index);
+ insertChildren(wrapper, nodes);
+ this.selection.select(wrapper.id);
+ return wrapper;
+ }
+
+ this.removeNode(wrapper);
+ return null;
+ }
+
+ /**
+ * 导出 schema 数据
+ */
+ get schema(): RootSchema {
+ return this.rootNode.schema as any;
+ }
+
+ /**
+ * 导出节点数据
+ */
+ getNodeSchema(id: string): NodeData | null {
+ const node = this.getNode(id);
+ if (node) {
+ return node.schema;
+ }
+ return null;
+ }
+
+ /**
+ * 是否已修改
+ */
+ isModified() {
+ // return !this.history.isSavePoint();
+ }
+
+ /**
+ * 提供给模拟器的参数
+ */
+ @computed get simulatorProps(): object {
+ let simulatorProps = this.designer.simulatorProps;
+ if (typeof simulatorProps === 'function') {
+ simulatorProps = simulatorProps(this);
+ }
+ return {
+ ...simulatorProps,
+ documentContext: this,
+ onMount: this.mountSimulator.bind(this),
+ };
+ }
+
+ private mountSimulator(simulator: ISimulator) {
+ this._simulator = simulator;
+ // TODO: emit simulator mounted
+ }
+
+ /**
+ * 根据节点取得视图实例,在循环等场景会有多个,依赖 simulator 的接口
+ */
+ getComponentInstance(node: Node): ComponentInstance[] | null {
+ if (this.simulator) {
+ this.simulator.getComponentInstance(node);
+ }
+ return null;
+ }
+
+ getComponent(componentName: string): any {
+ return this.simulator!.getComponent(componentName);
+ }
+
+ getComponentConfig(component: Component, componentName: string): ComponentConfig {
+ // TODO: guess componentConfig from component by simulator
+ return this.designer.getComponentConfig(componentName);
+ }
+
+ /**
+ * 通过 DOM 节点获取节点,依赖 simulator 的接口
+ */
+ getNodeFromElement(target: Element | null): Node | null {
+ if (!this.simulator || !target) {
+ return null;
+ }
+
+ const id = this.simulator.getClosestNodeId(target);
+ if (!id) {
+ return null;
+ }
+ return this.getNode(id) as Node;
+ }
+
+ /**
+ * 获得到的结果是一个数组
+ * 表示一个实例对应多个外层 DOM 节点,依赖 simulator 的接口
+ */
+ getDOMNodes(instance: ComponentInstance): Array | null {
+ if (!this.simulator) {
+ return null;
+ }
+
+ if (isElement(instance)) {
+ return [instance];
+ }
+
+ return this.simulator.findDOMNodes(instance);
+ }
+
+ private _opened: boolean = true;
+ private _suspensed: boolean = false;
+
+ /**
+ * 是否不是激活的
+ */
+ get suspensed(): boolean {
+ return this._suspensed || !this._opened;
+ }
+
+ /**
+ * 与 suspensed 相反,是否是激活的,这个函数可能用的更多一点
+ */
+ get actived(): boolean {
+ return !this._suspensed;
+ }
+
+ /**
+ * 是否打开
+ */
+ get opened() {
+ return this._opened;
+ }
+
+ /**
+ * 切换激活,只有打开的才能激活
+ * 不激活,打开之后切换到另外一个时发生,比如 tab 视图,切换到另外一个标签页
+ */
+ private setSuspense(flag: boolean) {
+ if (!this._opened && !flag) {
+ return;
+ }
+ this._suspensed = flag;
+ this.simulator?.setSuspense(flag);
+ if (!flag) {
+ this.project.checkExclusive(this);
+ }
+ }
+
+ suspense() {
+ this.setSuspense(true);
+ }
+
+ active() {
+ this.setSuspense(false);
+ }
+
+ /**
+ * 打开,已载入,默认建立时就打开状态,除非手动关闭
+ */
+ open(): void {
+ this._opened = true;
+ if (this._suspensed) {
+ this.setSuspense(false);
+ }
+ }
+
+ /**
+ * 关闭,相当于 sleep,仍然缓存,停止一切响应,如果有发生的变更没被保存,仍然需要去取数据保存
+ */
+ close(): void {
+ this.setSuspense(true);
+ this._opened = false;
+ }
+
+ /**
+ * 从项目中移除
+ */
+ remove() {}
+}
+
+export function isDocumentModel(obj: any): obj is DocumentModel {
+ return obj && obj.rootNode;
+}
diff --git a/packages/designer/src/designer/document/document-view.tsx b/packages/designer/src/designer/document/document-view.tsx
new file mode 100644
index 000000000..4a599e624
--- /dev/null
+++ b/packages/designer/src/designer/document/document-view.tsx
@@ -0,0 +1,35 @@
+import { Component } from 'react';
+import DocumentModel from './document-model';
+import { observer } from '@recore/core-obx';
+import classNames from 'classnames';
+
+@observer
+export default class DocumentView extends Component<{ document: DocumentModel }> {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ const { document } = this.props;
+ const simulatorProps = document.simulatorProps;
+ const Simulator = document.designer.simulatorComponent;
+ return (
+
+ {/* 这一层将来做缩放用途 */}
+
+
+
+
+
+ );
+ }
+}
+
+class DocumentInfoView extends Component<{ document: DocumentModel }> {
+ render() {
+ return null;
+ }
+}
diff --git a/packages/designer/src/designer/document/node/component-config.ts b/packages/designer/src/designer/document/node/component-config.ts
new file mode 100644
index 000000000..a296f896d
--- /dev/null
+++ b/packages/designer/src/designer/document/node/component-config.ts
@@ -0,0 +1,380 @@
+import { ReactNode, ReactElement, ComponentType } from 'react';
+import Node, { NodeParent } from './node';
+import { NodeData, NodeSchema } from '../../schema';
+
+export type BasicTypes = 'array' | 'bool' | 'func' | 'number' | 'object' | 'string' | 'node' | 'element' | 'any';
+export interface CompositeType {
+ type: BasicTypes;
+ isRequired: boolean;
+}
+
+// TODO: add complex types
+
+export interface PropConfig {
+ name: string;
+ propType: BasicTypes | CompositeType;
+ description?: string;
+ defaultValue?: any;
+}
+
+export type CustomView = ReactElement | ComponentType;
+
+export interface TipConfig {
+ className?: string;
+ children?: ReactNode;
+ theme?: string;
+ direction?: string; // 'n|s|w|e|top|bottom|left|right';
+}
+
+export interface IconConfig {
+ name: string;
+ size?: string;
+ className?: string;
+ effect?: string;
+}
+
+export interface TitleConfig {
+ label?: ReactNode;
+ tip?: string | ReactElement | TipConfig;
+ icon?: string | ReactElement | IconConfig;
+ className?: string;
+}
+
+export type Title = string | ReactElement | TitleConfig;
+
+export enum DisplayType {
+ Inline = 'inline',
+ Block = 'block',
+ Accordion = 'Accordion',
+ Plain = 'plain',
+ Caption = 'caption',
+}
+
+export interface SetterConfig {
+ /**
+ * if *string* passed must be a registered Setter Name
+ */
+ componentName: string | CustomView;
+ /**
+ * the props pass to Setter Component
+ */
+ props?: {
+ [prop: string]: any;
+ };
+}
+
+/**
+ * if *string* passed must be a registered Setter Name
+ */
+export type SetterType = SetterConfig | string | CustomView;
+
+export interface SettingFieldConfig {
+ /**
+ * the name of this setting field, which used in quickEditor
+ */
+ name: string;
+ /**
+ * the field body contains
+ */
+ setter: SetterType;
+ /**
+ * the prop target which to set, eg. "style.width"
+ * @default sameas .name
+ */
+ propTarget?: string;
+ /**
+ * the field title
+ * @default sameas .propTarget
+ */
+ title?: Title;
+ extraProps?: {
+ /**
+ * default value of target prop for setter use
+ */
+ defaultValue?: any;
+ onChange?: (value: any) => void;
+ getValue?: () => any;
+ /**
+ * the field conditional show, is not set always true
+ * @default undefined
+ */
+ condition?: (node: Node) => boolean;
+ /**
+ * quick add "required" validation
+ */
+ required?: boolean;
+ /**
+ * the field display
+ * @default DisplayType.Block
+ */
+ display?: DisplayType.Inline | DisplayType.Block | DisplayType.Accordion | DisplayType.Plain;
+ /**
+ * default collapsed when display accordion
+ */
+ defaultCollapsed?: boolean;
+ /**
+ * layout control
+ * number or [column number, left offset]
+ * @default 6
+ */
+ span?: number | [number, number];
+ };
+}
+
+export interface SettingGroupConfig {
+ /**
+ * the type "group"
+ */
+ type: 'group';
+ /**
+ * the name of this setting group, which used in quickEditor
+ */
+ name?: string;
+ /**
+ * the setting items which group body contains
+ */
+ items: Array;
+ /**
+ * the group title
+ * @default sameas .name
+ */
+ title?: Title;
+ extraProps: {
+ /**
+ * the field conditional show, is not set always true
+ * @default undefined
+ */
+ condition?: (node: Node) => boolean;
+ /**
+ * the group display
+ * @default DisplayType.Block
+ */
+ display?: DisplayType.Block | DisplayType.Accordion;
+ /**
+ * default collapsed when display accordion
+ */
+ defaultCollapsed?: boolean;
+ /**
+ * the gap between span
+ * @default 0 px
+ */
+ gap?: number;
+ /**
+ * layout control
+ * number or [column number, left offset]
+ * @default 6
+ */
+ span?: number | [number, number];
+ };
+}
+
+export type PropSettingConfig = SettingFieldConfig | SettingGroupConfig | CustomView;
+
+export interface NestingRule {
+ childWhitelist?: string[];
+ parentWhitelist?: string[];
+}
+
+export interface Configure {
+ props?: PropSettingConfig[];
+ styles?: object;
+ events?: object;
+ component?: {
+ isContainer?: boolean;
+ isModal?: boolean;
+ descriptor?: string;
+ nestingRule?: NestingRule;
+ };
+}
+
+export interface ComponentDescriptionSpec {
+ componentName: string;
+ /**
+ * unique id
+ */
+ uri?: string;
+ /**
+ * title or description
+ */
+ title?: string;
+ /**
+ * svg icon for component
+ */
+ icon?: string | ReactNode;
+ tags?: string[];
+ description?: string;
+ docUrl?: string;
+ screenshot?: string;
+ devMode?: 'procode' | 'lowcode';
+ npm?: {
+ package: string;
+ exportName: string;
+ subName: string;
+ main: string;
+ destructuring: boolean;
+ version: string;
+ };
+ props?: PropConfig[];
+ configure?: PropSettingConfig[] | Configure;
+}
+
+function ensureAList(list?: string | string[]): string[] | null {
+ if (!list) {
+ return null;
+ }
+ if (!Array.isArray(list)) {
+ list = list.split(/ *[ ,|] */).filter(Boolean);
+ }
+ if (list.length < 1) {
+ return null;
+ }
+ return list;
+}
+
+function npmToURI(npm: {
+ package: string;
+ exportName?: string;
+ subName?: string;
+ destructuring?: boolean;
+ main?: string;
+ version: string;
+}): string {
+ let pkg = [];
+ if (npm.package) {
+ pkg.push(npm.package);
+ }
+ if (npm.main) {
+ if (npm.main[0] === '/') {
+ pkg.push(npm.main.slice(1));
+ } else if (npm.main.slice(0, 2) === './') {
+ pkg.push(npm.main.slice(2));
+ } else {
+ pkg.push(npm.main);
+ }
+ }
+
+ let uri = pkg.join('/');
+ uri += `:${npm.destructuring && npm.exportName ? npm.exportName : 'default'}`;
+
+ if (npm.subName) {
+ uri += `.${npm.subName}`;
+ }
+
+ return uri;
+}
+
+function generatePropsConfigure(props: PropConfig[]) {
+ // todo:
+ return [];
+}
+
+export class ComponentConfig {
+ readonly isComponentConfig = true;
+ private _uri?: string;
+ get uri(): string {
+ return this._uri!;
+ }
+ private _componentName?: string;
+ get componentName(): string {
+ return this._componentName!;
+ }
+ private _isContainer?: boolean;
+ get isContainer(): boolean {
+ return this._isContainer!;
+ }
+ private _isModal?: boolean;
+ get isModal(): boolean {
+ return this._isModal!;
+ }
+ private _descriptor?: string;
+ get descriptor(): string {
+ return this._descriptor!;
+ }
+ private _acceptable?: boolean;
+ get acceptable(): boolean {
+ return this._acceptable!;
+ }
+ private _configure?: Configure;
+ get configure(): Configure {
+ return this._configure!;
+ }
+
+ private parentWhitelist?: string[] | null;
+ private childWhitelist?: string[] | null;
+
+ get title() {
+ return this._spec.title;
+ }
+
+ get icon() {
+ return this._spec.icon;
+ }
+
+ get propsConfigure() {
+ return this.configure.props;
+ }
+
+ constructor(private _spec: ComponentDescriptionSpec) {
+ this.parseSpec(_spec);
+ }
+
+ private parseSpec(spec: ComponentDescriptionSpec) {
+ const { componentName, uri, configure, npm, props } = spec;
+ this._uri = uri || (npm ? npmToURI(npm) : componentName);
+ this._componentName = componentName;
+ this._acceptable = false;
+
+ if (!configure || Array.isArray(configure)) {
+ this._configure = {
+ props: !configure ? [] : configure,
+ styles: {
+ supportClassName: true,
+ supportInlineStyle: true,
+ },
+ };
+ } else {
+ this._configure = configure;
+ }
+ if (!this.configure.props) {
+ this.configure.props = props ? generatePropsConfigure(props) : [];
+ }
+ const { component } = this.configure;
+ if (component) {
+ this._isContainer = component.isContainer ? true : false;
+ this._isModal = component.isModal ? true : false;
+ this._descriptor = component.descriptor;
+ if (component.nestingRule) {
+ const { parentWhitelist, childWhitelist } = component.nestingRule;
+ this.parentWhitelist = ensureAList(parentWhitelist);
+ this.childWhitelist = ensureAList(childWhitelist);
+ }
+ } else {
+ this._isContainer = false;
+ this._isModal = false;
+ }
+ }
+
+ set spec(spec: ComponentDescriptionSpec) {
+ this._spec = spec;
+ this.parseSpec(spec);
+ }
+
+ get spec(): ComponentDescriptionSpec {
+ return this._spec;
+ }
+
+
+ checkNestingUp(my: Node | NodeData, parent: NodeParent) {
+ if (this.parentWhitelist) {
+ return this.parentWhitelist.includes(parent.componentName);
+ }
+ return true;
+ }
+
+ checkNestingDown(my: Node, target: Node | NodeSchema) {
+ if (this.childWhitelist) {
+ return this.childWhitelist.includes(target.componentName);
+ }
+ return true;
+ }
+}
diff --git a/packages/designer/src/designer/document/node/node-children.ts b/packages/designer/src/designer/document/node/node-children.ts
new file mode 100644
index 000000000..58bff9747
--- /dev/null
+++ b/packages/designer/src/designer/document/node/node-children.ts
@@ -0,0 +1,186 @@
+import Node, { NodeParent } from './node';
+import { NodeData } from '../../schema';
+
+export default class NodeChildren {
+ private children: Node[];
+ constructor(readonly owner: NodeParent, childrenData: NodeData | NodeData[]) {
+ this.children = (Array.isArray(childrenData) ? childrenData : [childrenData]).map(child => {
+ const node = this.owner.document.createNode(child);
+ node.internalSetParent(this.owner);
+ return node;
+ });
+ }
+
+ /**
+ * 导出 schema
+ * @param serialize 序列化,加 id 标识符,用于储存为操作记录
+ */
+ exportSchema(serialize = false): NodeData[] {
+ return this.children.map(node => node.exportSchema(serialize));
+ }
+
+ /**
+ * 元素个数
+ */
+ get size(): number {
+ return this.children.length;
+ }
+
+ /**
+ * 是否空
+ */
+ isEmpty() {
+ return this.size < 1;
+ }
+
+ /*
+ // 用于数据重新灌入
+ merge() {
+ for (let i = 0, l = data.length; i < l; i++) {
+ const item = this.children[i];
+ if (item && isMergeable(item) && item.tagName === data[i].tagName) {
+ item.merge(data[i]);
+ } else {
+ if (item) {
+ item.purge();
+ }
+ this.children[i] = this.document.createNode(data[i]);
+ this.children[i].internalSetParent(this);
+ }
+ }
+ if (this.children.length > data.length) {
+ this.children.splice(data.length).forEach(child => child.purge());
+ }
+ }
+ */
+
+ /**
+ * 删除一个节点
+ */
+ delete(node: Node, purge: boolean = false): boolean {
+ const i = this.children.indexOf(node);
+ if (i < 0) {
+ return false;
+ }
+ const deleted = this.children.splice(i, 1)[0];
+ if (purge) {
+ // should set parent null
+ deleted.internalSetParent(null);
+ deleted.purge();
+ }
+ return false;
+ }
+
+ /**
+ * 插入一个节点,返回新长度
+ */
+ insert(node: Node, at?: number | null): void {
+ const children = this.children;
+ let index = at == null || at === -1 ? children.length : at;
+
+ const i = children.indexOf(node);
+
+ if (i < 0) {
+ if (index < children.length) {
+ children.splice(index, 0, node);
+ } else {
+ children.push(node);
+ }
+ node.internalSetParent(this.owner);
+ } else {
+ if (index > i) {
+ index -= 1;
+ }
+
+ if (index === i) {
+ return;
+ }
+
+ children.splice(i, 1);
+ children.splice(index, 0, node);
+ }
+
+ // check condition group
+ node.conditionGroup = null;
+ if (node.prevSibling && node.nextSibling) {
+ const conditionGroup = node.prevSibling.conditionGroup;
+ if (conditionGroup && conditionGroup === node.nextSibling.conditionGroup) {
+ node.conditionGroup = conditionGroup;
+ }
+ }
+ }
+
+ /**
+ * 取得节点索引编号
+ */
+ indexOf(node: Node): number {
+ return this.children.indexOf(node);
+ }
+
+ /**
+ * 根据索引获得节点
+ */
+ get(index: number): Node | null {
+ return this.children[index] || null;
+ }
+
+ /**
+ * 是否存在节点
+ */
+ has(node: Node) {
+ return this.indexOf(node) > -1;
+ }
+
+ /**
+ * 迭代器
+ */
+ [Symbol.iterator](): { next(): { value: Node } } {
+ let index = 0;
+ const children = this.children;
+ const length = children.length || 0;
+ return {
+ next() {
+ if (index < length) {
+ return {
+ value: children[index++],
+ done: false,
+ };
+ }
+ return {
+ value: undefined as any,
+ done: true,
+ };
+ },
+ };
+ }
+
+ /**
+ * 遍历
+ */
+ forEach(fn: (item: Node, index: number) => void): void {
+ this.children.forEach((child, index) => {
+ return fn(child, index);
+ });
+ }
+
+ /**
+ * 遍历
+ */
+ map(fn: (item: Node, index: number) => T): T[] | null {
+ return this.children.map((child, index) => {
+ return fn(child, index);
+ });
+ }
+
+ private purged = false;
+ /**
+ * 回收销毁
+ */
+ purge() {
+ if (this.purged) {
+ return;
+ }
+ this.purged = true;
+ this.children.forEach(child => child.purge());
+ }
+}
diff --git a/packages/designer/src/designer/document/node/node-content.ts b/packages/designer/src/designer/document/node/node-content.ts
new file mode 100644
index 000000000..af146a15b
--- /dev/null
+++ b/packages/designer/src/designer/document/node/node-content.ts
@@ -0,0 +1,86 @@
+import { obx } from '@recore/obx';
+import { JSExpression, isJSExpression } from '../../schema';
+
+export default class NodeContent {
+ @obx.ref private _value: string | JSExpression = '';
+
+ get value(): string | JSExpression {
+ return this._value;
+ }
+
+ set value(val: string | JSExpression) {
+ this._value = val;
+ }
+
+ /**
+ * 获得表达式值
+ */
+ get code() {
+ if (isJSExpression(this._value)) {
+ return this._value.value;
+ }
+ return JSON.stringify(this.value);
+ }
+
+ /**
+ * 设置表达式值
+ */
+ set code(code: string) {
+ if (isJSExpression(this._value)) {
+ this._value = {
+ ...this._value,
+ value: code,
+ };
+ } else {
+ let useCode: boolean = true;
+ try {
+ const v = JSON.parse(code);
+ const t = typeof v;
+ if (v == null) {
+ this._value = '';
+ useCode = false;
+ } else if (t === 'string' || t === 'number' || t === 'boolean') {
+ this._value = String(v);
+ useCode = false;
+ }
+ } catch (e) {
+ // ignore
+ }
+ if (useCode) {
+ this._value = {
+ type: 'JSExpression',
+ value: code,
+ mock: this._value,
+ };
+ }
+ }
+ }
+
+ constructor(value: any) {
+ const type = typeof value;
+ if (value == null) {
+ this._value = '';
+ } else if (type === 'string' || type === 'number' || type === 'boolean') {
+ this._value = String(value);
+ } else if (isJSExpression(value)) {
+ this._value = value;
+ }
+ }
+
+ /**
+ * 是否表达式
+ */
+ isJSExpression(): boolean {
+ return isJSExpression(this._value);
+ }
+
+ /**
+ * 是否空值
+ */
+ isEmpty() {
+ if (isJSExpression(this._value)) {
+ return this._value.value === '';
+ }
+ return this._value === '';
+ }
+}
diff --git a/packages/designer/src/designer/document/node/node.ts b/packages/designer/src/designer/document/node/node.ts
new file mode 100644
index 000000000..e38eddcd6
--- /dev/null
+++ b/packages/designer/src/designer/document/node/node.ts
@@ -0,0 +1,481 @@
+import { obx } from '@recore/obx';
+import { NodeSchema, NodeData, PropsMap, PropsList } from '../../schema';
+import Props from './props/props';
+import DocumentModel from '../document-model';
+import NodeChildren from './node-children';
+import Prop from './props/prop';
+import NodeContent from './node-content';
+import { Component } from '../../simulator';
+import { ComponentConfig } from './component-config';
+
+const DIRECTIVES = ['condition', 'conditionGroup', 'loop', 'loopArgs', 'title', 'ignore', 'hidden', 'locked'];
+
+/**
+ * 基础节点
+ *
+ * [Node Properties]
+ * componentName: Page/Block/Component
+ * props
+ * children
+ *
+ * [Directives]
+ * loop
+ * loopArgs
+ * condition
+ * ------- future support -----
+ * conditionGroup
+ * title
+ * ignore
+ * locked
+ * hidden
+ */
+export default class Node {
+ /**
+ * 是节点实例
+ */
+ readonly isNode = true;
+
+ /**
+ * 节点 id
+ */
+ readonly id: string;
+
+ /**
+ * 节点组件类型
+ * 特殊节点:
+ * * #text 文字节点
+ * * #expression 表达式节点
+ * * Page 页面
+ * * Block/Fragment 区块
+ * * Component 组件/元件
+ */
+ readonly componentName: string;
+ protected _props?: Props;
+ protected _directives?: Props;
+ protected _extras?: Props;
+ protected _children: NodeChildren | NodeContent;
+ private _parent: NodeParent | null = null;
+ private _zLevel = 0;
+ get props(): Props | undefined {
+ return this._props;
+ }
+ get directives(): Props | undefined {
+ return this._directives;
+ }
+ get extras(): Props | undefined {
+ return this._extras;
+ }
+ /**
+ * 父级节点
+ */
+ get parent(): NodeParent | null {
+ return this._parent;
+ }
+ /**
+ * 当前节点子集
+ */
+ get children(): NodeChildren | NodeContent {
+ return this._children;
+ }
+ /**
+ * 当前节点深度
+ */
+ get zLevel(): number {
+ return this._zLevel;
+ }
+
+ constructor(readonly document: DocumentModel, nodeSchema: NodeSchema) {
+ const { componentName, id, children, props, ...extras } = nodeSchema;
+ this.id = id || `node$${document.nextId()}`;
+ this.componentName = componentName;
+ if (this.isNodeParent) {
+ this._props = new Props(this, props);
+ this._directives = new Props(this, {});
+ Object.keys(extras).forEach(key => {
+ if (DIRECTIVES.indexOf(key) > -1) {
+ this.directives!.add((extras as any)[key], key);
+ delete (extras as any)[key];
+ }
+ });
+ this._extras = new Props(this, extras as any);
+ this._children = new NodeChildren(this as NodeParent, children || []);
+ } else {
+ this._children = new NodeContent(children);
+ }
+ }
+
+ /**
+ * 是否一个父亲类节点
+ */
+ get isNodeParent(): boolean {
+ return this.componentName.charAt(0) !== '#';
+ }
+
+ /**
+ * 内部方法,请勿使用
+ *
+ * @ignore
+ */
+ internalSetParent(parent: NodeParent | null) {
+ if (this._parent === parent) {
+ return;
+ }
+ if (this._parent) {
+ this._parent.children.delete(this);
+ }
+
+ this._parent = parent;
+ if (parent) {
+ this._zLevel = parent.zLevel + 1;
+ } else {
+ this._zLevel = -1;
+ }
+ }
+
+ /**
+ * 移除当前节点
+ */
+ remove() {
+ if (this.parent) {
+ this.parent.children.delete(this, true);
+ }
+ }
+
+ /**
+ * 选择当前节点
+ */
+ select() {
+ this.document.selection.select(this.id);
+ }
+
+ /**
+ * 节点组件类
+ */
+ @obx.ref get component(): Component {
+ return this.document.getComponent(this.componentName);
+ }
+
+ /**
+ * 节点组件描述
+ */
+ @obx.ref get componentConfig(): ComponentConfig {
+ return this.document.getComponentConfig(this.component, this.componentName);
+ }
+
+ @obx.ref get propsData(): PropsMap | PropsList | null {
+ if (!this.isNodeParent || this.componentName === 'Fragment') {
+ return null;
+ }
+ return this.props?.value || null;
+ }
+
+ get directivesData(): PropsMap | null {
+ if (!this.isNodeParent) {
+ return null;
+ }
+ return this.directives?.value as PropsMap || null;
+ }
+
+ private _conditionGroup: string | null = null;
+ /**
+ * 条件组
+ */
+ get conditionGroup(): string | null {
+ if (this._conditionGroup) {
+ return this._conditionGroup;
+ }
+ // 如果 condition 有值,且没有 group
+ if (this._condition) {
+ return this.id;
+ }
+ return null;
+ }
+ set conditionGroup(val) {
+ this._conditionGroup = val;
+ }
+
+ private _condition: any;
+ /**
+ *
+ */
+ get condition() {
+ if (this._condition == null) {
+ if (this._conditionGroup) {
+ // FIXME: should be expression
+ return true;
+ }
+ return null;
+ }
+ return this._condition;
+ }
+
+ wrapWith(schema: NodeSchema) {
+
+ }
+
+ replaceWith(schema: NodeSchema, migrate: boolean = true) {
+
+ //
+ }
+
+ /*
+ // TODO
+ // 外部修改,merge 进来,产生一次可恢复的历史数据
+ merge(data: ElementData) {
+ this.elementData = data;
+ const { leadingComments } = data;
+ this.leadingComments = leadingComments ? leadingComments.slice() : [];
+ this.parse();
+ this.mergeChildren(data.children || []);
+ }
+
+ // TODO: 再利用历史数据,不产生历史数据
+ reuse(timelineData: NodeSchema) {}
+ */
+
+ getProp(path: string, useStash: boolean = true): Prop | null {
+ return this.props?.query(path, useStash as any) || null;
+ }
+
+ getDirective(name: string, useStash: boolean = true): Prop | null {
+ return this.directives?.get(name, useStash as any) || null;
+ }
+
+ /**
+ * 获取节点在父容器中的索引
+ */
+ get index(): number {
+ if (!this.parent) {
+ return -1;
+ }
+ return this.parent.children.indexOf(this);
+ }
+
+ /**
+ * 获取下一个兄弟节点
+ */
+ get nextSibling(): Node | null {
+ if (!this.parent) {
+ return null;
+ }
+ const index = this.index;
+ if (index < 0) {
+ return null;
+ }
+ return this.parent.children.get(index + 1);
+ }
+
+ /**
+ * 获取上一个兄弟节点
+ */
+ get prevSibling(): Node | null {
+ if (!this.parent) {
+ return null;
+ }
+ const index = this.index;
+ if (index < 1) {
+ return null;
+ }
+ return this.parent.children.get(index - 1);
+ }
+
+ /**
+ * 获取符合搭建协议-节点 schema 结构
+ */
+ get schema(): NodeSchema {
+ // TODO: ..
+ return this.exportSchema(true);
+ }
+
+ /**
+ * 导出 schema
+ * @param serialize 序列化,加 id 标识符,用于储存为操作记录
+ */
+ exportSchema(serialize = false): NodeSchema {
+ // TODO...
+ const schema: any = {
+ componentName: this.componentName,
+ ...this.extras,
+ props: this.props,
+ ...this.directives,
+ };
+ if (serialize) {
+ schema.id = this.id;
+ }
+ if (isNodeParent(this)) {
+ if (this.children.size > 0) {
+ schema.children = this.children.exportSchema(serialize);
+ }
+ } else {
+ schema.children = (this.children as NodeContent).value;
+ }
+ return schema;
+ }
+
+ /**
+ * 判断是否包含特定节点
+ */
+ contains(node: Node): boolean {
+ return contains(this, node);
+ }
+
+ /**
+ * 获取特定深度的父亲节点
+ */
+ getZLevelTop(zLevel: number): Node | null {
+ return getZLevelTop(this, zLevel);
+ }
+
+ /**
+ * 判断与其它节点的位置关系
+ *
+ * 16 thisNode contains otherNode
+ * 8 thisNode contained_by otherNode
+ * 2 thisNode before or after otherNode
+ * 0 thisNode same as otherNode
+ */
+ comparePosition(otherNode: Node): number {
+ return comparePosition(this, otherNode);
+ }
+
+ private purged = false;
+ /**
+ * 是否已销毁
+ */
+ get isPurged() {
+ return this.purged;
+ }
+ /**
+ * 销毁
+ */
+ purge() {
+ if (this.purged) {
+ return;
+ }
+ if (this._parent) {
+ // should remove thisNode before purge
+ this.remove();
+ return;
+ }
+ this.purged = true;
+ if (isNodeParent(this)) {
+ this.children.purge();
+ }
+ this.props?.purge();
+ this.directives?.purge();
+ this.extras?.purge();
+ this.document.internalRemoveAndPurgeNode(this);
+ }
+}
+
+export interface NodeParent extends Node {
+ readonly children: NodeChildren;
+ readonly props: Props;
+ readonly directives: Props;
+ readonly extras: Props;
+}
+
+export function isNode(node: any): node is Node {
+ return node && node.isNode;
+}
+
+export function isNodeParent(node: Node): node is NodeParent {
+ return node.isNodeParent;
+}
+
+export function getZLevelTop(child: Node, zLevel: number): Node | null {
+ let l = child.zLevel;
+ if (l < zLevel || zLevel < 0) {
+ return null;
+ }
+ if (l === zLevel) {
+ return child;
+ }
+ let r: any = child;
+ while (r && l-- > zLevel) {
+ r = r.parent;
+ }
+ return r;
+}
+
+export function contains(node1: Node, node2: Node): boolean {
+ if (node1 === node2) {
+ return true;
+ }
+
+ if (!node1.isNodeParent || !node2.parent) {
+ return false;
+ }
+
+ const p = getZLevelTop(node2, node1.zLevel);
+ if (!p) {
+ return false;
+ }
+
+ return node1 === p;
+}
+
+// 16 node1 contains node2
+// 8 node1 contained_by node2
+// 2 node1 before or after node2
+// 0 node1 same as node2
+export function comparePosition(node1: Node, node2: Node): number {
+ if (node1 === node2) {
+ return 0;
+ }
+ const l1 = node1.zLevel;
+ const l2 = node2.zLevel;
+ if (l1 === l2) {
+ return 2;
+ }
+
+ let p: any;
+ if (l1 > l2) {
+ p = getZLevelTop(node2, l1);
+ if (p && p === node1) {
+ return 16;
+ }
+ return 2;
+ }
+
+ p = getZLevelTop(node1, l2);
+ if (p && p === node2) {
+ return 8;
+ }
+
+ return 2;
+}
+
+export function insertChild(container: NodeParent, thing: Node | NodeData, at?: number | null, copy?: boolean): Node {
+ let node: Node;
+ if (copy && isNode(thing)) {
+ thing = thing.schema;
+ }
+ if (isNode(thing)) {
+ node = thing;
+ } else {
+ node = container.document.createNode(thing);
+ }
+
+ container.children.insert(node, at);
+
+ return node;
+}
+
+export function insertChildren(
+ container: NodeParent,
+ nodes: Node[] | NodeData[],
+ at?: number | null,
+ copy?: boolean,
+): Node[] {
+ let index = at;
+ let node: any;
+ const results: Node[] = [];
+ // tslint:disable-next-line
+ while ((node = nodes.pop())) {
+ results.push(insertChild(container, node, index, copy));
+ index = node.index;
+ }
+ return results;
+}
+
diff --git a/packages/designer/src/designer/document/node/props/prop.ts b/packages/designer/src/designer/document/node/props/prop.ts
new file mode 100644
index 000000000..9986884d7
--- /dev/null
+++ b/packages/designer/src/designer/document/node/props/prop.ts
@@ -0,0 +1,453 @@
+import { untracked, computed, obx } from '@recore/obx';
+import { valueToSource } from '../../../../utils/value-to-source';
+import { CompositeValue, isJSExpression } from '../../../schema';
+import StashSpace from './stash-space';
+import { uniqueId } from '../../../../utils/unique-id';
+import { isPlainObject } from '../../../../utils/is-plain-object';
+import { hasOwnProperty } from '../../../../utils/has-own-property';
+
+export const UNSET = Symbol.for('unset');
+export type UNSET = typeof UNSET;
+
+export interface IPropParent {
+ delete(prop: Prop): void;
+}
+
+export default class Prop implements IPropParent {
+ readonly isProp = true;
+
+ readonly id = uniqueId('prop$');
+
+ private _type: 'unset' | 'literal' | 'map' | 'list' | 'expression' = 'unset';
+ /**
+ * 属性类型
+ */
+ get type(): 'unset' | 'literal' | 'map' | 'list' | 'expression' {
+ return this._type;
+ }
+
+ @obx.ref private _value: any = UNSET;
+
+ /**
+ * 属性值
+ */
+ @computed get value(): CompositeValue {
+ if (this._type === 'unset') {
+ return null;
+ }
+
+ const type = this._type;
+ if (type === 'literal' || type === 'expression') {
+ return this._value;
+ }
+
+ if (type === 'map') {
+ if (!this._items) {
+ return this._value;
+ }
+ const maps: any = {};
+ this.items!.forEach((prop, key) => {
+ maps[key] = prop.value;
+ });
+ return maps;
+ }
+
+ if (type === 'list') {
+ if (!this._items) {
+ return this._items;
+ }
+ return this.items!.map(prop => prop.value);
+ }
+
+ return null;
+ }
+
+ /**
+ * set value, val should be JSON Object
+ */
+ set value(val: CompositeValue) {
+ this._value = val;
+ const t = typeof val;
+ if (val == null) {
+ this._value = null;
+ this._type = 'literal';
+ } else if (t === 'string' || t === 'number' || t === 'boolean') {
+ this._value = val;
+ this._type = 'literal';
+ } else if (Array.isArray(val)) {
+ this._type = 'list';
+ } else if (isPlainObject(val)) {
+ if (isJSExpression(val)) {
+ this._type = 'expression';
+ } else {
+ this._type = 'map';
+ }
+ this._type = 'map';
+ } else {
+ this._type = 'expression';
+ this._value = {
+ type: 'JSExpression',
+ value: valueToSource(val),
+ };
+ }
+ if (untracked(() => this._items)) {
+ this._items!.forEach(prop => prop.purge());
+ this._items = null;
+ }
+ this._maps = null;
+ if (this.stash) {
+ this.stash.clear();
+ }
+ }
+
+ /**
+ * 取消设置值
+ */
+ unset() {
+ this._type = 'unset';
+ }
+
+ /**
+ * 是否未设置值
+ */
+ isUnset() {
+ return this._type === 'unset';
+ }
+
+ /**
+ * 值是否包含表达式
+ * 包含 JSExpresion | JSSlot 等值
+ */
+ isContainJSExpression(): boolean {
+ const type = this._type;
+ if (type === 'expression') {
+ return true;
+ }
+ if (type === 'literal' || type === 'unset') {
+ return false;
+ }
+ if ((type === 'list' || type === 'map') && this.items) {
+ return this.items.some(item => item.isContainJSExpression());
+ }
+ return false;
+ }
+
+ /**
+ * 是否简单 JSON 数据
+ */
+ isJSON() {
+ return !this.isContainJSExpression();
+ }
+
+ private _items: Prop[] | null = null;
+ private _maps: Map | null = null;
+ @computed private get items(): Prop[] | null {
+ let _items: any;
+ untracked(() => {
+ _items = this._items;
+ });
+ if (!_items) {
+ if (this._type === 'list') {
+ const data = this._value;
+ const items = [];
+ for (const item of data) {
+ items.push(new Prop(this, item));
+ }
+ _items = items;
+ this._maps = null;
+ } else if (this._type === 'map') {
+ const data = this._value;
+ const items = [];
+ const maps = new Map();
+ const keys = Object.keys(data);
+ for (const key of keys) {
+ const prop = new Prop(this, data[key], key);
+ items.push(prop);
+ maps.set(key, prop);
+ }
+ _items = items;
+ this._maps = maps;
+ } else {
+ _items = null;
+ this._maps = null;
+ }
+ this._items = _items;
+ }
+ return _items;
+ }
+ @computed private get maps(): Map | null {
+ if (!this.items || this.items.length < 1) {
+ return null;
+ }
+ return this._maps;
+ }
+
+ private stash: StashSpace | undefined;
+
+ /**
+ * 键值
+ */
+ @obx key: string | number | undefined;
+ /**
+ * 扩展值
+ */
+ @obx spread: boolean;
+
+ constructor(
+ public parent: IPropParent,
+ value: CompositeValue | UNSET = UNSET,
+ key?: string | number,
+ spread = false,
+ ) {
+ if (value !== UNSET) {
+ this.value = value;
+ }
+ this.key = key;
+ this.spread = spread;
+ }
+
+ /**
+ * 获取某个属性
+ * @param stash 强制
+ */
+ get(path: string, stash: false): Prop | null;
+ /**
+ * 获取某个属性, 如果不存在,临时获取一个待写入
+ * @param stash 强制
+ */
+ get(path: string, stash: true): Prop;
+ /**
+ * 获取某个属性, 如果不存在,临时获取一个待写入
+ */
+ get(path: string): Prop;
+ get(path: string, stash = true) {
+ const type = this._type;
+ if (type !== 'map' && type !== 'unset' && !stash) {
+ return null;
+ }
+
+ const maps = type === 'map' ? this.maps : null;
+
+ let prop: any = maps ? maps.get(path) : null;
+
+ if (prop) {
+ return prop;
+ }
+
+ const i = path.indexOf('.');
+ let entry = path;
+ let nest = '';
+ if (i > 0) {
+ nest = path.slice(i + 1);
+ if (nest) {
+ entry = path.slice(0, i);
+ prop = maps ? maps.get(entry) : null;
+ if (prop) {
+ return prop.get(nest, stash);
+ }
+ }
+ }
+
+ if (stash) {
+ if (!this.stash) {
+ this.stash = new StashSpace(
+ item => {
+ // item take effect
+ this.set(String(item.key), item);
+ item.parent = this;
+ },
+ () => {
+ return true;
+ },
+ );
+ }
+ prop = this.stash.get(entry);
+ if (nest) {
+ return prop.get(nest, true);
+ }
+
+ return prop;
+ }
+
+ return null;
+ }
+
+ /**
+ * 从父级移除本身
+ */
+ remove() {
+ this.parent.delete(this);
+ }
+
+ /**
+ * 删除项
+ */
+ delete(prop: Prop): void {
+ if (this.items) {
+ const i = this.items.indexOf(prop);
+ if (i > -1) {
+ this.items.slice(i, 1);
+ prop.purge();
+ }
+ if (this._maps && prop.key) {
+ this._maps.delete(String(prop.key));
+ }
+ }
+ }
+
+ /**
+ * 删除 key
+ */
+ deleteKey(key: string): void {
+ if (this.maps) {
+ const prop = this.maps.get(key);
+ if (prop) {
+ this.delete(prop);
+ }
+ }
+ }
+
+ /**
+ * 元素个数
+ */
+ size(): number {
+ return this.items?.length || 0;
+ }
+
+ /**
+ * 添加值到列表
+ *
+ * @param force 强制
+ */
+ add(value: CompositeValue, force = false): Prop | null {
+ const type = this._type;
+ if (type !== 'list' && type !== 'unset' && !force) {
+ return null;
+ }
+ if (type === 'unset' || (force && type !== 'list')) {
+ this.value = [];
+ }
+ const prop = new Prop(this, value);
+ this.items!.push(prop);
+ return prop;
+ }
+
+ /**
+ * 设置值到字典
+ *
+ * @param force 强制
+ */
+ set(key: string, value: CompositeValue | Prop, force = false) {
+ const type = this._type;
+ if (type !== 'map' && type !== 'unset' && !force) {
+ return null;
+ }
+ if (type === 'unset' || (force && type !== 'map')) {
+ this.value = {};
+ }
+ const prop = isProp(value) ? value : new Prop(this, value, key);
+ const items = this.items!;
+ const maps = this.maps!;
+ const orig = maps.get(key);
+ if (orig) {
+ // replace
+ const i = items.indexOf(orig);
+ if (i > -1) {
+ items.splice(i, 1, prop)[0].purge();
+ }
+ maps.set(key, prop);
+ } else {
+ // push
+ items.push(prop);
+ maps.set(key, prop);
+ }
+
+ return prop;
+ }
+
+ /**
+ * 是否存在 key
+ */
+ has(key: string): boolean {
+ if (this._type !== 'map') {
+ return false;
+ }
+ if (this._maps) {
+ return this._maps.has(key);
+ }
+ return hasOwnProperty(this._value, key);
+ }
+
+ private purged = false;
+ /**
+ * 回收销毁
+ */
+ purge() {
+ if (this.purged) {
+ return;
+ }
+ this.purged = true;
+ if (this.stash) {
+ this.stash.purge();
+ }
+ if (this._items) {
+ this._items.forEach(item => item.purge());
+ }
+ this._maps = null;
+ }
+
+ /**
+ * 迭代器
+ */
+ [Symbol.iterator](): { next(): { value: Prop } } {
+ let index = 0;
+ const items = this.items;
+ const length = items?.length || 0;
+ return {
+ next() {
+ if (index < length) {
+ return {
+ value: items![index++],
+ done: false,
+ };
+ }
+ return {
+ value: undefined as any,
+ done: true,
+ };
+ },
+ };
+ }
+
+ /**
+ * 遍历
+ */
+ forEach(fn: (item: Prop, key: number | string | undefined) => void): void {
+ const items = this.items;
+ if (!items) {
+ return;
+ }
+ const isMap = this._type === 'map';
+ items.forEach((item, index) => {
+ return isMap ? fn(item, item.key) : fn(item, index);
+ });
+ }
+
+ /**
+ * 遍历
+ */
+ map(fn: (item: Prop, key: number | string | undefined) => T): T[] | null {
+ const items = this.items;
+ if (!items) {
+ return null;
+ }
+ const isMap = this._type === 'map';
+ return items.map((item, index) => {
+ return isMap ? fn(item, item.key) : fn(item, index);
+ });
+ }
+}
+
+export function isProp(obj: any): obj is Prop {
+ return obj && obj.isProp;
+}
diff --git a/packages/designer/src/designer/document/node/props/props.ts b/packages/designer/src/designer/document/node/props/props.ts
new file mode 100644
index 000000000..d0d0691d6
--- /dev/null
+++ b/packages/designer/src/designer/document/node/props/props.ts
@@ -0,0 +1,242 @@
+import { computed, obx } from '@recore/obx';
+import { uniqueId } from '../../../../utils/unique-id';
+import { CompositeValue, PropsList, PropsMap } from '../../../schema';
+import StashSpace from './stash-space';
+import Prop, { IPropParent } from './prop';
+
+export const UNSET = Symbol.for('unset');
+export type UNSET = typeof UNSET;
+
+
+export default class Props implements IPropParent {
+ readonly id = uniqueId('props');
+ @obx.val private items: Prop[] = [];
+ @obx.ref private get maps(): Map {
+ const maps = new Map();
+ if (this.items.length > 0) {
+ this.items.forEach(prop => {
+ if (prop.key) {
+ maps.set(prop.key, prop);
+ }
+ });
+ }
+ return maps;
+ }
+
+ private stash = new StashSpace(
+ prop => {
+ this.items.push(prop);
+ prop.parent = this;
+ },
+ () => {
+ return true;
+ },
+ );
+
+ /**
+ * 元素个数
+ */
+ get size() {
+ return this.items.length;
+ }
+
+ @computed get value(): PropsMap | PropsList | null {
+ if (this.items.length < 1) {
+ return null;
+ }
+ if (this.type === 'list') {
+ return this.items.map(item => ({
+ spread: item.spread,
+ name: item.key as string,
+ value: item.value,
+ }));
+ }
+ const maps: any = {};
+ this.items.forEach(prop => {
+ if (prop.key) {
+ maps[prop.key] = prop.value;
+ }
+ });
+ return maps;
+ }
+
+ @obx type: 'map' | 'list' = 'map';
+
+ constructor(readonly owner: O, value?: PropsMap | PropsList | null) {
+ if (Array.isArray(value)) {
+ this.type = 'list';
+ value.forEach(item => {});
+ } else if (value != null) {
+ this.type = 'map';
+ }
+ }
+
+ /**
+ * 根据 path 路径查询属性,如果没有则临时生成一个
+ */
+ query(path: string): Prop;
+ /**
+ * 根据 path 路径查询属性
+ *
+ * @useStash 如果没有则临时生成一个
+ */
+ query(path: string, useStash: true): Prop;
+ /**
+ * 根据 path 路径查询属性
+ */
+ query(path: string, useStash: false): Prop | null;
+ /**
+ * 根据 path 路径查询属性
+ *
+ * @useStash 如果没有则临时生成一个
+ */
+ query(path: string, useStash: boolean = true) {
+ let matchedLength = 0;
+ let firstMatched = null;
+ if (this.items) {
+ // target: a.b.c
+ // trys: a.b.c, a.b, a
+ let i = this.items.length;
+ while (i-- > 0) {
+ const expr = this.items[i];
+ if (!expr.key) {
+ continue;
+ }
+ const name = String(expr.key);
+ if (name === path) {
+ // completely match
+ return expr;
+ }
+
+ // fisrt match
+ const l = name.length;
+ if (path.slice(0, l + 1) === `${name}.`) {
+ matchedLength = l;
+ firstMatched = expr;
+ }
+ }
+ }
+
+ let ret = null;
+ if (firstMatched) {
+ ret = firstMatched.get(path.slice(matchedLength + 1), true);
+ }
+ if (!ret && useStash) {
+ return this.stash.get(path);
+ }
+
+ return ret;
+ }
+
+ /**
+ * 获取某个属性, 如果不存在,临时获取一个待写入
+ * @param useStash 强制
+ */
+ get(path: string, useStash: true): Prop;
+ /**
+ * 获取某个属性
+ * @param useStash 强制
+ */
+ get(path: string, useStash: false): Prop | null;
+ /**
+ * 获取某个属性
+ */
+ get(path: string): Prop | null;
+ get(name: string, useStash = false) {
+ return this.maps.get(name) || (useStash && this.stash.get(name)) || null;
+ }
+
+ /**
+ * 删除项
+ */
+ delete(prop: Prop): void {
+ const i = this.items.indexOf(prop);
+ if (i > -1) {
+ this.items.splice(i, 1);
+ prop.purge();
+ }
+ }
+
+ /**
+ * 删除 key
+ */
+ deleteKey(key: string): void {
+ this.items = this.items.filter(item => {
+ if (item.key === key) {
+ item.purge();
+ return false;
+ }
+ return true;
+ });
+ }
+
+ /**
+ * 添加值
+ */
+ add(value: CompositeValue | null, key?: string | number, spread = false): Prop {
+ const prop = new Prop(this, value, key, spread);
+ this.items.push(prop);
+ return prop;
+ }
+
+ /**
+ * 是否存在 key
+ */
+ has(key: string): boolean {
+ return this.maps.has(key);
+ }
+
+ /**
+ * 迭代器
+ */
+ [Symbol.iterator](): { next(): { value: Prop } } {
+ let index = 0;
+ const items = this.items;
+ const length = items.length || 0;
+ return {
+ next() {
+ if (index < length) {
+ return {
+ value: items[index++],
+ done: false,
+ };
+ }
+ return {
+ value: undefined as any,
+ done: true,
+ };
+ },
+ };
+ }
+
+ /**
+ * 遍历
+ */
+ forEach(fn: (item: Prop, key: number | string | undefined) => void): void {
+ this.items.forEach(item => {
+ return fn(item, item.key);
+ });
+ }
+
+ /**
+ * 遍历
+ */
+ map(fn: (item: Prop, key: number | string | undefined) => T): T[] | null {
+ return this.items.map(item => {
+ return fn(item, item.key);
+ });
+ }
+
+ private purged = false;
+ /**
+ * 回收销毁
+ */
+ purge() {
+ if (this.purged) {
+ return;
+ }
+ this.purged = true;
+ this.stash.purge();
+ this.items.forEach(item => item.purge());
+ }
+}
diff --git a/packages/designer/src/designer/document/node/props/stash-space.ts b/packages/designer/src/designer/document/node/props/stash-space.ts
new file mode 100644
index 000000000..14cb36e18
--- /dev/null
+++ b/packages/designer/src/designer/document/node/props/stash-space.ts
@@ -0,0 +1,65 @@
+import { obx, autorun, untracked } from '@recore/obx';
+import Prop, { IPropParent } from './prop';
+
+export type PendingItem = Prop[];
+export default class StashSpace implements IPropParent {
+ @obx.val private space: Set = new Set();
+ @obx.ref private get maps(): Map {
+ const maps = new Map();
+ if (this.space.size > 0) {
+ this.space.forEach(prop => {
+ maps.set(prop.key, prop);
+ });
+ }
+ return maps;
+ }
+ private willPurge: () => void;
+
+ constructor(write: (item: Prop) => void, before: () => boolean) {
+ this.willPurge = autorun(() => {
+ if (this.space.size < 1) {
+ return;
+ }
+ const pending: Prop[] = [];
+ for (const prop of this.space) {
+ if (!prop.isUnset()) {
+ this.space.delete(prop);
+ pending.push(prop);
+ }
+ }
+ if (pending.length > 0) {
+ untracked(() => {
+ if (before()) {
+ for (const item of pending) {
+ write(item);
+ }
+ }
+ });
+ }
+ });
+ }
+
+ get(key: string): Prop {
+ let prop = this.maps.get(key);
+ if (!prop) {
+ prop = new Prop(this, null, key);
+ this.space.add(prop);
+ }
+ return prop;
+ }
+
+ delete(prop: Prop) {
+ this.space.delete(prop);
+ prop.purge();
+ }
+
+ clear() {
+ this.space.forEach(item => item.purge());
+ this.space.clear();
+ }
+
+ purge() {
+ this.willPurge();
+ this.space.clear();
+ }
+}
diff --git a/packages/designer/src/designer/document/node/root-node.ts b/packages/designer/src/designer/document/node/root-node.ts
new file mode 100644
index 000000000..5a65b8711
--- /dev/null
+++ b/packages/designer/src/designer/document/node/root-node.ts
@@ -0,0 +1,77 @@
+import Node, { NodeParent } from './node';
+import { RootSchema } from '../../schema';
+import DocumentModel from '../document-model';
+import NodeChildren from './node-children';
+import Props from './props/props';
+
+/**
+ * 根容器节点
+ *
+ * [Node Properties]
+ * componentName: Page/Block/Component
+ * props
+ * children
+ *
+ * [Root Container Extra Properties]
+ * fileName
+ * meta
+ * state
+ * defaultProps
+ * lifeCycles
+ * methods
+ * dataSource
+ * css
+ *
+ * [Directives **not used**]
+ * loop
+ * loopArgs
+ * condition
+ * ------- future support -----
+ * conditionGroup
+ * title
+ * ignore
+ * locked
+ * hidden
+ */
+export default class RootNode extends Node implements NodeParent {
+ readonly isRootNode = true;
+ readonly isNodeParent = true;
+ readonly index = 0;
+ readonly nextSibling = null;
+ readonly prevSibling = null;
+ readonly zLevel = 0;
+ readonly parent = null;
+ get children(): NodeChildren {
+ return this._children as NodeChildren;
+ }
+ get props(): Props {
+ return this._props as any;
+ }
+ get extras(): Props {
+ return this._extras as any;
+ }
+ get directives(): Props {
+ return this._props as any;
+ }
+ internalSetParent(parent: null) {}
+
+ constructor(readonly document: DocumentModel, rootSchema: RootSchema) {
+ super(document, rootSchema);
+ }
+
+ isPage() {
+ return this.componentName === 'Page';
+ }
+
+ isComponent() {
+ return this.componentName === 'Component';
+ }
+
+ isBlock() {
+ return this.componentName === 'Block';
+ }
+}
+
+export function isRootNode(node: any): node is RootNode {
+ return node && node.isRootNode;
+}
diff --git a/packages/designer/src/designer/dragon.ts b/packages/designer/src/designer/dragon.ts
index f1b0a5328..2aaca11d2 100644
--- a/packages/designer/src/designer/dragon.ts
+++ b/packages/designer/src/designer/dragon.ts
@@ -168,17 +168,14 @@ export default class Dragon {
}
getMasterSensors(): ISimulator[] {
- return this.designer.project.documents.map(doc => (doc.actived && doc.simulator) || null).filter(Boolean);
- }
-
- get master(): DocumentModel {
-
+ return this.designer.project.documents.map(doc => (doc.actived && doc.simulator) || null).filter(Boolean) as any;
}
/**
* dragTarget should be a INode | INode[] | NodeData | NodeData[]
*/
boost(dragObject: DragObject, boostEvent: MouseEvent) {
+ /*
const doc = document;
const fromTop = isFromTopDocument(boostEvent);
let lastLocation: any = null;
@@ -368,6 +365,7 @@ export default class Dragon {
topDoc.addEventListener('keyup', checkcopy as any, false);
}
}
+ */
}
addSensor(sensor: any) {
diff --git a/packages/designer/src/designer/history.ts b/packages/designer/src/designer/history.ts
new file mode 100644
index 000000000..65b3dba38
--- /dev/null
+++ b/packages/designer/src/designer/history.ts
@@ -0,0 +1 @@
+// todo
diff --git a/packages/designer/src/designer/hovering.ts b/packages/designer/src/designer/hovering.ts
new file mode 100644
index 000000000..641b8e3a7
--- /dev/null
+++ b/packages/designer/src/designer/hovering.ts
@@ -0,0 +1,34 @@
+import { obx } from '@recore/obx';
+import Node from './document/node/node';
+import DocumentModel from './document/document-model';
+
+export default class Hovering {
+ @obx.ref private _enable: boolean = true;
+ get enable() {
+ return this._enable;
+ }
+ set enable(flag: boolean) {
+ this._enable = flag;
+ if (!flag) {
+ this._hovering = null;
+ }
+ }
+ @obx.ref xRayMode: boolean = false;
+
+ @obx.ref private _hovering: Node | null = null;
+ get hovering() {
+ return this._hovering;
+ }
+
+ @obx.ref event?: MouseEvent;
+ hover(node: Node | null, e: MouseEvent) {
+ this._hovering = node;
+ this.event = e;
+ }
+
+ leave(document: DocumentModel) {
+ if (this.hovering && this.hovering.document === document) {
+ this._hovering = null;
+ }
+ }
+}
diff --git a/packages/designer/src/designer/index.ts b/packages/designer/src/designer/index.ts
index e69de29bb..748611c59 100644
--- a/packages/designer/src/designer/index.ts
+++ b/packages/designer/src/designer/index.ts
@@ -0,0 +1,3 @@
+import DesignerView from './designer-view';
+
+export default DesignerView;
diff --git a/packages/designer/src/designer/location.ts b/packages/designer/src/designer/location.ts
new file mode 100644
index 000000000..121200a03
--- /dev/null
+++ b/packages/designer/src/designer/location.ts
@@ -0,0 +1,126 @@
+import ComponentNode, { NodeParent } from './document/node/node';
+import DocumentModel from './document/document-model';
+
+export interface LocationData {
+ target: NodeParent; // shadowNode | ConditionFlow | ElementNode | RootNode
+ detail: LocationDetail;
+}
+
+export enum LocationDetailType {
+ Children = 'Children',
+ Prop = 'Prop',
+}
+
+export interface LocationChildrenDetail {
+ type: LocationDetailType.Children;
+ index: number;
+ near?: {
+ node: ComponentNode;
+ pos: 'before' | 'after';
+ rect?: Rect;
+ align?: 'V' | 'H';
+ };
+}
+
+export interface LocationPropDetail {
+ // cover 形态,高亮 domNode,如果 domNode 为空,取 container 的值
+ type: LocationDetailType.Prop;
+ name: string;
+ domNode?: HTMLElement;
+}
+
+export type LocationDetail = LocationChildrenDetail | LocationPropDetail | { type: string; [key: string]: any };
+
+export interface Point {
+ clientX: number;
+ clientY: number;
+}
+
+export type Rects = Array & {
+ elements: Array;
+};
+
+export type Rect = (ClientRect | DOMRect) & {
+ elements: Array;
+ computed?: boolean;
+};
+
+export function isLocationData(obj: any): obj is LocationData {
+ return obj && obj.target && obj.detail;
+}
+
+export function isLocationChildrenDetail(obj: any): obj is LocationChildrenDetail {
+ return obj && obj.type === LocationDetailType.Children;
+}
+
+export function isRowContainer(container: Element | Text, win?: Window) {
+ if (isText(container)) {
+ return true;
+ }
+ const style = (win || getWindow(container)).getComputedStyle(container);
+ const display = style.getPropertyValue('display');
+ if (/flex$/.test(display)) {
+ const direction = style.getPropertyValue('flex-direction') || 'row';
+ if (direction === 'row' || direction === 'row-reverse') {
+ return true;
+ }
+ }
+ return false;
+}
+
+export function isChildInline(child: Element | Text, win?: Window) {
+ if (isText(child)) {
+ return true;
+ }
+ const style = (win || getWindow(child)).getComputedStyle(child);
+ return /^inline/.test(style.getPropertyValue('display'));
+}
+
+export function getRectTarget(rect: Rect | null) {
+ if (!rect || rect.computed) {
+ return null;
+ }
+ const els = rect.elements;
+ return els && els.length > 0 ? els[0]! : null;
+}
+
+export function isVerticalContainer(rect: Rect | null) {
+ const el = getRectTarget(rect);
+ if (!el) {
+ return false;
+ }
+ return isRowContainer(el);
+}
+
+export function isVertical(rect: Rect | null) {
+ const el = getRectTarget(rect);
+ if (!el) {
+ return false;
+ }
+ return isChildInline(el) || (el.parentElement ? isRowContainer(el.parentElement) : false);
+}
+
+function isText(elem: any): elem is Text {
+ return elem.nodeType === Node.TEXT_NODE;
+}
+
+function isDocument(elem: any): elem is Document {
+ return elem.nodeType === Node.DOCUMENT_NODE;
+}
+
+export function getWindow(elem: Element | Document): Window {
+ return (isDocument(elem) ? elem : elem.ownerDocument!).defaultView!;
+}
+
+export default class Location {
+ readonly target: NodeParent;
+ readonly detail: LocationDetail;
+ get document(): DocumentModel {
+ return this.target.document;
+ }
+
+ constructor({ target, detail }: LocationData) {
+ this.target = target;
+ this.detail = detail;
+ }
+}
diff --git a/packages/designer/src/designer/project-view.tsx b/packages/designer/src/designer/project-view.tsx
new file mode 100644
index 000000000..406563b8a
--- /dev/null
+++ b/packages/designer/src/designer/project-view.tsx
@@ -0,0 +1,22 @@
+import { Component } from 'react';
+import { observer } from '@recore/core-obx';
+import Designer from './designer';
+import DocumentView from './document/document-view';
+
+@observer
+export default class ProjectView extends Component<{ designer: Designer }> {
+ render() {
+ const { designer } = this.props;
+ // TODO: support splitview
+ return (
+
+ {designer.project.documents.map(doc => {
+ if (!doc.opened) {
+ return null;
+ }
+ return ;
+ })}
+
+ );
+ }
+}
diff --git a/packages/designer/src/designer/schema.ts b/packages/designer/src/designer/schema.ts
index f027c66b4..cb2d4eb91 100644
--- a/packages/designer/src/designer/schema.ts
+++ b/packages/designer/src/designer/schema.ts
@@ -31,7 +31,7 @@ export interface CompositeObject {
}
export interface NpmInfo {
- componentName: string;
+ componentName?: string;
package: string;
version: string;
destructuring?: boolean;
diff --git a/packages/designer/src/designer/scroller.ts b/packages/designer/src/designer/scroller.ts
new file mode 100644
index 000000000..cba629927
--- /dev/null
+++ b/packages/designer/src/designer/scroller.ts
@@ -0,0 +1,181 @@
+import { isElement } from '../utils/dom';
+
+export class ScrollTarget {
+ get left() {
+ return 'scrollX' in this.target ? this.target.scrollX : this.target.scrollLeft;
+ }
+ get top() {
+ return 'scrollY' in this.target ? this.target.scrollY : this.target.scrollTop;
+ }
+ scrollTo(options: { left?: number; top?: number }) {
+ this.target.scrollTo(options);
+ }
+
+ scrollToXY(x: number, y: number) {
+ this.target.scrollTo(x, y);
+ }
+
+ get scrollHeight(): number {
+ return ((this.doe || this.target) as any).scrollHeight;
+ }
+
+ get scrollWidth(): number {
+ return ((this.doe || this.target) as any).scrollWidth;
+ }
+
+ private doe?: HTMLElement;
+ constructor(private target: Window | Element) {
+ if (isWindow(target)) {
+ this.doe = target.document.documentElement;
+ }
+ }
+}
+
+function isWindow(obj: any): obj is Window {
+ return obj && obj.document;
+}
+
+function easing(n: number) {
+ return Math.sin((n * Math.PI) / 2);
+}
+
+const SCROLL_ACCURCY = 30;
+
+export interface IScrollable {
+ scrollTarget?: ScrollTarget | Element;
+ bounds: DOMRect;
+ scale: number;
+}
+
+export default class Scroller {
+ private pid: number | undefined;
+
+ get scrollTarget(): ScrollTarget | null {
+ let target = this.scrollable.scrollTarget;
+ if (!target) {
+ return null;
+ }
+ if (isElement(target)) {
+ target = new ScrollTarget(target);
+ this.scrollable.scrollTarget = target;
+ }
+ return target;
+ }
+
+ constructor(private scrollable: IScrollable) {
+ }
+
+ scrollTo(options: { left?: number; top?: number }) {
+ this.cancel();
+
+ const scrollTarget = this.scrollTarget;
+ if (!scrollTarget) {
+ return;
+ }
+
+ let pid: number;
+ const left = scrollTarget.left;
+ const top = scrollTarget.top;
+ const end = () => {
+ this.cancel();
+ };
+
+ if ((left === options.left || options.left == null) && top === options.top) {
+ end();
+ return;
+ }
+
+ const duration = 200;
+ const start = +new Date();
+
+ const animate = () => {
+ if (pid !== this.pid) {
+ return;
+ }
+
+ const now = +new Date();
+ const time = Math.min(1, (now - start) / duration);
+ const eased = easing(time);
+ const opt: any = {};
+ if (options.left != null) {
+ opt.left = eased * (options.left - left) + left;
+ }
+ if (options.top != null) {
+ opt.top = eased * (options.top - top) + top;
+ }
+
+ scrollTarget.scrollTo(opt);
+
+ if (time < 1) {
+ this.pid = pid = requestAnimationFrame(animate);
+ } else {
+ end();
+ }
+ };
+
+ this.pid = pid = requestAnimationFrame(animate);
+ }
+
+ scrolling(point: { globalX: number; globalY: number }) {
+ this.cancel();
+
+ const { bounds, scale } = this.scrollable;
+ const scrollTarget = this.scrollTarget;
+ if (!scrollTarget) {
+ return;
+ }
+
+ const x = point.globalX;
+ const y = point.globalY;
+
+ const maxScrollHeight = scrollTarget.scrollHeight - bounds.height / scale;
+ const maxScrollWidth = scrollTarget.scrollWidth - bounds.width / scale;
+ let sx = scrollTarget.left;
+ let sy = scrollTarget.top;
+ let ax = 0;
+ let ay = 0;
+ if (y < bounds.top + SCROLL_ACCURCY) {
+ ay = -Math.min(Math.max(bounds.top + SCROLL_ACCURCY - y, 10), 50) / scale;
+ } else if (y > bounds.bottom - SCROLL_ACCURCY) {
+ ay = Math.min(Math.max(y + SCROLL_ACCURCY - bounds.bottom, 10), 50) / scale;
+ }
+ if (x < bounds.left + SCROLL_ACCURCY) {
+ ax = -Math.min(Math.max(bounds.top + SCROLL_ACCURCY - y, 10), 50) / scale;
+ } else if (x > bounds.right - SCROLL_ACCURCY) {
+ ax = Math.min(Math.max(x + SCROLL_ACCURCY - bounds.right, 10), 50) / scale;
+ }
+
+ if (!ax && !ay) {
+ return;
+ }
+
+ const animate = () => {
+ let scroll = false;
+ if ((ay > 0 && sy < maxScrollHeight) || (ay < 0 && sy > 0)) {
+ sy += ay;
+ sy = Math.min(Math.max(sy, 0), maxScrollHeight);
+ scroll = true;
+ }
+ if ((ax > 0 && sx < maxScrollWidth) || (ax < 0 && sx > 0)) {
+ sx += ax;
+ sx = Math.min(Math.max(sx, 0), maxScrollWidth);
+ scroll = true;
+ }
+ if (!scroll) {
+ return;
+ }
+
+ scrollTarget.scrollTo({ left: sx, top: sy });
+ this.pid = requestAnimationFrame(animate);
+ };
+
+ animate();
+ }
+
+ cancel() {
+ if (this.pid) {
+ cancelAnimationFrame(this.pid);
+ }
+ this.pid = undefined;
+ }
+}
diff --git a/packages/designer/src/designer/simulator.ts b/packages/designer/src/designer/simulator.ts
new file mode 100644
index 000000000..36912242a
--- /dev/null
+++ b/packages/designer/src/designer/simulator.ts
@@ -0,0 +1,156 @@
+import { Component as ReactComponent, ComponentType } from 'react';
+import { LocateEvent, ISensor } from './dragon';
+import { Point } from './location';
+import Node from './document/node/node';
+import { ScrollTarget, IScrollable } from './scroller';
+import { ComponentDescriptionSpec } from './document/node/component-config';
+
+export type AutoFit = '100%';
+export const AutoFit = '100%';
+
+export interface IViewport extends IScrollable {
+ /**
+ * 视口大小
+ */
+ width: number;
+ height: number;
+
+ /**
+ * 内容大小
+ */
+ contentWidth: number | AutoFit;
+ contentHeight: number | AutoFit;
+
+ /**
+ * 内容缩放
+ */
+ scale: number;
+
+ /**
+ * 视口矩形维度
+ */
+ readonly bounds: DOMRect;
+ /**
+ * 内容矩形维度
+ */
+ readonly contentBounds: DOMRect;
+ readonly scrollTarget?: ScrollTarget;
+ /**
+ * 内容当前滚动 X
+ */
+ readonly scrollX: number;
+ /**
+ * 内容当前滚动 Y
+ */
+ readonly scrollY: number;
+
+ /**
+ * 全局坐标系转化为本地坐标系
+ */
+ toLocalPoint(point: Point): Point;
+
+ /**
+ * 本地坐标系转化为全局坐标系
+ */
+ toGlobalPoint(point: Point): Point;
+}
+
+/**
+ * 模拟器控制进程协议
+ */
+export interface ISimulator extends ISensor {
+ /**
+ * 获得边界维度等信息
+ */
+ readonly viewport: IViewport;
+ readonly contentWindow?: Window;
+ readonly contentDocument?: Document;
+
+ // dependsAsset // like react jQuery lodash
+ // themesAsset
+ // componentsAsset
+ // simulatorUrl //
+ // utils, dataSource, constants 模拟
+ //
+ // later:
+ // layout: ComponentName
+ // 获取区块代码, 通过 components 传递,可异步获取
+ setProps(props: P): void;
+
+ /**
+ * 设置拖拽态
+ */
+ setDraggingState(state: boolean): void;
+
+ /**
+ * 是否拖拽态
+ */
+ isDraggingState(): boolean;
+
+ /**
+ * 设置拷贝态
+ */
+ setCopyState(state: boolean): void;
+
+ /**
+ * 是否拷贝态
+ */
+ isCopyState(): boolean;
+
+ /**
+ * 清除所有态:拖拽态、拷贝态
+ */
+ clearState(): void;
+
+ /**
+ * 在模拟器拖拽定位
+ */
+ locate(e: LocateEvent): any;
+
+ /**
+ * 滚动视口到节点
+ */
+ scrollToNode(node: Node, detail?: any): void;
+
+ /**
+ * 给 event 打补丁,添加 canvasX, globalX 等信息,用于拖拽
+ */
+ fixEvent(e: LocateEvent): LocateEvent;
+
+ /**
+ * 描述组件
+ */
+ describeComponent(component: Component): ComponentDescriptionSpec;
+ /**
+ * 根据组件信息获取组件类
+ */
+ getComponent(componentName: string): Component | any;
+ /**
+ * 根据节点获取节点的组件实例
+ */
+ getComponentInstance(node: Node): ComponentInstance[] | null;
+ /**
+ * 根据节点获取节点的组件运行上下文
+ */
+ getComponentContext(node: Node): object;
+
+ getClosestNodeId(elem: Element): string;
+
+ findDOMNodes(instance: ComponentInstance): Array | null;
+
+ setSuspense(suspensed: boolean): void;
+ /**
+ * 销毁
+ */
+ purge(): void;
+}
+
+/**
+ * 组件类定义
+ */
+export type Component = ComponentType | object;
+
+/**
+ * 组件实例定义
+ */
+export type ComponentInstance = Element | ReactComponent | object;
diff --git a/packages/designer/src/index.ts b/packages/designer/src/index.ts
index e69de29bb..d7b940c2d 100644
--- a/packages/designer/src/index.ts
+++ b/packages/designer/src/index.ts
@@ -0,0 +1,3 @@
+import DesignerView from './designer';
+
+export default DesignerView;
diff --git a/packages/designer/src/module.d.ts b/packages/designer/src/module.d.ts
new file mode 100644
index 000000000..57479fc2e
--- /dev/null
+++ b/packages/designer/src/module.d.ts
@@ -0,0 +1 @@
+declare module '@ali/iceluna-sdk';
diff --git a/packages/designer/src/utils/index.ts b/packages/designer/src/utils/index.ts
deleted file mode 100644
index 35cf98d29..000000000
--- a/packages/designer/src/utils/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export * from './is-object';
-export * from './is-plain-object';
-export * from './has-own-property';
-export * from './set-prototype-of';
-export * from './get-prototype-of';
-export * from './shallow-equal';
-export * from './clone-deep';
-export * from './throttle';
-export * from './unique-id';
diff --git a/packages/designer/src/utils/navtive-selection.ts b/packages/designer/src/utils/navtive-selection.ts
new file mode 100644
index 000000000..76f51f48a
--- /dev/null
+++ b/packages/designer/src/utils/navtive-selection.ts
@@ -0,0 +1,15 @@
+let nativeSelectionEnabled = true;
+const preventSelection = (e: Event) => {
+ if (nativeSelectionEnabled) {
+ return null;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+};
+document.addEventListener('selectstart', preventSelection, true);
+document.addEventListener('dragstart', preventSelection, true);
+
+export function setNativeSelection(enableFlag: boolean) {
+ nativeSelectionEnabled = enableFlag;
+}
diff --git a/packages/designer/src/utils/parse-code.ts b/packages/designer/src/utils/parse-code.ts
deleted file mode 100644
index 643a41611..000000000
--- a/packages/designer/src/utils/parse-code.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export function parseCode(code: string): string {
- try {
- return JSON.parse(code);
- } catch (e) {
- return code;
- }
-}
diff --git a/packages/designer/src/utils/shallow-equal.ts b/packages/designer/src/utils/shallow-equal.ts
deleted file mode 100644
index c7fdb9cb1..000000000
--- a/packages/designer/src/utils/shallow-equal.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { hasOwnProperty } from './has-own-property';
-
-export function shallowEqual(objA: any, objB: any): boolean {
- if (objA === objB) {
- return true;
- }
-
- if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
- return false;
- }
-
- const keysA = Object.keys(objA);
- const keysB = Object.keys(objB);
-
- if (keysA.length !== keysB.length) {
- return false;
- }
-
- // Test for A's keys different from B.
- for (let i = 0; i < keysA.length; i++) {
- if (!hasOwnProperty(objB, keysA[i]) || objA[keysA[i]] !== objB[keysA[i]]) {
- return false;
- }
- }
-
- return true;
-}
diff --git a/packages/designer/src/utils/style-point.ts b/packages/designer/src/utils/style-point.ts
deleted file mode 100644
index fb7381cbf..000000000
--- a/packages/designer/src/utils/style-point.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-export default class StylePoint {
- private lastContent: string | undefined;
- private lastUrl: string | undefined;
- placeholder: Element | Text;
- next: StylePoint | null = null;
- prev: StylePoint | null = null;
-
- constructor(readonly id: string, readonly level: number, placeholder?: Element) {
- if (placeholder) {
- this.placeholder = placeholder;
- } else {
- this.placeholder = document.createTextNode('');
- }
- }
-
- insert() {
- if (this.next) {
- document.head.insertBefore(this.placeholder, this.next.placeholder);
- } else if (this.prev) {
- document.head.insertBefore(this.placeholder, this.prev.placeholder.nextSibling);
- } else {
- document.head.appendChild(this.placeholder);
- }
- }
-
- applyText(content: string) {
- if (this.lastContent === content) {
- return;
- }
- this.lastContent = content;
- this.lastUrl = undefined;
- const element = document.createElement('style');
- element.setAttribute('type', 'text/css');
- element.setAttribute('data-for', this.id);
- element.appendChild(document.createTextNode(content));
- document.head.insertBefore(element, this.placeholder);
- document.head.removeChild(this.placeholder);
- this.placeholder = element;
- }
-
- applyUrl(url: string) {
- if (this.lastUrl === url) {
- return;
- }
- this.lastContent = undefined;
- this.lastUrl = url;
- const element = document.createElement('link');
- element.href = url;
- element.rel = 'stylesheet';
- element.setAttribute('data-for', this.id);
- document.head.insertBefore(element, this.placeholder);
- document.head.removeChild(this.placeholder);
- this.placeholder = element;
- }
-}
diff --git a/packages/designer/src/utils/type-check.ts b/packages/designer/src/utils/type-check.ts
deleted file mode 100644
index ea1aacfeb..000000000
--- a/packages/designer/src/utils/type-check.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { Component, isValidElement } from 'react';
-
-export function testReactType(obj: any) {
- const t = typeof obj;
- if (t === 'function' && obj.prototype && (obj.prototype.isReactComponent || obj.prototype instanceof Component)) {
- return 'ReactClass';
- } else if (t === 'object' && isValidElement(obj)) {
- return 'ReactElement';
- }
- return t;
-}