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,23 @@
.expression-editor-counter {
position: absolute;
right: 0;
bottom: 0;
color: var(--semi-color-text-2);
p {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 24px;
padding: 3px 12px 5px;
font-size: 12px;
line-height: 16px;
text-align: right;
}
&-error {
color: var(--semi-color-danger);
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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, type FC } from 'react';
import classNames from 'classnames';
import { type ExpressionEditorModel } from '../../model';
import styles from './index.module.less';
interface ExpressionEditorCounterProps {
className?: string;
model: ExpressionEditorModel;
maxLength?: number;
disabled?: boolean;
isError?: boolean;
}
/**
* 长度计数器
*/
export const ExpressionEditorCounter: FC<
ExpressionEditorCounterProps
> = props => {
const { className, model, maxLength, disabled, isError } = props;
const { visible, count, max } = useMemo(() => {
if (typeof model.value.length !== 'number') {
return {
visible: false,
};
}
if (typeof maxLength !== 'number') {
return {
visible: false,
};
}
return {
visible: true,
count: model.value.length,
max: maxLength,
};
}, [model.value.length, maxLength]);
if (disabled || !visible) {
return <></>;
}
return (
<div
className={classNames(styles['expression-editor-counter'], className, {
[styles['expression-editor-counter-error']]: isError,
})}
>
<p>
{count} / {max}
</p>
</div>
);
};

View File

@@ -0,0 +1,20 @@
/*
* 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 { ExpressionEditorCounter } from './counter';
export { ExpressionEditorLeaf } from './leaf';
export { ExpressionEditorRender } from './render';
export { ExpressionEditorSuggestion } from './suggestion';

View File

@@ -0,0 +1,53 @@
/*
* 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 CSSProperties } from 'react';
import type { RenderLeafProps } from 'slate-react';
import { ExpressionEditorSignal } from '../../constant';
const LeafStyle: Partial<Record<ExpressionEditorSignal, CSSProperties>> = {
[ExpressionEditorSignal.Valid]: {
color: '#6675D9',
},
[ExpressionEditorSignal.Invalid]: {
color: 'inherit',
},
[ExpressionEditorSignal.SelectedValid]: {
color: '#6675D9',
borderRadius: 2,
backgroundColor:
'var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 0.08))',
},
[ExpressionEditorSignal.SelectedInvalid]: {
color: 'inherit',
borderRadius: 2,
backgroundColor:
'var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 0.08))',
},
};
export const ExpressionEditorLeaf = (props: RenderLeafProps) => {
const { type } = props.leaf as {
type?: ExpressionEditorSignal;
};
return (
<span style={type && LeafStyle[type]} {...props.attributes}>
{props.children}
</span>
);
};

View File

@@ -0,0 +1,11 @@
/* stylelint-disable declaration-no-important */
/* stylelint-disable property-no-vendor-prefix */
.slate-editable {
// WARNING: 别删,有人在全局加了 user-select: none 删了会在低版本 safari 浏览器上出现无法输入文本
user-select: text !important;
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
-o-user-select: text !important;
}

View File

@@ -0,0 +1,92 @@
/*
* 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 CompositionEventHandler } from 'react';
import { Slate, Editable } from 'slate-react';
import classNames from 'classnames';
import { ExpressionEditorLeaf } from '../leaf';
import { type ExpressionEditorLine } from '../../type';
import { type ExpressionEditorModel } from '../../model';
import styles from './index.module.less';
interface ExpressionEditorRenderProps {
model: ExpressionEditorModel;
className?: string;
placeholder?: string;
readonly?: boolean;
onFocus?: () => void;
onBlur?: () => void;
dataTestID?: string;
}
/**
* 应当只包含编辑器逻辑,业务无关
*/
export const ExpressionEditorRender: React.FC<
ExpressionEditorRenderProps
> = props => {
const {
model,
className,
placeholder,
onFocus,
onBlur,
readonly = false,
dataTestID,
} = props;
return (
<div className={className}>
<Slate
editor={model.editor}
initialValue={model.lines}
onChange={value => {
// eslint-disable-next-line @typescript-eslint/require-await -- 防止阻塞 slate 渲染
const asyncOnChange = async () => {
const lines = value as ExpressionEditorLine[];
model.change(lines);
model.select(lines);
};
asyncOnChange();
}}
>
<Editable
data-testid={dataTestID}
className={classNames(
styles.slateEditable,
'flow-canvas-not-draggable',
)}
data-flow-editor-selectable="false"
readOnly={readonly}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
renderLeaf={ExpressionEditorLeaf}
decorate={model.decorate}
onKeyDown={e => model.keydown(e)}
onCompositionStart={e =>
model.compositionStart(
e as unknown as CompositionEventHandler<HTMLDivElement>,
)
}
/>
</Slate>
</div>
);
};

View File

@@ -0,0 +1,693 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable @typescript-eslint/no-magic-numbers */
/* eslint-disable max-lines */
import { useCallback, useEffect } from 'react';
import { ReactEditor } from 'slate-react';
import { Range, type Selection, Transforms } from 'slate';
import { I18n } from '@coze-arch/i18n';
import type {
ExpressionEditorEventParams,
ExpressionEditorParseData,
ExpressionEditorTreeNode,
} from '../../type';
import { ExpressionEditorTreeHelper } from '../../tree-helper';
import { ExpressionEditorParser } from '../../parser';
import type { ExpressionEditorModel } from '../../model';
import {
ExpressionEditorEvent,
ExpressionEditorSegmentType,
ExpressionEditorToken,
} from '../../constant';
import {
type SuggestionReducer,
SuggestionActionType,
type SuggestionState,
} from './type';
import styles from './index.module.less';
/** 内置函数 */
namespace SuggestionViewUtils {
/** 编辑器选中事件处理 */
export const editorSelectHandler = (params: {
reducer: SuggestionReducer;
payload: ExpressionEditorEventParams<ExpressionEditorEvent.Select>;
}) => {
const { reducer, payload } = params;
const [state, dispatch] = reducer;
// 设置解析数据
const parseData = ExpressionEditorParser.parse({
lineContent: payload.content,
lineOffset: payload.offset,
});
if (!parseData) {
dispatch({
type: SuggestionActionType.ClearParseDataAndEditorPath,
});
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
dispatch({
type: SuggestionActionType.SetRect,
payload: undefined,
});
return;
}
dispatch({
type: SuggestionActionType.SetParseDataAndEditorPath,
payload: {
parseData,
editorPath: payload.path,
},
});
// 重置UI组件内部状态
const shouldRefresh = parseData.content.reachable === '';
if (shouldRefresh) {
dispatch({
type: SuggestionActionType.Refresh,
});
}
// 设置选中值
const selected = SuggestionViewUtils.computeSelected({
model: state.model,
parseData,
});
dispatch({
type: SuggestionActionType.SetSelected,
payload: selected,
});
// 设置可见变量树
const variableTree = SuggestionViewUtils.computeVariableTree({
model: state.model,
parseData,
});
dispatch({
type: SuggestionActionType.SetVariableTree,
payload: variableTree,
});
// 设置匹配树枝
const matchTreeBranch: ExpressionEditorTreeNode[] | undefined =
ExpressionEditorTreeHelper.matchTreeBranch({
tree: state.model.variableTree,
segments: parseData.segments.reachable,
});
dispatch({
type: SuggestionActionType.SetMatchTreeBranch,
payload: matchTreeBranch,
});
// 设置空内容
const emptyContent = SuggestionViewUtils.computeEmptyContent({
parseData,
fullVariableTree: state.model.variableTree,
variableTree,
matchTreeBranch,
});
dispatch({
type: SuggestionActionType.SetEmptyContent,
payload: emptyContent,
});
// 设置UI相对坐标
const rect = SuggestionViewUtils.computeRect(state);
dispatch({
type: SuggestionActionType.SetRect,
payload: rect,
});
if (!rect) {
dispatch({
type: SuggestionActionType.ClearParseDataAndEditorPath,
});
return;
}
// FIXME: 设置搜索值很hack的逻辑后面建议重构不用semi组件自己写一个
if (!state.ref.tree.current) {
// 不设为可见获取不到ref
dispatch({
type: SuggestionActionType.SetVisible,
payload: true,
});
}
dispatch({
type: SuggestionActionType.SearchEffectStart,
});
};
export const getFinalScale = (state: SuggestionState): number => {
if (state.entities.playgroundConfig) {
return state.entities.playgroundConfig.finalScale;
}
return 1;
};
/** 计算可见时相对父容器坐标 */
export const computeRect = (
state: SuggestionState,
):
| {
top: number;
left: number;
}
| undefined => {
const borderTopOffset = 5;
const containerRect = state.ref.container.current?.getBoundingClientRect();
if (!state.model.editor.selection || !containerRect) {
return;
}
try {
const rect = ReactEditor.toDOMRange(
state.model.editor,
state.model.editor.selection,
).getBoundingClientRect();
return {
top:
(rect.top - containerRect.top) / getFinalScale(state) +
borderTopOffset,
left: (rect.left - containerRect.left) / getFinalScale(state),
};
} catch (e) {
// slate DOM 计算报错可忽略
return;
}
};
/** 计算当前选中变量 */
export const computeSelected = (params: {
model: ExpressionEditorModel;
parseData: ExpressionEditorParseData;
}): ExpressionEditorTreeNode | undefined => {
const { model, parseData } = params;
if (!parseData?.segments.inline) {
return;
}
const treeBrach = ExpressionEditorTreeHelper.matchTreeBranch({
tree: model.variableTree,
segments: parseData.segments.inline,
});
if (!treeBrach) {
return;
}
return treeBrach[treeBrach.length - 1];
};
/** 计算当前搜索值 */
export const computeSearch = (
parseData: ExpressionEditorParseData,
): string => {
if (!parseData) {
return '';
}
const segments = parseData.segments.reachable;
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 const computeVariableTree = (params: {
model: ExpressionEditorModel;
parseData: ExpressionEditorParseData;
}): ExpressionEditorTreeNode[] => {
const { model, parseData } = params;
if (!parseData?.segments.reachable) {
return [];
}
const prunedVariableTree = ExpressionEditorTreeHelper.pruning({
tree: model.variableTree,
segments: parseData.segments.reachable,
});
return prunedVariableTree;
};
export const computeEmptyContent = (params: {
parseData: ExpressionEditorParseData;
fullVariableTree: ExpressionEditorTreeNode[];
variableTree: ExpressionEditorTreeNode[];
matchTreeBranch: ExpressionEditorTreeNode[] | undefined;
}): string | undefined => {
const { parseData, fullVariableTree, variableTree, matchTreeBranch } =
params;
if (
!fullVariableTree ||
!Array.isArray(fullVariableTree) ||
fullVariableTree.length === 0
) {
if (parseData.content.reachable === '') {
return I18n.t('workflow_variable_refer_no_input');
}
return;
}
if (
!variableTree ||
!Array.isArray(variableTree) ||
variableTree.length === 0
) {
if (parseData.content.inline === '') {
return I18n.t('workflow_variable_refer_no_input');
}
if (matchTreeBranch && matchTreeBranch.length !== 0) {
return I18n.t('workflow_variable_refer_no_sub_variable');
}
return;
}
return;
};
export const keyboardSelectedClassName = () =>
styles['expression-editor-suggestion-keyboard-selected'];
/** 将选中项设为高亮 */
export const setUIOptionSelected = (uiOption: Element): void => {
if (
!uiOption?.classList?.add ||
!uiOption?.classList?.contains ||
uiOption.classList.contains('semi-tree-option-empty')
) {
return;
}
uiOption.classList.add(SuggestionViewUtils.keyboardSelectedClassName());
};
/** 获取所有选项UI元素 */
export const computeUIOptions = (
state: SuggestionState,
):
| {
optionList: Element[];
selectedIndex: number;
selectedOption?: Element;
}
| undefined => {
// 获取所有的选项元素
const optionListDom =
state.ref.suggestion.current?.children?.[0]?.children?.[1]?.children;
if (!optionListDom) {
return;
}
const optionList = Array.from(optionListDom);
// 找到当前高亮的选项
const selectedIndex = optionList.findIndex(element =>
element.classList.contains(keyboardSelectedClassName()),
);
return {
optionList,
selectedIndex,
selectedOption: optionList[selectedIndex],
};
};
/** 禁止变更 visible 防止 ui 抖动 */
export const preventVisibleJitter = (
reducer: SuggestionReducer,
time = 150,
) => {
const [state, dispatch] = reducer;
if (!state.allowVisibleChange) {
return;
}
dispatch({
type: SuggestionActionType.SetAllowVisibleChange,
payload: false,
});
setTimeout(() => {
dispatch({
type: SuggestionActionType.SetAllowVisibleChange,
payload: true,
});
}, time);
};
/** 清空键盘UI选项 */
export const clearSelectedUIOption = (state: SuggestionState) => {
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
if (uiOptions?.selectedOption) {
// 清空键盘选中状态
uiOptions.selectedOption.classList.remove(
SuggestionViewUtils.keyboardSelectedClassName(),
);
}
};
/** 默认键盘UI选项为第一项 */
export const selectFirstUIOption = (state: SuggestionState) => {
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
if (!uiOptions?.optionList) {
return;
}
clearSelectedUIOption(state);
if (!uiOptions?.optionList?.[0]?.classList?.add) {
return;
}
// 默认首项高亮
SuggestionViewUtils.setUIOptionSelected(uiOptions.optionList[0]);
};
}
/** 选中节点 */
export const useSelectNode = (reducer: SuggestionReducer) => {
const [state] = reducer;
return useCallback(
(node: ExpressionEditorTreeNode) => {
const fullPath: string = ExpressionEditorTreeHelper.concatFullPath({
node,
segments: state.parseData?.segments.reachable ?? [],
});
if (!state.parseData || !state.editorPath) {
return;
}
const selection: Selection = {
anchor: {
path: state.editorPath,
offset: state.parseData.offset.lastStart - 1,
},
focus: {
path: state.editorPath,
offset: state.parseData.offset.firstEnd + 2,
},
};
const insertText = `${ExpressionEditorToken.FullStart}${fullPath}${ExpressionEditorToken.FullEnd}`;
// 替换文本
Transforms.insertText(state.model.editor, insertText, {
at: selection,
});
},
[state],
);
};
/** 挂载监听器 */
export const useListeners = (reducer: SuggestionReducer) => {
const [state, dispatch] = reducer;
useEffect(() => {
// 挂载监听: 鼠标点击事件
const mouseHandler = (e: MouseEvent) => {
if (!state.visible || !state.ref.suggestion.current) {
return;
}
if (state.ref.suggestion.current?.contains(e.target as Node)) {
return;
}
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
dispatch({
type: SuggestionActionType.SetRect,
payload: undefined,
});
};
window.addEventListener('mousedown', mouseHandler);
const mouseDisposer = () => {
window.removeEventListener('mousedown', mouseHandler);
};
return () => {
// 销毁时卸载监听器防止内存泄露
mouseDisposer();
};
}, [state]);
useEffect(() => {
// 挂载监听: 编辑器选择事件
const editorSelectDisposer = state.model.on<ExpressionEditorEvent.Select>(
ExpressionEditorEvent.Select,
payload =>
SuggestionViewUtils.editorSelectHandler({
reducer,
payload,
}),
);
return () => {
// 销毁时卸载监听器防止内存泄露
editorSelectDisposer();
};
}, []);
useEffect(() => {
// 挂载监听: 编辑器拼音输入事件
const compositionStartDisposer =
state.model.on<ExpressionEditorEvent.CompositionStart>(
ExpressionEditorEvent.CompositionStart,
payload =>
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
}),
);
return () => {
// 销毁时卸载监听器防止内存泄露
compositionStartDisposer();
};
}, []);
useEffect(() => {
// 初始化前首次渲染激活DOM
if (state.initialized) {
return;
}
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
}, []);
useEffect(() => {
// 初始化前监听到DOM激活后隐藏
if (state.initialized || state.visible) {
return;
}
dispatch({
type: SuggestionActionType.SetHiddenDOM,
payload: false,
});
dispatch({
type: SuggestionActionType.SetInitialized,
});
}, [state]);
};
/** 键盘上下回车键选中节点 */
export const useKeyboardSelect = (
reducer: SuggestionReducer,
selectNode: (node: ExpressionEditorTreeNode) => void,
) => {
const [state, dispatch] = reducer;
// 键盘上下
useEffect(() => {
const keyboardArrowHandler = event => {
if (
!state.visible ||
!state.ref.suggestion.current ||
!['ArrowDown', 'ArrowUp'].includes(event.key)
) {
return;
}
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
if (!uiOptions) {
return;
}
const { optionList, selectedIndex } = uiOptions;
if (optionList.length === 1) {
// 仅有一项可选项的时候不做处理
return;
}
event.preventDefault();
let newIndex = selectedIndex;
if (event.key === 'ArrowDown') {
// 如果当前没有高亮的选项或者是最后一个选项,则高亮第一个选项
newIndex =
selectedIndex === -1 || selectedIndex === optionList.length - 1
? 0
: selectedIndex + 1;
} else if (event.key === 'ArrowUp') {
// 如果当前没有高亮的选项或者是第一个选项,则高亮最后一个选项
newIndex =
selectedIndex <= 0 ? optionList.length - 1 : selectedIndex - 1;
}
const selectedOption = optionList[newIndex];
// 更新高亮选项
if (selectedIndex !== -1) {
optionList[selectedIndex].classList.remove(
SuggestionViewUtils.keyboardSelectedClassName(),
);
}
SuggestionViewUtils.setUIOptionSelected(selectedOption);
// 将新选中的选项滚动到视图中
selectedOption.scrollIntoView({
behavior: 'smooth', // 平滑滚动
block: 'nearest', // 最接近的视图边界,可能是顶部或底部
});
};
document.addEventListener('keydown', keyboardArrowHandler);
return () => {
document.removeEventListener('keydown', keyboardArrowHandler);
};
}, [state]);
// 键盘回车
useEffect(() => {
const keyboardEnterHandler = event => {
if (
!state.visible ||
!state.ref.suggestion.current ||
event.key !== 'Enter'
) {
return;
}
const uiOptions = SuggestionViewUtils.computeUIOptions(state);
if (!uiOptions?.selectedOption) {
return;
}
const { selectedOption } = uiOptions;
const selectedDataKey = selectedOption.getAttribute('data-key');
if (!selectedDataKey) {
return;
}
const variableTreeNode =
state.ref.tree.current?.state?.keyEntities?.[selectedDataKey]?.data;
if (!variableTreeNode) {
return;
}
event.preventDefault();
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
selectNode(variableTreeNode as ExpressionEditorTreeNode);
if (
!variableTreeNode.variable?.children ||
variableTreeNode.variable?.children.length === 0
) {
// 叶子节点
return;
}
// 非叶子节点,光标向前移动两格
const { selection } = state.model.editor;
if (selection && Range.isCollapsed(selection)) {
// 向前移动两个字符的光标
Transforms.move(state.model.editor, { distance: 2, reverse: true });
}
SuggestionViewUtils.preventVisibleJitter(reducer);
};
document.addEventListener('keydown', keyboardEnterHandler);
return () => {
document.removeEventListener('keydown', keyboardEnterHandler);
};
}, [state]);
// 键盘 ESC 取消弹窗
useEffect(() => {
const keyboardESCHandler = event => {
if (
!state.visible ||
!state.ref.suggestion.current ||
event.key !== 'Escape'
) {
return;
}
event.preventDefault();
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
};
document.addEventListener('keydown', keyboardESCHandler);
return () => {
document.removeEventListener('keydown', keyboardESCHandler);
};
}, [state]);
// 默认选中首项
useEffect(() => {
SuggestionViewUtils.selectFirstUIOption(state);
}, [state]);
};
/** 等待semi组件数据更新后的副作用 */
export const useRenderEffect = (reducer: SuggestionReducer) => {
const [state, dispatch] = reducer;
// 组件树状数据更新后设置搜索值
useEffect(() => {
if (!state.renderEffect.search || !state.parseData) {
return;
}
dispatch({
type: SuggestionActionType.SearchEffectEnd,
});
const searchValue = SuggestionViewUtils.computeSearch(state.parseData);
state.ref.tree.current?.search(searchValue);
if (!searchValue && state.matchTreeBranch) {
dispatch({
type: SuggestionActionType.SetVisible,
payload: true,
});
return;
}
dispatch({
type: SuggestionActionType.FilteredEffectStart,
});
}, [state]);
// 搜索过滤后是否为空
useEffect(() => {
if (!state.renderEffect.filtered) {
return;
}
dispatch({
type: SuggestionActionType.FilteredEffectEnd,
});
const filteredKeys = Array.from(
state.ref.tree.current?.state.filteredKeys || [],
);
if (!state.emptyContent && filteredKeys.length === 0) {
dispatch({
type: SuggestionActionType.SetVisible,
payload: false,
});
return;
}
dispatch({
type: SuggestionActionType.SetVisible,
payload: true,
});
}, [state]);
};

View File

@@ -0,0 +1,145 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable declaration-no-important */
/* stylelint-disable max-nesting-depth */
.expression-editor-suggestion-pin {
position: absolute;
width: 0;
height: 1.5rem;
transform: translateY(-0.5rem);
}
.expression-editor-suggestion {
max-height: 236px;
width: 272px;
z-index: 1000;
background: var(--light-usage-bg-color-bg-3, #FFF);
border-radius: 8px;
border: 0.5px solid rgba(153, 182, 255, 12%);
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
overflow: auto;
}
.expression-editor-suggestion-empty {
z-index: 1000;
border-radius: 8px;
border: 0.5px solid rgba(153, 182, 255, 12%);
background: #FFF;
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
p {
margin: 4px 6px;
color: var(--light-usage-text-color-text-2, rgba(29, 28, 35, 60%));
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 16px;
}
}
.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 {
background-color: transparent;
pointer-events: none;
}
.semi-tree-option-label {
height: 24px;
padding: 0 4px;
margin-right: 4px;
border-radius: 4px;
pointer-events: auto;
&: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 {
width: fit-content;
display: inline-block;
white-space: nowrap;
& span {
width: fit-content;
display: inline-block;
white-space: nowrap;
}
.semi-tree-option-highlight {
color: var(--light-usage-warning-color-warning, #FF9600)
}
}
}
.semi-tree-option-selected {
color: var(--light-usage-primary-color-primary, #4D53E8);
font-weight: 600;
}
.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 {
width: 16px;
height: 16px;
padding: 4px;
margin-right: 0;
border-radius: 4px;
pointer-events: auto;
&: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,150 @@
/*
* 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 FC, useRef, type RefObject } from 'react';
import classNames from 'classnames';
import { type PopoverProps } from '@coze-arch/bot-semi/Popover';
import { Popover, Tree } from '@coze-arch/bot-semi';
import type { SelectorBoxConfigEntity } from '@flowgram-adapter/free-layout-editor';
import type { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
import { type ExpressionEditorTreeNode } from '../../type';
import { type ExpressionEditorModel } from '../../model';
import { useSuggestionReducer } from './state';
import {
useListeners,
useSelectNode,
useKeyboardSelect,
useRenderEffect,
} from './hooks';
import styles from './index.module.less';
interface ExpressionEditorSuggestionProps {
className?: string;
model: ExpressionEditorModel;
containerRef: RefObject<HTMLDivElement>;
getPopupContainer?: PopoverProps['getPopupContainer'];
playgroundConfig?: PlaygroundConfigEntity;
selectorBoxConfig?: SelectorBoxConfigEntity;
disabled?: boolean;
}
/**
* 自动提示
*/
export const ExpressionEditorSuggestion: FC<
ExpressionEditorSuggestionProps
> = props => {
const {
model,
containerRef,
className,
playgroundConfig,
selectorBoxConfig,
disabled = false,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getPopupContainer = () => containerRef.current!,
} = props;
const suggestionRef = useRef<HTMLDivElement>(null);
const treeRef = useRef<Tree>(null);
const suggestionReducer = useSuggestionReducer({
model,
entities: {
playgroundConfig,
selectorBoxConfig,
},
ref: {
container: containerRef,
suggestion: suggestionRef,
tree: treeRef,
},
});
const [state] = suggestionReducer;
const selectNode = useSelectNode(suggestionReducer);
useRenderEffect(suggestionReducer);
useListeners(suggestionReducer);
useKeyboardSelect(suggestionReducer, selectNode);
if (disabled) {
return <></>;
}
return (
<Popover
trigger="custom"
visible={state.visible}
keepDOM={true}
getPopupContainer={getPopupContainer}
content={
<>
<div
className={styles['expression-editor-suggestion-empty']}
style={{
display:
!state.visible || !state.emptyContent ? 'none' : 'inherit',
}}
>
<p>{state.emptyContent}</p>
</div>
<div
className={classNames(
className,
styles['expression-editor-suggestion'],
)}
ref={suggestionRef}
style={{
display:
!state.visible || state.emptyContent || state.hiddenDOM
? 'none'
: 'inherit',
}}
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tree
key={state.key}
className={styles['expression-editor-suggestion-tree']}
showFilteredOnly
filterTreeNode
onChangeWithObject
ref={treeRef}
treeData={state.variableTree}
searchRender={false}
value={state.selected}
emptyContent={<></>}
onSelect={(key, selected, node) => {
selectNode(node as ExpressionEditorTreeNode);
}}
/>
</div>
</>
}
>
<div
className={styles['expression-editor-suggestion-pin']}
style={{
top: state.rect?.top,
left: state.rect?.left,
}}
/>
</Popover>
);
};

View File

@@ -0,0 +1,199 @@
/*
* 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.
*/
/* eslint-disable complexity */
import { useReducer } from 'react';
import { type ExpressionEditorTreeNode } from '../../type';
import {
type SuggestionState,
type SuggestionAction,
SuggestionActionType,
type SuggestionActionPayload,
type SuggestionReducer,
} from './type';
const updateState = (
state: SuggestionState,
snapshot: Partial<SuggestionState>,
): SuggestionState => ({
...state,
version: state.version + 1,
...snapshot,
});
export const suggestionReducer = (
state: SuggestionState,
action: SuggestionAction,
) => {
if (action.type === SuggestionActionType.SetInitialized) {
return updateState(state, {
initialized: true,
});
}
if (action.type === SuggestionActionType.Refresh) {
return updateState(state, {
key: state.key + 1,
});
}
if (action.type === SuggestionActionType.SetParseDataAndEditorPath) {
const { parseData, editorPath } =
(action.payload as SuggestionActionPayload<SuggestionActionType.SetParseDataAndEditorPath>) ||
{};
return updateState(state, {
parseData,
editorPath,
});
}
if (action.type === SuggestionActionType.ClearParseDataAndEditorPath) {
return updateState(state, {
parseData: undefined,
editorPath: undefined,
});
}
if (action.type === SuggestionActionType.SetVariableTree) {
const variableTree = action.payload as ExpressionEditorTreeNode[];
return updateState(state, {
variableTree,
});
}
if (action.type === SuggestionActionType.SetAllowVisibleChange) {
const allowVisibleChange =
action.payload as SuggestionActionPayload<SuggestionActionType.SetAllowVisibleChange>;
return updateState(state, {
allowVisibleChange,
});
}
if (
action.type === SuggestionActionType.SetVisible &&
state.allowVisibleChange
) {
const visible =
action.payload as SuggestionActionPayload<SuggestionActionType.SetVisible>;
if (state.entities.selectorBoxConfig) {
if (visible) {
state.entities.selectorBoxConfig.disabled = true; // 防止鼠标拖选不触发点击
}
if (!visible) {
state.entities.selectorBoxConfig.disabled = false;
}
}
return updateState(state, {
visible,
});
}
if (action.type === SuggestionActionType.SetHiddenDOM) {
const hiddenDOM =
action.payload as SuggestionActionPayload<SuggestionActionType.SetHiddenDOM>;
return updateState(state, {
hiddenDOM,
});
}
if (action.type === SuggestionActionType.SetRect) {
const rect = action.payload as {
top: number;
left: number;
};
return updateState(state, {
rect,
});
}
if (action.type === SuggestionActionType.SetSelected) {
const selected = action.payload as ExpressionEditorTreeNode;
return updateState(state, {
selected,
});
}
if (action.type === SuggestionActionType.SetEmptyContent) {
const emptyContent = action.payload as string;
return updateState(state, {
emptyContent,
});
}
if (action.type === SuggestionActionType.SetMatchTreeBranch) {
const matchTreeBranch = action.payload as
| ExpressionEditorTreeNode[]
| undefined;
return updateState(state, {
matchTreeBranch,
});
}
if (action.type === SuggestionActionType.SearchEffectStart) {
return updateState(state, {
renderEffect: {
...state.renderEffect,
search: true,
},
});
}
if (action.type === SuggestionActionType.SearchEffectEnd) {
return updateState(state, {
renderEffect: {
...state.renderEffect,
search: false,
},
});
}
if (action.type === SuggestionActionType.FilteredEffectStart) {
return updateState(state, {
renderEffect: {
...state.renderEffect,
filtered: true,
},
});
}
if (action.type === SuggestionActionType.FilteredEffectEnd) {
return updateState(state, {
renderEffect: {
...state.renderEffect,
filtered: false,
},
});
}
return state;
};
/** 获取状态 */
export const useSuggestionReducer = (
initialState: Omit<
SuggestionState,
| 'initialized'
| 'version'
| 'key'
| 'visible'
| 'allowVisibleChange'
| 'hiddenDOM'
| 'variableTree'
| 'renderEffect'
>,
): SuggestionReducer => {
const [state, dispatch]: SuggestionReducer = useReducer(suggestionReducer, {
...initialState,
initialized: false, // 初始化
version: 0, // 更新状态计数
key: 0, // 用于触发 react 重新渲染组件
variableTree: [], // 用于展示的组件树
visible: true, // 默认显示让ref能访问到DOM
hiddenDOM: true, // 默认隐藏让用户看不到UI
allowVisibleChange: true, // 允许visible变更
renderEffect: {
// 渲染副作用
search: false,
filtered: false,
},
});
return [state, dispatch];
};

View File

@@ -0,0 +1,115 @@
/*
* 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 { Dispatch, RefObject } from 'react';
import type { Tree } from '@coze-arch/bot-semi';
import type { SelectorBoxConfigEntity } from '@flowgram-adapter/free-layout-editor';
import type { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
import type {
ExpressionEditorParseData,
ExpressionEditorTreeNode,
} from '../../type';
import type { ExpressionEditorModel } from '../../model';
export interface SuggestionState {
initialized: boolean;
version: number;
model: ExpressionEditorModel;
entities: {
playgroundConfig?: PlaygroundConfigEntity;
selectorBoxConfig?: SelectorBoxConfigEntity;
};
ref: {
container: RefObject<HTMLDivElement>;
suggestion: RefObject<HTMLDivElement>;
tree: RefObject<Tree>;
};
key: number;
variableTree: ExpressionEditorTreeNode[];
visible: boolean;
allowVisibleChange: boolean;
hiddenDOM: boolean;
renderEffect: {
search: boolean;
filtered: boolean;
};
rect?: {
top: number;
left: number;
};
selected?: ExpressionEditorTreeNode;
parseData?: ExpressionEditorParseData;
editorPath?: number[];
emptyContent?: string;
matchTreeBranch?: ExpressionEditorTreeNode[];
}
export enum SuggestionActionType {
SetInitialized = 'set_initialized',
Refresh = 'refresh',
SetParseDataAndEditorPath = 'set_parse_data_and_editor_path',
ClearParseDataAndEditorPath = 'clear_parse_data_and_editor_path',
SetVariableTree = 'set_variable_tree',
SetVisible = 'set_visible',
SetAllowVisibleChange = 'set_allow_visible_change',
SetHiddenDOM = 'set_hidden_dom',
SetRect = 'set_rect',
SetSelected = 'set_selected',
SetEmptyContent = 'set_empty_content',
SetMatchTreeBranch = 'set_match_tree_branch',
SearchEffectStart = 'search_effect_start',
SearchEffectEnd = 'search_effect_end',
FilteredEffectStart = 'filtered_effect_start',
FilteredEffectEnd = 'filtered_effect_end',
}
export type SuggestionActionPayload<T extends SuggestionActionType> = {
[SuggestionActionType.SetInitialized]?: undefined;
[SuggestionActionType.Refresh]?: undefined;
[SuggestionActionType.SetParseDataAndEditorPath]?: {
parseData: ExpressionEditorParseData;
editorPath: number[];
};
[SuggestionActionType.ClearParseDataAndEditorPath]?: undefined;
[SuggestionActionType.SetVariableTree]: ExpressionEditorTreeNode[];
[SuggestionActionType.SetVisible]: boolean;
[SuggestionActionType.SetAllowVisibleChange]: boolean;
[SuggestionActionType.SetHiddenDOM]: boolean;
[SuggestionActionType.SetRect]: {
top: number;
left: number;
};
[SuggestionActionType.SetSelected]?: ExpressionEditorTreeNode;
[SuggestionActionType.SetEmptyContent]?: string;
[SuggestionActionType.SetMatchTreeBranch]:
| ExpressionEditorTreeNode[]
| undefined;
[SuggestionActionType.SearchEffectStart]?: undefined;
[SuggestionActionType.SearchEffectEnd]?: undefined;
[SuggestionActionType.FilteredEffectStart]?: undefined;
[SuggestionActionType.FilteredEffectEnd]?: undefined;
}[T];
export interface SuggestionAction<
T extends SuggestionActionType = SuggestionActionType,
> {
type: SuggestionActionType;
payload?: SuggestionActionPayload<T>;
}
export type SuggestionReducer = [SuggestionState, Dispatch<SuggestionAction>];

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.
*/
export enum ExpressionEditorEvent {
Change = 'change',
Select = 'select',
Dispose = 'dispose',
CompositionStart = 'compositionStart',
}
export enum ExpressionEditorToken {
Start = '{',
End = '}',
FullStart = '{{',
FullEnd = '}}',
Separator = '.',
ArrayStart = '[',
ArrayEnd = ']',
}
export enum ExpressionEditorSegmentType {
ObjectKey = 'object_key',
ArrayIndex = 'array_index',
EndEmpty = 'end_empty',
}
export enum ExpressionEditorSignal {
Line = 'paragraph',
Valid = 'valid',
Invalid = 'invalid',
SelectedValid = 'selectedValid',
SelectedInvalid = 'selectedInvalid',
}

View File

@@ -0,0 +1,55 @@
/*
* 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 {
ExpressionEditorEvent,
ExpressionEditorToken,
ExpressionEditorSegmentType,
ExpressionEditorSignal,
} from './constant';
export {
ExpressionEditorEventParams,
ExpressionEditorEventDisposer,
ExpressionEditorSegment,
ExpressionEditorVariable,
ExpressionEditorTreeNode,
ExpressionEditorParseData,
ExpressionEditorLine,
ExpressionEditorValidateData,
ExpressionEditorRange,
} from './type';
export type { SelectorBoxConfigEntity } from '@flowgram-adapter/free-layout-editor';
export type { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
export {
ExpressionEditorLeaf,
ExpressionEditorSuggestion,
ExpressionEditorRender,
ExpressionEditorCounter,
} from './components';
export { ExpressionEditorModel } from './model';
export { ExpressionEditorParser } from './parser';
export { ExpressionEditorTreeHelper } from './tree-helper';
export { ExpressionEditorValidator } from './validator';
export { useSuggestionReducer } from './components/suggestion/state';
export {
useListeners,
useSelectNode,
useKeyboardSelect,
useRenderEffect,
} from './components/suggestion/hooks';

View File

@@ -0,0 +1,327 @@
/*
* 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 { CompositionEventHandler, KeyboardEventHandler } from 'react';
import { type ReactEditor, withReact } from 'slate-react';
import { withHistory } from 'slate-history';
import {
Text,
type NodeEntry,
Transforms,
createEditor,
Range,
type BaseEditor,
Editor,
} from 'slate';
import EventEmitter from 'eventemitter3';
import { ExpressionEditorValidator } from '../validator';
import type {
ExpressionEditorEventParams,
ExpressionEditorRange,
ExpressionEditorEventDisposer,
ExpressionEditorLine,
ExpressionEditorTreeNode,
ExpressionEditorValidateData,
} from '../type';
import { ExpressionEditorParser } from '../parser';
import {
ExpressionEditorEvent,
ExpressionEditorToken,
ExpressionEditorSignal,
} from '../constant';
export class ExpressionEditorModel {
public readonly editor: BaseEditor & ReactEditor;
protected innerValue: string;
protected innerFocus: boolean;
protected innerLines: ExpressionEditorLine[];
protected innerVariableTree: ExpressionEditorTreeNode[];
protected emitter: EventEmitter;
constructor(initialValue: string) {
this.emitter = new EventEmitter();
this.editor = withReact(withHistory(createEditor()));
this.innerValue = initialValue;
this.innerLines = ExpressionEditorParser.deserialize(initialValue);
}
/** 设置变量树 */
public setVariableTree(variableTree: ExpressionEditorTreeNode[]): void {
this.innerVariableTree = variableTree;
}
/** 获取变量树 */
public get variableTree(): ExpressionEditorTreeNode[] {
return this.innerVariableTree;
}
/** 获取行数据 */
public get lines(): ExpressionEditorLine[] {
return this.innerLines;
}
/** 获取序列化值 */
public get value(): string {
return this.innerValue;
}
/** 外部设置模型值 */
public setValue(value: string): void {
if (value === this.innerValue) {
return;
}
this.innerValue = value;
this.innerLines = ExpressionEditorParser.deserialize(value);
this.syncEditorValue();
}
/** 同步选中状态 */
public setFocus(focus: boolean): void {
if (this.innerFocus === focus) {
return;
}
this.innerFocus = focus;
if (focus) {
// 首次选中时主动触发选区事件,主动触发变量推荐
this.select(this.lines);
} else if (this.innerValue !== '' && this.editor.children.length !== 0) {
// 触发失焦且编辑器内容不为空,则重置选区
Transforms.select(this.editor, Editor.start(this.editor, []));
}
}
/** 注册事件 */
public on<T extends ExpressionEditorEvent>(
event: T,
callback: (params: ExpressionEditorEventParams<T>) => void,
): ExpressionEditorEventDisposer {
this.emitter.on(event, callback);
return () => {
this.emitter.off(event, callback);
};
}
/** 数据变更事件 */
public change(lines: ExpressionEditorLine[]): void {
const isAstChange = this.editor.operations.some(
op => 'set_selection' !== op.type,
);
if (!isAstChange) {
return;
}
this.innerLines = lines;
this.innerValue = ExpressionEditorParser.serialize(lines);
this.emitter.emit(ExpressionEditorEvent.Change, {
lines,
value: this.innerValue,
});
}
/** 选中事件 */
public select(lines: ExpressionEditorLine[]): void {
const { selection } = this.editor;
if (!selection || !Range.isCollapsed(selection)) {
return;
}
if (
selection.anchor.offset !== selection.focus.offset ||
selection.anchor.path[0] !== selection.focus.path[0] ||
selection.anchor.path[1] !== selection.focus.path[1]
) {
// 框选
this.emitter.emit(ExpressionEditorEvent.Select, {
content: '',
offset: -1,
});
return;
}
const cursorOffset = selection.anchor.offset;
const lineIndex = selection.anchor.path[0];
const contentIndex = selection.anchor.path[1];
const line = lines[lineIndex];
if (!line) {
return;
}
const content = line.children[contentIndex];
const cursorContent = content?.text;
if (typeof cursorContent !== 'string') {
return;
}
this.emitter.emit(ExpressionEditorEvent.Select, {
content: cursorContent,
offset: cursorOffset,
path: selection.anchor.path,
});
}
/** 键盘事件 */
public keydown(
event: Parameters<KeyboardEventHandler<HTMLDivElement>>[0],
): void {
if (event.key === ExpressionEditorToken.Start) {
event.preventDefault();
Transforms.insertText(
this.editor,
ExpressionEditorToken.FullStart + ExpressionEditorToken.FullEnd,
);
Transforms.move(this.editor, {
distance: 2,
reverse: true,
});
setTimeout(() => {
// slate UI 渲染滞后
this.select(this.innerLines);
}, 0);
}
if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case 'a':
event.preventDefault();
Transforms.select(this.editor, {
anchor: Editor.start(this.editor, []),
focus: Editor.end(this.editor, []),
});
return;
default:
return;
}
}
}
/** 开始输入拼音 */
public compositionStart(
event: CompositionEventHandler<HTMLDivElement>,
): void {
this.emitter.emit(ExpressionEditorEvent.CompositionStart, {
event,
});
}
/** 装饰叶子节点 */
public get decorate(): ([node, path]: NodeEntry) => ExpressionEditorRange[] {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const decorateFn = ([node, path]: NodeEntry): ExpressionEditorRange[] => {
const ranges: ExpressionEditorRange[] = [];
if (!Text.isText(node)) {
return ranges;
}
// 计算表达式合法/非法
const validateList = ExpressionEditorValidator.lineTextValidate({
lineText: node.text,
tree: self.innerVariableTree,
});
validateList.forEach(validateData => {
const { start, end, valid } = validateData;
const rangePath = {
anchor: { path, offset: start },
focus: { path, offset: end },
};
if (valid) {
ranges.push({
type: ExpressionEditorSignal.Valid,
...rangePath,
});
} else {
ranges.push({
type: ExpressionEditorSignal.Invalid,
...rangePath,
});
}
});
if (!this.innerFocus) {
return ranges;
}
// 以下是计算当前选中表达式逻辑
const selectedItem = self.isValidateSelectPath([node, path]);
const selectedValidItem = validateList.find(
validateData =>
validateData.valid &&
validateData.start === selectedItem?.start &&
validateData.end === selectedItem?.end,
);
if (selectedItem && selectedValidItem) {
ranges.push({
type: ExpressionEditorSignal.SelectedValid,
anchor: { path, offset: selectedItem.start },
focus: { path, offset: selectedItem.end },
});
} else if (selectedItem && !selectedValidItem) {
ranges.push({
type: ExpressionEditorSignal.SelectedInvalid,
anchor: { path, offset: selectedItem.start },
focus: { path, offset: selectedItem.end },
});
}
return ranges;
};
return decorateFn;
}
/**
* 同步编辑器实例内容
* > **NOTICE:** *为确保不影响性能,应仅在外部值变更导致编辑器值与模型值不一致时调用*
*/
private syncEditorValue(): void {
// 删除编辑器内所有行
this.editor.children.forEach((line, index) => {
Transforms.removeNodes(this.editor, {
at: [index],
});
});
// 重新在编辑器插入当前行内容
this.lines.forEach((line, index) => {
Transforms.insertNodes(this.editor, line, {
at: [this.editor.children.length],
});
});
}
private isValidateSelectPath([node, path]: NodeEntry):
| ExpressionEditorValidateData
| undefined {
if (!Text.isText(node)) {
return;
}
const { selection } = this.editor;
if (!selection) {
return;
}
const cursorOffset = selection.anchor.offset;
const lineIndex = selection.anchor.path[0];
const contentIndex = selection.anchor.path[1];
if (lineIndex !== path[0] || contentIndex !== path[1]) {
return;
}
const lineContent = node.text;
const lineOffset = cursorOffset;
const parsedData = ExpressionEditorParser.parse({
lineContent,
lineOffset,
});
if (!parsedData) {
return;
}
return {
start: parsedData.offset.lastStart - 1,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
end: parsedData.offset.firstEnd + 2,
valid: true,
};
}
}

View File

@@ -0,0 +1,509 @@
/*
* 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 'reflect-metadata';
import { type ExpressionEditorParseData } from '../type';
import { ExpressionEditorSegmentType } from '../constant';
import { ExpressionEditorParser, ExpressionEditorParserBuiltin } from '.';
describe('ExpressionEditorParserBuiltin', () => {
it('tokenIndex', () => {
const result = ExpressionEditorParserBuiltin.tokenOffset({
lineContent: 'test input: {{Earth.Asia.China.Hangzhou}}',
lineOffset: 39,
});
expect(result).toEqual({
lastStartTokenOffset: 13,
firstEndTokenOffset: 39,
});
});
it('extractContent', () => {
const result = ExpressionEditorParserBuiltin.extractContent({
lineContent: 'test input: {{Earth.Asia.China.Hangzhou}}',
lineOffset: 39,
lastStartTokenOffset: 13,
firstEndTokenOffset: 39,
});
expect(result).toEqual({
content: 'Earth.Asia.China.Hangzhou',
offset: 25,
});
});
it('sliceReachable', () => {
const result = ExpressionEditorParserBuiltin.sliceReachable({
content: 'China.Hangzhou',
offset: 6,
});
expect(result).toEqual({
reachable: 'China.',
unreachable: 'Hangzhou',
});
});
describe('splitPath', () => {
it('pure object keys', () => {
const result = ExpressionEditorParserBuiltin.splitText(
'Earth.Asia.China.Hangzhou',
);
expect(result).toEqual(['Earth', 'Asia', 'China', 'Hangzhou']);
});
it('pure object keys with number type', () => {
const result = ExpressionEditorParserBuiltin.splitText(
'Earth.Asia.China.0.Hangzhou',
);
expect(result).toEqual(['Earth', 'Asia', 'China', '0', 'Hangzhou']);
});
it('object keys and array indexes', () => {
const result = ExpressionEditorParserBuiltin.splitText(
'Earth.Asia.China[0].Hangzhou',
);
expect(result).toEqual(['Earth', 'Asia', 'China', '[0]', 'Hangzhou']);
});
it('object keys and individual array indexes', () => {
const result = ExpressionEditorParserBuiltin.splitText(
'Earth.Asia.China.[0].Hangzhou',
);
expect(result).toEqual(['Earth', 'Asia', 'China', '', '[0]', 'Hangzhou']);
});
it('continues array index', () => {
const result =
ExpressionEditorParserBuiltin.splitText('Hangzhou[0][0][0]');
expect(result).toEqual(['Hangzhou[0][0]', '[0]']);
});
it('continues array index start with separator', () => {
const result =
ExpressionEditorParserBuiltin.splitText('Hangzhou.[0][0][0]');
expect(result).toEqual(['Hangzhou', '[0][0]', '[0]']);
});
it('start with array index', () => {
const result = ExpressionEditorParserBuiltin.splitText('[0].Hangzhou');
expect(result).toEqual(['', '[0]', 'Hangzhou']);
});
it('object keys with empty', () => {
const result =
ExpressionEditorParserBuiltin.splitText('Earth...Hangzhou');
expect(result).toEqual(['Earth', '', '', 'Hangzhou']);
});
it('object keys and end with empty', () => {
const result = ExpressionEditorParserBuiltin.splitText('China.Hangzhou.');
expect(result).toEqual(['China', 'Hangzhou', '']);
});
it('object keys and start with empty', () => {
const result = ExpressionEditorParserBuiltin.splitText('.China.Hangzhou');
expect(result).toEqual(['', 'China', 'Hangzhou']);
});
it('all empty', () => {
const result = ExpressionEditorParserBuiltin.splitText('..');
expect(result).toEqual(['', '', '']);
});
});
describe('textToPath', () => {
it('pure object keys', () => {
const result = ExpressionEditorParserBuiltin.toSegments('China.Hangzhou');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'China',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'Hangzhou',
},
]);
});
it('pure object keys with number type', () => {
const result =
ExpressionEditorParserBuiltin.toSegments('China.0.Hangzhou');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'China',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: '0',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'Hangzhou',
},
]);
});
it('object keys and array indexes', () => {
const result =
ExpressionEditorParserBuiltin.toSegments('China[0].Hangzhou');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'China',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 0,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'Hangzhou',
},
]);
});
it('object keys and end with empty', () => {
const result = ExpressionEditorParserBuiltin.toSegments(
'China_Zhejiang.Hangzhou.',
);
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'China_Zhejiang',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'Hangzhou',
},
{
type: ExpressionEditorSegmentType.EndEmpty,
index: 2,
},
]);
});
it('should be undefined', () => {
const invalidPatterns = [
'foo..bar',
'..',
'.foo',
'foo[]',
'foo.[]',
'foo.[0]',
'foo[0',
'foo[0.',
'foo[0].{a}',
'foo[0][0]',
'foo[0].[0]',
'foo.[0].[0]',
'[]foo',
'.[]foo',
'[.]foo',
'[].foo',
'[0].foo',
'.[0].foo',
'{a}',
'foo-bar',
'😊[0]',
];
invalidPatterns.forEach(pattern => {
const result = ExpressionEditorParserBuiltin.toSegments(pattern);
expect(result).toBeUndefined();
});
});
});
});
describe('textToPath unicode', () => {
it('pure object keys', () => {
const result = ExpressionEditorParserBuiltin.toSegments('主题.名称');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: '主题',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: '名称',
},
]);
});
it('pure object keys with number type', () => {
const result = ExpressionEditorParserBuiltin.toSegments('主题.0.名称');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: '主题',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: '0',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: '名称',
},
]);
});
it('object keys and array indexes', () => {
const result = ExpressionEditorParserBuiltin.toSegments('主题[0].名称');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: '主题',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 0,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: '名称',
},
]);
});
it('object keys and end with empty', () => {
const result = ExpressionEditorParserBuiltin.toSegments('主题.名称.');
expect(result).toEqual([
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: '主题',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: '名称',
},
{
type: ExpressionEditorSegmentType.EndEmpty,
index: 2,
},
]);
});
});
describe('ExpressionEditorParser parse should be successful', () => {
it('parse object keys', () => {
const result = ExpressionEditorParser.parse({
lineContent: 'test: {{foo.bar}}',
lineOffset: 15,
});
const expected: ExpressionEditorParseData = {
content: {
line: 'test: {{foo.bar}}',
inline: 'foo.bar',
reachable: 'foo.bar',
unreachable: '',
},
offset: {
line: 15,
inline: 7,
lastStart: 7,
firstEnd: 15,
},
segments: {
inline: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'bar',
},
],
reachable: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'bar',
},
],
},
};
expect(result).toEqual(expected);
});
it('parse array indexes', () => {
const result = ExpressionEditorParser.parse({
lineContent: 'test: {{foo[0].bar}}',
lineOffset: 18,
});
const expected: ExpressionEditorParseData = {
content: {
line: 'test: {{foo[0].bar}}',
inline: 'foo[0].bar',
reachable: 'foo[0].bar',
unreachable: '',
},
offset: {
line: 18,
inline: 10,
lastStart: 7,
firstEnd: 18,
},
segments: {
inline: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 0,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'bar',
},
],
reachable: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 0,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'bar',
},
],
},
};
expect(result).toEqual(expected);
});
it('parse end with empty', () => {
const result = ExpressionEditorParser.parse({
lineContent: 'test: {{foo.bar}}',
lineOffset: 12,
});
const expected: ExpressionEditorParseData = {
content: {
line: 'test: {{foo.bar}}',
inline: 'foo.bar',
reachable: 'foo.',
unreachable: 'bar',
},
offset: { line: 12, inline: 4, lastStart: 7, firstEnd: 15 },
segments: {
inline: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'bar',
},
],
reachable: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 1 },
],
},
};
expect(result).toEqual(expected);
});
it('empty {{content}}', () => {
const result = ExpressionEditorParser.parse({
lineContent: 'test: {{}}',
lineOffset: 8,
});
const expected: ExpressionEditorParseData = {
content: {
line: 'test: {{}}',
inline: '',
reachable: '',
unreachable: '',
},
offset: { line: 8, inline: 0, lastStart: 7, firstEnd: 8 },
segments: {
inline: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
reachable: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
},
};
expect(result).toEqual(expected);
});
it('only empty {{content}}', () => {
const result = ExpressionEditorParser.parse({
lineContent: '{{}}',
lineOffset: 2,
});
const expected: ExpressionEditorParseData = {
content: { line: '{{}}', inline: '', reachable: '', unreachable: '' },
offset: { line: 2, inline: 0, lastStart: 1, firstEnd: 2 },
segments: {
inline: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
reachable: [{ type: ExpressionEditorSegmentType.EndEmpty, index: 0 }],
},
};
expect(result).toEqual(expected);
});
});
describe('ExpressionEditorParser parse should be fail', () => {
it('out of bucket', () => {
const result = ExpressionEditorParser.parse({
lineContent: 'test: {{foo.bar}}',
lineOffset: 7,
});
expect(result).toBeUndefined();
});
it('dangling null pointer', () => {
const result = ExpressionEditorParser.parse({
lineContent: '{{foo.bar}}',
lineOffset: 12,
});
expect(result).toBeUndefined();
});
it('empty content with not zero offset', () => {
const result = ExpressionEditorParser.parse({
lineContent: '',
lineOffset: 1,
});
expect(result).toBeUndefined();
});
it('invalid char', () => {
const result = ExpressionEditorParser.parse({
lineContent: '{{foo(0).bar}}',
lineOffset: 12,
});
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,272 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-namespace */
import { Node } from 'slate';
import type {
ExpressionEditorLine,
ExpressionEditorParseData,
ExpressionEditorSegment,
} from '../type';
import {
ExpressionEditorSegmentType,
ExpressionEditorSignal,
ExpressionEditorToken,
} from '../constant';
export namespace ExpressionEditorParserBuiltin {
/** 计算开始和结束标识的序号 */
export const tokenOffset = (line: {
lineContent: string;
lineOffset: number;
}):
| {
lastStartTokenOffset: number;
firstEndTokenOffset: number;
}
| undefined => {
const { lineContent: content, lineOffset: offset } = line;
const firstEndTokenOffset = content.indexOf(
ExpressionEditorToken.End,
offset,
);
const endChars = content.slice(
firstEndTokenOffset,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
firstEndTokenOffset + 2,
);
if (endChars !== ExpressionEditorToken.FullEnd) {
// 结束符号 "}}" 不完整
return;
}
const lastStartTokenOffset = content.lastIndexOf(
ExpressionEditorToken.Start,
offset - 1,
);
const startChars = content.slice(
lastStartTokenOffset - 1,
lastStartTokenOffset + 1,
);
if (startChars !== ExpressionEditorToken.FullStart) {
// 开始符号 "{{" 不完整
return;
}
return {
lastStartTokenOffset,
firstEndTokenOffset,
};
};
/** 从行内容提取内容 */
export const extractContent = (params: {
lineContent: string;
lineOffset: number;
lastStartTokenOffset: number;
firstEndTokenOffset: number;
}):
| {
content: string;
offset: number;
}
| undefined => {
const {
lineContent,
lineOffset,
lastStartTokenOffset,
firstEndTokenOffset,
} = params;
const content = lineContent.slice(
lastStartTokenOffset + 1,
firstEndTokenOffset,
);
const offset = lineOffset - lastStartTokenOffset - 1;
return {
content,
offset,
};
};
/** 根据 offset 将文本内容切分为可用与不可用 */
export const sliceReachable = (params: {
content: string;
offset: number;
}): {
reachable: string;
unreachable: string;
} => {
const { content, offset } = params;
const reachable = content.slice(0, offset);
const unreachable = content.slice(offset, content.length);
return {
reachable,
unreachable,
};
};
/** 切分文本 */
export const splitText = (pathString: string): string[] => {
// 得到的分割数组,初始为原字符串以"."分割的结果
const segments = pathString.split(ExpressionEditorToken.Separator);
// 定义结果数组,并处理连续的"."导致的空字符串
const result: string[] = [];
segments.forEach(segment => {
if (!segment.match(/\[\d+\]/)) {
// 如果不是数组索引,直接加入结果数组,即使是空字符串也加入以保持正确的分割
result.push(segment);
return;
}
// 如果当前段是数组索引,将前面的字符串和当前数组索引分别加入结果数组
const lastSegmentIndex = segment.lastIndexOf(
ExpressionEditorToken.ArrayStart,
);
const key = segment.substring(0, lastSegmentIndex);
const index = segment.substring(lastSegmentIndex);
// {{array[0]}} 中的 array
result.push(key);
// {{array[0]}} 中的 [0]
result.push(index);
return;
});
return result;
};
/** 字符串解析为路径 */
export const toSegments = (
text: string,
): ExpressionEditorSegment[] | undefined => {
const textSegments = ExpressionEditorParserBuiltin.splitText(text);
const segments: ExpressionEditorSegment[] = [];
const validate = textSegments.every((textSegment, index) => {
// 数组下标
if (
textSegment.startsWith(ExpressionEditorToken.ArrayStart) &&
textSegment.endsWith(ExpressionEditorToken.ArrayEnd)
) {
const arrayIndexString = textSegment.slice(1, -1);
const arrayIndex = Number(arrayIndexString);
if (arrayIndexString === '' || Number.isNaN(arrayIndex)) {
// index 必须是数字
return false;
}
const lastSegment = segments[segments.length - 1];
if (
!lastSegment ||
lastSegment.type !== ExpressionEditorSegmentType.ObjectKey
) {
// 数组索引必须在 key 之后
return false;
}
segments.push({
type: ExpressionEditorSegmentType.ArrayIndex,
index,
arrayIndex,
});
}
// 最后一行空文本
else if (index === textSegments.length - 1 && textSegment === '') {
segments.push({
type: ExpressionEditorSegmentType.EndEmpty,
index,
});
} else {
if (!textSegment || !/^[\u4e00-\u9fa5_a-zA-Z0-9]*$/.test(textSegment)) {
return false;
}
segments.push({
type: ExpressionEditorSegmentType.ObjectKey,
index,
objectKey: textSegment,
});
}
return true;
});
if (!validate) {
return undefined;
}
return segments;
};
}
export namespace ExpressionEditorParser {
/** 序列化 */
export const serialize = (value: ExpressionEditorLine[]) =>
value.map(n => Node.string(n)).join('\n');
/** 反序列化 */
export const deserialize = (text: string): ExpressionEditorLine[] => {
const lines = text.split('\n');
return lines.map(line => ({
type: ExpressionEditorSignal.Line,
children: [{ text: line || '' }],
}));
};
export const parse = (line: {
lineContent: string;
lineOffset: number;
}): ExpressionEditorParseData | undefined => {
const { lineContent, lineOffset } = line;
const tokenOffsets = ExpressionEditorParserBuiltin.tokenOffset(line);
if (!tokenOffsets) {
return;
}
const { lastStartTokenOffset, firstEndTokenOffset } = tokenOffsets;
const extractedContent = ExpressionEditorParserBuiltin.extractContent({
...line,
...tokenOffsets,
});
if (!extractedContent) {
return;
}
const { content, offset } = extractedContent;
const slicedReachable =
ExpressionEditorParserBuiltin.sliceReachable(extractedContent);
if (!slicedReachable) {
return;
}
const reachableSegments = ExpressionEditorParserBuiltin.toSegments(
slicedReachable.reachable,
);
const inlineSegments = ExpressionEditorParserBuiltin.toSegments(content);
if (!reachableSegments) {
return;
}
return {
content: {
line: lineContent,
inline: content,
reachable: slicedReachable.reachable,
unreachable: slicedReachable.unreachable,
},
offset: {
line: lineOffset,
inline: offset,
lastStart: lastStartTokenOffset,
firstEnd: firstEndTokenOffset,
},
segments: {
inline: inlineSegments,
reachable: reachableSegments,
},
};
};
}

View File

@@ -0,0 +1,570 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import 'reflect-metadata';
import { ViewVariableType } from '@coze-workflow/base';
import type {
ExpressionEditorTreeNode,
ExpressionEditorSegment,
ExpressionEditorVariable,
} from '../type';
import { ExpressionEditorSegmentType } from '../constant';
import { ExpressionEditorTreeHelper } from '.';
vi.mock('@coze-workflow/base', () => {
enum VariableType {
String = 1,
Integer,
Boolean,
Number,
Object = 6,
ArrayString = 99,
ArrayInteger,
ArrayBoolean,
ArrayNumber,
ArrayObject,
}
return {
ViewVariableType: {
...VariableType,
isArrayType: (type: VariableType): boolean => {
const arrayTypes = [
VariableType.ArrayString,
VariableType.ArrayInteger,
VariableType.ArrayBoolean,
VariableType.ArrayNumber,
VariableType.ArrayObject,
];
return arrayTypes.includes(type);
},
},
};
});
describe('ExpressionEditorTreeHelper pruning', () => {
let defaultTree: ExpressionEditorTreeNode[];
let defaultSegments: ExpressionEditorSegment[];
beforeEach(() => {
defaultTree = [
{
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.ArrayObject,
} as ExpressionEditorVariable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.Object,
} as ExpressionEditorVariable,
children: [
{
label: 'baz',
value: 'baz',
key: 'baz',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
},
],
},
],
},
];
defaultSegments = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'bar',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 2 },
];
});
it('should pruning', () => {
const result = ExpressionEditorTreeHelper.pruning({
tree: defaultTree,
segments: defaultSegments,
});
expect(result).toEqual([
{
label: 'baz',
value: 'baz',
key: 'baz',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
},
]);
});
it('should not pruning', () => {
const result = ExpressionEditorTreeHelper.pruning({
tree: defaultTree,
segments: [],
});
expect(result).toEqual(defaultTree);
});
it('should be empty', () => {
const result = ExpressionEditorTreeHelper.pruning({
tree: defaultTree,
segments: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'trs',
},
],
});
expect(result).toEqual(defaultTree);
});
it('should pruning and ignore array index segments', () => {
const result = ExpressionEditorTreeHelper.pruning({
tree: defaultTree,
segments: [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 10,
},
],
});
expect(result).toEqual(defaultTree);
});
});
describe('ExpressionEditorTreeHelper fullPath without segments', () => {
it('return node full path', () => {
const node = {
label: 'bar',
value: 'bar',
key: 'bar',
variable: {} as ExpressionEditorVariable,
parent: {
label: 'foo',
value: 'foo',
key: 'foo',
variable: {} as ExpressionEditorVariable,
},
};
const fullString = ExpressionEditorTreeHelper.concatFullPath({
node,
segments: [],
});
expect(fullString).toEqual('foo.bar');
});
});
describe('ExpressionEditorTreeHelper fullPath with segments', () => {
it('return node full path', () => {
const node = {
label: 'bar',
value: 'bar',
key: 'bar',
variable: {} as ExpressionEditorVariable,
parent: {
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.ArrayObject,
} as ExpressionEditorVariable,
},
};
const segments: ExpressionEditorSegment[] = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 10,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'bar',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
];
const fullString = ExpressionEditorTreeHelper.concatFullPath({
node,
segments,
});
expect(fullString).toEqual('foo[10].bar');
});
});
describe('ExpressionEditorTreeHelper matchBranch', () => {
it('match tree branch', () => {
const tree: ExpressionEditorTreeNode[] = [
{
label: 'foo',
value: 'foo',
key: 'foo',
variable: {} as ExpressionEditorVariable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.ArrayObject,
} as ExpressionEditorVariable,
children: [
{
label: 'baz',
value: 'baz',
key: 'baz',
variable: {} as ExpressionEditorVariable,
},
],
},
],
},
];
const segments: ExpressionEditorSegment[] = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'bar',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 2,
arrayIndex: 10,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 1,
objectKey: 'baz',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
];
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
expect(treeBranch).not.toBeUndefined();
});
it('match tree branch failed with incorrect array index', () => {
const tree: ExpressionEditorTreeNode[] = [
{
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
},
],
},
];
const segments: ExpressionEditorSegment[] = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 10,
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'bar',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
];
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
expect(treeBranch).toBeUndefined();
});
it('match tree branch failed, array object without index before sub item', () => {
const tree: ExpressionEditorTreeNode[] = [
{
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.ArrayObject,
} as ExpressionEditorVariable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
},
],
},
];
const segments: ExpressionEditorSegment[] = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 2,
objectKey: 'bar',
},
{ type: ExpressionEditorSegmentType.EndEmpty, index: 3 },
];
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
expect(treeBranch).toBeUndefined();
});
it('match tree branch failed with constant follow array index', () => {
const tree: ExpressionEditorTreeNode[] = [
{
label: 'foo',
value: 'foo',
key: 'foo',
},
];
const segments: ExpressionEditorSegment[] = [
{
type: ExpressionEditorSegmentType.ObjectKey,
index: 0,
objectKey: 'foo',
},
{
type: ExpressionEditorSegmentType.ArrayIndex,
index: 1,
arrayIndex: 10,
},
];
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
expect(treeBranch).toBeUndefined();
});
});
describe('ExpressionEditorTreeHelper findAvailableVariables & createVariableTree', () => {
let variables: ExpressionEditorVariable[];
let inputs: {
name: string;
keyPath?: string[];
}[];
let availableVariables: ExpressionEditorTreeHelper.AvailableVariable[];
beforeEach(() => {
variables = [
{
key: 'G3UiXFzKjTefY_iu8U59Z',
type: 6,
name: 'obj',
children: [
{
key: 'klAhNVg0xasVuN-l3bZdw',
type: 1,
name: 'str',
},
{
key: 'j8Hp-0mQhGfW618h35Pql',
type: 4,
name: 'num',
},
],
nodeTitle: 'Code',
nodeId: '112561',
},
{
key: 'UVxP2tcAXIe2DXIeT1C-o',
type: 103,
name: 'arr_obj',
children: [
{
key: '7-id-zYuO7aBPiC48Jkk4',
type: 1,
name: 'str',
},
{
key: 'QHB4k7Z3k2VyTipg8rjlL',
type: 4,
name: 'num',
},
],
nodeTitle: 'Code',
nodeId: '112561',
},
{
key: 'GX1IupmKt-gaMKC54d1a4',
type: 99,
name: 'arr_str',
nodeTitle: 'Code',
nodeId: '112561',
},
];
inputs = [
{
name: 'ref_obj',
keyPath: ['112561', 'G3UiXFzKjTefY_iu8U59Z'],
},
{
name: 'ref_arr_obj',
keyPath: ['112561', 'UVxP2tcAXIe2DXIeT1C-o'],
},
{
name: 'test_ref',
keyPath: ['112561', 'G3UiXFzKjTefY_iu8U59Z', 'klAhNVg0xasVuN-l3bZdw'],
},
{
name: 'ref_arr_str',
keyPath: ['112561', 'GX1IupmKt-gaMKC54d1a4'],
},
{
name: 'constant',
keyPath: [],
},
];
availableVariables = [
{
name: 'ref_obj',
keyPath: ['G3UiXFzKjTefY_iu8U59Z'],
variable: {
key: 'G3UiXFzKjTefY_iu8U59Z',
type: 6,
name: 'obj',
children: [
{
key: 'klAhNVg0xasVuN-l3bZdw',
type: 1,
name: 'str',
},
{
key: 'j8Hp-0mQhGfW618h35Pql',
type: 4,
name: 'num',
},
],
nodeTitle: 'Code',
nodeId: '112561',
},
},
{
name: 'ref_arr_obj',
keyPath: ['UVxP2tcAXIe2DXIeT1C-o'],
variable: {
key: 'UVxP2tcAXIe2DXIeT1C-o',
type: 103,
name: 'arr_obj',
children: [
{
key: '7-id-zYuO7aBPiC48Jkk4',
type: 1,
name: 'str',
},
{
key: 'QHB4k7Z3k2VyTipg8rjlL',
type: 4,
name: 'num',
},
],
nodeTitle: 'Code',
nodeId: '112561',
},
},
{
name: 'test_ref',
keyPath: ['G3UiXFzKjTefY_iu8U59Z', 'klAhNVg0xasVuN-l3bZdw'],
variable: {
key: 'klAhNVg0xasVuN-l3bZdw',
type: 1,
name: 'str',
},
},
{
name: 'ref_arr_str',
keyPath: ['GX1IupmKt-gaMKC54d1a4'],
variable: {
key: 'GX1IupmKt-gaMKC54d1a4',
type: 99,
name: 'arr_str',
nodeTitle: 'Code',
nodeId: '112561',
},
},
{
name: 'constant',
},
];
});
it('find available variables', () => {
const results = ExpressionEditorTreeHelper.findAvailableVariables({
variables,
inputs,
});
expect(results).toEqual(availableVariables);
});
it('create variable tree', () => {
const results =
ExpressionEditorTreeHelper.createVariableTree(availableVariables);
expect(results.length).toEqual(5);
});
});

View File

@@ -0,0 +1,296 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable @typescript-eslint/no-magic-numbers */
import { ViewVariableType } from '@coze-workflow/base/types';
import type {
ExpressionEditorTreeNode,
ExpressionEditorSegment,
ExpressionEditorVariable,
} from '../type';
import { ExpressionEditorSegmentType } from '../constant';
export namespace ExpressionEditorTreeHelper {
export interface Input {
name: string;
keyPath?: string[];
children?: Input[];
}
export interface AvailableVariable extends Input {
variable?: ExpressionEditorVariable;
children?: AvailableVariable[];
}
const findAvailableVariable = (params: {
variables: ExpressionEditorVariable[];
input: Input;
}): AvailableVariable => {
const { variables, input } = params;
if (!input.keyPath) {
return {
name: input.name,
};
}
const nodeId = input.keyPath.shift();
const nodePath = input.keyPath;
const nodeVariables = variables.filter(
variable => variable.nodeId === nodeId,
);
let variable: ExpressionEditorVariable | undefined;
nodePath.reduce(
(
availableVariables: ExpressionEditorVariable[],
path: string,
index: number,
) => {
const targetVariable = availableVariables.find(
availableVariable => availableVariable.key === path,
);
if (index === nodePath.length - 1) {
variable = targetVariable;
}
if (targetVariable && targetVariable.children) {
return targetVariable.children;
}
return [];
},
nodeVariables,
);
if (!variable) {
return {
name: input.name,
};
}
return {
name: input.name,
keyPath: input.keyPath,
variable,
};
};
export const findAvailableVariables = (params: {
variables: ExpressionEditorVariable[];
inputs: Input[];
}): AvailableVariable[] => {
const { variables, inputs } = params;
return inputs.map(input => {
const availableVariable = findAvailableVariable({ input, variables });
if (input.children?.length) {
availableVariable.children = findAvailableVariables({
variables,
inputs: input.children || [],
});
}
return availableVariable;
});
};
const createVariableLeaves = (
variables: ExpressionEditorVariable[],
parent: ExpressionEditorTreeNode,
): ExpressionEditorTreeNode[] =>
variables.map(
(variable: ExpressionEditorVariable): ExpressionEditorTreeNode => {
const node: ExpressionEditorTreeNode = {
label: variable.name,
value: `${parent.value}.${variable.key}`,
key: `${parent.value}.${variable.key}`,
variable,
parent,
};
node.children = createVariableLeaves(variable.children || [], node);
return node;
},
);
export const createVariableTree = (
availableVariables: AvailableVariable[],
parent?: ExpressionEditorTreeNode,
): ExpressionEditorTreeNode[] =>
availableVariables.map(
(availableVariable: AvailableVariable): ExpressionEditorTreeNode => {
const path = parent
? `${parent.key}.${availableVariable.name}`
: availableVariable.name;
const node: ExpressionEditorTreeNode = {
label: availableVariable.name,
value: availableVariable.keyPath?.join('.') || path,
key: path,
keyPath: availableVariable.keyPath,
variable: availableVariable.variable,
parent,
};
if (availableVariable.children?.length) {
node.children = createVariableTree(availableVariable.children, node);
} else {
node.children = createVariableLeaves(
availableVariable.variable?.children || [],
node,
);
}
return node;
},
);
export const pruning = (params: {
tree: ExpressionEditorTreeNode[];
segments: ExpressionEditorSegment[];
}): ExpressionEditorTreeNode[] => {
const { tree, segments } = params;
if (segments.length === 0) {
return tree;
}
const lastSegment = segments[segments.length - 1];
const segmentsRemovedLast =
lastSegment.type === ExpressionEditorSegmentType.ArrayIndex
? segments.slice(0, segments.length - 2) // 数组索引属于上一层级,需要去除两层
: segments.slice(0, segments.length - 1);
let treeLayer = tree;
segmentsRemovedLast.forEach(segment => {
if (segment.type !== ExpressionEditorSegmentType.ObjectKey) {
return;
}
const treeChild = treeLayer.find(
node => node.label === segment.objectKey,
);
if (treeChild) {
treeLayer = treeChild.children || [];
} else {
treeLayer = [];
}
});
return treeLayer;
};
export const concatFullPath = (params: {
node: ExpressionEditorTreeNode;
segments: ExpressionEditorSegment[];
}): string => {
const { node, segments } = params;
let current: ExpressionEditorTreeNode | undefined = node;
const pathList: { objectKey: string; arrayIndex?: number }[] = [];
while (current) {
if (current.variable?.type === ViewVariableType.ArrayObject) {
// 默认第0个
pathList.unshift({
objectKey: current.label,
arrayIndex: 0,
});
} else {
pathList.unshift({
objectKey: current.label,
});
}
current = current.parent;
}
let pathIndex = 0;
segments.find((segment, index) => {
if (segment.type !== ExpressionEditorSegmentType.ObjectKey) {
return false;
}
const pathItem = pathList[pathIndex];
pathIndex++;
if (pathItem.objectKey !== segment.objectKey) {
// 退出循环
return true;
}
const nextSegment = segments[index + 1];
if (
typeof pathItem.arrayIndex === 'number' &&
nextSegment?.type === ExpressionEditorSegmentType.ArrayIndex
) {
pathItem.arrayIndex = nextSegment.arrayIndex;
}
return false;
});
return pathList
.map((pathItem, index) => {
const isLastPathItem = index === pathList.length - 1;
if (typeof pathItem.arrayIndex === 'number' && !isLastPathItem) {
return `${pathItem.objectKey}[${pathItem.arrayIndex}]`;
}
return pathItem.objectKey;
})
.join('.');
};
export const matchTreeBranch = (params: {
tree: ExpressionEditorTreeNode[];
segments: ExpressionEditorSegment[];
}): ExpressionEditorTreeNode[] | undefined => {
const { tree, segments } = params;
const treeBranch: (ExpressionEditorTreeNode | null)[] = [];
let treeLayer = tree;
const invalid = segments.find((segment, index) => {
const itemInvalid = (): boolean => {
treeBranch.push(null);
return true;
};
const itemValid = (treeNode?: ExpressionEditorTreeNode): boolean => {
treeBranch.push(treeNode || null);
return false;
};
const beforeTreeNode = treeBranch[treeBranch.length - 1];
// 确认非法情况:是否对非数组类型使用数组索引
if (
segment.type === ExpressionEditorSegmentType.ArrayIndex &&
beforeTreeNode &&
(!beforeTreeNode.variable ||
!ViewVariableType.isArrayType(beforeTreeNode.variable.type))
) {
return itemInvalid();
}
// 确认非法情况:数组只能跟随数组下标
if (
beforeTreeNode?.variable?.type &&
ViewVariableType.isArrayType(beforeTreeNode.variable.type) &&
segment.type !== ExpressionEditorSegmentType.ArrayIndex
) {
return itemInvalid();
}
// 忽略
if (segment.type !== ExpressionEditorSegmentType.ObjectKey) {
return itemValid();
}
const treeNode = treeLayer.find(node => node.label === segment.objectKey);
// 确认非法情况:每一个 object key 必须对应一个 variable node
if (!treeNode) {
return itemInvalid();
}
treeLayer = treeNode.children || [];
return itemValid(treeNode);
});
const filteredTreeBranch = treeBranch.filter(
Boolean,
) as ExpressionEditorTreeNode[];
const filteredSegments = segments.filter(
segment => segment.type === ExpressionEditorSegmentType.ObjectKey,
);
if (invalid || filteredSegments.length !== filteredTreeBranch.length) {
return;
}
return filteredTreeBranch;
};
}

View File

@@ -0,0 +1,124 @@
/*
* 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 { CompositionEventHandler } from 'react';
import type { BaseElement, BaseRange } from 'slate';
import {
type StandardNodeType,
type ViewVariableMeta,
} from '@coze-workflow/base';
import type { TreeNodeData } from '@coze-arch/bot-semi/Tree';
import type {
ExpressionEditorEvent,
ExpressionEditorSegmentType,
ExpressionEditorSignal,
} from './constant';
export type ExpressionEditorEventParams<T extends ExpressionEditorEvent> = {
[ExpressionEditorEvent.Change]: {
lines: ExpressionEditorLine[];
value: string;
};
[ExpressionEditorEvent.Select]: {
content: string;
offset: number;
path: number[];
};
[ExpressionEditorEvent.Dispose]: undefined;
[ExpressionEditorEvent.CompositionStart]: {
event: CompositionEventHandler<HTMLDivElement>;
};
}[T];
export type ExpressionEditorEventDisposer = () => void;
export type ExpressionEditorSegment<
T extends ExpressionEditorSegmentType = ExpressionEditorSegmentType,
> = {
[ExpressionEditorSegmentType.ObjectKey]: {
type: ExpressionEditorSegmentType.ObjectKey;
index: number;
objectKey: string;
};
[ExpressionEditorSegmentType.ArrayIndex]: {
type: ExpressionEditorSegmentType.ArrayIndex;
index: number;
arrayIndex: number;
};
[ExpressionEditorSegmentType.EndEmpty]: {
type: ExpressionEditorSegmentType.EndEmpty;
index: number;
};
}[T];
export interface ExpressionEditorVariable extends ViewVariableMeta {
nodeTitle?: string;
nodeId?: string;
nodeType?: StandardNodeType;
}
export interface ExpressionEditorTreeNode extends TreeNodeData {
label: string;
value: string;
key: string;
keyPath?: string[];
variable?: ExpressionEditorVariable;
children?: ExpressionEditorTreeNode[];
parent?: ExpressionEditorTreeNode;
}
export interface ExpressionEditorParseData {
content: {
line: string;
inline: string;
reachable: string;
unreachable: string;
};
offset: {
line: number;
inline: number;
lastStart: number;
firstEnd: number;
};
segments: {
inline?: ExpressionEditorSegment[];
reachable: ExpressionEditorSegment[];
};
}
export interface ExpressionEditorLine extends BaseElement {
type: ExpressionEditorSignal.Line;
children: {
text: string;
}[];
}
export interface ExpressionEditorValidateData {
start: number;
end: number;
valid: boolean;
message?: string;
}
export interface ExpressionEditorRange extends BaseRange {
type:
| ExpressionEditorSignal.Valid
| ExpressionEditorSignal.Invalid
| ExpressionEditorSignal.SelectedValid
| ExpressionEditorSignal.SelectedInvalid;
}

View File

@@ -0,0 +1,160 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import 'reflect-metadata';
import type {
ExpressionEditorTreeNode,
ExpressionEditorVariable,
} from '../type';
import { ExpressionEditorValidator } from './index';
enum ViewVariableType {
String = 1,
Integer,
Boolean,
Number,
Object = 6,
ArrayString = 99,
ArrayInteger,
ArrayBoolean,
ArrayNumber,
ArrayObject,
}
vi.mock('@coze-arch/logger', () => ({
logger: {
createLoggerWith: vi.fn(),
},
reporter: {
createReporterWithPreset: vi.fn(),
},
}));
vi.mock('@coze-workflow/base', () => {
enum VariableType {
String = 1,
Integer,
Boolean,
Number,
Object = 6,
ArrayString = 99,
ArrayInteger,
ArrayBoolean,
ArrayNumber,
ArrayObject,
}
return {
ViewVariableType: {
...VariableType,
isArrayType: (type: VariableType): boolean => {
const arrayTypes = [
VariableType.ArrayString,
VariableType.ArrayInteger,
VariableType.ArrayBoolean,
VariableType.ArrayNumber,
VariableType.ArrayObject,
];
return arrayTypes.includes(type);
},
},
};
});
describe('ExpressionEditorValidatorBuiltin', () => {
describe('findPatterns', () => {
it('findPatterns should work', () => {
const patterns = ExpressionEditorValidator.findPatterns(
'first {{foo1.bar1}} second {{foo2.bar2}}',
);
expect(patterns).toEqual([
{
start: 6,
end: 19,
content: 'foo1.bar1',
},
{
start: 27,
end: 40,
content: 'foo2.bar2',
},
]);
});
it('findPatterns with empty content', () => {
const patterns = ExpressionEditorValidator.findPatterns('{{}}');
expect(patterns).toEqual([
{
start: 0,
end: 4,
content: '',
},
]);
});
it('findPatterns satisfies length', () => {
const lengthTests = {
'first {{foo1.bar1}}': 1,
'first {{foo1.bar1}} second {{foo2.bar2}}': 2,
'first {{foo1.bar1}} second {{foo2.bar2}} third {{foo3.bar3}}': 3,
'first| {{foo1.bar1}}': 1,
'first{} {{foo1.bar1}}': 1,
'first{} {{foo1.bar1}} {}': 1,
'{} {} {} {{ {{}} }{}{}{': 1,
};
for (const [input, expected] of Object.entries(lengthTests)) {
const patterns = ExpressionEditorValidator.findPatterns(input);
expect(patterns.length).toEqual(expected);
}
});
});
});
describe('ExpressionEditorValidator lineTextValidator', () => {
let tree: ExpressionEditorTreeNode[];
beforeEach(() => {
tree = [
{
label: 'foo',
value: 'foo',
key: 'foo',
variable: {
type: ViewVariableType.ArrayObject,
} as ExpressionEditorVariable,
children: [
{
label: 'bar',
value: 'bar',
key: 'bar',
variable: {
type: ViewVariableType.String,
} as ExpressionEditorVariable,
},
],
},
];
});
it('line text validator correctly', () => {
const validateList = ExpressionEditorValidator.lineTextValidate({
lineText: 'first {{foo[0].bar}} second {{foo[1].bar}}',
tree,
});
expect(validateList).toEqual([
{ start: 6, end: 20, valid: true },
{ start: 28, end: 42, valid: true },
]);
});
});

View File

@@ -0,0 +1,123 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable no-cond-assign */
import { Node } from 'slate';
import type {
ExpressionEditorLine,
ExpressionEditorTreeNode,
ExpressionEditorValidateData,
} from '../type';
import { ExpressionEditorTreeHelper } from '../tree-helper';
import { ExpressionEditorParserBuiltin } from '../parser';
import { ExpressionEditorSegmentType } from '../constant';
export namespace ExpressionEditorValidator {
interface ExpressionEditorPattern {
start: number;
end: number;
content: string;
}
export const findPatterns = (text: string): ExpressionEditorPattern[] => {
const matches: ExpressionEditorPattern[] = [];
const regex = /{{(.*?)}}/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const start: number = match.index;
const end: number = regex.lastIndex;
const content: string = match[1];
matches.push({ start, end, content });
}
return matches;
};
const patternValidate = (params: {
pattern: ExpressionEditorPattern;
tree: ExpressionEditorTreeNode[];
}) => {
const { pattern, tree } = params;
// 1. content to segments
const segments = ExpressionEditorParserBuiltin.toSegments(pattern.content);
if (!segments) {
return {
start: pattern.start,
end: pattern.end,
valid: false,
message: 'invalid variable path',
};
}
if (
segments[segments.length - 1].type ===
ExpressionEditorSegmentType.EndEmpty
) {
return {
start: pattern.start,
end: pattern.end,
valid: false,
message: 'empty with empty',
};
}
// 2. segments mix variable tree, match tree branch
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
tree,
segments,
});
if (!treeBranch) {
return {
start: pattern.start,
end: pattern.end,
valid: false,
message: 'no match variable path',
};
}
// 3. if full segments path could match one tree branch, the pattern is valid
return {
start: pattern.start,
end: pattern.end,
valid: true,
};
};
export const lineTextValidate = (params: {
lineText: string;
tree: ExpressionEditorTreeNode[];
}): ExpressionEditorValidateData[] => {
const { lineText, tree } = params;
// find patterns {{content}}, record start / end offset
const patterns: ExpressionEditorPattern[] = findPatterns(lineText);
const validateList: ExpressionEditorValidateData[] = patterns.map(pattern =>
patternValidate({ pattern, tree }),
);
return validateList;
};
export const validate = (params: {
lines: ExpressionEditorLine[];
tree: ExpressionEditorTreeNode[];
}): ExpressionEditorValidateData[] => {
const { lines, tree } = params;
const textLines: string[] = lines.map(n => Node.string(n));
const validateList: ExpressionEditorValidateData[] = textLines
.map((lineText: string, lineIndex: number) =>
ExpressionEditorValidator.lineTextValidate({
lineText,
tree,
}),
)
.flat();
return validateList;
};
}