diff --git a/packages/designer/.editorconfig b/packages/designer/.editorconfig
new file mode 100644
index 000000000..16a029ac9
--- /dev/null
+++ b/packages/designer/.editorconfig
@@ -0,0 +1,16 @@
+# EditorConfig is awesome: http://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Tab indentation
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/packages/designer/.eslintignore b/packages/designer/.eslintignore
new file mode 100644
index 000000000..1fb2edf7c
--- /dev/null
+++ b/packages/designer/.eslintignore
@@ -0,0 +1,6 @@
+.idea/
+.vscode/
+build/
+.*
+~*
+node_modules
diff --git a/packages/designer/.eslintrc b/packages/designer/.eslintrc
new file mode 100644
index 000000000..db78d35d1
--- /dev/null
+++ b/packages/designer/.eslintrc
@@ -0,0 +1,3 @@
+{
+ "extends": "./node_modules/@recore/config/.eslintrc"
+}
diff --git a/packages/designer/.gitignore b/packages/designer/.gitignore
new file mode 100644
index 000000000..5261403b4
--- /dev/null
+++ b/packages/designer/.gitignore
@@ -0,0 +1,40 @@
+node_modules/
+coverage/
+build/
+dist/
+.idea/
+.vscode/
+.theia/
+.recore/
+~*
+package-lock.json
+
+# Packages #
+############
+# it's better to unpack these files and commit the raw source
+# git has its own built in compression methods
+*.7z
+*.dmg
+*.gz
+*.iso
+*.jar
+*.rar
+*.tar
+*.zip
+
+# Logs and databases #
+######################
+*.log
+*.sql
+*.sqlite
+
+# OS generated files #
+######################
+.DS_Store
+.Trash*
+*.swp
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
diff --git a/packages/designer/.prettierrc b/packages/designer/.prettierrc
new file mode 100644
index 000000000..8748c5ed3
--- /dev/null
+++ b/packages/designer/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "semi": true,
+ "singleQuote": true,
+ "printWidth": 120,
+ "trailingComma": "all"
+}
diff --git a/packages/designer/package.json b/packages/designer/package.json
new file mode 100644
index 000000000..e302646e8
--- /dev/null
+++ b/packages/designer/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "lowcode-designer",
+ "version": "0.9.0",
+ "description": "alibaba lowcode designer",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "author": "",
+ "license": "MIT",
+ "dependencies": {
+ "@recore/obx": "^1.0.5",
+ "@types/medium-editor": "^5.0.3",
+ "classnames": "^2.2.6",
+ "react": "^16",
+ "react-dom": "^16.7.0"
+ },
+ "devDependencies": {
+ "@types/classnames": "^2.2.7",
+ "@types/jest": "^24.0.16",
+ "@types/react": "^16",
+ "@types/react-dom": "^16",
+ "@recore/config": "^2.0.0",
+ "ts-jest": "^24.0.2",
+ "ts-node": "^8.0.1",
+ "eslint": "^6.5.1",
+ "husky": "^1.1.2",
+ "jest": "^23.4.1",
+ "lint-staged": "^7.1.2",
+ "tslib": "^1.9.3",
+ "typescript": "^3.1.3",
+ "prettier": "^1.18.2"
+ },
+ "lint-staged": {
+ "./src/**/*.{ts,tsx}": [
+ "eslint --fix",
+ "git add"
+ ]
+ }
+}
diff --git a/packages/designer/src/builtins/drag-ghost/ghost.less b/packages/designer/src/builtins/drag-ghost/ghost.less
new file mode 100644
index 000000000..c470c4ebc
--- /dev/null
+++ b/packages/designer/src/builtins/drag-ghost/ghost.less
@@ -0,0 +1,29 @@
+.my-ghost-group {
+ box-sizing: border-box;
+ position: fixed;
+ z-index: 99999;
+ width: 100px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ pointer-events: none;
+ background-color: rgba(0, 0, 0, 0.4);
+ opacity: 0.5;
+ .my-ghost {
+ .my-ghost-title {
+ text-align: center;
+ font-size: var(--font-size-text);
+ text-overflow: ellipsis;
+ color: var(--color-text-light);
+ white-space: nowrap;
+ overflow: hidden;
+ }
+ }
+}
+
+.dragging {
+ position: fixed;
+ z-index: 99999;
+ width: 100%;
+ box-shadow: 0 0 6px grey;
+}
diff --git a/packages/designer/src/builtins/drag-ghost/ghost.tsx b/packages/designer/src/builtins/drag-ghost/ghost.tsx
new file mode 100644
index 000000000..972516abe
--- /dev/null
+++ b/packages/designer/src/builtins/drag-ghost/ghost.tsx
@@ -0,0 +1,105 @@
+import { Component } from 'react';
+import { observer, obx } from '@ali/recore';
+import { dragon } from '../../globals/dragon';
+
+import './ghost.less';
+import { OutlineBoardID } from '../builtin-panes/outline-pane/outline-board';
+// import { INode } from '../../document/node';
+
+type offBinding = () => any;
+
+@observer
+export default class Ghost extends Component {
+ private dispose: offBinding[] = [];
+ @obx.ref private dragment: any = null;
+ @obx.ref private x = 0;
+ @obx.ref private y = 0;
+
+ componentWillMount() {
+ this.dispose = [
+ dragon.onDragstart(e => {
+ this.dragment = e.dragTarget;
+ this.x = e.clientX;
+ this.y = e.clientY;
+ }),
+ dragon.onDrag(e => {
+ this.x = e.clientX;
+ this.y = e.clientY;
+ }),
+ 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;
+ }
+
+ // let x = this.x;
+ // let y = this.y;
+
+ // todo: 考虑多个图标、title、不同 sensor 区域的形态
+ if (dragon.activeSensor && dragon.activeSensor.id === OutlineBoardID) {
+ // const nodeId = (this.dragment as INode).id;
+ // const elt = document.querySelector(`[data-id="${nodeId}"`) as HTMLDivElement;
+ //
+ // if (elt) {
+ // // do something
+ // // const target = elt.cloneNode(true) as HTMLDivElement;
+ // console.log('>>> target', elt);
+ // elt.classList.remove('hidden');
+ // elt.classList.add('dragging');
+ // elt.style.transform = `translate(${this.x}px, ${this.y}px)`;
+ // }
+ //
+ // return null;
+ // x -= 30;
+ // y += 30;
+ }
+
+ return (
+
+ {this.renderGhostGroup()}
+
+ );
+ }
+}
diff --git a/packages/designer/src/builtins/embed-editor.ts b/packages/designer/src/builtins/embed-editor.ts
new file mode 100644
index 000000000..89368a8e8
--- /dev/null
+++ b/packages/designer/src/builtins/embed-editor.ts
@@ -0,0 +1,59 @@
+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/auxilary/README.md b/packages/designer/src/builtins/simulator/auxilary/README.md
new file mode 100644
index 000000000..bd959599f
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/README.md
@@ -0,0 +1,22 @@
+辅助类
+ 对齐线
+ 插入指示 insertion 竖线 横线 插入块 禁止插入块
+ 幽灵替身 ghost
+ 聚焦编辑指示
+
+
+插入指示 insertion 竖线 横线 插入块 禁止插入块
+
+竖线:红色,绿色
+横线:红色,绿色
+插入块:透明绿色,透明红色
+
+投放指示线
+
+cover
+
+轮廓服务
+ 悬停指示线 xray mode?
+ 选中指示线
+ 投放指示线
+ 透视线 x-ray
diff --git a/packages/designer/src/builtins/simulator/auxilary/auxiliary.less b/packages/designer/src/builtins/simulator/auxilary/auxiliary.less
new file mode 100644
index 000000000..0cd365d85
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/auxiliary.less
@@ -0,0 +1,20 @@
+.my-auxiliary {
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ overflow: visible;
+ z-index: 800;
+ .embed-editor-toolbar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ > * {
+ pointer-events: all;
+ }
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/auxilary/auxiliary.tsx b/packages/designer/src/builtins/simulator/auxilary/auxiliary.tsx
new file mode 100644
index 000000000..1e5e308fc
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/auxiliary.tsx
@@ -0,0 +1,31 @@
+import { observer } from '@ali/recore';
+import { Component } from 'react';
+import { getCurrentDocument } from '../../globals';
+import './auxiliary.less';
+import { EdgingView } from './edging';
+import { InsertionView } from './insertion';
+import { SelectingView } from './selecting';
+import EmbedEditorToolbar from './embed-editor-toolbar';
+
+@observer
+export class AuxiliaryView extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ const doc = getCurrentDocument();
+ if (!doc || !doc.ready) {
+ return null;
+ }
+ const { scrollX, scrollY, scale } = doc.viewport;
+ return (
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/auxilary/droping.ts b/packages/designer/src/builtins/simulator/auxilary/droping.ts
new file mode 100644
index 000000000..4b1ce2884
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/droping.ts
@@ -0,0 +1,13 @@
+// outline
+// insertion
+/*
+// 插入指示 insertion 竖线 横线 插入块 禁止插入块
+
+竖线:红色,绿色
+横线:红色,绿色
+插入块:透明绿色,透明红色
+
+投放指示线
+
+cover
+*/
diff --git a/packages/designer/src/builtins/simulator/auxilary/edging.less b/packages/designer/src/builtins/simulator/auxilary/edging.less
new file mode 100644
index 000000000..733ae746d
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/edging.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/src/builtins/simulator/auxilary/edging.tsx b/packages/designer/src/builtins/simulator/auxilary/edging.tsx
new file mode 100644
index 000000000..2cde1a9aa
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/edging.tsx
@@ -0,0 +1,62 @@
+import { observer } from '@ali/recore';
+import { Component } from 'react';
+import { edging } from '../../globals/edging';
+import './edging.less';
+import { hasConditionFlow } from '../../document/node';
+import { isShadowNode } from '../../document/node/shadow-node';
+import { isConditionFlow } from '../../document/node/condition-flow';
+import { current } from '../../globals';
+
+@observer
+export class EdgingView 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 rects
+ // 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';
+
+ // handle x-for node
+ if (isShadowNode(node)) {
+ className += ' x-shadow';
+ }
+ // handle x-if/else-if/else node
+ if (isConditionFlow(node) || hasConditionFlow(node)) {
+ className += ' x-flow';
+ }
+
+ // TODO:
+ // 1. thinkof icon
+ // 2. thinkof top|bottom|inner space
+
+ return (
+
+ );
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/auxilary/embed-editor-toolbar.tsx b/packages/designer/src/builtins/simulator/auxilary/embed-editor-toolbar.tsx
new file mode 100644
index 000000000..e8d6d8fac
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/embed-editor-toolbar.tsx
@@ -0,0 +1,12 @@
+import { Component } from 'react';
+import embedEditor from '../../globals/embed-editor';
+
+export default class EmbedEditorToolbar extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ return embedEditor.mount(shell)} />;
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/auxilary/index.ts b/packages/designer/src/builtins/simulator/auxilary/index.ts
new file mode 100644
index 000000000..61552f4e3
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/index.ts
@@ -0,0 +1 @@
+export * from './auxiliary';
diff --git a/packages/designer/src/builtins/simulator/auxilary/insertion.less b/packages/designer/src/builtins/simulator/auxilary/insertion.less
new file mode 100644
index 000000000..871210b7b
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/insertion.less
@@ -0,0 +1,23 @@
+.my-insertion {
+ position: absolute;
+ top: -1.5px;
+ left: 0;
+ z-index: 12;
+ pointer-events: none !important;
+ background-color: var(--color-brand-light);
+ height: 3px;
+
+ &.cover {
+ top: 0;
+ height: auto;
+ width: auto;
+ opacity: 0.3;
+ }
+
+ &.vertical {
+ top: 0;
+ left: -1.5px;
+ width: 3px;
+ height: auto;
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/auxilary/insertion.tsx b/packages/designer/src/builtins/simulator/auxilary/insertion.tsx
new file mode 100644
index 000000000..cc09d6ec4
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/insertion.tsx
@@ -0,0 +1,139 @@
+import { Component } from 'react';
+import { observer } from '@ali/recore';
+import { getCurrentDocument } from '../../globals';
+import './insertion.less';
+import Location, { isLocationChildrenDetail, isVertical, LocationChildrenDetail, Rect } from '../../document/location';
+import { isConditionFlow } from '../../document/node/condition-flow';
+import { getChildAt, INodeParent } from '../../document/node';
+import DocumentContext from '../../document/document-context';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+function processPropDetail() {
+ // return { insertType: 'cover', coverEdge: ? };
+}
+
+interface InsertionData {
+ edge?: Rect;
+ insertType?: string;
+ vertical?: boolean;
+ nearRect?: Rect;
+ coverRect?: Rect;
+}
+
+/**
+ * 处理拖拽子节点(INode)情况
+ */
+function processChildrenDetail(
+ doc: DocumentContext,
+ target: INodeParent,
+ detail: LocationChildrenDetail,
+): InsertionData {
+ const edge = doc.computeRect(target);
+ if (!edge) {
+ return {};
+ }
+
+ const ret: any = {
+ edge,
+ insertType: 'before',
+ };
+
+ if (isConditionFlow(target)) {
+ ret.insertType = 'cover';
+ ret.coverRect = edge;
+ return ret;
+ }
+
+ if (detail.near) {
+ const { node, pos, rect, align } = detail.near;
+ ret.nearRect = rect || doc.computeRect(node);
+ ret.vertical = align ? align === 'V' : isVertical(ret.nearRect);
+ ret.insertType = pos;
+ return ret;
+ }
+
+ // from outline-tree: has index, but no near
+ // TODO: think of shadowNode & ConditionFlow
+ const { index } = detail;
+ let nearNode = getChildAt(target, index);
+ if (!nearNode) {
+ // index = 0, eg. nochild,
+ nearNode = getChildAt(target, index > 0 ? index - 1 : 0);
+ if (!nearNode) {
+ ret.insertType = 'cover';
+ ret.coverRect = edge;
+ return ret;
+ }
+ ret.insertType = 'after';
+ }
+ if (nearNode) {
+ ret.nearRect = doc.computeRect(nearNode);
+ ret.vertical = isVertical(ret.nearRect);
+ }
+ return ret;
+}
+
+/**
+ * 将 detail 信息转换为页面"坐标"信息
+ */
+function processDetail({ target, detail, document }: Location): InsertionData {
+ if (isLocationChildrenDetail(detail)) {
+ return processChildrenDetail(document, target, detail);
+ } else {
+ // TODO: others...
+ const edge = document.computeRect(target);
+ return edge ? { edge, insertType: 'cover', coverRect: edge } : {};
+ }
+}
+
+@observer
+export class InsertionView extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ const doc = getCurrentDocument();
+ if (!doc || !doc.dropLocation) {
+ return null;
+ }
+
+ const { scale, scrollTarget } = doc.viewport;
+ const sx = scrollTarget!.left;
+ const sy = scrollTarget!.top;
+
+ const { edge, insertType, coverRect, nearRect, vertical } = processDetail(doc.dropLocation);
+ if (!edge) {
+ return null;
+ }
+
+ let className = 'my-insertion';
+ const style: any = {};
+ let x: number;
+ let y: number;
+ if (insertType === 'cover') {
+ className += ' cover';
+ x = (coverRect!.left + sx) * scale;
+ y = (coverRect!.top + sy) * scale;
+ style.width = coverRect!.width * scale;
+ style.height = coverRect!.height * scale;
+ } else {
+ if (!nearRect) {
+ return null;
+ }
+ if (vertical) {
+ className += ' vertical';
+ x = ((insertType === 'before' ? nearRect.left : nearRect.right) + sx) * scale;
+ y = (nearRect.top + sy) * scale;
+ style.height = nearRect!.height * scale;
+ } else {
+ x = (nearRect.left + sx) * scale;
+ y = ((insertType === 'before' ? nearRect.top : nearRect.bottom) + sy) * scale;
+ style.width = nearRect.width * scale;
+ }
+ }
+ style.transform = `translate3d(${x}px, ${y}px, 0)`;
+
+ return
;
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/auxilary/offset-observer.ts b/packages/designer/src/builtins/simulator/auxilary/offset-observer.ts
new file mode 100644
index 000000000..960674b47
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/offset-observer.ts
@@ -0,0 +1,60 @@
+import { obx } from '@ali/recore';
+import { INode } from '../../document/node';
+
+export default class OffsetObserver {
+ @obx.ref offsetTop = 0;
+ @obx.ref offsetLeft = 0;
+ @obx.ref offsetRight = 0;
+ @obx.ref offsetBottom = 0;
+ @obx.ref height = 0;
+ @obx.ref width = 0;
+ @obx.ref hasOffset = false;
+ @obx.ref left = 0;
+ @obx.ref top = 0;
+ @obx.ref right = 0;
+ @obx.ref bottom = 0;
+
+ private pid: number | undefined;
+
+ constructor(node: INode) {
+ const document = node.document;
+ const scrollTarget = document.viewport.scrollTarget!;
+
+ let pid: number;
+ const compute = () => {
+ if (pid !== this.pid) {
+ return;
+ }
+
+ const rect = document.computeRect(node);
+ if (!rect) {
+ this.hasOffset = false;
+ return;
+ }
+ this.hasOffset = true;
+ this.offsetLeft = rect.left + scrollTarget.left;
+ this.offsetRight = rect.right + scrollTarget.left;
+ this.offsetTop = rect.top + scrollTarget.top;
+ this.offsetBottom = rect.bottom + scrollTarget.top;
+ this.height = rect.height;
+ this.width = rect.width;
+ this.left = rect.left;
+ this.top = rect.top;
+ this.right = rect.right;
+ this.bottom = rect.bottom;
+ this.pid = pid = (window as any).requestIdleCallback(compute);
+ };
+
+ // try first
+ compute();
+ // try second, ensure the dom mounted
+ this.pid = pid = (window as any).requestIdleCallback(compute);
+ }
+
+ destroy() {
+ if (this.pid) {
+ (window as any).cancelIdleCallback(this.pid);
+ }
+ this.pid = undefined;
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/auxilary/selecting.less b/packages/designer/src/builtins/simulator/auxilary/selecting.less
new file mode 100644
index 000000000..924c52d8c
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/selecting.less
@@ -0,0 +1,39 @@
+.my-selecting {
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ left: 0;
+ border: 1px solid var(--color-brand-light);
+ z-index: 2;
+ overflow: visible;
+ >.title {
+ position: absolute;
+ color: var(--color-brand-light);
+ top: -20px;
+ left: 0;
+ font-weight: lighter;
+ }
+ &.dragging {
+ background: rgba(182, 178, 178, 0.8);
+ border: none;
+ pointer-events: all;
+ }
+
+ &.x-shadow {
+ border-color: rgba(147, 112, 219, 1.0);
+ background: rgba(147, 112, 219, 0.04);
+ >.title {
+ color: rgba(147, 112, 219, 1.0);
+ }
+ &.highlight {
+ background: transparent;
+ }
+ }
+
+ &.x-flow {
+ border-color: rgb(255, 99, 8);
+ >.title {
+ color: rgb(255, 99, 8);
+ }
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/auxilary/selecting.tsx b/packages/designer/src/builtins/simulator/auxilary/selecting.tsx
new file mode 100644
index 000000000..b89c35c99
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/auxilary/selecting.tsx
@@ -0,0 +1,85 @@
+import { observer } from '@ali/recore';
+import { Component, Fragment } from 'react';
+import classNames from 'classnames';
+import { INode, isElementNode, isConfettiNode, hasConditionFlow } from '../../document/node';
+import OffsetObserver from './offset-observer';
+import './selecting.less';
+import { isShadowNode, isShadowsContainer } from '../../document/node/shadow-node';
+import { isConditionFlow } from '../../document/node/condition-flow';
+import { current, dragon } from '../../globals';
+
+@observer
+export class SingleSelectingView extends Component<{ node: INode; highlight?: boolean }> {
+ private offsetObserver: OffsetObserver;
+
+ constructor(props: { node: INode; highlight?: boolean }) {
+ super(props);
+ this.offsetObserver = new OffsetObserver(props.node);
+ }
+
+ render() {
+ if (!this.offsetObserver.hasOffset) {
+ return null;
+ }
+
+ const scale = this.props.node.document.viewport.scale;
+ const { width, height, offsetTop, offsetLeft } = this.offsetObserver;
+
+ const style = {
+ width: width * scale,
+ height: height * scale,
+ transform: `translate3d(${offsetLeft * scale}px, ${offsetTop * scale}px, 0)`,
+ } as any;
+
+ const { node, highlight } = this.props;
+
+ const className = classNames('my-selecting', {
+ 'x-shadow': isShadowNode(node),
+ 'x-flow': hasConditionFlow(node) || isConditionFlow(node),
+ highlight,
+ });
+
+ return
;
+ }
+}
+
+@observer
+export class SelectingView extends Component {
+ get selecting(): INode[] {
+ const sel = current.selection;
+ if (!sel) {
+ return [];
+ }
+ if (dragon.dragging) {
+ return sel.getTopNodes();
+ }
+
+ return sel.getNodes();
+ }
+ render() {
+ return this.selecting.map(node => {
+ // select all nodes when doing x-for
+ if (isShadowsContainer(node)) {
+ // FIXME: thinkof nesting for
+ const views = [];
+ for (const shadowNode of (node as any).getShadows()!.values()) {
+ views.push(
);
+ }
+ return
{views};
+ } else if (isShadowNode(node)) {
+ const shadows = node.origin.getShadows()!.values();
+ const views = [];
+ for (const shadowNode of shadows) {
+ views.push(
);
+ }
+ return
{views};
+ }
+ // select the visible node when doing x-if
+ else if (isConditionFlow(node)) {
+ return
;
+ }
+
+ return
;
+ });
+ }
+}
diff --git a/packages/designer/src/builtins/simulator/create-simulator.ts b/packages/designer/src/builtins/simulator/create-simulator.ts
new file mode 100644
index 000000000..9c673e59b
--- /dev/null
+++ b/packages/designer/src/builtins/simulator/create-simulator.ts
@@ -0,0 +1,80 @@
+import { getCurrentAdaptor } from '../globals';
+import Simulator from '../adaptors/simulator';
+import { isCSSUrl } from '../utils/is-css-url';
+
+export interface AssetMap {
+ jsUrl?: string;
+ cssUrl?: string;
+ jsText?: string;
+ cssText?: string;
+}
+export type AssetList = string[];
+export type Assets = AssetMap[] | AssetList;
+
+function isAssetMap(obj: any): obj is AssetMap {
+ return obj && typeof obj === 'object';
+}
+
+export function createSimulator
(iframe: HTMLIFrameElement, vendors: Assets = []): Promise> {
+ const currentAdaptor = getCurrentAdaptor();
+ const win: any = iframe.contentWindow;
+ const doc = iframe.contentDocument!;
+
+ const styles: string[] = [];
+ let scripts: string[] = [];
+ const afterScripts: string[] = [];
+
+ vendors.forEach((asset: any) => {
+ if (!isAssetMap(asset)) {
+ if (isCSSUrl(asset)) {
+ asset = { cssUrl: asset };
+ } else {
+ if (asset.startsWith('!')) {
+ afterScripts.push(``);
+ return;
+ }
+ asset = { jsUrl: asset };
+ }
+ }
+ if (asset.jsText) {
+ scripts.push(``);
+ }
+ if (asset.jsUrl) {
+ scripts.push(``);
+ }
+ if (asset.cssUrl) {
+ styles.push(``);
+ }
+ if (asset.cssText) {
+ styles.push(``);
+ }
+ });
+
+ currentAdaptor.simulatorUrls.forEach(url => {
+ if (isCSSUrl(url)) {
+ styles.push(``);
+ } else {
+ scripts.push(``);
+ }
+ });
+ scripts = scripts.concat(afterScripts);
+
+ doc.open();
+ doc.write(`
+${styles.join('\n')}
+
+${scripts.join('\n')}
+`);
+ doc.close();
+
+ return new Promise(resolve => {
+ if (win.VisionSimulator) {
+ return resolve(win.VisionSimulator);
+ }
+ const loaded = () => {
+ resolve(win.VisionSimulator);
+ win.removeEventListener('load', loaded);
+ };
+ win.addEventListener('load', loaded);
+ });
+}
diff --git a/packages/designer/src/builtins/simulator/index.tsx b/packages/designer/src/builtins/simulator/index.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/designer/src/builtins/simulator/screen b/packages/designer/src/builtins/simulator/screen
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/designer/src/designer/canvas.less b/packages/designer/src/designer/canvas.less
new file mode 100644
index 000000000..cfc57d730
--- /dev/null
+++ b/packages/designer/src/designer/canvas.less
@@ -0,0 +1,47 @@
+.my-canvas {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ margin: 10px;
+ box-shadow: 0 2px 10px 0 rgba(31,56,88,.15);
+}
+
+html.my-show-topbar .my-canvas {
+ top: var(--topbar-height);
+}
+html.my-show-toolbar .my-canvas {
+ top: var(--toolbar-height);
+}
+html.my-show-topbar.my-show-toolbar .my-canvas {
+ top: calc(var(--topbar-height) + var(--topbar-height));
+}
+
+.my-screen {
+ top: 0;
+ bottom: 0;
+ width: 100%;
+ left: 0;
+ position: absolute;
+ overflow: hidden;
+}
+
+.my-doc-shell {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ overflow: hidden;
+ .my-doc-frame {
+ border: none;
+ transform-origin: 0 0;
+ height: 100%;
+ width: 100%;
+ }
+}
+
+.my-drag-pane-mode .my-doc-shell {
+ pointer-events: none;
+}
diff --git a/packages/designer/src/designer/canvas.tsx b/packages/designer/src/designer/canvas.tsx
new file mode 100644
index 000000000..3a6daadf3
--- /dev/null
+++ b/packages/designer/src/designer/canvas.tsx
@@ -0,0 +1,76 @@
+import { Component } from 'react';
+import { observer } from '@ali/recore';
+import { getCurrentDocument, screen, progressing } from '../../globals';
+import { AutoFit } from '../../document/viewport';
+import { AuxiliaryView } from '../auxiliary';
+import { PreLoaderView } from '../widgets/pre-loader';
+import DocumentContext from '../../document/document-context';
+import FocusingArea from '../widgets/focusing-area';
+import './canvas.less';
+
+const Canvas = () => (
+ {
+ const doc = getCurrentDocument();
+ if (doc) {
+ doc.selection.clear();
+ }
+ return false;
+ }}
+ >
+
+
+);
+
+export default Canvas;
+
+@observer
+class Screen extends Component {
+ render() {
+ const doc = getCurrentDocument();
+ // TODO: thinkof multi documents
+ return (
+ screen.mount(elmt)} className="my-screen">
+ {progressing.visible ?
: null}
+
+ {doc ?
: null}
+
+ );
+ }
+}
+
+@observer
+class DocumentView extends Component<{ doc: DocumentContext }> {
+ componentWillUnmount() {
+ this.props.doc.sleep();
+ }
+
+ render() {
+ const { doc } = this.props;
+ const viewport = doc.viewport;
+ let shellStyle = {};
+ let frameStyle = {};
+ if (viewport.width !== AutoFit && viewport.height !== AutoFit) {
+ const shellWidth = viewport.width * viewport.scale;
+ const screenWidth = screen.width;
+ const shellLeft = shellWidth < screenWidth ? `calc((100% - ${shellWidth}px) / 2)` : 0;
+ shellStyle = {
+ width: shellWidth,
+ left: shellLeft,
+ };
+ frameStyle = {
+ transform: `scale(${viewport.scale})`,
+ height: viewport.height,
+ width: viewport.width,
+ };
+ }
+
+ return (
+
+
+ );
+ }
+}
diff --git a/packages/designer/src/designer/designer.ts b/packages/designer/src/designer/designer.ts
new file mode 100644
index 000000000..ba47f5d23
--- /dev/null
+++ b/packages/designer/src/designer/designer.ts
@@ -0,0 +1,18 @@
+class Designer {
+ id: string = guid();
+ hotkey: Hotkey;
+
+ constructor(options: BuilderOptions): Builder;
+
+ getValue(): ProjectSchema;
+ setValue(schema: ProjectSchema): void;
+ project: Project;
+ dragboost(locateEvent: LocateEvent): void;
+ addDropSensor(dropSensor: DropSensor): void;
+
+ // 事件 & 消息
+ onActiveChange(): () => void;
+ onDragstart(): void;
+ onDragend(): void;
+ //....
+}
diff --git a/packages/designer/src/designer/document/document-context.ts b/packages/designer/src/designer/document/document-context.ts
new file mode 100644
index 000000000..52ae1ccc6
--- /dev/null
+++ b/packages/designer/src/designer/document/document-context.ts
@@ -0,0 +1,173 @@
+import Project from '../project';
+import { RootSchema, NodeData, isDOMText, isJSExpression } from '../schema';
+
+export default class DocumentContext {
+ /**
+ * 文档编号
+ */
+ readonly id: string;
+ /**
+ * 选区控制
+ */
+ readonly selection: Selection = new Selection(this);
+ /**
+ * 操作记录控制
+ */
+ readonly history: History = new History(this);
+ /**
+ * 根节点 类型有:Page/Component/Block
+ */
+ readonly root: Root;
+ /**
+ * 模拟器
+ */
+ simulator?: SimulatorInterface;
+
+ private nodesMap = new Map();
+ private nodes = new Set();
+ private seqId = 0;
+
+ constructor(readonly project: Project, schema: RootSchema) {
+ this.id = uniqueId('doc');
+ this.root = new Root(this, viewData);
+ }
+
+ /**
+ * 根据 id 获取节点
+ */
+ getNode(id: string): Node | null {
+ return this.nodesMap.get(id) || null;
+ }
+
+ /**
+ * 根据 schema 创建一个节点
+ */
+ createNode(data: NodeData): Node {
+ let schema: any;
+ if (isDOMText(data)) {
+ schema = {
+ componentName: '#text',
+ children: data,
+ };
+ } else if (isJSExpression(data)) {
+ schema = {
+ componentName: '#expression',
+ 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: Node, thing: Node | Schema, at?: number | null, copy?: boolean): Node;
+ /**
+ * 移除一个节点
+ */
+ 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 || !node.parent) {
+ return;
+ }
+ this.nodesMap.delete(id);
+ this.nodes.delete(node);
+ node.parent.removeChild(node);
+ }
+ /**
+ * 导出 schema 数据
+ */
+ getSchema(): Schema {
+ return this.root.getSchema();
+ }
+ /**
+ * 导出节点 Schema
+ */
+ getNodeSchema(id: string): Schema | null {
+ const node = this.getNode(id);
+ if (node) {
+ return node.getSchema();
+ }
+ return null;
+ }
+ /**
+ * 根据节点取得视图实例,在循环等场景会有多个,依赖 simulator 的接口
+ */
+ getViewInstance(node: Node): ViewInstance[] | null {
+ if (this.simulator) {
+ this.simulator.getViewInstance(node.id);
+ }
+ return null;
+ }
+ /**
+ * 通过 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(viewInstance: ViewInstance): Array | null {
+ if (!this.simulator) {
+ return null;
+ }
+
+ if (isElement(viewInstance)) {
+ return [viewInstance];
+ }
+
+ return this.simulator.findDOMNodes(viewInstance);
+ }
+ /**
+ * 激活当前文档
+ */
+ active(): void {}
+ /**
+ * 不激活
+ */
+ suspense(): void {}
+ /**
+ * 销毁
+ */
+ destroy(): void {}
+
+ /**
+ * 是否已修改
+ */
+ isModified() {
+ return !this.history.isSavePoint();
+ }
+
+ /**
+ * 生成唯一id
+ */
+ nextId() {
+ return (++this.seqId).toString(36).toLocaleLowerCase();
+ }
+
+ getComponent(tagName: string): any {
+ return this.simulator!.getCurrentComponent(tagName);
+ }
+}
diff --git a/packages/designer/src/designer/document/document.tsx b/packages/designer/src/designer/document/document.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/designer/src/designer/document/history.ts b/packages/designer/src/designer/document/history.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/designer/src/designer/document/location.ts b/packages/designer/src/designer/document/location.ts
new file mode 100644
index 000000000..5d309bbe1
--- /dev/null
+++ b/packages/designer/src/designer/document/location.ts
@@ -0,0 +1,123 @@
+import { INode, INodeParent } from './node';
+import DocumentContext from './document-context';
+
+export interface LocationData {
+ target: INodeParent; // shadowNode | ConditionFlow | ElementNode | RootNode
+ detail: LocationDetail;
+}
+
+export enum LocationDetailType {
+ Children = 'Children',
+ Prop = 'Prop',
+}
+
+export interface LocationChildrenDetail {
+ type: LocationDetailType.Children;
+ index: number;
+ near?: {
+ node: INode;
+ 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: INodeParent;
+ readonly detail: LocationDetail;
+
+ constructor(readonly document: DocumentContext, { target, detail }: LocationData) {
+ this.target = target;
+ this.detail = detail;
+ }
+}
diff --git a/packages/designer/src/designer/document/master-board.ts b/packages/designer/src/designer/document/master-board.ts
new file mode 100644
index 000000000..79ecc7702
--- /dev/null
+++ b/packages/designer/src/designer/document/master-board.ts
@@ -0,0 +1,733 @@
+import RootNode from './root-node';
+import { flags, panes } from '../globals';
+import {
+ dragon,
+ ISenseAble,
+ isShaken,
+ LocateEvent,
+ isNodesDragTarget,
+ NodesDragTarget,
+ NodeDatasDragTarget,
+ isNodeDatasDragTarget,
+ DragTargetType,
+ isAnyDragTarget,
+} from '../globals/dragon';
+import cursor from '../utils/cursor';
+import {
+ INode,
+ isElementNode,
+ isNode,
+ INodeParent,
+ insertChildren,
+ hasConditionFlow,
+ contains,
+ isRootNode,
+ isConfettiNode,
+} from './node';
+import {
+ Point,
+ Rect,
+ getRectTarget,
+ isChildInline,
+ isRowContainer,
+ LocationDetailType,
+ LocationChildrenDetail,
+ isLocationChildrenDetail,
+ LocationData,
+ isLocationData,
+} from './location';
+import { isConditionFlow } from './node/condition-flow';
+import { isElementData, NodeData } from './document-data';
+import ElementNode from './node/element-node';
+import { AT_CHILD } from '../prototype/prototype';
+import Scroller from './scroller';
+import { isShadowNode } from './node/shadow-node';
+import { activeTracker } from '../globals/active-tracker';
+import { edging } from '../globals/edging';
+import { setNativeSelection } from '../utils/navtive-selection';
+import DocumentContext from './document-context';
+import Simulator from '../adaptors/simulator';
+import { focusing } from '../globals/focusing';
+import embedEditor from '../globals/embed-editor';
+
+export const MasterBoardID = 'master-board';
+export default class MasterBoard implements ISenseAble {
+ id = MasterBoardID;
+ sensitive = true;
+ readonly contentDocument: Document;
+
+ private simulator: Simulator;
+ private sensing = false;
+ private scroller: Scroller;
+
+ get bounds() {
+ const vw = this.document.viewport;
+ const bounds = vw.bounds;
+ const innerBounds = vw.innerBounds;
+ const doe = this.contentDocument.documentElement;
+ return {
+ top: bounds.top,
+ left: bounds.left,
+ right: bounds.right,
+ bottom: bounds.bottom,
+ width: bounds.width,
+ height: bounds.height,
+ innerBounds,
+ scale: vw.scale,
+ scrollHeight: doe.scrollHeight,
+ scrollWidth: doe.scrollWidth,
+ };
+ }
+
+ constructor(readonly document: DocumentContext, frame: HTMLIFrameElement) {
+ this.simulator = document.simulator!;
+ this.contentDocument = frame.contentDocument!;
+ this.scroller = new Scroller(this, document.viewport.scrollTarget!);
+ const doc = this.contentDocument;
+ const selection = document.selection;
+
+ // TODO: think of lock when edit a node
+ // 事件路由
+ doc.addEventListener('mousedown', (downEvent: MouseEvent) => {
+ /*
+ if (embedEditor.editing) {
+ return;
+ }
+ */
+ const target = document.getNodeFromElement(downEvent.target as Element);
+ panes.dockingStation.visible = false;
+ focusing.focus('canvas');
+ if (!target) {
+ selection.clear();
+ return;
+ }
+
+ const isMulti = downEvent.metaKey || downEvent.ctrlKey;
+ const isLeftButton = downEvent.which === 1 || downEvent.button === 0;
+
+ if (isLeftButton) {
+ let node: INode = target;
+ if (hasConditionFlow(node)) {
+ node = node.conditionFlow;
+ }
+ let nodes: INode[] = [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
+ }
+ dragon.boost(
+ {
+ type: DragTargetType.Nodes,
+ 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;
+ activeTracker.track(node);
+ if (isMulti && selection.has(id)) {
+ selection.del(id);
+ } else {
+ selection.select(id);
+ }
+ }
+ };
+ doc.addEventListener('mouseup', checkSelect, true);
+ });
+
+ dragon.onDragstart(({ dragTarget }) => {
+ if (this.disableEdging) {
+ this.disableEdging();
+ }
+ if (isNodesDragTarget(dragTarget) && dragTarget.nodes.length === 1) {
+ // ensure current selecting
+ selection.select(dragTarget.nodes[0].id);
+ }
+ flags.setDragComponentsMode(true);
+ });
+
+ dragon.onDragend(({ dragTarget, copy }) => {
+ const loc = this.document.dropLocation;
+ flags.setDragComponentsMode(false);
+ if (loc) {
+ if (!isConditionFlow(loc.target)) {
+ if (isLocationChildrenDetail(loc.detail)) {
+ let nodes: INode[] | undefined;
+ if (isNodesDragTarget(dragTarget)) {
+ nodes = insertChildren(loc.target, dragTarget.nodes, loc.detail.index, copy);
+ } else if (isNodeDatasDragTarget(dragTarget)) {
+ // process nodeData
+ const nodesData = this.document.processDocumentData(dragTarget.data, dragTarget.maps);
+ nodes = insertChildren(loc.target, nodesData, loc.detail.index);
+ }
+ if (nodes) {
+ this.document.selection.selectAll(nodes.map(o => o.id));
+ setTimeout(() => activeTracker.track(nodes![0]), 10);
+ }
+ }
+ // TODO: others...
+ }
+ }
+ this.document.clearLocation();
+ this.enableEdging();
+ });
+
+ // cause edit
+ doc.addEventListener('dblclick', (e: MouseEvent) => {
+ // TODO: refactor
+ let target = document.getNodeFromElement(e.target as Element)!;
+ if (target && isElementNode(target)) {
+ if (isShadowNode(target)) {
+ target = target.origin;
+ }
+ if (target.children.length === 1 && isConfettiNode(target.children[0])) {
+ // test
+ // embedEditor.edit(target as any, 'children', document.getDOMNodes(target) as any);
+
+ activeTracker.track(target.children[0]);
+ selection.select(target.children[0].id);
+ }
+ }
+ });
+
+ activeTracker.onChange(({ node, detail }) => {
+ this.scrollToNode(node, detail);
+ });
+
+ this.enableEdging();
+ }
+
+ private disableEdging: (() => void) | undefined;
+
+ enableEdging() {
+ const edgingWatch = (e: Event) => {
+ const node = this.document.getNodeFromElement(e.target as Element);
+ edging.watch(node);
+ e.stopPropagation();
+ };
+ const leave = () => edging.watch(null);
+
+ this.contentDocument.addEventListener('mouseover', edgingWatch, true);
+ this.contentDocument.addEventListener('mouseleave', leave, false);
+
+ // TODO: refactor this line, contains click, mousedown, mousemove
+ this.contentDocument.addEventListener(
+ 'mousemove',
+ (e: Event) => {
+ e.stopPropagation();
+ },
+ true,
+ );
+
+ this.disableEdging = () => {
+ edging.watch(null);
+ this.contentDocument.removeEventListener('mouseover', edgingWatch, true);
+ this.contentDocument.removeEventListener('mouseleave', leave, false);
+ };
+ }
+
+ setNativeSelection(enableFlag: boolean) {
+ setNativeSelection(enableFlag);
+ this.simulator.utils.setNativeSelection(enableFlag);
+ }
+
+ setDragging(flag: boolean) {
+ cursor.setDragging(flag);
+ this.simulator.utils.cursor.setDragging(flag);
+ }
+
+ setCopy(flag: boolean) {
+ cursor.setCopy(flag);
+ this.simulator.utils.cursor.setCopy(flag);
+ }
+
+ isCopy(): boolean {
+ return this.simulator.utils.cursor.isCopy();
+ }
+
+ releaseCursor() {
+ cursor.release();
+ this.simulator.utils.cursor.release();
+ }
+
+ getDropTarget(e: LocateEvent): INodeParent | 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: RootNode | ElementNode, 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: INodeParent, e: LocateEvent): INodeParent | null {
+ 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);
+ }
+
+ private tryScrollAgain: number | null = null;
+ scrollToNode(node: INode, 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.document.viewport.scrollTarget!;
+ const st = scrollTarget.top;
+ const sl = scrollTarget.left;
+ const { innerBounds, scrollHeight, scrollWidth } = this.bounds;
+ const { height, width, top, bottom, left, right } = innerBounds;
+
+ 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.clientX, e.clientY);
+ }
+ return e;
+ }
+
+ isEnter(e: LocateEvent): boolean {
+ const rect = this.bounds;
+ return e.globalY >= rect.top && e.globalY <= rect.bottom && e.globalX >= rect.left && e.globalX <= rect.right;
+ }
+
+ inRange(e: LocateEvent): boolean {
+ return e.globalX <= this.bounds.right;
+ }
+
+ deactive() {
+ this.sensing = false;
+ this.scroller.cancel();
+ }
+
+ isAcceptable(container: ElementNode): boolean {
+ const proto = container.prototype;
+ const view: any = this.document.getView(container);
+ if (view && '$accept' in view) {
+ return true;
+ }
+ return proto.acceptable;
+ }
+
+ acceptAnyData(container: ElementNode, 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: ElementNode, dragTarget: NodesDragTarget | NodeDatasDragTarget): boolean {
+ const items: Array = dragTarget.nodes || (dragTarget as NodeDatasDragTarget).data;
+ return items.every(item => this.checkNestingDown(dropTarget, item));
+ }
+
+ checkDropTarget(dropTarget: RootNode | ElementNode, dragTarget: NodesDragTarget | NodeDatasDragTarget): boolean {
+ const items: Array = dragTarget.nodes || (dragTarget as NodeDatasDragTarget).data;
+ return items.every(item => this.checkNestingUp(dropTarget, item));
+ }
+
+ checkNestingUp(parent: RootNode | ElementNode, target: NodeData | INode): 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: ElementNode, target: NodeData | INode): 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);
+ }
+ }
+}
+
+function isPointInRect(point: Point, rect: Rect) {
+ return (
+ point.clientY >= rect.top &&
+ point.clientY <= rect.bottom &&
+ point.clientX >= rect.left &&
+ point.clientX <= rect.right
+ );
+}
+
+function distanceToRect(point: Point, rect: Rect) {
+ let minX = Math.min(Math.abs(point.clientX - rect.left), Math.abs(point.clientX - rect.right));
+ let minY = Math.min(Math.abs(point.clientY - rect.top), Math.abs(point.clientY - rect.bottom));
+ if (point.clientX >= rect.left && point.clientX <= rect.right) {
+ minX = 0;
+ }
+ if (point.clientY >= rect.top && point.clientY <= rect.bottom) {
+ minY = 0;
+ }
+
+ return Math.sqrt(minX ** 2 + minY ** 2);
+}
+
+function distanceToEdge(point: Point, rect: Rect) {
+ const distanceTop = Math.abs(point.clientY - rect.top);
+ const distanceBottom = Math.abs(point.clientY - rect.bottom);
+
+ return {
+ distance: Math.min(distanceTop, distanceBottom),
+ nearAfter: distanceBottom < distanceTop,
+ };
+}
+
+function isNearAfter(point: Point, rect: Rect, inline: boolean) {
+ if (inline) {
+ return (
+ Math.abs(point.clientX - rect.left) + Math.abs(point.clientY - rect.top) >
+ Math.abs(point.clientX - rect.right) + Math.abs(point.clientY - rect.bottom)
+ );
+ }
+ return Math.abs(point.clientY - rect.top) > Math.abs(point.clientY - rect.bottom);
+}
diff --git a/packages/designer/src/designer/document/node.ts b/packages/designer/src/designer/document/node.ts
new file mode 100644
index 000000000..ab6cf0f2d
--- /dev/null
+++ b/packages/designer/src/designer/document/node.ts
@@ -0,0 +1,478 @@
+import { NodeSchema, isNodeSchema, NodeData, DOMText, JSExpression, PropsMap } from '../schema';
+import Props, { Prop } from './props';
+
+/**
+ * nodeSchema are:
+ * [basic]
+ * .componentName
+ * .props
+ * .children
+ * [directive]
+ * .condition
+ * .loop
+ * .loopArgs
+ * [addon]
+ * .conditionGroup = 'abc' // 当移动时值会改
+ * .title
+ * .ignore
+ * .hidden
+ * .locked
+ */
+export default class Node {
+ /**
+ * 是节点实例
+ */
+ readonly isNode = true;
+
+ /**
+ * 节点 id
+ */
+ readonly id: string;
+
+ /**
+ * 节点组件类型
+ * 特殊节点:
+ * * #text 文字节点
+ * * #expression 表达式节点
+ * * Page 页面
+ * * Block/Fragment 区块
+ * * Component 组件/元件
+ */
+ readonly componentName: string;
+
+ constructor(readonly document: DocumentContext, private nodeSchema: NodeSchema) {
+ const { componentName, id, children, props, leadingComponents, ...directives } = nodeSchema;
+ // clone
+ this.id = id || `node$${document.nextId()}`;
+ this.componentName = componentName;
+ if (this.isNodeParent()) {
+ this._props = new Props(this, props);
+ this._directives = new Props(this, directives as PropsMap);
+ if (children) {
+ this._children = (Array.isArray(children) ? children : [children]).map(child => {
+ const node = this.document.createNode(child);
+ node.internalSetParent(this);
+ return node;
+ });
+ }
+ }
+ }
+
+ /**
+ * 是否一个父亲类节点
+ */
+ isNodeParent(): boolean {
+ return this.componentName.charAt(0) !== '#';
+ }
+
+ private _parent: Node | null = null;
+
+ /**
+ * 父级节点
+ */
+ get parent(): Node | null {
+ return this._parent;
+ }
+ /**
+ * 内部方法
+ *
+ * @ignore
+ */
+ internalSetParent(parent: Node | null) {
+ if (this._parent === parent) {
+ return;
+ }
+ if (this._parent) {
+ removeChild(this._parent, this);
+ }
+
+ this._parent = parent;
+ if (parent) {
+ this._zLevel = parent.zLevel + 1;
+ } else {
+ this._zLevel = -1;
+ }
+ }
+
+ private _zLevel = 0;
+ /**
+ * 当前节点深度
+ */
+ get zLevel(): number {
+ return this._zLevel;
+ }
+
+ private _children: Node[] | null = null;
+ /**
+ * 当前节点子集
+ */
+ get children(): Node[] | null {
+ if (this.purged) {
+ return [];
+ }
+ if (this._children) {
+ return this._children;
+ }
+ }
+
+ @obx.ref get component(): ReactType {
+ return this.document.getComponent(this.tagName);
+ }
+ @obx.ref get prototype(): Prototype {
+ return this.document.getPrototype(this.component, this.tagName);
+ }
+
+ @obx.ref get props(): object {
+ if (!this.isNodeParent() || this.componentName === 'Fragment') {
+ return {};
+ }
+ // ...
+ }
+
+ private _directives: any = {};
+ get directives() {
+ return {
+ condition: this.condition,
+ conditionGroup: this.conditionGroup,
+ loop: '',
+ };
+ }
+
+ 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;
+ }
+
+ // 外部修改,merge 进来,产生一次可恢复的历史数据
+ merge(data: ElementData) {
+ this.elementData = data;
+ const { leadingComments } = data;
+ this.leadingComments = leadingComments ? leadingComments.slice() : [];
+ this.parse();
+ this.mergeChildren(data.children || []);
+ }
+
+ private mergeChildren(data: NodeData[]) {
+ 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());
+ }
+ }
+
+ getProp(path: string): Prop;
+ getProp(path: string, useStash: true): Prop;
+ getProp(path: string, useStash = true): Prop | null {
+ return this._props!.query(path, useStash)!;
+ }
+
+ getProps(): Props {
+ return this._props;
+ }
+
+ /**
+ * 获取节点在父容器中的索引
+ */
+ get index(): number {
+ if (!this.parent) {
+ return -1;
+ }
+ return indexOf(this.parent, this);
+ }
+
+ /**
+ * 获取下一个兄弟节点
+ */
+ get nextSibling(): Node | null {
+ if (!this.parent) {
+ return null;
+ }
+ const index = this.index;
+ if (index < 0) {
+ return null;
+ }
+ return getChildAt(this.parent, index + 1);
+ }
+
+ /**
+ * 获取上一个兄弟节点
+ */
+ get prevSibling(): Node | null {
+ if (!this.parent) {
+ return null;
+ }
+ const index = this.index;
+ if (index < 1) {
+ return null;
+ }
+ return getChildAt(this.parent, index - 1);
+ }
+
+ /**
+ * 获取符合搭建协议-节点 schema 结构
+ */
+ get schema(): NodeSchema {
+ return this.exportSchema();
+ }
+
+ /**
+ * 导出 schema
+ * @param serialize 序列化,加 id 标识符,用于储存为操作记录
+ */
+ exportSchema(serialize = false): NodeSchema {
+ const schema: any = {
+ componentName: this.componentName,
+ props: this.props,
+ condition: this.condition,
+ conditionGroup: this.conditionGroup,
+ ...this.directives,
+ };
+ if (serialize) {
+ schema.id = this.id;
+ }
+ const children = this.children;
+ if (children && children.length > 0) {
+ schema.children = children.map(node => node.exportSchema(serialize));
+ }
+ return schema;
+ }
+
+ // TODO: 再利用历史数据,不产生历史数据
+ reuse(timelineData: NodeSchema) {}
+
+ /**
+ * 判断是否包含特定节点
+ */
+ 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;
+ /**
+ * 销毁
+ */
+ purge() {
+ if (this.purged) {
+ return;
+ }
+ this.purged = true;
+ this.children.forEach(child => child.purge());
+ // TODO: others dispose...
+ }
+}
+
+export interface INodeParent extends Node {
+ readonly children: Node[];
+}
+
+export function isNode(node: any): node is Node {
+ return node && node.isNode;
+}
+
+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() || !node1.children || !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: INodeParent, 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);
+ }
+ const children = container.children;
+ let index = at == null ? 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(container);
+ } else {
+ if (index > i) {
+ index -= 1;
+ }
+
+ if (index === i) {
+ return node;
+ }
+
+ 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;
+ }
+ }
+
+ return node;
+}
+
+export function insertChildren(
+ container: INodeParent,
+ nodes: Node[] | NodeSchema[],
+ 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;
+}
+
+export function getChildAt(parent: INodeParent, index: number): Node | null {
+ if (!parent.children) {
+ return null;
+ }
+ return parent.children[index];
+}
+
+export function indexOf(parent: INodeParent, child: Node): number {
+ if (!parent.children) {
+ return -1;
+ }
+ return parent.children.indexOf(child);
+}
+
+export function removeChild(parent: INodeParent, child: Node) {
+ if (!parent.children) {
+ return;
+ }
+ const i = parent.children.indexOf(child);
+ if (i > -1) {
+ parent.children.splice(i, 1);
+ }
+}
diff --git a/packages/designer/src/designer/document/props.ts b/packages/designer/src/designer/document/props.ts
new file mode 100644
index 000000000..d466656e0
--- /dev/null
+++ b/packages/designer/src/designer/document/props.ts
@@ -0,0 +1,558 @@
+import { untracked, computed, obx } from '@recore/obx';
+import { uniqueId, isPlainObject, hasOwnProperty } from '../../utils';
+import { valueToSource } from '../../utils/value-to-source';
+import { CompositeValue, isJSExpression, PropsList, PropsMap } from '../schema';
+import StashSpace from './stash-space';
+
+export const UNSET = Symbol.for('unset');
+export type UNSET = typeof UNSET;
+
+export interface IPropParent {
+ delete(prop: Prop): void;
+}
+
+export 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;
+}
+
+export default class Props implements IPropParent {
+ @obx.val private readonly 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;
+ }
+
+ private _type: 'map' | 'list' | 'unset' = 'unset';
+
+ constructor(owner: T, value?: PropsMap | PropsList | null) {
+ if (value == null) {
+ this._type = 'unset';
+ } else if (Array.isArray(value)) {
+ this._type = 'list';
+ value.forEach(item => {});
+ } else {
+ this._type = 'map';
+ }
+ }
+
+ delete(prop: Prop) {
+ const i = this.items.indexOf(prop);
+ if (i > -1) {
+ this.items.splice(i, 1);
+ prop.purge();
+ }
+ }
+
+ query(path: string, useStash = 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;
+ }
+
+ get(name: string, useStash = false) {
+ return this.maps.get(name) || (useStash && this.stash.get(name)) || null;
+ }
+
+ add(value: CompositeValue | null, key?: string | number, spread = false) {
+ const prop = new Prop(this, value, key, spread);
+ this.items.push(prop);
+ }
+
+ 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/root-node.ts b/packages/designer/src/designer/document/root-node.ts
new file mode 100644
index 000000000..23b2c4e4e
--- /dev/null
+++ b/packages/designer/src/designer/document/root-node.ts
@@ -0,0 +1,133 @@
+import Node from './node';
+
+/**
+ * state
+ * lifeCycles
+ * fileName
+ * meta
+ * methods
+ * dataSource
+ * css
+ * defaultProps
+ */
+export default class RootNode extends Node {
+ readonly isRootNode = true;
+ readonly index = 0;
+ readonly props: object = {};
+ readonly nextSibling = null;
+ readonly prevSibling = null;
+ readonly zLevel = 0;
+ readonly parent = null;
+ internalSetParent(parent: null) {}
+
+ get viewData(): ViewData {
+ return {
+ file: this.file,
+ children: this.nodeData,
+ };
+ }
+
+ readonly fileName: string;
+ readonly viewType: string;
+ readonly viewVersion: string;
+
+ get ready() {
+ return this.document.ready;
+ }
+
+ get nodeData(): NodeData[] {
+ if (!this.ready) {
+ // TODO: add mocks data
+ return this.childrenData;
+ }
+ const children = this.children;
+ if (!children || children.length < 1) {
+ return [];
+ }
+ return children.map(node => node.nodeData as NodeData);
+ }
+
+ private childrenData: NodeData[];
+ private _children: INode[] | null = null;
+ @obx.val get children(): INode[] {
+ if (this._children) {
+ return this._children;
+ }
+ if (!this.ready || this.purged) {
+ return [];
+ }
+ const children = this.childrenData;
+ /* eslint-disable */
+ this._children = children
+ ? untracked(() =>
+ children.map(child => {
+ const node = this.document.createNode(child);
+ node.internalSetParent(this);
+ return node;
+ }),
+ )
+ : [];
+ /* eslint-enable */
+ return this._children;
+ }
+
+ get scope() {
+ return this.mocks.scope;
+ }
+
+ constructor(readonly document: DocumentContext, { children, file, viewType, viewVersion }: ViewData) {
+ this.file = file;
+ this.viewType = viewType || '';
+ this.viewVersion = viewVersion || '';
+
+ const expr = getMockExpr(children);
+ if (expr) {
+ this.childrenData = children.slice(0, -1);
+ this.mocksExpr = expr;
+ } else {
+ this.childrenData = children.slice();
+ }
+ }
+
+ merge(schema: DocumentSchema) {
+ 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());
+ }
+ }
+
+ // todo:
+ reuse() {}
+
+ private purged = false;
+
+ purge() {
+ if (this.purged) {
+ return;
+ }
+ this.purged = true;
+ if (this._children) {
+ this._children.forEach(child => child.purge());
+ }
+ }
+
+ receiveViewData({ children }: ViewData) {
+ this.merge(children);
+ // this.selection.dispose();
+ }
+}
+
+export function isRootNode(node: any): node is RootNode {
+ return node && node.isRootNode;
+}
diff --git a/packages/designer/src/designer/document/scroller.ts b/packages/designer/src/designer/document/scroller.ts
new file mode 100644
index 000000000..f7409511c
--- /dev/null
+++ b/packages/designer/src/designer/document/scroller.ts
@@ -0,0 +1,134 @@
+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);
+ }
+
+ constructor(private target: Window | Element) {}
+}
+
+function easing(n: number) {
+ return Math.sin((n * Math.PI) / 2);
+}
+
+const SCROLL_ACCURCY = 30;
+
+export default class Scroller {
+ private pid: number | undefined;
+
+ constructor(private board: { bounds: any }, private scrollTarget: ScrollTarget) {}
+
+ scrollTo(options: { left?: number; top?: number }) {
+ this.cancel();
+
+ let pid: number;
+ const left = this.scrollTarget.left;
+ const top = this.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;
+ }
+
+ this.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 x = point.globalX;
+ const y = point.globalY;
+ const bounds = this.board.bounds;
+ const scale = bounds.scale;
+
+ const maxScrollHeight = bounds.scrollHeight - bounds.height / scale;
+ const maxScrollWidth = bounds.scrollWidth - bounds.width / scale;
+ let sx = this.scrollTarget.left;
+ let sy = this.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;
+ }
+
+ this.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/document/selection.ts b/packages/designer/src/designer/document/selection.ts
new file mode 100644
index 000000000..8e962c05d
--- /dev/null
+++ b/packages/designer/src/designer/document/selection.ts
@@ -0,0 +1,151 @@
+import { INode, contains, isNode, comparePosition } from './node';
+import { obx } from '@ali/recore';
+import DocumentContext from './document-context';
+
+export class Selection {
+ @obx.val private selected: string[] = [];
+
+ constructor(private doc: DocumentContext) {}
+
+ select(id: string) {
+ if (this.selected.length === 1 && this.selected.indexOf(id) > -1) {
+ // avoid cause reaction
+ return;
+ }
+
+ this.selected = [id];
+ }
+
+ selectAll(ids: string[]) {
+ this.selected = ids;
+ }
+
+ clear() {
+ this.selected = [];
+ }
+
+ dispose() {
+ let i = this.selected.length;
+ while (i-- > 0) {
+ const id = this.selected[i];
+ const node = this.doc.getNode(id, true);
+ if (!node) {
+ this.selected.splice(i, 1);
+ } else if (node.id !== id) {
+ this.selected[i] = id;
+ }
+ }
+ }
+
+ add(id: string) {
+ if (this.selected.indexOf(id) > -1) {
+ return;
+ }
+
+ const i = this.findIndex(id);
+ if (i > -1) {
+ this.selected.splice(i, 1);
+ }
+
+ this.selected.push(id);
+ }
+
+ private findIndex(id: string): number {
+ const ns = getNamespace(id);
+ const nsx = `${ns}:`;
+ return this.selected.findIndex(idx => {
+ return idx === ns || idx.startsWith(nsx);
+ });
+ }
+
+ has(id: string, variant = false) {
+ return this.selected.indexOf(id) > -1 || (variant && this.findIndex(id) > -1);
+ }
+
+ del(id: string, variant = false) {
+ let i = this.selected.indexOf(id);
+ if (i > -1) {
+ this.selected.splice(i, 1);
+ } else if (variant) {
+ i = this.findIndex(id);
+ this.selected.splice(i, 1);
+ }
+ }
+
+ containsNode(node: INode) {
+ for (const id of this.selected) {
+ const parent = this.doc.getNode(id);
+ if (parent && contains(parent, node)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ getNodes() {
+ const nodes = [];
+ for (const id of this.selected) {
+ const node = this.doc.getNode(id, true);
+ if (node) {
+ nodes.push(node);
+ }
+ }
+ return nodes;
+ }
+
+ getOriginNodes(): INode[] {
+ const nodes: any[] = [];
+ for (const id of this.selected) {
+ const node = this.doc.getOriginNode(id);
+ if (node && !nodes.includes(node)) {
+ nodes.push(node);
+ }
+ }
+ return nodes;
+ }
+
+ /**
+ * get union items that at top level
+ */
+ getTopNodes(origin?: boolean) {
+ const nodes = [];
+ for (const id of this.selected) {
+ const node = origin ? this.doc.getOriginNode(id) : this.doc.getNode(id);
+ if (!node) {
+ continue;
+ }
+ let i = nodes.length;
+ let isTop = true;
+ while (i-- > 0) {
+ const n = comparePosition(nodes[i], node);
+ // nodes[i] contains node
+ if (n === 16 || n === 0) {
+ isTop = false;
+ break;
+ }
+ // node contains nodes[i], delete nodes[i]
+ if (n === 8) {
+ nodes.splice(i, 1);
+ }
+ }
+ // node is top item, push to nodes
+ if (isTop) {
+ nodes.push(node);
+ }
+ }
+ return nodes;
+ }
+}
+
+function getNamespace(id: string) {
+ const i = id.indexOf(':');
+ if (i < 0) {
+ return id;
+ }
+
+ return id.substring(0, i);
+}
+
+export function isSelectable(obj: any): obj is INode {
+ return isNode(obj);
+}
diff --git a/packages/designer/src/designer/document/stash-space.ts b/packages/designer/src/designer/document/stash-space.ts
new file mode 100644
index 000000000..2bd1361e6
--- /dev/null
+++ b/packages/designer/src/designer/document/stash-space.ts
@@ -0,0 +1,65 @@
+import { obx, autorun, untracked } from '@recore/obx';
+import { Prop, IPropParent } from './props';
+
+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/viewport.ts b/packages/designer/src/designer/document/viewport.ts
new file mode 100644
index 000000000..f3c510ad2
--- /dev/null
+++ b/packages/designer/src/designer/document/viewport.ts
@@ -0,0 +1,96 @@
+import { obx } from '@ali/recore';
+import { screen } from '../globals/screen';
+import { Point } from './location';
+import { ScrollTarget } from './scroller';
+
+export type AutoFit = '100%';
+export const AutoFit = '100%';
+
+export default class Viewport {
+ private shell: HTMLDivElement | undefined;
+ scrollTarget: ScrollTarget | undefined;
+
+ get scale(): number {
+ if (this.width === AutoFit) {
+ return 1;
+ }
+ return screen.width / this.width;
+ }
+
+ get height(): number | AutoFit {
+ if (this.scale === 1) {
+ return AutoFit;
+ }
+ return screen.height / this.scale;
+ }
+
+ private _bounds: ClientRect | DOMRect | null = null;
+ get bounds(): ClientRect | DOMRect {
+ if (this._bounds) {
+ return this._bounds;
+ }
+ this._bounds = this.shell!.getBoundingClientRect();
+ requestAnimationFrame(() => {
+ this._bounds = null;
+ });
+ return this._bounds;
+ }
+
+ get innerBounds(): ClientRect | DOMRect {
+ const bounds = this.bounds;
+ const scale = this.scale;
+ const ret: any = {
+ top: 0,
+ left: 0,
+ x: 0,
+ y: 0,
+ width: bounds.width / scale,
+ height: bounds.height / scale,
+ };
+ ret.right = ret.width;
+ ret.bottom = ret.height;
+ return ret;
+ }
+
+ @obx.ref width: number | AutoFit = AutoFit;
+ @obx.ref scrollX = 0;
+ @obx.ref scrollY = 0;
+
+ setShell(shell: HTMLDivElement) {
+ this.shell = shell;
+ }
+
+ setScrollTarget(target: Window) {
+ this.scrollTarget = new ScrollTarget(target);
+ this.scrollX = this.scrollTarget.left;
+ this.scrollY = this.scrollTarget.top;
+ target.onscroll = () => {
+ this.scrollX = this.scrollTarget!.left;
+ this.scrollY = this.scrollTarget!.top;
+ };
+ }
+
+ toGlobalPoint(point: Point): Point {
+ if (!this.shell) {
+ return point;
+ }
+
+ const rect = this.shell.getBoundingClientRect();
+ return {
+ clientX: point.clientX * this.scale + rect.left,
+ clientY: point.clientY * this.scale + rect.top,
+ };
+ }
+
+ toLocalPoint(point: Point): Point {
+ if (!this.shell) {
+ return point;
+ }
+
+ const rect = this.shell.getBoundingClientRect();
+ return {
+ clientX: (point.clientX - rect.left) / this.scale,
+ clientY: (point.clientY - rect.top) / this.scale,
+ };
+ }
+}
diff --git a/packages/designer/src/designer/dragon.ts b/packages/designer/src/designer/dragon.ts
new file mode 100644
index 000000000..311f06a2f
--- /dev/null
+++ b/packages/designer/src/designer/dragon.ts
@@ -0,0 +1,349 @@
+import { EventEmitter } from 'events';
+import MasterBoard from '../document/master-board';
+import Location from '../document/location';
+import { INode } from '../document/node';
+import { NodeData } from '../document/document-data';
+import { getCurrentDocument } from './current';
+import { obx } from '@ali/recore';
+
+export interface LocateEvent {
+ readonly type: 'LocateEvent';
+ readonly clientX: number;
+ readonly clientY: number;
+ readonly globalX: number;
+ readonly globalY: number;
+ readonly originalEvent: MouseEvent;
+ readonly dragTarget: DragTarget;
+ target: Element | null;
+ fixed?: true;
+}
+
+export type DragTarget = NodesDragTarget | NodeDatasDragTarget | AnyDragTarget;
+
+export enum DragTargetType {
+ Nodes = 'nodes',
+ NodeDatas = 'nodedatas',
+}
+
+export interface NodesDragTarget {
+ type: DragTargetType.Nodes;
+ nodes: INode[];
+}
+
+export function isNodesDragTarget(obj: any): obj is NodesDragTarget {
+ return obj && obj.type === DragTargetType.Nodes;
+}
+
+export interface NodeDatasDragTarget {
+ type: DragTargetType.NodeDatas;
+ data: NodeData[];
+ maps?: { [tagName: string]: string };
+ thumbnail?: string;
+ description?: string;
+ [extra: string]: any;
+}
+
+export function isNodeDatasDragTarget(obj: any): obj is NodeDatasDragTarget {
+ return obj && obj.type === DragTargetType.NodeDatas;
+}
+
+export interface AnyDragTarget {
+ type: string;
+ [key: string]: any;
+}
+
+export function isAnyDragTarget(obj: any): obj is AnyDragTarget {
+ return obj && obj.type !== DragTargetType.NodeDatas && obj.type !== DragTargetType.Nodes;
+}
+
+export interface ISenseAble {
+ id: string;
+ sensitive: boolean;
+ fixEvent(e: LocateEvent): LocateEvent;
+ locate(e: LocateEvent): Location | undefined;
+ isEnter(e: LocateEvent): boolean;
+ inRange(e: LocateEvent): boolean;
+ deactive(): void;
+}
+
+export function isLocateEvent(e: any): e is LocateEvent {
+ return e && e.type === 'LocateEvent';
+}
+
+const SHAKE_DISTANCE = 4;
+/**
+ * mouse shake check
+ */
+export function isShaken(e1: MouseEvent, e2: MouseEvent): boolean {
+ if ((e1 as any).shaken) {
+ return true;
+ }
+ if (e1.target !== e2.target) {
+ return true;
+ }
+ return Math.pow(e1.clientY - e2.clientY, 2) + Math.pow(e1.clientX - e2.clientX, 2) > SHAKE_DISTANCE;
+}
+
+export function setShaken(e: any) {
+ e.shaken = true;
+}
+
+function getTopDocument(e: MouseEvent, local: Document) {
+ return e.view!.document === local ? null : document;
+}
+
+class Dragon {
+ private sensors: ISenseAble[] = [];
+
+ /**
+ * current actived sensor
+ */
+ private _activeSensor: ISenseAble | undefined;
+ get activeSensor(): ISenseAble | undefined {
+ return this._activeSensor;
+ }
+
+ @obx.ref dragging = false;
+ private emitter = new EventEmitter();
+ private get master(): MasterBoard | undefined {
+ const doc = getCurrentDocument();
+ if (!doc) {
+ return undefined;
+ }
+ return doc.masterBoard;
+ }
+
+ from(shell: Element, boost: (e: MouseEvent) => DragTarget | null) {
+ const mousedown = (e: MouseEvent) => {
+ // ESC or RightClick
+ if (e.which === 3 || e.button === 2) {
+ return;
+ }
+
+ // Get a new node to be dragged
+ const dragTarget = boost(e);
+ if (!dragTarget) {
+ return;
+ }
+
+ this.boost(dragTarget, e);
+ };
+ shell.addEventListener('mousedown', mousedown as any);
+ return () => {
+ shell.removeEventListener('mousedown', mousedown as any);
+ };
+ }
+
+ /**
+ * dragTarget should be a INode | INode[] | NodeData | NodeData[]
+ */
+ boost(dragTarget: DragTarget, boostEvent: MouseEvent) {
+ if (!this.master) {
+ return;
+ }
+ const master = this.master;
+ const doc = master.contentDocument;
+ const viewport = master.document.viewport;
+ const topDoc = getTopDocument(boostEvent, doc);
+ const newBie = dragTarget.type !== DragTargetType.Nodes;
+ let lastLocation: any = null;
+ let lastSensor: ISenseAble | undefined;
+ this.dragging = false;
+ master.setNativeSelection(false);
+
+ const checkesc = (e: KeyboardEvent) => {
+ if (e.keyCode === 27) {
+ lastLocation = null;
+ master.document.clearLocation();
+ over();
+ }
+ };
+
+ const checkcopy = (e: MouseEvent) => {
+ if (newBie || e.altKey || e.ctrlKey) {
+ master.setCopy(true);
+ } else {
+ master.setCopy(false);
+ }
+ };
+
+ const drag = (e: MouseEvent) => {
+ checkcopy(e);
+
+ const locateEvent = fixEvent(e);
+ const sensor = chooseSensor(locateEvent);
+ if (sensor) {
+ sensor.fixEvent(locateEvent);
+ lastLocation = sensor.locate(locateEvent);
+ } else {
+ master.document.clearLocation();
+ lastLocation = null;
+ }
+ this.emitter.emit('drag', locateEvent, lastLocation);
+ };
+
+ const dragstart = () => {
+ const locateEvent = fixEvent(boostEvent);
+ if (!newBie) {
+ chooseSensor(locateEvent);
+ }
+ master.setDragging(true);
+ // ESC cancel drag
+ doc.addEventListener('keydown', checkesc, false);
+ if (topDoc) {
+ topDoc.addEventListener('keydown', checkesc, false);
+ }
+ this.emitter.emit('dragstart', locateEvent);
+ };
+
+ const move = (e: MouseEvent) => {
+ if (this.dragging) {
+ drag(e);
+ return;
+ }
+
+ if (isShaken(boostEvent, e)) {
+ this.dragging = true;
+
+ setShaken(boostEvent);
+ dragstart();
+ drag(e);
+ }
+ };
+
+ const over = (e?: any) => {
+ if (lastSensor) {
+ lastSensor.deactive();
+ }
+ master.setNativeSelection(true);
+
+ let exception;
+ if (this.dragging) {
+ this.dragging = false;
+ try {
+ this.emitter.emit('dragend', { dragTarget, copy: master.isCopy() }, lastLocation);
+ } catch (ex) {
+ exception = ex;
+ }
+ }
+
+ master.releaseCursor();
+
+ doc.removeEventListener('mousemove', move, true);
+ doc.removeEventListener('mouseup', over, true);
+ doc.removeEventListener('mousedown', over, true);
+ doc.removeEventListener('keydown', checkesc, false);
+ doc.removeEventListener('keydown', checkcopy as any, false);
+ doc.removeEventListener('keyup', checkcopy as any, false);
+ if (topDoc) {
+ topDoc.removeEventListener('mousemove', move, true);
+ topDoc.removeEventListener('mouseup', over, true);
+ topDoc.removeEventListener('mousedown', over, true);
+ topDoc.removeEventListener('keydown', checkesc, false);
+ topDoc.removeEventListener('keydown', checkcopy as any, false);
+ topDoc.removeEventListener('keyup', checkcopy as any, false);
+ }
+ if (exception) {
+ throw exception;
+ }
+ };
+
+ const fixEvent = (e: MouseEvent): LocateEvent => {
+ if (isLocateEvent(e)) {
+ return e;
+ }
+ const evt: any = {
+ type: 'LocateEvent',
+ target: e.target,
+ dragTarget,
+ originalEvent: e,
+ };
+ if (e.view!.document === document) {
+ const l = viewport.toLocalPoint(e);
+ evt.clientX = l.clientX;
+ evt.clientY = l.clientY;
+ evt.globalX = e.clientX;
+ evt.globalY = e.clientY;
+ } else {
+ const g = viewport.toGlobalPoint(e);
+ evt.clientX = e.clientX;
+ evt.clientY = e.clientY;
+ evt.globalX = g.clientX;
+ evt.globalY = g.clientY;
+ }
+ return evt;
+ };
+
+ const sensors: ISenseAble[] = ([master] as any).concat(this.sensors);
+ const chooseSensor = (e: LocateEvent) => {
+ let sensor;
+ if (newBie && !lastLocation) {
+ sensor = sensors.find(s => s.sensitive && s.isEnter(e));
+ } else {
+ sensor = sensors.find(s => s.sensitive && s.inRange(e)) || lastSensor;
+ }
+ if (sensor !== lastSensor) {
+ if (lastSensor) {
+ lastSensor.deactive();
+ }
+ lastSensor = sensor;
+ }
+ if (sensor) {
+ sensor.fixEvent(e);
+ }
+ this._activeSensor = sensor;
+ return sensor;
+ };
+
+ doc.addEventListener('mousemove', move, true);
+ doc.addEventListener('mouseup', over, true);
+ doc.addEventListener('mousedown', over, true);
+ if (topDoc) {
+ topDoc.addEventListener('mousemove', move, true);
+ topDoc.addEventListener('mouseup', over, true);
+ topDoc.addEventListener('mousedown', over, true);
+ }
+ if (!newBie) {
+ doc.addEventListener('keydown', checkcopy as any, false);
+ doc.addEventListener('keyup', checkcopy as any, false);
+ if (topDoc) {
+ topDoc.addEventListener('keydown', checkcopy as any, false);
+ topDoc.addEventListener('keyup', checkcopy as any, false);
+ }
+ }
+ }
+
+ addSensor(sensor: any) {
+ this.sensors.push(sensor);
+ }
+
+ removeSensor(sensor: any) {
+ const i = this.sensors.indexOf(sensor);
+ if (i > -1) {
+ this.sensors.splice(i, 1);
+ }
+ }
+
+ onDragstart(func: (e: LocateEvent) => any) {
+ this.emitter.on('dragstart', func);
+ return () => {
+ this.emitter.removeListener('dragstart', func);
+ };
+ }
+
+ onDrag(func: (e: LocateEvent, location: Location) => any) {
+ this.emitter.on('drag', func);
+ return () => {
+ this.emitter.removeListener('drag', func);
+ };
+ }
+
+ onDragend(func: (x: { dragTarget: DragTarget; copy: boolean }, location: Location) => any) {
+ this.emitter.on('dragend', func);
+ return () => {
+ this.emitter.removeListener('dragend', func);
+ };
+ }
+}
+
+export const dragon = new Dragon();
diff --git a/packages/designer/src/designer/hotkey.ts b/packages/designer/src/designer/hotkey.ts
new file mode 100644
index 000000000..44edffbde
--- /dev/null
+++ b/packages/designer/src/designer/hotkey.ts
@@ -0,0 +1,114 @@
+import Hotkey, { isFormEvent } from '../utils/hotkey';
+import { getCurrentDocument, getCurrentAdaptor } from './current';
+import { isShadowNode } from '../document/node/shadow-node';
+import { focusing } from './focusing';
+import { INode, isElementNode, insertChildren } from '../document/node';
+import { activeTracker } from './active-tracker';
+import clipboard from '../utils/clipboard';
+
+export const hotkey = new Hotkey();
+
+// hotkey binding
+hotkey.bind(['backspace', 'del'], (e: KeyboardEvent) => {
+ const doc = getCurrentDocument();
+ if (isFormEvent(e) || !doc) {
+ return;
+ }
+ e.preventDefault();
+
+ const sel = doc.selection;
+ const topItems = sel.getTopNodes();
+ topItems.forEach(node => {
+ if (isShadowNode(node)) {
+ doc.removeNode(node.origin);
+ } else {
+ doc.removeNode(node);
+ }
+ });
+ sel.clear();
+});
+
+hotkey.bind('escape', (e: KeyboardEvent) => {
+ const currentFocus = focusing.current;
+ if (isFormEvent(e) || !currentFocus) {
+ return;
+ }
+ e.preventDefault();
+
+ currentFocus.esc();
+});
+
+function isHTMLTag(name: string) {
+ return /^[a-z]\w*$/.test(name);
+}
+
+function isIgnore(uri: string) {
+ return /^(\.|@(builtins|html|imported):)/.test(uri);
+}
+
+function generateMaps(node: INode | INode[], maps: any = {}) {
+ if (Array.isArray(node)) {
+ node.forEach(n => generateMaps(n, maps));
+ return maps;
+ }
+ if (isElementNode(node)) {
+ const { uri, tagName } = node;
+ if (uri && !isHTMLTag(tagName) && !isIgnore(uri)) {
+ maps[tagName] = uri;
+ }
+ generateMaps(node.children, maps);
+ }
+ return maps;
+}
+
+// command + c copy command + x cut
+hotkey.bind(['command+c', 'ctrl+c', 'command+x', 'ctrl+x'], (e, action) => {
+ const doc = getCurrentDocument();
+ if (isFormEvent(e) || !doc || !(focusing.id === 'outline' || focusing.id === 'canvas')) {
+ return;
+ }
+ e.preventDefault();
+
+ const selected = doc.selection.getTopNodes(true);
+ if (!selected || selected.length < 1) return;
+
+ const maps = generateMaps(selected);
+ const nodesData = selected.map(item => item.nodeData);
+ const code = getCurrentAdaptor().viewDataToSource({
+ file: '',
+ children: nodesData as any,
+ });
+
+ clipboard.setData({ code, maps });
+ /*
+ const cutMode = action.indexOf('x') > 0;
+ if (cutMode) {
+ const parentNode = selected.getParent();
+ parentNode.select();
+ selected.remove();
+ }
+ */
+});
+
+// command + v paste
+hotkey.bind(['command+v', 'ctrl+v'], e => {
+ const doc = getCurrentDocument();
+ if (isFormEvent(e) || !doc) {
+ return;
+ }
+ clipboard.waitPasteData(e, data => {
+ if (data.code && data.maps) {
+ const adaptor = getCurrentAdaptor();
+ let nodesData = adaptor.parseToViewData(data.code, data.maps).children;
+ nodesData = doc.processDocumentData(nodesData, data.maps);
+ const { target, index } = doc.getSuitableInsertion();
+ const nodes = insertChildren(target, nodesData, index);
+ if (nodes) {
+ doc.selection.selectAll(nodes.map(o => o.id));
+ setTimeout(() => activeTracker.track(nodes[0]), 10);
+ }
+ }
+ });
+});
+
+hotkey.mount(window);
diff --git a/packages/designer/src/designer/index.ts b/packages/designer/src/designer/index.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/designer/src/designer/project.ts b/packages/designer/src/designer/project.ts
new file mode 100644
index 000000000..a761f438f
--- /dev/null
+++ b/packages/designer/src/designer/project.ts
@@ -0,0 +1,76 @@
+import { obx } from '@recore/obx';
+import { DocumentSchema, ProjectSchema } from './schema';
+import { EventEmitter } from 'events';
+
+export default class Project {
+ @obx documents: DocumentContext[];
+ displayMode: 'exclusive' | 'split'; // P2
+ private emitter = new EventEmitter();
+ private data: ProjectSchema = {};
+ constructor(schema: ProjectSchema) {
+ this.data = { ...schema };
+ }
+
+ getDocument(fileName: string): DocumentContext {}
+
+ addDocument(data: DocumentSchema): DocumentContext {
+ this.documents.push(new DocumentContext(data));
+ }
+
+ /**
+ * 获取项目整体 schema
+ */
+ getSchema(): ProjectSchema {
+ return {
+ ...this.data,
+ componentsTree: this.documents.map(doc => doc.getSchema()),
+ };
+ }
+ /**
+ * 整体设置项目 schema
+ */
+ setSchema(schema: ProjectSchema): void {}
+
+ /**
+ * 分字段设置储存数据,不记录操作记录
+ */
+ set(
+ key:
+ | 'version'
+ | 'componentsTree'
+ | 'componentsMap'
+ | 'utils'
+ | 'constants'
+ | 'i18n'
+ | 'css'
+ | 'dataSource'
+ | string,
+ value: any,
+ ): void {}
+
+ /**
+ * 分字段设置储存数据
+ */
+ get(
+ key:
+ | 'version'
+ | 'componentsTree'
+ | 'componentsMap'
+ | 'utils'
+ | 'constants'
+ | 'i18n'
+ | 'css'
+ | 'dataSource'
+ | string,
+ ): any;
+
+ edit(document): void {}
+
+ /**
+ * documents 列表发生变化
+ */
+ onDocumentsChange(fn: (documents: DocumentContext[]) => void): () => void {}
+ /**
+ *
+ */
+}
diff --git a/packages/designer/src/designer/schema.ts b/packages/designer/src/designer/schema.ts
new file mode 100644
index 000000000..f027c66b4
--- /dev/null
+++ b/packages/designer/src/designer/schema.ts
@@ -0,0 +1,159 @@
+// 表达式
+export interface JSExpression {
+ type: 'JSExpression';
+ /**
+ * 表达式字符串
+ */
+ value: string;
+ /**
+ * 模拟值
+ */
+ mock?: any;
+}
+
+export interface JSSlot {
+ type: 'JSSlot';
+ value: NodeSchema;
+}
+
+// JSON 基本类型
+export type JSONValue = boolean | string | number | null | JSONArray | JSONObject;
+export type JSONArray = JSONValue[];
+export interface JSONObject {
+ [key: string]: JSONValue;
+}
+
+// 复合类型
+export type CompositeValue = JSONValue | JSExpression | JSSlot | CompositeArray | CompositeObject;
+export type CompositeArray = CompositeValue[];
+export interface CompositeObject {
+ [key: string]: CompositeValue;
+}
+
+export interface NpmInfo {
+ componentName: string;
+ package: string;
+ version: string;
+ destructuring?: boolean;
+ exportName?: string;
+ subName?: string;
+ main?: string;
+}
+
+export type ComponentsMap = NpmInfo[];
+
+export type UtilsMap = Array<
+| {
+ name: string;
+ type: 'npm';
+ content: NpmInfo;
+}
+| {
+ name: string;
+ type: '';
+}
+>;
+
+// lang "en-US" | "zh-CN" | "zh-TW" | ...
+export interface I18nMap {
+ [lang: string]: { [key: string]: string };
+}
+
+export interface DataSourceConfig {
+ id: string;
+ isInit: boolean;
+ type: string;
+ options: {
+ uri: string;
+ [option: string]: CompositeValue;
+ };
+ [otherKey: string]: CompositeValue;
+}
+
+export interface NodeSchema {
+ id?: string;
+ componentName: string;
+ props?: PropsMap | PropsList;
+ leadingComponents?: string;
+ condition?: CompositeValue;
+ loop?: CompositeValue;
+ loopArgs?: [string, string];
+ children?: NodeData | NodeData[];
+}
+
+export type PropsMap = CompositeObject;
+export type PropsList = Array<{
+ spread?: boolean;
+ name?: string;
+ value: CompositeValue;
+}>;
+
+export type NodeData = NodeSchema | JSExpression | DOMText;
+
+export interface JSExpression {
+ type: 'JSExpression';
+ value: string;
+}
+
+export function isJSExpression(data: any): data is JSExpression {
+ return data && data.type === 'JSExpression';
+}
+
+export function isDOMText(data: any): data is DOMText {
+ return typeof data === 'string';
+}
+
+export type DOMText = string;
+
+export interface RootSchema extends NodeSchema {
+ componentName: 'Block' | 'Page' | 'Component';
+ fileName: string;
+ meta?: object;
+ state?: {
+ [key: string]: CompositeValue;
+ };
+ methods?: {
+ [key: string]: JSExpression;
+ };
+ lifeCycles?: {
+ [key: string]: JSExpression;
+ };
+ css?: string;
+ dataSource?: {
+ items: DataSourceConfig[];
+ };
+ defaultProps?: CompositeObject;
+}
+
+export interface BlockSchema extends RootSchema {
+ componentName: 'Block';
+}
+
+export interface PageSchema extends RootSchema {
+ componentName: 'Page';
+}
+
+export interface ComponentSchema extends RootSchema {
+ componentName: 'Component';
+}
+
+export interface ProjectSchema {
+ version: string;
+ componentsMap: ComponentsMap;
+ componentsTree: RootSchema[];
+ i18n?: I18nMap;
+ utils?: UtilsMap;
+ constants?: JSONObject;
+ css?: string;
+ dataSource?: {
+ items: DataSourceConfig[];
+ };
+}
+
+export function isNodeSchema(data: any): data is NodeSchema {
+ return data && data.componentName;
+}
+
+export function isProjectSchema(data: any): data is ProjectSchema {
+ return data && data.componentsTree;
+}
diff --git a/packages/designer/src/designer/simulator-interface.ts b/packages/designer/src/designer/simulator-interface.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/designer/src/designer/workspace.tsx b/packages/designer/src/designer/workspace.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/designer/src/index.ts b/packages/designer/src/index.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/designer/src/utils/clipboard.ts b/packages/designer/src/utils/clipboard.ts
new file mode 100644
index 000000000..afbe5fcf9
--- /dev/null
+++ b/packages/designer/src/utils/clipboard.ts
@@ -0,0 +1,95 @@
+function getDataFromPasteEvent(event: ClipboardEvent) {
+ const clipboardData = event.clipboardData;
+ if (!clipboardData) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(clipboardData.getData('text/plain'));
+ } catch (error) {
+ /*
+ const html = clipboardData.getData('text/html');
+ if (html !== '') {
+ // TODO: clear the html
+ return {
+ code: '',
+ maps: {},
+ };
+ }
+ */
+ // paste the text by div
+ return {
+ code: clipboardData.getData('text/plain'),
+ maps: {},
+ };
+ }
+}
+
+class Clipboard {
+ private copyPasters: HTMLTextAreaElement[] = [];
+ private waitFn?: (data: any, e: ClipboardEvent) => void;
+
+ isCopyPasteEvent(e: Event) {
+ this.isCopyPaster(e.target);
+ }
+
+ isCopyPaster(el: any) {
+ return this.copyPasters.includes(el);
+ }
+
+ initCopyPaster(el: HTMLTextAreaElement) {
+ this.copyPasters.push(el);
+ const onPaste = (e: ClipboardEvent) => {
+ if (this.waitFn) {
+ this.waitFn(getDataFromPasteEvent(e), e);
+ this.waitFn = undefined;
+ }
+ el.blur();
+ };
+ el.addEventListener('paste', onPaste, false);
+ return () => {
+ el.removeEventListener('paste', onPaste, false);
+ const i = this.copyPasters.indexOf(el);
+ if (i > -1) {
+ this.copyPasters.splice(i, 1);
+ }
+ };
+ }
+
+ injectCopyPaster(document: Document) {
+ const copyPaster = document.createElement<'textarea'>('textarea');
+ copyPaster.style.cssText = 'position: relative;left: -9999px;';
+ document.body.appendChild(copyPaster);
+ const dispose = this.initCopyPaster(copyPaster);
+ return () => {
+ dispose();
+ document.removeChild(copyPaster);
+ };
+ }
+
+ setData(data: any) {
+ const copyPaster = this.copyPasters.find(x => x.ownerDocument);
+ if (!copyPaster) {
+ return;
+ }
+ copyPaster.value = typeof data === 'string' ? data : JSON.stringify(data);
+ copyPaster.select();
+ copyPaster.ownerDocument!.execCommand('copy');
+
+ copyPaster.blur();
+ }
+
+ waitPasteData(e: KeyboardEvent, cb: (data: any, e: ClipboardEvent) => void) {
+ const win = e.view;
+ if (!win) {
+ return;
+ }
+ const copyPaster = this.copyPasters.find(cp => cp.ownerDocument === win.document);
+ if (copyPaster) {
+ copyPaster.select();
+ this.waitFn = cb;
+ }
+ }
+}
+
+export default new Clipboard();
diff --git a/packages/designer/src/utils/clone-deep.ts b/packages/designer/src/utils/clone-deep.ts
new file mode 100644
index 000000000..94719418a
--- /dev/null
+++ b/packages/designer/src/utils/clone-deep.ts
@@ -0,0 +1,23 @@
+import { isPlainObject } from './is-plain-object';
+
+export function cloneDeep(src: any): any {
+ const type = typeof src;
+
+ let data: any;
+ if (src === null || src === undefined) {
+ data = src;
+ } else if (Array.isArray(src)) {
+ data = src.map(item => cloneDeep(item));
+ } else if (type === 'object' && isPlainObject(src)) {
+ data = {};
+ for (const key in src) {
+ if (src.hasOwnProperty(key)) {
+ data[key] = cloneDeep(src[key]);
+ }
+ }
+ } else {
+ data = src;
+ }
+
+ return data;
+}
diff --git a/packages/designer/src/utils/create-content.ts b/packages/designer/src/utils/create-content.ts
new file mode 100644
index 000000000..fbe8592fc
--- /dev/null
+++ b/packages/designer/src/utils/create-content.ts
@@ -0,0 +1,17 @@
+import { ReactNode, ComponentType, isValidElement, cloneElement, createElement, ReactElement } from 'react';
+import { isReactClass } from './is-react';
+
+export function createContent(content: ReactNode | ComponentType, props?: object): ReactNode {
+ if (isValidElement(content)) {
+ return props ? cloneElement(content, props) : content;
+ }
+ if (isReactClass(content)) {
+ return createElement(content, props);
+ }
+
+ if (typeof content === 'function') {
+ return content(props) as ReactElement;
+ }
+
+ return content;
+}
diff --git a/packages/designer/src/utils/create-defer.ts b/packages/designer/src/utils/create-defer.ts
new file mode 100644
index 000000000..e7997365a
--- /dev/null
+++ b/packages/designer/src/utils/create-defer.ts
@@ -0,0 +1,17 @@
+export interface Defer {
+ resolve(value?: T | PromiseLike): void;
+ reject(reason?: any): void;
+ promise(): Promise;
+}
+
+export function createDefer(): Defer {
+ const r: any = {};
+ const promise = new Promise((resolve, reject) => {
+ r.resolve = resolve;
+ r.reject = reject;
+ });
+
+ r.promise = () => promise;
+
+ return r;
+}
diff --git a/packages/designer/src/utils/cursor.less b/packages/designer/src/utils/cursor.less
new file mode 100644
index 000000000..178e8b5c6
--- /dev/null
+++ b/packages/designer/src/utils/cursor.less
@@ -0,0 +1,15 @@
+html.my-cursor-dragging, html.my-cursor-dragging * {
+ cursor: move !important
+}
+
+html.my-cursor-x-resizing, html.my-cursor-x-resizing * {
+ cursor: col-resize;
+}
+
+html.my-cursor-y-resizing, html.my-cursor-y-resizing * {
+ cursor: row-resize;
+}
+
+html.my-cursor-copy, html.my-cursor-copy * {
+ cursor: copy !important
+}
diff --git a/packages/designer/src/utils/cursor.ts b/packages/designer/src/utils/cursor.ts
new file mode 100644
index 000000000..a738d7b8e
--- /dev/null
+++ b/packages/designer/src/utils/cursor.ts
@@ -0,0 +1,60 @@
+import './cursor.less';
+export class Cursor {
+ private states = new Set();
+
+ setDragging(flag: boolean) {
+ if (flag) {
+ this.addState('dragging');
+ } else {
+ this.removeState('dragging');
+ }
+ }
+ setXResizing(flag: boolean) {
+ if (flag) {
+ this.addState('x-resizing');
+ } else {
+ this.removeState('x-resizing');
+ }
+ }
+ setYResizing(flag: boolean) {
+ if (flag) {
+ this.addState('y-resizing');
+ } else {
+ this.removeState('y-resizing');
+ }
+ }
+
+ setCopy(flag: boolean) {
+ if (flag) {
+ this.addState('copy');
+ } else {
+ this.removeState('copy');
+ }
+ }
+
+ isCopy() {
+ return this.states.has('copy');
+ }
+
+ release() {
+ for (const state of this.states) {
+ this.removeState(state);
+ }
+ }
+
+ private addState(state: string) {
+ if (!this.states.has(state)) {
+ this.states.add(state);
+ document.documentElement.classList.add(`my-cursor-${state}`);
+ }
+ }
+
+ private removeState(state: string) {
+ if (this.states.has(state)) {
+ this.states.delete(state);
+ document.documentElement.classList.remove(`my-cursor-${state}`);
+ }
+ }
+}
+
+export default new Cursor();
diff --git a/packages/designer/src/utils/dom.ts b/packages/designer/src/utils/dom.ts
new file mode 100644
index 000000000..f723945d0
--- /dev/null
+++ b/packages/designer/src/utils/dom.ts
@@ -0,0 +1,19 @@
+export function isDOMNode(node: any): node is Element | Text {
+ return node.nodeType && (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE);
+}
+
+export function isElement(node: any): node is Element {
+ return node.nodeType === Node.ELEMENT_NODE;
+}
+
+// a range for test TextNode clientRect
+const cycleRange = document.createRange();
+
+export function getClientRects(node: Element | Text) {
+ if (isElement(node)) {
+ return [node.getBoundingClientRect()];
+ }
+
+ cycleRange.selectNode(node);
+ return Array.from(cycleRange.getClientRects());
+}
diff --git a/packages/designer/src/utils/get-prototype-of.ts b/packages/designer/src/utils/get-prototype-of.ts
new file mode 100644
index 000000000..b64eebf63
--- /dev/null
+++ b/packages/designer/src/utils/get-prototype-of.ts
@@ -0,0 +1,7 @@
+export function getPrototypeOf(target: any) {
+ if (typeof Object.getPrototypeOf !== 'undefined') {
+ return Object.getPrototypeOf(target);
+ }
+
+ return target.__proto__;
+}
diff --git a/packages/designer/src/utils/has-own-property.ts b/packages/designer/src/utils/has-own-property.ts
new file mode 100644
index 000000000..ea5ece914
--- /dev/null
+++ b/packages/designer/src/utils/has-own-property.ts
@@ -0,0 +1,4 @@
+const prototypeHasOwnProperty = Object.prototype.hasOwnProperty;
+export function hasOwnProperty(obj: any, key: string | number | symbol): boolean {
+ return obj && prototypeHasOwnProperty.call(obj, key);
+}
diff --git a/packages/designer/src/utils/hotkey.ts b/packages/designer/src/utils/hotkey.ts
new file mode 100644
index 000000000..66889a4a5
--- /dev/null
+++ b/packages/designer/src/utils/hotkey.ts
@@ -0,0 +1,618 @@
+interface KeyMap {
+ [key: number]: string;
+}
+
+interface CtrlKeyMap {
+ [key: string]: string;
+}
+
+interface ActionEvent {
+ type: string;
+}
+
+interface HotkeyCallbacks {
+ [key: string]: HotkeyCallbackCfg[];
+}
+
+interface HotkeyDirectMap {
+ [key: string]: HotkeyCallback;
+}
+
+export type HotkeyCallback = (e: KeyboardEvent, combo?: string) => any | false;
+
+interface HotkeyCallbackCfg {
+ callback: HotkeyCallback;
+ modifiers: string[];
+ action: string;
+ seq?: string;
+ level?: number;
+ combo?: string;
+}
+
+interface KeyInfo {
+ key: string;
+ modifiers: string[];
+ action: string;
+}
+
+interface SequenceLevels {
+ [key: string]: number;
+}
+
+const MAP: KeyMap = {
+ 8: 'backspace',
+ 9: 'tab',
+ 13: 'enter',
+ 16: 'shift',
+ 17: 'ctrl',
+ 18: 'alt',
+ 20: 'capslock',
+ 27: 'esc',
+ 32: 'space',
+ 33: 'pageup',
+ 34: 'pagedown',
+ 35: 'end',
+ 36: 'home',
+ 37: 'left',
+ 38: 'up',
+ 39: 'right',
+ 40: 'down',
+ 45: 'ins',
+ 46: 'del',
+ 91: 'meta',
+ 93: 'meta',
+ 224: 'meta',
+};
+
+const KEYCODE_MAP: KeyMap = {
+ 106: '*',
+ 107: '+',
+ 109: '-',
+ 110: '.',
+ 111: '/',
+ 186: ';',
+ 187: '=',
+ 188: ',',
+ 189: '-',
+ 190: '.',
+ 191: '/',
+ 192: '`',
+ 219: '[',
+ 220: '\\',
+ 221: ']',
+ 222: "'",
+};
+
+const SHIFT_MAP: CtrlKeyMap = {
+ '~': '`',
+ '!': '1',
+ '@': '2',
+ '#': '3',
+ $: '4',
+ '%': '5',
+ '^': '6',
+ '&': '7',
+ '*': '8',
+ '(': '9',
+ ')': '0',
+ _: '-',
+ '+': '=',
+ ':': ';',
+ '"': "'",
+ '<': ',',
+ '>': '.',
+ '?': '/',
+ '|': '\\',
+};
+
+const SPECIAL_ALIASES: CtrlKeyMap = {
+ option: 'alt',
+ command: 'meta',
+ return: 'enter',
+ escape: 'esc',
+ plus: '+',
+ mod: /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'meta' : 'ctrl',
+};
+
+let REVERSE_MAP: CtrlKeyMap;
+
+/**
+ * loop through the f keys, f1 to f19 and add them to the map
+ * programatically
+ */
+for (let i = 1; i < 20; ++i) {
+ MAP[111 + i] = 'f' + i;
+}
+
+/**
+ * loop through to map numbers on the numeric keypad
+ */
+for (let i = 0; i <= 9; ++i) {
+ MAP[i + 96] = String(i);
+}
+
+/**
+ * takes the event and returns the key character
+ */
+function characterFromEvent(e: KeyboardEvent): string {
+ const keyCode = e.keyCode || e.which;
+ // for keypress events we should return the character as is
+ if (e.type === 'keypress') {
+ let character = String.fromCharCode(keyCode);
+ // if the shift key is not pressed then it is safe to assume
+ // that we want the character to be lowercase. this means if
+ // you accidentally have caps lock on then your key bindings
+ // will continue to work
+ //
+ // the only side effect that might not be desired is if you
+ // bind something like 'A' cause you want to trigger an
+ // event when capital A is pressed caps lock will no longer
+ // trigger the event. shift+a will though.
+ if (!e.shiftKey) {
+ character = character.toLowerCase();
+ }
+ return character;
+ }
+ // for non keypress events the special maps are needed
+ if (MAP[keyCode]) {
+ return MAP[keyCode];
+ }
+ if (KEYCODE_MAP[keyCode]) {
+ return KEYCODE_MAP[keyCode];
+ }
+ // if it is not in the special map
+ // with keydown and keyup events the character seems to always
+ // come in as an uppercase character whether you are pressing shift
+ // or not. we should make sure it is always lowercase for comparisons
+ return String.fromCharCode(keyCode).toLowerCase();
+}
+
+interface KeypressEvent extends KeyboardEvent {
+ type: 'keypress';
+}
+
+function isPressEvent(e: KeyboardEvent | ActionEvent): e is KeypressEvent {
+ return e.type === 'keypress';
+}
+
+export function isFormEvent(e: KeyboardEvent) {
+ const t = e.target as HTMLFormElement;
+ if (!t) {
+ return false;
+ }
+
+ if (t.form || /^(INPUT|SELECT|TEXTAREA)$/.test(t.tagName)) {
+ return true;
+ }
+ if (/write/.test(window.getComputedStyle(t).getPropertyValue('-webkit-user-modify'))) {
+ return true;
+ }
+ return false;
+}
+/**
+ * checks if two arrays are equal
+ */
+function modifiersMatch(modifiers1: string[], modifiers2: string[]): boolean {
+ return modifiers1.sort().join(',') === modifiers2.sort().join(',');
+}
+
+/**
+ * takes a key event and figures out what the modifiers are
+ */
+function eventModifiers(e: KeyboardEvent): string[] {
+ const modifiers = [];
+
+ if (e.shiftKey) {
+ modifiers.push('shift');
+ }
+
+ if (e.altKey) {
+ modifiers.push('alt');
+ }
+
+ if (e.ctrlKey) {
+ modifiers.push('ctrl');
+ }
+
+ if (e.metaKey) {
+ modifiers.push('meta');
+ }
+
+ return modifiers;
+}
+
+/**
+ * determines if the keycode specified is a modifier key or not
+ */
+function isModifier(key: string): boolean {
+ return key === 'shift' || key === 'ctrl' || key === 'alt' || key === 'meta';
+}
+
+/**
+ * reverses the map lookup so that we can look for specific keys
+ * to see what can and can't use keypress
+ *
+ * @return {Object}
+ */
+function getReverseMap(): CtrlKeyMap {
+ if (!REVERSE_MAP) {
+ REVERSE_MAP = {};
+ for (const key in MAP) {
+ // pull out the numeric keypad from here cause keypress should
+ // be able to detect the keys from the character
+ if (Number(key) > 95 && Number(key) < 112) {
+ continue;
+ }
+
+ if (MAP.hasOwnProperty(key)) {
+ REVERSE_MAP[MAP[key]] = key;
+ }
+ }
+ }
+ return REVERSE_MAP;
+}
+
+/**
+ * picks the best action based on the key combination
+ */
+function pickBestAction(key: string, modifiers: string[], action?: string): string {
+ // if no action was picked in we should try to pick the one
+ // that we think would work best for this key
+ if (!action) {
+ action = getReverseMap()[key] ? 'keydown' : 'keypress';
+ }
+ // modifier keys don't work as expected with keypress,
+ // switch to keydown
+ if (action === 'keypress' && modifiers.length) {
+ action = 'keydown';
+ }
+ return action;
+}
+
+/**
+ * Converts from a string key combination to an array
+ *
+ * @param {string} combination like "command+shift+l"
+ * @return {Array}
+ */
+function keysFromString(combination: string): string[] {
+ if (combination === '+') {
+ return ['+'];
+ }
+
+ combination = combination.replace(/\+{2}/g, '+plus');
+ return combination.split('+');
+}
+
+/**
+ * Gets info for a specific key combination
+ *
+ * @param combination key combination ("command+s" or "a" or "*")
+ */
+function getKeyInfo(combination: string, action?: string): KeyInfo {
+ let keys: string[] = [];
+ let key = '';
+ let i: number;
+ const modifiers: string[] = [];
+
+ // take the keys from this pattern and figure out what the actual
+ // pattern is all about
+ keys = keysFromString(combination);
+
+ for (i = 0; i < keys.length; ++i) {
+ key = keys[i];
+
+ // normalize key names
+ if (SPECIAL_ALIASES[key]) {
+ key = SPECIAL_ALIASES[key];
+ }
+
+ // if this is not a keypress event then we should
+ // be smart about using shift keys
+ // this will only work for US keyboards however
+ if (action && action !== 'keypress' && SHIFT_MAP[key]) {
+ key = SHIFT_MAP[key];
+ modifiers.push('shift');
+ }
+
+ // if this key is a modifier then add it to the list of modifiers
+ if (isModifier(key)) {
+ modifiers.push(key);
+ }
+ }
+
+ // depending on what the key combination is
+ // we will try to pick the best event for it
+ action = pickBestAction(key, modifiers, action);
+
+ return {
+ key,
+ modifiers,
+ action,
+ };
+}
+
+/**
+ * actually calls the callback function
+ *
+ * if your callback function returns false this will use the jquery
+ * convention - prevent default and stop propogation on the event
+ */
+function fireCallback(callback: HotkeyCallback, e: KeyboardEvent, combo?: string, sequence?: string): void {
+ if (callback(e, combo) === false) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+}
+
+export default class Hotkey {
+ private callBacks: HotkeyCallbacks = {};
+ private directMap: HotkeyDirectMap = {};
+ private sequenceLevels: SequenceLevels = {};
+ private resetTimer = 0;
+ private ignoreNextKeyup: boolean | string = false;
+ private ignoreNextKeypress = false;
+ private nextExpectedAction: boolean | string = false;
+
+ mount(window: Window) {
+ const document = window.document;
+ const handleKeyEvent = this.handleKeyEvent.bind(this);
+ document.addEventListener('keypress', handleKeyEvent, false);
+ document.addEventListener('keydown', handleKeyEvent, false);
+ document.addEventListener('keyup', handleKeyEvent, false);
+ return () => {
+ document.removeEventListener('keypress', handleKeyEvent, false);
+ document.removeEventListener('keydown', handleKeyEvent, false);
+ document.removeEventListener('keyup', handleKeyEvent, false);
+ };
+ }
+
+ bind(combos: string[] | string, callback: HotkeyCallback, action?: string): Hotkey {
+ this.bindMultiple(Array.isArray(combos) ? combos : [combos], callback, action);
+ return this;
+ }
+
+ /**
+ * resets all sequence counters except for the ones passed in
+ */
+ private resetSequences(doNotReset?: SequenceLevels): void {
+ // doNotReset = doNotReset || {};
+ let activeSequences = false;
+ let key = '';
+ for (key in this.sequenceLevels) {
+ if (doNotReset && doNotReset[key]) {
+ activeSequences = true;
+ } else {
+ this.sequenceLevels[key] = 0;
+ }
+ }
+ if (!activeSequences) {
+ this.nextExpectedAction = false;
+ }
+ }
+
+ /**
+ * finds all callbacks that match based on the keycode, modifiers,
+ * and action
+ */
+ private getMatches(
+ character: string,
+ modifiers: string[],
+ e: KeyboardEvent | ActionEvent,
+ sequenceName?: string,
+ combination?: string,
+ level?: number,
+ ): HotkeyCallbackCfg[] {
+ let i: number;
+ let callback: HotkeyCallbackCfg;
+ const matches: HotkeyCallbackCfg[] = [];
+ const action: string = e.type;
+
+ // if there are no events related to this keycode
+ if (!this.callBacks[character]) {
+ return [];
+ }
+
+ // if a modifier key is coming up on its own we should allow it
+ if (action === 'keyup' && isModifier(character)) {
+ modifiers = [character];
+ }
+
+ // loop through all callbacks for the key that was pressed
+ // and see if any of them match
+ for (i = 0; i < this.callBacks[character].length; ++i) {
+ callback = this.callBacks[character][i];
+
+ // if a sequence name is not specified, but this is a sequence at
+ // the wrong level then move onto the next match
+ if (!sequenceName && callback.seq && this.sequenceLevels[callback.seq] !== callback.level) {
+ continue;
+ }
+
+ // if the action we are looking for doesn't match the action we got
+ // then we should keep going
+ if (action !== callback.action) {
+ continue;
+ }
+
+ // if this is a keypress event and the meta key and control key
+ // are not pressed that means that we need to only look at the
+ // character, otherwise check the modifiers as well
+ //
+ // chrome will not fire a keypress if meta or control is down
+ // safari will fire a keypress if meta or meta+shift is down
+ // firefox will fire a keypress if meta or control is down
+ if ((isPressEvent(e) && !e.metaKey && !e.ctrlKey) || modifiersMatch(modifiers, callback.modifiers)) {
+ const deleteCombo = !sequenceName && callback.combo === combination;
+ const deleteSequence = sequenceName && callback.seq === sequenceName && callback.level === level;
+ if (deleteCombo || deleteSequence) {
+ this.callBacks[character].splice(i, 1);
+ }
+
+ matches.push(callback);
+ }
+ }
+ return matches;
+ }
+
+ private handleKey(character: string, modifiers: string[], e: KeyboardEvent): void {
+ const callbacks: HotkeyCallbackCfg[] = this.getMatches(character, modifiers, e);
+ let i: number;
+ const doNotReset: SequenceLevels = {};
+ let maxLevel = 0;
+ let processedSequenceCallback = false;
+
+ // Calculate the maxLevel for sequences so we can only execute the longest callback sequence
+ for (i = 0; i < callbacks.length; ++i) {
+ if (callbacks[i].seq) {
+ maxLevel = Math.max(maxLevel, callbacks[i].level || 0);
+ }
+ }
+
+ // loop through matching callbacks for this key event
+ for (i = 0; i < callbacks.length; ++i) {
+ // fire for all sequence callbacks
+ // this is because if for example you have multiple sequences
+ // bound such as "g i" and "g t" they both need to fire the
+ // callback for matching g cause otherwise you can only ever
+ // match the first one
+ if (callbacks[i].seq) {
+ // only fire callbacks for the maxLevel to prevent
+ // subsequences from also firing
+ //
+ // for example 'a option b' should not cause 'option b' to fire
+ // even though 'option b' is part of the other sequence
+ //
+ // any sequences that do not match here will be discarded
+ // below by the resetSequences call
+ if (callbacks[i].level !== maxLevel) {
+ continue;
+ }
+
+ processedSequenceCallback = true;
+
+ // keep a list of which sequences were matches for later
+ doNotReset[callbacks[i].seq || ''] = 1;
+ fireCallback(callbacks[i].callback, e, callbacks[i].combo, callbacks[i].seq);
+ continue;
+ }
+
+ // if there were no sequence matches but we are still here
+ // that means this is a regular match so we should fire that
+ if (!processedSequenceCallback) {
+ fireCallback(callbacks[i].callback, e, callbacks[i].combo);
+ }
+ }
+
+ const ignoreThisKeypress = e.type === 'keypress' && this.ignoreNextKeypress;
+ if (e.type === this.nextExpectedAction && !isModifier(character) && !ignoreThisKeypress) {
+ this.resetSequences(doNotReset);
+ }
+
+ this.ignoreNextKeypress = processedSequenceCallback && e.type === 'keydown';
+ }
+
+ private handleKeyEvent(e: KeyboardEvent): void {
+ const character = characterFromEvent(e);
+
+ // no character found then stop
+ if (!character) {
+ return;
+ }
+
+ // need to use === for the character check because the character can be 0
+ if (e.type === 'keyup' && this.ignoreNextKeyup === character) {
+ this.ignoreNextKeyup = false;
+ return;
+ }
+
+ this.handleKey(character, eventModifiers(e), e);
+ }
+
+ private resetSequenceTimer(): void {
+ if (this.resetTimer) {
+ clearTimeout(this.resetTimer);
+ }
+ this.resetTimer = window.setTimeout(this.resetSequences, 1000);
+ }
+
+ private bindSequence(combo: string, keys: string[], callback: HotkeyCallback, action?: string): void {
+ // const self: any = this;
+ this.sequenceLevels[combo] = 0;
+ const increaseSequence = (nextAction: string) => {
+ return () => {
+ this.nextExpectedAction = nextAction;
+ ++this.sequenceLevels[combo];
+ this.resetSequenceTimer();
+ };
+ };
+ const callbackAndReset = (e: KeyboardEvent): void => {
+ fireCallback(callback, e, combo);
+
+ if (action !== 'keyup') {
+ this.ignoreNextKeyup = characterFromEvent(e);
+ }
+
+ setTimeout(this.resetSequences, 10);
+ };
+ for (let i = 0; i < keys.length; ++i) {
+ const isFinal = i + 1 === keys.length;
+ const wrappedCallback = isFinal ? callbackAndReset : increaseSequence(action || getKeyInfo(keys[i + 1]).action);
+ this.bindSingle(keys[i], wrappedCallback, action, combo, i);
+ }
+ }
+
+ private bindSingle(
+ combination: string,
+ callback: HotkeyCallback,
+ action?: string,
+ sequenceName?: string,
+ level?: number,
+ ): void {
+ // store a direct mapped reference for use with HotKey.trigger
+ this.directMap[`${combination}:${action}`] = callback;
+
+ // make sure multiple spaces in a row become a single space
+ combination = combination.replace(/\s+/g, ' ');
+
+ const sequence: string[] = combination.split(' ');
+ let info: KeyInfo;
+
+ // if this pattern is a sequence of keys then run through this method
+ // to reprocess each pattern one key at a time
+ if (sequence.length > 1) {
+ this.bindSequence(combination, sequence, callback, action);
+ return;
+ }
+
+ info = getKeyInfo(combination, action);
+
+ // make sure to initialize array if this is the first time
+ // a callback is added for this key
+ this.callBacks[info.key] = this.callBacks[info.key] || [];
+
+ // remove an existing match if there is one
+ this.getMatches(info.key, info.modifiers, { type: info.action }, sequenceName, combination, level);
+
+ // add this call back to the array
+ // if it is a sequence put it at the beginning
+ // if not put it at the end
+ //
+ // this is important because the way these are processed expects
+ // the sequence ones to come first
+ this.callBacks[info.key][sequenceName ? 'unshift' : 'push']({
+ callback,
+ modifiers: info.modifiers,
+ action: info.action,
+ seq: sequenceName,
+ level,
+ combo: combination,
+ });
+ }
+
+ private bindMultiple(combinations: string[], callback: HotkeyCallback, action?: string) {
+ for (const item of combinations) {
+ this.bindSingle(item, callback, action);
+ }
+ }
+}
diff --git a/packages/designer/src/utils/index.ts b/packages/designer/src/utils/index.ts
new file mode 100644
index 000000000..35cf98d29
--- /dev/null
+++ b/packages/designer/src/utils/index.ts
@@ -0,0 +1,9 @@
+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/is-css-url.ts b/packages/designer/src/utils/is-css-url.ts
new file mode 100644
index 000000000..1f900f18c
--- /dev/null
+++ b/packages/designer/src/utils/is-css-url.ts
@@ -0,0 +1,3 @@
+export function isCSSUrl(url: string): boolean {
+ return /\.css$/.test(url);
+}
diff --git a/packages/designer/src/utils/is-es-module.ts b/packages/designer/src/utils/is-es-module.ts
new file mode 100644
index 000000000..32839a38b
--- /dev/null
+++ b/packages/designer/src/utils/is-es-module.ts
@@ -0,0 +1,3 @@
+export function isESModule(obj: any): obj is { [key: string]: any } {
+ return obj && obj.__esModule;
+}
diff --git a/packages/designer/src/utils/is-function.ts b/packages/designer/src/utils/is-function.ts
new file mode 100644
index 000000000..4f1c1c0e2
--- /dev/null
+++ b/packages/designer/src/utils/is-function.ts
@@ -0,0 +1,3 @@
+export function isFunction(fn: any): boolean {
+ return typeof fn === 'function';
+}
diff --git a/packages/designer/src/utils/is-object.ts b/packages/designer/src/utils/is-object.ts
new file mode 100644
index 000000000..1db32995e
--- /dev/null
+++ b/packages/designer/src/utils/is-object.ts
@@ -0,0 +1,3 @@
+export function isObject(value: any): value is object {
+ return value !== null && typeof value === 'object';
+}
diff --git a/packages/designer/src/utils/is-plain-object.ts b/packages/designer/src/utils/is-plain-object.ts
new file mode 100644
index 000000000..899ec5c64
--- /dev/null
+++ b/packages/designer/src/utils/is-plain-object.ts
@@ -0,0 +1,9 @@
+import { isObject } from './is-object';
+
+export function isPlainObject(value: any) {
+ if (!isObject(value)) {
+ return false;
+ }
+ const proto = Object.getPrototypeOf(value);
+ return proto === Object.prototype || proto === null || Object.getPrototypeOf(proto) === null;
+}
diff --git a/packages/designer/src/utils/is-react.ts b/packages/designer/src/utils/is-react.ts
new file mode 100644
index 000000000..1f755138a
--- /dev/null
+++ b/packages/designer/src/utils/is-react.ts
@@ -0,0 +1,9 @@
+import { ComponentClass, Component, ComponentType } from 'react';
+
+export function isReactClass(obj: any): obj is ComponentClass {
+ return obj && obj.prototype && (obj.prototype.isReactComponent || obj.prototype instanceof Component);
+}
+
+export function isReactComponent(obj: any): obj is ComponentType {
+ return obj && (isReactClass(obj) || typeof obj === 'function');
+}
diff --git a/packages/designer/src/utils/parse-code.ts b/packages/designer/src/utils/parse-code.ts
new file mode 100644
index 000000000..643a41611
--- /dev/null
+++ b/packages/designer/src/utils/parse-code.ts
@@ -0,0 +1,7 @@
+export function parseCode(code: string): string {
+ try {
+ return JSON.parse(code);
+ } catch (e) {
+ return code;
+ }
+}
diff --git a/packages/designer/src/utils/path.ts b/packages/designer/src/utils/path.ts
new file mode 100644
index 000000000..c9a32e353
--- /dev/null
+++ b/packages/designer/src/utils/path.ts
@@ -0,0 +1,173 @@
+/**
+ * Check whether a component is external package, e.g. @ali/uxcore
+ * @param path Component path
+ */
+export function isPackagePath(path: string): boolean {
+ return !path.startsWith('.') && !path.startsWith('/');
+}
+
+/**
+ * Title cased string
+ * @param s original string
+ */
+export function toTitleCase(s: string): string {
+ return s
+ .split(/[-_ .]+/)
+ .map(token => token[0].toUpperCase() + token.substring(1))
+ .join('');
+}
+
+/**
+ * Make up an import name/tag for components
+ * @param path Original path name
+ */
+export function generateComponentName(path: string): string {
+ const parts = path.split('/');
+ let name = parts.pop();
+ if (name && /^index\./.test(name)) {
+ name = parts.pop();
+ }
+ return name ? toTitleCase(name) : 'Component';
+}
+
+/**
+ * normalizing import path for easier comparison
+ */
+export function getNormalizedImportPath(path: string): string {
+ const segments = path.split('/');
+ let basename = segments.pop();
+ if (!basename) {
+ return path;
+ }
+ const ignoredExtensions = ['.ts', '.js', '.tsx', '.jsx'];
+ const extIndex = basename.lastIndexOf('.');
+ if (extIndex > -1) {
+ const ext = basename.slice(extIndex);
+ if (ignoredExtensions.includes(ext)) {
+ basename = basename.slice(0, extIndex);
+ }
+ }
+ if (basename !== 'index') {
+ segments.push(basename);
+ }
+ return segments.join('/');
+}
+
+/**
+ * make a relative path
+ *
+ * @param toPath abolute path
+ * @param fromPath absolute path
+ */
+export function makeRelativePath(toPath: string, fromPath: string) {
+ // not a absolute path, eg. @ali/uxcore
+ if (!toPath.startsWith('/')) {
+ return toPath;
+ }
+ const toParts = toPath.split('/');
+ const fromParts = fromPath.split('/');
+
+ // find shared path header
+ const length = Math.min(fromParts.length, toParts.length);
+ let sharedUpTo = length;
+ for (let i = 0; i < length; i++) {
+ if (fromParts[i] !== toParts[i]) {
+ sharedUpTo = i;
+ break;
+ }
+ }
+
+ // find how many levels to go up from
+ // minus another 1 since we do not include the final
+ const numGoUp = fromParts.length - sharedUpTo - 1;
+
+ // generate final path
+ let outputParts = [];
+ if (numGoUp === 0) {
+ // in the same dir
+ outputParts.push('.');
+ } else {
+ // needs to go up
+ for (let i = 0; i < numGoUp; ++i) {
+ outputParts.push('..');
+ }
+ }
+
+ outputParts = outputParts.concat(toParts.slice(sharedUpTo));
+
+ return outputParts.join('/');
+}
+
+function normalizeArray(parts: string[], allowAboveRoot: boolean) {
+ const res = [];
+ for (let i = 0; i < parts.length; i++) {
+ const p = parts[i];
+
+ // ignore empty parts
+ if (!p || p === '.') {
+ continue;
+ }
+
+ if (p === '..') {
+ if (res.length && res[res.length - 1] !== '..') {
+ res.pop();
+ } else if (allowAboveRoot) {
+ res.push('..');
+ }
+ } else {
+ res.push(p);
+ }
+ }
+
+ return res;
+}
+
+function normalize(path: string): string {
+ const isAbsolute = path[0] === '/';
+
+ const segments = normalizeArray(path.split('/'), !isAbsolute);
+ if (isAbsolute) {
+ segments.unshift('');
+ } else if (segments.length < 1 || segments[0] !== '..') {
+ segments.unshift('.');
+ }
+
+ return segments.join('/');
+}
+
+/**
+ * Resolve component with absolute path to relative path
+ * @param path absolute path of component from project
+ */
+export function resolveAbsoluatePath(path: string, base: string): string {
+ if (!path.startsWith('.')) {
+ // eg. /usr/path/to, @ali/button
+ return path;
+ }
+ path = path.replace(/\\/g, '/');
+ if (base.slice(-1) !== '/') {
+ base += '/';
+ }
+ return normalize(base + path);
+}
+
+export function joinPath(...segments: string[]) {
+ let path = '';
+ for (const seg of segments) {
+ if (seg) {
+ if (path === '') {
+ path += seg;
+ } else {
+ path += '/' + seg;
+ }
+ }
+ }
+ return normalize(path);
+}
+
+export function removeVersion(path: string): string {
+ if (path.lastIndexOf('@') > 0) {
+ path = path.replace(/(@?[^@]+)(@[\w.-]+)(.+)/, '$1$3');
+ }
+ return path;
+}
diff --git a/packages/designer/src/utils/react.ts b/packages/designer/src/utils/react.ts
new file mode 100644
index 000000000..948804c4c
--- /dev/null
+++ b/packages/designer/src/utils/react.ts
@@ -0,0 +1,32 @@
+import { ReactInstance } from 'react';
+import { isDOMNode, isElement } from './dom';
+
+const FIBER_KEY = '_reactInternalFiber';
+
+function elementsFromFiber(fiber: any, elements: Array) {
+ if (fiber) {
+ if (fiber.stateNode && isDOMNode(fiber.stateNode)) {
+ elements.push(fiber.stateNode);
+ } else if (fiber.child) {
+ // deep fiberNode.child
+ elementsFromFiber(fiber.child, elements);
+ }
+
+ if (fiber.sibling) {
+ elementsFromFiber(fiber.sibling, elements);
+ }
+ }
+}
+
+export function findDOMNodes(elem: Element | ReactInstance | null): Array | null {
+ if (!elem) {
+ return null;
+ }
+ if (isElement(elem)) {
+ return [elem];
+ }
+ const elements: Array = [];
+ const fiberNode = (elem as any)[FIBER_KEY];
+ elementsFromFiber(fiberNode.child, elements);
+ return elements.length > 0 ? elements : null;
+}
diff --git a/packages/designer/src/utils/script.ts b/packages/designer/src/utils/script.ts
new file mode 100644
index 000000000..9577946c3
--- /dev/null
+++ b/packages/designer/src/utils/script.ts
@@ -0,0 +1,38 @@
+import { createDefer } from './create-defer';
+
+export function evaluate(script: string) {
+ const scriptEl = document.createElement('script');
+ scriptEl.text = script;
+ document.head.appendChild(scriptEl);
+ document.head.removeChild(scriptEl);
+}
+
+export function load(url: string) {
+ const node: any = document.createElement('script');
+
+ // node.setAttribute('crossorigin', 'anonymous');
+
+ node.onload = onload;
+ node.onerror = onload;
+
+ const i = createDefer();
+
+ function onload(e: any) {
+ node.onload = null;
+ node.onerror = null;
+ if (e.type === 'load') {
+ i.resolve();
+ } else {
+ i.reject();
+ }
+ // document.head.removeChild(node);
+ // node = null;
+ }
+
+ // node.async = true;
+ node.src = url;
+
+ document.head.appendChild(node);
+
+ return i.promise();
+}
diff --git a/packages/designer/src/utils/set-prototype-of.ts b/packages/designer/src/utils/set-prototype-of.ts
new file mode 100644
index 000000000..7f242ac6a
--- /dev/null
+++ b/packages/designer/src/utils/set-prototype-of.ts
@@ -0,0 +1,8 @@
+export function setPrototypeOf(target: any, proto: any) {
+ // tslint:disable-next-line
+ if (typeof Object.setPrototypeOf !== 'undefined') {
+ Object.setPrototypeOf(target, proto); // tslint:disable-line
+ } else {
+ target.__proto__ = proto;
+ }
+}
diff --git a/packages/designer/src/utils/shallow-equal.ts b/packages/designer/src/utils/shallow-equal.ts
new file mode 100644
index 000000000..c7fdb9cb1
--- /dev/null
+++ b/packages/designer/src/utils/shallow-equal.ts
@@ -0,0 +1,27 @@
+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
new file mode 100644
index 000000000..fb7381cbf
--- /dev/null
+++ b/packages/designer/src/utils/style-point.ts
@@ -0,0 +1,55 @@
+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/throttle.ts b/packages/designer/src/utils/throttle.ts
new file mode 100644
index 000000000..c426837d6
--- /dev/null
+++ b/packages/designer/src/utils/throttle.ts
@@ -0,0 +1,100 @@
+const useRAF = typeof requestAnimationFrame === 'function';
+
+export function throttle(func: Function, delay: number) {
+ let lastArgs: any;
+ let lastThis: any;
+ let result: any;
+ let timerId: number | undefined;
+ let lastCalled: number | undefined;
+ let lastInvoked = 0;
+
+ function invoke(time: number) {
+ const args = lastArgs;
+ const thisArg = lastThis;
+
+ lastArgs = undefined;
+ lastThis = undefined;
+ lastInvoked = time;
+ result = func.apply(thisArg, args);
+ return result;
+ }
+
+ function startTimer(pendingFunc: any, wait: number): number {
+ if (useRAF) {
+ return requestAnimationFrame(pendingFunc);
+ }
+ return setTimeout(pendingFunc, wait) as any;
+ }
+
+ function leadingEdge(time: number) {
+ lastInvoked = time;
+ timerId = startTimer(timerExpired, delay);
+ return invoke(time);
+ }
+
+ function shouldInvoke(time: number) {
+ const timeSinceLastCalled = time - lastCalled!;
+ const timeSinceLastInvoked = time - lastInvoked;
+
+ return (
+ lastCalled === undefined ||
+ timeSinceLastCalled >= delay ||
+ timeSinceLastCalled < 0 ||
+ timeSinceLastInvoked >= delay
+ );
+ }
+
+ function remainingWait(time: number) {
+ const timeSinceLastCalled = time - lastCalled!;
+ const timeSinceLastInvoked = time - lastInvoked;
+
+ return Math.min(delay - timeSinceLastCalled, delay - timeSinceLastInvoked);
+ }
+
+ function timerExpired() {
+ const time = Date.now();
+ if (shouldInvoke(time)) {
+ return trailingEdge(time);
+ }
+
+ timerId = startTimer(timerExpired, remainingWait(time));
+ }
+
+ function trailingEdge(time: number) {
+ timerId = undefined;
+
+ if (lastArgs) {
+ return invoke(time);
+ }
+
+ lastArgs = undefined;
+ lastThis = undefined;
+ return result;
+ }
+
+ function debounced(this: any, ...args: any[]) {
+ const time = Date.now();
+ const isInvoking = shouldInvoke(time);
+
+ lastArgs = args;
+ lastThis = this;
+ lastCalled = time;
+
+ if (isInvoking) {
+ if (timerId === undefined) {
+ return leadingEdge(lastCalled);
+ }
+
+ timerId = startTimer(timerExpired, delay);
+ return invoke(lastCalled);
+ }
+
+ if (timerId === undefined) {
+ timerId = startTimer(timerExpired, delay);
+ }
+
+ return result;
+ }
+
+ return debounced;
+}
diff --git a/packages/designer/src/utils/type-check.ts b/packages/designer/src/utils/type-check.ts
new file mode 100644
index 000000000..ea1aacfeb
--- /dev/null
+++ b/packages/designer/src/utils/type-check.ts
@@ -0,0 +1,11 @@
+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;
+}
diff --git a/packages/designer/src/utils/unique-id.ts b/packages/designer/src/utils/unique-id.ts
new file mode 100644
index 000000000..3713cbd06
--- /dev/null
+++ b/packages/designer/src/utils/unique-id.ts
@@ -0,0 +1,4 @@
+let guid = Date.now();
+export function uniqueId(prefix = '') {
+ return `${prefix}${(guid++).toString(36).toLowerCase()}`;
+}
diff --git a/packages/designer/src/utils/value-to-source.ts b/packages/designer/src/utils/value-to-source.ts
new file mode 100644
index 000000000..40cbecd61
--- /dev/null
+++ b/packages/designer/src/utils/value-to-source.ts
@@ -0,0 +1,232 @@
+function propertyNameRequiresQuotes(propertyName: string) {
+ try {
+ const context = {
+ worksWithoutQuotes: false,
+ };
+
+ new Function('ctx', `ctx.worksWithoutQuotes = {${propertyName}: true}['${propertyName}']`)();
+
+ return !context.worksWithoutQuotes;
+ } catch (ex) {
+ return true;
+ }
+}
+
+function quoteString(str: string, { doubleQuote }: any) {
+ return doubleQuote ? `"${str.replace(/"/gu, '\\"')}"` : `'${str.replace(/'/gu, "\\'")}'`;
+}
+
+export function valueToSource(
+ value: any,
+ {
+ circularReferenceToken = 'CIRCULAR_REFERENCE',
+ doubleQuote = true,
+ includeFunctions = true,
+ includeUndefinedProperties = false,
+ indentLevel = 0,
+ indentString = ' ',
+ lineEnding = '\n',
+ visitedObjects = new Set(),
+ }: any = {},
+): any {
+ switch (typeof value) {
+ case 'boolean':
+ return value ? `${indentString.repeat(indentLevel)}true` : `${indentString.repeat(indentLevel)}false`;
+ case 'function':
+ if (includeFunctions) {
+ return `${indentString.repeat(indentLevel)}${value}`;
+ }
+ return null;
+ case 'number':
+ return `${indentString.repeat(indentLevel)}${value}`;
+ case 'object':
+ if (!value) {
+ return `${indentString.repeat(indentLevel)}null`;
+ }
+
+ if (visitedObjects.has(value)) {
+ return `${indentString.repeat(indentLevel)}${circularReferenceToken}`;
+ }
+
+ if (value instanceof Date) {
+ return `${indentString.repeat(indentLevel)}new Date(${quoteString(value.toISOString(), {
+ doubleQuote,
+ })})`;
+ }
+
+ if (value instanceof Map) {
+ return value.size
+ ? `${indentString.repeat(indentLevel)}new Map(${valueToSource([...value], {
+ circularReferenceToken,
+ doubleQuote,
+ includeFunctions,
+ includeUndefinedProperties,
+ indentLevel,
+ indentString,
+ lineEnding,
+ visitedObjects: new Set([value, ...visitedObjects]),
+ }).substr(indentLevel * indentString.length)})`
+ : `${indentString.repeat(indentLevel)}new Map()`;
+ }
+
+ if (value instanceof RegExp) {
+ return `${indentString.repeat(indentLevel)}/${value.source}/${value.flags}`;
+ }
+
+ if (value instanceof Set) {
+ return value.size
+ ? `${indentString.repeat(indentLevel)}new Set(${valueToSource([...value], {
+ circularReferenceToken,
+ doubleQuote,
+ includeFunctions,
+ includeUndefinedProperties,
+ indentLevel,
+ indentString,
+ lineEnding,
+ visitedObjects: new Set([value, ...visitedObjects]),
+ }).substr(indentLevel * indentString.length)})`
+ : `${indentString.repeat(indentLevel)}new Set()`;
+ }
+
+ if (Array.isArray(value)) {
+ if (!value.length) {
+ return `${indentString.repeat(indentLevel)}[]`;
+ }
+
+ const itemsStayOnTheSameLine = value.every(
+ item =>
+ typeof item === 'object' &&
+ item &&
+ !(item instanceof Date) &&
+ !(item instanceof Map) &&
+ !(item instanceof RegExp) &&
+ !(item instanceof Set) &&
+ (Object.keys(item).length || value.length === 1),
+ );
+
+ let previousIndex: number | null = null;
+
+ value = value.reduce((items, item, index) => {
+ if (previousIndex !== null) {
+ for (let i = index - previousIndex - 1; i > 0; i -= 1) {
+ items.push(indentString.repeat(indentLevel + 1));
+ }
+ }
+
+ previousIndex = index;
+
+ item = valueToSource(item, {
+ circularReferenceToken,
+ doubleQuote,
+ includeFunctions,
+ includeUndefinedProperties,
+ indentLevel: itemsStayOnTheSameLine ? indentLevel : indentLevel + 1,
+ indentString,
+ lineEnding,
+ visitedObjects: new Set([value, ...visitedObjects]),
+ });
+
+ if (item === null) {
+ items.push(indentString.repeat(indentLevel + 1));
+ } else if (itemsStayOnTheSameLine) {
+ items.push(item.substr(indentLevel * indentString.length));
+ } else {
+ items.push(item);
+ }
+
+ return items;
+ }, []);
+
+ return itemsStayOnTheSameLine
+ ? `${indentString.repeat(indentLevel)}[${value.join(', ')}]`
+ : `${indentString.repeat(indentLevel)}[${lineEnding}${value.join(
+ `,${lineEnding}`,
+ )}${lineEnding}${indentString.repeat(indentLevel)}]`;
+ }
+
+ value = Object.keys(value).reduce((entries, propertyName) => {
+ const propertyValue = value[propertyName],
+ propertyValueString =
+ typeof propertyValue !== 'undefined' || includeUndefinedProperties
+ ? valueToSource(value[propertyName], {
+ circularReferenceToken,
+ doubleQuote,
+ includeFunctions,
+ includeUndefinedProperties,
+ indentLevel: indentLevel + 1,
+ indentString,
+ lineEnding,
+ visitedObjects: new Set([value, ...visitedObjects]),
+ })
+ : null;
+
+ if (propertyValueString) {
+ const quotedPropertyName = propertyNameRequiresQuotes(propertyName)
+ ? quoteString(propertyName, {
+ doubleQuote,
+ })
+ : propertyName,
+ trimmedPropertyValueString = propertyValueString.substr((indentLevel + 1) * indentString.length);
+
+ if (typeof propertyValue === 'function' && trimmedPropertyValueString.startsWith(`${propertyName}()`)) {
+ entries.push(
+ `${indentString.repeat(indentLevel + 1)}${quotedPropertyName} ${trimmedPropertyValueString.substr(
+ propertyName.length,
+ )}`,
+ );
+ } else {
+ entries.push(`${indentString.repeat(indentLevel + 1)}${quotedPropertyName}: ${trimmedPropertyValueString}`);
+ }
+ }
+
+ return entries;
+ }, []);
+
+ return value.length
+ ? `${indentString.repeat(indentLevel)}{${lineEnding}${value.join(
+ `,${lineEnding}`,
+ )}${lineEnding}${indentString.repeat(indentLevel)}}`
+ : `${indentString.repeat(indentLevel)}{}`;
+ case 'string':
+ return `${indentString.repeat(indentLevel)}${quoteString(value, {
+ doubleQuote,
+ })}`;
+ case 'symbol': {
+ let key = Symbol.keyFor(value);
+
+ if (typeof key === 'string') {
+ return `${indentString.repeat(indentLevel)}Symbol.for(${quoteString(key, {
+ doubleQuote,
+ })})`;
+ }
+
+ key = value.toString().slice(7, -1);
+
+ if (key) {
+ return `${indentString.repeat(indentLevel)}Symbol(${quoteString(key, {
+ doubleQuote,
+ })})`;
+ }
+
+ return `${indentString.repeat(indentLevel)}Symbol()`;
+ }
+ case 'undefined':
+ return `${indentString.repeat(indentLevel)}undefined`;
+ }
+}
+
+export function getSource(value: any): string {
+ if (value && value.__source) {
+ return value.__source;
+ }
+ let source = valueToSource(value);
+ if (source === 'undefined') {
+ source = '';
+ }
+ if (value) {
+ try {
+ value.__source = source;
+ } catch (ex) {}
+ }
+ return source;
+}
diff --git a/packages/designer/tsconfig.json b/packages/designer/tsconfig.json
new file mode 100644
index 000000000..aad669598
--- /dev/null
+++ b/packages/designer/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./node_modules/@recore/config/tsconfig",
+ "compilerOptions": {
+ "experimentalDecorators": true
+ },
+ "include": [
+ "./src/"
+ ]
+}