refactor(client): change Variable.TextArea to controlled component (#1605)

* refactor(client): change Variable.TextArea to controlled component

* fix(client): fix space and range index

* fix(client): fix ime state in Variable.TextArea

* chore(ci): trigger ci

* fix(client): fix expression validation

* fix(plugin-workflow): fix test case

* fix(client): fix caret when not focused

* fix(client): add html-sanitize

* fix(client): fix range errors on any input

* fix(client): fix start and end range
This commit is contained in:
Junyi 2023-04-03 07:57:19 +07:00 committed by GitHub
parent 86abc251d9
commit 7036879ea6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 198 additions and 67 deletions

View File

@ -25,7 +25,7 @@ export default defineConfig({
resolveNocobasePackagesAlias(memo);
// 在引入 mermaid 之后,运行 yarn dev 的时候会报错,添加下面的代码可以解决。
memo.module.rule('js-in-node_modules').test(/.*mermaid.*\.js$/).include.clear();
memo.module.rule('js-in-node_modules').test(/(htmlparser2|(.*mermaid.*\.js$))/).include.clear();
return memo;
},
});

View File

@ -31,7 +31,6 @@
"mathjs": "^10.6.0",
"react-beautiful-dnd": "^13.1.0",
"react-big-calendar": "^0.38.7",
"react-contenteditable": "^3.3.6",
"react-drag-listview": "^0.1.9",
"react-helmet": "^6.1.0",
"react-hotkeys-hook": "^3.4.7",
@ -42,6 +41,7 @@
"react-quill": "^1.3.5",
"react-router-dom": "^5.2.0",
"react-to-print": "^2.14.7",
"sanitize-html": "2.10.0",
"solarlunar-es": "^1.0.9",
"use-deep-compare-effect": "^1.8.1"
},

View File

@ -3,37 +3,40 @@ import { Input, Cascader, Tooltip, Button } from 'antd';
import { useForm } from '@formily/react';
import { cx, css } from '@emotion/css';
import { useTranslation } from 'react-i18next';
import * as sanitizeHTML from 'sanitize-html';
import { useCompile } from '../..';
type RangeIndexes = [number, number, number, number];
const VARIABLE_RE = /{{\s*([^{}]+)\s*}}/g;
function pasteHtml(container, html, { selectPastedContent = false, range: indexes }) {
function pasteHTML(container: HTMLElement, html: string, { selectPastedContent = false, range: indexes }: { selectPastedContent?: boolean; range?: RangeIndexes } = {}) {
// IE9 and non-IE
const sel = window.getSelection?.();
if (!sel?.getRangeAt || !sel.rangeCount) {
return;
}
const range = sel.getRangeAt(0);
const range = sel?.getRangeAt(0);
if (!range) {
return;
}
const children = Array.from(container.childNodes) as HTMLElement[];
if (indexes[0] === -1) {
if (indexes[1]) {
range.setStartAfter(children[indexes[1] - 1]);
}
} else {
range.setStart(children[indexes[0]], indexes[1]);
}
if (indexes[2] === -1) {
if (indexes[3]) {
range.setEndAfter(children[indexes[3] - 1]);
if (indexes) {
const children = Array.from(container.childNodes);
if (indexes[0] === -1) {
if (indexes[1]) {
range.setStartAfter(children[indexes[1] - 1]);
}
} else {
range.setStart(children[indexes[0]], indexes[1]);
}
if (indexes[2] === -1) {
if (indexes[3]) {
range.setEndAfter(children[indexes[3] - 1]);
}
} else {
range.setEnd(children[indexes[2]], indexes[3]);
}
} else {
range.setEnd(children[indexes[2]], indexes[3]);
}
range.deleteContents();
// Range.createContextualFragment() would be useful here but is
// only relatively recently standardized and is not supported in
@ -47,11 +50,12 @@ function pasteHtml(container, html, { selectPastedContent = false, range: indexe
lastNode = frag.appendChild(el.firstChild);
}
const { firstChild } = frag;
range.deleteContents();
range.insertNode(frag);
// Preserve the selection
if (lastNode) {
const next = range.cloneRange();
const next = new Range();
next.setStartAfter(lastNode);
if (selectPastedContent) {
if (firstChild) {
@ -60,21 +64,21 @@ function pasteHtml(container, html, { selectPastedContent = false, range: indexe
} else {
next.collapse(true);
}
sel.removeAllRanges();
sel.addRange(next);
sel!.removeAllRanges();
sel!.addRange(next);
}
}
function getValue(el) {
const values: any[] = [];
for (const node of el.childNodes) {
if (node.nodeName === 'SPAN') {
values.push(`{{${node['dataset']['key']}}}`);
if (node.nodeName === 'SPAN' && node['dataset']['variable']) {
values.push(`{{${node['dataset']['variable']}}}`);
} else {
values.push(node.textContent?.trim?.());
values.push(node.textContent);
}
}
return values.join(' ').replace(/\s+/g, ' ').trim();
return values.join('');
}
function renderHTML(exp: string, keyLabelMap) {
@ -99,11 +103,75 @@ function createOptionsValueLabelMap(options: any[]) {
function createVariableTagHTML(variable, keyLabelMap) {
const labels = keyLabelMap.get(variable);
return `<span class="ant-tag ant-tag-blue" contentEditable="false" data-key="${variable}">${labels?.join(
return `<span class="ant-tag ant-tag-blue" contentEditable="false" data-variable="${variable}">${labels?.join(
' / ',
)}</span>`;
}
// [#, <>, #, #, <>]
// [#, #]
// [<>, #, #]
// [#, #, <>]
function getSingleEndRange(nodes: ChildNode[], index: number, offset: number): [number, number] {
if (index === -1) {
let realIndex = offset;
let collapseFlag = false;
if (realIndex && nodes[realIndex - 1].nodeName === '#text' && nodes[realIndex]?.nodeName === '#text') {
// set a flag for collapse
collapseFlag = true;
}
let textOffset = 0;
for (let i = offset - 1; i >= 0; i--) {
if (collapseFlag) {
if (nodes[i].nodeName === '#text') {
textOffset += nodes[i].textContent!.length;
} else {
collapseFlag = false;
}
}
if (nodes[i].nodeName === '#text' && nodes[i + 1]?.nodeName === '#text') {
realIndex -= 1;
}
}
return textOffset ? [realIndex, textOffset] : [-1, realIndex];
} else {
let realIndex = 0;
let textOffset = 0;
for (let i = 0; i < index + 1; i++) {
// console.log(i, realIndex, textOffset);
if (nodes[i].nodeName === '#text') {
if (i !== index && nodes[i + 1] && nodes[i + 1].nodeName !== '#text') {
realIndex += 1;
}
textOffset += i === index ? offset : nodes[i].textContent!.length;
} else {
realIndex += 1;
textOffset = 0;
}
}
return [realIndex, textOffset];
}
}
function getCurrentRange(element: HTMLElement): RangeIndexes {
const sel = window.getSelection?.();
const range = sel?.getRangeAt(0);
if (!range) {
return [-1, 0, -1, 0];
}
const nodes = Array.from(element.childNodes);
if (!nodes.length) {
return [-1, 0, -1, 0];
}
const startElementIndex = range.startContainer === element ? -1 : nodes.indexOf(range.startContainer as HTMLElement);
const endElementIndex = range.endContainer === element ? -1 : nodes.indexOf(range.endContainer as HTMLElement);
const result: RangeIndexes = [...getSingleEndRange(nodes, startElementIndex, range.startOffset), ...getSingleEndRange(nodes, endElementIndex, range.endOffset)];
return result;
}
export function TextArea(props) {
const { value = '', scope, onChange, multiline = true, button } = props;
const compile = useCompile();
@ -112,32 +180,57 @@ export function TextArea(props) {
const options = compile((typeof scope === 'function' ? scope() : scope) ?? []);
const form = useForm();
const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [scope]);
const [ime, setIME] = useState<boolean>(false);
const [changed, setChanged] = useState(false);
const [html, setHtml] = useState(() => renderHTML(value ?? '', keyLabelMap));
// [startElementIndex, startOffset, endElementIndex, endOffset]
// NOTE: e.g. [startElementIndex, startOffset, endElementIndex, endOffset]
const [range, setRange] = useState<[number, number, number, number]>([-1, 0, -1, 0]);
useEffect(() => {
if (changed) {
return;
}
setHtml(renderHTML(value ?? '', keyLabelMap));
}, [value]);
useEffect(() => {
const { current } = inputRef;
if (!current || changed) {
if (!current) {
return;
}
const nextRange = new Range();
const { lastChild } = current;
if (lastChild) {
nextRange.setStartAfter(lastChild);
nextRange.setEndAfter(lastChild);
const nodes = Array.from(current.childNodes);
const startElementIndex = nextRange.startContainer === current ? -1 : nodes.indexOf(lastChild);
const endElementIndex = nextRange.startContainer === current ? -1 : nodes.indexOf(lastChild);
setRange([startElementIndex, nextRange.startOffset, endElementIndex, nextRange.endOffset]);
if (changed) {
if (range.join() === '-1,0,-1,0') {
return;
}
const sel = window.getSelection?.();
if (sel) {
const children = Array.from(current.childNodes) as HTMLElement[];
if (range[0] === -1) {
if (range[1]) {
nextRange.setStartAfter(children[range[1] - 1]);
}
} else {
nextRange.setStart(children[range[0]], range[1]);
}
if (range[2] === -1) {
if (range[3]) {
nextRange.setEndAfter(children[range[3] - 1]);
}
} else {
nextRange.setEnd(children[range[2]], range[3]);
}
nextRange.collapse(true);
sel.removeAllRanges();
sel.addRange(nextRange);
}
} else {
const { lastChild } = current;
if (lastChild) {
nextRange.setStartAfter(lastChild);
nextRange.setEndAfter(lastChild);
const nodes = Array.from(current.childNodes);
const startElementIndex = nextRange.startContainer === current ? -1 : nodes.indexOf(lastChild);
const endElementIndex = nextRange.startContainer === current ? -1 : nodes.indexOf(lastChild);
setRange([startElementIndex, nextRange.startOffset, endElementIndex, nextRange.endOffset]);
}
}
}, [html]);
@ -150,31 +243,70 @@ export function TextArea(props) {
current.focus();
pasteHtml(current, createVariableTagHTML(variable.join('.'), keyLabelMap), {
pasteHTML(current, createVariableTagHTML(variable.join('.'), keyLabelMap), {
range,
});
setChanged(true);
setRange(getCurrentRange(current));
onChange(getValue(current));
}
function onInput({ currentTarget }) {
if (ime) {
return;
}
setChanged(true);
setRange(getCurrentRange(currentTarget));
onChange(getValue(currentTarget));
}
function onBlur({ currentTarget }) {
const sel = window.getSelection?.();
if (!sel?.getRangeAt || !sel.rangeCount) {
return;
}
const r = sel.getRangeAt(0);
const nodes = Array.from(currentTarget.childNodes);
const startElementIndex = nodes.indexOf(r.startContainer);
const endElementIndex = nodes.indexOf(r.endContainer);
setRange([startElementIndex, r.startOffset, endElementIndex, r.endOffset]);
setRange(getCurrentRange(currentTarget));
}
function onKeyDown(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
}
setIME(ev.keyCode === 229);
// if (ev.key === 'Control') {
// console.debug(getSelection().getRangeAt(0));
// }
// if (ev.key === 'Alt') {
// console.debug(getCurrentRange(ev.currentTarget));
// }
}
function onPaste(ev) {
ev.preventDefault();
const input = ev.clipboardData.getData('text/html') || ev.clipboardData.getData('text');
const sanitizedHTML = sanitizeHTML(input, {
allowedTags: ['span'],
allowedAttributes: {
span: ['data-variable', 'contenteditable'],
},
allowedClasses: {
span: ['ant-tag', 'ant-tag-*'],
},
transformTags: {
span(tagName, attribs) {
return attribs['data-variable'] ? {
tagName: tagName,
attribs,
} : {};
}
}
}).replace(/\n/g, ' ');
// ev.clipboardData.setData('text/html', sanitizedHTML);
// console.log(input, sanitizedHTML);
setChanged(true);
pasteHTML(ev.currentTarget, sanitizedHTML);
setRange(getCurrentRange(ev.currentTarget));
onChange(getValue(ev.currentTarget));
}
const disabled = props.disabled || form.disabled;
return (
@ -199,11 +331,8 @@ export function TextArea(props) {
<div
onInput={onInput}
onBlur={onBlur}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
}
}}
onKeyDown={onKeyDown}
onPaste={onPaste}
className={cx(
'ant-input',
{ 'ant-input-disabled': disabled },

View File

@ -46,16 +46,18 @@ export function evaluate(this: Evaluator, expression: string, scope: Scope = {})
const item = get(context, v);
let result;
if (item == null) {
return 'null';
result = 'null';
} else if (typeof item === 'function') {
result = item();
result = typeof result === 'string' ? `'${result.replace(/'/g, "\\'")}'` : result;
} else {
result = replaceNumberIndex(v, context);
}
if (typeof item === 'function') {
const result = item();
return typeof result === 'string' ? `'${result.replace(/'/g, "\\'")}'` : result;
}
return replaceNumberIndex(v, context);
return ` ${result} `;
});
return this(exp, context);
}

View File

@ -39,7 +39,7 @@ export default {
['x-validator'](value, rules, { form }) {
const { values } = form;
const { evaluate } = evaluators.get(values.engine) as Evaluator;
const exp = value.trim().replace(/{{([^{}]+)}}/g, '1');
const exp = value.trim().replace(/{{([^{}]+)}}/g, ' 1 ');
try {
evaluate(exp);
return '';

View File

@ -363,7 +363,7 @@ export default {
['x-validator'](value, rules, { form }) {
const { values } = form;
const { evaluate } = evaluators.get(values.engine);
const exp = value.trim().replace(/{{([^{}]+)}}/g, '1');
const exp = value.trim().replace(/{{([^{}]+)}}/g, ' 1 ');
try {
evaluate(exp);
return '';

View File

@ -148,7 +148,7 @@ describe('workflow > instructions > calculation', () => {
const [execution] = await workflow.getExecutions();
const [job] = await execution.getJobs();
expect(job.result).toBe('a$context.data.title');
expect(job.result).toBe('a $context.data.title ');
});
it('text', async () => {