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