feat: manually mirror opencoze's code from bytedance

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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