feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render } from '@testing-library/react'
import { Popover } from '../popover'
vi.mock('../popover/hooks/use-tree', () => {
return {
useTreeRefresh() {},
useTreeSearch() {},
}
});
vi.mock('@coze-arch/bot-semi', async () => {
const { forwardRef } = await vi.importActual('react') as any;
return {
Popover({ content }) {
return <div>{content}</div>
},
Tree: forwardRef((_, ref) => {
return <div ref={ref}></div>
})
}
})
vi.mock('@coze-editor/editor', () => {
return {
mixLanguages() {},
astDecorator: {
whole: {
of() {}
},
fromCursor: {
of() {}
},
},
};
});
vi.mock('@coze-editor/editor/react', () => {
return {
Renderer() {},
CursorMirror() {
return null;
},
SelectionSide: {
Head: 'head',
Anchor: 'anchor',
},
useEditor() {
return {
disableKeybindings() {},
$on() {},
$off() {},
replaceTextByRange() {},
$view: {
state: {
selection: {
main: {
from: 0,
to: 0,
anchor: 0,
head: 0
}
}
}
}
};
},
};
});
vi.mock('@coze-editor/editor/preset-expression', () => {
return {
default: []
};
});
vi.mock('@/expression-editor', () => ({}));
describe('popover', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('Should render props.className correctly', () => {
const { container } = render(<Popover variableTree={[]} className='foo' />)
const elements = container.querySelectorAll('.foo')
expect(elements.length).toBe(1)
})
})

View File

@@ -0,0 +1,189 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import { render } from '@testing-library/react';
import { Renderer } from '../renderer';
const { SDKRenderer } = vi.hoisted(() => ({ SDKRenderer: vi.fn() }));
vi.mock('@coze-editor/editor', () => {
return {
mixLanguages() {},
astDecorator: {
whole: {
of() {},
},
fromCursor: {
of() {},
},
},
};
});
vi.mock('@coze-editor/editor/react', () => {
return {
Renderer: SDKRenderer,
};
});
vi.mock('@coze-editor/editor/preset-expression', () => {
return {
default: [],
};
});
vi.mock('@/expression-editor', () => ({}));
describe('renderer', () => {
beforeEach(() => {
SDKRenderer.mockImplementation(({ defaultValue, didMount }) => {
useEffect(() => {
didMount({
getValue() {},
setValue() {},
updateWholeDecorations() {},
});
}, []);
return null;
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('Should props.className work correctly', () => {
render(<Renderer variableTree={[]} className="foo" />);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
options: {
contentAttributes: {
class: 'foo flow-canvas-not-draggable',
},
},
});
});
it('Should props.dataTestID work correctly', () => {
render(<Renderer variableTree={[]} dataTestID="foo" />);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
options: {
contentAttributes: {
'data-testid': 'foo',
},
},
});
});
it('Should props.placeholder work correctly', () => {
render(<Renderer variableTree={[]} placeholder="foo" />);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
options: {
placeholder: 'foo',
},
});
});
it('Should props.value work correctly', () => {
let value = '';
const getValue = () => value;
const setValue = vi.fn();
SDKRenderer.mockImplementation(({ defaultValue, didMount }) => {
useEffect(() => {
value = defaultValue;
didMount({
getValue,
setValue,
updateWholeDecorations() {},
});
}, []);
return null;
});
const { rerender } = render(<Renderer variableTree={[]} value="value" />);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
defaultValue: 'value',
});
rerender(<Renderer variableTree={[]} value="value2" />);
expect(setValue).toHaveBeenCalledTimes(1);
expect(setValue).toHaveBeenLastCalledWith('value2');
});
it('Should props.onChange work correctly', () => {
let change: ((e: { value: string }) => void) | null = null;
SDKRenderer.mockImplementation(({ onChange, didMount }) => {
change = onChange;
useEffect(() => {
didMount({
getValue() {},
setValue() {},
updateWholeDecorations() {},
});
}, []);
});
const onChange = vi.fn();
render(<Renderer variableTree={[]} onChange={onChange} />);
change!({
value: 'foo',
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith('foo');
change!({
value: 'bar',
});
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith('bar');
});
it('Should props.readonly work correctly', () => {
const { rerender } = render(<Renderer variableTree={[]} readonly={true} />);
expect(SDKRenderer).toHaveBeenCalledTimes(1);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
options: {
readOnly: true,
},
});
rerender(<Renderer variableTree={[]} readonly={false} />);
expect(SDKRenderer).toHaveBeenCalledTimes(2);
// @ts-expect-error -- mock
expect(SDKRenderer.mock.lastCall[0]).toMatchObject({
options: {
readOnly: false,
},
});
});
});

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EditorProvider } from '@coze-editor/editor/react';
import { Renderer } from './renderer';
import { Popover } from './popover';
// eslint-disable-next-line @typescript-eslint/naming-convention
const Expression = {
Renderer,
Popover,
EditorProvider,
};
export { Expression };

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { useInterpolationContent } from './use-interpolation-content';
export { useEmptyContent } from './use-empty-content';
export { usePrunedVariableTree } from './use-pruned-variable-tree';
export { useFilteredVariableTree } from './use-filtered-variable-tree';
export { useFocused } from './use-focused';
export { useTreeRefresh, useTreeSearch } from './use-tree';
export { useKeyboard } from './use-keyboard';
export { useSelection } from './use-selection';
export { useOptionsOperations } from './use-options-operations';
export { useSelectedValue } from './use-selected-value';

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
interface InterpolationContent {
from: number;
to: number;
text: string;
offset: number;
textBefore: string;
}
export type { InterpolationContent };

View File

@@ -0,0 +1,79 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { I18n } from '@coze-arch/i18n';
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '@/expression-editor';
import { type InterpolationContent } from './types';
function isEmpty(value: unknown) {
return !value || !Array.isArray(value) || value.length === 0;
}
function useEmptyContent(
fullVariableTree: ExpressionEditorTreeNode[] | undefined,
variableTree: ExpressionEditorTreeNode[] | undefined,
interpolationContent: InterpolationContent | undefined,
) {
return useMemo(() => {
if (!interpolationContent) {
return;
}
if (isEmpty(fullVariableTree)) {
if (interpolationContent.textBefore === '') {
return I18n.t('workflow_variable_refer_no_input');
}
return;
}
if (isEmpty(variableTree)) {
if (interpolationContent.text === '') {
return I18n.t('workflow_variable_refer_no_input');
}
const segments = ExpressionEditorParserBuiltin.toSegments(
interpolationContent.textBefore,
);
if (!segments) {
return;
}
const matchTreeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree: fullVariableTree ?? [],
segments,
});
const isMatchedButEmpty = matchTreeBranch && matchTreeBranch.length !== 0;
if (isMatchedButEmpty) {
return I18n.t('workflow_variable_refer_no_sub_variable');
}
return;
}
return;
}, [fullVariableTree, variableTree, interpolationContent]);
}
export { useEmptyContent };

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { type ExpressionEditorTreeNode } from '@/expression-editor';
import { getSearchValue } from '../../shared';
import { type InterpolationContent } from './types';
function useFilteredVariableTree(
interpolationContent: InterpolationContent | undefined,
prunedVariableTree: ExpressionEditorTreeNode[],
) {
return useMemo(() => {
if (!prunedVariableTree) {
return [];
}
if (!interpolationContent) {
return prunedVariableTree;
}
const searchValue = getSearchValue(interpolationContent.textBefore);
return prunedVariableTree.filter(variable =>
variable.label.startsWith(searchValue),
);
}, [interpolationContent, prunedVariableTree]);
}
export { useFilteredVariableTree };

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
function useFocused(editor) {
const [focused, setFocused] = useState(false);
useEffect(() => {
if (!editor) {
return;
}
function handleFocus() {
setFocused(true);
}
function handleBlur() {
setFocused(false);
}
editor.$on('focus', handleFocus);
editor.$on('blur', handleBlur);
return () => {
editor.$off('focus', handleFocus);
editor.$off('blur', handleBlur);
};
}, [editor]);
return focused;
}
export { useFocused };

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { type EditorView } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { type InterpolationContent } from './types';
function getInterpolationContentAtPos(view: EditorView, pos: number) {
const tree = syntaxTree(view.state);
const cursor = tree.cursorAt(pos);
do {
if (
cursor.node.type.name === 'Interpolation' &&
cursor.node.firstChild &&
cursor.node.lastChild &&
pos >= cursor.node.firstChild.to &&
pos <= cursor.node.lastChild.from
) {
const text = view.state.sliceDoc(
cursor.node.firstChild.to,
cursor.node.lastChild.from,
);
const offset = pos - cursor.node.firstChild.to;
return {
from: cursor.node.firstChild.to,
to: cursor.node.lastChild.from,
text,
offset,
textBefore: text.slice(0, offset),
};
}
} while (cursor.parent());
}
function useInterpolationContent(
editor: ExpressionEditorAPI | undefined,
pos: number | undefined,
): InterpolationContent | undefined {
return useMemo(() => {
if (!editor || typeof pos === 'undefined') {
return;
}
const view = editor.$view;
return getInterpolationContentAtPos(view, pos);
}, [editor, pos]);
}
export { useInterpolationContent };

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import { useLatest } from '../../shared';
type Keymap = Record<string, (e: KeyboardEvent) => void>;
function useKeyboard(enable: boolean, keymap: Keymap) {
const keymapRef = useLatest(keymap);
useEffect(() => {
if (!enable) {
return;
}
function handleKeydown(e) {
const callback = keymapRef.current[e.key];
if (typeof callback === 'function') {
callback(e);
}
}
document.addEventListener('keydown', handleKeydown, false);
return () => {
document.removeEventListener('keydown', handleKeydown, false);
};
}, [enable]);
}
export { useKeyboard };

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { applyNode, getOptionInfoFromDOM, selectNodeByIndex } from '../shared';
import { useLatest } from '../../shared';
import { type InterpolationContent } from './types';
// eslint-disable-next-line max-params
function useOptionsOperations(
editor: ExpressionEditorAPI | undefined,
interpolationContent: InterpolationContent | undefined,
treeContainerRef,
treeRef,
) {
const editorRef = useLatest(editor);
const interpolationContentRef = useLatest(interpolationContent);
return useMemo(() => {
function prev() {
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
if (!optionsInfo) {
return;
}
const { elements, selectedIndex } = optionsInfo;
if (elements.length === 1) {
return;
}
const newIndex =
selectedIndex - 1 < 0 ? elements.length - 1 : selectedIndex - 1;
selectNodeByIndex(elements, newIndex);
}
function next() {
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
if (!optionsInfo) {
return;
}
const { elements, selectedIndex } = optionsInfo;
const newIndex =
selectedIndex + 1 >= elements.length ? 0 : selectedIndex + 1;
selectNodeByIndex(elements, newIndex);
}
function apply() {
if (!interpolationContentRef.current) {
return;
}
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
if (!optionsInfo) {
return;
}
const { selectedElement } = optionsInfo;
const selectedDataKey = selectedElement?.getAttribute('data-key');
if (!selectedDataKey) {
return;
}
const variableTreeNode =
treeRef.current?.state?.keyEntities?.[selectedDataKey]?.data;
if (!variableTreeNode) {
return;
}
applyNode(
editorRef.current,
variableTreeNode,
interpolationContentRef.current,
);
}
return {
prev,
next,
apply,
};
}, []);
}
export { useOptionsOperations };

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '@/expression-editor';
import { type InterpolationContent } from './types';
function usePrunedVariableTree(
editor: ExpressionEditorAPI | undefined,
variableTree: ExpressionEditorTreeNode[],
interpolationContent: InterpolationContent | undefined,
): ExpressionEditorTreeNode[] {
return useMemo(() => {
if (!editor || !interpolationContent) {
return [];
}
const segments = ExpressionEditorParserBuiltin.toSegments(
interpolationContent.textBefore,
);
if (!segments) {
return [];
}
const prunedVariableTree = ExpressionEditorTreeHelper.pruning({
tree: variableTree,
segments,
});
return prunedVariableTree;
}, [editor, variableTree, interpolationContent]);
}
export { usePrunedVariableTree };

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo } from 'react';
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '@/expression-editor';
function useSelectedValue(
interpolationText: string | undefined,
variableTree: ExpressionEditorTreeNode[],
) {
return useMemo(() => {
if (!interpolationText) {
return;
}
const segments =
ExpressionEditorParserBuiltin.toSegments(interpolationText);
if (!segments) {
return;
}
const treeBrach = ExpressionEditorTreeHelper.matchTreeBranch({
tree: variableTree,
segments,
});
if (!treeBrach) {
return;
}
return treeBrach[treeBrach.length - 1];
}, [interpolationText, variableTree]);
}
export { useSelectedValue };

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { type ViewUpdate } from '@codemirror/view';
import { isSkipSelectionChangeUserEvent } from '../shared';
import { useLatest } from '../../shared';
interface Selection {
anchor: number;
head: number;
from: number;
to: number;
}
function isSameSelection(a?: Selection, b?: Selection) {
if (!a && !b) {
return true;
}
return (
a &&
b &&
a.anchor === b.anchor &&
a.head === b.head &&
a.from === b.from &&
a.to === b.to
);
}
function useSelection(editor: ExpressionEditorAPI | undefined) {
const [selection, setSelection] = useState<Selection | undefined>();
const selectionRef = useLatest(selection);
useEffect(() => {
if (!editor) {
return;
}
const view = editor.$view;
function updateSelection(update?: ViewUpdate) {
// 忽略 replaceTextByRange 导致的 selection change效果不触发 selection 变更,进而不显示推荐面板)
if (update?.transactions.some(tr => isSkipSelectionChangeUserEvent(tr))) {
setSelection(undefined);
return;
}
const { from, to, anchor, head } = view.state.selection.main;
const newSelection = { from, to, anchor, head };
if (isSameSelection(newSelection, selectionRef.current)) {
return;
}
setSelection({ from, to, anchor, head });
}
function handleSelectionChange(e: { update: ViewUpdate }) {
updateSelection(e.update);
}
function handleFocus() {
updateSelection();
}
editor.$on('selectionChange', handleSelectionChange);
editor.$on('focus', handleFocus);
return () => {
editor.$off('selectionChange', handleSelectionChange);
editor.$off('focus', handleFocus);
};
}, [editor]);
return selection;
}
export { useSelection };

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect, type MutableRefObject } from 'react';
import { type Tree } from '@coze-arch/bot-semi';
import { type ExpressionEditorTreeNode } from '@/expression-editor';
import { generateUniqueId, getSearchValue, useLatest } from '../../shared';
import { type InterpolationContent } from './types';
// 在数据更新后,强制 Tree 组件重新渲染
function useTreeRefresh(filteredVariableTree: ExpressionEditorTreeNode[]) {
const [treeRefreshKey, setTreeRefreshKey] = useState('');
useEffect(() => {
setTreeRefreshKey(generateUniqueId());
}, [filteredVariableTree]);
return treeRefreshKey;
}
// Tree 组件重新渲染后进行搜索
// eslint-disable-next-line max-params
function useTreeSearch(
treeRefreshKey: string,
treeRef: MutableRefObject<Tree | null>,
interpolationContent: InterpolationContent | undefined,
callback: () => void,
) {
const interpolationContentRef = useLatest(interpolationContent);
useEffect(() => {
if (treeRef.current && interpolationContentRef.current) {
const searchValue = getSearchValue(
interpolationContentRef.current.textBefore,
);
treeRef.current.search(searchValue);
callback();
}
}, [treeRefreshKey, interpolationContent]);
}
export { useTreeRefresh, useTreeSearch };

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Popover } from './popover';

View File

@@ -0,0 +1,146 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable max-nesting-depth */
.expression-editor-suggestion {
z-index: 1000;
overflow: auto;
width: 272px;
max-height: 236px;
background: var(--light-usage-bg-color-bg-3, #FFF);
border: 0.5px solid rgba(153, 182, 255, 12%);
border-radius: 8px;
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
}
.expression-editor-suggestion-empty {
z-index: 1000;
background: #FFF;
border: 0.5px solid rgba(153, 182, 255, 12%);
border-radius: 8px;
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
p {
margin: 4px 6px;
font-size: 12px;
font-weight: 600;
font-style: normal;
line-height: 16px;
color: var(--light-usage-text-color-text-2, rgba(29, 28, 35, 60%));
}
}
.expression-editor-suggestion-tree {
:global {
.semi-tree-search-wrapper {
display: none;
}
.semi-tree-option-list {
width: fit-content;
min-width: 100%;
padding: 4px;
padding-left: 0;
li {
height: 32px;
}
.semi-tree-option {
pointer-events: none;
background-color: transparent;
}
.semi-tree-option-label {
pointer-events: auto;
height: 24px;
margin-right: 4px;
padding: 0 4px;
border-radius: 4px;
&:hover {
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
}
&:active {
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
}
.semi-tree-option-label-text {
display: inline-block;
width: fit-content;
white-space: nowrap;
& span {
display: inline-block;
width: fit-content;
white-space: nowrap;
}
.semi-tree-option-highlight {
color: var(--light-usage-warning-color-warning, #FF9600)
}
}
}
.semi-tree-option-selected {
font-weight: 600;
color: var(--light-usage-primary-color-primary, #4D53E8);
}
.semi-tree-option-disabled {
.semi-tree-option-label {
cursor: not-allowed;
background: transparent;
}
.semi-icon+.semi-tree-option-label {
color: var(--light-usage-text-color-text-0, #1D1C23);
}
}
}
.semi-tree-option-empty-icon {
width: 16px;
}
.semi-tree-option-expand-icon {
pointer-events: auto;
width: 16px;
height: 16px;
margin-right: 0;
padding: 4px;
border-radius: 4px;
&:hover {
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
}
&:active {
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
}
svg {
width: 16px;
height: 16px;
}
}
}
}
.expression-editor-suggestion-keyboard-selected {
:global {
.semi-tree-option-label {
background: var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 8%)) !important;
}
}
}

View File

@@ -0,0 +1,257 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {
type ReactNode,
forwardRef,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import {
CursorMirror,
useEditor,
SelectionSide,
} from '@coze-editor/editor/react';
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { type TreeNodeData } from '@coze-arch/bot-semi/Tree';
import { type PopoverProps } from '@coze-arch/bot-semi/Popover';
import { Popover as SemiPopover, Tree } from '@coze-arch/bot-semi';
import { type ExpressionEditorTreeNode } from '@/expression-editor';
import { generateUniqueId, useDeepEqualMemo, useLatest } from '../shared';
import { applyNode, getOptionInfoFromDOM, selectNodeByIndex } from './shared';
import {
useEmptyContent,
useFilteredVariableTree,
useFocused,
useInterpolationContent,
usePrunedVariableTree,
useSelection,
useTreeRefresh,
useTreeSearch,
useKeyboard,
useOptionsOperations,
useSelectedValue,
} from './hooks';
import styles from './popover.module.less';
interface Props {
getPopupContainer?: PopoverProps['getPopupContainer'];
variableTree: ExpressionEditorTreeNode[];
className?: string;
onVisibilityChange?: (visible: boolean) => void;
}
// eslint-disable-next-line @coze-arch/max-line-per-function
function Popover({
getPopupContainer,
variableTree: vTree,
className,
onVisibilityChange,
}: Props) {
const variableTree = useDeepEqualMemo(vTree);
const treeRef = useRef<Tree | null>(null);
const treeContainerRef = useRef<HTMLDivElement | null>(null);
const onVisibilityChangeRef = useLatest(onVisibilityChange);
const [posKey, setPosKey] = useState('');
const editor = useEditor<ExpressionEditorAPI | undefined>();
const editorRef = useLatest(editor);
const selection = useSelection(editor);
const focused = useFocused(editor);
const interpolationContent = useInterpolationContent(
editor,
selection?.anchor,
);
const prunedVariableTree = usePrunedVariableTree(
editor,
variableTree,
interpolationContent,
);
const filteredVariableTree = useFilteredVariableTree(
interpolationContent,
prunedVariableTree,
);
const emptyContent = useEmptyContent(
variableTree,
prunedVariableTree,
interpolationContent,
);
const treeRefreshKey = useTreeRefresh(filteredVariableTree);
useTreeSearch(treeRefreshKey, treeRef, interpolationContent, () => {
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
if (!optionsInfo) {
return;
}
const { elements } = optionsInfo;
selectNodeByIndex(elements, 0);
});
// selected 仅用于 Tree 组件对应项展示蓝色选中效果,无其他用途
const selected = useSelectedValue(interpolationContent?.text, variableTree);
// 基于用户选中项,替换所在 {{}} 中的内容
const handleSelect = useCallback(
// eslint-disable-next-line @typescript-eslint/naming-convention
(_, __, node: TreeNodeData) => {
if (!editor || !interpolationContent) {
return;
}
applyNode(editor, node as ExpressionEditorTreeNode, interpolationContent);
},
[editor, interpolationContent],
);
const internalVisible =
focused &&
((Boolean(interpolationContent) && filteredVariableTree.length > 0) ||
Boolean(emptyContent));
const [allowVisible, setAllowVisible] = useState(false);
// 选区变化时,清除锁定效果
useEffect(() => {
setAllowVisible(true);
}, [selection]);
const visible = internalVisible && allowVisible;
const { prev, next, apply } = useOptionsOperations(
editor,
interpolationContent,
treeContainerRef,
treeRef,
);
// 上下键切换推荐项,回车填入
useKeyboard(visible, {
ArrowUp: prev,
ArrowDown: next,
Enter: apply,
});
// ESC 关闭
useKeyboard(visible, {
// eslint-disable-next-line @typescript-eslint/naming-convention
Escape() {
setAllowVisible(false);
},
});
// 推荐面板出现时,禁用 ArrowUp/ArrowDown/Enter 的默认行为(行为改为上下键切换推荐项 & 回车插入)
useEffect(() => {
if (visible === true) {
editorRef.current?.disableKeybindings(['ArrowUp', 'ArrowDown', 'Enter']);
} else {
editorRef.current?.disableKeybindings([]);
}
}, [visible]);
useEffect(() => {
if (typeof onVisibilityChangeRef.current === 'function') {
onVisibilityChangeRef.current(visible);
}
}, [visible]);
return (
<SemiPopover
trigger="custom"
visible={visible}
keepDOM={true}
rePosKey={posKey}
getPopupContainer={getPopupContainer}
content={
<div
onMouseDown={e => e.preventDefault()}
style={{ display: visible ? 'block' : 'none' }}
// The data-attribute is used for other components to ignore some click outside event
data-expression-popover
>
<EmptyContent visible={!!emptyContent} content={emptyContent} />
<TreeContainer
ref={treeContainerRef}
visible={!emptyContent}
className={className}
>
<Tree
// key={treeRefreshKey}
className={styles['expression-editor-suggestion-tree']}
showFilteredOnly
filterTreeNode
onChangeWithObject
ref={treeRef}
treeData={prunedVariableTree}
searchRender={false}
value={selected}
emptyContent={null}
onSelect={handleSelect}
/>
</TreeContainer>
</div>
}
>
<CursorMirror
side={SelectionSide.Anchor}
onChange={() => setPosKey(generateUniqueId())}
/>
</SemiPopover>
);
}
interface EmptyContentProps {
visible: boolean;
content?: string;
}
function EmptyContent({ visible, content }: EmptyContentProps) {
return (
<div
className={styles['expression-editor-suggestion-empty']}
style={{ display: visible ? 'block' : 'none' }}
>
<p>{content}</p>
</div>
);
}
interface TreeContainerProps {
visible: boolean;
className?: string;
children?: ReactNode;
}
const TreeContainer = forwardRef<HTMLDivElement, TreeContainerProps>(
function TreeContainer({ visible, className, children }, ref) {
return (
<div
className={classNames(
className,
styles['expression-editor-suggestion'],
)}
style={{ display: visible ? 'block' : 'none' }}
ref={ref}
>
{children}
</div>
);
},
);
export { Popover };

View File

@@ -0,0 +1,147 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import {
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '@/expression-editor';
import styles from './popover.module.less';
const SKIP_SELECTION_CHANGE_USER_EVENT = 'api.skip-selection-change';
const SELECTED_OPTION_CLASSNAME =
styles['expression-editor-suggestion-keyboard-selected'];
// modified from:
// file: packages/workflow/components/src/expression-editor/components/suggestion/hooks.ts
// method: computeUIOptions
interface OptionsInfo {
elements: Element[];
selectedIndex: number;
selectedElement?: Element;
}
const getOptionInfoFromDOM = (
root: Element | null,
): OptionsInfo | undefined => {
if (!root) {
return;
}
// 获取所有的选项元素
const foundNodes = root.querySelectorAll(
'.semi-tree-option-list .semi-tree-option',
);
if (foundNodes.length === 0) {
return;
}
const optionElements = [...foundNodes];
// 找到当前高亮的选项
const selectedIndex = optionElements.findIndex(element =>
element.classList.contains(SELECTED_OPTION_CLASSNAME),
);
return {
elements: optionElements,
selectedIndex,
selectedElement: optionElements[selectedIndex],
};
};
function selectNodeByIndex(elements: Element[], index: number) {
const newSelectedElement = elements[index];
if (!newSelectedElement) {
return;
}
// remove old selected class
elements.forEach(element => {
if (element.classList.contains(SELECTED_OPTION_CLASSNAME)) {
element.classList.remove(SELECTED_OPTION_CLASSNAME);
}
});
newSelectedElement.classList.add(SELECTED_OPTION_CLASSNAME);
newSelectedElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
interface ApplyNodeOptions {
from: number;
to: number;
textBefore: string;
}
function isLeafNode(node: ExpressionEditorTreeNode) {
return !node.variable?.children || node.variable.children.length === 0;
}
function applyNode(
editor: ExpressionEditorAPI | undefined,
node: ExpressionEditorTreeNode,
options: ApplyNodeOptions,
) {
if (!editor) {
return;
}
const { from, to, textBefore } = options;
const text = getInsertTextFromNode(node, textBefore);
if (!text) {
return;
}
editor.replaceTextByRange({
from,
to,
text,
cursorOffset: isLeafNode(node) ? '}}'.length : 0,
userEvent: SKIP_SELECTION_CHANGE_USER_EVENT,
});
}
function isSkipSelectionChangeUserEvent(tr) {
return tr.isUserEvent(SKIP_SELECTION_CHANGE_USER_EVENT);
}
function getInsertTextFromNode(
node: ExpressionEditorTreeNode,
textBefore: string,
) {
const segments = ExpressionEditorParserBuiltin.toSegments(textBefore) ?? [];
return ExpressionEditorTreeHelper.concatFullPath({
node,
segments,
});
}
export {
getOptionInfoFromDOM,
applyNode,
selectNodeByIndex,
isSkipSelectionChangeUserEvent,
};

View File

@@ -0,0 +1,148 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type MutableRefObject, useMemo } from 'react';
import { type EditorAPI } from '@coze-editor/editor/preset-expression';
import { mixLanguages, astDecorator } from '@coze-editor/editor';
import { EditorView } from '@codemirror/view';
import { type ExpressionEditorTreeNode } from '@/expression-editor/type';
import { validateExpression } from './validate';
function useInputRules(apiRef: MutableRefObject<EditorAPI | null>) {
return useMemo(
() => [
{
type: 'character' as const,
triggerCharacter: '{',
handler({ from, to }) {
apiRef.current?.replaceTextByRange({
from,
to,
text: '{{}}',
cursorOffset: -2,
});
return true;
},
},
],
[],
);
}
type Extension =
| {
extension: Extension;
}
| readonly Extension[];
function useExtensions(
variableTreeRef: MutableRefObject<ExpressionEditorTreeNode[] | undefined>,
): Extension[] {
return useMemo(
() => [
mixLanguages({}),
EditorView.baseTheme({
'& .cm-line': {
padding: 0,
},
'& .cm-placeholder': {
color: 'inherit',
opacity: 0.333,
},
'& .cm-content': {
wordBreak: 'break-all',
},
}),
[
astDecorator.whole.of((cursor, state) => {
if (!variableTreeRef.current) {
return;
}
if (
cursor.node.type.name === 'Interpolation' &&
// 由于 parser 存在容错能力
// 可能出现缺少右花括号也被正常解析为 Interpolation 的情况
// 如:{{variable
cursor.node.firstChild?.type.name === '{{' &&
cursor.node.lastChild?.type.name === '}}'
) {
const source = state.sliceDoc(
cursor.node.firstChild.to,
cursor.node.lastChild.from,
);
const isValid = validateExpression(source, variableTreeRef.current);
if (isValid) {
return {
type: 'className',
className: 'cm-decoration-interpolation-valid',
};
}
return {
type: 'className',
className: 'cm-decoration-interpolation-invalid',
};
}
}),
EditorView.baseTheme({
'& .cm-decoration-interpolation-valid': {
color: '#6675D9',
caretColor: '#6675D9',
},
// '& .cm-decoration-interpolation-invalid': {
// },
}),
],
[
astDecorator.fromCursor.of((cursor, state) => {
const { anchor } = state.selection.main;
const pos = anchor;
if (
cursor.node.type.name === 'Interpolation' &&
cursor.node.firstChild &&
cursor.node.lastChild &&
pos >= cursor.node.firstChild.to &&
pos <= cursor.node.lastChild.from
) {
return {
type: 'background',
className: 'cm-decoration-interpolation-active',
from: cursor.node.firstChild.from,
to: cursor.node.lastChild.to,
};
}
}),
EditorView.baseTheme({
'& .cm-decoration-interpolation-active': {
borderRadius: '2px',
backgroundColor:
'var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 0.08))',
},
}),
],
],
[],
);
}
export { useInputRules, useExtensions };

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Renderer } from './renderer';

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Renderer as SDKRenderer } from '@coze-editor/editor/react';
import expression, {
type EditorAPI as ExpressionEditorAPI,
} from '@coze-editor/editor/preset-expression';
import { type ExpressionEditorTreeNode } from '@/expression-editor';
import { useDeepEqualMemo, useLatest } from '../shared';
import { useInputRules, useExtensions } from './hooks';
interface RendererProps {
value?: string;
className?: string;
readonly?: boolean;
placeholder?: string;
dataTestID?: string;
variableTree: ExpressionEditorTreeNode[];
onChange?: (value: string) => void;
}
function Renderer({
value,
variableTree,
className,
readonly,
placeholder,
dataTestID,
onChange,
}: RendererProps) {
const apiRef = useRef<ExpressionEditorAPI | null>(null);
const variableTreeRef = useLatest<ExpressionEditorTreeNode[] | undefined>(
variableTree,
);
const changedVariableTree = useDeepEqualMemo(variableTree);
const inputRules = useInputRules(apiRef);
const extensions = useExtensions(variableTreeRef);
const contentAttributes = useMemo(
() => ({
class: `${className ?? ''} flow-canvas-not-draggable`,
'data-testid': dataTestID ?? '',
'data-flow-editor-selectable': 'false',
}),
[className, dataTestID],
);
const handleChange = useCallback(
(e: { value: string }) => {
if (typeof onChange === 'function') {
onChange(e.value);
}
},
[onChange],
);
// Note: changedVariableTree 这里只用来进行性能优化
// useVariableTree 的触发时机仍然存在问题,缩放画布也会频繁触发 variableTree 的变更
useEffect(() => {
const editor = apiRef.current;
if (!editor) {
return;
}
editor.updateWholeDecorations();
}, [changedVariableTree]);
function handleFocus() {
const editor = apiRef.current;
if (!editor) {
return;
}
editor.updateWholeDecorations();
}
// 值受控
useEffect(() => {
const editor = apiRef.current;
if (!editor) {
return;
}
if (typeof value === 'string' && value !== editor.getValue()) {
editor.setValue(value);
}
}, [value]);
return (
<SDKRenderer
plugins={expression}
defaultValue={value ?? ''}
options={{
fontSize: 14,
inputRules,
readOnly: readonly,
placeholder,
contentAttributes,
}}
onFocus={handleFocus}
onChange={handleChange}
extensions={extensions}
didMount={api => (apiRef.current = api)}
/>
);
}
export { Renderer };

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import {
ExpressionEditorSegmentType,
ExpressionEditorTreeHelper,
type ExpressionEditorTreeNode,
} from '@/expression-editor';
function validateExpression(source: string, tree: ExpressionEditorTreeNode[]) {
const segments = ExpressionEditorParserBuiltin.toSegments(source);
if (!segments) {
return false;
}
if (
segments[segments.length - 1].type === ExpressionEditorSegmentType.EndEmpty
) {
return false;
}
// 2. segments mix variable tree, match tree branch
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
if (!treeBranch) {
return false;
}
// 3. if full segments path could match one tree branch, the pattern is valid
return true;
}
export { validateExpression };

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type MutableRefObject,
useRef,
useState,
useLayoutEffect,
} from 'react';
import { dequal } from 'dequal';
import { ExpressionEditorParserBuiltin } from '@/expression-editor/parser';
import { ExpressionEditorSegmentType } from '@/expression-editor';
function useLatest<T>(value: T): MutableRefObject<T> {
const ref = useRef(value);
ref.current = value;
return ref;
}
// 解除 parent 导致的循环依赖(否则无法深比较)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function cloneWithout(target: any, keys: string[]) {
// target 为 undefined 时会抛错
try {
return JSON.parse(
JSON.stringify(target, function (key, value) {
if (keys.includes(key)) {
return null;
}
return value;
}),
);
} catch (e) {
return target;
}
}
function useDeepEqualMemo<T>(value: T): T {
const [state, setState] = useState<T>(value);
const lastValueRef = useRef<T>(value);
useLayoutEffect(() => {
if (
!dequal(
cloneWithout(value, ['parent']),
cloneWithout(lastValueRef.current, ['parent']),
)
) {
setState(value);
lastValueRef.current = value;
}
}, [value]);
return state;
}
function generateUniqueId(): string {
return Math.floor(Math.random() * 2e6).toString(36);
}
function getSearchValue(textBefore: string) {
const segments = ExpressionEditorParserBuiltin.toSegments(textBefore);
if (!segments) {
return '';
}
const lastSegment =
segments[segments.length - 1].type ===
ExpressionEditorSegmentType.ArrayIndex
? segments[segments.length - 2] // 数组索引属于上一层级,需要去除防止影响到搜索值
: segments[segments.length - 1];
if (
!lastSegment ||
lastSegment.type !== ExpressionEditorSegmentType.ObjectKey
) {
return '';
}
return lastSegment.objectKey;
}
export { useLatest, useDeepEqualMemo, getSearchValue, generateUniqueId };