diff --git a/packages/amis-editor/src/index.tsx b/packages/amis-editor/src/index.tsx index e73ea23c1..08cb84258 100644 --- a/packages/amis-editor/src/index.tsx +++ b/packages/amis-editor/src/index.tsx @@ -149,6 +149,8 @@ import './renderer/ValidationItem'; import './renderer/SwitchMoreControl'; import './renderer/StatusControl'; import './renderer/FormulaControl'; +import './renderer/ExpressionFormulaControl'; +import './renderer/textarea-formula/TextareaFormulaControl'; import './renderer/DateShortCutControl'; import './renderer/BadgeControl'; import './renderer/style-control/BoxModel'; diff --git a/packages/amis-editor/src/renderer/ExpressionFormulaControl.tsx b/packages/amis-editor/src/renderer/ExpressionFormulaControl.tsx new file mode 100644 index 000000000..78cf96c33 --- /dev/null +++ b/packages/amis-editor/src/renderer/ExpressionFormulaControl.tsx @@ -0,0 +1,190 @@ +/** + * @file 表达式输入框组件 + */ + +import React from 'react'; +import {autobind, FormControlProps} from 'amis-core'; +import cx from 'classnames'; +import isEqual from 'lodash/isEqual'; +import {FormItem, Button, Icon} from 'amis'; +import FormulaPicker from './textarea-formula/FormulaPicker'; +import {FormulaEditor} from 'amis-ui/lib/components/formula/Editor'; +import type {VariableItem} from 'amis-ui/lib/components/formula/Editor'; +import {resolveVariablesFromScope} from './textarea-formula/utils'; + +interface ExpressionFormulaControlProps extends FormControlProps { + variables?: any; // 公式变量 + + variableMode?: 'tree' | 'tabs'; +} + +interface ExpressionFormulaControlState { + variables: Array; + + formulaPickerOpen: boolean; + + formulaPickerValue: string; +} + +export default class ExpressionFormulaControl extends React.Component< + ExpressionFormulaControlProps, + ExpressionFormulaControlState +> { + static defaultProps: Partial = { + variableMode: 'tabs' + }; + + isUnmount: boolean; + + constructor(props: ExpressionFormulaControlProps) { + super(props); + this.state = { + variables: [], + formulaPickerOpen: false, + formulaPickerValue: '' + }; + } + + componentDidMount() { + this.initFormulaPickerValue(this.props.value); + } + + componentDidUpdate(prevProps: ExpressionFormulaControlProps) { + // 优先使用props中的变量数据 + if (!this.props.variables) { + // 从amis数据域中取变量数据 + const {node, manager} = this.props.formProps || this.props; + resolveVariablesFromScope(node, manager).then(variables => { + if (Array.isArray(variables)) { + if (!this.isUnmount && !isEqual(variables, this.state.variables)) { + this.setState({ + variables: variables + }); + } + } + }); + } + if (prevProps.value !== this.props.value) { + this.initFormulaPickerValue(this.props.value); + } + } + + componentWillUnmount() { + this.isUnmount = true; + } + + @autobind + initFormulaPickerValue(value: string) { + const formulaPickerValue = + value?.replace(/^\$\{(.*)\}$/, (match: string, p1: string) => p1) || ''; + this.setState({ + formulaPickerValue + }); + } + + @autobind + renderFormulaValue(item: any) { + const html = {__html: item.html}; + // bca-disable-next-line + return ; + } + + async resolveVariablesFromScope() { + const {node, manager} = this.props.formProps || this.props; + await manager?.getContextSchemas(node); + const dataPropsAsOptions = manager?.dataSchema?.getDataPropsAsOptions(); + + if (dataPropsAsOptions) { + return dataPropsAsOptions.map((item: any) => ({ + selectMode: 'tree', + ...item + })); + } + return []; + } + + @autobind + openFormulaPickerModal() { + this.setState({ + formulaPickerOpen: true + }); + } + + @autobind + handleConfirm(value = '') { + value = value.replace(/^\$\{(.*)\}$/, (match: string, p1: string) => p1); + value = value ? `\${${value}}` : ''; + this.props?.onChange?.(value); + this.setState({ + formulaPickerOpen: false + }); + } + + @autobind + handleClearExpression(e: React.MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + this.props?.onChange?.(''); + } + + render() { + const {value, className, variableMode} = this.props; + const {formulaPickerOpen, formulaPickerValue} = this.state; + + const variables = this.props.variables || this.state.variables; + const highlightValue = FormulaEditor.highlightValue( + formulaPickerValue, + this.state.variables + ) || { + html: formulaPickerValue + }; + + return ( +
+ {formulaPickerValue ? ( + + ) : ( + + )} + + {formulaPickerOpen ? ( + this.setState({formulaPickerOpen: false})} + onConfirm={this.handleConfirm} + /> + ) : null} +
+ ); + } +} + +@FormItem({ + type: 'ae-expressionFormulaControl' +}) +export class ExpressionFormulaControlRenderer extends ExpressionFormulaControl {} diff --git a/packages/amis-editor/src/renderer/textarea-formula/FormulaPicker.tsx b/packages/amis-editor/src/renderer/textarea-formula/FormulaPicker.tsx new file mode 100644 index 000000000..1a241a676 --- /dev/null +++ b/packages/amis-editor/src/renderer/textarea-formula/FormulaPicker.tsx @@ -0,0 +1,69 @@ +import React, {useEffect} from 'react'; +import {Modal, Button} from 'amis'; +import cx from 'classnames'; +import Editor from 'amis-ui/lib/components/formula/Editor'; + +export interface FormulaPickerProps { + onConfirm: (data: string) => void; + onClose: () => void; + variables: any[]; + value?: string; + initable?: boolean; + variableMode?: 'tabs' | 'tree'; + evalMode?: boolean; +} + +const FormulaPicker: React.FC = props => { + const {variables, variableMode, evalMode = true} = props; + const [formula, setFormula] = React.useState(''); + useEffect(() => { + const {initable, value} = props; + if (initable && value) { + setFormula(value); + } + }, [props.value]); + + const handleChange = (data: any) => { + setFormula(data); + }; + + const handleClose = () => { + props.onClose && props.onClose(); + }; + + const handleConfirm = () => { + props.onConfirm && props.onConfirm(formula); + }; + + return ( + + + 表达式 + + + + + + + + + + ); +}; + +export default FormulaPicker; diff --git a/packages/amis-editor/src/renderer/textarea-formula/TextareaFormulaControl.tsx b/packages/amis-editor/src/renderer/textarea-formula/TextareaFormulaControl.tsx new file mode 100644 index 000000000..1e0806064 --- /dev/null +++ b/packages/amis-editor/src/renderer/textarea-formula/TextareaFormulaControl.tsx @@ -0,0 +1,248 @@ +/** + * @file 长文本公式输入框 + */ + +import React from 'react'; +import isEqual from 'lodash/isEqual'; +import cx from 'classnames'; +import {Icon, render as amisRender, FormItem} from 'amis'; +import {autobind, FormControlProps, Schema} from 'amis-core'; +import CodeMirrorEditor from 'amis-ui/lib/components/CodeMirror'; +import {FormulaPlugin, editorFactory} from './plugin'; + +import FormulaPicker from './FormulaPicker'; +import debounce from 'lodash/debounce'; +import CodeMirror from 'codemirror'; +import {resolveVariablesFromScope} from './utils'; + +export interface TextareaFormulaControlProps extends FormControlProps { + height?: number; // 输入框的高度 + + variables?: any; // 公式变量 + + variableMode?: 'tree' | 'tabs'; + + additionalMenus?: Array; // 附加底部按钮菜单项 +} + +interface TextareaFormulaControlState { + value: string; // 当前文本值 + + variables: any; // 变量数据 + + menusList: Schema[]; // 底部按钮菜单 + + formulaPickerOpen: boolean; // 是否打开公式编辑器 + + formulaPickerValue: string; // 公式编辑器内容 + + expressionBrace?: Array; // 表达式所在位置 + + isFullscreen: boolean; //是否全屏 +} + +export class TextareaFormulaControl extends React.Component< + TextareaFormulaControlProps, + TextareaFormulaControlState +> { + static defaultProps: Partial = { + variableMode: 'tabs', + height: 100 + }; + + isUnmount: boolean; + + wrapRef: any; + + editorPlugin?: FormulaPlugin; + + constructor(props: TextareaFormulaControlProps) { + super(props); + this.state = { + value: '', + variables: [], + menusList: [], + formulaPickerOpen: false, + formulaPickerValue: '', + isFullscreen: false + }; + } + + componentDidMount() { + const {additionalMenus = [], value} = this.props; + const menusList = [ + { + type: 'button', + label: '表达式', + onClick: () => { + this.setState({ + formulaPickerOpen: true, + formulaPickerValue: '', + expressionBrace: undefined + }); + } + } + ]; + this.setState({ + menusList: [...menusList, ...additionalMenus] + }); + } + + componentDidUpdate(prevProps: TextareaFormulaControlProps) { + // 优先使用props中的变量数据 + if (!this.props.variables) { + // 从amis数据域中取变量数据 + const {node, manager} = this.props.formProps || this.props; + resolveVariablesFromScope(node, manager).then(variables => { + if (Array.isArray(variables)) { + if (!this.isUnmount && !isEqual(variables, this.state.variables)) { + this.setState({ + variables + }); + } + } + }); + } + } + componentWillUnmount() { + this.isUnmount = true; + } + + @autobind + onExpressionClick(expression: string, brace?: Array) { + this.setState({ + formulaPickerValue: expression, + formulaPickerOpen: true, + expressionBrace: brace + }); + } + + @autobind + closeFormulaPicker() { + this.setState({formulaPickerOpen: false}); + } + + @autobind + handleConfirm(value: any) { + const {expressionBrace} = this.state; + // 去除可能包裹的最外层的${} + value = value.replace(/^\$\{(.*)\}$/, (match: string, p1: string) => p1); + value = value ? `\${${value}}` : value; + this.editorPlugin?.insertContent(value, 'expression', expressionBrace); + this.setState({ + formulaPickerOpen: false, + expressionBrace: undefined + }); + + this.closeFormulaPicker(); + } + + handleOnChange = debounce((value: any) => { + this.props.onChange?.(value); + }, 1000); + + @autobind + editorFactory(dom: HTMLElement, cm: any) { + const variables = this.props.variables || this.state.variables; + return editorFactory(dom, cm, {...this.props, variables}); + } + @autobind + handleEditorMounted(cm: any, editor: any) { + const variables = this.props.variables || this.state.variables; + this.editorPlugin = new FormulaPlugin( + editor, + cm, + () => ({...this.props, variables}), + this.onExpressionClick + ); + } + + @autobind + handleFullscreenModeChange() { + this.setState({ + isFullscreen: !this.state.isFullscreen + }); + } + + render() { + const {className, header, label, placeholder, height, ...rest} = this.props; + const { + value, + menusList, + formulaPickerOpen, + formulaPickerValue, + isFullscreen + } = this.state; + + const variables = rest.variables || this.state.variables || []; + + // 输入框样式 + let resultBoxStyle: {[key in string]: string} = {}; + if (height) { + resultBoxStyle.height = `${height}px`; + } + + return ( +
(this.wrapRef = ref)} + > +
+ + {amisRender({ + type: 'dropdown-button', + className: 'ae-TextareaResultBox-dropdown', + menuClassName: 'ae-TextareaResultBox-menus', + popOverContainer: this.wrapRef, + label: '', + level: 'link', + size: 'md', + icon: 'fa fa-plus', + trigger: 'hover', + closeOnClick: true, + closeOnOutside: true, + hideCaret: true, + buttons: menusList + })} +
+ + + +
+
+ {formulaPickerOpen ? ( + this.setState({formulaPickerOpen: false})} + onConfirm={this.handleConfirm} + /> + ) : null} +
+ ); + } +} + +@FormItem({ + type: 'ae-textareaFormulaControl' +}) +export default class TextareaFormulaControlRenderer extends TextareaFormulaControl {} diff --git a/packages/amis-editor/src/renderer/textarea-formula/plugin.tsx b/packages/amis-editor/src/renderer/textarea-formula/plugin.tsx new file mode 100644 index 000000000..79373b65e --- /dev/null +++ b/packages/amis-editor/src/renderer/textarea-formula/plugin.tsx @@ -0,0 +1,228 @@ +/** + * @file 扩展 codemirror + */ + +import type CodeMirror from 'codemirror'; +import {TextareaFormulaControlProps} from './TextareaFormulaControl'; +import {FormulaEditor} from 'amis-ui/lib/components/formula/Editor'; + +export function editorFactory( + dom: HTMLElement, + cm: typeof CodeMirror, + props: any +) { + return cm(dom, { + value: props.value || '', + autofocus: true, + lineWrapping: true + }); +} + +export class FormulaPlugin { + constructor( + readonly editor: CodeMirror.Editor, + readonly cm: typeof CodeMirror, + readonly getProps: () => TextareaFormulaControlProps, + readonly onExpressionClick: ( + expression: string, + brace?: Array + ) => any + ) { + const {value} = this.getProps(); + if (value) { + this.autoMark(); + this.focus(value); + } + } + + autoMark() { + const editor = this.editor; + const lines = editor.lineCount(); + for (let line = 0; line < lines; line++) { + const content = editor.getLine(line); + const braces = this.computedBracesPosition(content); + for (let i = 0; i < braces.length; i++) { + // 替换每个公式表达式中的内容 + const start = braces[i].begin; + const end = braces[i].end; + const expression = content.slice(start, end); + this.markExpression( + { + line: line, + ch: start - 2 + }, + { + line: line, + ch: end + 1 + }, + expression + ); + } + } + } + + // 找到表达式所在的位置 + getExpressionBrace(expression: string) { + const editor = this.editor; + const lines = editor.lineCount(); + for (let line = 0; line < lines; line++) { + const content = editor.getLine(line); + const braces = this.computedBracesPosition(content); + for (let i = 0; i < braces.length; i++) { + // 替换每个公式表达式中的内容 + const start = braces[i].begin; + const end = braces[i].end; + if (expression === content.slice(start, end)) { + return [ + { + line: line, + ch: start - 2 + }, + { + line: line, + ch: end + 1 + } + ]; + } + } + } + return undefined; + } + + // 计算 `${`、`}` 括号的位置,如 ${a}+${b}, 结果是 [ { from: 0, to: 3 }, { from: 5, to: 8 } ] + computedBracesPosition(exp: string) { + const braces: {begin: number; end: number}[] = []; + exp?.replace(/\$\{/g, (val, offset) => { + if (val) { + const charArr = exp.slice(offset + val.length).split(''); + const cache = ['${']; + + for (let index = 0; index < charArr.length; index++) { + const char = charArr[index]; + if (char === '$' && charArr[index + 1] === '{') { + cache.push('${'); + } else if (char === '}') { + cache.pop(); + } + + if (cache.length === 0) { + braces.push({begin: offset + 2, end: index + offset + 2}); + break; + } + } + } + return ''; + }); + + return braces; + } + + // 判断字符串是否在 ${} 中 + checkStrIsInBraces( + [from, to]: number[], + braces: {begin: number; end: number}[] + ) { + let isIn = false; + if (braces.length) { + for (let index = 0; index < braces.length; index++) { + const brace = braces[index]; + if (from >= brace.begin && to <= brace.end) { + isIn = true; + break; + } + } + } + return isIn; + } + + insertBraces(originFrom: CodeMirror.Position, originTo: CodeMirror.Position) { + const str = this.editor.getValue(); + const braces = this.computedBracesPosition(str); + + if (!this.checkStrIsInBraces([originFrom.ch, originTo.ch], braces)) { + this.editor.setCursor({ + line: originFrom.line, + ch: originFrom.ch + }); + this.editor.replaceSelection('${'); + + this.editor.setCursor({ + line: originTo.line, + ch: originTo.ch + 2 + }); + this.editor.replaceSelection('}'); + } + } + + insertContent( + value: any, + type?: 'expression', + brace?: Array + ) { + if (brace) { + // 替换 + const [from, to] = brace; + if (type === 'expression') { + this.editor.replaceRange(value, from, to); + this.autoMark(); + } else if (typeof value === 'string') { + this.editor.replaceRange(value, from, to); + } + } else { + // 新增 + if (type === 'expression') { + this.editor.replaceSelection(value); + this.autoMark(); + } else if (typeof value === 'string') { + this.editor.replaceSelection(value); + } + this.editor.focus(); + } + } + + markExpression( + from: CodeMirror.Position, + to: CodeMirror.Position, + expression = '', + className = 'cm-expression' + ) { + const text = document.createElement('span'); + text.className = className; + text.innerText = '表达式'; + text.setAttribute('data-expression', expression); + text.onclick = () => { + const brace = this.getExpressionBrace(expression); + this.onExpressionClick(expression, brace); + }; + const {variables} = this.getProps(); + const highlightValue = FormulaEditor.highlightValue( + expression, + variables + ) || { + html: expression + }; + // 添加popover + const popoverEl = document.createElement('div'); + // bca-disable-next-line + popoverEl.innerHTML = highlightValue.html; + popoverEl.classList.add('expression-popover'); + text.appendChild(popoverEl); + + this.editor.markText(from, to, { + atomic: true, + replacedWith: text + }); + } + + // 焦点放在最后 + focus(value: string) { + this.editor.setCursor({ + line: 0, + ch: value?.length || 0 + }); + } + + dispose() {} + + validate() {} +} diff --git a/packages/amis-editor/src/renderer/textarea-formula/utils.ts b/packages/amis-editor/src/renderer/textarea-formula/utils.ts new file mode 100644 index 000000000..b59c6b53d --- /dev/null +++ b/packages/amis-editor/src/renderer/textarea-formula/utils.ts @@ -0,0 +1,20 @@ +/** + * 从amis数据域中取变量数据 + * @param node + * @param manager + * @returns + */ +export async function resolveVariablesFromScope(node: any, manager: any) { + await manager?.getContextSchemas(node); + const dataPropsAsOptions = manager?.dataSchema?.getDataPropsAsOptions(); + + if (dataPropsAsOptions) { + return dataPropsAsOptions + .map((item: any) => ({ + selectMode: 'tree', + ...item + })) + .filter((item: any) => item.children?.length); + } + return []; +}