feat: 添加 InputFormula 公式编辑器 (#3227)

* feat: 添加 inputFormula

* feat: 添加 inputFormula

* 先简单处理一下代码高亮

* sdk 编译时 codemirror 单独打包
This commit is contained in:
liaoxuezhi 2021-12-20 18:54:13 +08:00 committed by GitHub
parent 389e2ab1c4
commit 8301e67f83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1047 additions and 21 deletions

View 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 里面定义的函数,如果扩充了新的函数则需要指定 |

View File

@ -370,6 +370,14 @@ export const components = [
makeMarkdownRenderer makeMarkdownRenderer
) )
}, },
{
label: 'InputFormula 公式编辑器',
path: '/zh-CN/components/form/input-formula',
getComponent: () =>
import('../../docs/zh-CN/components/form/input-formula.md').then(
makeMarkdownRenderer
)
},
{ {
label: 'DiffEditor 对比编辑器', label: 'DiffEditor 对比编辑器',
path: '/zh-CN/components/form/diff-editor', path: '/zh-CN/components/form/diff-editor',

View File

@ -495,6 +495,7 @@ if (fis.project.currentMedia() === 'publish') {
'!mpegts.js/**', '!mpegts.js/**',
'!hls.js/**', '!hls.js/**',
'!froala-editor/**', '!froala-editor/**',
'!codemirror/**',
'!tinymce/**', '!tinymce/**',
'!zrender/**', '!zrender/**',
@ -530,6 +531,7 @@ if (fis.project.currentMedia() === 'publish') {
'tinymce.js': ['src/components/Tinymce.tsx', 'tinymce/**'], 'tinymce.js': ['src/components/Tinymce.tsx', 'tinymce/**'],
'codemirror.js': ['codemirror/**'],
'papaparse.js': ['papaparse/**'], 'papaparse.js': ['papaparse/**'],
'exceljs.js': ['exceljs/**'], 'exceljs.js': ['exceljs/**'],
@ -562,6 +564,7 @@ if (fis.project.currentMedia() === 'publish') {
'rest.js': [ 'rest.js': [
'*.js', '*.js',
'!monaco-editor/**', '!monaco-editor/**',
'!codemirror/**',
'!mpegts.js/**', '!mpegts.js/**',
'!hls.js/**', '!hls.js/**',
'!froala-editor/**', '!froala-editor/**',
@ -770,6 +773,7 @@ if (fis.project.currentMedia() === 'publish') {
'/examples/mod.js', '/examples/mod.js',
'node_modules/**.js', 'node_modules/**.js',
'!monaco-editor/**', '!monaco-editor/**',
'!codemirror/**',
'!mpegts.js/**', '!mpegts.js/**',
'!hls.js/**', '!hls.js/**',
'!froala-editor/**', '!froala-editor/**',
@ -808,6 +812,8 @@ if (fis.project.currentMedia() === 'publish') {
'pkg/tinymce.js': ['src/components/Tinymce.tsx', 'tinymce/**'], 'pkg/tinymce.js': ['src/components/Tinymce.tsx', 'tinymce/**'],
'pkg/codemirror.js': ['codemirror/**'],
'pkg/papaparse.js': ['papaparse/**'], 'pkg/papaparse.js': ['papaparse/**'],
'pkg/exceljs.js': ['exceljs/**'], 'pkg/exceljs.js': ['exceljs/**'],

View File

@ -88,9 +88,11 @@
"tinymce": "^5.10.2", "tinymce": "^5.10.2",
"tslib": "^2.3.1", "tslib": "^2.3.1",
"uncontrollable": "7.2.1", "uncontrollable": "7.2.1",
"video-react": "0.14.1" "video-react": "0.14.1",
"codemirror": "^5.63.0"
}, },
"devDependencies": { "devDependencies": {
"@types/codemirror": "^5.60.3",
"@fortawesome/fontawesome-free": "^5.15.4", "@fortawesome/fontawesome-free": "^5.15.4",
"@testing-library/react": "^12.0.0", "@testing-library/react": "^12.0.0",
"@types/async": "^2.0.45", "@types/async": "^2.0.45",

View 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;
}
}

View File

@ -118,5 +118,6 @@
@import '../components/markdown'; @import '../components/markdown';
@import '../components/link'; @import '../components/link';
@import '../components/mapping'; @import '../components/mapping';
@import '../components/formula';
@import '../utilities'; @import '../utilities';

View File

@ -227,6 +227,7 @@ export type SchemaType =
| 'input-time-range' | 'input-time-range'
| 'input-datetime-range' | 'input-datetime-range'
| 'input-excel' | 'input-excel'
| 'input-formula'
| 'diff-editor' | 'diff-editor'
// editor 系列 // editor 系列

View 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;

View File

@ -26,13 +26,14 @@ const collapseStyles: {
export interface CollapseProps { export interface CollapseProps {
key?: string; key?: string;
id?: string; id?: string;
propKey?: string;
mountOnEnter?: boolean; mountOnEnter?: boolean;
unmountOnExit?: boolean; unmountOnExit?: boolean;
className?: string; className?: string;
classPrefix: string; classPrefix: string;
classnames: ClassNamesFn; classnames: ClassNamesFn;
headerPosition?: 'top' | 'bottom'; headerPosition?: 'top' | 'bottom';
header?: React.ReactElement; header?: React.ReactNode;
body: any; body: any;
bodyClassName?: string; bodyClassName?: string;
disabled?: boolean; disabled?: boolean;

View File

@ -10,11 +10,12 @@ import Button from './Button';
export interface PickerContainerProps extends ThemeProps, LocaleProps { export interface PickerContainerProps extends ThemeProps, LocaleProps {
title?: string; title?: string;
showTitle?: boolean;
children: (props: { children: (props: {
onClick: (e: React.MouseEvent) => void; onClick: (e: React.MouseEvent) => void;
isOpened: boolean; isOpened: boolean;
}) => JSX.Element; }) => JSX.Element;
popOverRender: (props: { bodyRender: (props: {
onClose: () => void; onClose: () => void;
value: any; value: any;
onChange: (value: any) => void; onChange: (value: any) => void;
@ -85,8 +86,9 @@ export class PickerContainer extends React.Component<
render() { render() {
const { const {
children, children,
popOverRender: dropdownRender, bodyRender: popOverRender,
title, title,
showTitle,
translate: __, translate: __,
size size
} = this.props; } = this.props;
@ -103,11 +105,13 @@ export class PickerContainer extends React.Component<
show={this.state.isOpened} show={this.state.isOpened}
onHide={this.close} onHide={this.close}
> >
<Modal.Header onClose={this.close}> {showTitle !== false ? (
{__(title || 'Select.placeholder')} <Modal.Header onClose={this.close}>
</Modal.Header> {__(title || 'Select.placeholder')}
</Modal.Header>
) : null}
<Modal.Body> <Modal.Body>
{dropdownRender({ {popOverRender({
onClose: this.close, onClose: this.close,
value: this.state.value, value: this.state.value,
onChange: this.handleChange onChange: this.handleChange

View File

@ -44,7 +44,7 @@ export class TransferPicker extends React.Component<TabsTransferPickerProps> {
return ( return (
<PickerContainer <PickerContainer
title={__('Select.placeholder')} title={__('Select.placeholder')}
popOverRender={({onClose, value, onChange}) => { bodyRender={({onClose, value, onChange}) => {
return <TabsTransfer {...rest} value={value} onChange={onChange} />; return <TabsTransfer {...rest} value={value} onChange={onChange} />;
}} }}
value={value} value={value}

View File

@ -19,18 +19,9 @@ export interface TransferPickerProps extends Omit<TransferProps, 'itemRender'> {
} }
export class TransferPicker extends React.Component<TransferPickerProps> { export class TransferPicker extends React.Component<TransferPickerProps> {
@autobind
handleClose() {
this.setState({
inputValue: '',
searchResult: null
});
}
@autobind @autobind
handleConfirm(value: any) { handleConfirm(value: any) {
this.props.onChange?.(value); this.props.onChange?.(value);
this.handleClose();
} }
render() { render() {
@ -49,12 +40,11 @@ export class TransferPicker extends React.Component<TransferPickerProps> {
return ( return (
<PickerContainer <PickerContainer
title={__('Select.placeholder')} title={__('Select.placeholder')}
popOverRender={({onClose, value, onChange}) => { bodyRender={({onClose, value, onChange}) => {
return <Transfer {...rest} value={value} onChange={onChange} />; return <Transfer {...rest} value={value} onChange={onChange} />;
}} }}
value={value} value={value}
onConfirm={this.handleConfirm} onConfirm={this.handleConfirm}
onCancel={this.handleClose}
size={size} size={size}
> >
{({onClick, isOpened}) => ( {({onClick, isOpened}) => (

View 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']
);

View 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);

View 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'
})
)
);

View 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>
);
}

View 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'});
}

View File

@ -89,6 +89,7 @@ import './renderers/Form/Select';
import './renderers/Form/Static'; import './renderers/Form/Static';
import './renderers/Form/InputDate'; import './renderers/Form/InputDate';
import './renderers/Form/InputDateRange'; import './renderers/Form/InputDateRange';
import './renderers/Form/InputFormula';
import './renderers/Form/InputRepeat'; import './renderers/Form/InputRepeat';
import './renderers/Form/InputTree'; import './renderers/Form/InputTree';
import './renderers/Form/TreeSelect'; import './renderers/Form/TreeSelect';

View 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}
/>
);
}
}

View File

@ -4,5 +4,5 @@ declare module 'uncontrollable' {
P extends { P extends {
[propName: string]: any; [propName: string]: any;
} }
>(arg: T, config: P): T; >(arg: T, config: P, mapping?: any): T;
} }