diff --git a/packages/app/client/.umirc.ts b/packages/app/client/.umirc.ts index 7ff290b04..a38010435 100644 --- a/packages/app/client/.umirc.ts +++ b/packages/app/client/.umirc.ts @@ -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; }, }); diff --git a/packages/core/client/package.json b/packages/core/client/package.json index 5443258a3..a65c3da3e 100644 --- a/packages/core/client/package.json +++ b/packages/core/client/package.json @@ -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" }, diff --git a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx index 22aaee234..9fa6ddd4d 100644 --- a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx +++ b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx @@ -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 `${labels?.join( + return `${labels?.join( ' / ', )}`; } +// [#, <>, #, #, <>] +// [#, #] +// [<>, #, #] +// [#, #, <>] +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(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) {
{ - if (e.key === 'Enter') { - e.preventDefault(); - } - }} + onKeyDown={onKeyDown} + onPaste={onPaste} className={cx( 'ant-input', { 'ant-input-disabled': disabled }, diff --git a/packages/core/evaluators/src/utils/index.ts b/packages/core/evaluators/src/utils/index.ts index feca86d55..de20ff22a 100644 --- a/packages/core/evaluators/src/utils/index.ts +++ b/packages/core/evaluators/src/utils/index.ts @@ -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); } diff --git a/packages/plugins/workflow/src/client/nodes/calculation.tsx b/packages/plugins/workflow/src/client/nodes/calculation.tsx index 77f5dbebc..1de6adf6a 100644 --- a/packages/plugins/workflow/src/client/nodes/calculation.tsx +++ b/packages/plugins/workflow/src/client/nodes/calculation.tsx @@ -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 ''; diff --git a/packages/plugins/workflow/src/client/nodes/condition.tsx b/packages/plugins/workflow/src/client/nodes/condition.tsx index 1408a268a..6f70db06b 100644 --- a/packages/plugins/workflow/src/client/nodes/condition.tsx +++ b/packages/plugins/workflow/src/client/nodes/condition.tsx @@ -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 ''; diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts index 77b35c025..42c14b479 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts @@ -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 () => {