chore: init open source repo

This commit is contained in:
wwsun
2023-08-25 09:39:35 +08:00
commit e8b185d6ff
280 changed files with 82396 additions and 0 deletions
+8
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
lib/
coverage/
.umi/
.umi-production/
+5
View File
@@ -0,0 +1,5 @@
{
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100
}
+3
View File
@@ -0,0 +1,3 @@
# tango
一个基于源码的低代码引擎
+37
View File
@@ -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;
},
};
+1
View File
@@ -0,0 +1 @@
<!--insert head here-->
+23
View File
@@ -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];
+3
View File
@@ -0,0 +1,3 @@
# `docs`
开发调试用的文档
+30
View File
@@ -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"
}
}
+45
View File
@@ -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>
);
}
+383
View File
@@ -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>
);
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import { ChatInput } from '@music163/tango-ui';
export default {
title: 'UI/ChatGPT',
};
export function Basic() {
return <ChatInput />;
}
+14
View File
@@ -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>
);
}
+33
View File
@@ -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" />;
}
+11
View File
@@ -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} />;
}
+19
View File
@@ -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>
);
}
+35
View File
@@ -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} />;
}
+25
View File
@@ -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>
);
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "esnext"
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
PORT=6006
HOST=local.netease.com
+5
View File
@@ -0,0 +1,5 @@
# `playground`
搭建平台演示示例
## 说明
+64
View File
@@ -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-----
+26
View File
@@ -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>;
}
+1
View File
@@ -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;
+248
View File
@@ -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);
`,
});
+21
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
@import '~antd/es/style/themes/default.less';
@import '~antd/dist/antd.less'; // 引入官方提供的 less 样式入口文件
@primary-color: #2f54eb;
@border-radius-base: 2px;
+81
View File
@@ -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>
);
}
+326
View File
@@ -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',
],
},
],
};
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
+6
View File
@@ -0,0 +1,6 @@
/**
* BabelConfig for Jest
*/
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-react', '@babel/preset-typescript'],
};
+195
View File
@@ -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
View File
@@ -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"
}
}
}
+68
View File
@@ -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"
}
}
+22
View File
@@ -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>;
});
```
+42
View File
@@ -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/"
}
}
+131
View File
@@ -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,
};
}
+2
View File
@@ -0,0 +1,2 @@
export { observer } from 'mobx-react-lite';
export * from './context';
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.prod.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"]
}
+11
View File
@@ -0,0 +1,11 @@
# `core`
搭建引擎
## 如何使用
```js
import { createEngine } from '@music/tango-apps-core';
const engine = createEngine();
```
+41
View File
@@ -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/"
}
}
+83
View File
@@ -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;
}
+47
View File
@@ -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;
}
+22
View File
@@ -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;
}
+241
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './generate';
export * from './parse';
export * from './traverse';
+283
View File
@@ -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
+49
View File
@@ -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;
}
}
+8
View File
@@ -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';
+103
View File
@@ -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];
}
}
+163
View File
@@ -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();
}
+226
View File
@@ -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);
}
+4
View File
@@ -0,0 +1,4 @@
export * from './models';
export * from './factory';
export * from './types';
export * from './helpers';
+176
View File
@@ -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();
}
}
}
+91
View File
@@ -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;
}
}
+84
View File
@@ -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;
}
}
+21
View File
@@ -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;
}
}
+193
View File
@@ -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;
}
}
+142
View File
@@ -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;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from './engine';
export * from './workspace';
export * from './designer';
export * from './drop-target';
export * from './module';
export * from './interfaces';
+237
View File
@@ -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[];
}
+733
View File
@@ -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;
}
}
+60
View File
@@ -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;
}
}
+119
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
export * from './types';
+276
View File
@@ -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[];
};
+43
View File
@@ -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');
});
});
+30
View File
@@ -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);
});
});
+283
View File
@@ -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);
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.prod.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"]
}
+5
View File
@@ -0,0 +1,5 @@
# Tango 低代码设计器
## 使用文档
https://tango-docs.st.netease.com/
+57
View File
@@ -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/"
}
}
+30
View File
@@ -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;
};
+51
View File
@@ -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';
+9
View File
@@ -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