Merge changes Iaa7a4fb7,Ia0998f0b,Id04fddb5,I7e047a08,Iab086db2 into fix-fx-formula

* changes:
  amis-saas-6892 fix: 长文本输入框支持折行
  amis-saas-6892 fix: 长文本公式输入框支持全屏
  amis-saas-6892 fix: 长文本公式输入框基于codeMirror实现
  amis-saas-7603 feat: 纯表达式输入框
  amis-saas-6892 feat: 长文本公式输入框
This commit is contained in:
wutong25 2022-11-01 17:52:26 +08:00 committed by iCode
commit 51ef48fb56
6 changed files with 757 additions and 0 deletions

View File

@ -149,6 +149,8 @@ import './renderer/ValidationItem';
import './renderer/SwitchMoreControl'; import './renderer/SwitchMoreControl';
import './renderer/StatusControl'; import './renderer/StatusControl';
import './renderer/FormulaControl'; import './renderer/FormulaControl';
import './renderer/ExpressionFormulaControl';
import './renderer/textarea-formula/TextareaFormulaControl';
import './renderer/DateShortCutControl'; import './renderer/DateShortCutControl';
import './renderer/BadgeControl'; import './renderer/BadgeControl';
import './renderer/style-control/BoxModel'; import './renderer/style-control/BoxModel';

View File

@ -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<VariableItem>;
formulaPickerOpen: boolean;
formulaPickerValue: string;
}
export default class ExpressionFormulaControl extends React.Component<
ExpressionFormulaControlProps,
ExpressionFormulaControlState
> {
static defaultProps: Partial<ExpressionFormulaControlProps> = {
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 <span dangerouslySetInnerHTML={html}></span>;
}
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<HTMLElement>) {
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 (
<div className={cx('ae-ExpressionFormulaControl', className)}>
{formulaPickerValue ? (
<Button
className="btn-configured"
level="primary"
tooltip={{
placement: 'bottom',
tooltipClassName: 'btn-configured-tooltip',
children: () => this.renderFormulaValue(highlightValue)
}}
onClick={this.openFormulaPickerModal}
>
<Icon
icon="close"
className="icon"
onClick={this.handleClearExpression}
/>
</Button>
) : (
<Button
className="btn-set-expression"
onClick={this.openFormulaPickerModal}
>
</Button>
)}
{formulaPickerOpen ? (
<FormulaPicker
value={formulaPickerValue}
initable={true}
variables={variables}
variableMode={variableMode}
evalMode={true}
onClose={() => this.setState({formulaPickerOpen: false})}
onConfirm={this.handleConfirm}
/>
) : null}
</div>
);
}
}
@FormItem({
type: 'ae-expressionFormulaControl'
})
export class ExpressionFormulaControlRenderer extends ExpressionFormulaControl {}

View File

@ -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<FormulaPickerProps> = 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 (
<Modal
className={cx('FormulaPicker-Modal')}
size="lg"
show
onHide={handleClose}
closeOnEsc
>
<Modal.Header onClose={handleClose}>
<Modal.Title></Modal.Title>
</Modal.Header>
<Modal.Body>
<Editor
header="表达式"
variables={variables}
variableMode={variableMode}
value={formula}
evalMode={evalMode}
onChange={handleChange}
/>
</Modal.Body>
<Modal.Footer>
<Button onClick={handleClose}></Button>
<Button onClick={handleConfirm} level="primary">
</Button>
</Modal.Footer>
</Modal>
);
};
export default FormulaPicker;

View File

@ -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<Schema>; // 附加底部按钮菜单项
}
interface TextareaFormulaControlState {
value: string; // 当前文本值
variables: any; // 变量数据
menusList: Schema[]; // 底部按钮菜单
formulaPickerOpen: boolean; // 是否打开公式编辑器
formulaPickerValue: string; // 公式编辑器内容
expressionBrace?: Array<CodeMirror.Position>; // 表达式所在位置
isFullscreen: boolean; //是否全屏
}
export class TextareaFormulaControl extends React.Component<
TextareaFormulaControlProps,
TextareaFormulaControlState
> {
static defaultProps: Partial<TextareaFormulaControlProps> = {
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<CodeMirror.Position>) {
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 (
<div
className={cx('ae-TextareaFormulaControl', {
'is-fullscreen': this.state.isFullscreen
})}
ref={(ref: any) => (this.wrapRef = ref)}
>
<div className={cx('ae-TextareaResultBox')} style={resultBoxStyle}>
<CodeMirrorEditor
className="ae-TextareaResultBox-editor"
value={value}
onChange={this.handleOnChange}
editorFactory={this.editorFactory}
editorDidMount={this.handleEditorMounted}
/>
{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
})}
<div className="ae-TextareaResultBox-fullscreen">
<a
className={cx('Modal-fullscreen')}
data-tooltip={isFullscreen ? '退出全屏' : '全屏'}
data-position="left"
onClick={this.handleFullscreenModeChange}
>
<Icon
icon={isFullscreen ? 'compress-alt' : 'expand-alt'}
className="icon"
/>
</a>
</div>
</div>
{formulaPickerOpen ? (
<FormulaPicker
value={formulaPickerValue}
initable={true}
variables={variables}
variableMode={rest.variableMode}
evalMode={true}
onClose={() => this.setState({formulaPickerOpen: false})}
onConfirm={this.handleConfirm}
/>
) : null}
</div>
);
}
}
@FormItem({
type: 'ae-textareaFormulaControl'
})
export default class TextareaFormulaControlRenderer extends TextareaFormulaControl {}

View File

@ -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<CodeMirror.Position>
) => 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<CodeMirror.Position>
) {
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() {}
}

View File

@ -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 [];
}