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