From 8301e67f835eed32bd49e19d2eacd44dc82d6c4b Mon Sep 17 00:00:00 2001 From: liaoxuezhi Date: Mon, 20 Dec 2021 18:54:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20InputFormula=20?= =?UTF-8?q?=E5=85=AC=E5=BC=8F=E7=BC=96=E8=BE=91=E5=99=A8=20(#3227)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 添加 inputFormula * feat: 添加 inputFormula * 先简单处理一下代码高亮 * sdk 编译时 codemirror 单独打包 --- docs/zh-CN/components/form/input-formula.md | 61 +++++ examples/components/Components.tsx | 8 + fis-conf.js | 6 + package.json | 4 +- scss/components/_formula.scss | 122 +++++++++ scss/themes/_common.scss | 1 + src/Schema.ts | 1 + src/components/CodeMirror.tsx | 99 ++++++++ src/components/Collapse.tsx | 3 +- src/components/PickerContainer.tsx | 16 +- src/components/TabsTransferPicker.tsx | 2 +- src/components/TransferPicker.tsx | 12 +- src/components/formula/Editor.tsx | 261 ++++++++++++++++++++ src/components/formula/FuncList.tsx | 82 ++++++ src/components/formula/Picker.tsx | 86 +++++++ src/components/formula/VariableList.tsx | 49 ++++ src/components/formula/plugin.ts | 177 +++++++++++++ src/index.tsx | 1 + src/renderers/Form/InputFormula.tsx | 75 ++++++ types/uncontrollable/index.d.ts | 2 +- 20 files changed, 1047 insertions(+), 21 deletions(-) create mode 100644 docs/zh-CN/components/form/input-formula.md create mode 100644 scss/components/_formula.scss create mode 100644 src/components/CodeMirror.tsx create mode 100644 src/components/formula/Editor.tsx create mode 100644 src/components/formula/FuncList.tsx create mode 100644 src/components/formula/Picker.tsx create mode 100644 src/components/formula/VariableList.tsx create mode 100644 src/components/formula/plugin.ts create mode 100644 src/renderers/Form/InputFormula.tsx diff --git a/docs/zh-CN/components/form/input-formula.md b/docs/zh-CN/components/form/input-formula.md new file mode 100644 index 000000000..9bb324594 --- /dev/null +++ b/docs/zh-CN/components/form/input-formula.md @@ -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 里面定义的函数,如果扩充了新的函数则需要指定 | diff --git a/examples/components/Components.tsx b/examples/components/Components.tsx index e4cae92df..511dae4cc 100644 --- a/examples/components/Components.tsx +++ b/examples/components/Components.tsx @@ -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', diff --git a/fis-conf.js b/fis-conf.js index 795a2d11a..48c471132 100644 --- a/fis-conf.js +++ b/fis-conf.js @@ -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/**'], diff --git a/package.json b/package.json index 754e04108..6f9c1f078 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scss/components/_formula.scss b/scss/components/_formula.scss new file mode 100644 index 000000000..9e34b87f7 --- /dev/null +++ b/scss/components/_formula.scss @@ -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; + } +} diff --git a/scss/themes/_common.scss b/scss/themes/_common.scss index 7a5e1a8ba..777789fa4 100644 --- a/scss/themes/_common.scss +++ b/scss/themes/_common.scss @@ -118,5 +118,6 @@ @import '../components/markdown'; @import '../components/link'; @import '../components/mapping'; +@import '../components/formula'; @import '../utilities'; diff --git a/src/Schema.ts b/src/Schema.ts index 5bf9c91c3..e1bff0983 100644 --- a/src/Schema.ts +++ b/src/Schema.ts @@ -227,6 +227,7 @@ export type SchemaType = | 'input-time-range' | 'input-datetime-range' | 'input-excel' + | 'input-formula' | 'diff-editor' // editor 系列 diff --git a/src/components/CodeMirror.tsx b/src/components/CodeMirror.tsx new file mode 100644 index 000000000..79925cfa6 --- /dev/null +++ b/src/components/CodeMirror.tsx @@ -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 { + dom = React.createRef(); + + 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
; + } +} + +export default CodeMirrorEditor; diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx index 84a243f19..5c2505353 100644 --- a/src/components/Collapse.tsx +++ b/src/components/Collapse.tsx @@ -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; diff --git a/src/components/PickerContainer.tsx b/src/components/PickerContainer.tsx index dd6731e0c..a8d0a35bc 100644 --- a/src/components/PickerContainer.tsx +++ b/src/components/PickerContainer.tsx @@ -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} > - - {__(title || 'Select.placeholder')} - + {showTitle !== false ? ( + + {__(title || 'Select.placeholder')} + + ) : null} - {dropdownRender({ + {popOverRender({ onClose: this.close, value: this.state.value, onChange: this.handleChange diff --git a/src/components/TabsTransferPicker.tsx b/src/components/TabsTransferPicker.tsx index 294096b29..be93f6779 100644 --- a/src/components/TabsTransferPicker.tsx +++ b/src/components/TabsTransferPicker.tsx @@ -44,7 +44,7 @@ export class TransferPicker extends React.Component { return ( { + bodyRender={({onClose, value, onChange}) => { return ; }} value={value} diff --git a/src/components/TransferPicker.tsx b/src/components/TransferPicker.tsx index 3b9dd2277..c371496e0 100644 --- a/src/components/TransferPicker.tsx +++ b/src/components/TransferPicker.tsx @@ -19,18 +19,9 @@ export interface TransferPickerProps extends Omit { } export class TransferPicker extends React.Component { - @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 { return ( { + bodyRender={({onClose, value, onChange}) => { return ; }} value={value} onConfirm={this.handleConfirm} - onCancel={this.handleClose} size={size} > {({onClick, isOpened}) => ( diff --git a/src/components/formula/Editor.tsx b/src/components/formula/Editor.tsx new file mode 100644 index 000000000..6d9f543fe --- /dev/null +++ b/src/components/formula/Editor.tsx @@ -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; + selectMode?: 'tree' | 'tabs'; +} + +export interface FuncGroup { + groupName: string; + items: Array; +} + +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; + + variableMode?: 'tabs' | 'tree'; + + /** + * 函数集合,默认不需要传,即 amis-formula 里面那个函数 + * 如果有扩充,则需要传。 + */ + functions: Array; + + /** + * 顶部标题,默认为表达式 + */ + 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 = []; + + 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, + functions: Array + ) { + // 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 ( +
+
{header ?? '表达式'}
+ + + +
+ {Array.isArray(functions) && functions.length ? ( +
+

变量

+ +
+ ) : null} + {Array.isArray(variables) && variables.length ? ( +
+

函数

+ +
+ ) : null} +
+
+ ); + } +} + +export default uncontrollable( + themeable(localeable(FormulaEditor)), + { + value: 'onChange' + }, + ['validate'] +); diff --git a/src/components/formula/FuncList.tsx b/src/components/formula/FuncList.tsx new file mode 100644 index 000000000..1a770f1cd --- /dev/null +++ b/src/components/formula/FuncList.tsx @@ -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; + 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(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 ( +
+ +
+ + {filteredFuncs.map(item => ( + + {item.items.map(item => ( +
setActiveFunc(item)} + onClick={() => props.onSelect?.(item)} + key={item.name} + > + {item.name} +
+ ))} +
+ ))} +
+
+ {activeFunc ? ( +
+

{activeFunc.example}

+
{activeFunc.description}
+
+ ) : null} +
+
+
+ ); +} + +export default themeable(FuncList); diff --git a/src/components/formula/Picker.tsx b/src/components/formula/Picker.tsx new file mode 100644 index 000000000..11f7ce276 --- /dev/null +++ b/src/components/formula/Picker.tsx @@ -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 { + @autobind + handleConfirm(value: any) { + this.props.onChange?.(value); + } + + render() { + const { + classnames: cx, + value, + translate: __, + disabled, + className, + onChange, + size, + borderMode, + ...rest + } = this.props; + + return ( + { + return ; + }} + value={value} + onConfirm={this.handleConfirm} + size={'md'} + > + {({onClick, isOpened}) => ( + + + + + + )} + + ); + } +} + +export default themeable( + localeable( + uncontrollable(FormulaPicker, { + value: 'onChange' + }) + ) +); diff --git a/src/components/formula/VariableList.tsx b/src/components/formula/VariableList.tsx new file mode 100644 index 000000000..60a9ceb73 --- /dev/null +++ b/src/components/formula/VariableList.tsx @@ -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; + selectMode?: 'list' | 'tree' | 'tabs'; + onSelect?: (item: VariableItem) => void; +} + +export function VariableList({ + data: list, + className, + selectMode, + onSelect +}: VariableListProps) { + return ( +
+ {selectMode === 'tabs' ? ( + + {list.map((item, index) => ( + + + + ))} + + ) : selectMode === 'tree' ? ( + onSelect?.(item)} + /> + ) : ( + onSelect?.(item)} + /> + )} +
+ ); +} diff --git a/src/components/formula/plugin.ts b/src/components/formula/plugin.ts new file mode 100644 index 000000000..fbe59ec4e --- /dev/null +++ b/src/components/formula/plugin.ts @@ -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) { + 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'}); +} diff --git a/src/index.tsx b/src/index.tsx index a98df2d13..b93b0def4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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'; diff --git a/src/renderers/Form/InputFormula.tsx b/src/renderers/Form/InputFormula.tsx new file mode 100644 index 000000000..dbbac09f1 --- /dev/null +++ b/src/renderers/Form/InputFormula.tsx @@ -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; + + variableMode?: 'tabs' | 'tree'; + + /** + * 函数集合,默认不需要传,即 amis-formula 里面那个函数 + * 如果有扩充,则需要传。 + */ + functions: Array; + + /** + * 顶部标题,默认为表达式 + */ + header: string; +} + +export interface InputFormulaProps + extends FormControlProps, + Omit< + InputFormulaControlSchema, + 'options' | 'inputClassName' | 'className' | 'descriptionClassName' + > {} + +@FormItem({ + type: 'input-formula' +}) +export class InputFormulaRenderer extends React.Component { + render() { + const { + selectedOptions, + disabled, + onChange, + evalMode, + variables, + variableMode, + functions, + header + } = this.props; + + return ( + + ); + } +} diff --git a/types/uncontrollable/index.d.ts b/types/uncontrollable/index.d.ts index 6d878bfd9..f8d32c5d5 100644 --- a/types/uncontrollable/index.d.ts +++ b/types/uncontrollable/index.d.ts @@ -4,5 +4,5 @@ declare module 'uncontrollable' { P extends { [propName: string]: any; } - >(arg: T, config: P): T; + >(arg: T, config: P, mapping?: any): T; }