feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user