mirror of
https://gitee.com/baidu/amis.git
synced 2024-11-29 18:48:45 +08:00
feat: 添加 InputFormula 公式编辑器 (#3227)
* feat: 添加 inputFormula * feat: 添加 inputFormula * 先简单处理一下代码高亮 * sdk 编译时 codemirror 单独打包
This commit is contained in:
parent
389e2ab1c4
commit
8301e67f83
61
docs/zh-CN/components/form/input-formula.md
Normal file
61
docs/zh-CN/components/form/input-formula.md
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
title: InputFormula 公式编辑器
|
||||
description:
|
||||
type: 0
|
||||
group: null
|
||||
menuName: InputFormula
|
||||
icon:
|
||||
order: 21
|
||||
---
|
||||
|
||||
## 基本用法
|
||||
|
||||
用来输入公式。还是 beta 版本,整体待优化。
|
||||
|
||||
```schema: scope="formitem"
|
||||
{
|
||||
"type": "input-formula",
|
||||
"name": "formula",
|
||||
"label": "公式",
|
||||
"variableMode": "tabs",
|
||||
"evalMode": false,
|
||||
"variables": [
|
||||
{
|
||||
"label": "表单字段",
|
||||
"children": [
|
||||
{
|
||||
"label": "ID",
|
||||
"value": "id"
|
||||
},
|
||||
{
|
||||
"label": "ID2",
|
||||
"value": "id2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "流程字段",
|
||||
"children": [
|
||||
{
|
||||
"label": "ID",
|
||||
"value": "id"
|
||||
},
|
||||
{
|
||||
"label": "ID2",
|
||||
"value": "id2"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## 属性表
|
||||
|
||||
| 属性名 | 类型 | 默认值 | 说明 |
|
||||
| ------------ | --------------------------------------------------- | ------ | ------------------------------------------------------------------------------ |
|
||||
| header | string | | 弹出来的弹框标题 |
|
||||
| evalMode | Boolean | true | 表达式模式 或者 模板模式,模板模式则需要将表达式写在 `${` 和 `}` 中间。 |
|
||||
| variables | {label: string; value: string; children?: any[];}[] | [] | 可用变量 |
|
||||
| variableMode | string | `list` | 可配置成 `tabs` 或者 `tree` 默认为列表,支持分组。 |
|
||||
| functions | Object[] | | 可以不设置,默认就是 amis-formula 里面定义的函数,如果扩充了新的函数则需要指定 |
|
@ -370,6 +370,14 @@ export const components = [
|
||||
makeMarkdownRenderer
|
||||
)
|
||||
},
|
||||
{
|
||||
label: 'InputFormula 公式编辑器',
|
||||
path: '/zh-CN/components/form/input-formula',
|
||||
getComponent: () =>
|
||||
import('../../docs/zh-CN/components/form/input-formula.md').then(
|
||||
makeMarkdownRenderer
|
||||
)
|
||||
},
|
||||
{
|
||||
label: 'DiffEditor 对比编辑器',
|
||||
path: '/zh-CN/components/form/diff-editor',
|
||||
|
@ -495,6 +495,7 @@ if (fis.project.currentMedia() === 'publish') {
|
||||
'!mpegts.js/**',
|
||||
'!hls.js/**',
|
||||
'!froala-editor/**',
|
||||
'!codemirror/**',
|
||||
|
||||
'!tinymce/**',
|
||||
'!zrender/**',
|
||||
@ -530,6 +531,7 @@ if (fis.project.currentMedia() === 'publish') {
|
||||
|
||||
'tinymce.js': ['src/components/Tinymce.tsx', 'tinymce/**'],
|
||||
|
||||
'codemirror.js': ['codemirror/**'],
|
||||
'papaparse.js': ['papaparse/**'],
|
||||
|
||||
'exceljs.js': ['exceljs/**'],
|
||||
@ -562,6 +564,7 @@ if (fis.project.currentMedia() === 'publish') {
|
||||
'rest.js': [
|
||||
'*.js',
|
||||
'!monaco-editor/**',
|
||||
'!codemirror/**',
|
||||
'!mpegts.js/**',
|
||||
'!hls.js/**',
|
||||
'!froala-editor/**',
|
||||
@ -770,6 +773,7 @@ if (fis.project.currentMedia() === 'publish') {
|
||||
'/examples/mod.js',
|
||||
'node_modules/**.js',
|
||||
'!monaco-editor/**',
|
||||
'!codemirror/**',
|
||||
'!mpegts.js/**',
|
||||
'!hls.js/**',
|
||||
'!froala-editor/**',
|
||||
@ -808,6 +812,8 @@ if (fis.project.currentMedia() === 'publish') {
|
||||
|
||||
'pkg/tinymce.js': ['src/components/Tinymce.tsx', 'tinymce/**'],
|
||||
|
||||
'pkg/codemirror.js': ['codemirror/**'],
|
||||
|
||||
'pkg/papaparse.js': ['papaparse/**'],
|
||||
|
||||
'pkg/exceljs.js': ['exceljs/**'],
|
||||
|
@ -88,9 +88,11 @@
|
||||
"tinymce": "^5.10.2",
|
||||
"tslib": "^2.3.1",
|
||||
"uncontrollable": "7.2.1",
|
||||
"video-react": "0.14.1"
|
||||
"video-react": "0.14.1",
|
||||
"codemirror": "^5.63.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/codemirror": "^5.60.3",
|
||||
"@fortawesome/fontawesome-free": "^5.15.4",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/async": "^2.0.45",
|
||||
|
122
scss/components/_formula.scss
Normal file
122
scss/components/_formula.scss
Normal file
@ -0,0 +1,122 @@
|
||||
.#{$ns}FormulaEditor {
|
||||
overflow: visible;
|
||||
max-width: 100%;
|
||||
box-sizing: content-box;
|
||||
|
||||
&-header {
|
||||
width: 100%;
|
||||
height: px2rem(40px);
|
||||
line-height: px2rem(40px);
|
||||
padding-left: px2rem(10px);
|
||||
box-sizing: border-box;
|
||||
background: #f3f8fb;
|
||||
}
|
||||
|
||||
&-editor {
|
||||
min-height: px2rem(238px);
|
||||
max-height: px2rem(320px);
|
||||
height: auto;
|
||||
border: var(--Form-input-borderWidth) solid var(--Form-input-borderColor);
|
||||
}
|
||||
|
||||
&.is-error &-editor {
|
||||
border-color: var(--Form-input-onError-borderColor);
|
||||
}
|
||||
|
||||
&.is-focused &-editor {
|
||||
border-color: var(--Form-input-onFocused-borderColor);
|
||||
}
|
||||
|
||||
&-settings {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
max-height: px2rem(350px);
|
||||
margin: 0 -5px;
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
padding: 0 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> h3 {
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-field,
|
||||
.cm-func {
|
||||
border-radius: 2px;
|
||||
color: #fff;
|
||||
margin: 0 1px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.cm-field {
|
||||
background: #007bff;
|
||||
}
|
||||
.cm-func {
|
||||
background: #17a2b8;
|
||||
}
|
||||
}
|
||||
|
||||
.#{$ns}FormulaFuncList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > &-searchBox {
|
||||
display: flex;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: px2rem(8px);
|
||||
}
|
||||
|
||||
&-columns {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
|
||||
> div:first-child {
|
||||
min-width: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
&-funcItem {
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&.is-active {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
&-groupTitle {
|
||||
padding: 5px 0;
|
||||
background: transparent;
|
||||
}
|
||||
&-groupBody {
|
||||
> div {
|
||||
padding: 5px 0;
|
||||
}
|
||||
}
|
||||
&-funcDetail {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.#{$ns}FormulaPicker {
|
||||
&-icon {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
@ -118,5 +118,6 @@
|
||||
@import '../components/markdown';
|
||||
@import '../components/link';
|
||||
@import '../components/mapping';
|
||||
@import '../components/formula';
|
||||
|
||||
@import '../utilities';
|
||||
|
@ -227,6 +227,7 @@ export type SchemaType =
|
||||
| 'input-time-range'
|
||||
| 'input-datetime-range'
|
||||
| 'input-excel'
|
||||
| 'input-formula'
|
||||
| 'diff-editor'
|
||||
|
||||
// editor 系列
|
||||
|
99
src/components/CodeMirror.tsx
Normal file
99
src/components/CodeMirror.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import 'codemirror/lib/codemirror.css';
|
||||
import type CodeMirror from 'codemirror';
|
||||
import {autobind} from '../utils/helper';
|
||||
import {resizeSensor} from '../utils/resize-sensor';
|
||||
|
||||
export interface CodeMirrorEditorProps {
|
||||
className?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: (e: any) => void;
|
||||
onBlur?: (e: any) => void;
|
||||
editorFactory?: (
|
||||
dom: HTMLElement,
|
||||
cm: typeof CodeMirror,
|
||||
props?: any
|
||||
) => CodeMirror.Editor;
|
||||
editorDidMount?: (cm: typeof CodeMirror, editor: CodeMirror.Editor) => void;
|
||||
editorWillUnMount?: (
|
||||
cm: typeof CodeMirror,
|
||||
editor: CodeMirror.Editor
|
||||
) => void;
|
||||
}
|
||||
|
||||
export class CodeMirrorEditor extends React.Component<CodeMirrorEditorProps> {
|
||||
dom = React.createRef<HTMLDivElement>();
|
||||
|
||||
editor?: CodeMirror.Editor;
|
||||
toDispose: Array<() => void> = [];
|
||||
unmounted = false;
|
||||
async componentDidMount() {
|
||||
const cm = (await import('codemirror')).default;
|
||||
// @ts-ignore
|
||||
await import('codemirror/mode/javascript/javascript');
|
||||
// @ts-ignore
|
||||
await import('codemirror/mode/htmlmixed/htmlmixed');
|
||||
await import('codemirror/addon/mode/simple');
|
||||
await import('codemirror/addon/mode/multiplex');
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.editor =
|
||||
this.props.editorFactory?.(this.dom.current!, cm, this.props) ??
|
||||
cm(this.dom.current!, {
|
||||
value: this.props.value || ''
|
||||
});
|
||||
|
||||
this.props.editorDidMount?.(cm, this.editor);
|
||||
this.editor.on('change', this.handleChange);
|
||||
|
||||
this.toDispose.push(
|
||||
resizeSensor(this.dom.current as HTMLElement, () =>
|
||||
this.editor?.refresh()
|
||||
)
|
||||
);
|
||||
// todo 以后优化这个,解决弹窗里面默认光标太小的问题
|
||||
setTimeout(() => this.editor?.refresh(), 350);
|
||||
this.toDispose.push(() => {
|
||||
this.props.editorWillUnMount?.(cm, this.editor!);
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: CodeMirrorEditorProps) {
|
||||
const props = this.props;
|
||||
|
||||
if (props.value !== prevProps.value) {
|
||||
this.editor && this.setValue(props.value);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.editor?.off('change', this.handleChange);
|
||||
this.toDispose.forEach(fn => fn());
|
||||
this.toDispose = [];
|
||||
}
|
||||
|
||||
@autobind
|
||||
handleChange(editor: any) {
|
||||
this.props.onChange?.(editor.getValue());
|
||||
}
|
||||
|
||||
setValue(value?: string) {
|
||||
const doc = this.editor!.getDoc();
|
||||
if (value && value !== doc.getValue()) {
|
||||
const cursor = doc.getCursor();
|
||||
doc.setValue(value);
|
||||
doc.setCursor(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {className} = this.props;
|
||||
return <div className={className} ref={this.dom}></div>;
|
||||
}
|
||||
}
|
||||
|
||||
export default CodeMirrorEditor;
|
@ -26,13 +26,14 @@ const collapseStyles: {
|
||||
export interface CollapseProps {
|
||||
key?: string;
|
||||
id?: string;
|
||||
propKey?: string;
|
||||
mountOnEnter?: boolean;
|
||||
unmountOnExit?: boolean;
|
||||
className?: string;
|
||||
classPrefix: string;
|
||||
classnames: ClassNamesFn;
|
||||
headerPosition?: 'top' | 'bottom';
|
||||
header?: React.ReactElement;
|
||||
header?: React.ReactNode;
|
||||
body: any;
|
||||
bodyClassName?: string;
|
||||
disabled?: boolean;
|
||||
|
@ -10,11 +10,12 @@ import Button from './Button';
|
||||
|
||||
export interface PickerContainerProps extends ThemeProps, LocaleProps {
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
children: (props: {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
isOpened: boolean;
|
||||
}) => JSX.Element;
|
||||
popOverRender: (props: {
|
||||
bodyRender: (props: {
|
||||
onClose: () => void;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
@ -85,8 +86,9 @@ export class PickerContainer extends React.Component<
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
popOverRender: dropdownRender,
|
||||
bodyRender: popOverRender,
|
||||
title,
|
||||
showTitle,
|
||||
translate: __,
|
||||
size
|
||||
} = this.props;
|
||||
@ -103,11 +105,13 @@ export class PickerContainer extends React.Component<
|
||||
show={this.state.isOpened}
|
||||
onHide={this.close}
|
||||
>
|
||||
<Modal.Header onClose={this.close}>
|
||||
{__(title || 'Select.placeholder')}
|
||||
</Modal.Header>
|
||||
{showTitle !== false ? (
|
||||
<Modal.Header onClose={this.close}>
|
||||
{__(title || 'Select.placeholder')}
|
||||
</Modal.Header>
|
||||
) : null}
|
||||
<Modal.Body>
|
||||
{dropdownRender({
|
||||
{popOverRender({
|
||||
onClose: this.close,
|
||||
value: this.state.value,
|
||||
onChange: this.handleChange
|
||||
|
@ -44,7 +44,7 @@ export class TransferPicker extends React.Component<TabsTransferPickerProps> {
|
||||
return (
|
||||
<PickerContainer
|
||||
title={__('Select.placeholder')}
|
||||
popOverRender={({onClose, value, onChange}) => {
|
||||
bodyRender={({onClose, value, onChange}) => {
|
||||
return <TabsTransfer {...rest} value={value} onChange={onChange} />;
|
||||
}}
|
||||
value={value}
|
||||
|
@ -19,18 +19,9 @@ export interface TransferPickerProps extends Omit<TransferProps, 'itemRender'> {
|
||||
}
|
||||
|
||||
export class TransferPicker extends React.Component<TransferPickerProps> {
|
||||
@autobind
|
||||
handleClose() {
|
||||
this.setState({
|
||||
inputValue: '',
|
||||
searchResult: null
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
handleConfirm(value: any) {
|
||||
this.props.onChange?.(value);
|
||||
this.handleClose();
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -49,12 +40,11 @@ export class TransferPicker extends React.Component<TransferPickerProps> {
|
||||
return (
|
||||
<PickerContainer
|
||||
title={__('Select.placeholder')}
|
||||
popOverRender={({onClose, value, onChange}) => {
|
||||
bodyRender={({onClose, value, onChange}) => {
|
||||
return <Transfer {...rest} value={value} onChange={onChange} />;
|
||||
}}
|
||||
value={value}
|
||||
onConfirm={this.handleConfirm}
|
||||
onCancel={this.handleClose}
|
||||
size={size}
|
||||
>
|
||||
{({onClick, isOpened}) => (
|
||||
|
261
src/components/formula/Editor.tsx
Normal file
261
src/components/formula/Editor.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @file 公式编辑器
|
||||
*/
|
||||
import React from 'react';
|
||||
import {uncontrollable} from 'uncontrollable';
|
||||
import {FormulaPlugin, editorFactory} from './plugin';
|
||||
import {doc} from 'amis-formula/dist/doc';
|
||||
import FuncList from './FuncList';
|
||||
import {VariableList} from './VariableList';
|
||||
import {parse} from 'amis-formula';
|
||||
import {autobind} from '../../utils/helper';
|
||||
import CodeMirrorEditor from '../CodeMirror';
|
||||
import {themeable, ThemeProps} from '../../theme';
|
||||
import {localeable, LocaleProps} from '../../locale';
|
||||
|
||||
export interface VariableItem {
|
||||
label: string;
|
||||
value?: string;
|
||||
children?: Array<VariableItem>;
|
||||
selectMode?: 'tree' | 'tabs';
|
||||
}
|
||||
|
||||
export interface FuncGroup {
|
||||
groupName: string;
|
||||
items: Array<FuncItem>;
|
||||
}
|
||||
|
||||
export interface FuncItem {
|
||||
name: string;
|
||||
[propName: string]: any;
|
||||
}
|
||||
|
||||
export interface FormulaEditorProps extends ThemeProps, LocaleProps {
|
||||
onChange?: (value: string) => void;
|
||||
value: string;
|
||||
/**
|
||||
* evalMode 即直接就是表达式,否则
|
||||
* 需要 ${这里面才是表达式}
|
||||
* 默认为 true
|
||||
*/
|
||||
evalMode?: boolean;
|
||||
|
||||
/**
|
||||
* 用于提示的变量集合,默认为空
|
||||
*/
|
||||
variables: Array<VariableItem>;
|
||||
|
||||
variableMode?: 'tabs' | 'tree';
|
||||
|
||||
/**
|
||||
* 函数集合,默认不需要传,即 amis-formula 里面那个函数
|
||||
* 如果有扩充,则需要传。
|
||||
*/
|
||||
functions: Array<FuncGroup>;
|
||||
|
||||
/**
|
||||
* 顶部标题,默认为表达式
|
||||
*/
|
||||
header: string;
|
||||
}
|
||||
|
||||
export interface FunctionsProps {
|
||||
name: string;
|
||||
items: FunctionProps[];
|
||||
}
|
||||
|
||||
export interface FunctionProps {
|
||||
name: string;
|
||||
intro: string;
|
||||
usage: string;
|
||||
example: string;
|
||||
}
|
||||
|
||||
export interface FormulaState {
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export class FormulaEditor extends React.Component<
|
||||
FormulaEditorProps,
|
||||
FormulaState
|
||||
> {
|
||||
state: FormulaState = {
|
||||
focused: false
|
||||
};
|
||||
editorPlugin?: FormulaPlugin;
|
||||
|
||||
static buildDefaultFunctions(
|
||||
doc: Array<{
|
||||
namespace: string;
|
||||
name: string;
|
||||
[propName: string]: any;
|
||||
}>
|
||||
) {
|
||||
const funcs: Array<FuncGroup> = [];
|
||||
|
||||
doc.forEach(item => {
|
||||
const namespace = item.namespace || 'Others';
|
||||
let exists = funcs.find(item => item.groupName === namespace);
|
||||
if (!exists) {
|
||||
exists = {
|
||||
groupName: namespace,
|
||||
items: []
|
||||
};
|
||||
funcs.push(exists);
|
||||
}
|
||||
exists.items.push(item);
|
||||
});
|
||||
|
||||
return funcs;
|
||||
}
|
||||
|
||||
static defaultProps: Pick<
|
||||
FormulaEditorProps,
|
||||
'functions' | 'variables' | 'evalMode'
|
||||
> = {
|
||||
functions: this.buildDefaultFunctions(doc),
|
||||
variables: [],
|
||||
evalMode: true
|
||||
};
|
||||
|
||||
static highlightValue(
|
||||
value: string,
|
||||
variables: Array<VariableItem>,
|
||||
functions: Array<FuncGroup>
|
||||
) {
|
||||
// todo 高亮原始文本
|
||||
return value;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.editorPlugin?.dispose();
|
||||
}
|
||||
|
||||
@autobind
|
||||
handleFocus() {
|
||||
this.setState({
|
||||
focused: true
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
handleBlur() {
|
||||
this.setState({
|
||||
focused: false
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
insertValue(value: any, type: 'variable' | 'func') {
|
||||
this.editorPlugin?.insertContent(value, type);
|
||||
}
|
||||
|
||||
@autobind
|
||||
handleEditorMounted(cm: any, editor: any) {
|
||||
this.editorPlugin = new FormulaPlugin(editor, cm, () => this.props);
|
||||
}
|
||||
|
||||
@autobind
|
||||
validate() {
|
||||
const value = this.props.value;
|
||||
|
||||
try {
|
||||
value
|
||||
? parse(value, {
|
||||
evalMode: this.props.evalMode
|
||||
})
|
||||
: null;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@autobind
|
||||
handleFunctionSelect(item: FuncItem) {
|
||||
this.editorPlugin?.insertContent(`${item.name}`, 'func');
|
||||
}
|
||||
|
||||
@autobind
|
||||
handleVariableSelect(item: VariableItem) {
|
||||
this.editorPlugin?.insertContent(
|
||||
{
|
||||
key: item.value,
|
||||
name: item.label
|
||||
},
|
||||
'variable'
|
||||
);
|
||||
}
|
||||
|
||||
@autobind
|
||||
handleOnChange(value: any) {
|
||||
const onChange = this.props.onChange;
|
||||
onChange?.(value);
|
||||
}
|
||||
|
||||
@autobind
|
||||
editorFactory(dom: HTMLElement, cm: any) {
|
||||
return editorFactory(dom, cm, this.props);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
variables,
|
||||
header,
|
||||
value,
|
||||
functions,
|
||||
variableMode,
|
||||
classnames: cx
|
||||
} = this.props;
|
||||
const {focused} = this.state;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(`FormulaEditor`, {
|
||||
'is-focused': focused
|
||||
})}
|
||||
>
|
||||
<div className={cx(`FormulaEditor-header`)}>{header ?? '表达式'}</div>
|
||||
|
||||
<CodeMirrorEditor
|
||||
className={cx('FormulaEditor-editor')}
|
||||
value={value}
|
||||
onChange={this.handleOnChange}
|
||||
editorFactory={this.editorFactory}
|
||||
editorDidMount={this.handleEditorMounted}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
></CodeMirrorEditor>
|
||||
|
||||
<div className={cx('FormulaEditor-settings')}>
|
||||
{Array.isArray(functions) && functions.length ? (
|
||||
<div>
|
||||
<h3>变量</h3>
|
||||
<VariableList
|
||||
className={cx('VariableList')}
|
||||
selectMode={variableMode}
|
||||
data={variables}
|
||||
onSelect={this.handleVariableSelect}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{Array.isArray(variables) && variables.length ? (
|
||||
<div>
|
||||
<h3>函数</h3>
|
||||
<FuncList data={functions} onSelect={this.handleFunctionSelect} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default uncontrollable(
|
||||
themeable(localeable(FormulaEditor)),
|
||||
{
|
||||
value: 'onChange'
|
||||
},
|
||||
['validate']
|
||||
);
|
82
src/components/formula/FuncList.tsx
Normal file
82
src/components/formula/FuncList.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import {themeable, ThemeProps} from '../../theme';
|
||||
import Collapse from '../Collapse';
|
||||
import CollapseGroup from '../CollapseGroup';
|
||||
import SearchBox from '../SearchBox';
|
||||
import type {FuncGroup, FuncItem} from './Editor';
|
||||
|
||||
export interface FuncListProps extends ThemeProps {
|
||||
data: Array<FuncGroup>;
|
||||
onSelect?: (item: FuncItem) => void;
|
||||
}
|
||||
|
||||
export function FuncList(props: FuncListProps) {
|
||||
const cx = props.classnames;
|
||||
const [filteredFuncs, setFiteredFuncs] = React.useState(props.data);
|
||||
const [activeFunc, setActiveFunc] = React.useState<any>(null);
|
||||
function onSearch(term: string) {
|
||||
const filtered = props.data
|
||||
.map(item => {
|
||||
return {
|
||||
...item,
|
||||
items: term
|
||||
? item.items.filter(item => ~item.name.indexOf(term.toUpperCase()))
|
||||
: item.items
|
||||
};
|
||||
})
|
||||
.filter(item => item.items.length);
|
||||
setFiteredFuncs(filtered);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('FormulaFuncList')}>
|
||||
<SearchBox
|
||||
className="FormulaFuncList-searchBox"
|
||||
mini={false}
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
<div className={cx('FormulaFuncList-columns')}>
|
||||
<CollapseGroup
|
||||
className="FormulaFuncList-group"
|
||||
defaultActiveKey={filteredFuncs[0]?.groupName}
|
||||
accordion
|
||||
>
|
||||
{filteredFuncs.map(item => (
|
||||
<Collapse
|
||||
headingClassName="FormulaFuncList-groupTitle"
|
||||
bodyClassName="FormulaFuncList-groupBody"
|
||||
propKey={item.groupName}
|
||||
header={item.groupName}
|
||||
key={item.groupName}
|
||||
>
|
||||
{item.items.map(item => (
|
||||
<div
|
||||
className={cx(
|
||||
`FormulaFuncList-funcItem ${
|
||||
item.name === activeFunc?.name ? 'is-active' : ''
|
||||
}`
|
||||
)}
|
||||
onMouseEnter={() => setActiveFunc(item)}
|
||||
onClick={() => props.onSelect?.(item)}
|
||||
key={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
))}
|
||||
</Collapse>
|
||||
))}
|
||||
</CollapseGroup>
|
||||
<div className={cx('FormulaFuncList-column')}>
|
||||
{activeFunc ? (
|
||||
<div className={cx('FormulaFuncList-funcDetail')}>
|
||||
<p>{activeFunc.example}</p>
|
||||
<div>{activeFunc.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default themeable(FuncList);
|
86
src/components/formula/Picker.tsx
Normal file
86
src/components/formula/Picker.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import {uncontrollable} from 'uncontrollable';
|
||||
import React from 'react';
|
||||
import {FormulaEditor, FormulaEditorProps} from './Editor';
|
||||
import {autobind} from '../../utils/helper';
|
||||
import PickerContainer from '../PickerContainer';
|
||||
import Editor from './Editor';
|
||||
import ResultBox from '../ResultBox';
|
||||
import {Icon} from '../icons';
|
||||
import {themeable} from '../../theme';
|
||||
import {localeable} from '../../locale';
|
||||
|
||||
export interface FormulaPickerProps extends FormulaEditorProps {
|
||||
// 新的属性?
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
|
||||
/**
|
||||
* 边框模式,全边框,还是半边框,或者没边框。
|
||||
*/
|
||||
borderMode?: 'full' | 'half' | 'none';
|
||||
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export class FormulaPicker extends React.Component<FormulaPickerProps> {
|
||||
@autobind
|
||||
handleConfirm(value: any) {
|
||||
this.props.onChange?.(value);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
classnames: cx,
|
||||
value,
|
||||
translate: __,
|
||||
disabled,
|
||||
className,
|
||||
onChange,
|
||||
size,
|
||||
borderMode,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<PickerContainer
|
||||
showTitle={false}
|
||||
bodyRender={({onClose, value, onChange}) => {
|
||||
return <Editor {...rest} value={value} onChange={onChange} />;
|
||||
}}
|
||||
value={value}
|
||||
onConfirm={this.handleConfirm}
|
||||
size={'md'}
|
||||
>
|
||||
{({onClick, isOpened}) => (
|
||||
<ResultBox
|
||||
className={cx(
|
||||
'FormulaPicker',
|
||||
className,
|
||||
isOpened ? 'is-active' : ''
|
||||
)}
|
||||
allowInput={false}
|
||||
result={FormulaEditor.highlightValue(
|
||||
value,
|
||||
rest.variables,
|
||||
rest.functions
|
||||
)}
|
||||
onResultClick={onClick}
|
||||
disabled={disabled}
|
||||
borderMode={borderMode}
|
||||
>
|
||||
<span className={cx('FormulaPicker-icon')}>
|
||||
<Icon icon="pencil" className="icon" />
|
||||
</span>
|
||||
</ResultBox>
|
||||
)}
|
||||
</PickerContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default themeable(
|
||||
localeable(
|
||||
uncontrollable(FormulaPicker, {
|
||||
value: 'onChange'
|
||||
})
|
||||
)
|
||||
);
|
49
src/components/formula/VariableList.tsx
Normal file
49
src/components/formula/VariableList.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import GroupedSelection from '../GroupedSelection';
|
||||
import Tabs, {Tab} from '../Tabs';
|
||||
import TreeSelection from '../TreeSelection';
|
||||
import type {VariableItem} from './Editor';
|
||||
|
||||
export interface VariableListProps {
|
||||
className?: string;
|
||||
data: Array<VariableItem>;
|
||||
selectMode?: 'list' | 'tree' | 'tabs';
|
||||
onSelect?: (item: VariableItem) => void;
|
||||
}
|
||||
|
||||
export function VariableList({
|
||||
data: list,
|
||||
className,
|
||||
selectMode,
|
||||
onSelect
|
||||
}: VariableListProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{selectMode === 'tabs' ? (
|
||||
<Tabs tabsMode="radio">
|
||||
{list.map((item, index) => (
|
||||
<Tab eventKey={index} key={index} title={item.label}>
|
||||
<VariableList
|
||||
selectMode={item.selectMode}
|
||||
data={item.children!}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
) : selectMode === 'tree' ? (
|
||||
<TreeSelection
|
||||
multiple={false}
|
||||
options={list}
|
||||
onChange={(item: any) => onSelect?.(item)}
|
||||
/>
|
||||
) : (
|
||||
<GroupedSelection
|
||||
multiple={false}
|
||||
options={list}
|
||||
onChange={(item: any) => onSelect?.(item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
177
src/components/formula/plugin.ts
Normal file
177
src/components/formula/plugin.ts
Normal file
@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @file 扩展 codemirror
|
||||
*/
|
||||
|
||||
import type CodeMirror from 'codemirror';
|
||||
import {eachTree} from '../../utils/helper';
|
||||
import type {FormulaEditorProps, VariableItem} from './Editor';
|
||||
|
||||
export function editorFactory(
|
||||
dom: HTMLElement,
|
||||
cm: typeof CodeMirror,
|
||||
props: any
|
||||
) {
|
||||
registerLaunguageMode(cm);
|
||||
|
||||
console.log('here', props.evalMode);
|
||||
|
||||
return cm(dom, {
|
||||
value: props.value || '',
|
||||
autofocus: true,
|
||||
mode: props.evalMode ? 'text/formula' : 'text/formula-template'
|
||||
});
|
||||
}
|
||||
|
||||
export class FormulaPlugin {
|
||||
constructor(
|
||||
readonly editor: CodeMirror.Editor,
|
||||
readonly cm: typeof CodeMirror,
|
||||
readonly getProps: () => FormulaEditorProps
|
||||
) {
|
||||
// editor.on('change', this.autoMarkText);
|
||||
this.autoMarkText();
|
||||
}
|
||||
|
||||
autoMarkText() {
|
||||
const {functions, variables, value} = this.getProps();
|
||||
if (value) {
|
||||
// todo functions 也需要自动替换
|
||||
this.autoMark(variables);
|
||||
}
|
||||
}
|
||||
|
||||
insertContent(value: any, type: 'variable' | 'func') {
|
||||
const from = this.editor.getCursor();
|
||||
if (type === 'variable') {
|
||||
this.editor.replaceSelection(value.key);
|
||||
var to = this.editor.getCursor();
|
||||
|
||||
this.markText(from, to, value.name, 'cm-field');
|
||||
} else if (type === 'func') {
|
||||
// todo 支持 snippet,目前是不支持的
|
||||
|
||||
this.editor.replaceSelection(`${value}()`);
|
||||
var to = this.editor.getCursor();
|
||||
this.markText(
|
||||
from,
|
||||
{
|
||||
line: to.line,
|
||||
ch: to.ch - 2
|
||||
},
|
||||
value,
|
||||
'cm-func'
|
||||
);
|
||||
|
||||
this.editor.setCursor({
|
||||
line: to.line,
|
||||
ch: to.ch - 1
|
||||
});
|
||||
} else if (typeof value === 'string') {
|
||||
this.editor.replaceSelection(value);
|
||||
}
|
||||
|
||||
this.editor.focus();
|
||||
}
|
||||
|
||||
markText(
|
||||
from: CodeMirror.Position,
|
||||
to: CodeMirror.Position,
|
||||
label: string,
|
||||
className = 'cm-func'
|
||||
) {
|
||||
const text = document.createElement('span');
|
||||
text.className = className;
|
||||
text.innerText = label;
|
||||
this.editor.markText(from, to, {
|
||||
atomic: true,
|
||||
replacedWith: text
|
||||
});
|
||||
}
|
||||
|
||||
autoMark(variables: Array<VariableItem>) {
|
||||
if (!Array.isArray(variables) || !variables.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const varMap: {
|
||||
[propname: string]: string;
|
||||
} = {};
|
||||
|
||||
eachTree(
|
||||
variables,
|
||||
item => item.value && (varMap[item.value] = item.label)
|
||||
);
|
||||
const vars = Object.keys(varMap).sort((a, b) => b.length - a.length);
|
||||
|
||||
const editor = this.editor;
|
||||
const lines = editor.lineCount();
|
||||
for (let line = 0; line < lines; line++) {
|
||||
const content = editor.getLine(line);
|
||||
|
||||
// 标记方法调用
|
||||
content.replace(/([A-Z]+)\s*\(/g, (_, func, pos) => {
|
||||
this.markText(
|
||||
{
|
||||
line: line,
|
||||
ch: pos
|
||||
},
|
||||
{
|
||||
line: line,
|
||||
ch: pos + func.length
|
||||
},
|
||||
func,
|
||||
'cm-func'
|
||||
);
|
||||
return _;
|
||||
});
|
||||
|
||||
// 标记变量
|
||||
vars.forEach(v => {
|
||||
let from = 0;
|
||||
let idx = -1;
|
||||
while (~(idx = content.indexOf(v, from))) {
|
||||
this.markText(
|
||||
{
|
||||
line: line,
|
||||
ch: idx
|
||||
},
|
||||
{
|
||||
line: line,
|
||||
ch: idx + v.length
|
||||
},
|
||||
varMap[v],
|
||||
'cm-field'
|
||||
);
|
||||
from = idx + v.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {}
|
||||
|
||||
validate() {}
|
||||
}
|
||||
|
||||
let modeRegisted = false;
|
||||
function registerLaunguageMode(cm: typeof CodeMirror) {
|
||||
if (modeRegisted) {
|
||||
return;
|
||||
}
|
||||
modeRegisted = true;
|
||||
|
||||
// 对应 evalMode
|
||||
cm.defineMode('formula', (config: any, parserConfig: any) => {
|
||||
var formula = cm.getMode(config, 'javascript');
|
||||
if (!parserConfig || !parserConfig.base) return formula;
|
||||
|
||||
return cm.multiplexingMode(cm.getMode(config, parserConfig.base), {
|
||||
open: '${',
|
||||
close: '}',
|
||||
mode: formula
|
||||
});
|
||||
});
|
||||
|
||||
cm.defineMIME('text/formula', {name: 'formula'});
|
||||
cm.defineMIME('text/formula-template', {name: 'formula', base: 'htmlmixed'});
|
||||
}
|
@ -89,6 +89,7 @@ import './renderers/Form/Select';
|
||||
import './renderers/Form/Static';
|
||||
import './renderers/Form/InputDate';
|
||||
import './renderers/Form/InputDateRange';
|
||||
import './renderers/Form/InputFormula';
|
||||
import './renderers/Form/InputRepeat';
|
||||
import './renderers/Form/InputTree';
|
||||
import './renderers/Form/TreeSelect';
|
||||
|
75
src/renderers/Form/InputFormula.tsx
Normal file
75
src/renderers/Form/InputFormula.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import FormItem, {FormBaseControl, FormControlProps} from './Item';
|
||||
import FormulaPicker from '../../components/formula/Picker';
|
||||
import type {FuncGroup, VariableItem} from '../../components/formula/Editor';
|
||||
|
||||
/**
|
||||
* InputFormula 公式编辑器
|
||||
* 文档:https://baidu.gitee.io/amis/docs/components/form/input-formula
|
||||
*/
|
||||
export interface InputFormulaControlSchema extends FormBaseControl {
|
||||
type: 'input-formula';
|
||||
|
||||
/**
|
||||
* evalMode 即直接就是表达式,否则
|
||||
* 需要 ${这里面才是表达式}
|
||||
* 默认为 true
|
||||
*/
|
||||
evalMode?: boolean;
|
||||
|
||||
/**
|
||||
* 用于提示的变量集合,默认为空
|
||||
*/
|
||||
variables: Array<VariableItem>;
|
||||
|
||||
variableMode?: 'tabs' | 'tree';
|
||||
|
||||
/**
|
||||
* 函数集合,默认不需要传,即 amis-formula 里面那个函数
|
||||
* 如果有扩充,则需要传。
|
||||
*/
|
||||
functions: Array<FuncGroup>;
|
||||
|
||||
/**
|
||||
* 顶部标题,默认为表达式
|
||||
*/
|
||||
header: string;
|
||||
}
|
||||
|
||||
export interface InputFormulaProps
|
||||
extends FormControlProps,
|
||||
Omit<
|
||||
InputFormulaControlSchema,
|
||||
'options' | 'inputClassName' | 'className' | 'descriptionClassName'
|
||||
> {}
|
||||
|
||||
@FormItem({
|
||||
type: 'input-formula'
|
||||
})
|
||||
export class InputFormulaRenderer extends React.Component<InputFormulaProps> {
|
||||
render() {
|
||||
const {
|
||||
selectedOptions,
|
||||
disabled,
|
||||
onChange,
|
||||
evalMode,
|
||||
variables,
|
||||
variableMode,
|
||||
functions,
|
||||
header
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<FormulaPicker
|
||||
value={selectedOptions}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
evalMode={evalMode}
|
||||
variables={variables}
|
||||
variableMode={variableMode}
|
||||
functions={functions}
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
2
types/uncontrollable/index.d.ts
vendored
2
types/uncontrollable/index.d.ts
vendored
@ -4,5 +4,5 @@ declare module 'uncontrollable' {
|
||||
P extends {
|
||||
[propName: string]: any;
|
||||
}
|
||||
>(arg: T, config: P): T;
|
||||
>(arg: T, config: P, mapping?: any): T;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user