mirror of
https://gitee.com/baidu/amis.git
synced 2024-12-15 17:31:18 +08:00
amis-saas-6892 fix: 长文本公式输入框基于codeMirror实现
Change-Id: Id04fddb52e9591ba42c66505ea4c00cea3a97122
This commit is contained in:
parent
7c110abcc7
commit
fa51bef367
@ -139,6 +139,7 @@ export default class ExpressionFormulaControl extends React.Component<
|
||||
level="primary"
|
||||
tooltip={{
|
||||
placement: 'bottom',
|
||||
tooltipClassName: 'btn-configured-tooltip',
|
||||
children: () => renderFormulaValue(highlightValue)
|
||||
}}
|
||||
onClick={this.openFormulaPickerModal}
|
||||
|
@ -4,24 +4,18 @@
|
||||
|
||||
import React from 'react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import isString from 'lodash/isString';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
Icon,
|
||||
isExpression,
|
||||
render as amisRender,
|
||||
TooltipWrapper,
|
||||
FormItem
|
||||
} from 'amis';
|
||||
import {autobind, FormControlProps, Schema} from 'amis-core';
|
||||
import {FormulaEditor} from 'amis-ui/lib/components/formula/Editor';
|
||||
import FormulaPicker from './FormulaPicker';
|
||||
import CodeMirrorEditor from 'amis-ui/lib/components/CodeMirror';
|
||||
import {FormulaPlugin, editorFactory} from './plugin';
|
||||
|
||||
export function renderFormulaValue(item: any) {
|
||||
const html = {__html: item.html};
|
||||
// bca-disable-next-line
|
||||
return <span dangerouslySetInnerHTML={html}></span>;
|
||||
}
|
||||
import FormulaPicker from './FormulaPicker';
|
||||
import debounce from 'lodash/debounce';
|
||||
import CodeMirror from 'codemirror';
|
||||
|
||||
export interface TextareaFormulaControlProps extends FormControlProps {
|
||||
height?: number; // 输入框的高度
|
||||
@ -44,19 +38,10 @@ interface TextareaFormulaControlState {
|
||||
|
||||
formulaPickerValue: string; // 公式编辑器内容
|
||||
|
||||
formulaPickerReplaceIdx: number; // 替换表达式的索引,-1代表新增表达式
|
||||
|
||||
expressionList: string[]; // value中包含的表达式列表
|
||||
|
||||
cursorStartOffset: number; // 光标偏移量
|
||||
|
||||
cursorRangeText: string; // 光标所处的文本
|
||||
expressionBrace?: Array<CodeMirror.Position>; // 表达式所在位置
|
||||
}
|
||||
|
||||
// 用于替换现有表达式
|
||||
const REPLACE_KEY = 'TEXTAREA_FORMULA_REPLACE_KEY';
|
||||
|
||||
export default class TextareaFormulaControl extends React.Component<
|
||||
export class TextareaFormulaControl extends React.Component<
|
||||
TextareaFormulaControlProps,
|
||||
TextareaFormulaControlState
|
||||
> {
|
||||
@ -66,22 +51,18 @@ export default class TextareaFormulaControl extends React.Component<
|
||||
|
||||
isUnmount: boolean;
|
||||
|
||||
inputRef: any;
|
||||
|
||||
wrapRef: any;
|
||||
|
||||
editorPlugin?: FormulaPlugin;
|
||||
|
||||
constructor(props: TextareaFormulaControlProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: '',
|
||||
variables: [],
|
||||
menusList: [],
|
||||
expressionList: [],
|
||||
formulaPickerOpen: false,
|
||||
formulaPickerValue: '',
|
||||
formulaPickerReplaceIdx: -1,
|
||||
cursorStartOffset: 0,
|
||||
cursorRangeText: ''
|
||||
formulaPickerValue: ''
|
||||
};
|
||||
}
|
||||
|
||||
@ -95,7 +76,7 @@ export default class TextareaFormulaControl extends React.Component<
|
||||
this.setState({
|
||||
formulaPickerOpen: true,
|
||||
formulaPickerValue: '',
|
||||
formulaPickerReplaceIdx: -1
|
||||
expressionBrace: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -103,7 +84,6 @@ export default class TextareaFormulaControl extends React.Component<
|
||||
this.setState({
|
||||
menusList: [...menusList, ...additionalMenus]
|
||||
});
|
||||
this.initExpression(value);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: TextareaFormulaControlProps) {
|
||||
@ -121,10 +101,6 @@ export default class TextareaFormulaControl extends React.Component<
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (prevProps.value !== this.props.value) {
|
||||
this.initExpression(this.props.value);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.isUnmount = true;
|
||||
@ -145,43 +121,14 @@ export default class TextareaFormulaControl extends React.Component<
|
||||
}
|
||||
|
||||
@autobind
|
||||
initExpression(value: string) {
|
||||
let replacedValue = '';
|
||||
const expressionList: string[] = [];
|
||||
if (value && typeof value === 'string') {
|
||||
// 先把 \${ 转成 \_&{ 方便后面正则处理
|
||||
value = value.replace(/\\\${/g, '\\_&{');
|
||||
|
||||
replacedValue = value.replace(/\${([^{}]*)}/g, (match: string, p1: string) => {
|
||||
expressionList.push(p1);
|
||||
return REPLACE_KEY;
|
||||
});
|
||||
replacedValue = replacedValue.replace(/\\_\&{/g, '${');
|
||||
}
|
||||
onExpressionClick(expression: string, brace: Array<CodeMirror.Position>) {
|
||||
this.setState({
|
||||
expressionList,
|
||||
value: replacedValue
|
||||
formulaPickerValue: expression,
|
||||
formulaPickerOpen: true,
|
||||
expressionBrace: brace
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前输入框中的值转化成最终的值
|
||||
*/
|
||||
@autobind
|
||||
revertFinalValue(inputValue: any): any {
|
||||
// 将 ${xx}(非 \${xx})替换成 \${xx},手动编辑时,自动处理掉 ${xx},避免识别成 公式表达式
|
||||
if (inputValue && isString(inputValue) && (isExpression(inputValue) || inputValue.includes('${}'))) {
|
||||
inputValue = inputValue.replace(/(^|[^\\])\$\{/g, '\\${');
|
||||
}
|
||||
|
||||
// 将表达式转化成对应的表达式
|
||||
const reg = /<div class="ae-TextareaResultBox-expression"(.*?)>表达式.*?<\/div>/g;
|
||||
inputValue = inputValue.replace(reg, (match: string) => (
|
||||
match.replace(/.*data-expression="(.*?)".*/g, (match: string, p1: string) => p1 ? `\${${p1}}` : '')
|
||||
));
|
||||
return inputValue;
|
||||
}
|
||||
|
||||
@autobind
|
||||
closeFormulaPicker() {
|
||||
this.setState({formulaPickerOpen: false});
|
||||
@ -189,160 +136,32 @@ export default class TextareaFormulaControl extends React.Component<
|
||||
|
||||
@autobind
|
||||
handleConfirm(value: any) {
|
||||
const {formulaPickerReplaceIdx, cursorStartOffset, cursorRangeText} = this.state;
|
||||
// 去除可能包裹的最外层的${}
|
||||
const {expressionBrace} = this.state;
|
||||
// // 去除可能包裹的最外层的${}
|
||||
value = value.replace(/^\$\{(.*)\}$/, (match: string, p1: string) => p1);
|
||||
// 获取焦点
|
||||
this.inputRef?.focus();
|
||||
// 替换表达式
|
||||
if (~formulaPickerReplaceIdx) {
|
||||
this.replaceExpression(formulaPickerReplaceIdx, value);
|
||||
} else if (value) {
|
||||
if (cursorRangeText && this.inputRef) {
|
||||
let innerHTML = this.inputRef.innerHTML;
|
||||
const cursorIndex = innerHTML.indexOf(cursorRangeText) + cursorStartOffset;
|
||||
// 将表达式通过__&[[]]进行包裹,使用${}会被转成\${}
|
||||
const formula = `__&[[${value}]]`;
|
||||
// 在光标位置进行添加
|
||||
innerHTML = innerHTML.slice(0, cursorIndex) + formula + innerHTML.slice(cursorIndex);
|
||||
value = this.revertFinalValue(innerHTML);
|
||||
// 将__&[[]]转化为${}
|
||||
value = value.replace(/__\&\[\[(.*)\]\]/, (match: string, p1: string) => `\${${p1}}`)
|
||||
} else { // 添加到最后
|
||||
const formula = `\${${value.replace(/^\$\{(.*)\}$/,(match: string, p1: string) => p1)}}`;
|
||||
|
||||
// 多加一个空格避免部分浏览器不能再表达式后面输入的问题
|
||||
value = this.props.value + formula + ' ';
|
||||
}
|
||||
this.props?.onChange?.(value);
|
||||
setTimeout(() => {
|
||||
const selection = getSelection();
|
||||
selection?.selectAllChildren(this.inputRef);
|
||||
selection?.collapseToEnd();
|
||||
value = value ? `\${${value}}` : value;
|
||||
this.editorPlugin?.insertContent(value, 'expression', expressionBrace);
|
||||
this.setState({
|
||||
formulaPickerOpen: false,
|
||||
expressionBrace: undefined
|
||||
});
|
||||
}
|
||||
|
||||
this.closeFormulaPicker();
|
||||
}
|
||||
|
||||
@autobind
|
||||
handleTextareaBlur(e: React.FocusEvent<HTMLElement>) {
|
||||
this.recordLastSelectionRange();
|
||||
let inputValue = e.currentTarget.innerHTML?.trim();
|
||||
inputValue = inputValue.replace(/(\<br>)|(\ )|(\ )/g, '');
|
||||
|
||||
const curValue = this.revertFinalValue(inputValue);
|
||||
if (curValue !== this.props.value) {
|
||||
this.props?.onChange?.(curValue);
|
||||
}
|
||||
}
|
||||
handleOnChange = debounce((value: any) => {
|
||||
this.props.onChange?.(value);
|
||||
}, 1000)
|
||||
|
||||
@autobind
|
||||
handleTextareaKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
||||
// 不支持输入回车键,因为回车键在不同浏览器重表现不同,有的会加上<div>标签
|
||||
if (e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
editorFactory(dom: HTMLElement, cm: any) {
|
||||
const variables = this.props.variables || this.state.variables;
|
||||
return editorFactory(dom, cm, {...this.props, variables});
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
replaceExpression(index: number, value: string = '') {
|
||||
const {expressionList} = this.state;
|
||||
expressionList.splice(index, 1, value);
|
||||
this.setState({expressionList});
|
||||
// 组件更新完后再更新value
|
||||
setTimeout(() => {
|
||||
const curValue = this.revertFinalValue(this.inputRef.innerHTML);
|
||||
this.props?.onChange(curValue);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@autobind
|
||||
removeExpression(e: React.MouseEvent<HTMLElement>, idx: number) {
|
||||
e.stopPropagation();
|
||||
this.replaceExpression(idx);
|
||||
}
|
||||
|
||||
// 记录失焦时的光标位置
|
||||
@autobind
|
||||
recordLastSelectionRange() {
|
||||
const selection = getSelection();
|
||||
const lastEditRange = selection?.getRangeAt(0);
|
||||
const startContainer: any = lastEditRange?.startContainer;
|
||||
let cursorStartOffset = 0;
|
||||
let cursorRangeText = '';
|
||||
if (startContainer?.parentNode?.className === 'ae-TextareaResultBox-input') {
|
||||
cursorStartOffset = lastEditRange?.startOffset || 0;
|
||||
cursorRangeText = startContainer?.data;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
cursorStartOffset,
|
||||
cursorRangeText
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
renderExpressionItem(value: string, idx: number) {
|
||||
const highlightValue = FormulaEditor.highlightValue(value, this.state.variables) || {
|
||||
html: value
|
||||
};
|
||||
return (
|
||||
<TooltipWrapper
|
||||
trigger="hover"
|
||||
placement="bottom"
|
||||
key={value + idx}
|
||||
tooltip={{
|
||||
children: () => renderFormulaValue(highlightValue)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="ae-TextareaResultBox-expression"
|
||||
contentEditable={false}
|
||||
data-expression={value}
|
||||
onClick={(e) => {
|
||||
this.setState({
|
||||
formulaPickerOpen: true,
|
||||
formulaPickerValue: value || '',
|
||||
formulaPickerReplaceIdx: idx
|
||||
})
|
||||
}}
|
||||
>
|
||||
表达式
|
||||
<Icon
|
||||
icon="close"
|
||||
className="icon"
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => this.removeExpression(e, idx)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@autobind
|
||||
getTextareaViewValue(value: string, expressionList: string[] = []) {
|
||||
let replaceStartIdx = value.indexOf(REPLACE_KEY);
|
||||
let idx = 0;
|
||||
let result: any = [];
|
||||
while(~replaceStartIdx) {
|
||||
const preStr = value.slice(0, replaceStartIdx);
|
||||
value = value.slice(replaceStartIdx + REPLACE_KEY.length);
|
||||
replaceStartIdx = value.indexOf(REPLACE_KEY);
|
||||
if (preStr) {
|
||||
result.push(preStr);
|
||||
}
|
||||
result.push(this.renderExpressionItem(expressionList[idx], idx));
|
||||
idx++;
|
||||
}
|
||||
if (value) {
|
||||
result.push(value);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{result.map((item: string | React.ReactNode) => item)}
|
||||
</>
|
||||
);
|
||||
handleEditorMounted(cm: any, editor: any) {
|
||||
const variables = this.props.variables || this.state.variables;
|
||||
this.editorPlugin = new FormulaPlugin(editor, cm, () => ({...this.props, variables}), this.onExpressionClick);
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -354,9 +173,7 @@ export default class TextareaFormulaControl extends React.Component<
|
||||
height,
|
||||
...rest
|
||||
} = this.props;
|
||||
const {value, expressionList, menusList, formulaPickerOpen, formulaPickerValue} = this.state;
|
||||
|
||||
const textareaValues = this.getTextareaViewValue(value, expressionList);
|
||||
const {value, menusList, formulaPickerOpen, formulaPickerValue} = this.state;
|
||||
|
||||
const variables = rest.variables || this.state.variables || [];
|
||||
|
||||
@ -370,17 +187,13 @@ export default class TextareaFormulaControl extends React.Component<
|
||||
<div className={cx('ae-TextareaFormulaControl')} ref={(ref: any) => this.wrapRef = ref}>
|
||||
<div className='ae-TextareaResultBox' style={resultBoxStyle}>
|
||||
<div className="ae-TextareaResultBox-content">
|
||||
<div
|
||||
key={value}
|
||||
className='ae-TextareaResultBox-input'
|
||||
ref={(ref: any) => this.inputRef = ref}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onBlur={this.handleTextareaBlur}
|
||||
onKeyDown={this.handleTextareaKeyDown}
|
||||
>
|
||||
{textareaValues}
|
||||
</div>
|
||||
<CodeMirrorEditor
|
||||
className="ae-TextareaResultBox-editor"
|
||||
value={value}
|
||||
onChange={this.handleOnChange}
|
||||
editorFactory={this.editorFactory}
|
||||
editorDidMount={this.handleEditorMounted}
|
||||
/>
|
||||
{amisRender({
|
||||
type: 'dropdown-button',
|
||||
className: 'ae-TextareaResultBox-dropdown',
|
||||
@ -418,4 +231,4 @@ export default class TextareaFormulaControl extends React.Component<
|
||||
@FormItem({
|
||||
type: 'ae-textareaFormulaControl'
|
||||
})
|
||||
export class TextareaFormulaControlRenderer extends TextareaFormulaControl {}
|
||||
export default class TextareaFormulaControlRenderer extends TextareaFormulaControl {}
|
||||
|
188
packages/amis-editor/src/renderer/textarea-formula/plugin.tsx
Normal file
188
packages/amis-editor/src/renderer/textarea-formula/plugin.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @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
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 `${`、`}` 括号的位置,如 ${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 = () => {
|
||||
this.onExpressionClick(expression, [from, to]);
|
||||
}
|
||||
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() {}
|
||||
}
|
Loading…
Reference in New Issue
Block a user