mirror of
https://github.com/alibaba/lowcode-engine.git
synced 2026-03-01 05:30:40 +00:00
571 lines
16 KiB
TypeScript
571 lines
16 KiB
TypeScript
import React, { createElement, ReactInstance } from 'react';
|
|
import { render as reactRender } from 'react-dom';
|
|
import { host } from './host';
|
|
import SimulatorRendererView from './renderer-view';
|
|
import { computed, obx } from '@recore/obx';
|
|
import { getClientRects } from './utils/get-client-rects';
|
|
import { reactFindDOMNodes, FIBER_KEY } from './utils/react-find-dom-nodes';
|
|
import {
|
|
Asset,
|
|
isElement,
|
|
cursor,
|
|
setNativeSelection,
|
|
buildComponents,
|
|
getSubComponent,
|
|
} from '@ali/lowcode-utils';
|
|
import { RootSchema, ComponentSchema, TransformStage, NodeSchema } from '@ali/lowcode-types';
|
|
// import { isESModule, isElement, acceptsRef, wrapReactClass, cursor, setNativeSelection } from '@ali/lowcode-utils';
|
|
// import { RootSchema, NpmInfo, ComponentSchema, TransformStage, NodeSchema } from '@ali/lowcode-types';
|
|
// just use types
|
|
import { BuiltinSimulatorRenderer, NodeInstance, Component, DocumentModel } from '@ali/lowcode-designer';
|
|
import { createMemoryHistory, MemoryHistory } from 'history';
|
|
import Slot from './builtin-components/slot';
|
|
import Leaf from './builtin-components/leaf';
|
|
import { withQueryParams, parseQuery } from './utils/url';
|
|
|
|
export class DocumentInstance {
|
|
private instancesMap = new Map<string, ReactInstance[]>();
|
|
|
|
@obx.ref private _schema?: RootSchema;
|
|
@computed get schema(): any {
|
|
return this._schema;
|
|
}
|
|
|
|
private dispose?: () => void;
|
|
|
|
constructor(readonly container: SimulatorRendererContainer, readonly document: DocumentModel) {
|
|
this.dispose = host.autorun(() => {
|
|
// sync schema
|
|
this._schema = document.export(1);
|
|
});
|
|
}
|
|
|
|
// private _libraryMap: { [key: string]: string } = {};
|
|
// private buildComponents() {
|
|
// this._components = {
|
|
// ...builtinComponents,
|
|
// ...buildComponents(this._libraryMap, this._componentsMap),
|
|
// };
|
|
// }
|
|
@obx.ref private _components: any = {};
|
|
@computed get components(): object {
|
|
// 根据 device 选择不同组件,进行响应式
|
|
// 更好的做法是,根据 device 选择加载不同的组件资源,甚至是 simulatorUrl
|
|
return this._components;
|
|
}
|
|
// context from: utils、constants、history、location、match
|
|
@obx.ref private _appContext = {};
|
|
@computed get context(): any {
|
|
return this._appContext;
|
|
}
|
|
@obx.ref private _designMode = 'design';
|
|
@computed get designMode(): any {
|
|
return this._designMode;
|
|
}
|
|
@obx.ref private _device = 'default';
|
|
@computed get device() {
|
|
return this._device;
|
|
}
|
|
@obx.ref private _componentsMap = {};
|
|
@computed get componentsMap(): any {
|
|
return this._componentsMap;
|
|
}
|
|
@computed get suspended(): any {
|
|
return false;
|
|
}
|
|
@computed get scope(): any {
|
|
return null;
|
|
}
|
|
|
|
get path(): string {
|
|
return '/' + this.document.fileName;
|
|
}
|
|
|
|
get id() {
|
|
return this.document.id;
|
|
}
|
|
|
|
private unmountIntance(id: string, instance: ReactInstance) {
|
|
const instances = this.instancesMap.get(id);
|
|
if (instances) {
|
|
const i = instances.indexOf(instance);
|
|
if (i > -1) {
|
|
instances.splice(i, 1);
|
|
host.setInstance(this.document.id, id, instances);
|
|
}
|
|
}
|
|
}
|
|
|
|
mountInstance(id: string, instance: ReactInstance | null) {
|
|
const docId = this.document.id;
|
|
const instancesMap = this.instancesMap;
|
|
if (instance == null) {
|
|
let instances = this.instancesMap.get(id);
|
|
if (instances) {
|
|
instances = instances.filter(checkInstanceMounted);
|
|
if (instances.length > 0) {
|
|
instancesMap.set(id, instances);
|
|
host.setInstance(this.document.id, id, instances);
|
|
} else {
|
|
instancesMap.delete(id);
|
|
host.setInstance(this.document.id, id, null);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
const unmountIntance = this.unmountIntance.bind(this);
|
|
const origId = (instance as any)[SYMBOL_VNID];
|
|
if (origId && origId !== id) {
|
|
// 另外一个节点的 instance 在此被复用了,需要从原来地方卸载
|
|
unmountIntance(origId, instance);
|
|
}
|
|
if (isElement(instance)) {
|
|
cacheReactKey(instance);
|
|
} else if (origId !== id) {
|
|
// 涵盖 origId == null || origId !== id 的情况
|
|
let origUnmount: any = instance.componentWillUnmount;
|
|
if (origUnmount && origUnmount.origUnmount) {
|
|
origUnmount = origUnmount.origUnmount;
|
|
}
|
|
// hack! delete instance from map
|
|
const newUnmount = function(this: any) {
|
|
unmountIntance(id, instance);
|
|
origUnmount && origUnmount.call(this);
|
|
};
|
|
(newUnmount as any).origUnmount = origUnmount;
|
|
instance.componentWillUnmount = newUnmount;
|
|
}
|
|
|
|
(instance as any)[SYMBOL_VNID] = id;
|
|
(instance as any)[SYMBOL_VDID] = docId;
|
|
let instances = this.instancesMap.get(id);
|
|
if (instances) {
|
|
const l = instances.length;
|
|
instances = instances.filter(checkInstanceMounted);
|
|
let updated = instances.length !== l;
|
|
if (!instances.includes(instance)) {
|
|
instances.push(instance);
|
|
updated = true;
|
|
}
|
|
if (!updated) {
|
|
return;
|
|
}
|
|
} else {
|
|
instances = [instance];
|
|
}
|
|
instancesMap.set(id, instances);
|
|
host.setInstance(this.document.id, id, instances);
|
|
}
|
|
|
|
mountContext(docId: string, id: string, ctx: object) {
|
|
// this.ctxMap.set(id, ctx);
|
|
}
|
|
|
|
getNode(id: string): Node | null {
|
|
return this.document.getNode(id);
|
|
}
|
|
}
|
|
|
|
export class SimulatorRendererContainer implements BuiltinSimulatorRenderer {
|
|
readonly isSimulatorRenderer = true;
|
|
private dispose?: () => void;
|
|
readonly history: MemoryHistory;
|
|
|
|
@obx.ref private _documentInstances: DocumentInstance[] = [];
|
|
get documentInstances() {
|
|
return this._documentInstances;
|
|
}
|
|
|
|
constructor() {
|
|
this.dispose = host.connect(this, async () => {
|
|
await host.waitForCurrentDocument();
|
|
// sync layout config
|
|
this._layout = host.project.get('config').layout;
|
|
|
|
// todo: split with others, not all should recompute
|
|
if (this._libraryMap !== host.libraryMap || this._componentsMap !== host.designer.componentsMap) {
|
|
this._libraryMap = host.libraryMap || {};
|
|
this._componentsMap = host.designer.componentsMap;
|
|
this.buildComponents();
|
|
}
|
|
|
|
// sync designMode
|
|
this._designMode = host.designMode;
|
|
|
|
// sync device
|
|
this._device = host.device;
|
|
});
|
|
const documentInstanceMap = new Map<string, DocumentInstance>();
|
|
let initialEntry = '/';
|
|
host.autorun(({ firstRun }) => {
|
|
this._documentInstances = host.project.documents.map((doc) => {
|
|
let inst = documentInstanceMap.get(doc.id);
|
|
if (!inst) {
|
|
inst = new DocumentInstance(this, doc);
|
|
documentInstanceMap.set(doc.id, inst);
|
|
}
|
|
return inst;
|
|
});
|
|
const path = host.project.currentDocument
|
|
? documentInstanceMap.get(host.project.currentDocument.id)!.path
|
|
: '/';
|
|
if (firstRun) {
|
|
initialEntry = path;
|
|
} else {
|
|
if (this.history.location.pathname !== path) {
|
|
this.history.replace(path);
|
|
}
|
|
}
|
|
});
|
|
const history = createMemoryHistory({
|
|
initialEntries: [initialEntry],
|
|
});
|
|
this.history = history;
|
|
history.listen((location, action) => {
|
|
host.project.open(location.pathname.substr(1));
|
|
});
|
|
host.componentsConsumer.consume(async (componentsAsset) => {
|
|
if (componentsAsset) {
|
|
await this.load(componentsAsset);
|
|
this.buildComponents();
|
|
}
|
|
});
|
|
this._appContext = {
|
|
utils: {
|
|
router: {
|
|
push(path: string, params?: object) {
|
|
history.push(withQueryParams(path, params));
|
|
},
|
|
replace(path: string, params?: object) {
|
|
history.replace(withQueryParams(path, params));
|
|
},
|
|
},
|
|
legaoBuiltins: {
|
|
getUrlParams() {
|
|
const search = history.location.search;
|
|
return parseQuery(search);
|
|
}
|
|
}
|
|
},
|
|
constants: {},
|
|
};
|
|
host.injectionConsumer.consume((data) => {
|
|
// sync utils, i18n, contants,... config
|
|
});
|
|
}
|
|
|
|
@obx private _layout: any = null;
|
|
|
|
@computed get layout(): any {
|
|
// TODO: parse layout Component
|
|
return this._layout;
|
|
}
|
|
|
|
set layout(value: any) {
|
|
this._layout = value;
|
|
}
|
|
|
|
private _libraryMap: { [key: string]: string } = {};
|
|
|
|
private buildComponents() {
|
|
// TODO: remove this.createComponent
|
|
this._components = buildComponents(this._libraryMap, this._componentsMap, this.createComponent.bind(this));
|
|
this._components = {
|
|
...builtinComponents,
|
|
...this._components,
|
|
};
|
|
}
|
|
@obx.ref private _components: any = {};
|
|
|
|
@computed get components(): object {
|
|
// 根据 device 选择不同组件,进行响应式
|
|
// 更好的做法是,根据 device 选择加载不同的组件资源,甚至是 simulatorUrl
|
|
return this._components;
|
|
}
|
|
// context from: utils、constants、history、location、match
|
|
@obx.ref private _appContext = {};
|
|
@computed get context(): any {
|
|
return this._appContext;
|
|
}
|
|
@obx.ref private _designMode: string = 'design';
|
|
@computed get designMode(): any {
|
|
return this._designMode;
|
|
}
|
|
@obx.ref private _device: string = 'default';
|
|
@computed get device() {
|
|
return this._device;
|
|
}
|
|
@obx.ref private _componentsMap = {};
|
|
@computed get componentsMap(): any {
|
|
return this._componentsMap;
|
|
}
|
|
/**
|
|
* 加载资源
|
|
*/
|
|
load(asset: Asset): Promise<any> {
|
|
return loader.load(asset);
|
|
}
|
|
|
|
getComponent(componentName: string) {
|
|
const paths = componentName.split('.');
|
|
const subs: string[] = [];
|
|
|
|
while (true) {
|
|
const component = this._components[componentName];
|
|
if (component) {
|
|
return getSubComponent(component, subs);
|
|
}
|
|
|
|
const sub = paths.pop();
|
|
if (!sub) {
|
|
return null;
|
|
}
|
|
subs.unshift(sub);
|
|
componentName = paths.join('.');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
getClosestNodeInstance(from: ReactInstance, nodeId?: string): NodeInstance<ReactInstance> | null {
|
|
return getClosestNodeInstance(from, nodeId);
|
|
}
|
|
|
|
findDOMNodes(instance: ReactInstance): Array<Element | Text> | null {
|
|
return reactFindDOMNodes(instance);
|
|
}
|
|
|
|
getClientRects(element: Element | Text) {
|
|
return getClientRects(element);
|
|
}
|
|
|
|
setNativeSelection(enableFlag: boolean) {
|
|
setNativeSelection(enableFlag);
|
|
}
|
|
setDraggingState(state: boolean) {
|
|
cursor.setDragging(state);
|
|
}
|
|
setCopyState(state: boolean) {
|
|
cursor.setCopy(state);
|
|
}
|
|
clearState() {
|
|
cursor.release();
|
|
}
|
|
|
|
createComponent(schema: NodeSchema): Component | null {
|
|
const _schema: any = {
|
|
...schema,
|
|
};
|
|
_schema.methods = {};
|
|
_schema.lifeCycles = {};
|
|
|
|
if (schema.componentName === 'Component' && (schema as ComponentSchema).css) {
|
|
const doc = window.document;
|
|
const s = doc.createElement('style');
|
|
s.setAttribute('type', 'text/css');
|
|
s.setAttribute('id', `Component-${schema.id || ''}`);
|
|
s.appendChild(doc.createTextNode((schema as ComponentSchema).css || ''));
|
|
doc.getElementsByTagName('head')[0].appendChild(s);
|
|
}
|
|
|
|
// const node = host.currentDocument?.createNode(_schema);
|
|
// _schema = node?.export(TransformStage.Render) || {};
|
|
|
|
|
|
|
|
const renderer = this;
|
|
const { componentsMap } = renderer;
|
|
|
|
return getComponentController(schema, componentsMap);
|
|
}
|
|
|
|
private _running = false;
|
|
|
|
run() {
|
|
if (this._running) {
|
|
return;
|
|
}
|
|
this._running = true;
|
|
const containerId = 'app';
|
|
let container = document.getElementById(containerId);
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
container.id = containerId;
|
|
}
|
|
|
|
// ==== compatiable vision
|
|
document.documentElement.classList.add('engine-page');
|
|
document.body.classList.add('engine-document'); // important! Stylesheet.invoke depends
|
|
|
|
reactRender(createElement(SimulatorRendererView, { rendererContainer: this }), container);
|
|
host.project.setRendererReady(this);
|
|
}
|
|
}
|
|
|
|
// Slot/Leaf and Fragment|FunctionComponent polyfill(ref)
|
|
|
|
const builtinComponents = {
|
|
Slot,
|
|
Leaf,
|
|
};
|
|
|
|
let REACT_KEY = '';
|
|
function cacheReactKey(el: Element): Element {
|
|
if (REACT_KEY !== '') {
|
|
return el;
|
|
}
|
|
REACT_KEY = Object.keys(el).find((key) => key.startsWith('__reactInternalInstance$')) || '';
|
|
if (!REACT_KEY && (el as HTMLElement).parentElement) {
|
|
return cacheReactKey((el as HTMLElement).parentElement!);
|
|
}
|
|
return el;
|
|
}
|
|
|
|
const SYMBOL_VNID = Symbol('_LCNodeId');
|
|
const SYMBOL_VDID = Symbol('_LCDocId');
|
|
|
|
function getClosestNodeInstance(from: ReactInstance, specId?: string): NodeInstance<ReactInstance> | null {
|
|
let el: any = from;
|
|
if (el) {
|
|
if (isElement(el)) {
|
|
el = cacheReactKey(el);
|
|
} else {
|
|
return getNodeInstance(el[FIBER_KEY], specId);
|
|
}
|
|
}
|
|
while (el) {
|
|
if (SYMBOL_VNID in el) {
|
|
const nodeId = el[SYMBOL_VNID];
|
|
const docId = el[SYMBOL_VDID];
|
|
if (!specId || specId === nodeId) {
|
|
return {
|
|
docId,
|
|
nodeId,
|
|
instance: el,
|
|
};
|
|
}
|
|
}
|
|
// get fiberNode from element
|
|
if (el[REACT_KEY]) {
|
|
return getNodeInstance(el[REACT_KEY], specId);
|
|
}
|
|
el = el.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getNodeInstance(fiberNode: any, specId?: string): NodeInstance<ReactInstance> | null {
|
|
const instance = fiberNode?.stateNode;
|
|
if (instance && SYMBOL_VNID in instance) {
|
|
const nodeId = instance[SYMBOL_VNID];
|
|
const docId = instance[SYMBOL_VDID];
|
|
if (!specId || specId === nodeId) {
|
|
return {
|
|
docId,
|
|
nodeId,
|
|
instance,
|
|
};
|
|
}
|
|
}
|
|
return getNodeInstance(fiberNode?.return);
|
|
}
|
|
|
|
function checkInstanceMounted(instance: any): boolean {
|
|
if (isElement(instance)) {
|
|
return instance.parentElement != null;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const processPropsSchema = (propsSchema: any, propsMap: any, componentsMap: any): any => {
|
|
if (!propsSchema) {
|
|
return {};
|
|
}
|
|
|
|
const result = { ...propsSchema };
|
|
const reg = /^(?:this\.props|props)\.(\S+)$/;
|
|
Object.keys(result).map((key: string) => {
|
|
if (result[key]?.type === 'JSExpression') {
|
|
const { value } = result[key];
|
|
const matched = reg.exec(value);
|
|
if (matched) {
|
|
const propName = matched[1];
|
|
result[key] = propsMap[propName];
|
|
}
|
|
} else if (result[key]?.type === 'JSSlot') {
|
|
const schema = result[key].value;
|
|
result[key] = createElement(ComponentCreator, { schema, propsMap: {}, componentsMap });
|
|
}
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
class ComponentCreator extends React.Component<{ schema: any; propsMap: any, componentsMap: any }> {
|
|
private isModal: boolean;
|
|
|
|
constructor(props: any) {
|
|
super(props);
|
|
const componentMeta = host.currentDocument?.getComponentMeta(props.schema.componentName);
|
|
if (componentMeta?.isModal) {
|
|
this.isModal = true;
|
|
}
|
|
}
|
|
|
|
render() {
|
|
if (this.isModal) {
|
|
return null;
|
|
}
|
|
const { schema, propsMap } = this.props;
|
|
const componentsMap = this.props.componentsMap;
|
|
const ComponentClass = componentsMap[schema.componentName];
|
|
if (!ComponentClass) {
|
|
return null;
|
|
}
|
|
let children = null;
|
|
if (schema.children && schema.children.length > 0) {
|
|
children = schema.children.map((item: any) => createElement(ComponentCreator, { schema: item, propsMap, componentsMap }));
|
|
}
|
|
const props = processPropsSchema(schema.props, propsMap, componentsMap);
|
|
const _leaf = host.currentDocument?.createNode(schema);
|
|
|
|
return createElement(ComponentClass, { ...props, _leaf }, children);
|
|
}
|
|
}
|
|
|
|
function getComponentController(schema: NodeSchema, componentsMap: any) {
|
|
class ComponentController extends React.Component<{ schema: any }> {
|
|
renderSchema: any;
|
|
|
|
constructor(props: any) {
|
|
super(props);
|
|
const node = host.currentDocument?.createNode(schema);
|
|
this.renderSchema = node?.export(TransformStage.Render) || {};
|
|
}
|
|
|
|
// TODO: 暂时解决性能问题
|
|
shouldComponentUpdate() {
|
|
return false;
|
|
}
|
|
|
|
render() {
|
|
const { renderSchema } = this;
|
|
const { componentName } = renderSchema;
|
|
if (componentName === 'Component') {
|
|
let children = [] as any;
|
|
const propsMap = this.props || {};
|
|
if (renderSchema.children && Array.isArray(renderSchema.children)) {
|
|
children = renderSchema.children.map((item: any) => createElement(ComponentCreator, { schema: item, propsMap, componentsMap }));
|
|
}
|
|
return createElement('div', {}, children);
|
|
} else {
|
|
return createElement(ComponentCreator, { schema, propsMap: {}, componentsMap });
|
|
}
|
|
}
|
|
}
|
|
return ComponentController;
|
|
}
|
|
|
|
export default new SimulatorRendererContainer();
|