From 90ced059b1d0efe845496bf5ec4c1be49236a54a Mon Sep 17 00:00:00 2001 From: liaoxuezhi <2betop.cn@gmail.com> Date: Wed, 20 Mar 2024 20:30:50 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=85=AC=E5=BC=8F=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=20input=20=E6=8D=A2=E6=88=90=20codemirror=20=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C=20(#9821)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/zh-CN/components/form/input-formula.md | 75 +-- packages/amis-core/src/StatusScoped.tsx | 9 +- packages/amis-core/src/locale.tsx | 9 +- packages/amis-core/src/theme.tsx | 9 +- packages/amis-core/src/utils/formula.ts | 2 +- .../control/_expression-formula-control.scss | 2 + .../control/_textarea-formula-control.scss | 6 +- .../scss/control/_tpl-formula-control.scss | 6 +- .../amis-editor/src/plugin/CRUD2/BaseCRUD.tsx | 2 +- .../src/renderer/ExpressionFormulaControl.tsx | 34 +- .../src/renderer/FormulaControl.tsx | 82 +-- .../src/renderer/TplFormulaControl.tsx | 23 +- .../src/renderer/event-control/helper.tsx | 6 +- .../src/renderer/event-control/index.tsx | 2 +- .../textarea-formula/FormulaPicker.tsx | 2 +- packages/amis-editor/src/tpl/common.tsx | 2 +- packages/amis-ui/scss/_mixins.scss | 1 + .../scss/components/_condition-builder.scss | 17 + .../amis-ui/scss/components/_formula.scss | 148 ++++- .../amis-ui/src/components/CodeMirror.tsx | 21 +- packages/amis-ui/src/components/InputBox.tsx | 39 +- .../amis-ui/src/components/TooltipWrapper.tsx | 4 +- .../src/components/formula/CodeEditor.tsx | 235 ++++++++ .../amis-ui/src/components/formula/Editor.tsx | 338 +++++++---- .../src/components/formula/FuncList.tsx | 52 +- .../amis-ui/src/components/formula/Input.tsx | 173 ++---- .../amis-ui/src/components/formula/Picker.tsx | 151 ++--- .../src/components/formula/VariableList.tsx | 44 +- .../amis-ui/src/components/formula/plugin.ts | 350 ++++++++---- packages/amis-ui/src/components/index.tsx | 4 +- .../schema-editor/SchemaVariableList.tsx | 2 +- packages/amis-ui/src/locale/de-DE.ts | 5 + packages/amis-ui/src/locale/en-US.ts | 5 + packages/amis-ui/src/locale/zh-CN.ts | 5 + packages/amis/__tests__/helper.tsx | 10 + .../__snapshots__/inputFormula.test.tsx.snap | 146 ++++- .../renderers/Form/conditionBuilder.test.tsx | 532 ++++++++++-------- .../renderers/Form/inputFormula.test.tsx | 4 +- .../amis/src/renderers/Form/InputFormula.tsx | 2 +- 39 files changed, 1636 insertions(+), 923 deletions(-) create mode 100644 packages/amis-ui/src/components/formula/CodeEditor.tsx diff --git a/docs/zh-CN/components/form/input-formula.md b/docs/zh-CN/components/form/input-formula.md index 413115a7c..e6d4b81eb 100644 --- a/docs/zh-CN/components/form/input-formula.md +++ b/docs/zh-CN/components/form/input-formula.md @@ -102,8 +102,8 @@ order: 21 "name": "formula", "label": "公式", "variableMode": "tree", - "evalMode": false, - "value": "${SUM(1 , 2)}", + "evalMode": true, + "value": "SUM(1 , 2)", "inputMode": "button", "variables": [ { @@ -368,77 +368,6 @@ Tab 结构: } ``` -## 高亮文本 - -通过配置`allowInput`为`false`可以高亮文本内容,但是只能在编辑器中编辑 - -```schema: scope="body" -{ - "type": "form", - "debug": true, - "body": [ - { - "type": "input-formula", - "name": "formula", - "allowInput": false, - "label": "公式", - "evalMode": true, - "value": "SUM(1, 2)", - "variables": [ - { - "label": "表单字段", - "children": [ - { - "label": "文章名", - "value": "name", - "tag": "文本" - }, - { - "label": "作者", - "value": "author", - "tag": "文本" - }, - { - "label": "售价", - "value": "price", - "tag": "数字" - }, - { - "label": "出版时间", - "value": "time", - "tag": "时间" - }, - { - "label": "版本号", - "value": "version", - "tag": "数字" - }, - { - "label": "出版社", - "value": "publisher", - "tag": "文本" - } - ] - }, - { - "label": "流程字段", - "children": [ - { - "label": "联系电话", - "value": "telphone" - }, - { - "label": "地址", - "value": "addr" - } - ] - } - ], - } - ] -} -``` - ## 模板模式 当配置 `evalMode` 为 false 时则为模板模式,意思是说默认不当做表达式,只有 `${`和`}`包裹的部分才是表达式。 diff --git a/packages/amis-core/src/StatusScoped.tsx b/packages/amis-core/src/StatusScoped.tsx index d22922d5b..d19518148 100644 --- a/packages/amis-core/src/StatusScoped.tsx +++ b/packages/amis-core/src/StatusScoped.tsx @@ -59,9 +59,12 @@ export function StatusScoped< } = { statusStore: this.store! }; - const refConfig = ComposedComponent.prototype?.isReactComponent - ? {ref: this.childRef} - : {forwardedRef: this.childRef}; + const refConfig = + ComposedComponent.prototype?.isReactComponent || + (ComposedComponent as any).$$typeof === + Symbol.for('react.forward_ref') + ? {ref: this.childRef} + : {forwardedRef: this.childRef}; return ( .CodeMirror { height: 100%; font-family: inherit; - span[class^='cm-'] { - color: var(--input-default-default-color); - } + // span[class^='cm-'] { + // color: var(--input-default-default-color); + // } // 解决上下 pre标签中表达式浮层遮挡问题 .CodeMirror-measure + div { diff --git a/packages/amis-editor-core/scss/control/_tpl-formula-control.scss b/packages/amis-editor-core/scss/control/_tpl-formula-control.scss index f1b14483d..33503a451 100644 --- a/packages/amis-editor-core/scss/control/_tpl-formula-control.scss +++ b/packages/amis-editor-core/scss/control/_tpl-formula-control.scss @@ -48,9 +48,9 @@ color: var(--Form-input-color); font-family: inherit; - span[class^='cm-'] { - color: var(--input-default-default-color); - } + // span[class^='cm-'] { + // color: var(--input-default-default-color); + // } // 解决上下 pre标签中表达式浮层遮挡问题 .CodeMirror-measure + div { diff --git a/packages/amis-editor/src/plugin/CRUD2/BaseCRUD.tsx b/packages/amis-editor/src/plugin/CRUD2/BaseCRUD.tsx index 741f0f047..a73d24889 100644 --- a/packages/amis-editor/src/plugin/CRUD2/BaseCRUD.tsx +++ b/packages/amis-editor/src/plugin/CRUD2/BaseCRUD.tsx @@ -890,7 +890,7 @@ export class BaseCRUDPlugin extends BasePlugin { step: 10, min: 1000 }, - getSchemaTpl('tplFormulaControl', { + getSchemaTpl('expressionFormulaControl', { name: 'stopAutoRefreshWhen', label: tipedLabel( '停止条件', diff --git a/packages/amis-editor/src/renderer/ExpressionFormulaControl.tsx b/packages/amis-editor/src/renderer/ExpressionFormulaControl.tsx index a75808cd1..c67787508 100644 --- a/packages/amis-editor/src/renderer/ExpressionFormulaControl.tsx +++ b/packages/amis-editor/src/renderer/ExpressionFormulaControl.tsx @@ -6,9 +6,8 @@ import React from 'react'; import {autobind, FormControlProps} from 'amis-core'; import cx from 'classnames'; import {FormItem, Button, Icon, PickerContainer} from 'amis'; -import {FormulaEditor} from 'amis-ui'; +import {FormulaCodeEditor, FormulaEditor} from 'amis-ui'; import type {VariableItem} from 'amis-ui'; -import {renderFormulaValue} from './FormulaControl'; import {reaction} from 'mobx'; import {getVariables} from 'amis-editor-core'; @@ -76,6 +75,12 @@ export default class ExpressionFormulaControl extends React.Component< this.appCorpusData = editorStore?.appCorpusData; } ); + + // 要高亮,初始就要加载 + const variablesArr = await getVariables(this); + this.setState({ + variables: variablesArr + }); } async componentDidUpdate(prevProps: ExpressionFormulaControlProps) { @@ -133,13 +138,6 @@ export default class ExpressionFormulaControl extends React.Component< const {value, className, variableMode, header, size, ...rest} = this.props; const {formulaPickerValue, variables} = this.state; - const highlightValue = FormulaEditor.highlightValue( - formulaPickerValue, - variables - ) || { - html: formulaPickerValue - }; - // 自身字段 const selfName = this.props?.data?.name; return ( @@ -180,11 +178,25 @@ export default class ExpressionFormulaControl extends React.Component< mouseLeaveDelay: 20, content: value, tooltipClassName: 'btn-configured-tooltip', - children: () => renderFormulaValue(highlightValue) + children: () => ( + + ) }} onClick={e => this.handleOnClick(e, onClick)} > - {renderFormulaValue(highlightValue)} + renderFormulaValue(highlightValue) + tooltipClassName: 'btn-configured-tooltip', + children: () => ( + + ) }} > -
-
+ - { - this.handleInputChange(undefined); - }} - /> -
- {value && ( - this.handleInputChange('')} - /> - )} + hasError={isError} + inputRender={({value, onChange, onFocus, onBlur}: any) => ( + + )} + value={value} + onChange={this.handleInputChange} + />
)} diff --git a/packages/amis-editor/src/renderer/TplFormulaControl.tsx b/packages/amis-editor/src/renderer/TplFormulaControl.tsx index 6f352c552..fba83847b 100644 --- a/packages/amis-editor/src/renderer/TplFormulaControl.tsx +++ b/packages/amis-editor/src/renderer/TplFormulaControl.tsx @@ -5,12 +5,11 @@ import React from 'react'; import cx from 'classnames'; import {reaction} from 'mobx'; -import {CodeMirrorEditor, FormulaEditor} from 'amis-ui'; +import {CodeMirrorEditor, FormulaCodeEditor, FormulaEditor} from 'amis-ui'; import type {VariableItem, CodeMirror} from 'amis-ui'; import {Icon, Button, FormItem, TooltipWrapper} from 'amis'; import {autobind, FormControlProps} from 'amis-core'; import {FormulaPlugin, editorFactory} from './textarea-formula/plugin'; -import {renderFormulaValue} from './FormulaControl'; import FormulaPicker, { CustomFormulaPickerProps } from './textarea-formula/FormulaPicker'; @@ -383,13 +382,6 @@ export class TplFormulaControl extends React.Component< const FormulaPickerCmp = customFormulaPicker ?? FormulaPicker; - const highlightValue = FormulaEditor.highlightValue( - formulaPickerValue, - variables - ) || { - html: formulaPickerValue - }; - return (
renderFormulaValue(highlightValue) + tooltipClassName: 'btn-configured-tooltip', + children: () => ( + + ) }} >
void; diff --git a/packages/amis-editor/src/tpl/common.tsx b/packages/amis-editor/src/tpl/common.tsx index 9954c3e87..c7686ee8d 100644 --- a/packages/amis-editor/src/tpl/common.tsx +++ b/packages/amis-editor/src/tpl/common.tsx @@ -10,7 +10,7 @@ import type {SchemaObject} from 'amis'; import flatten from 'lodash/flatten'; import {InputComponentName} from '../component/InputComponentName'; import {FormulaDateType} from '../renderer/FormulaControl'; -import {VariableItem} from 'amis-ui/lib/components/formula/Editor'; +import type {VariableItem} from 'amis-ui/src/components/formula/CodeEditor'; import reduce from 'lodash/reduce'; import map from 'lodash/map'; import omit from 'lodash/omit'; diff --git a/packages/amis-ui/scss/_mixins.scss b/packages/amis-ui/scss/_mixins.scss index 91b58fdb8..fd9f6aac3 100644 --- a/packages/amis-ui/scss/_mixins.scss +++ b/packages/amis-ui/scss/_mixins.scss @@ -452,6 +452,7 @@ fill: var(--Form-input-clearBtn-color); width: var(--Form-input-clearBtn-size); height: var(--Form-input-clearBtn-size); + top: 0; } &:hover svg { diff --git a/packages/amis-ui/scss/components/_condition-builder.scss b/packages/amis-ui/scss/components/_condition-builder.scss index ad4557114..266477360 100644 --- a/packages/amis-ui/scss/components/_condition-builder.scss +++ b/packages/amis-ui/scss/components/_condition-builder.scss @@ -112,6 +112,7 @@ position: relative; &-wrapper { flex: 1; + min-width: 0; } &-collapse { @@ -139,6 +140,7 @@ &-body-wrapper { flex: 1; + min-width: 0; } &-toolbar { @@ -323,6 +325,8 @@ display: flex; flex-direction: row; align-items: center; + flex: 1; + min-width: 0; > .#{$ns}CBGroupOrItem-dragbar { left: px2rem(10px); position: absolute; @@ -431,11 +435,23 @@ } } +.#{$ns}CBItem { + display: flex; + flex: 1; + min-width: 0; + + > * { + flex-shrink: 0; + } +} + .#{$ns}CBValue { position: relative; display: inline-block; vertical-align: middle; margin: px2rem(3px); + flex: 1; + min-width: 0; } .#{$ns}CBFormula { @@ -461,6 +477,7 @@ width: 20px; text-align: center; display: inline-block; + align-self: center; user-select: none; } diff --git a/packages/amis-ui/scss/components/_formula.scss b/packages/amis-ui/scss/components/_formula.scss index 6a3f00779..ad1851a2c 100644 --- a/packages/amis-ui/scss/components/_formula.scss +++ b/packages/amis-ui/scss/components/_formula.scss @@ -2,6 +2,7 @@ overflow: visible; max-width: 100%; box-sizing: content-box; + min-height: px2rem(450px); @mixin scrollbar { &::-webkit-scrollbar { @@ -31,10 +32,59 @@ flex: 1; height: 100%; max-width: #{px2rem(530px)}; + min-width: 0; border-top: var(--Form-input-borderWidth) solid var(--Form-input-borderColor); border-bottom: var(--Form-input-borderWidth) solid var(--Form-input-borderColor); + display: flex; + flex-direction: column; + } + + &-runPanel { + &.in { + height: 0; + overflow: hidden; + } + height: px2rem(200px); + transition: height 0.1s ease-out; + border-top: 1px solid var(--Form-input-borderColor); + display: flex; + flex-wrap: nowrap; + flex-direction: row; + + &-context, + &-result { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + + > header { + @include panel-header(); + margin: 0; + } + + > div { + flex: 1; + min-height: 0; + font-size: 12px; + } + } + + &-context { + border-right: 1px solid var(--Form-input-borderColor); + } + + &-result { + > div { + padding: 0 px2rem(10px); + } + + &.is-error { + color: var(--Form-input-onError-borderColor); + } + } } &-header { @@ -51,8 +101,8 @@ &-editor { @include scrollbar(); - min-height: px2rem(200px); - height: calc(100% - 35px); + flex: 1; + min-height: 0; padding: #{px2rem(5px)}; padding-right: 0; @@ -418,27 +468,6 @@ overflow: hidden; background: var(--InputFormula-code-bgColor); } - - .cm-field, - .cm-func { - border-radius: 3px; - color: #fff; - margin: 0 1px; - padding: 0 2px; - } - - .cm-field { - padding: 2px 5px; - } - - .cm-field { - background: #007bff; - } - .cm-func { - color: #ae4597; - font-weight: bold; - line-height: 14px; - } } .#{$ns}FormulaPicker { @@ -459,8 +488,9 @@ &-input { flex: 1; + min-width: 0; margin-right: #{px2rem(10px)}; - padding-right: 0; + padding-right: px2rem(4px); max-width: calc(100% - #{px2rem(42px)}); } @@ -515,7 +545,8 @@ height: var(--Form-input-height); &.#{$ns}FormulaPicker--text { - padding: var(--Form-input-paddingY) var(--Form-input-paddingX); + padding: var(--Form-input-paddingY) var(--Form-input-paddingX) + var(--Form-input-paddingY) px2rem(5px); } .#{$ns}FormulaPicker-input { @@ -534,8 +565,8 @@ box-sizing: none; } - &-number, &-select, + &-number, &-boolean, &-date, &-time, @@ -548,6 +579,7 @@ .#{$ns}Number-handler-wrap { height: unset; /* 避免调节器超出Input框 */ } + padding-left: 0; } &-variable { @@ -575,3 +607,67 @@ } } } + +.#{$ns}FormulaCodeEditor { + .cm-field, + .cm-func { + border-radius: 3px; + color: #fff; + } + + .cm-field { + padding: 2px 5px; + } + + .cm-field { + background: #007bff; + } + .cm-func { + color: #ae4597; + font-weight: bold; + line-height: 14px; + } + .cm-error-token { + background-position: left bottom; + background-repeat: repeat-x; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==); + } + + .CodeMirror-placeholder { + color: var(--Form-input-placeholderColor) !important; + } + + .lint-error { + color: var(--Form-input-onError-borderColor); + } + + &--singleLine { + max-width: 100%; + > .CodeMirror { + height: 21px; + + .CodeMirror-hscrollbar, + .CodeMirror-vscrollbar { + display: none !important; + } + + .CodeMirror-sizer { + min-height: 21px !important; + } + .CodeMirror-scroll { + height: 21px; + margin: 0; + padding: 0; + overflow: hidden !important; + } + .CodeMirror-lines { + padding: 0; + } + } + } +} + +.#{$ns}InputBox > .#{$ns}FormulaCodeEditor { + flex: 1; + min-width: 0; +} diff --git a/packages/amis-ui/src/components/CodeMirror.tsx b/packages/amis-ui/src/components/CodeMirror.tsx index 87fa3f586..1b551cd07 100644 --- a/packages/amis-ui/src/components/CodeMirror.tsx +++ b/packages/amis-ui/src/components/CodeMirror.tsx @@ -1,13 +1,17 @@ import React from 'react'; // import 'codemirror/lib/codemirror.css'; import type CodeMirror from 'codemirror'; -import {autobind} from 'amis-core'; +import {autobind, changedEffect} from 'amis-core'; import {resizeSensor} from 'amis-core'; +import 'codemirror/theme/base16-dark.css'; +// import 'codemirror/theme/base16-light.css'; + export interface CodeMirrorEditorProps { className?: string; style?: any; value?: string; + readOnly?: boolean; onChange?: (value: string) => void; onFocus?: (e: any) => void; onBlur?: (e: any) => void; @@ -37,14 +41,17 @@ export class CodeMirrorEditor extends React.Component { await import('codemirror/mode/htmlmixed/htmlmixed'); await import('codemirror/addon/mode/simple'); await import('codemirror/addon/mode/multiplex'); + await import('codemirror/addon/display/placeholder'); if (this.unmounted) { return; } + this.dom.current!.innerHTML = ''; this.editor = this.props.editorFactory?.(this.dom.current!, cm, this.props) ?? cm(this.dom.current!, { - value: this.props.value || '' + value: this.props.value || '', + readOnly: this.props.readOnly ? 'nocursor' : false }); this.props.editorDidMount?.(cm, this.editor); @@ -52,6 +59,8 @@ export class CodeMirrorEditor extends React.Component { this.editor.on('blur', this.handleBlur); this.editor.on('focus', this.handleFocus); + this.setValue(this.props.value); + this.toDispose.push( resizeSensor(this.dom.current as HTMLElement, () => this.editor?.refresh() @@ -70,6 +79,10 @@ export class CodeMirrorEditor extends React.Component { if (props.value !== prevProps.value) { this.editor && this.setValue(props.value); } + + changedEffect(['readOnly'], prevProps, this.props, (changes: any) => { + this.editor?.setOption('readOnly', changes.readOnly ? 'nocursor' : false); + }); } componentWillUnmount() { @@ -97,9 +110,9 @@ export class CodeMirrorEditor extends React.Component { setValue(value?: string) { const doc = this.editor!.getDoc(); - if (value && value !== doc.getValue()) { + if (value !== doc.getValue()) { const cursor = doc.getCursor(); - doc.setValue(value); + doc.setValue(value || ''); doc.setCursor(cursor); } } diff --git a/packages/amis-ui/src/components/InputBox.tsx b/packages/amis-ui/src/components/InputBox.tsx index 2d41df4ab..c6dc1bc5d 100644 --- a/packages/amis-ui/src/components/InputBox.tsx +++ b/packages/amis-ui/src/components/InputBox.tsx @@ -19,6 +19,7 @@ export interface InputBoxProps children?: React.ReactNode | Array; borderMode?: 'full' | 'half' | 'none'; testid?: string; + inputRender?: (props: any, ref?: any) => JSX.Element; } export interface InputBoxState { @@ -49,7 +50,7 @@ export class InputBox extends React.Component { @autobind handleChange(e: React.ChangeEvent) { const onChange = this.props.onChange; - onChange && onChange(e.currentTarget.value); + onChange && onChange(e.currentTarget ? e.currentTarget.value : (e as any)); } @autobind @@ -86,6 +87,7 @@ export class InputBox extends React.Component { onClick, mobileUI, testid, + inputRender, ...rest } = this.props; const isFocused = this.state.isFocused; @@ -104,17 +106,30 @@ export class InputBox extends React.Component { > {result} - + {typeof inputRender === 'function' ? ( + inputRender({ + ...rest, + value: value ?? '', + onChange: this.handleChange as any, + placeholder, + onFocus: this.handleFocus, + onBlur: this.handleBlur, + disabled, + ...buildTestId(testid) + }) + ) : ( + + )} {children} diff --git a/packages/amis-ui/src/components/TooltipWrapper.tsx b/packages/amis-ui/src/components/TooltipWrapper.tsx index add73d052..26dce7f09 100644 --- a/packages/amis-ui/src/components/TooltipWrapper.tsx +++ b/packages/amis-ui/src/components/TooltipWrapper.tsx @@ -27,7 +27,7 @@ export interface TooltipObject { /** * 浮层出现位置 */ - placement?: 'top' | 'right' | 'bottom' | 'left'; + placement?: 'top' | 'right' | 'bottom' | 'left' | 'auto'; /** * 主题样式 */ @@ -95,7 +95,7 @@ export interface TooltipWrapperProps { tooltip?: string | TooltipObject; classPrefix: string; classnames: ClassNamesFn; - placement: 'top' | 'right' | 'bottom' | 'left'; + placement: 'top' | 'right' | 'bottom' | 'left' | 'auto'; container?: HTMLElement | (() => HTMLElement | null | undefined); trigger: Trigger | Array; rootClose: boolean; diff --git a/packages/amis-ui/src/components/formula/CodeEditor.tsx b/packages/amis-ui/src/components/formula/CodeEditor.tsx new file mode 100644 index 000000000..af57a63e7 --- /dev/null +++ b/packages/amis-ui/src/components/formula/CodeEditor.tsx @@ -0,0 +1,235 @@ +import {ThemeProps, themeable} from 'amis-core'; +import React from 'react'; +import CodeMirrorEditor, {CodeMirrorEditorProps} from '../CodeMirror'; +import {FormulaPlugin, editorFactory as createEditor} from './plugin'; +import type CodeMirror from 'codemirror'; + +export interface VariableItem { + label: string; + value?: string; + path?: string; // 路径(label) + children?: Array; + type?: string; + tag?: string; + selectMode?: 'tree' | 'tabs'; + isMember?: boolean; // 是否是数组成员 + // chunks?: string[]; // 内容块,作为一个整体进行高亮标记 +} + +export interface FuncGroup { + groupName: string; + items: Array; +} + +export interface FuncItem { + name: string; // 函数名 + example?: string; // 示例 + description?: string; // 描述 + [propName: string]: any; +} + +export interface CodeEditorProps + extends ThemeProps, + Omit { + readOnly?: boolean; + + /** + * 是否为单行模式,默认为 false + */ + singleLine?: boolean; + + /** + * evalMode 即直接就是表达式,否则 + * 需要 ${这里面才是表达式} + * 默认为 true + */ + evalMode?: boolean; + + autoFocus?: boolean; + + editorTheme?: 'dark' | 'light'; + + editorOptions?: any; + + /** + * 用于提示的变量集合,默认为空 + */ + variables?: Array; + + /** + * 函数集合,默认不需要传,即 amis-formula 里面那个函数 + * 如果有扩充,则需要传。 + */ + functions?: Array; + + placeholder?: string; + + editorDidMount?: ( + cm: typeof CodeMirror, + editor: CodeMirror.Editor, + plugin: FormulaPlugin + ) => void; +} + +function CodeEditor(props: CodeEditorProps, ref: any) { + const { + classnames: cx, + className, + value, + onChange, + editorDidMount, + onFocus, + onBlur, + functions, + variables, + evalMode, + singleLine, + autoFocus, + editorTheme, + theme: defaultTheme, + editorOptions, + placeholder + } = props; + const pluginRef = React.useRef(); + + const editorFactory = React.useCallback((dom: HTMLElement, cm: any) => { + let theme = + (editorTheme ?? + ((defaultTheme || '').includes('dark') ? 'dark' : 'light')) === 'dark' + ? 'base16-dark' + : 'default'; + let options: any = { + autoFocus, + indentUnit: 2, + lineNumbers: true, + lineWrapping: true, // 自动换行 + theme, + placeholder, + ...editorOptions + }; + if (singleLine) { + options = { + lineNumbers: false, + indentWithTabs: false, + indentUnit: 4, + lineWrapping: false, + scrollbarStyle: null, + theme, + placeholder, + ...editorOptions + }; + } + + return createEditor(dom, cm, props, options); + }, []); + + const [readOnly, setReadOnly] = React.useState(props.readOnly); + + React.useEffect(() => setReadOnly(props.readOnly), [props.readOnly]); + React.useEffect( + () => pluginRef.current?.editor?.setOption('placeholder', placeholder), + [placeholder] + ); + + // singleLine 模式下,禁止输入换行符 + const onEditorBeforeChange = React.useCallback((cm: any, event: any) => { + // Identify typing events that add a newline to the buffer. + const hasTypedNewline = + event.origin === '+input' && + typeof event.text === 'object' && + event.text.join('') === ''; + + // Prevent newline characters from being added to the buffer. + if (hasTypedNewline) { + return event.cancel(); + } + + // Identify paste events. + const hasPastedNewline = + event.origin === 'paste' && + typeof event.text === 'object' && + event.text.length > 1; + + // Format pasted text to replace newlines with spaces. + if (hasPastedNewline) { + const newText = event.text.join(' '); + return event.update(null, null, [newText]); + } + + return null; + }, []); + + const onEditorMount = React.useCallback( + (cm: any, editor: any) => { + const plugin = (pluginRef.current = new FormulaPlugin(editor, cm)); + plugin.setEvalMode(!!evalMode); + plugin.setFunctions(functions || []); + plugin.setVariables(variables || []); + editorDidMount?.(cm, editor, plugin); + plugin.autoMarkText(); + + // 单行模式,不允许输入换行,同时原来的换行符也要去掉 + if (singleLine) { + editor.on('beforeChange', onEditorBeforeChange); + + const value = editor.getValue(); + if (value && /[\n\r]/.test(value)) { + // 初始数据有换行,不允许直接编辑 + // 只能弹窗弹出非单行模式编辑 + setReadOnly(true); + editor.setValue(value.replace(/[\n\r]+/g, '')); + } + } + }, + [evalMode, functions, variables] + ); + + React.useEffect(() => { + return () => { + pluginRef.current?.editor.off('beforeChange', onEditorBeforeChange); + pluginRef.current?.dispose(); + }; + }, []); + + React.useImperativeHandle(ref, () => { + return { + insertContent: (value: any, type: 'variable' | 'func') => + pluginRef.current?.insertContent(value, type), + setValue: (value: any) => pluginRef.current?.setValue(value), + getValue: () => pluginRef.current?.getValue(), + setDisableAutoMark: (value: boolean) => + pluginRef.current?.setDisableAutoMark(value) + }; + }); + + React.useEffect(() => { + const plugin = pluginRef.current; + if (!plugin) { + return; + } + + plugin.setEvalMode(!!evalMode); + plugin.setFunctions(functions || []); + plugin.setVariables(variables || []); + plugin.autoMarkText(); + }, [evalMode, functions, variables, value]); + + return ( + + ); +} + +export default themeable(React.forwardRef(CodeEditor)); diff --git a/packages/amis-ui/src/components/formula/Editor.tsx b/packages/amis-ui/src/components/formula/Editor.tsx index 8eaa112ea..818273332 100644 --- a/packages/amis-ui/src/components/formula/Editor.tsx +++ b/packages/amis-ui/src/components/formula/Editor.tsx @@ -2,50 +2,43 @@ * @file 公式编辑器 */ import React from 'react'; -import {mapTree, uncontrollable} from 'amis-core'; +import { + eachTree, + resolveVariableAndFilterForAsync, + uncontrollable +} from 'amis-core'; import { parse, autobind, - utils, themeable, ThemeProps, localeable, - LocaleProps, - eachTree + LocaleProps } from 'amis-core'; -import {functionDocs} from 'amis-formula'; import type {FunctionDocMap} from 'amis-formula/lib/types'; -import {FormulaPlugin, editorFactory} from './plugin'; +import {editorFactory} from './plugin'; import FuncList from './FuncList'; import VariableList from './VariableList'; -import CodeMirrorEditor from '../CodeMirror'; import {toast} from '../Toast'; import Switch from '../Switch'; +import CodeEditor, {FuncGroup, FuncItem, VariableItem} from './CodeEditor'; +import {functionDocs} from 'amis-formula'; +import Transition, { + EXITED, + ENTERING, + EXITING +} from 'react-transition-group/Transition'; +import MonacoEditor from '../Editor'; +import debounce from 'lodash/debounce'; -export interface VariableItem { - label: string; - value?: string; - path?: string; // 路径(label) - children?: Array; - type?: string; - tag?: string; - selectMode?: 'tree' | 'tabs'; - isMember?: boolean; // 是否是数组成员 - // chunks?: string[]; // 内容块,作为一个整体进行高亮标记 -} - -export interface FuncGroup { - groupName: string; - items: Array; -} - -export interface FuncItem { - name: string; // 函数名 - example?: string; // 示例 - description?: string; // 描述 - [propName: string]: any; -} +const collapseStyles: { + [propName: string]: string; +} = { + [EXITED]: 'out', + [EXITING]: 'out', + [ENTERING]: 'in' +}; export interface FormulaEditorProps extends ThemeProps, LocaleProps { onChange?: (value: string) => void; @@ -106,11 +99,14 @@ export interface FunctionProps { } export interface FormulaState { - functions: FuncGroup[]; focused: boolean; isCodeMode: boolean; + showRunPanel: boolean; expandTree: boolean; - normalizeVariables?: Array; + functions?: Array; + runContext: string; + runResult: string; + runValid: boolean; } export class FormulaEditor extends React.Component< @@ -120,12 +116,15 @@ export class FormulaEditor extends React.Component< state: FormulaState = { focused: false, isCodeMode: false, + showRunPanel: false, expandTree: false, - normalizeVariables: [], - functions: [] + functions: this.props.functions, + runContext: '{\n}', + runResult: '', + runValid: false }; - editorPlugin?: FormulaPlugin; unmounted: boolean = false; + editor = React.createRef(); static buildDefaultFunctions( doc: Array<{ @@ -159,6 +158,25 @@ export class FormulaEditor extends React.Component< })); } + static async buildFunctions( + functions?: Array, + functionsFilter?: (functions: Array) => Array + ): Promise { + const {doc} = await import('amis-formula/lib/doc'); + const customFunctions = Array.isArray(functions) ? functions : []; + const functionList = [ + ...FormulaEditor.buildDefaultFunctions(doc), + ...FormulaEditor.buildCustomFunctions(functionDocs), + ...customFunctions + ]; + + if (functionsFilter) { + return functionsFilter(functionList); + } + + return functionList; + } + static defaultProps: Pick = { variables: [], evalMode: true @@ -182,6 +200,15 @@ export class FormulaEditor extends React.Component< return new RegExp(reg); } + /** + * 干不掉,太多地方使用了,但是要废弃了。 + * 不要用了,输入框也换成 codemirror 了,本身就支持高亮 + * @deprecated + * @param value + * @param variables + * @param evalMode + * @returns + */ static highlightValue( value: string, variables: Array, @@ -252,78 +279,38 @@ export class FormulaEditor extends React.Component< return {html}; } - componentDidMount(): void { - const {variables} = this.props; - this.normalizeVariables(variables as VariableItem[]); - this.buildFunctions(); + constructor(props: FormulaEditorProps) { + super(props); + this.runCode = debounce(this.runCode.bind(this), 250, { + leading: false, + trailing: true + }); } - componentDidUpdate( - prevProps: Readonly, - prevState: Readonly, - snapshot?: any - ): void { - if (prevProps.variables !== this.props.variables) { - this.normalizeVariables(this.props.variables as VariableItem[]); - } + async componentDidMount() { + if (!this.state.functions) { + const functionList = await FormulaEditor.buildFunctions(); + if (this.unmounted) { + return; + } + this.setState({ + functions: functionList + }); + } + } + + componentDidUpdate(prevProps: FormulaEditorProps): void { if (prevProps.functions !== this.props.functions) { - this.buildFunctions(); + this.setState({ + functions: this.props.functions + }); } } componentWillUnmount() { - this.editorPlugin?.dispose(); this.unmounted = true; - } - - async buildFunctions() { - const {doc} = await import('amis-formula/lib/doc'); - if (this.unmounted) { - return; - } - const customFunctions = Array.isArray(this.props.functions) - ? this.props.functions - : []; - const functionList = [ - ...FormulaEditor.buildDefaultFunctions(doc), - ...FormulaEditor.buildCustomFunctions(functionDocs), - ...customFunctions - ]; - this.setState({ - functions: functionList - }); - } - - normalizeVariables(variables?: Array) { - if (!variables) { - return; - } - // 追加path,用于分级高亮 - const list = mapTree( - variables, - (item: any, key: number, level: number, paths: any[]) => { - const path = paths?.reduce((prev, next) => { - return !next.value - ? prev - : `${prev}${prev ? '.' : ''}${next.label ?? next.value}`; - }, ''); - - return { - ...item, - path: `${path}${path ? '.' : ''}${item.label}`, - // 自己是数组成员或者父级有数组成员 - ...(item.isMember || paths.some(item => item.isMember) - ? { - memberDepth: paths?.filter((item: any) => item.type === 'array') - ?.length - } - : {}) - }; - } - ); - - this.setState({normalizeVariables: list}); + (this.runCode as any).cancel(); } @autobind @@ -340,17 +327,17 @@ export class FormulaEditor extends React.Component< }); } - @autobind - insertValue(value: any, type: 'variable' | 'func') { - this.editorPlugin?.insertContent(value, type); + getEditor() { + let ref = this.editor.current; + while (ref?.getWrappedInstance) { + ref = ref.getWrappedInstance(); + } + return ref; } @autobind - handleEditorMounted(cm: any, editor: any) { - this.editorPlugin = new FormulaPlugin(editor, cm, () => ({ - ...this.props, - variables: this.state.normalizeVariables - })); + insertValue(value: any, type: 'variable' | 'func') { + this.getEditor()?.insertContent(value, type); } @autobind @@ -372,12 +359,12 @@ export class FormulaEditor extends React.Component< @autobind handleFunctionSelect(item: FuncItem) { - this.editorPlugin?.insertContent(`${item.name}`, 'func'); + this.getEditor()?.insertContent(`${item.name}`, 'func'); } @autobind handleVariableSelect(item: VariableItem) { - const {evalMode, selfVariableName} = this.props; + const {selfVariableName} = this.props; if ( item && @@ -393,7 +380,7 @@ export class FormulaEditor extends React.Component< return; } - this.editorPlugin?.insertContent( + this.getEditor()?.insertContent( item.isMember ? item.value : { @@ -412,23 +399,72 @@ export class FormulaEditor extends React.Component< handleOnChange(value: any) { const onChange = this.props.onChange; onChange?.(value); + this.runCode(); } @autobind editorFactory(dom: HTMLElement, cm: any) { const {editorOptions, ...rest} = this.props; return editorFactory(dom, cm, rest, { - lineWrapping: true // 自动换行 + lineWrapping: true, // 自动换行 + autoFocus: true }); } @autobind handleIsCodeModeChange(showCode: boolean) { // 重置一下value - this.editorPlugin?.setValue(this.editorPlugin?.getValue()); + // this.getEditor()?.setValue(this.getEditor()?.getValue()); // 非源码模式,则mark一下 - !showCode && this.editorPlugin?.autoMarkText(); - this.setState({isCodeMode: showCode}); + // !showCode && this.getEditor()?.autoMarkText(); + this.setState({isCodeMode: showCode}, () => + this.getEditor()?.setDisableAutoMark(showCode ? true : false) + ); + } + + @autobind + toggleRunPanel() { + this.setState( + { + showRunPanel: !this.state.showRunPanel + }, + this.runCode + ); + } + @autobind + handleRunContextChange(value: string) { + this.setState({runContext: value}, this.runCode); + } + + async runCode() { + const value = this.props.value || ''; + if (!this.state.showRunPanel) { + return; + } + + try { + // 因为 resolveVariableAndFilterForAsync 不会报语法错误 + parse(value, { + evalMode: this.props.evalMode + }); + + const runContext = JSON.parse(this.state.runContext); + let code = this.props.evalMode ? `\${${value}}` : value; + + const result = await resolveVariableAndFilterForAsync(code, runContext); + + this.unmounted || + this.setState({ + runValid: true, + runResult: JSON.stringify(result) + }); + } catch (e) { + this.unmounted || + this.setState({ + runValid: false, + runResult: e.message + }); + } } @autobind @@ -440,21 +476,25 @@ export class FormulaEditor extends React.Component< const { header, value, - functions, + variables, variableMode, translate: __, classnames: cx, variableClassName, functionClassName, classPrefix, - selfVariableName + selfVariableName, + evalMode } = this.props; const { focused, isCodeMode, + showRunPanel, expandTree, - normalizeVariables, - functions: functionList + functions, + runContext, + runResult, + runValid } = this.state; return ( @@ -467,15 +507,19 @@ export class FormulaEditor extends React.Component<
{__(header || 'FormulaEditor.title')} +
+ {__('FormulaEditor.run')} + +
- 源码模式 + {__('FormulaEditor.sourceMode')}
- + + + {(status: string) => { + return ( +
+
+
{__('FormulaEditor.runContext')}
+
+ +
+
+
+
{__('FormulaEditor.runResult')}
+
{runResult}
+
+
+ ); + }} +
{variableMode !== 'tabs' ? ( @@ -499,7 +589,7 @@ export class FormulaEditor extends React.Component< {__('FormulaEditor.variable')} {variableMode === 'tree' ? (
- 展开全部 + {__('FormulaEditor.toggleAll')} diff --git a/packages/amis-ui/src/components/formula/FuncList.tsx b/packages/amis-ui/src/components/formula/FuncList.tsx index 0c5e5e080..bc107c27f 100644 --- a/packages/amis-ui/src/components/formula/FuncList.tsx +++ b/packages/amis-ui/src/components/formula/FuncList.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import {themeable, ThemeProps} from 'amis-core'; +import {mapTree, themeable, ThemeProps} from 'amis-core'; import Collapse from '../Collapse'; import CollapseGroup from '../CollapseGroup'; import SearchBox from '../SearchBox'; -import type {FuncGroup, FuncItem} from './Editor'; +import type {FuncGroup, FuncItem} from './CodeEditor'; import TooltipWrapper from '../TooltipWrapper'; import {Icon} from '../icons'; @@ -25,26 +25,32 @@ export function FuncList(props: FuncListProps) { descClassName, mobileUI } = props; + const [term, setTerm] = React.useState(''); const [filteredFuncs, setFiteredFuncs] = React.useState(props.data); const [activeFunc, setActiveFunc] = React.useState(null); - React.useEffect(() => { - setFiteredFuncs(props.data); - }, [props.data]); + const onSearch = React.useCallback( + (term: string) => { + const filtered = props.data + .map(item => { + return { + ...item, + items: term + ? item.items.filter( + (item: any) => ~item.name.indexOf(term.toUpperCase()) + ) + : item.items + }; + }) + .filter(item => item.items.length); + setFiteredFuncs(filtered); + }, + [props.data] + ); - 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); - } + React.useEffect(() => { + onSearch(term); + }, [props.data]); return (
@@ -57,7 +63,13 @@ export function FuncList(props: FuncListProps) {
{title}
- +
- {item.items.map(item => ( + {item.items.map((item: any) => (
; popOverContainer?: any; @@ -59,7 +71,7 @@ export interface FormulaInputProps itemRender?: (value: any) => JSX.Element | string; } -const FormulaInput: React.FC = props => { +const FormulaInput = (props: FormulaInputProps, ref: any) => { const { translate: __, className, @@ -71,6 +83,7 @@ const FormulaInput: React.FC = props => { mixedMode, value, variables, + functions, inputSettings = {type: 'text'}, popOverContainer, onChange, @@ -81,9 +94,16 @@ const FormulaInput: React.FC = props => { const sharedProps = pick(props, ['disabled', 'clearable']); const pipInValue = useCallback( (value?: any) => { + /** 数据来源可能是从 query中下发的(CRUD查询表头),导致数字或者布尔值被转为 string 格式,这里预处理一下 */ + if (schemaType === 'number') { + value = isNaN(+value) ? value : +value; + } else if (schemaType === 'boolean') { + value = value === 'true' ? true : value === 'false' ? false : value; + } + return value; }, - ['value'] + [schemaType] ); const pipOutValue = useCallback( (origin: any) => { @@ -133,106 +153,9 @@ const FormulaInput: React.FC = props => { ); let cmptValue = pipInValue(value ?? inputSettings.defaultValue); + const isExpr = isExpression(cmptValue); - /** 数据来源可能是从 query中下发的(CRUD查询表头),导致数字或者布尔值被转为 string 格式,这里预处理一下 */ - if (schemaType === 'number') { - cmptValue = isNaN(+cmptValue) ? cmptValue : +cmptValue; - } else if (schemaType === 'boolean') { - cmptValue = - cmptValue === 'true' ? true : cmptValue === 'false' ? false : cmptValue; - } - - const targetVariable = - variables && cmptValue != null && typeof cmptValue === 'string' - ? findTree(variables, item => { - return mixedMode - ? cmptValue.replace(/^\$\{/, '').replace(/\}$/, '') === item?.value - : cmptValue === item?.value; - }) - : null; - let useVariable = !!(isExpression(cmptValue) || targetVariable); - - /** 判断value是否为变量,如果是变量,使用ResultBox渲染 */ - if (!useVariable) { - if (schemaType === 'number') { - useVariable = cmptValue != null && typeof cmptValue !== 'number'; - } else if (['date', 'time', 'datetime'].includes(schemaType)) { - useVariable = !moment(cmptValue).isValid(); - } else if (schemaType === 'select') { - const { - options, - joinValues, - extractValue, - delimiter, - multiple, - valueField = 'value' - } = inputSettings; - let selctedValue: any[] = []; - - if (multiple) { - if (joinValues) { - selctedValue = - typeof cmptValue === 'string' ? cmptValue.split(delimiter) : []; - } else { - selctedValue = Array.isArray(cmptValue) - ? extractValue - ? cmptValue - : cmptValue.map(i => i?.[valueField]) - : []; - } - } else { - if (joinValues) { - selctedValue = typeof cmptValue === 'string' ? [cmptValue] : []; - } else { - selctedValue = isObject(cmptValue) ? [cmptValue?.[valueField]] : []; - } - } - - /** 选项类型清空后是空字符串, */ - useVariable = - cmptValue && - !(options ?? []).some((item: any) => - selctedValue.includes(item?.value) - ); - } else if (schemaType === 'boolean') { - useVariable = cmptValue != null && typeof cmptValue !== 'boolean'; - } - } - - if (useVariable) { - const varName = - typeof cmptValue === 'string' && cmptValue && mixedMode - ? cmptValue.replace(/^\$\{/, '').replace(/\}$/, '') - : cmptValue; - const resultValue = targetVariable?.value ?? varName; - - return ( - { - return ( -
- ); - }} - onResultChange={noop} - onChange={pipOutValue} - onClear={() => pipOutValue(undefined)} - clearable={true} - /> - ); - } - - if (schemaType === 'number') { + if (!isExpr && schemaType === 'number') { return ( = props => { onChange={pipOutValue} /> ); - } else if (schemaType === 'date') { - const cmptValue = pipInValue(value ?? inputSettings.defaultValue); - + } else if (!isExpr && schemaType === 'date') { return ( = props => { onChange={pipOutValue} /> ); - } else if (schemaType === 'time') { + } else if (!isExpr && schemaType === 'time') { return ( = props => { dateFormat="" timeFormat={inputSettings.format || 'HH:mm'} popOverContainer={popOverContainer} - value={pipInValue(value ?? inputSettings.defaultValue)} + value={cmptValue} onChange={pipOutValue} /> ); - } else if (schemaType === 'datetime') { + } else if (!isExpr && schemaType === 'datetime') { return ( = props => { inputFormat={inputSettings.inputFormat || 'YYYY-MM-DD HH:mm'} timeFormat={inputSettings.timeFormat || 'HH:mm'} popOverContainer={popOverContainer} - value={pipInValue(value ?? inputSettings.defaultValue)} + value={cmptValue} onChange={pipOutValue} /> ); - } else if (schemaType === 'select' || schemaType === 'boolean') { + } else if (!isExpr && (schemaType === 'select' || schemaType === 'boolean')) { return ( +
+
+