mirror of
https://github.com/NetEase/tango.git
synced 2026-08-30 16:55:35 +08:00
chore: init open source repo
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": ["eslint-config-ali/typescript/react", "prettier"],
|
||||
"ignorePatterns": ["**/dist/**/*", "**/lib/**/*", "**/node_modules/**/*", "scripts/**/*"],
|
||||
"rules": {
|
||||
"import/no-cycle": "off",
|
||||
"no-nested-ternary": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
lib/
|
||||
coverage/
|
||||
|
||||
.umi/
|
||||
.umi-production/
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
|
||||
|
||||
addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
|
||||
|
||||
babel: async (config) => {
|
||||
config.plugins.push('babel-plugin-styled-components');
|
||||
return config;
|
||||
},
|
||||
|
||||
typescript: {
|
||||
reactDocgen: false,
|
||||
},
|
||||
|
||||
webpack: async (config) => {
|
||||
if (config.mode === 'production') {
|
||||
config.devtool = false;
|
||||
}
|
||||
|
||||
if (config.resolve.plugins === null) {
|
||||
config.resolve.plugins = [];
|
||||
}
|
||||
|
||||
config.resolve.plugins.push(new TsconfigPathsPlugin());
|
||||
|
||||
// @see https://github.com/graphql/graphql-js/issues/1272#issuecomment-393903706
|
||||
config.module.rules.push({
|
||||
test: /\.mjs$/,
|
||||
include: /node_modules/,
|
||||
type: 'javascript/auto',
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<!--insert head here-->
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { SystemProvider } from 'coral-system';
|
||||
import 'antd/dist/antd.css';
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const withSystemProvider = (Story, context) => {
|
||||
return (
|
||||
<SystemProvider prefix="--tango">
|
||||
<Story {...context} />
|
||||
</SystemProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const decorators = [withSystemProvider];
|
||||
@@ -0,0 +1,3 @@
|
||||
# `docs`
|
||||
|
||||
开发调试用的文档
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "0.0.0",
|
||||
"private": "true",
|
||||
"description": "> tango-apps docs",
|
||||
"license": "MIT",
|
||||
"author": "wwsun <sunweiwei01@corp.netease.com>",
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "echo \"skip\"",
|
||||
"build-storybook": "build-storybook",
|
||||
"storybook": "start-storybook -p 6008"
|
||||
},
|
||||
"dependencies": {
|
||||
"@music163/tango-setting-form": "*",
|
||||
"@music163/tango-ui": "*",
|
||||
"mobx": "6.9.0",
|
||||
"mobx-react-lite": "4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "^6.5.14",
|
||||
"@storybook/addon-essentials": "^6.5.14",
|
||||
"@storybook/addon-links": "^6.5.14",
|
||||
"@storybook/react": "^6.5.14",
|
||||
"babel-plugin-styled-components": "^2.0.7",
|
||||
"tsconfig-paths-webpack-plugin": "^3.5.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { Box } from 'coral-system';
|
||||
import { SingleMonacoEditor } from '@music163/tango-ui';
|
||||
|
||||
export default {
|
||||
title: 'Editor',
|
||||
};
|
||||
|
||||
const code = `
|
||||
import React from 'react';
|
||||
import { definePage } from '@music163/tango-boot';
|
||||
import { Layout, Page, Section, Button } from '@music/tango-cms';
|
||||
|
||||
function About(props) {
|
||||
const { stores } = props;
|
||||
|
||||
const increment = () => {
|
||||
stores.counter.increment();
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Page title="About Page" height="100vh">
|
||||
<Section>
|
||||
<h1>Counter: {stores.counter.num}</h1>
|
||||
<Button type="primary" onClick={increment}>
|
||||
+1
|
||||
</Button>
|
||||
<p>原生html元素不可拖拽</p>
|
||||
</Section>
|
||||
</Page>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePage(About);
|
||||
`;
|
||||
|
||||
export function Basic() {
|
||||
return (
|
||||
<Box border="solid" borderColor="line.normal" height="400px">
|
||||
<SingleMonacoEditor defaultValue={code} height="100%" onBlur={console.log} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import React, { useState } from 'react';
|
||||
import { EditableVariableTree, FormModel, SettingForm } from '@music163/tango-setting-form';
|
||||
import { ComponentPrototypeType, getValue } from '@music163/tango-helpers';
|
||||
import { createServices } from '@music163/request';
|
||||
import { Box } from 'coral-system';
|
||||
import { JsonView } from '@music163/tango-ui';
|
||||
import { toJS } from 'mobx';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Card } from 'antd';
|
||||
|
||||
export default {
|
||||
title: 'SettingForm/ Setters',
|
||||
};
|
||||
|
||||
const modelVariables = [
|
||||
{
|
||||
title: 'stores',
|
||||
key: 'stores',
|
||||
selectable: false,
|
||||
children: [
|
||||
{
|
||||
title: 'app',
|
||||
key: 'stores.app',
|
||||
selectable: false,
|
||||
children: [
|
||||
{ title: 'app.title', key: 'stores.app.title', raw: '"hello"' },
|
||||
{ title: 'app.age', key: 'stores.app.age', raw: '20' },
|
||||
{ title: 'app.detail', key: 'stores.app.detail', raw: '{ foo: "foo" }' },
|
||||
{ title: 'app.list', key: 'stores.app.list', type: 'function', raw: '() => {}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'user',
|
||||
key: 'stores.user',
|
||||
selectable: false,
|
||||
children: [
|
||||
{ title: 'name', key: 'stores.user.name' },
|
||||
{ title: 'age', key: 'stores.user.age' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'services',
|
||||
key: 'services',
|
||||
selectable: false,
|
||||
children: [
|
||||
{
|
||||
title: 'listUsers',
|
||||
key: 'services.listUsers',
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const prototype: ComponentPrototypeType = {
|
||||
name: 'Test',
|
||||
exportType: 'namedExport',
|
||||
title: '测试',
|
||||
icon: 'icon-test',
|
||||
type: 'element',
|
||||
category: 'basic',
|
||||
package: '@music/tango-cms',
|
||||
hasChildren: false,
|
||||
props: [
|
||||
{
|
||||
name: 'text',
|
||||
title: 'textSetter',
|
||||
setter: 'textSetter',
|
||||
},
|
||||
{
|
||||
name: 'router',
|
||||
title: 'routerSetter',
|
||||
setter: 'routerSetter',
|
||||
},
|
||||
{
|
||||
name: 'object',
|
||||
title: '对象属性',
|
||||
tip: '一个嵌套的对象',
|
||||
props: [
|
||||
{
|
||||
name: 'name',
|
||||
title: 'Name',
|
||||
setter: 'textSetter',
|
||||
},
|
||||
{
|
||||
name: 'age',
|
||||
title: 'Age',
|
||||
setter: 'numberSetter',
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
title: 'Address',
|
||||
props: [
|
||||
{
|
||||
name: 'city',
|
||||
title: 'City',
|
||||
setter: 'textSetter',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
getVisible: (form) => {
|
||||
return form.getValue('text') !== 'test';
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'expression',
|
||||
title: 'expressionSetter',
|
||||
setter: 'expressionSetter',
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
title: 'modelSetter',
|
||||
setter: 'modelSetter',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
title: 'imageSetter',
|
||||
setter: 'imageSetter',
|
||||
},
|
||||
{
|
||||
name: 'css',
|
||||
title: 'cssSetter',
|
||||
setter: 'cssSetter',
|
||||
},
|
||||
{
|
||||
name: 'extra',
|
||||
title: 'jsxSetter',
|
||||
setter: 'jsxSetter',
|
||||
},
|
||||
{
|
||||
name: 'icon',
|
||||
title: 'iconSetter',
|
||||
setter: 'iconSetter',
|
||||
},
|
||||
{
|
||||
name: 'iconType',
|
||||
title: 'iconTypeSetter',
|
||||
setter: 'iconTypeSetter',
|
||||
},
|
||||
{
|
||||
name: 'onClick',
|
||||
title: 'eventSetter',
|
||||
tip: '当点击按钮时',
|
||||
setter: 'eventSetter',
|
||||
group: 'event',
|
||||
},
|
||||
{
|
||||
name: 'onClick2',
|
||||
title: 'actionSetter',
|
||||
tip: '当点击按钮时',
|
||||
setter: 'actionSetter',
|
||||
group: 'event',
|
||||
},
|
||||
{
|
||||
name: 'disabled',
|
||||
title: 'boolSetter',
|
||||
tip: 'disabled 是否禁用',
|
||||
defaultValue: false,
|
||||
setter: 'boolSetter',
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
title: 'choiceSetter',
|
||||
tip: 'size 按钮的尺寸',
|
||||
defaultValue: 'medium',
|
||||
setter: 'choiceSetter',
|
||||
setterProps: {
|
||||
options: [
|
||||
{ label: '小', value: 'small' },
|
||||
{ label: '中', value: 'medium' },
|
||||
{ label: '大', value: 'large' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
title: 'colorSetter',
|
||||
setter: 'colorSetter',
|
||||
},
|
||||
{
|
||||
name: 'columns',
|
||||
title: 'columnSetter',
|
||||
setter: 'columnSetter',
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
title: 'dateSetter',
|
||||
setter: 'dateSetter',
|
||||
},
|
||||
{
|
||||
name: 'dateRange',
|
||||
title: 'dateRangeSetter',
|
||||
setter: 'dateRangeSetter',
|
||||
},
|
||||
{
|
||||
name: 'enum',
|
||||
title: 'enumSetter',
|
||||
setter: 'enumSetter',
|
||||
setterProps: {},
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
title: 'timeSetter',
|
||||
setter: 'timeSetter',
|
||||
},
|
||||
{
|
||||
name: 'timeRange',
|
||||
title: 'timeRangeSetter',
|
||||
setter: 'timeRangeSetter',
|
||||
},
|
||||
{
|
||||
name: 'dataSource',
|
||||
title: 'jsonSetter 不推荐',
|
||||
setter: 'jsonSetter',
|
||||
},
|
||||
{
|
||||
name: 'listSetter',
|
||||
title: 'listSetter',
|
||||
setter: 'listSetter',
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
title: 'numberSetter',
|
||||
setter: 'numberSetter',
|
||||
},
|
||||
{
|
||||
name: 'options',
|
||||
title: 'optionSetter',
|
||||
setter: 'optionSetter',
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
title: 'pickerSetter',
|
||||
defaultValue: 'solid',
|
||||
setter: 'pickerSetter',
|
||||
setterProps: {
|
||||
options: [
|
||||
{ label: '文本型', value: 'text' },
|
||||
{ label: '实体型', value: 'solid' },
|
||||
{ label: '幽灵', value: 'ghost' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
title: 'textSetter',
|
||||
setter: 'textSetter',
|
||||
},
|
||||
{
|
||||
name: 'invalid',
|
||||
title: 'invalidSetter',
|
||||
setter: 'invalidSetter',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const deerService = createServices(
|
||||
{
|
||||
listMy: {
|
||||
url: '/my/upload/list',
|
||||
},
|
||||
listFav: {
|
||||
url: '/my/star/list',
|
||||
},
|
||||
listPub: {
|
||||
url: '/list',
|
||||
},
|
||||
},
|
||||
{
|
||||
baseURL: 'https://febase-openapi.fn.netease.com/deer/api/deer/pic',
|
||||
withCredentials: false, // 解决跨域时必须非*问题
|
||||
headers: {
|
||||
'Febase-Auth': 'dskPVkIRnQ2dEn1DbyxURmUj4rl4BsCh53xFAsJnvVs=',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const context = {
|
||||
stores: {
|
||||
app: {
|
||||
title: 'Sample App',
|
||||
age: 20,
|
||||
detail: {
|
||||
foo: 'foo',
|
||||
bar: 'bar',
|
||||
biz: {
|
||||
x: 'xxx',
|
||||
},
|
||||
},
|
||||
newKey: 'xxx',
|
||||
},
|
||||
foo: {
|
||||
test: 'test string',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 表单值预览
|
||||
*/
|
||||
const FormValuePreview = observer(({ model }: { model: FormModel }) => {
|
||||
const data = toJS(model.values);
|
||||
return <JsonView src={data} />;
|
||||
});
|
||||
|
||||
export function Basic() {
|
||||
const model = new FormModel(
|
||||
{
|
||||
router: 'www.163.com',
|
||||
expression: {
|
||||
foo: 'foo',
|
||||
},
|
||||
object: {
|
||||
name: 'Alice',
|
||||
},
|
||||
image:
|
||||
'https://p6.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/13270238619/2cc5/0782/1d6e/009b96bf90c557b9bbde09b1687a2c80.png',
|
||||
},
|
||||
{ onChange: console.log },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box display="flex">
|
||||
<SettingForm
|
||||
model={model}
|
||||
remoteServices={{
|
||||
ImageService: deerService as any,
|
||||
}}
|
||||
prototype={prototype}
|
||||
evaluateContext={{
|
||||
tango: context,
|
||||
__UNSAFE_TANGO_CURRENT_PAGE_RENDER_RUNTIME__: { routeData: { params: {}, query: {} } },
|
||||
}}
|
||||
modelVariables={modelVariables}
|
||||
expressionVariables={modelVariables}
|
||||
/>
|
||||
<Box position="relative">
|
||||
<Card title="表单状态预览" style={{ position: 'sticky', top: 0 }}>
|
||||
<FormValuePreview model={model} />
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoExpressionSwitch() {
|
||||
const model = new FormModel({});
|
||||
return (
|
||||
<Box>
|
||||
<SettingForm model={model} prototype={prototype} disableSwitchExpressionSetter />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function Binding() {
|
||||
const [data, setData] = useState<any>({});
|
||||
return (
|
||||
<Box>
|
||||
<Box as="code" bg="highlight" color="white" fontSize="24px">
|
||||
selected: {data?.key}
|
||||
</Box>
|
||||
<EditableVariableTree
|
||||
dataSource={modelVariables as any}
|
||||
getPreviewValue={(node) => {
|
||||
if (!node || !node.key) {
|
||||
return;
|
||||
}
|
||||
if (node.type === 'function') {
|
||||
return node.raw;
|
||||
}
|
||||
const keyPath = node.key.replaceAll('?', '');
|
||||
return getValue(context, keyPath);
|
||||
}}
|
||||
onSelect={setData}
|
||||
onAddVariable={console.log}
|
||||
onSave={console.log}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { ChatInput } from '@music163/tango-ui';
|
||||
|
||||
export default {
|
||||
title: 'UI/ChatGPT',
|
||||
};
|
||||
|
||||
export function Basic() {
|
||||
return <ChatInput />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { CopyClipboard } from '@music163/tango-ui';
|
||||
|
||||
export default {
|
||||
title: 'UI/Copy',
|
||||
};
|
||||
|
||||
export function Basic() {
|
||||
return (
|
||||
<CopyClipboard text="hello">
|
||||
{(copied) => <button>{copied ? 'copied' : 'copy'}</button>}
|
||||
</CopyClipboard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { IconButton, InputCode } from '@music163/tango-ui';
|
||||
import { BlockOutlined } from '@ant-design/icons';
|
||||
|
||||
export default {
|
||||
title: 'UI/InputCode',
|
||||
};
|
||||
|
||||
const context = {
|
||||
stores: {
|
||||
foo: {
|
||||
loading: false,
|
||||
action: () => {},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
list: () => {},
|
||||
get: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export function Basic() {
|
||||
return (
|
||||
<InputCode
|
||||
suffix={<IconButton icon={<BlockOutlined />} />}
|
||||
autoCompleteContext={{ tango: context }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Inset() {
|
||||
return <InputCode shape="inset" />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { InputList } from '@music163/tango-ui';
|
||||
|
||||
export default {
|
||||
title: 'UI/InputList',
|
||||
};
|
||||
|
||||
export function Basic() {
|
||||
const [value, setValue] = useState([]);
|
||||
return <InputList value={value} onChange={setValue} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Menu } from '@music163/tango-ui';
|
||||
|
||||
export default {
|
||||
title: 'UI/Menu',
|
||||
};
|
||||
|
||||
export function Basic() {
|
||||
return (
|
||||
<Menu
|
||||
activeKey="2"
|
||||
items={[
|
||||
{ key: '1', label: 'bob', note: 'male', deletable: true },
|
||||
{ key: '2', label: 'alice', note: 'female', deletable: true },
|
||||
{ key: '3', label: 'tom', deletable: true },
|
||||
]}
|
||||
></Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { useState } from 'react';
|
||||
import { SelectList } from '@music163/tango-ui';
|
||||
|
||||
export default {
|
||||
title: 'UI/SelectList',
|
||||
};
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'alice',
|
||||
label: 'Alice',
|
||||
},
|
||||
{
|
||||
value: 'jack',
|
||||
label: 'Jack',
|
||||
},
|
||||
{
|
||||
value: 'lucy',
|
||||
label: 'Lucy',
|
||||
},
|
||||
{
|
||||
value: 'bob',
|
||||
label: 'Bob',
|
||||
},
|
||||
];
|
||||
|
||||
export function Basic() {
|
||||
const [value, setValue] = useState([]);
|
||||
return <SelectList value={value} onChange={setValue} options={options} />;
|
||||
}
|
||||
|
||||
export function UniqueValue() {
|
||||
const [value, setValue] = useState([]);
|
||||
return <SelectList isUniqueValue value={value} onChange={setValue} options={options} />;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { TagSelect } from '@music163/tango-ui';
|
||||
|
||||
export default {
|
||||
title: 'UI/TagSelect',
|
||||
};
|
||||
|
||||
export function Basic() {
|
||||
return (
|
||||
<TagSelect
|
||||
options={['Movies', 'Books', 'Music', 'Sports'].map((item) => ({ label: item, value: item }))}
|
||||
onChange={console.log}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleMode() {
|
||||
return (
|
||||
<TagSelect
|
||||
options={['Movies', 'Books', 'Music', 'Sports'].map((item) => ({ label: item, value: item }))}
|
||||
mode="single"
|
||||
onChange={console.log}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { ToggleButton, IconFont } from '@music163/tango-ui';
|
||||
import { Box, Flex } from 'coral-system';
|
||||
|
||||
export default {
|
||||
title: 'UI/ToggleButton',
|
||||
};
|
||||
|
||||
export function Basic() {
|
||||
return (
|
||||
<Box>
|
||||
<Flex gap="l" bg="#efefef" p="l">
|
||||
<ToggleButton>
|
||||
<IconFont type="icon-undo" />
|
||||
</ToggleButton>
|
||||
<ToggleButton selected>
|
||||
<IconFont type="icon-redo" />
|
||||
</ToggleButton>
|
||||
<ToggleButton disabled>
|
||||
<IconFont type="icon-redo" />
|
||||
</ToggleButton>
|
||||
</Flex>
|
||||
<Flex gap="l" bg="#222" p="l">
|
||||
<ToggleButton shape="ghost">
|
||||
<IconFont type="icon-undo" />
|
||||
</ToggleButton>
|
||||
<ToggleButton shape="ghost" selected>
|
||||
<IconFont type="icon-redo" />
|
||||
</ToggleButton>
|
||||
<ToggleButton shape="ghost" disabled>
|
||||
<IconFont type="icon-undo" />
|
||||
</ToggleButton>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "esnext"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
PORT=6006
|
||||
HOST=local.netease.com
|
||||
@@ -0,0 +1,5 @@
|
||||
# `playground`
|
||||
|
||||
搭建平台演示示例
|
||||
|
||||
## 说明
|
||||
@@ -0,0 +1,64 @@
|
||||
import path from 'path';
|
||||
|
||||
const resolvePackageIndex = (relativeEntry: string) =>
|
||||
path.resolve(__dirname, '../../../packages/', relativeEntry);
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
exact: true,
|
||||
path: '/',
|
||||
component: 'index',
|
||||
name: '首页',
|
||||
},
|
||||
],
|
||||
devServer: {
|
||||
host: 'local.netease.com',
|
||||
port: 7007,
|
||||
https: {
|
||||
key: path.resolve(__dirname, 'local.netease.com-key.pem'),
|
||||
cert: path.resolve(__dirname, 'local.netease.com.pem'),
|
||||
},
|
||||
headers: { 'Origin-Agent-Cluster': '?0' },
|
||||
},
|
||||
targets: {
|
||||
chrome: 79,
|
||||
firefox: false,
|
||||
safari: false,
|
||||
edge: false,
|
||||
ios: false,
|
||||
},
|
||||
alias: {
|
||||
'@music163/tango-helpers': resolvePackageIndex('helpers/src/index.ts'),
|
||||
'@music163/tango-core': resolvePackageIndex('core/src/index.ts'),
|
||||
'@music163/tango-context': resolvePackageIndex('context/src/index.ts'),
|
||||
'@music163/tango-ui': resolvePackageIndex('ui/src/index.ts'),
|
||||
'@music163/tango-designer': resolvePackageIndex('designer/src/index.ts'),
|
||||
'@music163/tango-sandbox': resolvePackageIndex('sandbox/src/index.ts'),
|
||||
'@music163/tango-setting-form': resolvePackageIndex('setting-form/src/index.ts'),
|
||||
},
|
||||
externals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
'styled-components': 'styled',
|
||||
moment: 'moment',
|
||||
antd: 'antd',
|
||||
},
|
||||
chainWebpack: (config: any) => {
|
||||
// @see https://github.com/graphql/graphql-js/issues/1272#issuecomment-393903706
|
||||
config.module
|
||||
.rule('mjs')
|
||||
.test(/\.mjs$/)
|
||||
.include.add(/node_modules/)
|
||||
.end()
|
||||
.type('javascript/auto');
|
||||
config.module.rule('js').include.add(resolvePackageIndex('context/src'));
|
||||
config.module.rule('js').include.add(resolvePackageIndex('core/src'));
|
||||
config.module.rule('js').include.add(resolvePackageIndex('designer/src'));
|
||||
config.module.rule('js').include.add(resolvePackageIndex('helpers/src'));
|
||||
config.module.rule('js').include.add(resolvePackageIndex('sandbox/src'));
|
||||
config.module.rule('js').include.add(resolvePackageIndex('setting-form/src'));
|
||||
config.module.rule('js').include.add(resolvePackageIndex('ui/src'));
|
||||
return config;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4sAqDE3nhtSWD
|
||||
YqV4mlglnzIs2AioZr2uxfj/OKrpE1ZML/dflJyc5NGUpOZWV+W1vRc70wWVNFxB
|
||||
Si8nrYrrseoID7Ehxxe6+J1m/JsEbhZWHwV3M1iGW7JD7VhujaDpSLg67wMTMpSW
|
||||
3BUS+0NT+MNfCisp+u/ls4fXPCTgXiWcjMULm95oLlTeYwurPHfxwn9f9hh85I5I
|
||||
K3dI7w8MsQN7EFNrJz3UfrNLpmARMtSUBMwBYqrCitoUxRT0q7KYCru7kHxGPB6A
|
||||
SQrFpSpOWT79vp8GBSp9g3axhRsK+aEgzQ0QGrFPHBwBP/kWgmdB1URc/K7STSC9
|
||||
xyMNZOM7AgMBAAECggEATbyDYDKVbaRymr+tyHqmuYctdmSkGEXOdz8LFFoEzj/+
|
||||
ZekWpVuHJB7H/FoAPb2XiYyCuAKVafz1C+IuwPOx8o0bZbM9KmuDmIZGsm+GzDGO
|
||||
I0fBZC/vhfYYrbC8NSRV43thWCT8VVtH4cvW7vtllnWxvlIoYu4lhbiwZ68AEpO1
|
||||
8PlJsOhnagfkFpG8LehSDs9if5L+Ig0geQEcUY3pGUM9LKYsRoxFv6Vh8wQku1XD
|
||||
YOxLvLWDPqJ75m816t50RARe3e6eDJ+ukzDCoDjmLP8uhf1sk1FcL5aOgUNU3VOi
|
||||
P6sIH5uzuD0MA3FXx2mItYLOc44r4uaFJZiRUMEXGQKBgQDCjUYblE4rh0nKFkqx
|
||||
AhgfcRgpgLbP42OwFtD0u20RXdZAEJDRDQ43NdOgjFp2B50k75LBPIoFXYUZdzKj
|
||||
l7S2FH5O9Cc6hEgZGI4hCiYd+nQVI1mxv820YnzZJN4oooxyL+B5RlnW5cDT7LJN
|
||||
11Q5PRewkM+XpfbE3lKUjTNcDwKBgQDzBS+j8T9GJSzV2xgA+mpGHe1RjDq6yQtk
|
||||
Rl5GWpflW4Nr3/58BbmCWNA8WP0NOWutJhrkltwLCqJC8OZX/WHnmcmq2k+UAfQQ
|
||||
z98p+xcMWOTcl5XIbveuhYoZb2udt9WVJLcpRVxVhEWcBrtHsrpScZXsKYf/TaZ1
|
||||
JtCjPU5KFQKBgGXj/UqmYkYzxXZ8NEP998pHvcLGsXew/F2VpXv2yQlmXrYQtvd1
|
||||
YfOSUjJsL1hPZoKYogBoB1UGBJYwsimxYyhVFU2eWwkvjF7wWEd/fDUJHVCQwgHw
|
||||
lPga+I303BDmCK4o2uRI7vY4P8P92+gelPKhR03mmYIvnky+rvsz9CkLAoGAVerl
|
||||
du6Z0jDecxUsnVvrKrL17jjHorXlYyRFvFXXEe2SvxbIIAzeEylXJZ7IiF5epS6t
|
||||
n1d+oCC4UTZeGYmpjXofhFn+fHNcWH1FhtAQy/q5nwuwltY0luz7cBamU3Jk/n+m
|
||||
id7N0CYdfhYbLDQSD4e822ureyV2zkBUzwGjpikCgYEAoCVbhkxVrQHRmxLC2jDx
|
||||
pM94W1ij9TqblfGg3OMpPXjpTdlknatWjBw7JkoyxYiYJq5Gi2p4QV6HHUvS1osj
|
||||
BB+4A99/PD9A0ICoJglz8SXlGe/895zwHwpKT9f0zjizQrEd2KCPf75URa+t2FGY
|
||||
B2GkXsbChRWVrey5+uMzUD8=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,25 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEQjCCAqqgAwIBAgIRAIeLJ2jwt4MSKycHFmFXL6YwDQYJKoZIhvcNAQELBQAw
|
||||
fTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSkwJwYDVQQLDCB3d3N1
|
||||
bkBzdW4tbWJwLmxvY2FsIChTVU4gV2Vpd2VpKTEwMC4GA1UEAwwnbWtjZXJ0IHd3
|
||||
c3VuQHN1bi1tYnAubG9jYWwgKFNVTiBXZWl3ZWkpMB4XDTIyMDcyODAxNTIxMFoX
|
||||
DTI0MTAyODAxNTIxMFowVDEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3BtZW50IGNl
|
||||
cnRpZmljYXRlMSkwJwYDVQQLDCB3d3N1bkBzdW4tbWJwLmxvY2FsIChTVU4gV2Vp
|
||||
d2VpKTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALiwCoMTeeG1JYNi
|
||||
pXiaWCWfMizYCKhmva7F+P84qukTVkwv91+UnJzk0ZSk5lZX5bW9FzvTBZU0XEFK
|
||||
Lyetiuux6ggPsSHHF7r4nWb8mwRuFlYfBXczWIZbskPtWG6NoOlIuDrvAxMylJbc
|
||||
FRL7Q1P4w18KKyn67+Wzh9c8JOBeJZyMxQub3mguVN5jC6s8d/HCf1/2GHzkjkgr
|
||||
d0jvDwyxA3sQU2snPdR+s0umYBEy1JQEzAFiqsKK2hTFFPSrspgKu7uQfEY8HoBJ
|
||||
CsWlKk5ZPv2+nwYFKn2DdrGFGwr5oSDNDRAasU8cHAE/+RaCZ0HVRFz8rtJNIL3H
|
||||
Iw1k4zsCAwEAAaNmMGQwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUF
|
||||
BwMBMB8GA1UdIwQYMBaAFDILZR+SW32Ug0HkAKrH1KT2a1ttMBwGA1UdEQQVMBOC
|
||||
EWxvY2FsLm5ldGVhc2UuY29tMA0GCSqGSIb3DQEBCwUAA4IBgQCEdeV2Likl4MUt
|
||||
wWeN/X3tAR3L7mkl7e4f0CQ4v2lOs8cekOP7p3ZFk5nSyVbxda3usPB0OtbVTibw
|
||||
qZ1n/TMRb5epHroXIMleqngP82zFZNg+WVyHm/ZAml8gVD0VZjYM1Tb+U9+SrgGw
|
||||
lvZndbuUXa3h/d0irPH90KsdEeAR1SqwzAzvLO2JgvBuimHRceQWcTiiGWDZOyii
|
||||
tux4U+pEzh0Dia40uFozFAoYZPDUpZ+gsiQ3MR3HOrAu2OR0a+CfelDXZNLR3C4z
|
||||
cBKr53ajAbHstnpxKJh9IfeKph44PrbDo6HMe1XPprl7idoEesX3Ma3SBgm4lmId
|
||||
cfEOUWNIYOgtOT5bflXNNyzXOgYVjpLnjwg4EOASO/XIjhvqdjIyCu7HJMIcZ9oo
|
||||
Aob2exeluv5LVwic1UJQDl2McDBIgJ8R32oEN4skCKIBSbWlxWyjnxL5I5WkJF4y
|
||||
vlSx8bKAjVr8map0C3rWISuZdgheJkF96qjwhVSxOv++p6srq6M=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "playground",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "playground of tango-apps",
|
||||
"license": "MIT",
|
||||
"author": "wwsun <ww.sun@outlook.com>",
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "COMPRESS=none umi build",
|
||||
"start": "HTTPS=1 umi dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^4.8.0",
|
||||
"@music163/antd": "^0.1.1",
|
||||
"@music163/tango-designer": "*",
|
||||
"@music163/tango-helpers": "*",
|
||||
"antd": "^4.24.2",
|
||||
"coral-system": "^1.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"umi": "^3.5.24"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export function FooSetter({ value, ...rest }: any) {
|
||||
return <code {...rest}>fooSetter: {value}</code>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './foo-setter';
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Row, Switch } from 'antd';
|
||||
import { observer } from '@music163/tango-designer';
|
||||
|
||||
const OtherPanel = observer(({ autoRemove, setAutoRemove }: any) => {
|
||||
const onChange = (checked: boolean) => {
|
||||
setAutoRemove(checked);
|
||||
};
|
||||
return (
|
||||
<Row>
|
||||
<label>自动移除未引用变量:</label>
|
||||
<Switch
|
||||
checkedChildren="开启"
|
||||
unCheckedChildren="关闭"
|
||||
onChange={onChange}
|
||||
checked={autoRemove}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
});
|
||||
|
||||
export default OtherPanel;
|
||||
@@ -0,0 +1,248 @@
|
||||
const packageJson = {
|
||||
name: 'demo',
|
||||
private: true,
|
||||
dependencies: {
|
||||
'@music163/antd': '^0.1.0',
|
||||
'@music163/tango-boot': '^0.1.0',
|
||||
react: '17.0.2',
|
||||
'react-dom': '17.0.2',
|
||||
'prop-types': '15.7.2',
|
||||
tslib: '2.5.0',
|
||||
},
|
||||
};
|
||||
|
||||
const tangoJson = {
|
||||
packages: {
|
||||
react: {
|
||||
version: '17.0.2',
|
||||
library: 'React',
|
||||
type: 'dependency',
|
||||
resources: ['https://unpkg.com/react@{{version}}/umd/react.development.js'],
|
||||
},
|
||||
'react-dom': {
|
||||
version: '17.0.2',
|
||||
library: 'ReactDOM',
|
||||
type: 'dependency',
|
||||
resources: ['https://unpkg.com/react-dom@{{version}}/umd/react-dom.development.js'],
|
||||
},
|
||||
'react-is': {
|
||||
version: '16.13.1',
|
||||
library: 'ReactIs',
|
||||
type: 'dependency',
|
||||
resources: ['https://unpkg.com/react-is@{{version}}/umd/react-is.production.min.js'],
|
||||
},
|
||||
'styled-components': {
|
||||
version: '5.3.5',
|
||||
library: 'styled',
|
||||
type: 'dependency',
|
||||
resources: ['https://unpkg.com/styled-components@{{version}}/dist/styled-components.min.js'],
|
||||
},
|
||||
moment: {
|
||||
version: '2.29.4',
|
||||
library: 'moment',
|
||||
type: 'dependency',
|
||||
resources: ['https://unpkg.com/moment@{{version}}/moment.js'],
|
||||
},
|
||||
'@music163/tango-boot': {
|
||||
version: '0.1.2',
|
||||
library: 'TangoBoot',
|
||||
type: 'baseDependency',
|
||||
// resources: ['https://unpkg.com/@music163/tango-boot@{{version}}/dist/boot.js'],
|
||||
resources: ['http://localhost:9001/boot.js'],
|
||||
description: '云音乐低代码运行时框架',
|
||||
},
|
||||
'@music163/antd': {
|
||||
version: '0.1.1',
|
||||
library: 'TangoAntd',
|
||||
type: 'baseDependency',
|
||||
resources: [
|
||||
'https://unpkg.com/@music163/antd@{{version}}/dist/index.js',
|
||||
'https://unpkg.com/antd@4.24.13/dist/antd.css',
|
||||
],
|
||||
description: '云音乐低代码中后台应用基础物料',
|
||||
designerResources: [
|
||||
'https://unpkg.com/@music163/antd@{{version}}/dist/designer.js',
|
||||
'https://unpkg.com/antd@4.24.13/dist/antd.css',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const appJson: any = {
|
||||
pages: [
|
||||
{
|
||||
path: '/',
|
||||
name: '首页',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const routesCode = `
|
||||
import Index from "./pages/list";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
exact: true,
|
||||
component: Index
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
`;
|
||||
|
||||
const storeIndexCode = `
|
||||
export { default as app } from './app';
|
||||
export { default as counter } from './counter';
|
||||
`;
|
||||
|
||||
const entryCode = `
|
||||
import { runApp } from '@music163/tango-boot';
|
||||
import routes from './routes';
|
||||
import './services';
|
||||
import './stores';
|
||||
import './index.less';
|
||||
|
||||
runApp({
|
||||
boot: {
|
||||
mountElement: document.querySelector('#root'),
|
||||
qiankun: false,
|
||||
},
|
||||
|
||||
router: {
|
||||
type: 'browser',
|
||||
config: routes,
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
const storeCounter = `
|
||||
import { defineStore } from '@music163/tango-boot';
|
||||
|
||||
const counter = defineStore({
|
||||
// state
|
||||
num: 0,
|
||||
|
||||
// action
|
||||
increment: () => counter.num++,
|
||||
|
||||
decrement: () => {
|
||||
counter.num--;
|
||||
},
|
||||
}, 'counter');
|
||||
|
||||
export default counter;
|
||||
`;
|
||||
|
||||
const viewHomeCode = `
|
||||
import React from "react";
|
||||
import { definePage } from "@music163/tango-boot";
|
||||
import {
|
||||
Button,
|
||||
Input
|
||||
} from "@music163/antd";
|
||||
class App extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Button>button</Button>
|
||||
<Input />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default definePage(App);
|
||||
`;
|
||||
|
||||
const storeApp = `
|
||||
import { defineStore } from '@music163/tango-boot';
|
||||
|
||||
export default defineStore({
|
||||
|
||||
title: 'hello world',
|
||||
|
||||
array: [1, 2, 3],
|
||||
}, 'app');
|
||||
`;
|
||||
|
||||
const serviceCode = `
|
||||
import { defineServices } from '@music163/tango-boot';
|
||||
|
||||
export default defineServices({
|
||||
get: {
|
||||
url: 'https://nei.hz.netease.com/api/apimock-v2/cc974ffbaa7a85c77f30e4ce67deb67f/api/getUserProfile',
|
||||
formatter: res => res.data,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
list: {
|
||||
url: 'https://nei.hz.netease.com/api/apimock-v2/c45109399a1d33d83e32a59984b25b00/anchor-list-normal',
|
||||
},
|
||||
add: {
|
||||
url: 'https://nei.hz.netease.com/api/apimock-v2/c45109399a1d33d83e32a59984b25b00/api/users',
|
||||
method: 'post',
|
||||
},
|
||||
update: {
|
||||
url: 'https://nei.hz.netease.com/api/apimock-v2/c45109399a1d33d83e32a59984b25b00/api/users',
|
||||
method: 'post',
|
||||
},
|
||||
delete: {
|
||||
url: 'https://nei.hz.netease.com/api/apimock-v2/c45109399a1d33d83e32a59984b25b00/api/users?id=1',
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
const lessCode = `
|
||||
body {
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const cssCode = `
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: red;
|
||||
}
|
||||
`;
|
||||
|
||||
export const sampleFiles = [
|
||||
{ filename: '/package.json', code: JSON.stringify(packageJson) },
|
||||
{ filename: '/appJson.json', code: JSON.stringify(appJson) },
|
||||
{ filename: '/tango.config.json', code: JSON.stringify(tangoJson) },
|
||||
{ filename: '/README.md', code: '# readme' },
|
||||
{ filename: '/src/index.less', code: lessCode },
|
||||
{ filename: '/src/style.css', code: cssCode },
|
||||
{ filename: '/src/index.js', code: entryCode },
|
||||
{ filename: '/src/pages/list.js', code: viewHomeCode },
|
||||
{ filename: '/src/routes.js', code: routesCode },
|
||||
{ filename: '/src/stores/index.js', code: storeIndexCode },
|
||||
{ filename: '/src/stores/app.js', code: storeApp },
|
||||
{ filename: '/src/stores/counter.js', code: storeCounter },
|
||||
{ filename: '/src/services/index.js', code: serviceCode },
|
||||
];
|
||||
|
||||
export const genDefaultPage = (index: number) => ({
|
||||
name: 'new-page',
|
||||
code: `
|
||||
import React from 'react';
|
||||
import tango, { definePage } from '@music163/tango-boot';
|
||||
import { Layout, Page, Section } from '@music163/antd';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Page title="空白模板${index}">
|
||||
<Section></Section>
|
||||
</Page>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default definePage(App);
|
||||
`,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tango Playground</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="https://unpkg.com/react@16.14.0/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@16.14.0/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/react-is@16.13.1/umd/react-is.production.min.js"></script>
|
||||
<script src="https://unpkg.com/moment/min/moment-with-locales.js"></script>
|
||||
<script src="https://unpkg.com/styled-components@5.3.11/dist/styled-components.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.15.6/babel.js"></script>
|
||||
<script src="https://unpkg.com/prettier@2.6.0/standalone.js"></script>
|
||||
<script src="https://unpkg.com/prettier@2.6.0/parser-babel.js"></script>
|
||||
<script src="https://unpkg.com/antd@4.24.13/dist/antd-with-locales.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
@import '~antd/es/style/themes/default.less';
|
||||
@import '~antd/dist/antd.less'; // 引入官方提供的 less 样式入口文件
|
||||
|
||||
@primary-color: #2f54eb;
|
||||
@border-radius-base: 2px;
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
createEngine,
|
||||
Designer,
|
||||
DesignerPanel,
|
||||
SidebarPanel,
|
||||
SettingPanel,
|
||||
ToolbarPanel,
|
||||
WorkspacePanel,
|
||||
ViewPanel,
|
||||
CodeEditor,
|
||||
Sandbox,
|
||||
DndQuery,
|
||||
ComponentsView,
|
||||
} from '@music163/tango-designer';
|
||||
import { prototypes, menuData } from '@music163/antd/lib/esm/designer';
|
||||
import { Logo, ProjectDetail } from './share';
|
||||
import { sampleFiles } from '../mock/project';
|
||||
import './index.less';
|
||||
|
||||
/**
|
||||
* 1. 如果要支持代码格式化,需要提前在 html 模板中引入
|
||||
* <script src="https://unpkg.com/prettier@2.6.0/standalone.js"></script>
|
||||
* <scrip src="https://unpkg.com/prettier@2.6.0/parser-graphql.js"></script>
|
||||
*/
|
||||
|
||||
// 2. 引擎初始化
|
||||
const engine = createEngine({
|
||||
entry: '/src/index.js',
|
||||
files: sampleFiles,
|
||||
componentPrototypes: prototypes,
|
||||
});
|
||||
|
||||
const workspace = engine.workspace;
|
||||
|
||||
// @ts-ignore
|
||||
window.__workspace__ = workspace;
|
||||
|
||||
const sandboxQuery = new DndQuery({
|
||||
context: 'iframe',
|
||||
});
|
||||
|
||||
/**
|
||||
* 3. 平台初始化
|
||||
* 默认使用 CodeSandbox https://local.netease.com:6006/
|
||||
* 如果使用 ViteSandbox https://local.netease.com:6006?moduleType=esm
|
||||
*/
|
||||
export default function App() {
|
||||
return (
|
||||
<Designer engine={engine} sandboxQuery={sandboxQuery}>
|
||||
<DesignerPanel
|
||||
logo={<Logo />}
|
||||
description={<ProjectDetail />}
|
||||
actions={
|
||||
<ToolbarPanel>
|
||||
<ToolbarPanel.Item key="modeSwitch" placement="right" />
|
||||
<ToolbarPanel.Item key="togglePanel" placement="right" />
|
||||
</ToolbarPanel>
|
||||
}
|
||||
>
|
||||
<SidebarPanel>
|
||||
<SidebarPanel.Item key="outline" />
|
||||
<SidebarPanel.Item key="components">
|
||||
<ComponentsView menuData={menuData as any} />
|
||||
</SidebarPanel.Item>
|
||||
<SidebarPanel.Item key="model" isFloat width={800} />
|
||||
<SidebarPanel.Item key="dataSource" isFloat width={800} />
|
||||
</SidebarPanel>
|
||||
<WorkspacePanel>
|
||||
<ViewPanel mode="design">
|
||||
<Sandbox />
|
||||
</ViewPanel>
|
||||
<ViewPanel mode="code">
|
||||
<CodeEditor />
|
||||
</ViewPanel>
|
||||
</WorkspacePanel>
|
||||
<SettingPanel />
|
||||
</DesignerPanel>
|
||||
</Designer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import React from 'react';
|
||||
import { Box, Group } from 'coral-system';
|
||||
import { Avatar, Space, Switch } from 'antd';
|
||||
import { BranchesOutlined, MenuOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { registerSetter } from '@music163/tango-designer';
|
||||
import type { ComponentPrototypeType } from '@music163/tango-helpers';
|
||||
import { FooSetter } from '../components';
|
||||
|
||||
// folder-name
|
||||
// 物料列表定义
|
||||
const bizToggleButtonPrototype: ComponentPrototypeType = {
|
||||
name: 'CtPcToggleButton',
|
||||
exportType: 'defaultExport',
|
||||
title: '示例业务组件',
|
||||
icon: 'icon-tupian',
|
||||
type: 'element',
|
||||
docs: 'https://redstone.fn.netease.com/mt/fe-comp/w8bq8px7n5/toggle-button',
|
||||
hasChildren: false,
|
||||
props: [
|
||||
{
|
||||
name: 'checked',
|
||||
title: '是否选中',
|
||||
setter: 'boolSetter',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: 'children',
|
||||
title: '文本',
|
||||
setter: 'textSetter',
|
||||
initValue: '按钮',
|
||||
},
|
||||
],
|
||||
package: '@music/ct-pc-toggle-button',
|
||||
};
|
||||
|
||||
const sampleBlockCode = `
|
||||
<Section>
|
||||
<Result
|
||||
status="success"
|
||||
title="Successfully Purchased Cloud Server ECS!"
|
||||
subTitle="Order number: 2017182818828182881 Cloud server configuration takes 1-5 minutes, please wait."
|
||||
extra={[
|
||||
<Button type="primary" key="console">
|
||||
Go Console
|
||||
</Button>,
|
||||
<Button key="buy">Buy Again</Button>,
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
`;
|
||||
|
||||
const SnippetSuccessResult: ComponentPrototypeType = {
|
||||
name: 'SnippetSuccessResult',
|
||||
title: '成功结果',
|
||||
icon: 'icon-tupian',
|
||||
type: 'snippet',
|
||||
package: '@music/tango-cms',
|
||||
initChildren: sampleBlockCode,
|
||||
relatedImports: ['Section', 'Result', 'Button'],
|
||||
};
|
||||
|
||||
const Snippet2ColumnLayout: ComponentPrototypeType = {
|
||||
name: 'Snippet2ColumnLayout',
|
||||
title: '两列布局',
|
||||
icon: 'icon-columns',
|
||||
type: 'snippet',
|
||||
package: '@music/tango-cms',
|
||||
initChildren: `
|
||||
<Columns columns={12}>
|
||||
<Column colSpan={6}></Column>
|
||||
<Column colSpan={6}></Column>
|
||||
</Columns>
|
||||
`,
|
||||
relatedImports: ['Columns', 'Column'],
|
||||
};
|
||||
|
||||
const Snippet3ColumnLayout: ComponentPrototypeType = {
|
||||
name: 'Snippet3ColumnLayout',
|
||||
title: '三列布局',
|
||||
icon: 'icon-column3',
|
||||
type: 'snippet',
|
||||
package: '@music/tango-cms',
|
||||
initChildren: `
|
||||
<Columns columns={12}>
|
||||
<Column colSpan={4}></Column>
|
||||
<Column colSpan={4}></Column>
|
||||
<Column colSpan={4}></Column>
|
||||
</Columns>
|
||||
`,
|
||||
relatedImports: ['Columns', 'Column'],
|
||||
};
|
||||
|
||||
const SnippetButtonGroup: ComponentPrototypeType = {
|
||||
name: 'SnippetButtonGroup',
|
||||
title: '按钮组',
|
||||
icon: 'icon-anniuzu',
|
||||
type: 'snippet',
|
||||
package: '@music/tango-cms',
|
||||
initChildren: `
|
||||
<Space>
|
||||
<Button type="primary">按钮1</Button>
|
||||
<Button>按钮2</Button>
|
||||
</Space>
|
||||
`,
|
||||
relatedImports: ['Space', 'Button'],
|
||||
};
|
||||
|
||||
export const prototypes = {
|
||||
CtPcToggleButton: bizToggleButtonPrototype,
|
||||
SnippetSuccessResult,
|
||||
Snippet2ColumnLayout,
|
||||
Snippet3ColumnLayout,
|
||||
SnippetButtonGroup,
|
||||
};
|
||||
|
||||
// 注册自定义 setter
|
||||
registerSetter({
|
||||
name: 'fooSetter',
|
||||
component: FooSetter,
|
||||
});
|
||||
|
||||
// 平台 Logo
|
||||
export function Logo() {
|
||||
return (
|
||||
<Box width="50px" display="flex" alignItems="center" justifyContent="center" fontSize="20px">
|
||||
<MenuOutlined />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 项目信息
|
||||
export function ProjectDetail() {
|
||||
return (
|
||||
<Box display="flex" alignItems="center" columnGap="l">
|
||||
<Box className="ProjectName" fontSize="18px" fontWeight="bold">
|
||||
lc-online-test
|
||||
</Box>
|
||||
<Box className="BranchName" as="code" fontSize="14px">
|
||||
<BranchesOutlined /> feature/list
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarFooter() {
|
||||
return (
|
||||
<Space direction="vertical" align="center">
|
||||
<QuestionCircleOutlined style={{ fontSize: 20 }} />
|
||||
<Avatar src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png" />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionsProps {
|
||||
defaultChecked?: boolean;
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
onChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
// 平台核心行动点
|
||||
export function Actions({ defaultChecked }: ActionsProps) {
|
||||
return (
|
||||
<Group spacingX="8px">
|
||||
<Switch defaultChecked={defaultChecked} checkedChildren="新版" unCheckedChildren="老版" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export const menuData: any = {
|
||||
// 常用组件
|
||||
common: [
|
||||
{
|
||||
title: '基本',
|
||||
items: [
|
||||
'Button',
|
||||
'ButtonGroup',
|
||||
'ActionList',
|
||||
'Action',
|
||||
'Image',
|
||||
'Text',
|
||||
'MultilineText',
|
||||
'Link',
|
||||
'Title',
|
||||
'Paragraph',
|
||||
'Icon',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '数据和逻辑',
|
||||
items: ['When', 'DataProvider', 'Interval', 'Each'],
|
||||
},
|
||||
{
|
||||
title: '基础布局',
|
||||
items: [
|
||||
'Section',
|
||||
'Columns',
|
||||
'Box',
|
||||
'Divider',
|
||||
'Space',
|
||||
'Tabs',
|
||||
'Toolbar',
|
||||
'Modal',
|
||||
'Drawer',
|
||||
'SnippetButtonGroup',
|
||||
'Snippet2ColumnLayout',
|
||||
'Snippet3ColumnLayout',
|
||||
'SnippetSuccessResult',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '表单表格',
|
||||
items: [
|
||||
'XTable',
|
||||
'XEditableTable',
|
||||
// 'Table',
|
||||
'XForm',
|
||||
'XFormItem',
|
||||
'XStepForms',
|
||||
// 'SearchForm',
|
||||
// 'Form',
|
||||
// 'FormItem',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '常用图表',
|
||||
items: [
|
||||
'ChartContainer',
|
||||
'BarChart',
|
||||
'LineChart',
|
||||
'PieChart',
|
||||
'FunnelChart',
|
||||
'ScatterChart',
|
||||
'RadarChart',
|
||||
'WordCloud',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '导航',
|
||||
items: [
|
||||
'Breadcrumb',
|
||||
'Dropdown',
|
||||
'Menu',
|
||||
// 'PageHeader',
|
||||
'Pagination',
|
||||
'Steps',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '数据录入',
|
||||
items: [
|
||||
'AutoComplete',
|
||||
'Cascader',
|
||||
'Checkbox',
|
||||
'CheckboxGroup',
|
||||
'DatePicker',
|
||||
'DateRangePicker',
|
||||
'WeekPicker',
|
||||
'MonthPicker',
|
||||
'YearPicker',
|
||||
'Input',
|
||||
'InputNumber',
|
||||
'InputKV',
|
||||
'Mentions',
|
||||
'Radio',
|
||||
'RadioGroup',
|
||||
'Rate',
|
||||
'Select',
|
||||
'Slider',
|
||||
'Switch',
|
||||
'Search',
|
||||
'TextArea',
|
||||
'TimePicker',
|
||||
'TimeRangePicker',
|
||||
'Transfer',
|
||||
'TreeSelect',
|
||||
'Upload',
|
||||
'NosUpload',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '数据展示',
|
||||
items: [
|
||||
'Avatar',
|
||||
'Badge',
|
||||
'RibbonBadge',
|
||||
'Calendar',
|
||||
'Card',
|
||||
'Carousel',
|
||||
'Collapse',
|
||||
'Comment',
|
||||
'Descriptions',
|
||||
'Empty',
|
||||
'Image',
|
||||
'List',
|
||||
'Popover',
|
||||
'Statistic',
|
||||
'Table',
|
||||
'Tag',
|
||||
'CheckableTag',
|
||||
'Skeleton',
|
||||
'SkeletonAvatar',
|
||||
'SkeletonButton',
|
||||
'SkeletonInput',
|
||||
'SkeletonImage',
|
||||
'SkeletonNode',
|
||||
'Spin',
|
||||
'Timeline',
|
||||
'Tooltip',
|
||||
'Tree',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '反馈',
|
||||
items: [
|
||||
'Alert',
|
||||
'Drawer',
|
||||
// 'Message',
|
||||
'Modal',
|
||||
'Notification',
|
||||
'Popconfirm',
|
||||
'Progress',
|
||||
'Result',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* BabelConfig for Jest
|
||||
*/
|
||||
module.exports = {
|
||||
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-react', '@babel/preset-typescript'],
|
||||
};
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
const config = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/private/var/folders/q_/7k4h88k50pn0p6423z7smp9r0000gn/T/jest_dx",
|
||||
|
||||
// Automatically clear mock calls, instances, contexts and results before every test
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
collectCoverage: true,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: 'coverage',
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
// coverageProvider: "babel",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// The default configuration for fake timers
|
||||
// fakeTimers: {
|
||||
// "enableGlobally": false
|
||||
// },
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "mjs",
|
||||
// "cjs",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "json",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state before every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state and implementation before every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jest-circus/runner",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"useWorkspaces": true,
|
||||
"version": "independent",
|
||||
"packages": ["packages/*"],
|
||||
"npmClient": "yarn",
|
||||
"command": {
|
||||
"version": {
|
||||
"message": "chore(release): publish"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "tango-community",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "yarn workspace playground start",
|
||||
"start:docs": "yarn workspace docs storybook",
|
||||
"build": "lerna run build",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"eslint": "eslint packages/**/src/*.{ts,tsx}",
|
||||
"publish": "lerna publish",
|
||||
"ver": "lerna version --no-private",
|
||||
"release": "yarn eslint && yarn build && yarn run ver && lerna publish from-git",
|
||||
"release:beta": "yarn eslint && yarn build && yarn run ver && lerna publish from-package --dist-tag beta",
|
||||
"up": "yarn upgrade-interactive --latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.19.3",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@commitlint/cli": "^17.5.0",
|
||||
"@commitlint/config-conventional": "^17.3.0",
|
||||
"@types/jest": "^29.2.4",
|
||||
"@types/lodash-es": "^4.17.8",
|
||||
"@types/lodash.get": "^4.4.7",
|
||||
"@types/lodash.isequal": "^4.5.6",
|
||||
"@types/lodash.set": "^4.3.7",
|
||||
"@types/react": "^18.2.7",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"@types/styled-components": "^5.1.25",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"conventional-changelog-cli": "^2.2.2",
|
||||
"copyfiles": "^2.4.1",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-ali": "^14.0.2",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.0",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"http-server": "^14.1.0",
|
||||
"husky": "^8.0.1",
|
||||
"jest": "^29.1.1",
|
||||
"jest-environment-jsdom": "^29.1.1",
|
||||
"lerna": "^6.6.1",
|
||||
"less": "^4.1.2",
|
||||
"less-loader": "^7.3.0",
|
||||
"lint-staged": "^13.1.0",
|
||||
"mini-css-extract-plugin": "^1.6.2",
|
||||
"prettier": "^2.8.7",
|
||||
"progress-bar-webpack-plugin": "^2.1.0",
|
||||
"react": "^17.0.0",
|
||||
"react-dom": "^17.0.0",
|
||||
"styled-components": "^5.3.6",
|
||||
"typedoc": "^0.24.7",
|
||||
"typescript": "^5.0.4",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-cli": "^4.9.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"@yarnpkg/parsers": "3.0.0-rc.48.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# tango-apps-context
|
||||
|
||||
> React context of tango-apps core
|
||||
|
||||
## Usage
|
||||
|
||||
install
|
||||
|
||||
```bash
|
||||
yarn add @music/tango-apps-context
|
||||
```
|
||||
|
||||
usage
|
||||
|
||||
```jsx
|
||||
import { observer, useWorkspace } from '@music/tango-apps-context';
|
||||
|
||||
export const SampleWidget = observer(() => {
|
||||
const ws = useWorkspace();
|
||||
return <div></div>;
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@music163/tango-context",
|
||||
"version": "0.1.1",
|
||||
"description": "react context for tango-apps",
|
||||
"keywords": [
|
||||
"react",
|
||||
"hooks"
|
||||
],
|
||||
"author": "wwsun <ww.sun@outlook.com>",
|
||||
"homepage": "",
|
||||
"license": "MIT",
|
||||
"main": "lib/cjs/index.js",
|
||||
"module": "lib/esm/index.js",
|
||||
"types": "lib/esm/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"lib"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/netease/tango.git"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf lib/",
|
||||
"build": "yarn clean && yarn build:esm && yarn build:cjs",
|
||||
"build:esm": "tsc --project tsconfig.prod.json --outDir lib/esm/ --module ES2020",
|
||||
"build:cjs": "tsc --project tsconfig.prod.json --outDir lib/cjs/ --module CommonJS",
|
||||
"prepublishOnly": "yarn build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@music163/tango-core": "^0.1.1",
|
||||
"@music163/tango-helpers": "^0.1.1",
|
||||
"mobx-react-lite": "4.0.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Engine } from '@music163/tango-core';
|
||||
import { createContext } from '@music163/tango-helpers';
|
||||
|
||||
export interface ITangoEngineContext {
|
||||
/**
|
||||
* 低代码引擎
|
||||
*/
|
||||
engine: Engine;
|
||||
}
|
||||
|
||||
const [TangoEngineProvider, useTangoEngine] = createContext<ITangoEngineContext>({
|
||||
name: 'TangoEngineContext',
|
||||
});
|
||||
|
||||
export { TangoEngineProvider };
|
||||
|
||||
export const useWorkspace = () => {
|
||||
return useTangoEngine()?.engine.workspace;
|
||||
};
|
||||
|
||||
export const useDesigner = () => {
|
||||
return useTangoEngine()?.engine.designer;
|
||||
};
|
||||
|
||||
const builtinHelpers = [
|
||||
{
|
||||
title: 'setStoreValue',
|
||||
key: '() => tango.setStoreValue("variableName", "variableValue")',
|
||||
type: 'function',
|
||||
},
|
||||
{
|
||||
title: 'getStoreValue',
|
||||
key: '() => tango.getStoreValue("variableName")',
|
||||
type: 'function',
|
||||
},
|
||||
{ title: 'openModal', key: '() => tango.openModal("")', type: 'function' },
|
||||
{ title: 'closeModal', key: '() => tango.closeModal("")', type: 'function' },
|
||||
{ title: 'navigateTo', key: '() => tango.navigateTo("/")', type: 'function' },
|
||||
{ title: 'showToast', key: '() => tango.showToast("hello")', type: 'function' },
|
||||
{ title: 'formatDate', key: '() => tango.formatDate("2022-12-12")', type: 'function' },
|
||||
{ title: 'formatNumber', key: '() => tango.formatDate(9999)', type: 'function' },
|
||||
{
|
||||
title: 'copyToClipboard',
|
||||
key: '() => tango.copyToClipboard("hello")',
|
||||
type: 'function',
|
||||
},
|
||||
];
|
||||
|
||||
export const useWorkspaceData = () => {
|
||||
const workspace = useWorkspace();
|
||||
const modelVariables: any[] = []; // 绑定变量列表
|
||||
const storeActionVariables: any[] = []; // 模型中的所有 actions
|
||||
const storeVariables: any[] = []; // 模型中的所有变量
|
||||
const serviceVariables: any[] = []; // 服务中的所有变量
|
||||
|
||||
workspace.listStoreModules?.().forEach((file) => {
|
||||
const prefix = `stores.${file.name}`;
|
||||
const states = file.states.map((item) => ({
|
||||
title: item.name,
|
||||
key: `${prefix}.${item.name}`,
|
||||
raw: item.code,
|
||||
}));
|
||||
const actions = file.actions.map((item) => ({
|
||||
title: item.name,
|
||||
key: `${prefix}.${item.name}`,
|
||||
type: 'function',
|
||||
raw: item.code,
|
||||
}));
|
||||
|
||||
modelVariables.push({
|
||||
title: file.name,
|
||||
key: prefix,
|
||||
selectable: false,
|
||||
children: states,
|
||||
});
|
||||
|
||||
storeActionVariables.push({
|
||||
title: file.name,
|
||||
key: prefix,
|
||||
selectable: false,
|
||||
children: actions,
|
||||
});
|
||||
|
||||
storeVariables.push({
|
||||
title: file.name,
|
||||
key: prefix,
|
||||
selectable: false,
|
||||
children: [...states, ...actions],
|
||||
showAddChildIcon: true,
|
||||
showRemoveIcon: true,
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(workspace.serviceModule?.serviceFunctions || {}).forEach((key) => {
|
||||
serviceVariables.push({
|
||||
title: key,
|
||||
key: `services.${key}`,
|
||||
type: 'function',
|
||||
});
|
||||
});
|
||||
|
||||
// 路由选项列表
|
||||
const routeOptions = workspace.pages?.map((item) => ({
|
||||
label: `${item.name} (${item.path})`,
|
||||
value: item.path,
|
||||
}));
|
||||
|
||||
return {
|
||||
modelVariables: [buildVariableOptions('数据模型', 'stores', modelVariables)],
|
||||
actionVariables: [
|
||||
buildVariableOptions('数据模型', 'stores', storeActionVariables),
|
||||
buildVariableOptions('服务函数', 'services', serviceVariables),
|
||||
buildVariableOptions('工具函数', 'helpers', builtinHelpers),
|
||||
],
|
||||
storeVariables,
|
||||
expressionVariables: [
|
||||
buildVariableOptions('数据模型', 'stores', storeVariables),
|
||||
buildVariableOptions('服务函数', 'services', serviceVariables),
|
||||
buildVariableOptions('工具函数', 'helpers', builtinHelpers),
|
||||
],
|
||||
routeOptions,
|
||||
};
|
||||
};
|
||||
|
||||
function buildVariableOptions(title: string, key: string, children: any[]) {
|
||||
return {
|
||||
key,
|
||||
title,
|
||||
children,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { observer } from 'mobx-react-lite';
|
||||
export * from './context';
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.prod.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# `core`
|
||||
|
||||
搭建引擎
|
||||
|
||||
## 如何使用
|
||||
|
||||
```js
|
||||
import { createEngine } from '@music/tango-apps-core';
|
||||
|
||||
const engine = createEngine();
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@music163/tango-core",
|
||||
"version": "0.1.1",
|
||||
"description": "tango core",
|
||||
"author": "wwsun <ww.sun@outlook.com>",
|
||||
"homepage": "",
|
||||
"license": "MIT",
|
||||
"main": "lib/cjs/index.js",
|
||||
"module": "lib/esm/index.js",
|
||||
"types": "lib/esm/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"lib"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/netease/tango.git"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf lib/",
|
||||
"build": "yarn clean && yarn build:esm && yarn build:cjs",
|
||||
"build:esm": "tsc --project tsconfig.prod.json --outDir lib/esm/ --module ES2020",
|
||||
"build:cjs": "tsc --project tsconfig.prod.json --outDir lib/cjs/ --module CommonJS",
|
||||
"prepublishOnly": "yarn build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.21.3",
|
||||
"@babel/parser": "^7.21.3",
|
||||
"@babel/traverse": "^7.21.3",
|
||||
"@babel/types": "^7.21.3",
|
||||
"@music163/tango-helpers": "^0.1.1",
|
||||
"@types/babel__generator": "^7.6.4",
|
||||
"@types/babel__traverse": "^7.18.3",
|
||||
"mobx": "6.9.0",
|
||||
"path-browserify": "^1.0.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ComponentPrototypeType } from '@music163/tango-helpers';
|
||||
import { Designer, Engine, SimulatorNameType, Workspace } from './models';
|
||||
import { FileItemType } from './types';
|
||||
import { IWorkspace } from './models/interfaces';
|
||||
|
||||
/**
|
||||
* 这里提前实例化 workspace,先保证全局唯一
|
||||
* TIP: builtinWorkspace 暂时只是给老版的 sandbox 消费,其他场景不需要
|
||||
*/
|
||||
export const builtinWorkspace = new Workspace();
|
||||
|
||||
type CreateEngineOptionsType = {
|
||||
/**
|
||||
* 自定义工作区
|
||||
*/
|
||||
workspace?: IWorkspace;
|
||||
/**
|
||||
* 是否使用老版全局 workspace,目前仅 LegacySandbox 需要开启
|
||||
*/
|
||||
useBuiltinWorkspace?: boolean;
|
||||
/**
|
||||
* 文件入口
|
||||
*/
|
||||
entry?: string;
|
||||
/**
|
||||
* 初始化的文件列表
|
||||
*/
|
||||
files?: FileItemType[];
|
||||
/**
|
||||
* 组件的原型信息
|
||||
*/
|
||||
componentPrototypes?: Record<string, ComponentPrototypeType>;
|
||||
/**
|
||||
* 默认的模拟器模式
|
||||
*/
|
||||
defaultSimulatorMode?: SimulatorNameType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Designer 实例化工厂函数
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
export function createEngine({
|
||||
workspace: workspaceProp,
|
||||
useBuiltinWorkspace,
|
||||
defaultSimulatorMode,
|
||||
files,
|
||||
entry,
|
||||
componentPrototypes,
|
||||
}: CreateEngineOptionsType) {
|
||||
let workspace: IWorkspace = workspaceProp;
|
||||
|
||||
if (!workspace) {
|
||||
// 如果用户没有提供,则使用内置的初始化策略
|
||||
if (useBuiltinWorkspace) {
|
||||
workspace = builtinWorkspace;
|
||||
workspace.entry = entry;
|
||||
if (files && files.length) {
|
||||
workspace.addFiles(files);
|
||||
}
|
||||
if (componentPrototypes) {
|
||||
workspace.setComponentPrototypes(componentPrototypes);
|
||||
}
|
||||
} else {
|
||||
workspace = new Workspace({
|
||||
entry,
|
||||
files,
|
||||
prototypes: componentPrototypes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const engine = new Engine({
|
||||
workspace: workspaceProp || (workspace as any),
|
||||
designer: new Designer({
|
||||
workspace,
|
||||
simulator: defaultSimulatorMode,
|
||||
}),
|
||||
});
|
||||
|
||||
return engine;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { TangoViewNodeDataType } from '../types';
|
||||
|
||||
/**
|
||||
* 合并变量数组
|
||||
* @param list
|
||||
* @returns
|
||||
*/
|
||||
export function mergeVariableArray(...list: any[]) {
|
||||
const ret: any[] = [];
|
||||
for (const sub of list) {
|
||||
if (Array.isArray(sub)) {
|
||||
sub.forEach((item: any) => {
|
||||
if (item && item.children && item.children.length) {
|
||||
ret.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将输入值转换为 tree data 嵌套数组
|
||||
* @param list
|
||||
*/
|
||||
export function toTreeData(list: TangoViewNodeDataType[]) {
|
||||
const map: Record<string, TangoViewNodeDataType> = {};
|
||||
|
||||
list.forEach((item) => {
|
||||
// 如果不存在,则初始化
|
||||
if (!map[item.id]) {
|
||||
map[item.id] = {
|
||||
...item,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 是否找到父节点,找到则塞进去
|
||||
if (item.parentId && map[item.parentId]) {
|
||||
map[item.parentId].children.push(map[item.id]);
|
||||
}
|
||||
});
|
||||
|
||||
// 保留根节点
|
||||
const ret = Object.values(map).filter((item) => !item.parentId);
|
||||
return ret;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const defineServiceHandlerNames = ['defineServices', 'createServices'];
|
||||
const sfHandlerPattern = new RegExp(`^(${defineServiceHandlerNames.join('|')})$`);
|
||||
|
||||
/**
|
||||
* 判断给定的函数名是否是 defineServices
|
||||
* @param name
|
||||
* @returns
|
||||
*/
|
||||
export function isDefineService(name: string) {
|
||||
return sfHandlerPattern.test(name);
|
||||
}
|
||||
|
||||
const defineStoreHandlerName = 'defineStore';
|
||||
|
||||
/**
|
||||
* 判断给定的函数名是否是 defineStore
|
||||
* @param name
|
||||
* @returns
|
||||
*/
|
||||
export function isDefineStore(name: string) {
|
||||
return defineStoreHandlerName === name;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* ast to code
|
||||
*/
|
||||
import generator, { GeneratorOptions } from '@babel/generator';
|
||||
import * as t from '@babel/types';
|
||||
import { logger } from '@music163/tango-helpers';
|
||||
import { formatCode } from '../string';
|
||||
|
||||
const defaultGeneratorOptions: GeneratorOptions = {
|
||||
jsescOption: { minimal: true },
|
||||
retainLines: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 t.File 生成为代码
|
||||
* @param ast
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
export function ast2code(ast: t.Node, options: GeneratorOptions = defaultGeneratorOptions) {
|
||||
let code = generator(ast, {
|
||||
...options,
|
||||
}).code;
|
||||
code = formatCode(code);
|
||||
return code;
|
||||
}
|
||||
|
||||
const bracketPattern = /^\(.+\)$/s;
|
||||
|
||||
/**
|
||||
* 是否被 () 包裹
|
||||
*
|
||||
* @example ({ foo: 'foo' }) -> true
|
||||
* @example { foo: 'foo' } -> false
|
||||
*
|
||||
* @param str 目标字符串
|
||||
*/
|
||||
function isWrappingWithBrackets(str: string) {
|
||||
return bracketPattern.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将表达式生成为块级代码
|
||||
* @param node
|
||||
* @returns
|
||||
*/
|
||||
export function expression2code(node: t.Expression) {
|
||||
const statement = t.expressionStatement(node);
|
||||
let ret = ast2code(statement).trim();
|
||||
// 移除末尾的分号
|
||||
if (ret.endsWith(';')) {
|
||||
ret = ret.slice(0, -1);
|
||||
}
|
||||
|
||||
const isWrappingExpression = t.isObjectExpression(node) || t.isFunctionExpression(node);
|
||||
|
||||
if (isWrappingExpression && isWrappingWithBrackets(ret)) {
|
||||
// 如果是对象,输出包含 ({}),则去掉首尾的括号
|
||||
ret = ret.slice(1, -1);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成员表达式的调用名
|
||||
* @example Date.now() --> Date.now
|
||||
* @param node
|
||||
* @returns
|
||||
*/
|
||||
function getNameByMemberExpression(node: t.MemberExpression | t.JSXMemberExpression): string {
|
||||
let objectName;
|
||||
let propertyName;
|
||||
|
||||
if (t.isIdentifier(node.object) || t.isJSXIdentifier(node.object)) {
|
||||
objectName = node.object.name;
|
||||
}
|
||||
|
||||
if (t.isIdentifier(node.property) || t.isJSXIdentifier(node.property)) {
|
||||
propertyName = node.property.name;
|
||||
}
|
||||
|
||||
if (t.isMemberExpression(node.object) || t.isJSXMemberExpression(node.object)) {
|
||||
objectName = getNameByMemberExpression(node.object);
|
||||
}
|
||||
|
||||
if (t.isMemberExpression(node.property) || t.isJSXMemberExpression(node.property)) {
|
||||
propertyName = getNameByMemberExpression(node.property);
|
||||
}
|
||||
|
||||
return `${objectName}.${propertyName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 的 jsxAttributeName 或 objectPropertyKey 转换为 js value
|
||||
* @param node jsxAttributeName or objectPropertyKey
|
||||
* @returns simple js value
|
||||
*/
|
||||
export function keyNode2value(node: t.Node) {
|
||||
if (!node) {
|
||||
logger.error('invalid property key', node);
|
||||
return;
|
||||
}
|
||||
|
||||
let ret;
|
||||
|
||||
switch (node.type) {
|
||||
case 'Identifier':
|
||||
case 'JSXIdentifier':
|
||||
ret = node.name;
|
||||
break;
|
||||
case 'StringLiteral':
|
||||
ret = `"${node.value}"`;
|
||||
break;
|
||||
case 'NumericLiteral':
|
||||
ret = node.value;
|
||||
break;
|
||||
case 'MemberExpression':
|
||||
ret = getNameByMemberExpression(node);
|
||||
break;
|
||||
case 'JSXMemberExpression':
|
||||
ret = getNameByMemberExpression(node);
|
||||
break;
|
||||
default:
|
||||
logger.error('unknown property key', node);
|
||||
break;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 t.Node 生成为字符串代码
|
||||
* @param node
|
||||
* @returns
|
||||
*/
|
||||
export function node2code(node: t.Node) {
|
||||
let ret = '';
|
||||
switch (node.type) {
|
||||
case 'StringLiteral':
|
||||
case 'NumericLiteral':
|
||||
ret = node.extra.raw as string;
|
||||
break;
|
||||
case 'BooleanLiteral':
|
||||
ret = `${node.value}`;
|
||||
break;
|
||||
case 'NullLiteral':
|
||||
ret = 'null';
|
||||
break;
|
||||
default:
|
||||
ret = expression2code(node as t.Expression);
|
||||
break;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 t.Node 生成为 js 值
|
||||
* @param node ast node
|
||||
* @param hasExpressionWrapper 是否包裹表达式
|
||||
* @returns a plain javascript value
|
||||
*/
|
||||
export function node2value(node: t.Node, hasExpressionWrapper = true): any {
|
||||
let ret;
|
||||
switch (node.type) {
|
||||
case 'StringLiteral':
|
||||
case 'NumericLiteral':
|
||||
case 'BooleanLiteral': {
|
||||
ret = node.value;
|
||||
break;
|
||||
}
|
||||
case 'NullLiteral':
|
||||
ret = null;
|
||||
break;
|
||||
case 'Identifier': // {data}
|
||||
case 'MemberExpression': // {this.props.data}
|
||||
case 'OptionalMemberExpression': // {a?.b}
|
||||
case 'UnaryExpression': // {!false}
|
||||
case 'ArrowFunctionExpression': // {() => {}}
|
||||
case 'TemplateLiteral': // {`hello ${text}`}
|
||||
case 'ConditionalExpression': // {a ? 'foo' : 'bar'}
|
||||
case 'LogicalExpression': // { a || b}
|
||||
case 'BinaryExpression': // { a + b}
|
||||
case 'TaggedTemplateExpression': // {css``}
|
||||
case 'CallExpression': // {[1,2,3].map(fn)}
|
||||
case 'JSXElement': // {<Box>hello</Box>}
|
||||
case 'JSXFragment': // <><Box /></>
|
||||
ret = expression2code(node);
|
||||
if (hasExpressionWrapper) {
|
||||
ret = `{${ret}}`;
|
||||
}
|
||||
break;
|
||||
case 'ObjectExpression': {
|
||||
ret = node.properties.reduce((prev, propertyNode) => {
|
||||
if (propertyNode.type === 'ObjectProperty') {
|
||||
const key = keyNode2value(propertyNode.key);
|
||||
const value = node2value(propertyNode.value, hasExpressionWrapper);
|
||||
// key 可能是字符串,也可能是数字
|
||||
prev[key] = value;
|
||||
}
|
||||
// FIXME: property is a SpreadElement
|
||||
return prev;
|
||||
}, {});
|
||||
break;
|
||||
}
|
||||
case 'ArrayExpression': {
|
||||
ret = node.elements.map((elementNode) => node2value(elementNode, hasExpressionWrapper));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
logger.error('unknown ast node:', node);
|
||||
break;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* jsx 属性值节点转为 js value
|
||||
*/
|
||||
export function jsxAttributeValueNode2value(node: t.Node): any {
|
||||
// e.g. <Checkbox checked /> 此时没有 value node
|
||||
if (!node) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let ret;
|
||||
switch (node.type) {
|
||||
case 'JSXExpressionContainer':
|
||||
// <Foo bar={a}>
|
||||
// <Foo bar={a.b}>
|
||||
// <Foo bar={2.2}>
|
||||
// <Foo bar={{}}>
|
||||
// <Foo bar={[]}>
|
||||
ret = jsxAttributeValueNode2value(node.expression);
|
||||
break;
|
||||
default:
|
||||
ret = node2value(node);
|
||||
break;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './generate';
|
||||
export * from './parse';
|
||||
export * from './traverse';
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* code to ast
|
||||
*/
|
||||
import { parse, parseExpression, ParserOptions } from '@babel/parser';
|
||||
import * as t from '@babel/types';
|
||||
import {
|
||||
logger,
|
||||
isValidObjectString,
|
||||
isVariableString,
|
||||
getVariableContent,
|
||||
} from '@music163/tango-helpers';
|
||||
import { isPlainObject } from '../object';
|
||||
|
||||
// @see https://babeljs.io/docs/en/babel-parser#pluginss
|
||||
const babelParserConfig: ParserOptions = {
|
||||
sourceType: 'module',
|
||||
plugins: [
|
||||
'jsx',
|
||||
'doExpressions',
|
||||
'objectRestSpread',
|
||||
'decorators-legacy',
|
||||
'classProperties',
|
||||
'asyncGenerators',
|
||||
'functionBind',
|
||||
'functionSent',
|
||||
'dynamicImport',
|
||||
'optionalChaining',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测代码是否是合法的代码
|
||||
* @param code
|
||||
* @returns true 为合法代码,false 为非法代码
|
||||
*/
|
||||
export function isValidCode(code: string) {
|
||||
try {
|
||||
parse(code, babelParserConfig);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测代码是否是合法的表达式代码
|
||||
* @param code
|
||||
* @returns
|
||||
*/
|
||||
export function isValidExpressionCode(code: string) {
|
||||
try {
|
||||
parseExpression(code, babelParserConfig);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将源代码解析为一棵完整的 ast 树 t.File
|
||||
* @param code
|
||||
* @returns
|
||||
*/
|
||||
export function code2ast(code: string): t.File {
|
||||
try {
|
||||
return parse(code, babelParserConfig);
|
||||
} catch (err) {
|
||||
logger.error('[code2ast failed!]', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将代码片段解析为 ast 节点
|
||||
* @example <Button>hello</Button>
|
||||
* @example { foo: 'foo' }
|
||||
* @example [{ foo: 'bar' }]
|
||||
* @example () => {}
|
||||
* @param code 输入字符串
|
||||
* @returns
|
||||
*/
|
||||
export function code2expression(code: string) {
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.endsWith(';')) {
|
||||
code = code.slice(0, -1);
|
||||
}
|
||||
|
||||
let expNode;
|
||||
try {
|
||||
expNode = t.cloneNode(parseExpression(code, babelParserConfig), false, true);
|
||||
} catch (err) {
|
||||
console.error('invalid code', err);
|
||||
expNode = t.identifier('undefined');
|
||||
}
|
||||
return expNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表达式代码片段转为 ast 树
|
||||
* @param code
|
||||
* @returns File
|
||||
*/
|
||||
export function expressionCode2ast(code: string) {
|
||||
if (isVariableString(code)) {
|
||||
code = getVariableContent(code);
|
||||
}
|
||||
const node = code2expression(code);
|
||||
return t.file(t.program([t.blockStatement([t.expressionStatement(node)])]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 js 值解析为 t.Node
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
export function value2node(
|
||||
value: any,
|
||||
):
|
||||
| t.NullLiteral
|
||||
| t.Identifier
|
||||
| t.NumericLiteral
|
||||
| t.StringLiteral
|
||||
| t.BooleanLiteral
|
||||
| t.Expression {
|
||||
let ret;
|
||||
switch (typeof value) {
|
||||
case 'number':
|
||||
ret = t.numericLiteral(value);
|
||||
break;
|
||||
case 'string':
|
||||
if (isVariableString(value)) {
|
||||
// 再检查是否是表达式容器,例如 {this.foo}, {1}
|
||||
const innerString = getVariableContent(value);
|
||||
ret = code2expression(innerString);
|
||||
} else {
|
||||
ret = t.stringLiteral(value);
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
ret = t.booleanLiteral(value);
|
||||
break;
|
||||
case 'function':
|
||||
ret = code2expression(String(value)) as t.ArrowFunctionExpression | t.FunctionExpression;
|
||||
break;
|
||||
case 'object': {
|
||||
if (value === null) {
|
||||
ret = t.nullLiteral();
|
||||
} else if (isPlainObject(value)) {
|
||||
ret = object2node(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
ret = t.arrayExpression(value.map((val) => value2node(val)));
|
||||
} else {
|
||||
ret = t.identifier('undefined');
|
||||
logger.error('value2node: not support value!', ret);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'undefined':
|
||||
ret = t.identifier('undefined');
|
||||
break;
|
||||
default: {
|
||||
logger.error(`value2node: unsupport value <${value}>`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 js 普通对象解析为 t.Node
|
||||
*/
|
||||
export function object2node(
|
||||
obj: object,
|
||||
getValueNode: (value: any, key?: string) => t.Expression = value2node,
|
||||
) {
|
||||
if (!isPlainObject(obj)) {
|
||||
return value2node(obj);
|
||||
}
|
||||
return t.objectExpression(
|
||||
Object.keys(obj).map((key) => {
|
||||
const valNode = getValueNode(obj[key], key);
|
||||
return t.objectProperty(t.identifier(key), valNode);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function code2jsxAttributeValueNode(code: string) {
|
||||
return t.jsxExpressionContainer(code2expression(code));
|
||||
}
|
||||
|
||||
export function value2jsxAttributeValueNode(value: any) {
|
||||
let ret;
|
||||
switch (typeof value) {
|
||||
// FIXME: 重构这个逻辑,是不是统一当成 code 处理
|
||||
case 'string': {
|
||||
if (value.length > 1) {
|
||||
value = value.trim();
|
||||
}
|
||||
if (isValidObjectString(value)) {
|
||||
// 先检查是否是对象字符串
|
||||
ret = t.jsxExpressionContainer(code2expression(value));
|
||||
} else if (isVariableString(value)) {
|
||||
// 再检查是否是表达式容器,例如 {this.foo}, {1}
|
||||
const innerString = getVariableContent(value);
|
||||
ret = t.jsxExpressionContainer(code2expression(innerString));
|
||||
} else {
|
||||
ret = t.stringLiteral(value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
ret = t.jsxExpressionContainer(value2node(value));
|
||||
break;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function value2jsxChildrenValueNode(value: any) {
|
||||
let ret: t.JSXElement | t.JSXFragment | t.JSXExpressionContainer | t.JSXSpreadChild | t.JSXText;
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
if (isVariableString(value)) {
|
||||
const innerString = getVariableContent(value);
|
||||
ret = t.jsxExpressionContainer(code2expression(innerString));
|
||||
} else {
|
||||
ret = t.jsxText(value);
|
||||
}
|
||||
break;
|
||||
case 'number':
|
||||
ret = t.jsxText(String(value));
|
||||
break;
|
||||
case 'object':
|
||||
// value 为 JSXElement[]的情况下直接return
|
||||
return value as t.JSXElement[];
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return ret ? [ret] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 给定具体的 value 值,生成 JSXAttribute
|
||||
* @param name 属性名
|
||||
* @param value 属性值代码
|
||||
* @returns
|
||||
*/
|
||||
export function makeJSXAttribute(name: string, value: any) {
|
||||
return t.jsxAttribute(t.jsxIdentifier(name), value2jsxAttributeValueNode(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 给定具体的 value 代码,生成 JSXAttribute
|
||||
* @param name 属性名
|
||||
* @param valueCode 属性值代码
|
||||
* @returns
|
||||
*/
|
||||
export function makeJSXAttributeByCode(name: string, valueCode: string) {
|
||||
return t.jsxAttribute(t.jsxIdentifier(name), code2jsxAttributeValueNode(valueCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 JSXElement
|
||||
* @param name
|
||||
* @param attributes
|
||||
* @param children
|
||||
* @param selfClosing
|
||||
* @returns
|
||||
*/
|
||||
export function makeJSXElement(
|
||||
name: string,
|
||||
attributes: t.JSXAttribute[],
|
||||
children: t.JSXElement['children'],
|
||||
selfClosing: boolean,
|
||||
) {
|
||||
return t.jsxElement(
|
||||
t.jsxOpeningElement(t.jsxIdentifier(name), attributes),
|
||||
t.jsxClosingElement(t.jsxIdentifier(name)),
|
||||
children ?? [],
|
||||
selfClosing,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
type IdGeneratorOptionsType = { prefix?: string };
|
||||
/**
|
||||
* ID 生成器
|
||||
*/
|
||||
export class IdGenerator {
|
||||
/**
|
||||
* ID 前缀
|
||||
*/
|
||||
private readonly prefix: string;
|
||||
/**
|
||||
* 记录组件 ID 记录
|
||||
*/
|
||||
private map = new Map<string, string[]>();
|
||||
|
||||
constructor(options?: IdGeneratorOptionsType) {
|
||||
this.prefix = options?.prefix ? encodeURIComponent(options.prefix) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新组件记录
|
||||
* @param component
|
||||
*/
|
||||
setItem(component: string, id?: string) {
|
||||
if (this.map.has(component)) {
|
||||
const record = this.map.get(component);
|
||||
if (id && !record.includes(id)) {
|
||||
record.push(id);
|
||||
}
|
||||
this.map.set(component, record);
|
||||
} else {
|
||||
this.map.set(component, []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取组件 ID
|
||||
* @param component
|
||||
* @returns
|
||||
*/
|
||||
generateId(component: string) {
|
||||
const size = this.map.get(component)?.length + 1 || 1;
|
||||
let id = `${component}:${size}`;
|
||||
if (this.prefix) {
|
||||
id = `${this.prefix}:${id}`;
|
||||
}
|
||||
this.setItem(component, id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './array';
|
||||
export * from './ast';
|
||||
export * from './assert';
|
||||
export * from './string';
|
||||
export * from './object';
|
||||
export * from './prototype';
|
||||
export * from './schema-helpers';
|
||||
export * from './id-generator';
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ComponentPrototypeType } from '@music163/tango-helpers';
|
||||
import { ImportDeclarationPayloadType } from '../types';
|
||||
|
||||
/**
|
||||
* 是否是简单的 js 对象
|
||||
* @param value
|
||||
* @returns
|
||||
* @see https://github.com/sindresorhus/is-plain-obj/blob/main/index.js
|
||||
*/
|
||||
export function isPlainObject(value: any) {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return (
|
||||
(prototype === null ||
|
||||
prototype === Object.prototype ||
|
||||
Object.getPrototypeOf(prototype) === null) &&
|
||||
!(Symbol.toStringTag in value) &&
|
||||
!(Symbol.iterator in value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拷贝对象
|
||||
* @param obj 原始对象
|
||||
* @param omitKeys 忽略掉 key 列表
|
||||
* @returns 返回拷贝后的对象
|
||||
*/
|
||||
export function copyObject(obj: object, omitKeys: string[]) {
|
||||
const ret = {};
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (!omitKeys.includes(key)) {
|
||||
ret[key] = obj[key];
|
||||
}
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将对象的序列化字符串转为原始的 js 对象
|
||||
* @param str
|
||||
* @param defaultValue
|
||||
* @returns
|
||||
*/
|
||||
export function string2object(str: string, defaultValue?: any) {
|
||||
// eslint-disable-next-line no-new-func
|
||||
let ret = new Function(`return ${str}`);
|
||||
ret = ret ? ret() : defaultValue;
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得传入对象的类型
|
||||
* @param obj
|
||||
* @returns
|
||||
*/
|
||||
export function typeOf(obj?: any) {
|
||||
return {}.toString.call(obj).split(' ')[1].slice(0, -1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入列表解析为导入声明对象
|
||||
* @param names
|
||||
* @param nameMap
|
||||
* @returns
|
||||
*/
|
||||
export function namesToImportDeclarations(
|
||||
names: string[],
|
||||
nameMap: Record<string, { package: string; isDefault?: boolean }>,
|
||||
) {
|
||||
const map = {};
|
||||
names.forEach((name) => {
|
||||
const mod = nameMap[name];
|
||||
if (mod) {
|
||||
updateMod(map, mod.package, name, mod.isDefault, !map[mod.package]);
|
||||
}
|
||||
});
|
||||
return Object.keys(map).map((sourcePath) => ({
|
||||
sourcePath,
|
||||
...map[sourcePath],
|
||||
})) as ImportDeclarationPayloadType[];
|
||||
}
|
||||
|
||||
function updateMod(
|
||||
map: any,
|
||||
fromPackage: string,
|
||||
specifier: string,
|
||||
isDefault = false,
|
||||
shouldInit = true,
|
||||
) {
|
||||
if (shouldInit) {
|
||||
map[fromPackage] = {};
|
||||
}
|
||||
if (isDefault) {
|
||||
map[fromPackage].defaultSpecifier = specifier;
|
||||
} else if (map[fromPackage].specifiers) {
|
||||
map[fromPackage].specifiers.push(specifier);
|
||||
} else {
|
||||
map[fromPackage].specifiers = [specifier];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
ComponentPropType,
|
||||
ComponentPrototypeType,
|
||||
TangoSchemaTreeNodeType,
|
||||
isNil,
|
||||
isVariableString,
|
||||
logger,
|
||||
uuid,
|
||||
} from '@music163/tango-helpers';
|
||||
import * as t from '@babel/types';
|
||||
import { getRelativePath, isFilepath } from './string';
|
||||
import type { ImportDeclarationPayloadType } from '../types';
|
||||
import { code2expression } from './ast';
|
||||
|
||||
/**
|
||||
* 根据组件的 prototype 生成 ImportDeclarationPayload
|
||||
*/
|
||||
export function getImportDeclarationPayloadByPrototype(
|
||||
prototype: ComponentPrototypeType,
|
||||
relativeFilepath?: string,
|
||||
): ImportDeclarationPayloadType {
|
||||
let defaultSpecifier;
|
||||
let specifiers;
|
||||
|
||||
if (prototype.exportType === 'defaultExport') {
|
||||
defaultSpecifier = prototype.name;
|
||||
specifiers = prototype.relatedImports || [];
|
||||
} else {
|
||||
specifiers = [...(prototype.relatedImports || [])];
|
||||
if (prototype.type !== 'snippet') {
|
||||
specifiers.push(prototype.name);
|
||||
}
|
||||
}
|
||||
|
||||
let sourcePath = prototype.package;
|
||||
if (relativeFilepath && isFilepath(sourcePath)) {
|
||||
sourcePath = getRelativePath(relativeFilepath, sourcePath);
|
||||
}
|
||||
|
||||
return {
|
||||
defaultSpecifier,
|
||||
specifiers,
|
||||
sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 key-value 生成 prop={value} 字符串
|
||||
* @param key
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
function getPropKeyValuePair(item: ComponentPropType, generateValue: (...args: any[]) => string) {
|
||||
const key = item.name;
|
||||
|
||||
let value = item.initValue;
|
||||
|
||||
if (!value && item.autoInitValue) {
|
||||
value = generateValue(3);
|
||||
}
|
||||
|
||||
if (isNil(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case 'number':
|
||||
case 'boolean': {
|
||||
value = `{${value}}`;
|
||||
break;
|
||||
}
|
||||
case 'object': {
|
||||
// TIP: bugfix 如果 object 里有 jsx 或者 function 会失败
|
||||
try {
|
||||
value = `{${JSON.stringify(value)}}`;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'function': {
|
||||
value = `{${(value as object).toString()}}`;
|
||||
break;
|
||||
}
|
||||
case 'string': {
|
||||
if (!isVariableString(value)) {
|
||||
// 不是变量字符串
|
||||
value = `"${value}"`;
|
||||
} else {
|
||||
// 如果是变量字符串,无需处理
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return `${key}=${value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* prototype -> <Button>hello</Button>
|
||||
* @param prototype
|
||||
*/
|
||||
export function prototype2code(prototype: ComponentPrototypeType) {
|
||||
let code;
|
||||
switch (prototype.type) {
|
||||
case 'snippet':
|
||||
code = prototype.initChildren || prototype.defaultChildren;
|
||||
break;
|
||||
default: {
|
||||
const keys =
|
||||
prototype.props?.reduce((acc, item) => {
|
||||
const pair = getPropKeyValuePair(item, (fractionDigits: number) =>
|
||||
uuid(prototype.name, fractionDigits),
|
||||
);
|
||||
return pair ? ` ${acc} ${pair}` : acc;
|
||||
}, '') || '';
|
||||
|
||||
if (prototype.hasChildren) {
|
||||
code = `<${prototype.name} ${keys}>${
|
||||
prototype.initChildren || prototype.defaultChildren || ''
|
||||
}</${prototype.name}>`;
|
||||
} else {
|
||||
code = `<${prototype.name} ${keys.trim()} />`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 prototype 信息生成 t.JSXElement
|
||||
* @example ButtonPrototype -> <Button>hello</Button> -> t.JSXElement
|
||||
* @param code
|
||||
*/
|
||||
export function prototype2jsxElement(prototype: ComponentPrototypeType) {
|
||||
const code = prototype2code(prototype);
|
||||
return code2expression(code) as t.JSXElement;
|
||||
}
|
||||
|
||||
export function prototype2schemaNode(prototype: ComponentPrototypeType) {
|
||||
const node: TangoSchemaTreeNodeType = {
|
||||
id: uuid(`${prototype.name}:`),
|
||||
component: prototype.name,
|
||||
props: {},
|
||||
};
|
||||
prototype.props.forEach((prop) => {
|
||||
if ('initValue' in prop) {
|
||||
node.props[prop.name] = prop.initValue;
|
||||
}
|
||||
});
|
||||
if (prototype.initChildren) {
|
||||
try {
|
||||
const children = JSON.parse(prototype.initChildren);
|
||||
node.children = children;
|
||||
} catch (err) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { parseDndId, uuid } from '@music163/tango-helpers';
|
||||
|
||||
// { id: 1, component, props: { id: 1 }, children: [ { id: 2, component } ] }
|
||||
export function deepCloneNode(obj: any, component?: string): any {
|
||||
function deepCloneObject() {
|
||||
const target = {};
|
||||
for (const key in obj) {
|
||||
if (Object.hasOwn(obj, key)) {
|
||||
target[key] = deepCloneNode(obj[key], component);
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function deepCloneElementNode() {
|
||||
const target: any = deepCloneObject();
|
||||
let name = component || target.component;
|
||||
if (target.id) {
|
||||
const dnd = parseDndId(target.id);
|
||||
name = dnd.component || name;
|
||||
}
|
||||
target.id = uuid(`${name}:`);
|
||||
return target;
|
||||
}
|
||||
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => deepCloneNode(item, component));
|
||||
}
|
||||
|
||||
if (!obj.component) {
|
||||
return deepCloneObject();
|
||||
}
|
||||
|
||||
return deepCloneElementNode();
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import path from 'path';
|
||||
import { value2node, expression2code } from './ast';
|
||||
import { FileType } from './../types';
|
||||
|
||||
/**
|
||||
* 推断JS模块类型
|
||||
*/
|
||||
export function inferFileType(filename: string): FileType {
|
||||
// 增加 tangoConfigJson Module
|
||||
if (/\/tango\.config\.json$/.test(filename)) {
|
||||
return FileType.TangoConfigJson;
|
||||
}
|
||||
|
||||
if (/\/appJson\.json$/.test(filename)) {
|
||||
return FileType.AppJson;
|
||||
}
|
||||
|
||||
if (/\/package\.json$/.test(filename)) {
|
||||
return FileType.PackageJson;
|
||||
}
|
||||
|
||||
if (/\/routes\.js$/.test(filename)) {
|
||||
return FileType.RouteModule;
|
||||
}
|
||||
|
||||
// 所有 pages 下的 js 文件均认为是有效的 viewModule
|
||||
if (/\/pages\/.+\.jsx?$/.test(filename)) {
|
||||
return FileType.JsxViewModule;
|
||||
}
|
||||
|
||||
// 所有 pages 下的 js 文件均认为是有效的 viewModule
|
||||
if (/\/pages\/.+\.schema\.json?$/.test(filename)) {
|
||||
return FileType.JsonViewModule;
|
||||
}
|
||||
|
||||
if (/\/services\/.+\.js$/.test(filename)) {
|
||||
return FileType.ServiceModule;
|
||||
}
|
||||
|
||||
if (/service\.js$/.test(filename)) {
|
||||
return FileType.ServiceModule;
|
||||
}
|
||||
|
||||
if (/\/stores\/index\.js$/.test(filename)) {
|
||||
return FileType.StoreEntryModule;
|
||||
}
|
||||
|
||||
if (/\/stores\/.+\.js$/.test(filename)) {
|
||||
return FileType.StoreModule;
|
||||
}
|
||||
|
||||
if (/\/blocks\/[\w-]+\/index\.js/.test(filename)) {
|
||||
return FileType.BlockEntryModule;
|
||||
}
|
||||
|
||||
if (/\.jsx?$/.test(filename)) {
|
||||
return FileType.Module;
|
||||
}
|
||||
|
||||
if (/\.json$/.test(filename)) {
|
||||
return FileType.Json;
|
||||
}
|
||||
|
||||
if (/\.less$/.test(filename)) {
|
||||
return FileType.Less;
|
||||
}
|
||||
|
||||
if (/\.scss$/.test(filename)) {
|
||||
return FileType.Scss;
|
||||
}
|
||||
|
||||
return FileType.File;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断组件名是否合法
|
||||
* @example Button -> valid
|
||||
* @example div -> invalid
|
||||
* @param name
|
||||
* @returns
|
||||
*/
|
||||
export function isValidComponentName(name: string) {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstChar = name.charAt(0);
|
||||
return firstChar === firstChar.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 转为驼峰
|
||||
* @example foo -> foo
|
||||
* @example foo-bar -> fooBar
|
||||
* @param str
|
||||
* @returns
|
||||
*/
|
||||
export function camelCase(str: string) {
|
||||
return str.replace(/\W+(.)/g, (match, chr) => {
|
||||
return chr.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将输入字符串转换为大驼峰格式
|
||||
* @example about -> About
|
||||
* @example not-found -> NotFound
|
||||
* @param str
|
||||
*/
|
||||
export function upperCamelCase(str: string) {
|
||||
const text = camelCase(str.toLowerCase());
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 filename 中解析获得 moduleName
|
||||
* @example /stores/user.js -> user
|
||||
* @example /services/foo-bar.js -> fooBar
|
||||
* @param filename
|
||||
*/
|
||||
export function getModuleNameByFilename(filename: string) {
|
||||
const parts = filename.split('/');
|
||||
let name = parts[parts.length - 1];
|
||||
name = name.split('.')[0];
|
||||
return camelCase(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于路由名生成文件路径
|
||||
* @param routePath 路由地址
|
||||
* @param baseDir base dir
|
||||
* @param ext 后缀名
|
||||
* @returns
|
||||
*/
|
||||
export function getFilepath(routePath: string, baseDir: string, ext = '') {
|
||||
if (routePath.startsWith('/')) {
|
||||
routePath = routePath.substring(1);
|
||||
}
|
||||
const filename = routePath.replaceAll('/:', '@').split('/').join('-');
|
||||
if (!baseDir.endsWith('/')) {
|
||||
baseDir = `${baseDir}/`;
|
||||
}
|
||||
return `${baseDir}${filename}${ext}`;
|
||||
}
|
||||
|
||||
export function getPrivilegeCode(appName: string, routePath: string) {
|
||||
return `${appName}@${routePath.replaceAll('/', '%')}`;
|
||||
}
|
||||
|
||||
const prettier = (window as any).prettier;
|
||||
const prettierPlugins = (window as any).prettierPlugins;
|
||||
|
||||
/**
|
||||
* 格式化代码
|
||||
* @param code original source code
|
||||
* @param parser prettier parser, see https://prettier.io/docs/en/options.html#parser
|
||||
* @returns the formatted code
|
||||
*/
|
||||
export function formatCode(code: string, parser = 'babel') {
|
||||
if (prettier && prettierPlugins) {
|
||||
return prettier.format(code, { parser, plugins: prettierPlugins });
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 js value 转换为代码字符串
|
||||
*/
|
||||
export function value2code(value: any) {
|
||||
const node = value2node(value);
|
||||
const code = expression2code(node);
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否匹配路由
|
||||
* @example isPathnameMatchRoute('/user/123', '/users/:id') -> true
|
||||
*/
|
||||
export function isPathnameMatchRoute(pathname: string, route: string) {
|
||||
if (!pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
pathname = pathname.split('?')[0];
|
||||
|
||||
if (pathname === route) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const str = route.replaceAll(/:\w+/gi, '\\w+');
|
||||
const pt = new RegExp(`^${str}$`, 'i');
|
||||
return pt.test(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件路径计算区块的名字
|
||||
* @param filename 文件路径
|
||||
* @return 返回计算后的区块名(大驼峰)
|
||||
*/
|
||||
export function getBlockNameByFilename(filename: string) {
|
||||
const name = filename.split('/').slice(-2, -1)[0];
|
||||
return upperCamelCase(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXME: 有问题,需要优化下
|
||||
* 基于 from 文件的地址计算 to 文件的相对引用路径
|
||||
* @param from
|
||||
* @param to
|
||||
*/
|
||||
export function getRelativePath(from: string, to: string) {
|
||||
const fromFolder = path.dirname(from);
|
||||
return path.relative(fromFolder, to);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断给定字符串是否是文件路径
|
||||
* @example ./pages/index.js -- yes
|
||||
* @example ../pages/index.js -- yes
|
||||
* @example /src/pages/index.js -- yes
|
||||
* @example @music163/tango-designer -- no
|
||||
* @param str
|
||||
*/
|
||||
export function isFilepath(str: string) {
|
||||
return /^(\.\.?\/|\/).*\.[a-z]+$/.test(str);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './models';
|
||||
export * from './factory';
|
||||
export * from './types';
|
||||
export * from './helpers';
|
||||
@@ -0,0 +1,176 @@
|
||||
import { action, computed, makeObservable, observable, toJS } from 'mobx';
|
||||
import { IWorkspace } from './interfaces';
|
||||
|
||||
export type SimulatorNameType = 'desktop' | 'phone';
|
||||
|
||||
type SimulatorType = {
|
||||
name: SimulatorNameType;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type ViewportBoundingType = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type DesignerViewType = 'design' | 'code';
|
||||
|
||||
type DesignerOptionsType = { workspace: IWorkspace; simulator?: SimulatorNameType | SimulatorType };
|
||||
|
||||
const simulatorTypes: Record<string, SimulatorType> = {
|
||||
desktop: {
|
||||
name: 'desktop',
|
||||
width: 1366,
|
||||
height: 800,
|
||||
},
|
||||
phone: {
|
||||
name: 'phone',
|
||||
width: 375,
|
||||
height: 812,
|
||||
},
|
||||
};
|
||||
|
||||
export class Designer {
|
||||
/**
|
||||
* 当前的沙箱模拟器类型
|
||||
*/
|
||||
_simulator: SimulatorType = simulatorTypes.desktop;
|
||||
|
||||
/**
|
||||
* 当前的视图尺寸
|
||||
*/
|
||||
_viewport: ViewportBoundingType = {
|
||||
width: 1366,
|
||||
height: 800,
|
||||
};
|
||||
|
||||
/**
|
||||
* 当前激活的视图
|
||||
*/
|
||||
_activeView: DesignerViewType = 'design';
|
||||
|
||||
/**
|
||||
* 当前选中的侧边栏面板
|
||||
*/
|
||||
_activeSidebarPanel = '';
|
||||
|
||||
/**
|
||||
* 是否显示智能引导
|
||||
*/
|
||||
_showSmartWizard = false;
|
||||
|
||||
/**
|
||||
* 是否显示右侧面板
|
||||
*/
|
||||
_showRightPanel = true;
|
||||
|
||||
/**
|
||||
* 是否预览模式
|
||||
*/
|
||||
_isPreview = false;
|
||||
|
||||
private readonly workspace: IWorkspace;
|
||||
|
||||
get simulator(): SimulatorType {
|
||||
return toJS(this._simulator);
|
||||
}
|
||||
|
||||
get viewport() {
|
||||
return toJS(this._viewport);
|
||||
}
|
||||
|
||||
get activeView() {
|
||||
return this._activeView;
|
||||
}
|
||||
|
||||
get isPreview() {
|
||||
return this._isPreview;
|
||||
}
|
||||
|
||||
get showSmartWizard() {
|
||||
return this._showSmartWizard;
|
||||
}
|
||||
|
||||
get activeSidebarPanel() {
|
||||
return this._activeSidebarPanel;
|
||||
}
|
||||
|
||||
get showRightPanel() {
|
||||
return this._showRightPanel;
|
||||
}
|
||||
|
||||
constructor(options: DesignerOptionsType) {
|
||||
this.workspace = options.workspace;
|
||||
if (options.simulator) {
|
||||
this.setSimulator(options.simulator);
|
||||
}
|
||||
makeObservable(this, {
|
||||
_simulator: observable,
|
||||
_viewport: observable,
|
||||
_activeView: observable,
|
||||
_activeSidebarPanel: observable,
|
||||
_showSmartWizard: observable,
|
||||
_showRightPanel: observable,
|
||||
_isPreview: observable,
|
||||
simulator: computed,
|
||||
viewport: computed,
|
||||
activeView: computed,
|
||||
activeSidebarPanel: computed,
|
||||
isPreview: computed,
|
||||
showRightPanel: computed,
|
||||
showSmartWizard: computed,
|
||||
setSimulator: action,
|
||||
setViewport: action,
|
||||
setActiveView: action,
|
||||
setActiveSidebarPanel: action,
|
||||
closeSidebarPanel: action,
|
||||
toggleRightPanel: action,
|
||||
toggleSmartWizard: action,
|
||||
toggleIsPreview: action,
|
||||
});
|
||||
}
|
||||
|
||||
setSimulator(value: SimulatorType | SimulatorNameType) {
|
||||
if (typeof value === 'string') {
|
||||
this._simulator = simulatorTypes[value];
|
||||
} else {
|
||||
this._simulator = value;
|
||||
}
|
||||
}
|
||||
|
||||
setViewport(value: ViewportBoundingType) {
|
||||
this._viewport = value;
|
||||
}
|
||||
|
||||
setActiveView(view: DesignerViewType) {
|
||||
this._activeView = view;
|
||||
}
|
||||
|
||||
setActiveSidebarPanel(panel: string) {
|
||||
if (panel && panel !== this.activeSidebarPanel) {
|
||||
this._activeSidebarPanel = panel;
|
||||
} else {
|
||||
this._activeSidebarPanel = '';
|
||||
}
|
||||
}
|
||||
|
||||
closeSidebarPanel() {
|
||||
this._activeSidebarPanel = '';
|
||||
}
|
||||
|
||||
toggleSmartWizard(value: boolean) {
|
||||
this._showSmartWizard = value;
|
||||
}
|
||||
|
||||
toggleRightPanel(value?: boolean) {
|
||||
this._showRightPanel = value ?? !this._showRightPanel;
|
||||
}
|
||||
|
||||
toggleIsPreview(value: boolean) {
|
||||
this._isPreview = value ?? !this._isPreview;
|
||||
if (value) {
|
||||
this.workspace.selectSource.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { action, computed, makeObservable, observable } from 'mobx';
|
||||
import { ISelectedItemData } from '@music163/tango-helpers';
|
||||
import { DropTarget } from './drop-target';
|
||||
import { IWorkspace } from './interfaces';
|
||||
|
||||
/**
|
||||
* 拖拽来源类,被拖拽的物体
|
||||
*/
|
||||
export class DragSource {
|
||||
/**
|
||||
* 是否处于拖拽状态
|
||||
*/
|
||||
isDragging: boolean;
|
||||
|
||||
/**
|
||||
* 选中的目标元素数据
|
||||
*/
|
||||
data: ISelectedItemData;
|
||||
|
||||
/**
|
||||
* 放置目标
|
||||
*/
|
||||
dropTarget: DropTarget;
|
||||
|
||||
private readonly workspace: IWorkspace;
|
||||
|
||||
get node() {
|
||||
return this.workspace.getNode(this.data?.id, this.data?.filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对应的 prototype
|
||||
*/
|
||||
get prototype() {
|
||||
return this.workspace.getPrototype(this.data?.name);
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.data?.id;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.data?.name;
|
||||
}
|
||||
|
||||
get bounding() {
|
||||
return this.data?.bounding;
|
||||
}
|
||||
|
||||
constructor(workspace: IWorkspace) {
|
||||
this.workspace = workspace;
|
||||
this.data = null;
|
||||
this.isDragging = false;
|
||||
this.dropTarget = new DropTarget(workspace);
|
||||
|
||||
makeObservable(this, {
|
||||
data: observable,
|
||||
isDragging: observable,
|
||||
set: action,
|
||||
clear: action,
|
||||
node: computed,
|
||||
prototype: computed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新选中数据
|
||||
* @param props
|
||||
*/
|
||||
set(data: ISelectedItemData) {
|
||||
this.data = data;
|
||||
this.isDragging = !!data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
clear() {
|
||||
this.data = null;
|
||||
this.isDragging = false;
|
||||
this.dropTarget.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对应的 node
|
||||
* @deprecated
|
||||
*/
|
||||
getNode() {
|
||||
return this.node;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { action, computed, makeObservable, observable } from 'mobx';
|
||||
import { ISelectedItemData } from '@music163/tango-helpers';
|
||||
import { IWorkspace } from './interfaces';
|
||||
|
||||
export enum DropMethod {
|
||||
ReplaceNode = 'replaceNode', // 替换节点
|
||||
InsertBefore = 'insertBefore', // 插入节点,放置在前面
|
||||
InsertAfter = 'insertAfter', // 插入节点,放置在后面
|
||||
InsertChild = 'insertChild', // 插入子节点,放置在最后
|
||||
InsertFirstChild = 'insertFirstChild', // 插入子节点,放置在最前
|
||||
}
|
||||
|
||||
/**
|
||||
* 放置目标类
|
||||
*/
|
||||
export class DropTarget {
|
||||
/**
|
||||
* 插入方法
|
||||
*/
|
||||
method: DropMethod;
|
||||
/**
|
||||
* 放置的目标元素数据
|
||||
*/
|
||||
data: ISelectedItemData;
|
||||
|
||||
private readonly workspace: IWorkspace;
|
||||
|
||||
get node() {
|
||||
return this.workspace.getNode(this.data.id, this.data.filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对应的 prototype
|
||||
*/
|
||||
get prototype() {
|
||||
return this.data?.name ? this.workspace.getPrototype(this.data?.name) : null;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.data?.id;
|
||||
}
|
||||
|
||||
get bounding() {
|
||||
return this.data?.bounding;
|
||||
}
|
||||
|
||||
get display() {
|
||||
return this.data?.display;
|
||||
}
|
||||
|
||||
constructor(workspace: IWorkspace) {
|
||||
this.workspace = workspace;
|
||||
this.method = DropMethod.InsertAfter;
|
||||
this.data = null;
|
||||
|
||||
makeObservable(this, {
|
||||
method: observable,
|
||||
data: observable,
|
||||
set: action,
|
||||
clear: action,
|
||||
node: computed,
|
||||
});
|
||||
}
|
||||
|
||||
set(data: ISelectedItemData, method: DropMethod) {
|
||||
this.data = data;
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
clear() {
|
||||
this.data = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对应的 node
|
||||
* @deprecated
|
||||
*/
|
||||
getNode() {
|
||||
return this.node;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Designer } from './designer';
|
||||
import { IWorkspace } from './interfaces';
|
||||
|
||||
/**
|
||||
* 设计器引擎
|
||||
*/
|
||||
export class Engine {
|
||||
/**
|
||||
* 工作区状态
|
||||
*/
|
||||
workspace: IWorkspace;
|
||||
/**
|
||||
* 设计器状态
|
||||
*/
|
||||
designer: Designer;
|
||||
|
||||
constructor(options: Pick<Engine, 'workspace' | 'designer'>) {
|
||||
this.workspace = options.workspace;
|
||||
this.designer = options.designer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { action, computed, makeObservable, observable, toJS } from 'mobx';
|
||||
import { getValue, isNil, logger, setValue } from '@music163/tango-helpers';
|
||||
import type { FileType, ModulePropsType } from '../types';
|
||||
import { formatCode } from '../helpers';
|
||||
import { IWorkspace } from './interfaces';
|
||||
|
||||
/**
|
||||
* 普通文件,不进行 AST 解析
|
||||
*/
|
||||
export class TangoFile {
|
||||
readonly workspace: IWorkspace;
|
||||
/**
|
||||
* 文件名
|
||||
*/
|
||||
readonly filename: string;
|
||||
|
||||
/**
|
||||
* 文件类型
|
||||
*/
|
||||
readonly type: FileType;
|
||||
|
||||
/**
|
||||
* 最近修改的时间戳
|
||||
*/
|
||||
lastModified: number;
|
||||
|
||||
_code: string;
|
||||
_cleanCode: string;
|
||||
|
||||
get code() {
|
||||
return this._code;
|
||||
}
|
||||
|
||||
get cleanCode() {
|
||||
return this._cleanCode;
|
||||
}
|
||||
|
||||
constructor(workspace: IWorkspace, props: ModulePropsType, isSyncCode = true) {
|
||||
this.workspace = workspace;
|
||||
this.filename = props.filename;
|
||||
this.type = props.type;
|
||||
this.lastModified = Date.now();
|
||||
|
||||
// 这里主要是为了解决 umi ts 编译错误的问题,@see https://github.com/umijs/umi/issues/7594
|
||||
if (isSyncCode) {
|
||||
this.update(props.code);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件内容
|
||||
*/
|
||||
update(code?: string) {
|
||||
if (!isNil(code)) {
|
||||
this.lastModified = Date.now();
|
||||
this._code = code;
|
||||
this._cleanCode = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TangoLessFile extends TangoFile {
|
||||
constructor(workspace: IWorkspace, props: ModulePropsType) {
|
||||
super(workspace, props, false);
|
||||
this.update(props.code);
|
||||
makeObservable(this, {
|
||||
_code: observable,
|
||||
_cleanCode: observable,
|
||||
code: computed,
|
||||
cleanCode: computed,
|
||||
update: action,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class TangoJsonFile extends TangoFile {
|
||||
_object = {};
|
||||
|
||||
/**
|
||||
* @deprecated 使用 file.json 代替
|
||||
*/
|
||||
get object() {
|
||||
return toJS(this._object);
|
||||
}
|
||||
|
||||
get json() {
|
||||
return toJS(this._object);
|
||||
}
|
||||
|
||||
constructor(workspace: IWorkspace, props: ModulePropsType) {
|
||||
super(workspace, props, false);
|
||||
this.update(props.code);
|
||||
makeObservable(this, {
|
||||
_code: observable,
|
||||
_cleanCode: observable,
|
||||
_object: observable,
|
||||
code: computed,
|
||||
cleanCode: computed,
|
||||
object: computed,
|
||||
json: computed,
|
||||
update: action,
|
||||
setValue: action,
|
||||
});
|
||||
}
|
||||
|
||||
update(code?: string) {
|
||||
this.lastModified = Date.now();
|
||||
|
||||
if (isNil(code)) {
|
||||
// 基于最新的 json 同步代码
|
||||
let code = JSON.stringify(this._object);
|
||||
try {
|
||||
code = formatCode(code, 'json');
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return;
|
||||
}
|
||||
this._code = code;
|
||||
this._cleanCode = code;
|
||||
} else {
|
||||
try {
|
||||
// 基于传入的代码,同步 json 对象
|
||||
code = formatCode(code, 'json');
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return;
|
||||
}
|
||||
this._code = code;
|
||||
this._cleanCode = code;
|
||||
try {
|
||||
const json = JSON.parse(code);
|
||||
this._object = json;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径取值
|
||||
* @param valuePath
|
||||
* @returns
|
||||
*/
|
||||
getValue(valuePath: string) {
|
||||
return getValue(this.json, valuePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径设置值
|
||||
* @param valuePath
|
||||
* @param visitor
|
||||
*/
|
||||
setValue(valuePath: string, visitor: (targetValue: any) => any) {
|
||||
const target = this.getValue(valuePath);
|
||||
let next: unknown;
|
||||
if (typeof visitor === 'function') {
|
||||
next = visitor?.(target);
|
||||
} else {
|
||||
next = visitor;
|
||||
}
|
||||
if (next !== undefined) {
|
||||
setValue(this._object, valuePath, next);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径删除值
|
||||
* @param valuePath
|
||||
* @param visitor
|
||||
*/
|
||||
deleteValue(valuePath: string) {
|
||||
const pathList = valuePath.split('.');
|
||||
const lastPath = pathList.pop();
|
||||
const parentPath = pathList.join('.');
|
||||
let target;
|
||||
if (parentPath) {
|
||||
target = this.getValue(parentPath);
|
||||
} else {
|
||||
target = this.json;
|
||||
}
|
||||
if (!target) {
|
||||
return this;
|
||||
}
|
||||
delete target[lastPath];
|
||||
if (parentPath) {
|
||||
this.setValue(parentPath, target);
|
||||
} else {
|
||||
this._object = target;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { action, computed, makeObservable, observable, toJS } from 'mobx';
|
||||
import { IWorkspace } from './interfaces';
|
||||
|
||||
export enum HistoryMessage {
|
||||
InitView = 'initView',
|
||||
AddFile = 'addFile',
|
||||
RemoveFile = 'removeFile',
|
||||
UpdateDependency = 'updateDependency',
|
||||
RemoveDependency = 'removeDependency',
|
||||
RemoveNode = 'removeNode',
|
||||
ReplaceNode = 'replaceNode',
|
||||
CloneNode = 'cloneNode',
|
||||
InsertNode = 'insertNode',
|
||||
DropNode = 'dropNode',
|
||||
UpdateAttribute = 'updateAttribute',
|
||||
UpdateCode = 'updateCode',
|
||||
}
|
||||
|
||||
type HistoryRecordData = {
|
||||
[filename: string]: string;
|
||||
};
|
||||
|
||||
interface HistoryRecord {
|
||||
time: number;
|
||||
message: HistoryMessage;
|
||||
data: HistoryRecordData;
|
||||
}
|
||||
|
||||
type PushDataType = Pick<HistoryRecord, 'message' | 'data'>;
|
||||
|
||||
/**
|
||||
* 工作区的历史记录记录
|
||||
*/
|
||||
export class TangoHistory {
|
||||
// 历史记录
|
||||
_records: HistoryRecord[] = [];
|
||||
|
||||
// 当前记录指针
|
||||
_index = 0;
|
||||
|
||||
// 最多记录数
|
||||
_maxSize = 100;
|
||||
|
||||
private readonly workspace: IWorkspace;
|
||||
|
||||
get index() {
|
||||
return this._index;
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this._records.length;
|
||||
}
|
||||
|
||||
get list() {
|
||||
return toJS(this._records);
|
||||
}
|
||||
|
||||
get couldBack() {
|
||||
return this._records.length > 0 && this._index > -1;
|
||||
}
|
||||
|
||||
get couldForward() {
|
||||
return this._records.length > this._index + 1;
|
||||
}
|
||||
|
||||
constructor(workspace: IWorkspace) {
|
||||
this.workspace = workspace;
|
||||
|
||||
makeObservable(this, {
|
||||
_records: observable,
|
||||
_index: observable,
|
||||
back: action,
|
||||
forward: action,
|
||||
go: action,
|
||||
push: action,
|
||||
couldBack: computed,
|
||||
couldForward: computed,
|
||||
});
|
||||
}
|
||||
|
||||
_sync(data: HistoryRecordData) {
|
||||
if (data) {
|
||||
Object.keys(data).forEach((filename) => {
|
||||
this.workspace.getFile(filename).update(data[filename]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上一步
|
||||
*/
|
||||
back() {
|
||||
if (this.couldBack) {
|
||||
const item = this._records[this._index - 1];
|
||||
this._sync(item.data);
|
||||
this._index--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一步
|
||||
*/
|
||||
forward() {
|
||||
if (this.couldForward) {
|
||||
const item = this._records[this._index + 1];
|
||||
this._sync(item.data);
|
||||
this._index++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过相对位置从历史记录加载记录
|
||||
*/
|
||||
go(index: number) {
|
||||
const item = this._records[index];
|
||||
if (item) {
|
||||
this._sync(item.data);
|
||||
this._index = index;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* push 数据进入历史记录堆栈
|
||||
*/
|
||||
push(data: PushDataType) {
|
||||
if (this._index < this._records.length - 1) {
|
||||
this._records = this._records.slice(0, this._index + 1);
|
||||
}
|
||||
|
||||
this._index = this._records.length;
|
||||
this._records.push({
|
||||
time: Date.now(),
|
||||
...data,
|
||||
});
|
||||
|
||||
const overCount = this._records.length - this._maxSize;
|
||||
if (overCount > 0) {
|
||||
this._records.splice(0, overCount);
|
||||
this._index = this._records.length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './engine';
|
||||
export * from './workspace';
|
||||
export * from './designer';
|
||||
export * from './drop-target';
|
||||
export * from './module';
|
||||
export * from './interfaces';
|
||||
@@ -0,0 +1,237 @@
|
||||
import { ComponentPrototypeType, Dict } from '@music163/tango-helpers';
|
||||
import { TangoHistory } from './history';
|
||||
import { SelectSource } from './select-source';
|
||||
import { DragSource } from './drag-source';
|
||||
import {
|
||||
FileItemType,
|
||||
FileType,
|
||||
ImportDeclarationPayloadType,
|
||||
InsertChildPositionType,
|
||||
PackageConfigType,
|
||||
PageConfigType,
|
||||
ProjectDataType,
|
||||
ServiceFunctionPayloadType,
|
||||
} from '../types/types';
|
||||
import { TangoFile, TangoJsonFile } from './file';
|
||||
import { TangoRouteModule, TangoServiceModule, TangoStoreModule } from './module';
|
||||
|
||||
export interface IViewFile {
|
||||
readonly workspace: IWorkspace;
|
||||
readonly filename: string;
|
||||
readonly type: FileType;
|
||||
|
||||
/**
|
||||
* 通过导入组件名查找组件来自的包
|
||||
*/
|
||||
importMap?: Dict<{ package: string; isDefault?: boolean }>;
|
||||
|
||||
update: (code?: string, isFormatCode?: boolean, refreshWorkspace?: boolean) => void;
|
||||
|
||||
getNode: (targetNodeId: string) => IViewNode;
|
||||
|
||||
removeNode: (targetNodeId: string) => IViewFile;
|
||||
|
||||
insertChild: (
|
||||
targetNodeId: string,
|
||||
newNode: any,
|
||||
position?: InsertChildPositionType,
|
||||
sourcePrototype?: string | ComponentPrototypeType,
|
||||
) => IViewFile;
|
||||
|
||||
insertAfter: (targetNodeId: string, newNode: any, sourcePrototype?: string | ComponentPrototypeType) => IViewFile;
|
||||
|
||||
insertBefore: (targetNodeId: string, newNode: any, sourcePrototype?: string | ComponentPrototypeType) => IViewFile;
|
||||
|
||||
replaceNode: (targetNodeId: string, newNode: any, sourcePrototype?: string | ComponentPrototypeType) => IViewFile;
|
||||
|
||||
replaceViewChildren: (rawNodes: any[], importDeclarations?: ImportDeclarationPayloadType[]) => IViewFile;
|
||||
|
||||
updateNodeAttribute: (nodeId: string, attrName: string, attrValue?: any, relatedImports?: string[]) => IViewFile;
|
||||
|
||||
updateNodeAttributes: (nodeId: string, config: Record<string, any>, relatedImports?: string[]) => IViewFile;
|
||||
|
||||
get code(): string;
|
||||
get nodes(): Map<string, IViewNode>;
|
||||
get nodesTree(): object[];
|
||||
get tree(): any;
|
||||
}
|
||||
|
||||
export interface IViewNode {
|
||||
/**
|
||||
* 所属的文件
|
||||
*/
|
||||
file: IViewFile;
|
||||
|
||||
/**
|
||||
* 节点 ID
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* 对应的组件
|
||||
*/
|
||||
readonly component: string;
|
||||
|
||||
/**
|
||||
* 原始节点对象
|
||||
*/
|
||||
readonly rawNode: unknown;
|
||||
|
||||
/**
|
||||
* 属性集合
|
||||
*/
|
||||
readonly props: Record<string, any>;
|
||||
|
||||
/**
|
||||
* 克隆原始节点
|
||||
* @returns
|
||||
*/
|
||||
cloneRawNode: () => unknown;
|
||||
|
||||
/**
|
||||
* 销毁节点
|
||||
* @returns
|
||||
*/
|
||||
destroy: () => void;
|
||||
|
||||
/**
|
||||
* 原始节点的位置信息
|
||||
*/
|
||||
get loc(): unknown;
|
||||
}
|
||||
|
||||
export interface IWorkspace {
|
||||
history: TangoHistory;
|
||||
selectSource: SelectSource;
|
||||
dragSource: DragSource;
|
||||
|
||||
files: Map<string, TangoFile>;
|
||||
componentPrototypes: Map<string, ComponentPrototypeType>;
|
||||
|
||||
entry: string;
|
||||
activeFile: string;
|
||||
activeViewFile: string;
|
||||
activeRoute: string;
|
||||
|
||||
tangoConfigJson: TangoJsonFile;
|
||||
routeModule?: TangoRouteModule;
|
||||
serviceModule?: TangoServiceModule;
|
||||
|
||||
refresh: (names: string[]) => void;
|
||||
ready: () => void;
|
||||
|
||||
setActiveRoute: (path: string) => void;
|
||||
setActiveFile: (filename: string) => void;
|
||||
|
||||
setComponentPrototypes: (prototypes: Record<string, ComponentPrototypeType>) => void;
|
||||
getPrototype: (name: string | ComponentPrototypeType) => ComponentPrototypeType;
|
||||
|
||||
/**
|
||||
* 获取项目数据
|
||||
*/
|
||||
getProjectData?: () => ProjectDataType;
|
||||
|
||||
/**
|
||||
* 查询节点
|
||||
* @param id 节点 ID
|
||||
* @param module 节点所在的模块名
|
||||
* @returns 返回节点对象
|
||||
*/
|
||||
getNode: (id: string, module?: string) => IViewNode;
|
||||
listModals?: () => Array<{ label: string; value: string }>;
|
||||
listForms?: () => Record<string, string[]>;
|
||||
|
||||
addFile: (filename: string, code: string, fileType?: FileType) => void;
|
||||
addFiles: (files: FileItemType[]) => void;
|
||||
updateFile: (filename: string, code: string, shouldFormatCode?: boolean) => void;
|
||||
removeFile: (filename: string) => void;
|
||||
renameFile: (oldFilename: string, newFilename: string) => void;
|
||||
getFile: (filename: string) => TangoFile;
|
||||
listFiles: () => Record<string, string>;
|
||||
|
||||
addViewPage: (name: string, code: string) => void;
|
||||
|
||||
removeSelectedNode: () => void;
|
||||
|
||||
cloneSelectedNode: () => void;
|
||||
|
||||
copySelectedNode: () => void;
|
||||
|
||||
pasteSelectedNode: () => void;
|
||||
|
||||
insertToSelectedNode: (childNameOrPrototype: string | ComponentPrototypeType) => void;
|
||||
|
||||
dropNode: () => void;
|
||||
|
||||
insertToNode: (targetNodeId: string, sourceNameOrPrototype: string | ComponentPrototypeType) => void;
|
||||
|
||||
replaceNode: (targetNodeId: string, sourceNameOrPrototype: string | ComponentPrototypeType) => void;
|
||||
|
||||
updateSelectedNodeAttributes: (attributes: Record<string, any>, relatedImports?: string[]) => void;
|
||||
|
||||
addBlock?: (files: object, name: string) => void;
|
||||
|
||||
generateBlockFilesBySelectedNode?: () => Record<string, string>;
|
||||
|
||||
removeServiceFunction?: (serviceName: string) => void;
|
||||
|
||||
addServiceFunction?: (payload: ServiceFunctionPayloadType | ServiceFunctionPayloadType[]) => void;
|
||||
|
||||
updateServiceFunction?: (payload: any) => void;
|
||||
|
||||
updateServiceBaseConfig?: (name: string, value: any) => void;
|
||||
|
||||
listStoreModules?: () => TangoStoreModule[];
|
||||
|
||||
addStoreModule?: (storeName: string, code: string) => void;
|
||||
|
||||
removeStoreModule?: (storeName: string) => void;
|
||||
|
||||
addStoreState?: (storeName: string, stateName: string, initValue: string) => void;
|
||||
|
||||
removeStoreState?: (storeName: string, stateName: string) => void;
|
||||
|
||||
updateModuleCodeByVariablePath?: (variablePath: string, code: string) => void;
|
||||
|
||||
removeViewModule: (routePath: string) => void;
|
||||
|
||||
copyViewPage: (sourceRoutePath: string, targetPageData: PageConfigType) => void;
|
||||
|
||||
updateRoute: (sourceRoutePath: string, targetPageData: PageConfigType) => void;
|
||||
|
||||
listDependencies?: () => any;
|
||||
|
||||
updateDependency?: (
|
||||
name: string,
|
||||
version: string,
|
||||
options?: {
|
||||
package?: PackageConfigType;
|
||||
[x: string]: any;
|
||||
},
|
||||
) => void;
|
||||
|
||||
removeDependency?: (name: string) => void;
|
||||
|
||||
addBizComp?: (
|
||||
name: string,
|
||||
version: string,
|
||||
options?: {
|
||||
package?: PackageConfigType;
|
||||
[x: string]: any;
|
||||
},
|
||||
) => void;
|
||||
|
||||
removeBizComp?: (name: string) => void;
|
||||
|
||||
get activeViewModule(): IViewFile;
|
||||
// TODO: -> getStoreModules
|
||||
get storeModules(): TangoStoreModule[];
|
||||
// TODO: -> getPages
|
||||
get pages(): any[];
|
||||
// TODO: getBizComps
|
||||
get bizComps(): string[];
|
||||
// TODO: getBaseComps
|
||||
get baseComps(): string[];
|
||||
// TODO: getBlocks
|
||||
get blocks(): any[];
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
import * as t from '@babel/types';
|
||||
import { action, computed, makeObservable, observable, toJS } from 'mobx';
|
||||
import { clone, ComponentPrototypeType, Dict, isNil, logger } from '@music163/tango-helpers';
|
||||
import {
|
||||
code2ast,
|
||||
ast2code,
|
||||
traverseRouteFile,
|
||||
traverseViewFile,
|
||||
traverseServiceFile,
|
||||
traverseStoreFile,
|
||||
traverseStoreEntryFile,
|
||||
addStoreToEntryFile,
|
||||
updateServiceConfigToServiceFile,
|
||||
toTreeData,
|
||||
removeJSXElement,
|
||||
insertSiblingAfterJSXElement,
|
||||
getModuleNameByFilename,
|
||||
addRouteToRouteFile,
|
||||
removeRouteFromRouteFile,
|
||||
appendChildToJSXElement,
|
||||
addImportDeclaration,
|
||||
updateImportDeclaration,
|
||||
deleteServiceConfigFromServiceFile,
|
||||
replaceJSXElement,
|
||||
formatCode,
|
||||
getImportDeclarationPayloadByPrototype,
|
||||
addStoreState,
|
||||
removeUnusedImportSpecifiers,
|
||||
insertSiblingBeforeJSXElement,
|
||||
updateStoreState,
|
||||
removeStoreState,
|
||||
removeStoreToEntryFile,
|
||||
replaceRootJSXElementChildren,
|
||||
updateRouteToRouteFile,
|
||||
updateBaseConfigToServiceFile,
|
||||
IdGenerator,
|
||||
updateJSXAttributes,
|
||||
} from '../helpers';
|
||||
import { TangoNode } from './node';
|
||||
import { TangoFile } from './file';
|
||||
import {
|
||||
RouteDataType,
|
||||
ModulePropsType,
|
||||
ClassPropertyNodeType,
|
||||
ServiceFunctionPayloadType,
|
||||
TangoViewNodeDataType,
|
||||
StorePropertyType,
|
||||
ImportDeclarationPayloadType,
|
||||
InsertChildPositionType,
|
||||
} from '../types';
|
||||
import { IViewFile, IWorkspace } from './interfaces';
|
||||
|
||||
/**
|
||||
* 模块实现规范
|
||||
* - ast 操纵类方法,统一返回 this,支持外层链式调用
|
||||
* - observable state 统一用 _foo 格式,并提供 getter 方法
|
||||
*/
|
||||
class TangoModule extends TangoFile {
|
||||
ast: t.File;
|
||||
|
||||
constructor(workspace: IWorkspace, props: ModulePropsType, isSyncCode = true) {
|
||||
super(workspace, props, isSyncCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于最新的 ast 进行同步
|
||||
* @param code 如果传入 code,则基于 code 进行同步
|
||||
* @param isFormatCode 是否格式化代码
|
||||
* @param refreshWorkspace 是否刷新 workspace
|
||||
*/
|
||||
update(code?: string, isFormatCode = true, refreshWorkspace = true) {
|
||||
this.lastModified = Date.now();
|
||||
if (isNil(code)) {
|
||||
this._syncByAst();
|
||||
} else {
|
||||
this._syncByCode(code, isFormatCode);
|
||||
}
|
||||
|
||||
this._analysisAst();
|
||||
|
||||
if (refreshWorkspace) {
|
||||
this.workspace.refresh([this.filename]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于最新的 ast 进行源码同步
|
||||
*/
|
||||
_syncByAst() {
|
||||
const code = ast2code(this.ast);
|
||||
this._code = code;
|
||||
this._cleanCode = code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于输入的源码进行同步
|
||||
* @param code 源码
|
||||
* @param isFormatCode 是否格式化代码
|
||||
* @returns
|
||||
*/
|
||||
_syncByCode(code: string, isFormatCode = true) {
|
||||
if (code === this._code) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 提前格式化代码
|
||||
if (isFormatCode) {
|
||||
code = formatCode(code);
|
||||
}
|
||||
|
||||
this._code = code;
|
||||
this._cleanCode = code;
|
||||
this.ast = code2ast(code);
|
||||
}
|
||||
|
||||
_analysisAst() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 普通 JS 文件
|
||||
*/
|
||||
export class TangoJsModule extends TangoModule {
|
||||
constructor(workspace: IWorkspace, props: ModulePropsType) {
|
||||
super(workspace, props, false);
|
||||
this.update(props.code, false, false);
|
||||
|
||||
makeObservable(this, {
|
||||
_code: observable,
|
||||
_cleanCode: observable,
|
||||
code: computed,
|
||||
cleanCode: computed,
|
||||
update: action,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 入口配置模块
|
||||
*/
|
||||
export class TangoStoreEntryModule extends TangoModule {
|
||||
_stores: string[] = [];
|
||||
|
||||
get stores() {
|
||||
return toJS(this._stores);
|
||||
}
|
||||
|
||||
constructor(workspace: IWorkspace, props: ModulePropsType) {
|
||||
super(workspace, props, false);
|
||||
this.update(props.code, true, false);
|
||||
|
||||
makeObservable(this, {
|
||||
_stores: observable,
|
||||
_code: observable,
|
||||
_cleanCode: observable,
|
||||
stores: computed,
|
||||
code: computed,
|
||||
cleanCode: computed,
|
||||
update: action,
|
||||
});
|
||||
}
|
||||
|
||||
_analysisAst() {
|
||||
this._stores = traverseStoreEntryFile(this.ast);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建模型
|
||||
* @param name
|
||||
*/
|
||||
addStore(name: string) {
|
||||
this.ast = addStoreToEntryFile(this.ast, name);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模型
|
||||
* @param name
|
||||
*/
|
||||
removeStore(name: string) {
|
||||
this.ast = removeStoreToEntryFile(this.ast, name);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由配置模块
|
||||
*/
|
||||
export class TangoRouteModule extends TangoModule {
|
||||
_routes: RouteDataType[];
|
||||
|
||||
get routes() {
|
||||
return toJS(this._routes);
|
||||
}
|
||||
|
||||
constructor(workspace: IWorkspace, props: ModulePropsType) {
|
||||
super(workspace, props, false);
|
||||
this.update(props.code, true, false);
|
||||
|
||||
makeObservable(this, {
|
||||
_routes: observable,
|
||||
_code: observable,
|
||||
_cleanCode: observable,
|
||||
routes: computed,
|
||||
code: computed,
|
||||
cleanCode: computed,
|
||||
update: action,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路由地址获取 route 对象
|
||||
*/
|
||||
getRouteByRoutePath(route: string) {
|
||||
let record;
|
||||
for (const item of this.routes) {
|
||||
if (item.path === route) {
|
||||
record = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一条新路由
|
||||
* @param name
|
||||
*/
|
||||
addRoute(routePath: string, importFilePath: string) {
|
||||
this.ast = addRouteToRouteFile(this.ast, routePath, importFilePath);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新页面路由
|
||||
* @param oldRoutePath
|
||||
* @param newRoutePath
|
||||
* @returns
|
||||
*/
|
||||
updateRoute(oldRoutePath: string, newRoutePath: string) {
|
||||
this.ast = updateRouteToRouteFile(this.ast, oldRoutePath, newRoutePath);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一条路由
|
||||
* @param route 路由地址
|
||||
*/
|
||||
removeRoute(route: string) {
|
||||
if (route === '/') {
|
||||
console.warn('index route should not be removed!');
|
||||
return;
|
||||
}
|
||||
const record = this.getRouteByRoutePath(route);
|
||||
this.ast = removeRouteFromRouteFile(this.ast, route, record.importPath);
|
||||
return this;
|
||||
}
|
||||
|
||||
_analysisAst() {
|
||||
this._routes = traverseRouteFile(this.ast);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视图模块
|
||||
*/
|
||||
export class TangoViewModule extends TangoModule implements IViewFile {
|
||||
// 解析为树结构的 jsxNodes 数组
|
||||
_nodesTree: TangoViewNodeDataType[];
|
||||
/**
|
||||
* 通过导入组件名查找组件来自的包
|
||||
*/
|
||||
importMap: Dict<{ package: string; isDefault?: boolean }>;
|
||||
|
||||
/**
|
||||
* 视图中依赖的 tango 变量,仅 stores 和 services
|
||||
*/
|
||||
variables: string[];
|
||||
|
||||
/**
|
||||
* 节点列表
|
||||
*/
|
||||
private _nodes: Map<string, TangoNode>;
|
||||
/**
|
||||
* 导入的模块
|
||||
*/
|
||||
private _importedModules: Dict<ImportDeclarationPayloadType | ImportDeclarationPayloadType[]>;
|
||||
/**
|
||||
* 类属性
|
||||
* @deprecated
|
||||
*/
|
||||
private _classProperties: Dict<ClassPropertyNodeType>;
|
||||
/**
|
||||
* 状态属性
|
||||
*/
|
||||
private _stateProperties: string[];
|
||||
/**
|
||||
* 状态代码
|
||||
*/
|
||||
private _stateCode: string;
|
||||
/**
|
||||
* ID 生成器
|
||||
*/
|
||||
private _idGenerator: IdGenerator;
|
||||
|
||||
get classProperties() {
|
||||
return this._classProperties;
|
||||
}
|
||||
|
||||
get stateProperties() {
|
||||
return this._stateProperties;
|
||||
}
|
||||
|
||||
get nodes() {
|
||||
return this._nodes;
|
||||
}
|
||||
|
||||
get nodesTree() {
|
||||
return toJS(this._nodesTree);
|
||||
}
|
||||
|
||||
get stateCode() {
|
||||
return this._stateCode;
|
||||
}
|
||||
|
||||
get tree() {
|
||||
return this.ast;
|
||||
}
|
||||
|
||||
constructor(workspace: IWorkspace, props: ModulePropsType) {
|
||||
super(workspace, props, false);
|
||||
this._nodes = new Map();
|
||||
this._idGenerator = new IdGenerator({ prefix: props.filename });
|
||||
this.update(props.code, true, false);
|
||||
makeObservable(this, {
|
||||
_nodesTree: observable,
|
||||
|
||||
_code: observable,
|
||||
_cleanCode: observable,
|
||||
|
||||
code: computed,
|
||||
cleanCode: computed,
|
||||
|
||||
update: action,
|
||||
});
|
||||
}
|
||||
|
||||
_syncByAst() {
|
||||
// 空方法,逻辑合并到 this._analysisAst
|
||||
}
|
||||
|
||||
_analysisAst() {
|
||||
const {
|
||||
ast: newAst,
|
||||
cleanAst,
|
||||
nodes,
|
||||
stateCode,
|
||||
stateProperties,
|
||||
classProperties,
|
||||
importedModules,
|
||||
variables,
|
||||
} = traverseViewFile(this.ast, this._idGenerator);
|
||||
this.ast = newAst;
|
||||
|
||||
this._code = ast2code(newAst);
|
||||
this._cleanCode = ast2code(cleanAst);
|
||||
|
||||
this._stateCode = stateCode;
|
||||
|
||||
this._stateProperties = stateProperties;
|
||||
this._classProperties = classProperties;
|
||||
this._importedModules = importedModules;
|
||||
this.importMap = this.buildImportMap(importedModules);
|
||||
this.variables = variables;
|
||||
|
||||
this._nodes.clear();
|
||||
|
||||
nodes.forEach((cur) => {
|
||||
const node = new TangoNode({
|
||||
...cur,
|
||||
file: this,
|
||||
});
|
||||
this._nodes.set(cur.id, node);
|
||||
});
|
||||
|
||||
this._nodesTree = toTreeData(nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于组件的 prototype 信息更新导入信息
|
||||
* @param prototype
|
||||
* @param shouldUpdateCode
|
||||
*/
|
||||
updateImportSpecifiersByPrototype(sourcePrototype: string | ComponentPrototypeType) {
|
||||
const prototype = this.workspace.getPrototype(sourcePrototype);
|
||||
if (prototype) {
|
||||
const importDeclaration = getImportDeclarationPayloadByPrototype(prototype, this.filename);
|
||||
this.updateImportSpecifiers(importDeclaration);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新导入的变量(新版)
|
||||
*/
|
||||
updateImportSpecifiers(importDeclaration: ImportDeclarationPayloadType) {
|
||||
const mods = this._importedModules[importDeclaration.sourcePath];
|
||||
let ast;
|
||||
// 如果模块已存在,需要去重
|
||||
if (mods) {
|
||||
const targetMod = Array.isArray(mods) ? mods[0] : mods;
|
||||
const specifiers = Array.isArray(mods)
|
||||
? mods.reduce((prev, cur) => prev.concat(cur.specifiers || []), [])
|
||||
: mods.specifiers;
|
||||
|
||||
// 去掉已存在的导入声明
|
||||
const newSpecifiers = importDeclaration.specifiers.filter(
|
||||
(name) => !specifiers.includes(name),
|
||||
);
|
||||
|
||||
ast = updateImportDeclaration(this.ast, {
|
||||
...importDeclaration,
|
||||
specifiers: newSpecifiers.concat(targetMod.specifiers),
|
||||
});
|
||||
} else {
|
||||
ast = addImportDeclaration(this.ast, importDeclaration);
|
||||
}
|
||||
this.ast = ast;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除无效的导入声明
|
||||
*/
|
||||
removeUnusedImportSpecifiers() {
|
||||
this.ast = removeUnusedImportSpecifiers(this.ast);
|
||||
return this;
|
||||
}
|
||||
|
||||
getNode(nodeId: string) {
|
||||
return this._nodes.get(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除节点
|
||||
* @param nodeId
|
||||
*/
|
||||
removeNode(nodeId: string) {
|
||||
this.ast = removeJSXElement(this.ast, nodeId);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点的属性
|
||||
* @deprecated 使用 updateNodeAttributes 代替
|
||||
*/
|
||||
updateNodeAttribute(
|
||||
nodeId: string,
|
||||
attrName: string,
|
||||
attrValue?: any,
|
||||
relatedImports?: string[],
|
||||
) {
|
||||
return this.updateNodeAttributes(nodeId, { [attrName]: attrValue }, relatedImports);
|
||||
}
|
||||
|
||||
updateNodeAttributes(nodeId: string, config: Record<string, any>, relatedImports?: string[]) {
|
||||
if (relatedImports && relatedImports.length) {
|
||||
// 导入依赖的组件
|
||||
relatedImports.forEach((name: string) => {
|
||||
const proto = this.workspace.getPrototype(name);
|
||||
this.updateImportSpecifiersByPrototype(proto);
|
||||
});
|
||||
}
|
||||
this.ast = updateJSXAttributes(this.ast, nodeId, config);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入子节点的最后面
|
||||
* @param targetNodeId
|
||||
* @param newNode
|
||||
* @param sourceName
|
||||
* @returns
|
||||
*/
|
||||
insertChild(
|
||||
targetNodeId: string,
|
||||
newNode: t.JSXElement,
|
||||
position: InsertChildPositionType = 'last',
|
||||
sourceName: string | ComponentPrototypeType,
|
||||
) {
|
||||
this.ast = appendChildToJSXElement(this.ast, targetNodeId, newNode, position);
|
||||
if (sourceName) {
|
||||
this.updateImportSpecifiersByPrototype(sourceName);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
insertAfter(
|
||||
targetNodeId: string,
|
||||
newNode: t.JSXElement,
|
||||
sourceName?: string | ComponentPrototypeType,
|
||||
) {
|
||||
this.ast = insertSiblingAfterJSXElement(this.ast, targetNodeId, newNode);
|
||||
if (sourceName) {
|
||||
this.updateImportSpecifiersByPrototype(sourceName);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
insertBefore(
|
||||
targetNodeId: string,
|
||||
newNode: t.JSXElement,
|
||||
sourceName?: string | ComponentPrototypeType,
|
||||
) {
|
||||
this.ast = insertSiblingBeforeJSXElement(this.ast, targetNodeId, newNode);
|
||||
if (sourceName) {
|
||||
this.updateImportSpecifiersByPrototype(sourceName);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换目标节点为新节点
|
||||
* @param targetNodeId
|
||||
* @param newNode
|
||||
* @param sourcePrototype
|
||||
*/
|
||||
replaceNode(
|
||||
targetNodeId: string,
|
||||
newNode: t.JSXElement,
|
||||
sourceName?: string | ComponentPrototypeType,
|
||||
) {
|
||||
this.ast = replaceJSXElement(this.ast, targetNodeId, newNode);
|
||||
if (sourceName) {
|
||||
this.updateImportSpecifiersByPrototype(sourceName);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换 jsx 跟结点的子元素
|
||||
*/
|
||||
replaceViewChildren(
|
||||
childrenNodes: t.JSXElement[],
|
||||
importDeclarations?: ImportDeclarationPayloadType[],
|
||||
) {
|
||||
if (childrenNodes.length) {
|
||||
this.ast = replaceRootJSXElementChildren(this.ast, childrenNodes);
|
||||
}
|
||||
|
||||
if (importDeclarations?.length) {
|
||||
importDeclarations.forEach((item) => {
|
||||
this.updateImportSpecifiers(item);
|
||||
});
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private buildImportMap(
|
||||
importedModules: Dict<ImportDeclarationPayloadType | ImportDeclarationPayloadType[]>,
|
||||
) {
|
||||
const map = {};
|
||||
Object.keys(importedModules).forEach((modName) => {
|
||||
const mod = importedModules[modName];
|
||||
(Array.isArray(mod) ? mod : [mod]).forEach((item) => {
|
||||
if (item.defaultSpecifier) {
|
||||
map[item.defaultSpecifier] = {
|
||||
package: modName,
|
||||
isDefault: true,
|
||||
};
|
||||
}
|
||||
if (item.specifiers.length) {
|
||||
item.specifiers.forEach((spe) => {
|
||||
map[spe] = { package: modName };
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据服务模块
|
||||
*/
|
||||
export class TangoServiceModule extends TangoModule {
|
||||
/**
|
||||
* 模块名
|
||||
*/
|
||||
name: string;
|
||||
|
||||
_serviceFunctions: Dict;
|
||||
|
||||
_baseConfig: Dict;
|
||||
|
||||
get serviceFunctions() {
|
||||
return toJS(this._serviceFunctions);
|
||||
}
|
||||
|
||||
get baseConfig() {
|
||||
return toJS(this._baseConfig);
|
||||
}
|
||||
|
||||
constructor(workspace: IWorkspace, props: ModulePropsType) {
|
||||
super(workspace, props, false);
|
||||
this.name = getModuleNameByFilename(props.filename);
|
||||
this.update(props.code, true, false);
|
||||
|
||||
makeObservable(this, {
|
||||
_serviceFunctions: observable,
|
||||
_baseConfig: observable,
|
||||
_code: observable,
|
||||
_cleanCode: observable,
|
||||
serviceFunctions: computed,
|
||||
baseConfig: computed,
|
||||
cleanCode: computed,
|
||||
code: computed,
|
||||
update: action,
|
||||
});
|
||||
}
|
||||
|
||||
_analysisAst() {
|
||||
const { services, baseConfig } = traverseServiceFile(this.ast);
|
||||
this._serviceFunctions = services;
|
||||
this._baseConfig = baseConfig;
|
||||
}
|
||||
|
||||
addServiceFunction(payload: ServiceFunctionPayloadType) {
|
||||
const { name, ...rest } = payload;
|
||||
this.ast = updateServiceConfigToServiceFile(this.ast, { [name]: clone(rest, false) });
|
||||
return this;
|
||||
}
|
||||
|
||||
addServiceFunctions(payloads: ServiceFunctionPayloadType[]) {
|
||||
const config = payloads.reduce((acc, cur) => {
|
||||
const { name, ...rest } = cur;
|
||||
acc[name] = clone(rest, false);
|
||||
return acc;
|
||||
}, {});
|
||||
this.ast = updateServiceConfigToServiceFile(this.ast, config);
|
||||
return this;
|
||||
}
|
||||
|
||||
updateServiceFunction(payload: ServiceFunctionPayloadType) {
|
||||
const { name, ...rest } = payload;
|
||||
this.ast = updateServiceConfigToServiceFile(this.ast, { [name]: clone(rest, false) });
|
||||
return this;
|
||||
}
|
||||
|
||||
deleteServiceFunction(serviceFunctionName: string) {
|
||||
try {
|
||||
this.ast = deleteServiceConfigFromServiceFile(this.ast, serviceFunctionName);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新服务的基础配置
|
||||
*/
|
||||
updateBaseConfig(optionName: string, optionValue: any) {
|
||||
this.ast = updateBaseConfigToServiceFile(this.ast, optionName, optionValue);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态模型模块
|
||||
*/
|
||||
export class TangoStoreModule extends TangoModule {
|
||||
/**
|
||||
* 模块名
|
||||
*/
|
||||
name: string;
|
||||
|
||||
namespace: string;
|
||||
|
||||
states: StorePropertyType[];
|
||||
|
||||
actions: StorePropertyType[];
|
||||
|
||||
constructor(workspace: IWorkspace, props: ModulePropsType) {
|
||||
super(workspace, props, false);
|
||||
this.name = getModuleNameByFilename(props.filename);
|
||||
this.update(props.code, true, false);
|
||||
|
||||
makeObservable(this, {
|
||||
states: observable,
|
||||
actions: observable,
|
||||
_code: observable,
|
||||
_cleanCode: observable,
|
||||
cleanCode: computed,
|
||||
code: computed,
|
||||
update: action,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加状态属性
|
||||
* @param stateName
|
||||
* @param initValue
|
||||
*/
|
||||
addState(stateName: string, initValue: string) {
|
||||
this.ast = addStoreState(this.ast, stateName, initValue);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除状态
|
||||
*/
|
||||
removeState(stateName: string) {
|
||||
this.ast = removeStoreState(this.ast, stateName);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新状态代码
|
||||
* @param stateName 状态名
|
||||
* @param code 代码
|
||||
*/
|
||||
updateState(stateName: string, code: string) {
|
||||
this.ast = updateStoreState(this.ast, stateName, code);
|
||||
return this;
|
||||
}
|
||||
|
||||
_analysisAst() {
|
||||
const { namespace, states, actions } = traverseStoreFile(this.ast);
|
||||
this.namespace = namespace || this.name;
|
||||
this.states = states;
|
||||
this.actions = actions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { JSXElement, SourceLocation } from '@babel/types';
|
||||
import { cloneJSXElementWithoutTrackingData, getJSXElementAttributes } from '../helpers';
|
||||
import { TangoViewNodeDataType } from '../types';
|
||||
import { TangoViewModule } from './module';
|
||||
import { IViewNode } from './interfaces';
|
||||
|
||||
type TangoNodeConstructorPropsType = TangoViewNodeDataType & {
|
||||
file: TangoViewModule;
|
||||
};
|
||||
|
||||
/**
|
||||
* 视图节点类
|
||||
*/
|
||||
export class TangoNode implements IViewNode {
|
||||
/**
|
||||
* 节点 ID
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* 节点对应的组件名
|
||||
*/
|
||||
readonly component: string;
|
||||
|
||||
readonly rawNode: JSXElement;
|
||||
|
||||
/**
|
||||
* 节点所属的文件对象
|
||||
*/
|
||||
file: TangoViewModule;
|
||||
|
||||
props: Record<string, any>;
|
||||
|
||||
get loc(): SourceLocation {
|
||||
return this.rawNode?.loc;
|
||||
}
|
||||
|
||||
constructor(props: TangoNodeConstructorPropsType) {
|
||||
this.file = props.file;
|
||||
this.id = props.id;
|
||||
this.component = props.component;
|
||||
this.rawNode = props.rawNode;
|
||||
this.props = getJSXElementAttributes(cloneJSXElementWithoutTrackingData(props.rawNode));
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回克隆后的 ast 节点
|
||||
* @returns
|
||||
*/
|
||||
cloneRawNode() {
|
||||
return cloneJSXElementWithoutTrackingData(this.rawNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空节点的指向,交给 GC 去回收
|
||||
*/
|
||||
destroy() {
|
||||
this.file = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ISelectedItemData, MousePoint } from '@music163/tango-helpers';
|
||||
import { action, computed, makeObservable, observable, toJS } from 'mobx';
|
||||
import { IViewFile, IWorkspace } from './interfaces';
|
||||
|
||||
type StartDataType = {
|
||||
point: MousePoint;
|
||||
element: HTMLElement;
|
||||
};
|
||||
|
||||
export class SelectSource {
|
||||
/**
|
||||
* 选中元素列表
|
||||
*/
|
||||
_items: ISelectedItemData[] = [];
|
||||
|
||||
/**
|
||||
* 用户选择的起点
|
||||
*/
|
||||
_start: StartDataType = {
|
||||
point: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
element: null,
|
||||
};
|
||||
|
||||
private readonly workspace: IWorkspace;
|
||||
|
||||
get start() {
|
||||
return toJS(this._start);
|
||||
}
|
||||
|
||||
get first() {
|
||||
if (this._items.length) return this._items[0];
|
||||
return;
|
||||
}
|
||||
|
||||
get firstNode() {
|
||||
if (!this.first) return;
|
||||
return this.workspace.getNode(this.first.id, this.first.filename);
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this._items.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中的结点数据 NodeData
|
||||
*/
|
||||
get selected() {
|
||||
return toJS(this._items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否选中了结点
|
||||
*/
|
||||
get isSelected() {
|
||||
return !!this.selected.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中结点位于的文件
|
||||
*/
|
||||
get file(): IViewFile {
|
||||
return this.firstNode?.file;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中的结点 Nodes
|
||||
*/
|
||||
get nodes() {
|
||||
return this._items
|
||||
.map((item) => this.workspace.getNode(item.id, item.filename))
|
||||
.filter((node) => !!node);
|
||||
}
|
||||
|
||||
constructor(workspace: IWorkspace) {
|
||||
this.workspace = workspace;
|
||||
makeObservable(this, {
|
||||
_items: observable,
|
||||
_start: observable,
|
||||
select: action,
|
||||
setStart: action,
|
||||
clear: action,
|
||||
start: computed,
|
||||
selected: computed,
|
||||
first: computed,
|
||||
firstNode: computed,
|
||||
size: computed,
|
||||
isSelected: computed,
|
||||
file: computed,
|
||||
nodes: computed,
|
||||
});
|
||||
}
|
||||
|
||||
// 增加一个选中项
|
||||
add() {}
|
||||
|
||||
// 移除一个选中项
|
||||
remove() {}
|
||||
|
||||
select(items: ISelectedItemData | ISelectedItemData[]) {
|
||||
if (!items) {
|
||||
this._items = [];
|
||||
} else {
|
||||
this._items = Array.isArray(items) ? items : [items];
|
||||
}
|
||||
// 选中后清空起点位置信息
|
||||
this._start = null;
|
||||
}
|
||||
|
||||
setStart(data: StartDataType) {
|
||||
this._start = data;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._items = [];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export * from './types';
|
||||
@@ -0,0 +1,276 @@
|
||||
import { JSXElement } from '@babel/types';
|
||||
|
||||
export type SimulatorMode = 'desktop' | 'tablet' | 'phone';
|
||||
|
||||
/**
|
||||
* 文件类型枚举
|
||||
*/
|
||||
export enum FileType {
|
||||
// js 文件
|
||||
Module = 'module',
|
||||
StoreEntryModule = 'storeEntryModule',
|
||||
RouteModule = 'routeModule',
|
||||
BlockEntryModule = 'blockEntryModule',
|
||||
ServiceModule = 'serviceModule',
|
||||
StoreModule = 'storeModule',
|
||||
|
||||
JsxViewModule = 'jsxViewModule',
|
||||
JsonViewModule = 'jsonViewModule',
|
||||
|
||||
// 非 js 文件
|
||||
PackageJson = 'packageJson',
|
||||
TangoConfigJson = 'tangoConfigJson',
|
||||
AppJson = 'appJson',
|
||||
File = 'file',
|
||||
Json = 'json',
|
||||
Less = 'less',
|
||||
Scss = 'scss',
|
||||
}
|
||||
|
||||
export type FileItemType = {
|
||||
/**
|
||||
* 文件名
|
||||
*/
|
||||
filename: string;
|
||||
/**
|
||||
* 原始代码
|
||||
*/
|
||||
code: string;
|
||||
/**
|
||||
* 文件类型
|
||||
*/
|
||||
type?: FileType;
|
||||
};
|
||||
|
||||
export type ModulePropsType = FileItemType;
|
||||
|
||||
/**
|
||||
* 视图节点数据类型
|
||||
*/
|
||||
export type TangoViewNodeDataType<T = JSXElement> = {
|
||||
/**
|
||||
* 节点 ID
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* 父亲节点的 ID
|
||||
*/
|
||||
parentId: string;
|
||||
/**
|
||||
* 组件名
|
||||
*/
|
||||
component: string;
|
||||
/**
|
||||
* 组件的属性集合
|
||||
*/
|
||||
props?: Record<string, any>;
|
||||
/**
|
||||
* 原始的 ast 节点
|
||||
*/
|
||||
rawNode?: T;
|
||||
/**
|
||||
* 子节点列表
|
||||
*/
|
||||
children?: Array<TangoViewNodeDataType<T>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 模块导入的参数类型
|
||||
*/
|
||||
export type ImportDeclarationPayloadType = {
|
||||
defaultSpecifier?: string;
|
||||
specifiers?: string[];
|
||||
sourcePath: string;
|
||||
};
|
||||
|
||||
export type ClassPropertyNodeType = {
|
||||
reference: string;
|
||||
propertyName: string;
|
||||
propertyBody: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 服务函数参数类型
|
||||
*/
|
||||
export type ServiceFunctionPayloadType = {
|
||||
[key: string]: any;
|
||||
/**
|
||||
* 服务函数名
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Store 属性类型
|
||||
*/
|
||||
export type StorePropertyType = {
|
||||
/**
|
||||
* 属性名
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* 对应的源码
|
||||
*/
|
||||
code?: string;
|
||||
/**
|
||||
* codemirror 中对应的类型
|
||||
* @see https://codemirror.net/6/docs/ref/#autocomplete
|
||||
*/
|
||||
type?:
|
||||
| 'class'
|
||||
| 'constant'
|
||||
| 'enum'
|
||||
| 'function'
|
||||
| 'interface'
|
||||
| 'keyword'
|
||||
| 'method'
|
||||
| 'namespace'
|
||||
| 'property'
|
||||
| 'text'
|
||||
| 'type'
|
||||
| 'variable'
|
||||
| 'object';
|
||||
};
|
||||
|
||||
/*
|
||||
* 服务函数的操作类型
|
||||
*/
|
||||
export enum ESFOperationType {
|
||||
ADD = 'add',
|
||||
UPDATE = 'update',
|
||||
DELETE = 'delete',
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务函数数据源类型
|
||||
*/
|
||||
export enum ESFDataSourceType {
|
||||
OvermindX = 'ox',
|
||||
Custom = 'custom',
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务函数 HTTP Type
|
||||
* 云音乐网关只支持 get 和 post
|
||||
*/
|
||||
export enum ESFHTTPMethodType {
|
||||
GET = 'GET',
|
||||
// PUT = 'PUT',
|
||||
POST = 'POST',
|
||||
// PATCH = 'PATCH',
|
||||
// DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
/**
|
||||
* requestType
|
||||
* headers: Content-Type
|
||||
*/
|
||||
export enum ESFHTTRequestType {
|
||||
'application/json' = 'json',
|
||||
'application/x-www-form-urlencoded' = 'x-www-form-urlencoded',
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由解析数据
|
||||
*/
|
||||
export type RouteDataType = {
|
||||
/**
|
||||
* 路由
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* 组件名
|
||||
*/
|
||||
component?: string;
|
||||
/**
|
||||
* 导入路径
|
||||
*/
|
||||
importPath?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* 页面配置数据
|
||||
*/
|
||||
export type PageConfigType = {
|
||||
/**
|
||||
* 路由
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* 父菜单路由
|
||||
*/
|
||||
parentPath?: string;
|
||||
/**
|
||||
* 页面标题
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* PMS 权限码
|
||||
*/
|
||||
privilegeCode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 项目数据
|
||||
*/
|
||||
export type ProjectDataType = {
|
||||
/**
|
||||
* 页面列表
|
||||
*/
|
||||
pages: Array<{
|
||||
title?: string;
|
||||
filename: string;
|
||||
path?: string;
|
||||
includes?: { variables: string[] };
|
||||
}>;
|
||||
/**
|
||||
* 模型列表
|
||||
*/
|
||||
stores: {
|
||||
[key: string]: {
|
||||
filename: string;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* 服务列表
|
||||
*/
|
||||
services: {
|
||||
[key: string]: {
|
||||
filename: string;
|
||||
functions: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type InsertChildPositionType = 'first' | 'last';
|
||||
|
||||
/**
|
||||
* tango.config.json 中 packages 定义
|
||||
*/
|
||||
export type PackageConfigType = {
|
||||
/**
|
||||
* 依赖类型
|
||||
*/
|
||||
type?: 'baseDependency' | 'bizDependency' | 'dependency';
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
version?: string;
|
||||
/**
|
||||
* umd 资源全局变量名
|
||||
*/
|
||||
library?: string;
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* 基础包 umd 资源
|
||||
*/
|
||||
resources?: string[];
|
||||
/**
|
||||
* 设计态 umd 资源
|
||||
*/
|
||||
designerResources?: string[];
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
object2node,
|
||||
serviceConfig2Node,
|
||||
isValidCode,
|
||||
isValidExpressionCode,
|
||||
} from '../src/helpers';
|
||||
|
||||
describe('helpers', () => {
|
||||
test('isValidCode', () => {
|
||||
expect(isValidCode('() => { hello world }')).toBeFalsy();
|
||||
expect(isValidCode('function() {}')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('isValidExpression', () => {
|
||||
expect(isValidExpressionCode('() => { }')).toBeTruthy();
|
||||
expect(isValidExpressionCode('1')).toBeTruthy();
|
||||
expect(isValidExpressionCode('"hello"')).toBeTruthy();
|
||||
expect(isValidExpressionCode('false')).toBeTruthy();
|
||||
expect(isValidExpressionCode('{ bizId: "vip", type: "category" }')).toBeTruthy();
|
||||
expect(isValidExpressionCode('[1,2,3]')).toBeTruthy();
|
||||
expect(isValidExpressionCode('<div>hello</div>')).toBeTruthy();
|
||||
expect(isValidExpressionCode('<div>hello</div>')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('object2node', () => {
|
||||
const node = object2node({
|
||||
url: '/api/backend/clientversion/appmarket/list',
|
||||
method: 'POST',
|
||||
formatter: '',
|
||||
apiId: 647341,
|
||||
});
|
||||
expect(node.type).toEqual('ObjectExpression');
|
||||
});
|
||||
|
||||
test('serviceConfig2Node', () => {
|
||||
const node = serviceConfig2Node({
|
||||
formatter: '',
|
||||
method: 'get',
|
||||
url: 'https://nei.hz.netease.com/api/apimock-v2/cc974ffbaa7a85c77f30e4ce67deb67f/api/app-list',
|
||||
} as any);
|
||||
expect(node.type).toEqual('ObjectExpression');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createEngine } from '../src';
|
||||
|
||||
describe('engine', () => {
|
||||
it('engine init', () => {
|
||||
const engine = createEngine({
|
||||
entry: '/src/index.js',
|
||||
});
|
||||
expect(engine.workspace.entry).toEqual('/src/index.js');
|
||||
});
|
||||
|
||||
it('engine init without required files', () => {
|
||||
const engine = createEngine({
|
||||
entry: '/src/index.js',
|
||||
files: [
|
||||
{
|
||||
filename: '/src/index.js',
|
||||
code: 'console.log("hello")',
|
||||
},
|
||||
{
|
||||
filename: '/package.json',
|
||||
code: JSON.stringify({ name: 'sample' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(engine.workspace.activeViewModule).toBeUndefined();
|
||||
expect(engine.workspace.serviceModule).toBeUndefined();
|
||||
expect(engine.workspace.routeModule).toBeUndefined();
|
||||
expect(engine.workspace?.storeModules.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { JSXElement } from '@babel/types';
|
||||
import {
|
||||
code2ast,
|
||||
code2expression,
|
||||
value2node,
|
||||
value2code,
|
||||
expression2code,
|
||||
upperCamelCase,
|
||||
expressionCode2ast,
|
||||
typeOf,
|
||||
isPathnameMatchRoute,
|
||||
namesToImportDeclarations,
|
||||
getBlockNameByFilename,
|
||||
getRelativePath,
|
||||
isFilepath,
|
||||
isValidComponentName,
|
||||
getFilepath,
|
||||
getPrivilegeCode,
|
||||
isPlainObject,
|
||||
getJSXElementAttributes,
|
||||
inferFileType,
|
||||
camelCase,
|
||||
deepCloneNode,
|
||||
} from '../src/helpers';
|
||||
import { FileType } from '../src/types';
|
||||
|
||||
describe('helpers', () => {
|
||||
it('code2ast', () => {
|
||||
expect(code2ast('function App() {}').type).toEqual('File');
|
||||
});
|
||||
|
||||
it('code2expression: null', () => {
|
||||
expect(code2expression('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('code2expression: object', () => {
|
||||
const code = `
|
||||
{
|
||||
foo: 'bar',
|
||||
}
|
||||
`;
|
||||
const node = code2expression(code);
|
||||
expect(node.type).toEqual('ObjectExpression');
|
||||
});
|
||||
|
||||
it('code2expression: arrow function', () => {
|
||||
expect(code2expression('() => {};').type).toEqual('ArrowFunctionExpression');
|
||||
});
|
||||
|
||||
it('code2expression: list', () => {
|
||||
const code = `
|
||||
[
|
||||
{ label: 'foo', value: 'foo' },
|
||||
{ label: 'bar', value: 'bar' },
|
||||
]
|
||||
`;
|
||||
const node = code2expression(code);
|
||||
expect(node.type).toEqual('ArrayExpression');
|
||||
});
|
||||
|
||||
it('code2expression: jsxElement', () => {
|
||||
const node = code2expression('<Button>hello</Button>');
|
||||
expect(node.type).toEqual('JSXElement');
|
||||
});
|
||||
|
||||
it('parse jsxElement attributes', () => {
|
||||
const node = code2expression(
|
||||
"<XColumn dataIndex='col' enumMap={{ 1: '已解决', 2: '未解决' }} />",
|
||||
);
|
||||
const attributes = getJSXElementAttributes(node as JSXElement);
|
||||
expect(attributes).toEqual({
|
||||
dataIndex: 'col',
|
||||
enumMap: {
|
||||
1: '已解决',
|
||||
2: '未解决',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('code2expression: closed jsxElement', () => {
|
||||
const node = code2expression('<BreadcrumbItem children="节点名称" />');
|
||||
expect(node.type).toEqual('JSXElement');
|
||||
});
|
||||
|
||||
it('value2node: number', () => {
|
||||
expect(value2node(1).type).toEqual('NumericLiteral');
|
||||
});
|
||||
|
||||
it('value2node: string', () => {
|
||||
expect(value2node('hello').type).toEqual('StringLiteral');
|
||||
});
|
||||
|
||||
it('value2node: arrowFunction', () => {
|
||||
const node = value2node(() => {});
|
||||
expect(node.type).toEqual('ArrowFunctionExpression');
|
||||
});
|
||||
|
||||
it('value2node: object', () => {
|
||||
const node = value2node({
|
||||
num: 1,
|
||||
str: 'string',
|
||||
fn: () => {},
|
||||
nest: '{this.hello}',
|
||||
});
|
||||
expect(node.type).toEqual('ObjectExpression');
|
||||
});
|
||||
|
||||
it('expression2code: functionExpression', () => {
|
||||
expect(expression2code(code2expression('function(){}'))).toEqual('function () {}');
|
||||
});
|
||||
|
||||
it('expression2code: arrowFunctionExpression', () => {
|
||||
expect(expression2code(code2expression('() => {}'))).toEqual('() => {}');
|
||||
});
|
||||
|
||||
it('expression2code: memberExpression', () => {
|
||||
expect(expression2code(code2expression('this.foo.bar'))).toEqual('this.foo.bar');
|
||||
});
|
||||
|
||||
it('expression2code: identifier', () => {
|
||||
expect(expression2code(code2expression('data'))).toEqual('data');
|
||||
});
|
||||
|
||||
it('expressionCode2ast', () => {
|
||||
expect(expressionCode2ast('<Button>hello</Button>').type).toEqual('File');
|
||||
expect(expressionCode2ast('{<Button>hello</Button>}').type).toEqual('File');
|
||||
expect(expressionCode2ast('() => <Button>hello</Button>').type).toEqual('File');
|
||||
expect(expressionCode2ast('{() => <Button>hello</Button>}').type).toEqual('File');
|
||||
});
|
||||
});
|
||||
|
||||
describe('string helpers', () => {
|
||||
it('camelCase', () => {
|
||||
expect(camelCase('foo')).toEqual('foo');
|
||||
expect(camelCase('foo-bar')).toEqual('fooBar');
|
||||
});
|
||||
|
||||
it('upperCamelCase', () => {
|
||||
expect(upperCamelCase('foo')).toEqual('Foo');
|
||||
expect(upperCamelCase('foo-bar')).toEqual('FooBar');
|
||||
});
|
||||
|
||||
it('value2code: empty array', () => {
|
||||
expect(value2code([])).toEqual('[]');
|
||||
});
|
||||
|
||||
it('value2code: array', () => {
|
||||
expect(value2code([{ label: 'foo' }])).toEqual('[{ label: "foo" }]');
|
||||
});
|
||||
|
||||
it('value2code: object', () => {
|
||||
expect(value2code({ width: 200 })).toEqual('{ width: 200 }');
|
||||
});
|
||||
|
||||
it('typeOf', () => {
|
||||
expect(typeOf()).toBe('undefined');
|
||||
expect(typeOf('')).toBe('string');
|
||||
expect(typeOf('hello')).toBe('string');
|
||||
expect(typeOf(5)).toBe('number');
|
||||
expect(typeOf({})).toBe('object');
|
||||
expect(typeOf([])).toBe('array');
|
||||
});
|
||||
|
||||
it('isPathnameMatchRoute', () => {
|
||||
expect(isPathnameMatchRoute('/user/123', '/user/:id')).toBeTruthy();
|
||||
expect(isPathnameMatchRoute('/user/123?foo=bar', '/user/:id')).toBeTruthy();
|
||||
expect(isPathnameMatchRoute('/user/:id', '/user/:id')).toBeTruthy();
|
||||
expect(isPathnameMatchRoute('/user', '/user')).toBeTruthy();
|
||||
expect(isPathnameMatchRoute('/user/123/modify/123', '/user/:uid/modify/:rid')).toBeTruthy();
|
||||
expect(isPathnameMatchRoute('/user/123', '/user')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('namesToImportDeclarations', () => {
|
||||
expect(
|
||||
namesToImportDeclarations(['Button', 'Box', 'React'], {
|
||||
Button: { package: '@music/tango-cms' },
|
||||
Box: { package: '@music/tango-cms' },
|
||||
React: { package: 'react', isDefault: true },
|
||||
}),
|
||||
).toEqual([
|
||||
{ sourcePath: '@music/tango-cms', specifiers: ['Button', 'Box'] },
|
||||
{ sourcePath: 'react', defaultSpecifier: 'React' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('getBlockNameByFilename', () => {
|
||||
expect(getBlockNameByFilename('/src/blocks/local-comp/index.js')).toEqual('LocalComp');
|
||||
});
|
||||
|
||||
it('getRelativePath', () => {
|
||||
expect(getRelativePath('/src/pages/index.js', '/src/blocks/sample-block/index.js')).toEqual(
|
||||
'../blocks/sample-block/index.js',
|
||||
);
|
||||
// TODO: fix me
|
||||
// expect(getRelativePath('/src/pages/', '/src/pages/index.js')).toEqual('./index.js');
|
||||
});
|
||||
|
||||
it('getFilepath', () => {
|
||||
expect(getFilepath('/user', '/src/pages')).toBe('/src/pages/user');
|
||||
expect(getFilepath('/user/:id', '/src/pages')).toBe('/src/pages/user@id');
|
||||
expect(getFilepath('/user/detail', '/src/pages')).toBe('/src/pages/user-detail');
|
||||
});
|
||||
|
||||
it('isFilepath', () => {
|
||||
expect(isFilepath('./pages/index.js')).toBeTruthy();
|
||||
expect(isFilepath('../pages/index.js')).toBeTruthy();
|
||||
expect(isFilepath('./pages/index.css')).toBeTruthy();
|
||||
expect(isFilepath('/src/pages/index.js')).toBeTruthy();
|
||||
expect(isFilepath('path')).toBeFalsy();
|
||||
expect(isFilepath('path-browserify')).toBeFalsy();
|
||||
expect(isFilepath('@music/one')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('getPrivilegeCode', () => {
|
||||
expect(getPrivilegeCode('sample-app', '/user')).toBe('sample-app@%user');
|
||||
expect(getPrivilegeCode('sample-app', '/user/:id')).toBe('sample-app@%user%:id');
|
||||
});
|
||||
|
||||
it('isValidComponentName', () => {
|
||||
expect(isValidComponentName('Button')).toBeTruthy();
|
||||
expect(isValidComponentName('Button.Group')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('upperCamelCase', () => {
|
||||
expect(upperCamelCase('about')).toBe('About');
|
||||
expect(upperCamelCase('not-found')).toBe('NotFound');
|
||||
expect(upperCamelCase('@music/input')).toBe('MusicInput');
|
||||
expect(upperCamelCase('@music/ct-input')).toBe('MusicCtInput');
|
||||
// TODO: FIXME
|
||||
// expect(upperCamelCase('-not-found')).toBe('NotFound');
|
||||
// expect(upperCamelCase('not_found')).toBe('NotFound');
|
||||
// expect(upperCamelCase('_not_found')).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('isPlainObject', () => {
|
||||
expect(isPlainObject({})).toBeTruthy();
|
||||
expect(isPlainObject({ foo: 'foo' })).toBeTruthy();
|
||||
expect(isPlainObject(null)).toBeFalsy();
|
||||
expect(isPlainObject(undefined)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('inferFileType', () => {
|
||||
expect(inferFileType('/src/pages/template.js')).toBe(FileType.JsxViewModule);
|
||||
expect(inferFileType('/src/pages/template.jsx')).toBe(FileType.JsxViewModule);
|
||||
expect(inferFileType('/src/pages/template.ejs')).toBe(FileType.File);
|
||||
expect(inferFileType('/src/index.scss')).toBe(FileType.Scss);
|
||||
expect(inferFileType('/src/index.less')).toBe(FileType.Less);
|
||||
expect(inferFileType('/src/index.json')).toBe(FileType.Json);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema helpers', () => {
|
||||
const schema = {
|
||||
id: 'Section:1',
|
||||
component: 'Section',
|
||||
props: {
|
||||
id: '111',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'Button:1',
|
||||
component: 'Button',
|
||||
props: {
|
||||
id: '222',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Button:2',
|
||||
component: 'Button',
|
||||
props: {
|
||||
id: '333',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const cloned = deepCloneNode(schema);
|
||||
console.log(cloned);
|
||||
expect(cloned.props.id).toBe(schema.props.id);
|
||||
expect(cloned.children[0].props.id).toBe(schema.children[0].props.id);
|
||||
|
||||
expect(cloned.id).not.toBe(schema.id);
|
||||
expect(cloned.children[0].id).not.toBe(schema.children[0].id);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.prod.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Tango 低代码设计器
|
||||
|
||||
## 使用文档
|
||||
|
||||
https://tango-docs.st.netease.com/
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@music163/tango-designer",
|
||||
"version": "0.1.1",
|
||||
"description": "lowcode designer",
|
||||
"keywords": [
|
||||
"react"
|
||||
],
|
||||
"author": "wwsun <ww.sun@outlook.com>",
|
||||
"homepage": "",
|
||||
"license": "MIT",
|
||||
"main": "lib/cjs/index.js",
|
||||
"module": "lib/esm/index.js",
|
||||
"types": "lib/esm/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"lib"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/netease/tango.git"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf lib/",
|
||||
"build": "yarn clean && yarn build:esm && yarn build:cjs",
|
||||
"build:esm": "tsc --project tsconfig.prod.json --outDir lib/esm/ --module ES2020",
|
||||
"build:cjs": "tsc --project tsconfig.prod.json --outDir lib/cjs/ --module CommonJS",
|
||||
"prepublishOnly": "yarn build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^4.8.0",
|
||||
"@music163/request": "^0.1.0",
|
||||
"@music163/tango-context": "^0.1.1",
|
||||
"@music163/tango-core": "^0.1.1",
|
||||
"@music163/tango-helpers": "^0.1.1",
|
||||
"@music163/tango-sandbox": "^0.1.1",
|
||||
"@music163/tango-setting-form": "^0.1.1",
|
||||
"@music163/tango-ui": "^0.1.1",
|
||||
"antd": "^4.24.2",
|
||||
"cash-dom": "^8.1.2",
|
||||
"classnames": "^2.3.2",
|
||||
"coral-system": "^1.0.5",
|
||||
"date-fns": "^2.29.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"react-resizable": "^3.0.5",
|
||||
"semver": "^7.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react-resizable": "^3.0.4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createContext, TangoRemoteServicesType } from '@music163/tango-helpers';
|
||||
import type { DndQuery } from './framework';
|
||||
|
||||
/**
|
||||
* TODO: 是不是直接合并到 Context 中
|
||||
*/
|
||||
export interface IDesignerContext {
|
||||
/**
|
||||
* 沙箱查询实例
|
||||
*/
|
||||
sandboxQuery: DndQuery;
|
||||
/**
|
||||
* 远程服务
|
||||
*/
|
||||
remoteServices?: TangoRemoteServicesType;
|
||||
}
|
||||
|
||||
const [DesignerProvider, useDesigner] = createContext<IDesignerContext>({
|
||||
name: 'DesignerContext',
|
||||
});
|
||||
|
||||
export { DesignerProvider };
|
||||
|
||||
export const useSandboxQuery = () => {
|
||||
return useDesigner()?.sandboxQuery;
|
||||
};
|
||||
|
||||
export const useRemoteServices = () => {
|
||||
return useDesigner()?.remoteServices;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { SystemProvider } from 'coral-system';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/lib/locale/zh_CN';
|
||||
import { TangoEngineProvider, ITangoEngineContext } from '@music163/tango-context';
|
||||
import { DesignerProvider, IDesignerContext } from './context';
|
||||
import { theme as baseTheme } from './theme';
|
||||
import { createFromIconfontCN } from '@ant-design/icons';
|
||||
|
||||
export interface DesignerProps extends IDesignerContext, ITangoEngineContext {
|
||||
/**
|
||||
* 主题包
|
||||
*/
|
||||
theme?: any;
|
||||
/**
|
||||
* 自定义图表库svg脚本地址
|
||||
*/
|
||||
iconfontScriptUrl?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设计器状态和设置容器
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export function Designer(props: DesignerProps) {
|
||||
const {
|
||||
engine,
|
||||
theme: themeProp,
|
||||
sandboxQuery,
|
||||
remoteServices = {},
|
||||
iconfontScriptUrl = '//at.alicdn.com/t/c/font_2891794_lzc7rtwuzf.js',
|
||||
children,
|
||||
} = props;
|
||||
const theme = themeProp ?? baseTheme;
|
||||
useEffect(() => {
|
||||
createFromIconfontCN({
|
||||
scriptUrl: iconfontScriptUrl,
|
||||
});
|
||||
}, [iconfontScriptUrl]);
|
||||
return (
|
||||
<SystemProvider theme={theme} prefix="--tango">
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<TangoEngineProvider value={{ engine }}>
|
||||
<DesignerProvider value={{ sandboxQuery, remoteServices }}>{children}</DesignerProvider>
|
||||
</TangoEngineProvider>
|
||||
</ConfigProvider>
|
||||
</SystemProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Box } from 'coral-system';
|
||||
import { ReactComponentProps } from '@music163/tango-helpers';
|
||||
|
||||
export interface DesignerPanelProps extends ReactComponentProps {
|
||||
/**
|
||||
* 品牌图标
|
||||
*/
|
||||
logo?: React.ReactNode;
|
||||
/**
|
||||
* 项目描述
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
/**
|
||||
* 主行动点
|
||||
*/
|
||||
actions?: React.ReactNode;
|
||||
/**
|
||||
* 自定义头部节点
|
||||
*/
|
||||
header?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设计器面板
|
||||
*/
|
||||
export function DesignerPanel(props: DesignerPanelProps) {
|
||||
const { header, logo, description, actions, children } = props;
|
||||
return (
|
||||
<Box height="100vh" overflow="hidden" className="DesignerPanel">
|
||||
{header ?? (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
height="48px"
|
||||
bg="colors.custom.topNavBg"
|
||||
color="colors.custom.topNavColor"
|
||||
className="DesignerPanelHeader"
|
||||
>
|
||||
<Box display="flex" alignItems="center">
|
||||
{logo}
|
||||
{description}
|
||||
</Box>
|
||||
<Box flex="1">{actions}</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
display="flex"
|
||||
height="calc(100vh - 48px)"
|
||||
overflow="hidden"
|
||||
className="DesignerPanelBody"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import $, { Selector } from 'cash-dom';
|
||||
import { ISelectedItemData, MousePoint, SLOT } from '@music163/tango-helpers';
|
||||
import {
|
||||
getElementData,
|
||||
getElementBoundingData,
|
||||
buildQueryBySlotId,
|
||||
getRelativePoint,
|
||||
} from '../../helpers';
|
||||
|
||||
export const DRAGGABLE_SELECTOR = `[${SLOT.dnd}]`;
|
||||
|
||||
interface DndQueryOptions {
|
||||
/**
|
||||
* DOM 查询上下文选择器
|
||||
* TODO: 是不是可以合并成一个 API
|
||||
*/
|
||||
context?: string;
|
||||
/**
|
||||
* 二级上下文,适用于 iframe 中的 iframe
|
||||
*/
|
||||
secondaryContext?: string;
|
||||
/**
|
||||
* 上层容器选择器
|
||||
*/
|
||||
container?: string;
|
||||
}
|
||||
|
||||
export class DndQuery {
|
||||
/**
|
||||
* dnd 上下文选择器,如果是 iframe,则是 iframe 里的 window 对象
|
||||
*/
|
||||
private readonly _context: string;
|
||||
/**
|
||||
* dnd 二级上下文选择器,适用于存在 iframe 多层嵌套的场景
|
||||
*/
|
||||
private readonly _secondaryContext: string;
|
||||
/**
|
||||
* 拖拽沙箱的外层容器选择器,用于辅助计算相对位置
|
||||
*/
|
||||
private readonly _container: string;
|
||||
|
||||
get container() {
|
||||
return this._container ? $(this._container).get(0) : undefined;
|
||||
}
|
||||
|
||||
get context() {
|
||||
if (this._context) {
|
||||
// return the document object of iframe
|
||||
return this._secondaryContext
|
||||
? $(this._context).contents().find(this._secondaryContext).contents().get(0) // iframe in iframe
|
||||
: $(this._context).contents().get(0); // iframe
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是隔离的沙箱环境,目前仅 iframe 环境为隔离沙箱
|
||||
*/
|
||||
get isSeparated() {
|
||||
if (this.context && 'defaultView' in this.context) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get window() {
|
||||
if (this.context && 'defaultView' in this.context) {
|
||||
return (this.context as unknown as Document).defaultView;
|
||||
}
|
||||
// 否则返回当前的 window
|
||||
return window;
|
||||
}
|
||||
|
||||
get scrollTop() {
|
||||
if (this.context && 'documentElement' in this.context) {
|
||||
return (this.context as unknown as Document).documentElement.scrollTop;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
constructor({ context, secondaryContext, container }: DndQueryOptions) {
|
||||
this._context = context;
|
||||
this._secondaryContext = secondaryContext;
|
||||
this._container = container;
|
||||
}
|
||||
|
||||
get(selector: Selector) {
|
||||
return $(selector, this.context);
|
||||
}
|
||||
|
||||
getElement(selector: Selector) {
|
||||
return $(selector, this.context).get(0);
|
||||
}
|
||||
|
||||
getElementBySlotId(slotId: string) {
|
||||
return this.getElement(buildQueryBySlotId(slotId));
|
||||
}
|
||||
|
||||
getElementData(element: HTMLElement) {
|
||||
return getElementData(element, this.container);
|
||||
}
|
||||
|
||||
getElementBounding(element: HTMLElement) {
|
||||
return getElementBoundingData(element, this.container);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取相对容器的位置
|
||||
* @param point
|
||||
*/
|
||||
getRelativePoint(point: MousePoint) {
|
||||
return getRelativePoint(point, this.container);
|
||||
}
|
||||
|
||||
isChildOfElement(parentElementSlotId: string, childElementSlotId: string) {
|
||||
return !!this.get(buildQueryBySlotId(parentElementSlotId)).has(
|
||||
this.getElementBySlotId(childElementSlotId),
|
||||
).length;
|
||||
}
|
||||
|
||||
getDraggableParents(selector: Selector) {
|
||||
const closestElement = this.get(selector).closest(DRAGGABLE_SELECTOR).get(0);
|
||||
const parents = this.get(selector).parents(DRAGGABLE_SELECTOR).get();
|
||||
|
||||
// 对于只有一层结构的组件,需要额外将自身加到列表里
|
||||
if (parents[0] !== closestElement) {
|
||||
parents.unshift(closestElement);
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可拖拽的子元素
|
||||
* @param selector
|
||||
* @param locateSelector
|
||||
* @returns
|
||||
*/
|
||||
getDraggableDescendants(selector: Selector, descendantSelector: string = DRAGGABLE_SELECTOR) {
|
||||
return this.get(selector).find(descendantSelector).get();
|
||||
}
|
||||
|
||||
getDraggableParentsData(selector: Selector, hasParents: boolean): ISelectedItemData {
|
||||
const targets = this.getDraggableParents(selector);
|
||||
|
||||
if (!targets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasParents) {
|
||||
const parents = targets.map((target) => this.getElementData(target));
|
||||
const closet = parents[0];
|
||||
|
||||
return {
|
||||
...closet,
|
||||
parents: parents.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
return this.getElementData(targets[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得最近的一个可拖拽的父结点的所有子结点
|
||||
* @param selector
|
||||
* @param startPoint
|
||||
* @returns
|
||||
*/
|
||||
getDraggableElementsDataByArea(selector: Selector, startPoint: MousePoint, endPoint: MousePoint) {
|
||||
const firstChildElement = this.get(selector)
|
||||
.closest(DRAGGABLE_SELECTOR)
|
||||
.find(DRAGGABLE_SELECTOR)
|
||||
.get(0);
|
||||
if (!firstChildElement) {
|
||||
return [];
|
||||
}
|
||||
let children = this.get(firstChildElement).siblings(DRAGGABLE_SELECTOR).get();
|
||||
children = [firstChildElement, ...children];
|
||||
const list: ISelectedItemData[] = [];
|
||||
children.forEach((item) => {
|
||||
const data = this.getElementData(item);
|
||||
const { left, top } = data.bounding;
|
||||
if (left > startPoint.x && top > startPoint.y && left < endPoint.x && top < endPoint.y) {
|
||||
list.push(data);
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
scrollTo(leftOffset: number, topOffset: number) {
|
||||
this.window.scrollTo(leftOffset, topOffset);
|
||||
}
|
||||
|
||||
reload() {
|
||||
let iframe;
|
||||
if (this._context) {
|
||||
// ViteSandbox
|
||||
iframe = $(this._context).contents().get(0);
|
||||
} else {
|
||||
// CodeSandbox
|
||||
iframe = this.context;
|
||||
}
|
||||
|
||||
if (iframe && 'location' in iframe) {
|
||||
(iframe as unknown as Document)?.location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧版 API
|
||||
export const DomQuery = DndQuery;
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 快捷键
|
||||
*/
|
||||
export class Hotkey {
|
||||
private readonly hotkeyMap = {};
|
||||
|
||||
constructor(hotkeys: Record<string, Function>) {
|
||||
Object.keys(hotkeys).forEach((hotkey) => {
|
||||
const keys = hotkey.split(',');
|
||||
keys.forEach((key) => {
|
||||
if (key) {
|
||||
key = fixKey(key);
|
||||
this.hotkeyMap[key] = hotkeys[hotkey];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
run(hotkey: string) {
|
||||
const callback = this.hotkeyMap[hotkey];
|
||||
callback?.();
|
||||
}
|
||||
}
|
||||
|
||||
function fixKey(key: string) {
|
||||
if (key === 'esc') {
|
||||
return 'escape';
|
||||
}
|
||||
return key.replaceAll('command', 'meta');
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './use-dnd';
|
||||
export * from './dnd-query';
|
||||
@@ -0,0 +1,436 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { DropMethod, FileType, Designer, IWorkspace } from '@music163/tango-core';
|
||||
import { ISelectedItemData, events, getHotkey } from '@music163/tango-helpers';
|
||||
import {
|
||||
setElementStyle,
|
||||
getElementCSSDisplay,
|
||||
distanceToRect,
|
||||
getDragGhostElement,
|
||||
} from '../../helpers';
|
||||
import { DndQuery, DRAGGABLE_SELECTOR } from '../dnd';
|
||||
import { Hotkey } from './hotkey';
|
||||
import { SelectModeType } from '../../types';
|
||||
|
||||
interface UseDndProps {
|
||||
workspace: IWorkspace;
|
||||
designer: Designer;
|
||||
/**
|
||||
* 沙箱内的 DOM 查询操作
|
||||
*/
|
||||
sandboxQuery: DndQuery;
|
||||
/**
|
||||
* 选择模式
|
||||
* - point 点选
|
||||
* - area 框选
|
||||
*/
|
||||
selectMode?: SelectModeType;
|
||||
/**
|
||||
* 沙箱视图变化时的回调
|
||||
* @param data
|
||||
*/
|
||||
onViewChange?: (data: any) => void;
|
||||
}
|
||||
|
||||
export function useDnd({
|
||||
sandboxQuery,
|
||||
designer,
|
||||
workspace,
|
||||
selectMode = 'point',
|
||||
onViewChange,
|
||||
}: UseDndProps) {
|
||||
const selectSource = workspace.selectSource;
|
||||
|
||||
useEffect(() => {
|
||||
workspace.ready();
|
||||
}, [workspace]);
|
||||
|
||||
const hotkey = useMemo(() => {
|
||||
return new Hotkey({
|
||||
esc: () => {
|
||||
workspace.selectSource.clear();
|
||||
},
|
||||
'del,backspace': () => {
|
||||
workspace.removeSelectedNode();
|
||||
},
|
||||
'command+c,ctrl+c': () => {
|
||||
workspace.copySelectedNode();
|
||||
},
|
||||
'command+v,ctrl+v': () => {
|
||||
workspace.pasteSelectedNode();
|
||||
},
|
||||
});
|
||||
}, [workspace]);
|
||||
|
||||
let moveListener: any;
|
||||
let upListener: any;
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
if (designer.isPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = sandboxQuery.getRelativePoint({ x: e.clientX, y: e.clientY });
|
||||
selectSource.setStart({
|
||||
point,
|
||||
element: e.target as HTMLElement,
|
||||
});
|
||||
|
||||
const watchElement = sandboxQuery.context || sandboxQuery.container;
|
||||
|
||||
moveListener = events.on(watchElement, 'mousemove', onMouseMove);
|
||||
upListener = events.on(watchElement, 'mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: React.MouseEvent) => {
|
||||
const point = sandboxQuery.getRelativePoint({ x: e.clientX, y: e.clientY });
|
||||
setElementStyle('.SelectionMask', {
|
||||
width: Math.abs(selectSource.start?.point.x - point.x) + 'px',
|
||||
height: Math.abs(selectSource.start?.point.y - point.y) + 'px',
|
||||
left: Math.min(selectSource.start?.point.x, point.x) + 'px',
|
||||
top: Math.min(selectSource.start?.point.y, point.y) + 'px',
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseUp = (e: React.MouseEvent) => {
|
||||
moveListener?.off();
|
||||
upListener?.off();
|
||||
const point = sandboxQuery.getRelativePoint({ x: e.clientX, y: e.clientY });
|
||||
if (point.x === selectSource.start?.point.x && point.y === selectSource.start?.point.y) {
|
||||
// select current
|
||||
const data = sandboxQuery.getDraggableParentsData(e.target as HTMLElement, true);
|
||||
if (data && data.id) {
|
||||
selectSource.select(data);
|
||||
}
|
||||
} else {
|
||||
// select area
|
||||
setElementStyle('.SelectionMask', {
|
||||
width: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
const targets = sandboxQuery.getDraggableElementsDataByArea(
|
||||
selectSource.start.element,
|
||||
selectSource.start.point,
|
||||
point,
|
||||
);
|
||||
selectSource.select(targets);
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
const data = sandboxQuery.getDraggableParentsData(e.target as HTMLElement, true);
|
||||
if (data && data.id) {
|
||||
selectSource.select(data);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
const dragSourceData = sandboxQuery.getDraggableParentsData(e.target as HTMLElement, false);
|
||||
if (!dragSourceData || !dragSourceData.name) {
|
||||
// 如果没有找到可拖拽元素,直接退出
|
||||
return;
|
||||
}
|
||||
|
||||
const prototype = workspace.componentPrototypes.get(dragSourceData.name);
|
||||
if (!prototype) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { canDrag } = prototype.rules || {};
|
||||
if (canDrag && !canDrag()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
workspace.dragSource.set(dragSourceData);
|
||||
|
||||
const ghost = getDragGhostElement();
|
||||
e.dataTransfer.setDragImage(ghost, 0, 0);
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
workspace.dragSource.clear();
|
||||
};
|
||||
|
||||
let latestEnterId: string;
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!workspace.dragSource.prototype || // 没有原型
|
||||
!workspace.dragSource.dropTarget.id || // 没有明确的着陆点
|
||||
workspace.dragSource.id === workspace.dragSource.dropTarget.id || // 着陆点为自己
|
||||
sandboxQuery.isChildOfElement(workspace.dragSource.id, workspace.dragSource.dropTarget.id) // 自己是着陆点的父元素
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
workspace.dropNode();
|
||||
latestEnterId = undefined;
|
||||
moveListener?.off();
|
||||
upListener?.off();
|
||||
};
|
||||
|
||||
const onDragEnter = (e: React.DragEvent<HTMLElement>) => {
|
||||
if (!workspace.dragSource.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closetDropTargetData = sandboxQuery.getDraggableParentsData(
|
||||
e.target as HTMLElement,
|
||||
false,
|
||||
);
|
||||
if (!closetDropTargetData || !closetDropTargetData.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 不重复触发次逻辑
|
||||
if (latestEnterId === closetDropTargetData.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录上次进入的区域 ID
|
||||
latestEnterId = closetDropTargetData.id;
|
||||
|
||||
const closetDropTargetElement = closetDropTargetData.element;
|
||||
const closetDropTargetBounding = closetDropTargetData.bounding;
|
||||
const closetDropTargetDisplay = getElementCSSDisplay(closetDropTargetElement);
|
||||
const closetDropTargetPrototype = workspace.componentPrototypes.get(closetDropTargetData.name);
|
||||
|
||||
// 如果探测的最近着陆点为拖拽的元素自己的话,则跳过检测
|
||||
if (closetDropTargetData.id === workspace.dragSource.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!closetDropTargetPrototype) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验结点的拖拽规则
|
||||
if (closetDropTargetPrototype.rules) {
|
||||
const { canMoveIn } = closetDropTargetPrototype.rules;
|
||||
if (canMoveIn && !canMoveIn(workspace.dragSource.name)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let nextState: ISelectedItemData;
|
||||
let nextMethod: DropMethod;
|
||||
|
||||
if (closetDropTargetPrototype.type === 'placeholder') {
|
||||
nextState = closetDropTargetData;
|
||||
nextMethod = DropMethod.ReplaceNode;
|
||||
} else if (closetDropTargetPrototype.hasChildren) {
|
||||
let descendantSelector = DRAGGABLE_SELECTOR;
|
||||
if (closetDropTargetPrototype.rules?.childrenContainerSelector) {
|
||||
descendantSelector = `${closetDropTargetPrototype.rules?.childrenContainerSelector} ${DRAGGABLE_SELECTOR}`;
|
||||
}
|
||||
const children = sandboxQuery.getDraggableDescendants(
|
||||
closetDropTargetElement,
|
||||
descendantSelector,
|
||||
);
|
||||
|
||||
if (children.length) {
|
||||
// 容器组件中有子组件,定位到与当前位置最近的子元素,插入位置为该子元素的尾部
|
||||
let closetChildElement;
|
||||
let closetDistance;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const distance = distanceToRect(e, children[i].getBoundingClientRect());
|
||||
if (!closetDistance || distance < closetDistance) {
|
||||
closetDistance = distance;
|
||||
closetChildElement = children[i];
|
||||
}
|
||||
}
|
||||
const closetChildElementData = sandboxQuery.getElementData(closetChildElement);
|
||||
|
||||
if (!closetChildElementData.id || !closetChildElementData.name) {
|
||||
// 如果子元素没有特定的属性,直接提前结束
|
||||
return;
|
||||
}
|
||||
|
||||
const closetChildElementBounding = closetChildElementData.bounding;
|
||||
const closetChildElementDisplay = getElementCSSDisplay(closetChildElement);
|
||||
|
||||
nextState = closetChildElementData;
|
||||
|
||||
switch (closetChildElementDisplay) {
|
||||
case 'inline':
|
||||
case 'inline-block':
|
||||
case 'inline-flex':
|
||||
case 'inline-grid': {
|
||||
// 行内元素
|
||||
if (e.pageX < closetChildElementBounding.left + closetChildElementBounding.width / 2) {
|
||||
nextMethod = DropMethod.InsertBefore;
|
||||
} else {
|
||||
nextMethod = DropMethod.InsertAfter;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// 块级元素
|
||||
if (e.pageY < closetChildElementBounding.top + closetChildElementBounding.height / 2) {
|
||||
nextMethod = DropMethod.InsertBefore;
|
||||
} else {
|
||||
nextMethod = DropMethod.InsertAfter;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有孩子节点的容器节点
|
||||
nextState = closetDropTargetData;
|
||||
nextMethod = DropMethod.InsertChild;
|
||||
}
|
||||
} else {
|
||||
// dropTarget is not a container
|
||||
nextState = closetDropTargetData;
|
||||
|
||||
switch (closetDropTargetDisplay) {
|
||||
case 'inline':
|
||||
case 'inline-block':
|
||||
case 'inline-flex':
|
||||
case 'inline-grid':
|
||||
case 'table-cell': {
|
||||
// 行内元素
|
||||
if (
|
||||
closetDropTargetBounding.left < e.pageX &&
|
||||
e.pageX < closetDropTargetBounding.left + closetDropTargetBounding.width / 2
|
||||
) {
|
||||
nextMethod = DropMethod.InsertBefore;
|
||||
} else if (
|
||||
closetDropTargetBounding.left + closetDropTargetBounding.width / 2 < e.pageX &&
|
||||
e.pageX < closetDropTargetBounding.left + closetDropTargetBounding.width
|
||||
) {
|
||||
nextMethod = DropMethod.InsertAfter;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// 块级元素
|
||||
if (
|
||||
closetDropTargetBounding.top < e.pageY &&
|
||||
e.pageY < closetDropTargetBounding.top + closetDropTargetBounding.height / 2
|
||||
) {
|
||||
nextMethod = DropMethod.InsertBefore;
|
||||
} else {
|
||||
nextMethod = DropMethod.InsertAfter;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const closetDropTargetNode = workspace.getNode(nextState.id);
|
||||
|
||||
// 区块不能拖拽到区块中
|
||||
if (
|
||||
workspace.dragSource.prototype.type === 'block' &&
|
||||
closetDropTargetNode.file.type === FileType.BlockEntryModule
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextState) {
|
||||
// 更新拖拽放置的目标元素
|
||||
workspace.dragSource.dropTarget.set(nextState, nextMethod);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
let timer = 0;
|
||||
// 目前只有 iframe 的沙箱需要监听
|
||||
const onScroll = () => {
|
||||
if (!sandboxQuery.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectSource.isSelected) {
|
||||
if (timer) {
|
||||
cancelAnimationFrame(timer);
|
||||
}
|
||||
// TIP: 这里根据沙箱的滚动去修正选中框的位置
|
||||
timer = requestAnimationFrame(() => {
|
||||
// 重新获取选中元素的外观数据
|
||||
selectSource.selected.forEach((item) => {
|
||||
if (item.element) {
|
||||
const bounding = sandboxQuery.getElementBounding(item.element);
|
||||
setElementStyle(`[data-selection-id="${item.id}"]`, {
|
||||
transform: `translate(${bounding.left}px, ${bounding.top}px)`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
const isInputElement = isInputElements((e.target as HTMLElement).nodeName);
|
||||
// 禁用输入事件的触发的快捷键逻辑
|
||||
if (!isInputElement) {
|
||||
const key = getHotkey(e);
|
||||
hotkey.run(key);
|
||||
}
|
||||
};
|
||||
|
||||
const onTango = (e: CustomEvent) => {
|
||||
const detail = e.detail || {};
|
||||
|
||||
switch (detail.type) {
|
||||
case 'insertChild':
|
||||
workspace.insertToNode(detail.targetId, detail.sourceName);
|
||||
break;
|
||||
case 'replaceNode':
|
||||
workspace.replaceNode(detail.targetId, detail.sourceName);
|
||||
break;
|
||||
case 'viewChange':
|
||||
// FIXME: 内部路由跳转,更新 activeRoute 等信息,有风险,先去掉
|
||||
// const location = detail.data || {};
|
||||
// workspace.setActiveRoute(location.pathname + location.search);
|
||||
onViewChange?.(detail.data);
|
||||
break;
|
||||
case 'openSmartWizard':
|
||||
// 修正 selectSource
|
||||
if (detail.targetId !== selectSource.first?.id) {
|
||||
const element = sandboxQuery.getElementBySlotId(detail.targetId);
|
||||
const elementData = sandboxQuery.getElementData(element);
|
||||
selectSource.select(elementData);
|
||||
}
|
||||
|
||||
// 打开智能向导弹窗
|
||||
designer.toggleSmartWizard(true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const selectHandler = selectMode === 'point' ? { onClick } : { onMouseDown };
|
||||
|
||||
return {
|
||||
onDragStart,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
onScroll,
|
||||
onKeyDown,
|
||||
onTango,
|
||||
...selectHandler,
|
||||
};
|
||||
}
|
||||
|
||||
const inputElements = ['input', 'textarea', 'text'];
|
||||
|
||||
function isInputElements(elementType: string) {
|
||||
if (inputElements.includes(elementType?.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { Box } from 'coral-system';
|
||||
import { MultiEditor, MultiEditorProps } from '@music163/tango-ui';
|
||||
import { observer, useWorkspace } from '@music163/tango-context';
|
||||
import { isValidCode } from '@music163/tango-core';
|
||||
import { Modal } from 'antd';
|
||||
|
||||
const ideConfig = {
|
||||
// disableFileOps: {
|
||||
// add: true,
|
||||
// delete: true,
|
||||
// },
|
||||
// disableFolderOps: {
|
||||
// add: true,
|
||||
// delete: true,
|
||||
// rename: true,
|
||||
// },
|
||||
disablePrettier: true,
|
||||
disableEslint: true,
|
||||
saveWhenBlur: true,
|
||||
// disableSetting: true,
|
||||
};
|
||||
|
||||
export interface CodeEditorProps extends Partial<MultiEditorProps> {
|
||||
/**
|
||||
* 是否自动清楚未使用的导入
|
||||
*/
|
||||
autoRemoveUnusedImports?: boolean;
|
||||
}
|
||||
|
||||
export const CodeEditor = observer(
|
||||
({ autoRemoveUnusedImports = true, ...rest }: CodeEditorProps) => {
|
||||
const editorRef = useRef(null);
|
||||
const workspace = useWorkspace();
|
||||
const files = workspace.listFiles();
|
||||
const activeFile = workspace.activeFile;
|
||||
|
||||
let loc: any; // 记录视图代码的选中位置
|
||||
const selectNode = workspace.selectSource.firstNode;
|
||||
if (selectNode && activeFile === workspace.activeViewFile) {
|
||||
loc = selectNode.loc;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.refresh(files, activeFile, loc);
|
||||
}, [files, activeFile, loc]);
|
||||
|
||||
const fileSave = useCallback(
|
||||
(path: string, value: string) => {
|
||||
if (!isJsFile(path)) {
|
||||
// 非 js 文件直接保存
|
||||
workspace.updateFile(path, value, autoRemoveUnusedImports);
|
||||
return;
|
||||
}
|
||||
|
||||
// js 文件需要先检查语法,只有语法正确才会保存
|
||||
if (isValidCode(value)) {
|
||||
workspace.updateFile(path, value, autoRemoveUnusedImports);
|
||||
} else {
|
||||
Modal.confirm({
|
||||
title: '检测到代码中存在语法错误,暂时无法将代码同步给设计器,是否回退到安全代码?',
|
||||
onOk: () => {
|
||||
editorRef.current?.refresh(files, activeFile);
|
||||
},
|
||||
onCancel: () => {},
|
||||
});
|
||||
}
|
||||
},
|
||||
[workspace, autoRemoveUnusedImports, activeFile, files],
|
||||
);
|
||||
|
||||
const handleRenameFile = useCallback(
|
||||
(oldFilename: string, newFilename: string) => {
|
||||
workspace.renameFile(oldFilename, newFilename);
|
||||
workspace.setActiveFile(newFilename);
|
||||
},
|
||||
[workspace],
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(type: string, info: any) => {
|
||||
switch (type) {
|
||||
case 'addFile':
|
||||
workspace.addFile(info.path, info.value);
|
||||
break;
|
||||
case 'deleteFile':
|
||||
case 'deleteFolder':
|
||||
workspace.removeFile(info.path);
|
||||
break;
|
||||
case 'renameFile':
|
||||
case 'renameFolder':
|
||||
case 'addFolder':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[workspace],
|
||||
);
|
||||
|
||||
const handlePathChange = useCallback(
|
||||
(path: string) => {
|
||||
workspace.setActiveFile(path);
|
||||
},
|
||||
[workspace],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="row" height="100%" bg="white">
|
||||
<MultiEditor
|
||||
ref={editorRef}
|
||||
options={{
|
||||
fontSize: 14,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
ideConfig={ideConfig}
|
||||
onFileSave={fileSave}
|
||||
onRenameFile={handleRenameFile}
|
||||
onPathChange={handlePathChange}
|
||||
onFileChange={handleFileChange}
|
||||
defaultPath={activeFile}
|
||||
defaultTheme="GithubLightDefault"
|
||||
defaultFiles={files}
|
||||
{...rest}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function isJsFile(path: string) {
|
||||
return /.jsx?$/.test(path);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './editor';
|
||||
// export { LegacyCodeEditor as CodeEditor } from './legacy-editor';
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './designer-panel';
|
||||
export * from './sidebar-panel';
|
||||
export * from './setting-panel';
|
||||
export * from './workspace-panel';
|
||||
export * from './toolbar-panel';
|
||||
export * from './view-panel';
|
||||
export * from './editor';
|
||||
export * from './sandbox';
|
||||
export * from './dnd';
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Box, HTMLCoralProps, css } from 'coral-system';
|
||||
import { Resizable } from 'react-resizable';
|
||||
|
||||
const resizeHandleStyle = css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: var(--tango-colors-brand);
|
||||
}
|
||||
`;
|
||||
|
||||
export interface ResizableBoxProps extends HTMLCoralProps<'div'> {
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizeHandlePosition?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function ResizableBox({
|
||||
resizeHandlePosition = 'right',
|
||||
width: widthProp,
|
||||
height,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: ResizableBoxProps) {
|
||||
const [width, setWidth] = useState(widthProp);
|
||||
const barStyle = resizeHandlePosition === 'right' ? { right: '-4px' } : { left: '-4px' };
|
||||
return (
|
||||
<Resizable
|
||||
axis="x"
|
||||
width={width}
|
||||
height={height}
|
||||
onResize={(e, { size }) => {
|
||||
setWidth(size.width);
|
||||
}}
|
||||
onResizeStart={() => {
|
||||
document.body.style.pointerEvents = 'none';
|
||||
document.body.style.userSelect = 'none';
|
||||
}}
|
||||
onResizeStop={() => {
|
||||
document.body.style.pointerEvents = 'auto';
|
||||
document.body.style.userSelect = 'auto';
|
||||
}}
|
||||
handle={<Box className="ResizeHandle" css={resizeHandleStyle} {...barStyle} />}
|
||||
>
|
||||
<div
|
||||
className={cx('ResizableBox', className)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width,
|
||||
height,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Resizable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './sandbox';
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { Box, css, Group } from 'coral-system';
|
||||
import { IconButton, ToggleButton } from '@music163/tango-ui';
|
||||
import { observer, useDesigner } from '@music163/tango-context';
|
||||
import { Input } from 'antd';
|
||||
import {
|
||||
DesktopOutlined,
|
||||
MobileOutlined,
|
||||
ReloadOutlined,
|
||||
ArrowLeftOutlined,
|
||||
ArrowRightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const navigatorStyle = css`
|
||||
display: flex;
|
||||
column-gap: 12px;
|
||||
|
||||
.navigatorInput {
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
interface NavigatorProps {
|
||||
disabled?: boolean;
|
||||
startRoute?: string;
|
||||
onBack?: () => void;
|
||||
onForward?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onInputEnter?: (text: string, e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
interface NavigatorState {
|
||||
relativeUrl: string;
|
||||
}
|
||||
|
||||
export class Navigator extends React.Component<NavigatorProps, NavigatorState> {
|
||||
constructor(props: NavigatorProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
relativeUrl: props.startRoute || '/',
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<NavigatorProps>, prevState: Readonly<NavigatorState>) {
|
||||
if (
|
||||
this.props.startRoute &&
|
||||
prevProps.startRoute !== this.props.startRoute &&
|
||||
this.props.startRoute !== prevState.relativeUrl
|
||||
) {
|
||||
this.setState({
|
||||
relativeUrl: this.props.startRoute,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
changeRelativeUrl = (newUrl: string) => {
|
||||
if (newUrl !== this.state.relativeUrl) {
|
||||
this.setState({
|
||||
relativeUrl: newUrl,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { disabled, onBack, onForward, onRefresh } = this.props;
|
||||
return (
|
||||
<Box
|
||||
className="navigator"
|
||||
bg="white"
|
||||
px="l"
|
||||
py="4px"
|
||||
borderBottom="solid"
|
||||
borderBottomColor="line.normal"
|
||||
css={navigatorStyle}
|
||||
>
|
||||
<Box>
|
||||
<IconButton icon={<ArrowLeftOutlined />} onClick={onBack} title="返回" />
|
||||
<IconButton icon={<ArrowRightOutlined />} onClick={onForward} title="前进" />
|
||||
<IconButton icon={<ReloadOutlined />} onClick={onRefresh} title="刷新" />
|
||||
</Box>
|
||||
<Input
|
||||
className="navigatorInput"
|
||||
size="small"
|
||||
value={this.state.relativeUrl}
|
||||
onChange={this.onInputChange}
|
||||
onPressEnter={this.onPressEnter}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ViewportSwitch />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
private onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const path = e.target.value.startsWith('/') ? e.target.value : `/${e.target.value}`;
|
||||
this.changeRelativeUrl(path);
|
||||
};
|
||||
|
||||
private onPressEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const newUrl = e.currentTarget.value;
|
||||
this.props.onInputEnter?.(newUrl, e);
|
||||
};
|
||||
}
|
||||
|
||||
const ViewportSwitch = observer(() => {
|
||||
const designer = useDesigner();
|
||||
return (
|
||||
<Group attached>
|
||||
<ToggleButton
|
||||
size="s"
|
||||
selected={designer.simulator.name === 'desktop'}
|
||||
onClick={() => {
|
||||
designer.setSimulator('desktop');
|
||||
}}
|
||||
>
|
||||
<DesktopOutlined />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
size="s"
|
||||
selected={designer.simulator.name === 'phone'}
|
||||
onClick={() => {
|
||||
designer.setSimulator('phone');
|
||||
}}
|
||||
>
|
||||
<MobileOutlined />
|
||||
</ToggleButton>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
import React, { useImperativeHandle, ForwardedRef, useRef, useState, useEffect } from 'react';
|
||||
import { Box } from 'coral-system';
|
||||
import { CodeSandbox, CodeSandboxProps } from '@music163/tango-sandbox';
|
||||
import { getValue, logger, pick, setValue } from '@music163/tango-helpers';
|
||||
import { observer, useWorkspace, useDesigner } from '@music163/tango-context';
|
||||
import { Simulator, Viewport } from '../simulator';
|
||||
import { useSandboxQuery } from '../../context';
|
||||
import { DndQuery, useDnd } from '../dnd';
|
||||
import { Navigator } from './navigator';
|
||||
import { SelectionToolsProps } from '../simulator/selection';
|
||||
|
||||
type SandboxEventHandlerConfig = {
|
||||
sandboxQuery?: DndQuery;
|
||||
sandboxType?: 'design' | 'preview';
|
||||
isActive: boolean;
|
||||
[x: string]: any;
|
||||
};
|
||||
|
||||
export type SandboxProps = Omit<CodeSandboxProps, 'files' | 'eventHandlers' | 'onMessage'> & {
|
||||
isPreview?: boolean;
|
||||
selectionTools?: SelectionToolsProps['actions'];
|
||||
sandboxType?: 'design' | 'preview';
|
||||
mode?: 'single' | 'combined';
|
||||
injectScript?: string;
|
||||
onViewChange?: (data: any, config?: SandboxEventHandlerConfig) => void;
|
||||
onMessage?: (data: any, config?: SandboxEventHandlerConfig) => void;
|
||||
onLoad?: (config?: SandboxEventHandlerConfig) => void;
|
||||
};
|
||||
|
||||
export type CombinedSandboxRef = {
|
||||
designSandbox: CodeSandbox;
|
||||
previewSandbox: CodeSandbox;
|
||||
};
|
||||
|
||||
const LANDING_PAGE_PATH = '/__background_landing_page__';
|
||||
|
||||
function useSandbox({
|
||||
isPreview: isPreviewProp,
|
||||
onViewChange,
|
||||
onMessage: onMessageProp,
|
||||
onLoad: onLoadProp,
|
||||
sandboxType,
|
||||
startRoute,
|
||||
injectScript,
|
||||
}: SandboxProps = {}): CodeSandboxProps & { display: string } {
|
||||
const workspace = useWorkspace();
|
||||
const designer = useDesigner();
|
||||
const sandboxQuery = useSandboxQuery();
|
||||
const isPreview = isPreviewProp ?? designer.isPreview;
|
||||
|
||||
// 组件不一定会立即刷新,因此 isActive 需要实时获取
|
||||
const getIsActive = () => isPreview === designer.isPreview;
|
||||
const isActive = getIsActive();
|
||||
|
||||
const getSandboxConfig = () => ({
|
||||
sandboxQuery,
|
||||
isPreview,
|
||||
isActive: getIsActive(),
|
||||
sandboxType,
|
||||
});
|
||||
|
||||
const dndHandlers = useDnd({
|
||||
sandboxQuery,
|
||||
workspace,
|
||||
designer,
|
||||
onViewChange: (data) => onViewChange && onViewChange(data, getSandboxConfig()),
|
||||
});
|
||||
|
||||
let files = Array.from(workspace.files.keys()).reduce((prev, filename) => {
|
||||
let code = workspace.getFile(filename).code;
|
||||
if (filename === '/tango.config.json') {
|
||||
code = mergeTangoConfigJson(code, isPreview, { injectScript });
|
||||
}
|
||||
prev[filename] = { code };
|
||||
return prev;
|
||||
}, {});
|
||||
files = normalizeFiles(files, workspace.entry);
|
||||
|
||||
const onMessage = (data: any) => onMessageProp && onMessageProp(data, getSandboxConfig());
|
||||
const onLoad = () => onLoadProp && onLoadProp(getSandboxConfig());
|
||||
|
||||
// 根据当前 workspace 状态与组件传入的状态是否一致,控制是否需要切换到空白路由
|
||||
const display = isActive ? 'block' : 'none';
|
||||
const routePath = isActive ? startRoute || workspace.activeRoute : LANDING_PAGE_PATH;
|
||||
|
||||
const sandboxProps = isPreview
|
||||
? {
|
||||
files,
|
||||
eventHandlers: pick(dndHandlers, ['onTango']),
|
||||
onMessage,
|
||||
display,
|
||||
startRoute: routePath,
|
||||
onLoad,
|
||||
}
|
||||
: {
|
||||
files,
|
||||
eventHandlers: dndHandlers as any,
|
||||
onMessage,
|
||||
display,
|
||||
startRoute: routePath,
|
||||
onLoad,
|
||||
};
|
||||
|
||||
return sandboxProps;
|
||||
}
|
||||
|
||||
const PreviewSandbox = observer(
|
||||
(props: SandboxProps, ref: ForwardedRef<CodeSandbox>) => {
|
||||
const { onViewChange, onMessage, startRoute, isPreview = true, injectScript, ...rest } = props;
|
||||
const { display, ...sandboxProps } = useSandbox({
|
||||
isPreview,
|
||||
onViewChange,
|
||||
onMessage,
|
||||
startRoute,
|
||||
injectScript,
|
||||
sandboxType: 'preview',
|
||||
});
|
||||
|
||||
return (
|
||||
<Box display={display} width="100%" height="100%">
|
||||
<CodeSandbox ref={ref} iframeId="preview-sandbox-container" {...sandboxProps} {...rest} />
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
{
|
||||
forwardRef: true,
|
||||
},
|
||||
);
|
||||
PreviewSandbox.displayName = 'PreviewSandbox';
|
||||
|
||||
const DesignSandbox = observer(
|
||||
(props: SandboxProps, ref: ForwardedRef<CodeSandbox>) => {
|
||||
const { onViewChange, onMessage, startRoute, isPreview = false, injectScript, ...rest } = props;
|
||||
const workspace = useWorkspace();
|
||||
const { display, ...sandboxProps } = useSandbox({
|
||||
isPreview,
|
||||
onViewChange,
|
||||
onMessage: (data, config) => {
|
||||
if (onMessage) {
|
||||
onMessage(data, config);
|
||||
}
|
||||
|
||||
const { sandboxQuery } = config;
|
||||
|
||||
const sandboxRendered = ['success', 'resize'].includes(data.type);
|
||||
|
||||
// 根据代码更新情况重设选中元素的外框
|
||||
if (sandboxRendered && workspace.selectSource.isSelected) {
|
||||
const items = workspace.selectSource.selected.map((item) => {
|
||||
const element = sandboxQuery.getElementBySlotId(item.id);
|
||||
if (element) {
|
||||
const bounding = sandboxQuery.getElementBounding(element);
|
||||
item.bounding = bounding;
|
||||
item.element = element;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
workspace.selectSource.select(items);
|
||||
}
|
||||
},
|
||||
startRoute,
|
||||
injectScript,
|
||||
sandboxType: 'design',
|
||||
});
|
||||
|
||||
return (
|
||||
<Box display={display} width="100%" height="100%">
|
||||
<CodeSandbox ref={ref} {...sandboxProps} {...rest} />
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
{
|
||||
forwardRef: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const CombinedSandbox = observer(
|
||||
(
|
||||
{ onViewChange: onViewChangeProp, ...rest }: SandboxProps,
|
||||
ref: ForwardedRef<CombinedSandboxRef>,
|
||||
) => {
|
||||
const workspace = useWorkspace();
|
||||
const designer = useDesigner();
|
||||
const designSandboxRef = useRef<CodeSandbox>();
|
||||
const previewSandboxRef = useRef<CodeSandbox>();
|
||||
const routePath = useRef<string>();
|
||||
const activeSandbox = useRef<string>();
|
||||
const [startRoute, setStartRoute] = useState(workspace.activeRoute);
|
||||
|
||||
const onViewChange = (data: any, config: SandboxEventHandlerConfig) => {
|
||||
if (config.isActive) {
|
||||
const curPath = data?.pathname + data?.search;
|
||||
const isSandboxChanged = config.sandboxType !== activeSandbox.current;
|
||||
|
||||
// 沙箱从 inactive -> active,通过跳转回上一页把 landing page pop 出去
|
||||
// onViewChange 似乎被 memo 了?useCallback 不生效,使用 ref 存储当前的 sandbox 类型
|
||||
if (isSandboxChanged) {
|
||||
const curSandbox = config.isPreview
|
||||
? previewSandboxRef.current
|
||||
: designSandboxRef.current;
|
||||
if (curSandbox) {
|
||||
try {
|
||||
// 当 sandbox 状态变为 active 时,此事件后于 isPreview 触发,sandbox 已经开始加载 startRoute
|
||||
// iframe 从 landing page 跳转到 startRoute,因此相较于切换前,历史记录多了 landing page 和 startRoute
|
||||
// 而在此事件中调用的 history.back() 晚于上述跳转的逻辑,所以 history.back() 实际是跳到 landing page
|
||||
// 但是如果不 history.back(),用户主动点击返回按钮,会进入 landing page,因为沙箱始终用 push
|
||||
//
|
||||
// 如果只后退一次,再 replace 到当前路由,其虽然消解了 landing page 的问题,但是会多出一个重复的前进路由
|
||||
// 因此回退两次,再 push 当前的路由,从而将之前沙箱内部的 push 销毁掉
|
||||
curSandbox.iframe.contentWindow.history.go(-2);
|
||||
// sandbox 切换 active 状态时,curPath 是切换时 sandbox 接受到的 startRoute prop
|
||||
// 而当时的 startRoute 还没变更(下面的 useEffect 注定晚于 isPreview 变更,所以 startRoute 还是之前的)
|
||||
// 因此 pop 路由时,取上一次 iframe 记录的 routePath
|
||||
curSandbox.iframe.contentWindow.history.pushState(null, null, routePath.current);
|
||||
curSandbox.iframe.contentWindow.dispatchEvent(new PopStateEvent('popstate'));
|
||||
} catch (err) {
|
||||
// 跨域?
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如上所述,每次切换预览态时,sandbox 第一次抛出的 curPath 都是上一次 sandbox 的 startRoute,不应该记录
|
||||
routePath.current = curPath;
|
||||
}
|
||||
|
||||
// 切换沙箱后,需要使用之前沙箱的路由
|
||||
activeSandbox.current = config.sandboxType;
|
||||
}
|
||||
|
||||
if (onViewChangeProp) {
|
||||
onViewChangeProp(data, config);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 主动切换 activeRoute 时,需要重置沙箱的路由
|
||||
routePath.current = workspace.activeRoute;
|
||||
setStartRoute(workspace.activeRoute);
|
||||
}, [workspace.activeRoute]);
|
||||
|
||||
// startRoute 只在切换 sandbox 时才重新设置,避免沙箱自行 pushState
|
||||
useEffect(() => {
|
||||
setStartRoute(routePath.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [designer.isPreview]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
designSandbox: designSandboxRef.current,
|
||||
previewSandbox: previewSandboxRef.current,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box width="100%" height="100%">
|
||||
<DesignSandbox
|
||||
ref={designSandboxRef}
|
||||
onViewChange={onViewChange}
|
||||
startRoute={startRoute}
|
||||
{...rest}
|
||||
/>
|
||||
<PreviewSandbox
|
||||
ref={previewSandboxRef}
|
||||
onViewChange={onViewChange}
|
||||
startRoute={startRoute}
|
||||
{...rest}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
{
|
||||
forwardRef: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const Sandbox = observer(
|
||||
({
|
||||
isPreview: isPreviewProp,
|
||||
selectionTools,
|
||||
bundlerURL,
|
||||
mode = 'combined',
|
||||
...props
|
||||
}: SandboxProps) => {
|
||||
const bundlerUrl = bundlerURL ?? 'https://codesandbox.fn.netease.com';
|
||||
const lastScrollTopRef = useRef<number>();
|
||||
const sandboxRef = useRef<CodeSandbox>(null);
|
||||
const combinedSandboxRef = useRef<CombinedSandboxRef>(null);
|
||||
const navigatorRef = useRef<Navigator>(null);
|
||||
const workspace = useWorkspace();
|
||||
const designer = useDesigner();
|
||||
|
||||
let sandbox = sandboxRef.current;
|
||||
if (mode === 'combined') {
|
||||
sandbox = designer.isPreview
|
||||
? combinedSandboxRef.current?.previewSandbox
|
||||
: combinedSandboxRef.current?.designSandbox;
|
||||
}
|
||||
|
||||
const onViewChange = (data: any, { isActive }: SandboxEventHandlerConfig) => {
|
||||
if (isActive) {
|
||||
navigatorRef.current.changeRelativeUrl(data?.pathname + data?.search);
|
||||
}
|
||||
};
|
||||
const onMessage = (data: any, config: any = {}) => {
|
||||
const { sandboxQuery, isActive } = config;
|
||||
if (data.type === 'start' && isActive) {
|
||||
// 每次沙箱重新渲染前记住 iframe 内的 scrollTop
|
||||
lastScrollTopRef.current = sandboxQuery?.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="SandboxContainer" height="100%">
|
||||
<Navigator
|
||||
ref={navigatorRef}
|
||||
disabled={!sandbox?.iframe}
|
||||
startRoute={workspace.activeRoute}
|
||||
onInputEnter={(newUrl) => {
|
||||
if (newUrl?.split('?')[0] === workspace.activeRoute) {
|
||||
// 只是输入了路由参数,默认为调试模式,直接更新沙箱内的路由地址
|
||||
sandbox?.updateRoute(newUrl);
|
||||
} else {
|
||||
// 如果是输入了其他路由,则直接跳转
|
||||
workspace.setActiveRoute(newUrl);
|
||||
}
|
||||
}}
|
||||
onBack={() => {
|
||||
sandbox?.manager.iframeProtocol.dispatch({ type: 'urlback' });
|
||||
}}
|
||||
onForward={() => {
|
||||
sandbox?.manager.iframeProtocol.dispatch({ type: 'urlforward' });
|
||||
}}
|
||||
onRefresh={() => {
|
||||
if (!sandbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!canAccessIFrame(sandbox.iframe) ||
|
||||
sandbox.iframe.contentWindow.origin !== sandbox.props.bundlerURL
|
||||
) {
|
||||
sandbox.iframe.src = `${bundlerUrl}${workspace.activeRoute}`;
|
||||
} else {
|
||||
sandbox.manager.iframeProtocol.dispatch({ type: 'refresh' });
|
||||
}
|
||||
workspace.selectSource.clear();
|
||||
}}
|
||||
/>
|
||||
<Simulator>
|
||||
<Viewport selectionTools={selectionTools}>
|
||||
{mode === 'single' && (
|
||||
<DesignSandbox
|
||||
ref={sandboxRef}
|
||||
template="create-react-app"
|
||||
bundlerURL={bundlerUrl}
|
||||
entry={workspace.entry}
|
||||
onViewChange={onViewChange}
|
||||
onMessage={onMessage}
|
||||
startRoute={workspace.activeRoute}
|
||||
isPreview={isPreviewProp ?? designer.isPreview}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
{mode === 'combined' && (
|
||||
<CombinedSandbox
|
||||
ref={combinedSandboxRef}
|
||||
template="create-react-app"
|
||||
bundlerURL={bundlerUrl}
|
||||
entry={workspace.entry}
|
||||
onViewChange={onViewChange}
|
||||
onMessage={onMessage}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</Viewport>
|
||||
</Simulator>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 兼容 tango.config.json,转成 sandbox.config.json
|
||||
function normalizeFiles(files: object, entry = '/src/index.js') {
|
||||
if (files['/tango.config.json']) {
|
||||
const tangConfigJsonStr = files['/tango.config.json'].code;
|
||||
const tangConfigJson = JSON.parse(tangConfigJsonStr);
|
||||
files['/sandbox.config.json'] = {
|
||||
code: JSON.stringify(tangConfigJson.sandbox, null, 2),
|
||||
};
|
||||
}
|
||||
if (!files['/index.html']) {
|
||||
files['/index.html'] = {
|
||||
code: `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:image/ico;base64,aWNv">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" />
|
||||
<script type="module" src="${entry}"></script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function mergeTangoConfigJson(code: string, isPreview: boolean, config?: { [x: string]: any }) {
|
||||
if (isPreview) {
|
||||
code = code.replaceAll('/umd/designer.', '/umd/index.').replaceAll('/es/designer?', '?');
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(code);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return code;
|
||||
}
|
||||
|
||||
const ox = getValue(json, 'dataSource.ox');
|
||||
const userJs = getValue(json, 'sandbox.evaluateJavaScript') || '';
|
||||
let mergedUserJs = userJs;
|
||||
const { injectScript } = config || {};
|
||||
|
||||
if (ox) {
|
||||
// TIP: 自动拼装 __tango_ox__ 注入到沙箱中
|
||||
mergedUserJs = `window.__tango_ox__=${JSON.stringify(ox)};${mergedUserJs}`;
|
||||
}
|
||||
if (injectScript) {
|
||||
mergedUserJs = `${mergedUserJs};${injectScript}`;
|
||||
}
|
||||
|
||||
if (userJs !== mergedUserJs) {
|
||||
setValue(json, 'sandbox.evaluateJavaScript', mergedUserJs);
|
||||
}
|
||||
|
||||
const i18n = getValue(json, 'i18n');
|
||||
if (i18n) {
|
||||
// TIP: 合并 i18n 配置到沙箱配置中
|
||||
setValue(json, 'sandbox.i18n', {
|
||||
id: i18n.appId,
|
||||
preModule: i18n.preModule,
|
||||
});
|
||||
}
|
||||
|
||||
// 合并 packages 内的信息至 sandbox
|
||||
const packages = getValue(json, 'packages');
|
||||
const externals = getValue(json, 'sandbox.externals') || {};
|
||||
const externalResources = getValue(json, 'sandbox.externalResources') || [];
|
||||
const newExternalResources = getValue(json, 'externalResources') || [];
|
||||
if (packages) {
|
||||
// 追加 umd 资源,并替换 url 中的 token,如版本号等
|
||||
const pushExternalResources = (list: string[], tokenMap?: { [x: string]: any }) => {
|
||||
const result = list.map((item) =>
|
||||
item.replace(/{{(.*?)}}/g, (matched, token) => {
|
||||
return tokenMap?.[token] || matched;
|
||||
}),
|
||||
);
|
||||
externalResources.push(...result);
|
||||
};
|
||||
// 追加 externals
|
||||
const pushExternals = (name: string, library?: string) => {
|
||||
if (library) {
|
||||
externals[name] = library;
|
||||
}
|
||||
};
|
||||
|
||||
Object.keys(packages).forEach((name) => {
|
||||
const item = packages[name];
|
||||
// 如果是设计态,且拥有设计器资源,则使用设计器资源,否则使用默认资源
|
||||
if (item.designerResources && !isPreview) {
|
||||
pushExternalResources(item.designerResources, {
|
||||
name,
|
||||
version: item.version,
|
||||
});
|
||||
pushExternals(name, item.library);
|
||||
} else if (item.resources) {
|
||||
pushExternalResources(item.resources, {
|
||||
name,
|
||||
version: item.version,
|
||||
});
|
||||
pushExternals(name, item.library);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (newExternalResources?.length) {
|
||||
externalResources.push(...newExternalResources);
|
||||
}
|
||||
setValue(json, 'sandbox.externals', externals);
|
||||
setValue(json, 'sandbox.externalResources', [...new Set(externalResources)]);
|
||||
|
||||
return JSON.stringify(json);
|
||||
}
|
||||
|
||||
function canAccessIFrame(iframe: HTMLIFrameElement) {
|
||||
let html = null;
|
||||
try {
|
||||
// deal with older browsers
|
||||
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
html = doc.body.innerHTML;
|
||||
} catch (err) {
|
||||
// do nothing
|
||||
}
|
||||
return html !== null;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Box } from 'coral-system';
|
||||
import { SettingForm, FormModel, SettingFormProps } from '@music163/tango-setting-form';
|
||||
import { Panel } from '@music163/tango-ui';
|
||||
import { clone } from '@music163/tango-helpers';
|
||||
import { observer, useDesigner, useWorkspace, useWorkspaceData } from '@music163/tango-context';
|
||||
import { useSandboxQuery } from '../context';
|
||||
|
||||
export interface SettingPanelProps extends SettingFormProps {
|
||||
title?: React.ReactNode;
|
||||
}
|
||||
|
||||
const headerProps = {
|
||||
fontSize: '18px',
|
||||
};
|
||||
|
||||
export const SettingPanel = observer(({ title = '设置面板', ...props }: SettingPanelProps) => {
|
||||
const sandbox = useSandboxQuery();
|
||||
const workspace = useWorkspace();
|
||||
const designer = useDesigner();
|
||||
const { modelVariables, actionVariables, expressionVariables, routeOptions } = useWorkspaceData();
|
||||
|
||||
const onAction = useCallback(
|
||||
(action: string, args: unknown[]) => {
|
||||
workspace[action]?.(...args);
|
||||
},
|
||||
[workspace],
|
||||
);
|
||||
|
||||
if (!designer.showRightPanel) {
|
||||
return <Box className="SettingPanel" />;
|
||||
}
|
||||
|
||||
let display = 'flex';
|
||||
if (designer.activeView !== 'design' || designer.isPreview) {
|
||||
display = 'none';
|
||||
}
|
||||
|
||||
const getSettingValue = (attributes: Record<string, string>) => {
|
||||
const ret = {};
|
||||
const keys = Object.keys(attributes);
|
||||
for (const key of keys) {
|
||||
if (/^data-/.test(key)) {
|
||||
continue;
|
||||
}
|
||||
ret[key] = attributes[key];
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
|
||||
const formValue = getSettingValue(workspace.selectSource.firstNode?.props || {});
|
||||
const formModel = new FormModel(formValue, {
|
||||
onChange(name, value, field) {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
let firstName = name;
|
||||
let realValue = value;
|
||||
|
||||
const namePaths = name.split('.');
|
||||
if (namePaths.length > 1) {
|
||||
firstName = namePaths[0];
|
||||
realValue = clone(formModel.getValue(firstName), false);
|
||||
if (!Object.keys(realValue).length) {
|
||||
realValue = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!field) {
|
||||
// 针对 Form.Object 清空的情况
|
||||
workspace.updateSelectedNodeAttributes({
|
||||
[firstName]: realValue,
|
||||
});
|
||||
} else if (!field.error) {
|
||||
// 针对 Form.Item 变化的情况
|
||||
workspace.updateSelectedNodeAttributes({ [firstName]: realValue }, field.detail?.relatedImports);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const prototype = workspace.componentPrototypes.get(workspace.selectSource.first?.name);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={prototype?.title || title}
|
||||
display={display}
|
||||
flexDirection="column"
|
||||
width="320px"
|
||||
borderLeft="solid"
|
||||
borderLeftColor="line2"
|
||||
bg="white"
|
||||
headerProps={headerProps}
|
||||
className="SettingPanel"
|
||||
>
|
||||
{workspace.selectSource.isSelected ? (
|
||||
prototype ? (
|
||||
<SettingForm
|
||||
key={workspace.selectSource.first.id}
|
||||
model={formModel}
|
||||
prototype={prototype}
|
||||
modelVariables={modelVariables}
|
||||
actionVariables={actionVariables}
|
||||
expressionVariables={expressionVariables}
|
||||
modalOptions={workspace.listModals() || []}
|
||||
routeOptions={routeOptions}
|
||||
onAction={onAction}
|
||||
evaluateContext={sandbox.window}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<Box p="m">该节点没有可配置信息</Box>
|
||||
)
|
||||
) : (
|
||||
<Box p="m">请先选择一个节点</Box>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Box, Text, css, HTMLCoralProps } from 'coral-system';
|
||||
import {
|
||||
ClusterOutlined,
|
||||
HistoryOutlined,
|
||||
FunctionOutlined,
|
||||
SettingOutlined,
|
||||
ApiOutlined,
|
||||
AppstoreOutlined,
|
||||
BuildOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Badge } from 'antd';
|
||||
import {
|
||||
DataSourceView,
|
||||
HistoryView,
|
||||
DependencyView,
|
||||
ModelView,
|
||||
SettingView,
|
||||
PagesView,
|
||||
} from '../widgets/sidebar';
|
||||
import { ReactComponentProps } from '@music163/tango-helpers';
|
||||
import { observer, useDesigner } from '@music163/tango-context';
|
||||
import { ResizableBox } from './resizable-box';
|
||||
|
||||
const sidebarStyle = css`
|
||||
position: relative;
|
||||
background-color: var(--tango-colors-custom-sidebarBg);
|
||||
border-right: 1px solid var(--tango-colors-line-normal);
|
||||
box-shadow: rgb(0 0 0 / 5%) 0px 0px 18px;
|
||||
height: 100%;
|
||||
|
||||
.SidebarPanelBarList {
|
||||
width: 50px;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.SidebarPanelBarListItem {
|
||||
&.active {
|
||||
background-color: var(--tango-colors-custom-sidebarItemActiveBg);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--tango-colors-custom-sidebarItemHoverBg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface SidebarPanelProps extends ReactComponentProps {
|
||||
/**
|
||||
* 面板宽度
|
||||
*/
|
||||
panelWidth?: number;
|
||||
/**
|
||||
* 底部附加内容
|
||||
*/
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface SidebarPanelItemProps
|
||||
extends Omit<SidebarPanelBarItemProps, 'isActive'>,
|
||||
HTMLCoralProps<'div'> {
|
||||
/**
|
||||
* 面板唯一标识符
|
||||
*/
|
||||
key?: string;
|
||||
/**
|
||||
* 面板标题
|
||||
* @deprecated
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 文档地址
|
||||
* @deprecated
|
||||
*/
|
||||
doc?: string;
|
||||
/**
|
||||
* 是否为浮动面板
|
||||
*/
|
||||
isFloat?: boolean;
|
||||
/**
|
||||
* 面板宽度
|
||||
*/
|
||||
width?: number;
|
||||
}
|
||||
|
||||
const INTERNAL_SIDEBAR_PANEL_MAP: Record<string, SidebarPanelItemProps> = {
|
||||
components: {
|
||||
label: '组件',
|
||||
title: '组件列表',
|
||||
icon: <AppstoreOutlined />,
|
||||
},
|
||||
outline: {
|
||||
label: '结构',
|
||||
title: '结构',
|
||||
icon: <BuildOutlined />,
|
||||
children: <PagesView />,
|
||||
},
|
||||
dependency: {
|
||||
label: '依赖',
|
||||
title: '项目依赖',
|
||||
icon: <ClusterOutlined />,
|
||||
children: <DependencyView />,
|
||||
},
|
||||
model: {
|
||||
label: '变量',
|
||||
title: '变量管理',
|
||||
icon: <FunctionOutlined />,
|
||||
children: <ModelView />,
|
||||
width: 440,
|
||||
},
|
||||
dataSource: {
|
||||
label: '接口',
|
||||
title: '数据源与接口',
|
||||
icon: <ApiOutlined />,
|
||||
children: <DataSourceView />,
|
||||
width: 600,
|
||||
},
|
||||
history: {
|
||||
label: '历史',
|
||||
title: '历史记录',
|
||||
icon: <HistoryOutlined />,
|
||||
children: <HistoryView />,
|
||||
},
|
||||
setting: {
|
||||
label: '设置',
|
||||
title: '应用设置',
|
||||
icon: <SettingOutlined />,
|
||||
children: <SettingView />,
|
||||
width: 400,
|
||||
},
|
||||
};
|
||||
|
||||
function BaseSidebarPanel({
|
||||
panelWidth: defaultPanelWidth = 280,
|
||||
footer,
|
||||
children,
|
||||
}: SidebarPanelProps) {
|
||||
const items = useMemo(() => {
|
||||
const ret: Record<React.Key, SidebarPanelItemProps> = {};
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (child && React.isValidElement(child)) {
|
||||
ret[child.key] = {
|
||||
...INTERNAL_SIDEBAR_PANEL_MAP[child.key],
|
||||
...child.props,
|
||||
};
|
||||
}
|
||||
});
|
||||
return ret;
|
||||
}, [children]);
|
||||
|
||||
const designer = useDesigner();
|
||||
const panel = items[designer.activeSidebarPanel];
|
||||
const floatPanelStyle: any = panel?.isFloat
|
||||
? {
|
||||
position: 'absolute',
|
||||
left: '50px',
|
||||
top: 0,
|
||||
zIndex: 1000,
|
||||
height: '100%',
|
||||
boxShadow: 'var(--tango-shadows-lowRight)',
|
||||
}
|
||||
: {};
|
||||
const panelWidth = typeof panel?.width === 'number' ? panel?.width : defaultPanelWidth;
|
||||
|
||||
return (
|
||||
<Box display="flex" flexShrink={0} css={sidebarStyle} className="SidebarPanel">
|
||||
<Box className="SidebarPanelBar" display="flex" flexDirection="column" position="relative">
|
||||
<Box as="ul" flex="0" p="0" m="0" textAlign="center" className="SidebarPanelBarList">
|
||||
{Object.keys(items).map((key) => {
|
||||
const item = items[key];
|
||||
const isActive = key === designer.activeSidebarPanel;
|
||||
return (
|
||||
<li
|
||||
className={cx('SidebarPanelBarListItem', { active: isActive })}
|
||||
key={key}
|
||||
title={item.title}
|
||||
onClick={() => {
|
||||
designer.setActiveSidebarPanel(key);
|
||||
}}
|
||||
>
|
||||
<SidebarPanelBarItem
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
showBadge={item.showBadge}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box
|
||||
className="SidebarPanelBarFooter"
|
||||
pb="l"
|
||||
flex="1"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
{footer}
|
||||
</Box>
|
||||
</Box>
|
||||
{panel ? (
|
||||
<ResizableBox key={panel.label} width={panelWidth} style={floatPanelStyle}>
|
||||
<SidebarPanelExpandedContent
|
||||
isFloat={panel.isFloat}
|
||||
closeable={panel.isFloat}
|
||||
onClose={() => {
|
||||
designer.closeSidebarPanel();
|
||||
}}
|
||||
>
|
||||
{panel.children}
|
||||
</SidebarPanelExpandedContent>
|
||||
</ResizableBox>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
BaseSidebarPanel.Item = function ({ key, title, doc, isFloat, width }: SidebarPanelItemProps) {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export const SidebarPanel = observer(BaseSidebarPanel);
|
||||
|
||||
interface SidebarPanelBarItemProps {
|
||||
/**
|
||||
* 是否选中
|
||||
*/
|
||||
isActive?: boolean;
|
||||
/**
|
||||
* 侧边栏图标
|
||||
*/
|
||||
icon?: React.ReactNode;
|
||||
/**
|
||||
* 侧边栏图标说明,推荐使用 2 个字
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* 是否展示徽标
|
||||
*/
|
||||
showBadge?:
|
||||
| false
|
||||
| {
|
||||
/**
|
||||
* 是否显示小圆点
|
||||
*/
|
||||
dot?: boolean;
|
||||
/**
|
||||
* 展示的数字
|
||||
*/
|
||||
count?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function SidebarPanelBarItem({
|
||||
isActive,
|
||||
icon: iconProp,
|
||||
label,
|
||||
showBadge,
|
||||
}: SidebarPanelBarItemProps) {
|
||||
const color = isActive ? 'brand' : 'text.body';
|
||||
let icon = (
|
||||
<Text fontSize="24px" lineHeight={1} color={color}>
|
||||
{iconProp}
|
||||
</Text>
|
||||
);
|
||||
if (showBadge) {
|
||||
icon = (
|
||||
<Badge size="small" {...showBadge}>
|
||||
{icon}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
py="12px"
|
||||
>
|
||||
{icon}
|
||||
<Text fontSize="12px" mt="s" color={color}>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const expandPanelStyle = css`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--tango-colors-white);
|
||||
border-left: 1px solid var(--tango-colors-line-normal);
|
||||
position: relative;
|
||||
|
||||
&.isFloat {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-shadow: var(--tango-shadows-lowRight);
|
||||
}
|
||||
`;
|
||||
|
||||
function SidebarPanelExpandedContent({
|
||||
closeable,
|
||||
onClose,
|
||||
isFloat,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: Omit<SidebarPanelItemProps, 'label' | 'icon' | 'title' | 'doc'> & {
|
||||
closeable?: boolean;
|
||||
onClose: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}) {
|
||||
const classNames = cx(
|
||||
'SidebarPanelExpanded',
|
||||
{
|
||||
isFloat,
|
||||
},
|
||||
className,
|
||||
);
|
||||
return (
|
||||
<Box css={expandPanelStyle} className={classNames} {...rest}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Box, HTMLCoralProps, css } from 'coral-system';
|
||||
import { Breadcrumb } from 'antd';
|
||||
import { observer, useWorkspace, useDesigner } from '@music163/tango-context';
|
||||
|
||||
const itemWrapperStyle = css`
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--tango-colors-brand);
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemWrapper = (props: HTMLCoralProps<'div'>) => {
|
||||
return <Box display="inline-block" css={itemWrapperStyle} {...props} />;
|
||||
};
|
||||
|
||||
export const BottomBar = observer(() => {
|
||||
const workspace = useWorkspace();
|
||||
const designer = useDesigner();
|
||||
|
||||
if (workspace.selectSource.size !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parents = workspace.selectSource?.first?.parents || [];
|
||||
const reversedParents = [...parents].reverse();
|
||||
const length = parents.length;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="BottomBar"
|
||||
display={designer.isPreview ? 'none' : 'block'}
|
||||
flex="0"
|
||||
px="l"
|
||||
bg="background.normal"
|
||||
borderTop="solid"
|
||||
borderTopColor="line.normal"
|
||||
>
|
||||
<Breadcrumb>
|
||||
{reversedParents.map((parent, index) => (
|
||||
<Breadcrumb.Item
|
||||
key={parent.id}
|
||||
onClick={() => {
|
||||
workspace.selectSource.select({
|
||||
...parent,
|
||||
parents: parents.slice(length - index),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ItemWrapper>{parent.name}</ItemWrapper>
|
||||
</Breadcrumb.Item>
|
||||
))}
|
||||
<Breadcrumb.Item key={workspace.selectSource.first.id}>
|
||||
<ItemWrapper>{workspace.selectSource.first.name}</ItemWrapper>
|
||||
</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user