feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
31
frontend/packages/common/editor-plugins/.storybook/main.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { mergeConfig } from 'vite';
|
||||
import svgr from 'vite-plugin-svgr';
|
||||
|
||||
/** @type { import('@storybook/react-vite').StorybookConfig } */
|
||||
const config = {
|
||||
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-onboarding',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
viteFinal: config =>
|
||||
mergeConfig(config, {
|
||||
plugins: [
|
||||
svgr({
|
||||
svgrOptions: {
|
||||
native: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
/** @type { import('@storybook/react').Preview } */
|
||||
const preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
5
frontend/packages/common/editor-plugins/.stylelintrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { defineConfig } = require('@coze-arch/stylelint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
extends: [],
|
||||
});
|
||||
16
frontend/packages/common/editor-plugins/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# @coze-common/editor-plugins
|
||||
|
||||
> Project template for react component with storybook.
|
||||
|
||||
## Features
|
||||
|
||||
- [x] eslint & ts
|
||||
- [x] esm bundle
|
||||
- [x] umd bundle
|
||||
- [x] storybook
|
||||
|
||||
## Commands
|
||||
|
||||
- init: `rush update`
|
||||
- dev: `npm run dev`
|
||||
- build: `npm run build`
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"operationSettings": [
|
||||
{
|
||||
"operationName": "test:cov",
|
||||
"outputFolderNames": ["coverage"]
|
||||
},
|
||||
{
|
||||
"operationName": "ts-check",
|
||||
"outputFolderNames": ["./dist"]
|
||||
}
|
||||
]
|
||||
}
|
||||
7
frontend/packages/common/editor-plugins/eslint.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const { defineConfig } = require('@coze-arch/eslint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
packageRoot: __dirname,
|
||||
preset: 'web',
|
||||
rules: {},
|
||||
});
|
||||
108
frontend/packages/common/editor-plugins/package.json
Normal file
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"name": "@coze-common/editor-plugins",
|
||||
"version": "0.0.1",
|
||||
"description": "基于@coze-editor/editor的插件库",
|
||||
"license": "Apache-2.0",
|
||||
"author": "haozhenfei@bytedance.com",
|
||||
"maintainers": [],
|
||||
"exports": {
|
||||
"./*": "./src/*",
|
||||
"./theme": "./src/theme/index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./input-slot": "./src/input-slot/index.ts",
|
||||
"./library-insert": "./src/library-insert/index.ts",
|
||||
"./library-variable-insert": "./src/library-variable-insert/index.ts",
|
||||
"./language-support": "./src/language-support/index.ts",
|
||||
"./syntax-highlight": "./src/syntax-highlight/index.ts",
|
||||
"./expression": "./src/expression/index.ts",
|
||||
"./action-bar": "./src/action-bar/index.ts",
|
||||
"./actions": "./src/actions/index.ts"
|
||||
},
|
||||
"main": "src/index.tsx",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"theme": [
|
||||
"./src/theme/index.ts"
|
||||
],
|
||||
"language-support": [
|
||||
"./src/language-support/index.ts"
|
||||
],
|
||||
"syntax-highlight": [
|
||||
"./src/syntax-highlight/index.ts"
|
||||
],
|
||||
"expression": [
|
||||
"./src/expression/index.ts"
|
||||
],
|
||||
"types": [
|
||||
"./src/types.ts"
|
||||
],
|
||||
"input-slot": [
|
||||
"./src/input-slot/index.ts"
|
||||
],
|
||||
"library-insert": [
|
||||
"./src/library-insert/index.ts"
|
||||
],
|
||||
"library-variable-insert": [
|
||||
"./src/library-variable-insert/index.ts"
|
||||
],
|
||||
"action-bar": [
|
||||
"./src/action-bar/index.ts"
|
||||
],
|
||||
"actions": [
|
||||
"./src/actions/index.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "exit 0",
|
||||
"lint": "eslint ./ --cache",
|
||||
"test": "vitest --run --passWithNoTests",
|
||||
"test:cov": "npm run test -- --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.10.1",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.34.1",
|
||||
"@coze-arch/bot-api": "workspace:*",
|
||||
"@coze-arch/bot-semi": "workspace:*",
|
||||
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
|
||||
"@coze-arch/i18n": "workspace:*",
|
||||
"@coze-arch/idl": "workspace:*",
|
||||
"@coze-editor/editor": "0.1.0-alpha.d92d50",
|
||||
"@lezer/common": "^1.2.2",
|
||||
"ahooks": "^3.7.8",
|
||||
"classnames": "^2.3.2",
|
||||
"dequal": "^2.0.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@coze-arch/bot-flags": "workspace:*",
|
||||
"@coze-arch/bot-typings": "workspace:*",
|
||||
"@coze-arch/eslint-config": "workspace:*",
|
||||
"@coze-arch/stylelint-config": "workspace:*",
|
||||
"@coze-arch/ts-config": "workspace:*",
|
||||
"@coze-arch/vitest-config": "workspace:*",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/lodash-es": "^4.17.10",
|
||||
"@types/node": "18.18.9",
|
||||
"@types/react": "18.2.37",
|
||||
"@types/react-dom": "18.2.15",
|
||||
"@vitest/coverage-v8": "~3.0.5",
|
||||
"react": "~18.2.0",
|
||||
"react-dom": "~18.2.0",
|
||||
"stylelint": "^15.11.0",
|
||||
"tailwindcss": "~3.3.3",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^4.3.9",
|
||||
"vite-plugin-svgr": "~3.3.0",
|
||||
"vitest": "~3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.2.0",
|
||||
"react-dom": ">=18.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 { createContext } from 'react';
|
||||
|
||||
import { type ActionController, type ActionSize } from '../types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const ActionBarContext = createContext<{
|
||||
controller: ActionController;
|
||||
size: ActionSize;
|
||||
}>({
|
||||
controller: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
hideActionBar: () => {},
|
||||
// 重新定位
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
rePosition: () => {},
|
||||
},
|
||||
size: 'small',
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { useActionBarPreference } from './use-action-bar-perference';
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 { useContext } from 'react';
|
||||
|
||||
import { type ActionController, type ActionSize } from '../types';
|
||||
import { ActionBarContext } from '../context';
|
||||
|
||||
interface ActionBarPreference {
|
||||
size: ActionSize;
|
||||
controller: ActionController;
|
||||
}
|
||||
|
||||
export const useActionBarPreference = (): ActionBarPreference => {
|
||||
const { size, controller } = useContext(ActionBarContext);
|
||||
return { size, controller };
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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 { ActionBar } from './ui-component';
|
||||
export { ActionBarContext } from './context';
|
||||
export { useActionBarPreference } from './hooks/use-action-bar-perference';
|
||||
export type { ActionController, ActionSize } from './types';
|
||||
export type { SelectionInfo } from '../types';
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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 ButtonProps } from '@coze-arch/coze-design';
|
||||
|
||||
export interface ActionController {
|
||||
hideActionBar: () => void;
|
||||
rePosition: (position?: 'topLeft' | 'bottomRight') => void;
|
||||
}
|
||||
export type ActionSize = ButtonProps['size'];
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* 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 PropsWithChildren,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import cls from 'classnames';
|
||||
import {
|
||||
PositionMirror,
|
||||
useEditor,
|
||||
useInjector,
|
||||
} from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { Popover } from '@coze-arch/coze-design';
|
||||
import { drawSelection, EditorView } from '@codemirror/view';
|
||||
|
||||
import { type SelectionInfo } from '../types';
|
||||
import { ThemeExtension } from '../theme';
|
||||
import { useReadonly } from '../shared/hooks/use-editor-readonly';
|
||||
import { type ActionController } from './types';
|
||||
import { ActionBarContext } from './context';
|
||||
interface ActionBarProps {
|
||||
className?: string;
|
||||
size?: 'default' | 'small' | 'large';
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
trigger?: 'custom' | 'selection';
|
||||
}
|
||||
export const ActionBar: React.FC<PropsWithChildren<ActionBarProps>> = props => {
|
||||
const {
|
||||
className,
|
||||
size = 'small',
|
||||
children,
|
||||
visible,
|
||||
onVisibleChange,
|
||||
trigger = 'selection',
|
||||
} = props;
|
||||
const [internalVisible, setInternalVisible] = useState(false);
|
||||
const [reposKey, setReposKey] = useState('');
|
||||
const [popoverPosition, setPopoverPosition] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isReadOnly = useReadonly();
|
||||
const [selection, setSelection] = useState<SelectionInfo>({
|
||||
from: 0,
|
||||
to: 0,
|
||||
anchor: 0,
|
||||
head: 0,
|
||||
});
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const injector = useInjector();
|
||||
const [position, setPosition] = useState<
|
||||
'topLeft' | 'bottomRight' | undefined
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
setPosition(
|
||||
selection?.head > selection?.anchor ? 'bottomRight' : 'topLeft',
|
||||
);
|
||||
}, [selection]);
|
||||
|
||||
const controller: ActionController = {
|
||||
hideActionBar: () => {
|
||||
onVisibleChange?.(false);
|
||||
setInternalVisible(false);
|
||||
},
|
||||
rePosition: (newPosition?: 'topLeft' | 'bottomRight') => {
|
||||
setReposKey(String(Math.random()));
|
||||
newPosition && setPosition(newPosition);
|
||||
},
|
||||
};
|
||||
|
||||
useLayoutEffect(() => injector.inject([drawSelection()]), [injector]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleMousedown() {
|
||||
onVisibleChange?.(false);
|
||||
setInternalVisible(false);
|
||||
setPopoverPosition(-1);
|
||||
}
|
||||
|
||||
function handleMouseup(e: MouseEvent) {
|
||||
if (containerRef.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
const selectionRange = editor.getSelection();
|
||||
setSelection(selectionRange);
|
||||
|
||||
if (!selectionRange) {
|
||||
onVisibleChange?.(false);
|
||||
setPopoverPosition(-1);
|
||||
setInternalVisible(false);
|
||||
return;
|
||||
}
|
||||
const isSelection = selectionRange.from !== selectionRange.to;
|
||||
setSelection(selectionRange);
|
||||
onVisibleChange?.(isSelection);
|
||||
setInternalVisible(isSelection);
|
||||
setPopoverPosition(selectionRange.head);
|
||||
}
|
||||
|
||||
function handleSelectionChange() {
|
||||
onVisibleChange?.(false);
|
||||
setPopoverPosition(-1);
|
||||
setInternalVisible(false);
|
||||
}
|
||||
|
||||
// function handleBlur() {
|
||||
// onVisibleChange?.(false);
|
||||
// setInternalVisible(false);
|
||||
// editor.$view.dispatch({
|
||||
// selection: { anchor: editor.$view.state.selection.main.head },
|
||||
// });
|
||||
// }
|
||||
|
||||
editor.$on('mousedown', handleMousedown);
|
||||
// 不使用 editor.$on 监听 mouseup 事件,因为鼠标可能不在编辑器内
|
||||
document.addEventListener('mouseup', handleMouseup);
|
||||
editor.$on('selectionChange', handleSelectionChange);
|
||||
// editor.$on('blur', handleBlur);
|
||||
return () => {
|
||||
editor.$off('mousedown', handleMousedown);
|
||||
document.removeEventListener('mouseup', handleMouseup);
|
||||
editor.$off('selectionChange', handleSelectionChange);
|
||||
// editor.$off('blur', handleBlur);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (isReadOnly) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
rePosKey={reposKey}
|
||||
visible={trigger === 'custom' ? visible : internalVisible}
|
||||
trigger="custom"
|
||||
position={position}
|
||||
autoAdjustOverflow
|
||||
className="rounded"
|
||||
content={
|
||||
<ActionBarContext.Provider value={{ controller, size }}>
|
||||
<div className={cls('flex gap-1', className)} ref={containerRef}>
|
||||
{children}
|
||||
</div>
|
||||
</ActionBarContext.Provider>
|
||||
}
|
||||
>
|
||||
<PositionMirror
|
||||
position={popoverPosition}
|
||||
onChange={() => setReposKey(String(Math.random()))}
|
||||
/>
|
||||
</Popover>
|
||||
<ThemeExtension
|
||||
themes={[
|
||||
EditorView.theme({
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(148, 152, 247, 0.44)',
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 { useEditor } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozCopy } from '@coze-arch/coze-design/icons';
|
||||
import { Button, Toast } from '@coze-arch/coze-design';
|
||||
export const CopyAction = () => {
|
||||
const editor = useEditor<EditorAPI>();
|
||||
return (
|
||||
<Button
|
||||
icon={<IconCozCopy />}
|
||||
color="primary"
|
||||
size="small"
|
||||
className="w-6 h-6"
|
||||
onClick={() => {
|
||||
const text = editor?.$view.state.doc.toString();
|
||||
navigator.clipboard.writeText(text);
|
||||
Toast.success(I18n.t('prompt_library_prompt_copied_successfully'));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
19
frontend/packages/common/editor-plugins/src/actions/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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 { InsertInputSlotAction } from './insert-input-slot';
|
||||
export { insertInputSlot } from '../input-slot/action/insert-input-slot-action';
|
||||
export { CopyAction } from './copy';
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 cls from 'classnames';
|
||||
import { useEditor } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozInputSlot } from '@coze-arch/coze-design/icons';
|
||||
import { IconButton, Tooltip } from '@coze-arch/coze-design';
|
||||
import { type ButtonProps } from '@coze-arch/coze-design';
|
||||
|
||||
import { insertInputSlot } from '../../input-slot/action/insert-input-slot-action';
|
||||
import {
|
||||
useSelectionInInputSlot,
|
||||
useSelectionInJinjaRaw,
|
||||
} from '../../input-slot';
|
||||
import { useActionBarPreference } from '../../action-bar/hooks/use-action-bar-perference';
|
||||
|
||||
type InsertInputSlotProps = Pick<ButtonProps, 'className'>;
|
||||
|
||||
export const InsertInputSlotAction: React.FC<InsertInputSlotProps> = props => {
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const { className } = props;
|
||||
const { controller, size } = useActionBarPreference();
|
||||
const inInputSlot = useSelectionInInputSlot();
|
||||
const inJinjaRaw = useSelectionInJinjaRaw();
|
||||
|
||||
if (inInputSlot || inJinjaRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cls(
|
||||
'hover:coz-mg-secondary-hovered coz-icon-button rounded-little',
|
||||
)}
|
||||
>
|
||||
<Tooltip
|
||||
content={I18n.t('edit_block_set_as_edit_block')}
|
||||
position="bottom"
|
||||
>
|
||||
<IconButton
|
||||
color="primary"
|
||||
icon={<IconCozInputSlot />}
|
||||
className={cls('!bg-transparent', className)}
|
||||
size={size}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
controller?.hideActionBar();
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
insertInputSlot(editor);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { InsertInputSlotAction } from './component';
|
||||
@@ -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,32 @@
|
||||
/*
|
||||
* 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 {
|
||||
ExpressionEditorSegment,
|
||||
Variable,
|
||||
ExpressionEditorTreeNode,
|
||||
ExpressionEditorParseData,
|
||||
} from './type';
|
||||
export { ViewVariableType as VariableType } from '../variable-types';
|
||||
|
||||
export { ExpressionEditorParser } from './parser';
|
||||
export { ExpressionEditorTreeHelper } from './tree-helper';
|
||||
@@ -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 { describe, it, expect } from 'vitest';
|
||||
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,256 @@
|
||||
/*
|
||||
* 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 type {
|
||||
ExpressionEditorParseData,
|
||||
ExpressionEditorSegment,
|
||||
} from '../type';
|
||||
import {
|
||||
ExpressionEditorSegmentType,
|
||||
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 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 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,372 @@
|
||||
/*
|
||||
* 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 { describe, it, beforeEach, expect } from 'vitest';
|
||||
|
||||
import type {
|
||||
ExpressionEditorTreeNode,
|
||||
ExpressionEditorSegment,
|
||||
Variable,
|
||||
} from '../type';
|
||||
import { ExpressionEditorSegmentType } from '../constant';
|
||||
import { ExpressionEditorTreeHelper } from '.';
|
||||
import { ViewVariableType } from '../../variable-types';
|
||||
|
||||
describe('ExpressionEditorTreeHelper pruning', () => {
|
||||
let defaultTree: ExpressionEditorTreeNode[];
|
||||
let defaultSegments: ExpressionEditorSegment[];
|
||||
beforeEach(() => {
|
||||
defaultTree = [
|
||||
{
|
||||
label: 'foo',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
variable: {
|
||||
type: ViewVariableType.ArrayObject,
|
||||
} as Variable,
|
||||
children: [
|
||||
{
|
||||
label: 'bar',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
variable: {
|
||||
type: ViewVariableType.Object,
|
||||
} as Variable,
|
||||
children: [
|
||||
{
|
||||
label: 'baz',
|
||||
value: 'baz',
|
||||
key: 'baz',
|
||||
variable: {
|
||||
type: ViewVariableType.String,
|
||||
} as Variable,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
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 Variable,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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 Variable,
|
||||
parent: {
|
||||
label: 'foo',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
variable: {} as Variable,
|
||||
},
|
||||
};
|
||||
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 Variable,
|
||||
parent: {
|
||||
label: 'foo',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
variable: {
|
||||
type: ViewVariableType.ArrayObject,
|
||||
} as Variable,
|
||||
},
|
||||
};
|
||||
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 Variable,
|
||||
children: [
|
||||
{
|
||||
label: 'bar',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
variable: {
|
||||
type: ViewVariableType.ArrayObject,
|
||||
} as Variable,
|
||||
children: [
|
||||
{
|
||||
label: 'baz',
|
||||
value: 'baz',
|
||||
key: 'baz',
|
||||
variable: {} as Variable,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
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 Variable,
|
||||
children: [
|
||||
{
|
||||
label: 'bar',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
variable: {
|
||||
type: ViewVariableType.String,
|
||||
} as Variable,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
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 Variable,
|
||||
children: [
|
||||
{
|
||||
label: 'bar',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
variable: {
|
||||
type: ViewVariableType.String,
|
||||
} as Variable,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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 type {
|
||||
ExpressionEditorTreeNode,
|
||||
ExpressionEditorSegment,
|
||||
Variable,
|
||||
} from '../type';
|
||||
import { ExpressionEditorSegmentType } from '../constant';
|
||||
import { ViewVariableType, isArrayType } from '../../variable-types';
|
||||
|
||||
export namespace ExpressionEditorTreeHelper {
|
||||
export interface AvailableVariable {
|
||||
name: string;
|
||||
keyPath?: string[];
|
||||
variable?: Variable;
|
||||
}
|
||||
|
||||
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 treeChildren = treeLayer.filter(
|
||||
node => node.label === segment.objectKey,
|
||||
);
|
||||
// 兼容变量名重复,但子字段不同的场景
|
||||
if (treeChildren?.length) {
|
||||
treeLayer = treeChildren.reduce(
|
||||
(pre: ExpressionEditorTreeNode[], cur: ExpressionEditorTreeNode) => {
|
||||
if (cur.children?.length) {
|
||||
return [...pre, ...cur.children];
|
||||
}
|
||||
return pre;
|
||||
},
|
||||
[],
|
||||
);
|
||||
} 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 || !isArrayType(beforeTreeNode.variable.type))
|
||||
) {
|
||||
return itemInvalid();
|
||||
}
|
||||
// 确认非法情况:数组只能跟随数组下标
|
||||
if (
|
||||
beforeTreeNode?.variable?.type &&
|
||||
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,77 @@
|
||||
/*
|
||||
* 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 { TreeNodeData } from '@coze-arch/bot-semi/Tree';
|
||||
|
||||
import { type ViewVariableType as VariableType } from '../variable-types';
|
||||
import type { ExpressionEditorSegmentType } from './constant';
|
||||
|
||||
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 Variable {
|
||||
key: string;
|
||||
type: VariableType;
|
||||
name: string;
|
||||
children?: Variable[];
|
||||
// 用户自定义节点名展示
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ExpressionEditorTreeNode extends TreeNodeData {
|
||||
label: string;
|
||||
value: string;
|
||||
key: string;
|
||||
keyPath?: string[];
|
||||
variable?: Variable;
|
||||
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[];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 { useLayoutEffect } from 'react';
|
||||
|
||||
import { useInjector } from '@coze-editor/editor/react';
|
||||
import { astDecorator } from '@coze-editor/editor';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
function HighlightExpressionOnActive() {
|
||||
const injector = useInjector();
|
||||
|
||||
useLayoutEffect(() =>
|
||||
injector.inject([
|
||||
[
|
||||
astDecorator.fromCursor.of((cursor, state) => {
|
||||
const { anchor } = state.selection.main;
|
||||
|
||||
const pos = anchor;
|
||||
if (
|
||||
cursor.name === 'JinjaExpression' &&
|
||||
cursor.node.firstChild?.name === 'JinjaExpressionStart' &&
|
||||
cursor.node.lastChild?.name === 'JinjaExpressionEnd' &&
|
||||
pos >= cursor.node.firstChild.to &&
|
||||
pos <= cursor.node.lastChild.from &&
|
||||
state.sliceDoc(
|
||||
cursor.node.lastChild.from,
|
||||
cursor.node.lastChild.to,
|
||||
) === '}}'
|
||||
) {
|
||||
return {
|
||||
type: 'background',
|
||||
className: 'cm-decoration-interpolation-active',
|
||||
from: cursor.node.firstChild.from,
|
||||
to: cursor.node.lastChild.to,
|
||||
};
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'.cm-decoration-interpolation-active': {
|
||||
borderRadius: '2px',
|
||||
backgroundColor:
|
||||
'var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 0.08))',
|
||||
},
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export { HighlightExpressionOnActive };
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { Popover as Completion } from './popover';
|
||||
export { Validation } from './validation';
|
||||
export { HighlightExpressionOnActive } from './highlight';
|
||||
|
||||
export type {
|
||||
ExpressionEditorTreeNode as VariableNode,
|
||||
Variable,
|
||||
VariableType,
|
||||
} from './core';
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { useInterpolationContent } from './use-interpolation-content';
|
||||
export { useCharacterTriggerContext } from './use-character-trigger-context';
|
||||
export { useEmptyContent } from './use-empty-content';
|
||||
export { useDrillVariableTree } from './use-drill-variable-tree';
|
||||
export { useFilteredVariableTree } from './use-filtered-variable-tree';
|
||||
export { useFocused } from './use-focused';
|
||||
export { useTreeRefresh, useTreeSearch } from './use-tree';
|
||||
export { useKeyboard } from './use-keyboard';
|
||||
export { useSelection } from './use-selection';
|
||||
export { useOptionsOperations } from './use-options-operations';
|
||||
export { useSelectedValue } from './use-selected-value';
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
interface CompletionContext {
|
||||
from: number;
|
||||
to: number;
|
||||
text: string;
|
||||
offset: number;
|
||||
textBefore: string;
|
||||
}
|
||||
|
||||
export type { CompletionContext };
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* 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 @coze-arch/max-line-per-function: "warn" */
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useInjector, useLatest } from '@coze-editor/editor/react';
|
||||
import { ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
||||
import { EditorSelection, type EditorState } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
import {
|
||||
type ComposeTransaction,
|
||||
isSkipSelectionChangeUserEvent,
|
||||
} from '../shared';
|
||||
import { type CompletionContext } from './types';
|
||||
|
||||
interface TriggerContext extends CompletionContext {
|
||||
validate: (state: EditorState) => boolean;
|
||||
composeTransaction?: ComposeTransaction;
|
||||
}
|
||||
|
||||
function useCharacterTriggerContext(disableUpdateTrigger?: boolean) {
|
||||
const [triggerContext, setTriggerContext] = useState<
|
||||
TriggerContext | undefined
|
||||
>();
|
||||
const setTriggerContextRef = useLatest(setTriggerContext);
|
||||
const injector = useInjector();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
function getNodeAtCursor(state: EditorState) {
|
||||
const pos = state.selection.main.from;
|
||||
const tree = syntaxTree(state);
|
||||
const node = tree.resolve(pos);
|
||||
return node;
|
||||
}
|
||||
|
||||
return injector.inject([
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
triggerContext: TriggerContext | undefined = undefined;
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
let triggerCharacter:
|
||||
| { content: string; from: number; to: number }
|
||||
| undefined;
|
||||
|
||||
if (!disableUpdateTrigger && update.docChanged) {
|
||||
// eslint-disable-next-line max-params
|
||||
update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||
const insertString = inserted.toString();
|
||||
if (
|
||||
fromA === toA &&
|
||||
(insertString === '{' || insertString === '{}')
|
||||
) {
|
||||
triggerCharacter = {
|
||||
from: fromB,
|
||||
to: toB,
|
||||
content: insertString,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// when user input { or {}, show all available variables
|
||||
if (triggerCharacter) {
|
||||
const node = getNodeAtCursor(update.state);
|
||||
|
||||
// for case like {{<cursor>content, shall show variables
|
||||
if (
|
||||
node &&
|
||||
node.name === 'JinjaExpression' &&
|
||||
node.lastChild?.name === 'JinjaExpressionEnd' &&
|
||||
node.lastChild.to - node.lastChild.from === 2
|
||||
) {
|
||||
// {<cursor>} -> {{<cursor>}}
|
||||
// skip here, handover to use-interpolation-content
|
||||
this.triggerContext = undefined;
|
||||
} else {
|
||||
// <cursor>content -> {<cursor>content
|
||||
// <cursor> -> {<cursor>}
|
||||
const from = triggerCharacter.from + 1;
|
||||
const { to } = update.state.selection.main;
|
||||
this.triggerContext = {
|
||||
from,
|
||||
to,
|
||||
offset: 0,
|
||||
text: '',
|
||||
textBefore: '',
|
||||
validate(state) {
|
||||
if (!triggerCharacter) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check { still exists
|
||||
if (
|
||||
state.sliceDoc(
|
||||
triggerCharacter.from,
|
||||
triggerCharacter.from + 1,
|
||||
) !== '{'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check character after cursor is still }
|
||||
// edge case:user may press Delete, cause the } disappears
|
||||
if (
|
||||
triggerCharacter.content === '{}' &&
|
||||
state.sliceDoc(
|
||||
state.selection.main.to,
|
||||
state.selection.main.to + 1,
|
||||
) !== '}'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
composeTransaction: (tr, state) => {
|
||||
if (!triggerCharacter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// already exists { before {, we shall reuse it
|
||||
const characterBefore = state.sliceDoc(
|
||||
Math.max(0, triggerCharacter.from - 1),
|
||||
triggerCharacter.from,
|
||||
);
|
||||
const hasExtraLeftBrace = characterBefore === '{';
|
||||
|
||||
if (triggerCharacter.content.length === 1) {
|
||||
return [
|
||||
{
|
||||
changes: [
|
||||
{
|
||||
from: tr.changes.from,
|
||||
to: tr.changes.to,
|
||||
insert: `${
|
||||
hasExtraLeftBrace ? '' : '{'
|
||||
}${tr.changes.insert}}}`,
|
||||
},
|
||||
],
|
||||
selection: EditorSelection.cursor(
|
||||
tr.cursorPosition + (hasExtraLeftBrace ? 0 : 1),
|
||||
),
|
||||
userEvent: tr.userEvent,
|
||||
scrollIntoView: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
changes: [
|
||||
{
|
||||
from: tr.changes.from,
|
||||
to: tr.changes.to,
|
||||
insert: `{${tr.changes.insert}}`,
|
||||
},
|
||||
],
|
||||
selection: EditorSelection.cursor(
|
||||
tr.cursorPosition + 1,
|
||||
),
|
||||
userEvent: tr.userEvent,
|
||||
scrollIntoView: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (
|
||||
update.transactions.some(tr => tr.isUserEvent('select'))
|
||||
) {
|
||||
this.triggerContext = undefined;
|
||||
} else if (
|
||||
update.transactions.some(tr => isSkipSelectionChangeUserEvent(tr))
|
||||
) {
|
||||
this.triggerContext = undefined;
|
||||
} else if (this.triggerContext && update.docChanged) {
|
||||
if (!this.triggerContext.validate(update.state)) {
|
||||
this.triggerContext = undefined;
|
||||
} else {
|
||||
const { from } = this.triggerContext;
|
||||
const { to } = update.state.selection.main;
|
||||
const text = update.state.sliceDoc(from, to);
|
||||
this.triggerContext = {
|
||||
...this.triggerContext,
|
||||
from,
|
||||
to,
|
||||
offset: from - to,
|
||||
text,
|
||||
textBefore: text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setTriggerContextRef.current(this.triggerContext);
|
||||
}
|
||||
},
|
||||
),
|
||||
]);
|
||||
}, [injector]);
|
||||
|
||||
return triggerContext;
|
||||
}
|
||||
|
||||
export { useCharacterTriggerContext };
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from '../../core/parser';
|
||||
import {
|
||||
ExpressionEditorTreeHelper,
|
||||
type ExpressionEditorTreeNode,
|
||||
} from '../../core';
|
||||
import { type CompletionContext } from './types';
|
||||
|
||||
function useDrillVariableTree(
|
||||
editor: ExpressionEditorAPI | undefined,
|
||||
variableTree: ExpressionEditorTreeNode[],
|
||||
context: CompletionContext | undefined,
|
||||
): ExpressionEditorTreeNode[] {
|
||||
return useMemo(() => {
|
||||
if (!editor || !context) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segments = ExpressionEditorParserBuiltin.toSegments(
|
||||
context.textBefore,
|
||||
);
|
||||
|
||||
if (!segments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prunedVariableTree = ExpressionEditorTreeHelper.pruning({
|
||||
tree: variableTree,
|
||||
segments,
|
||||
});
|
||||
|
||||
return prunedVariableTree;
|
||||
}, [editor, variableTree, context]);
|
||||
}
|
||||
|
||||
export { useDrillVariableTree };
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from '../../core/parser';
|
||||
import {
|
||||
ExpressionEditorTreeHelper,
|
||||
type ExpressionEditorTreeNode,
|
||||
} from '../../core';
|
||||
import { type CompletionContext } from './types';
|
||||
|
||||
function isEmpty(value: unknown) {
|
||||
return !value || !Array.isArray(value) || value.length === 0;
|
||||
}
|
||||
|
||||
function useEmptyContent(
|
||||
fullVariableTree: ExpressionEditorTreeNode[] | undefined,
|
||||
variableTree: ExpressionEditorTreeNode[] | undefined,
|
||||
context: CompletionContext | undefined,
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(fullVariableTree)) {
|
||||
if (context.textBefore === '') {
|
||||
return I18n.t('workflow_variable_refer_no_input');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(variableTree)) {
|
||||
if (context.text === '') {
|
||||
return I18n.t('workflow_variable_refer_no_input');
|
||||
}
|
||||
|
||||
const segments = ExpressionEditorParserBuiltin.toSegments(
|
||||
context.textBefore,
|
||||
);
|
||||
|
||||
if (!segments) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchTreeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree: fullVariableTree ?? [],
|
||||
segments,
|
||||
});
|
||||
const isMatchedButEmpty = matchTreeBranch && matchTreeBranch.length !== 0;
|
||||
|
||||
if (isMatchedButEmpty) {
|
||||
return I18n.t('workflow_variable_refer_no_sub_variable');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}, [fullVariableTree, variableTree, context]);
|
||||
}
|
||||
|
||||
export { useEmptyContent };
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { getSearchValue } from '../../shared';
|
||||
import { type ExpressionEditorTreeNode } from '../../core';
|
||||
import { type CompletionContext } from './types';
|
||||
|
||||
function useFilteredVariableTree(
|
||||
context: CompletionContext | undefined,
|
||||
drilledVariableTree: ExpressionEditorTreeNode[],
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!drilledVariableTree) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
return drilledVariableTree;
|
||||
}
|
||||
|
||||
const searchValue = getSearchValue(context.textBefore);
|
||||
|
||||
return drilledVariableTree.filter(variable =>
|
||||
variable.label.startsWith(searchValue),
|
||||
);
|
||||
}, [context, drilledVariableTree]);
|
||||
}
|
||||
|
||||
export { useFilteredVariableTree };
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
|
||||
function useFocused(editor: EditorAPI | undefined) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
setFocused(true);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
setFocused(false);
|
||||
}
|
||||
|
||||
editor.$on('focus', handleFocus);
|
||||
editor.$on('blur', handleBlur);
|
||||
|
||||
return () => {
|
||||
editor.$off('focus', handleFocus);
|
||||
editor.$off('blur', handleBlur);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return focused;
|
||||
}
|
||||
|
||||
export { useFocused };
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
import { type EditorView } from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
import { type CompletionContext } from './types';
|
||||
|
||||
function getInterpolationContentAtPos(view: EditorView, pos: number) {
|
||||
const tree = syntaxTree(view.state);
|
||||
const cursor = tree.cursorAt(pos);
|
||||
|
||||
do {
|
||||
if (
|
||||
(cursor.node.type.name === 'JinjaExpression' &&
|
||||
cursor.node.firstChild?.name === 'JinjaExpressionStart' &&
|
||||
cursor.node.lastChild?.name === 'JinjaExpressionEnd' &&
|
||||
pos >= cursor.node.firstChild.to &&
|
||||
pos <= cursor.node.lastChild.from &&
|
||||
view.state.sliceDoc(
|
||||
cursor.node.lastChild.from,
|
||||
cursor.node.lastChild.to,
|
||||
) === '}}') ||
|
||||
(cursor.node.type.name === 'Interpolation' &&
|
||||
cursor.node.firstChild &&
|
||||
cursor.node.lastChild &&
|
||||
pos >= cursor.node.firstChild.to &&
|
||||
pos <= cursor.node.lastChild.from)
|
||||
) {
|
||||
const text = view.state.sliceDoc(
|
||||
cursor.node.firstChild.to,
|
||||
cursor.node.lastChild.from,
|
||||
);
|
||||
const offset = pos - cursor.node.firstChild.to;
|
||||
return {
|
||||
from: cursor.node.firstChild.to,
|
||||
to: cursor.node.lastChild.from,
|
||||
text,
|
||||
offset,
|
||||
textBefore: text.slice(0, offset),
|
||||
};
|
||||
}
|
||||
} while (cursor.parent());
|
||||
}
|
||||
|
||||
function useInterpolationContent(
|
||||
editor: ExpressionEditorAPI | undefined,
|
||||
pos: number | undefined,
|
||||
): CompletionContext | undefined {
|
||||
return useMemo(() => {
|
||||
if (!editor || typeof pos === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = editor.$view;
|
||||
return getInterpolationContentAtPos(view, pos);
|
||||
}, [editor, pos]);
|
||||
}
|
||||
|
||||
export { useInterpolationContent };
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useLatest } from '../../shared';
|
||||
|
||||
type Keymap = Record<string, (e: KeyboardEvent) => void>;
|
||||
|
||||
function useKeyboard(enable: boolean, keymap: Keymap) {
|
||||
const keymapRef = useLatest(keymap);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enable) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const callback = keymapRef.current[e.key];
|
||||
if (typeof callback === 'function') {
|
||||
callback(e);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeydown, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown, false);
|
||||
};
|
||||
}, [enable, keymapRef]);
|
||||
}
|
||||
|
||||
export { useKeyboard };
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
import { type Tree } from '@coze-arch/bot-semi';
|
||||
|
||||
import { applyNode, getOptionInfoFromDOM, selectNodeByIndex } from '../shared';
|
||||
import { useLatest } from '../../shared';
|
||||
import { type ExpressionEditorTreeNode } from '../../core';
|
||||
import { type CompletionContext } from './types';
|
||||
|
||||
// eslint-disable-next-line max-params
|
||||
function useOptionsOperations(
|
||||
editor: ExpressionEditorAPI | undefined,
|
||||
context: CompletionContext | undefined,
|
||||
treeContainerRef: React.MutableRefObject<HTMLDivElement | null>,
|
||||
treeRef: React.MutableRefObject<Tree | null>,
|
||||
) {
|
||||
const editorRef = useLatest(editor);
|
||||
const contextRef = useLatest(context);
|
||||
|
||||
return useMemo(() => {
|
||||
function prev() {
|
||||
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { elements, selectedIndex } = optionsInfo;
|
||||
|
||||
if (elements.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex =
|
||||
selectedIndex - 1 < 0 ? elements.length - 1 : selectedIndex - 1;
|
||||
selectNodeByIndex(elements, newIndex);
|
||||
}
|
||||
|
||||
function next() {
|
||||
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { elements, selectedIndex } = optionsInfo;
|
||||
|
||||
const newIndex =
|
||||
selectedIndex + 1 >= elements.length ? 0 : selectedIndex + 1;
|
||||
selectNodeByIndex(elements, newIndex);
|
||||
}
|
||||
|
||||
function apply() {
|
||||
if (!contextRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedElement } = optionsInfo;
|
||||
|
||||
const selectedDataKey = selectedElement?.getAttribute('data-key');
|
||||
|
||||
if (!selectedDataKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variableTreeNode =
|
||||
treeRef.current?.state?.keyEntities?.[selectedDataKey]?.data;
|
||||
if (!variableTreeNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyNode(
|
||||
editorRef.current,
|
||||
variableTreeNode as ExpressionEditorTreeNode,
|
||||
contextRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
prev,
|
||||
next,
|
||||
apply,
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
export { useOptionsOperations };
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from '../../core/parser';
|
||||
import {
|
||||
ExpressionEditorTreeHelper,
|
||||
type ExpressionEditorTreeNode,
|
||||
} from '../../core';
|
||||
|
||||
function useSelectedValue(
|
||||
interpolationText: string | undefined,
|
||||
variableTree: ExpressionEditorTreeNode[],
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!interpolationText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const segments =
|
||||
ExpressionEditorParserBuiltin.toSegments(interpolationText);
|
||||
|
||||
if (!segments) {
|
||||
return;
|
||||
}
|
||||
|
||||
const treeBrach = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree: variableTree,
|
||||
segments,
|
||||
});
|
||||
|
||||
if (!treeBrach) {
|
||||
return;
|
||||
}
|
||||
|
||||
return treeBrach[treeBrach.length - 1];
|
||||
}, [interpolationText, variableTree]);
|
||||
}
|
||||
|
||||
export { useSelectedValue };
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
import { type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
import { isSkipSelectionChangeUserEvent } from '../shared';
|
||||
import { useLatest } from '../../shared';
|
||||
|
||||
interface Selection {
|
||||
anchor: number;
|
||||
head: number;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
function isSameSelection(a?: Selection, b?: Selection) {
|
||||
if (!a && !b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
a &&
|
||||
b &&
|
||||
a.anchor === b.anchor &&
|
||||
a.head === b.head &&
|
||||
a.from === b.from &&
|
||||
a.to === b.to
|
||||
);
|
||||
}
|
||||
|
||||
function useSelection(editor: ExpressionEditorAPI | undefined) {
|
||||
const [selection, setSelection] = useState<Selection | undefined>();
|
||||
|
||||
const selectionRef = useLatest(selection);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = editor.$view;
|
||||
|
||||
function updateSelection(update?: ViewUpdate) {
|
||||
// 忽略 replaceTextByRange 导致的 selection change(效果:不触发 selection 变更,进而不显示推荐面板)
|
||||
if (update?.transactions.some(tr => isSkipSelectionChangeUserEvent(tr))) {
|
||||
setSelection(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to, anchor, head } = view.state.selection.main;
|
||||
const newSelection = { from, to, anchor, head };
|
||||
if (isSameSelection(newSelection, selectionRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelection({ from, to, anchor, head });
|
||||
}
|
||||
|
||||
function handleSelectionChange(e: { update: ViewUpdate }) {
|
||||
updateSelection(e.update);
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
editor.$on('selectionChange', handleSelectionChange);
|
||||
editor.$on('focus', handleFocus);
|
||||
|
||||
return () => {
|
||||
editor.$off('selectionChange', handleSelectionChange);
|
||||
editor.$off('focus', handleFocus);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
export { useSelection };
|
||||
@@ -0,0 +1,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.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, type MutableRefObject } from 'react';
|
||||
|
||||
import { type Tree } from '@coze-arch/bot-semi';
|
||||
|
||||
import { generateUniqueId, getSearchValue, useLatest } from '../../shared';
|
||||
import { type ExpressionEditorTreeNode } from '../../core';
|
||||
import { type CompletionContext } from './types';
|
||||
|
||||
// 在数据更新后,强制 Tree 组件重新渲染
|
||||
function useTreeRefresh(filteredVariableTree: ExpressionEditorTreeNode[]) {
|
||||
const [treeRefreshKey, setTreeRefreshKey] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setTreeRefreshKey(generateUniqueId());
|
||||
}, [filteredVariableTree]);
|
||||
|
||||
return treeRefreshKey;
|
||||
}
|
||||
|
||||
// Tree 组件重新渲染后进行搜索
|
||||
// eslint-disable-next-line max-params
|
||||
function useTreeSearch(
|
||||
treeRefreshKey: string,
|
||||
treeRef: MutableRefObject<Tree | null>,
|
||||
context: CompletionContext | undefined,
|
||||
callback: () => void,
|
||||
) {
|
||||
const contextRef = useLatest(context);
|
||||
|
||||
useEffect(() => {
|
||||
if (treeRef.current && contextRef.current) {
|
||||
const searchValue = getSearchValue(contextRef.current.textBefore);
|
||||
treeRef.current.search(searchValue);
|
||||
callback();
|
||||
}
|
||||
}, [treeRefreshKey, context]);
|
||||
}
|
||||
|
||||
export { useTreeRefresh, useTreeSearch };
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { Popover } from './popover';
|
||||
@@ -0,0 +1,153 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable max-nesting-depth */
|
||||
/* stylelint-disable declaration-no-important */
|
||||
.expression-editor-suggestion {
|
||||
z-index: 1000;
|
||||
|
||||
overflow: auto;
|
||||
|
||||
width: 272px;
|
||||
max-height: 236px;
|
||||
|
||||
background: var(--light-usage-bg-color-bg-3, #FFF);
|
||||
border: 0.5px solid rgba(153, 182, 255, 12%);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
|
||||
}
|
||||
|
||||
.expression-editor-suggestion-empty {
|
||||
z-index: 1000;
|
||||
|
||||
background: #FFF;
|
||||
border: 0.5px solid rgba(153, 182, 255, 12%);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 25%);
|
||||
|
||||
p {
|
||||
margin: 4px 6px;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
color: var(--light-usage-text-color-text-2, rgba(29, 28, 35, 60%));
|
||||
}
|
||||
}
|
||||
|
||||
.expression-editor-suggestion-tree {
|
||||
:global {
|
||||
.semi-tree-search-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.semi-tree-option-list {
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
padding: 4px;
|
||||
padding-left: 0;
|
||||
|
||||
li {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.semi-tree-option {
|
||||
pointer-events: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.semi-tree-option-label {
|
||||
pointer-events: auto;
|
||||
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
padding: 0 4px;
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
|
||||
}
|
||||
|
||||
.semi-tree-option-label-text {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
white-space: nowrap;
|
||||
|
||||
& span {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.semi-tree-option-highlight {
|
||||
color: var(--light-usage-warning-color-warning, #FF9600)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.semi-tree-option-selected {
|
||||
font-weight: 600;
|
||||
color: var(--light-usage-primary-color-primary, #4D53E8);
|
||||
}
|
||||
|
||||
.semi-tree-option-disabled {
|
||||
.semi-tree-option-label {
|
||||
cursor: not-allowed;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.semi-icon+.semi-tree-option-label {
|
||||
color: var(--light-usage-text-color-text-0, #1D1C23);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.semi-tree-option-empty-icon {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.semi-tree-option-expand-icon {
|
||||
pointer-events: auto;
|
||||
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 0;
|
||||
padding: 4px;
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expression-editor-suggestion-keyboard-selected {
|
||||
:global {
|
||||
.semi-tree-option-label {
|
||||
background: var(--light-usage-fill-color-fill-1, rgba(46, 46, 56, 8%)) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.expression-editor-suggestion-keyboard-selected-new {
|
||||
&:global.semi-tree-option {
|
||||
background: var(--semi-color-fill-0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
CursorMirror,
|
||||
useEditor,
|
||||
SelectionSide,
|
||||
} from '@coze-editor/editor/react';
|
||||
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
import { type TreeNodeData } from '@coze-arch/bot-semi/Tree';
|
||||
import { type PopoverProps } from '@coze-arch/bot-semi/Popover';
|
||||
import { Popover as SemiPopover, Tree } from '@coze-arch/bot-semi';
|
||||
|
||||
import { generateUniqueId, useDeepEqualMemo, useLatest } from '../shared';
|
||||
import { type ExpressionEditorTreeNode } from '../core';
|
||||
import { applyNode, getOptionInfoFromDOM, selectNodeByIndex } from './shared';
|
||||
import {
|
||||
useEmptyContent,
|
||||
useFilteredVariableTree,
|
||||
useFocused,
|
||||
useInterpolationContent,
|
||||
useCharacterTriggerContext,
|
||||
useDrillVariableTree,
|
||||
useSelection,
|
||||
useTreeRefresh,
|
||||
useTreeSearch,
|
||||
useKeyboard,
|
||||
useOptionsOperations,
|
||||
useSelectedValue,
|
||||
} from './hooks';
|
||||
|
||||
import styles from './popover.module.less';
|
||||
|
||||
interface Props {
|
||||
getPopupContainer?: PopoverProps['getPopupContainer'];
|
||||
variableTree: ExpressionEditorTreeNode[];
|
||||
className?: string;
|
||||
onVisibilityChange?: (visible: boolean) => void;
|
||||
customApplyNode?: (
|
||||
data: TreeNodeData,
|
||||
context: {
|
||||
from: number;
|
||||
to: number;
|
||||
},
|
||||
editor: ExpressionEditorAPI,
|
||||
) => void;
|
||||
disableUpdateTrigger?: boolean;
|
||||
disabled?: boolean;
|
||||
customInterpolationRule?: (content?: string, textBefore?: string) => boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @coze-arch/max-line-per-function
|
||||
function Popover({
|
||||
getPopupContainer,
|
||||
variableTree: vTree,
|
||||
className,
|
||||
onVisibilityChange,
|
||||
customApplyNode,
|
||||
disableUpdateTrigger,
|
||||
disabled = false,
|
||||
customInterpolationRule,
|
||||
}: Props) {
|
||||
const variableTree = useDeepEqualMemo(vTree);
|
||||
const treeRef = useRef<Tree | null>(null);
|
||||
const treeContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const onVisibilityChangeRef = useLatest(onVisibilityChange);
|
||||
const [posKey, setPosKey] = useState('');
|
||||
const editor = useEditor<ExpressionEditorAPI | undefined>();
|
||||
const editorRef = useLatest(editor);
|
||||
const selection = useSelection(editor);
|
||||
const focused = useFocused(editor);
|
||||
const baseInterpolationContent = useInterpolationContent(
|
||||
editor,
|
||||
selection?.anchor,
|
||||
);
|
||||
|
||||
const interpolationContent = useMemo(() => {
|
||||
if (!customInterpolationRule) {
|
||||
return baseInterpolationContent;
|
||||
}
|
||||
if (
|
||||
customInterpolationRule?.(
|
||||
baseInterpolationContent?.text,
|
||||
baseInterpolationContent?.textBefore,
|
||||
)
|
||||
) {
|
||||
return baseInterpolationContent;
|
||||
}
|
||||
return undefined;
|
||||
}, [baseInterpolationContent, customInterpolationRule]);
|
||||
|
||||
const triggerContext = useCharacterTriggerContext(disableUpdateTrigger);
|
||||
|
||||
const completionContext = interpolationContent ?? triggerContext;
|
||||
|
||||
const drilledVariableTree = useDrillVariableTree(
|
||||
editor,
|
||||
variableTree,
|
||||
completionContext,
|
||||
);
|
||||
const filteredVariableTree = useFilteredVariableTree(
|
||||
completionContext,
|
||||
drilledVariableTree,
|
||||
);
|
||||
const emptyContent = useEmptyContent(
|
||||
variableTree,
|
||||
drilledVariableTree,
|
||||
completionContext,
|
||||
);
|
||||
const treeRefreshKey = useTreeRefresh(filteredVariableTree);
|
||||
useTreeSearch(treeRefreshKey, treeRef, completionContext, () => {
|
||||
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
const { elements } = optionsInfo;
|
||||
selectNodeByIndex(elements, 0);
|
||||
});
|
||||
// selected 仅用于 Tree 组件对应项展示蓝色选中效果,无其他用途
|
||||
const selected = useSelectedValue(completionContext?.text, variableTree);
|
||||
|
||||
// 基于用户选中项,替换所在 {{}} 中的内容
|
||||
const handleSelect = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
(_: string, __: boolean, node: TreeNodeData) => {
|
||||
if (!editor || !completionContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (customApplyNode) {
|
||||
customApplyNode(node, completionContext, editor);
|
||||
return;
|
||||
}
|
||||
applyNode(editor, node as ExpressionEditorTreeNode, completionContext);
|
||||
},
|
||||
[editor, completionContext],
|
||||
);
|
||||
|
||||
const internalVisible =
|
||||
focused &&
|
||||
((Boolean(completionContext) && filteredVariableTree.length > 0) ||
|
||||
Boolean(emptyContent));
|
||||
|
||||
const [allowVisible, setAllowVisible] = useState(false);
|
||||
// 选区变化时,清除锁定效果
|
||||
useEffect(() => {
|
||||
setAllowVisible(true);
|
||||
}, [selection]);
|
||||
|
||||
const visible = internalVisible && allowVisible && !disabled;
|
||||
|
||||
const { prev, next, apply } = useOptionsOperations(
|
||||
editor,
|
||||
completionContext,
|
||||
treeContainerRef,
|
||||
treeRef,
|
||||
);
|
||||
|
||||
// 上下键切换推荐项,回车填入
|
||||
useKeyboard(visible, {
|
||||
ArrowUp: prev,
|
||||
ArrowDown: next,
|
||||
Enter: apply,
|
||||
});
|
||||
|
||||
// ESC 关闭
|
||||
useKeyboard(visible, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Escape() {
|
||||
setAllowVisible(false);
|
||||
},
|
||||
});
|
||||
|
||||
// 推荐面板出现时,禁用 ArrowUp/ArrowDown/Enter 的默认行为(行为改为上下键切换推荐项 & 回车插入)
|
||||
useEffect(() => {
|
||||
if (visible === true) {
|
||||
editorRef.current?.disableKeybindings(['ArrowUp', 'ArrowDown', 'Enter']);
|
||||
} else {
|
||||
editorRef.current?.disableKeybindings([]);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof onVisibilityChangeRef.current === 'function') {
|
||||
onVisibilityChangeRef.current(visible);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<SemiPopover
|
||||
trigger="custom"
|
||||
visible={visible}
|
||||
keepDOM={true}
|
||||
rePosKey={posKey}
|
||||
getPopupContainer={getPopupContainer}
|
||||
content={
|
||||
<div
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
style={{ display: visible ? 'block' : 'none' }}
|
||||
>
|
||||
<EmptyContent visible={!!emptyContent} content={emptyContent} />
|
||||
<TreeContainer
|
||||
ref={treeContainerRef}
|
||||
visible={!emptyContent}
|
||||
className={className}
|
||||
>
|
||||
<Tree
|
||||
// key={treeRefreshKey}
|
||||
className={styles['expression-editor-suggestion-tree']}
|
||||
showFilteredOnly
|
||||
filterTreeNode
|
||||
onChangeWithObject
|
||||
ref={treeRef}
|
||||
treeData={drilledVariableTree}
|
||||
searchRender={false}
|
||||
value={selected}
|
||||
emptyContent={null}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</TreeContainer>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CursorMirror
|
||||
side={SelectionSide.Anchor}
|
||||
onChange={() => setPosKey(generateUniqueId())}
|
||||
/>
|
||||
</SemiPopover>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyContentProps {
|
||||
visible: boolean;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
function EmptyContent({ visible, content }: EmptyContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={styles['expression-editor-suggestion-empty']}
|
||||
style={{ display: visible ? 'block' : 'none' }}
|
||||
>
|
||||
<p>{content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TreeContainerProps {
|
||||
visible: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const TreeContainer = forwardRef<HTMLDivElement, TreeContainerProps>(
|
||||
function TreeContainer({ visible, className, children }, ref) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles['expression-editor-suggestion'],
|
||||
)}
|
||||
style={{ display: visible ? 'block' : 'none' }}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export { Popover, TreeContainer, EmptyContent };
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type EditorAPI as ExpressionEditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
import { getFlags } from '@coze-arch/bot-flags';
|
||||
import {
|
||||
EditorSelection,
|
||||
type EditorState,
|
||||
type TransactionSpec,
|
||||
type Transaction,
|
||||
} from '@codemirror/state';
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from '../core/parser';
|
||||
import {
|
||||
ExpressionEditorTreeHelper,
|
||||
type ExpressionEditorTreeNode,
|
||||
} from '../core';
|
||||
|
||||
import styles from './popover.module.less';
|
||||
|
||||
const SKIP_SELECTION_CHANGE_USER_EVENT = 'api.skip-selection-change';
|
||||
const SELECTED_OPTION_CLASSNAME =
|
||||
styles['expression-editor-suggestion-keyboard-selected'];
|
||||
const SELECTED_OPTION_CLASSNAME_NEW =
|
||||
styles['expression-editor-suggestion-keyboard-selected-new'];
|
||||
|
||||
const getSelectedClassName = () => {
|
||||
const FLAGS = getFlags();
|
||||
const isHitLLMPromptSkills = FLAGS?.['bot.automation.llm_prompt_skills'];
|
||||
if (isHitLLMPromptSkills) {
|
||||
return SELECTED_OPTION_CLASSNAME_NEW;
|
||||
}
|
||||
|
||||
return SELECTED_OPTION_CLASSNAME;
|
||||
};
|
||||
|
||||
// modified from:
|
||||
// file: packages/workflow/components/src/expression-editor/components/suggestion/hooks.ts
|
||||
// method: computeUIOptions
|
||||
interface OptionsInfo {
|
||||
elements: Element[];
|
||||
selectedIndex: number;
|
||||
selectedElement?: Element;
|
||||
}
|
||||
const getOptionInfoFromDOM = (
|
||||
root: Element | null,
|
||||
): OptionsInfo | undefined => {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取所有的选项元素
|
||||
const foundNodes = root.querySelectorAll(
|
||||
'.semi-tree-option-list .semi-tree-option',
|
||||
);
|
||||
|
||||
if (foundNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionElements = [...foundNodes];
|
||||
|
||||
// 找到当前高亮的选项
|
||||
const selectedIndex = optionElements.findIndex(element =>
|
||||
element.classList.contains(getSelectedClassName()),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: optionElements,
|
||||
selectedIndex,
|
||||
selectedElement: optionElements[selectedIndex],
|
||||
};
|
||||
};
|
||||
|
||||
function selectNodeByIndex(elements: Element[], index: number) {
|
||||
const newSelectedElement = elements[index];
|
||||
|
||||
if (!newSelectedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove old selected class
|
||||
elements.forEach(element => {
|
||||
if (element.classList.contains(getSelectedClassName())) {
|
||||
element.classList.remove(getSelectedClassName());
|
||||
}
|
||||
});
|
||||
|
||||
newSelectedElement.classList.add(getSelectedClassName());
|
||||
|
||||
newSelectedElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
|
||||
export type ComposeTransaction = (
|
||||
tr: {
|
||||
changes: { from: number; to: number; insert: string };
|
||||
cursorPosition: number;
|
||||
userEvent: string;
|
||||
},
|
||||
state: EditorState,
|
||||
) => TransactionSpec[];
|
||||
|
||||
interface ApplyNodeOptions {
|
||||
from: number;
|
||||
to: number;
|
||||
textBefore: string;
|
||||
composeTransaction?: ComposeTransaction;
|
||||
}
|
||||
|
||||
function isLeafNode(node: ExpressionEditorTreeNode) {
|
||||
return !node.children || node.children.length === 0;
|
||||
}
|
||||
|
||||
function applyNode(
|
||||
editor: ExpressionEditorAPI | undefined,
|
||||
node: ExpressionEditorTreeNode,
|
||||
options: ApplyNodeOptions,
|
||||
) {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to, textBefore, composeTransaction } = options;
|
||||
|
||||
const text = getInsertTextFromNode(node, textBefore);
|
||||
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorOffset = isLeafNode(node) ? '}}'.length : 0;
|
||||
const cursorPosition = from + text.length + cursorOffset;
|
||||
const transaction = {
|
||||
changes: {
|
||||
from,
|
||||
to,
|
||||
insert: text,
|
||||
},
|
||||
selection: EditorSelection.cursor(cursorPosition),
|
||||
userEvent: SKIP_SELECTION_CHANGE_USER_EVENT,
|
||||
};
|
||||
|
||||
let transactions: TransactionSpec[] = [transaction];
|
||||
if (typeof composeTransaction === 'function') {
|
||||
transactions = composeTransaction(
|
||||
{
|
||||
changes: transaction.changes,
|
||||
userEvent: transaction.userEvent,
|
||||
cursorPosition,
|
||||
},
|
||||
editor.$view.state,
|
||||
);
|
||||
}
|
||||
|
||||
editor.$view.dispatch(...transactions);
|
||||
}
|
||||
|
||||
function isSkipSelectionChangeUserEvent(tr: Transaction) {
|
||||
return tr.isUserEvent(SKIP_SELECTION_CHANGE_USER_EVENT);
|
||||
}
|
||||
|
||||
function getInsertTextFromNode(
|
||||
node: ExpressionEditorTreeNode,
|
||||
textBefore: string,
|
||||
) {
|
||||
const segments = ExpressionEditorParserBuiltin.toSegments(textBefore) ?? [];
|
||||
return ExpressionEditorTreeHelper.concatFullPath({
|
||||
node,
|
||||
segments,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
getOptionInfoFromDOM,
|
||||
applyNode,
|
||||
selectNodeByIndex,
|
||||
isSkipSelectionChangeUserEvent,
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
type MutableRefObject,
|
||||
useRef,
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
|
||||
import { dequal } from 'dequal';
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from './core/parser';
|
||||
import { ExpressionEditorSegmentType } from './core';
|
||||
|
||||
function useLatest<T>(value: T): MutableRefObject<T> {
|
||||
const ref = useRef(value);
|
||||
ref.current = value;
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
// 解除 parent 导致的循环依赖(否则无法深比较)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function cloneWithout(target: any, keys: string[]) {
|
||||
// target 为 undefined 时会抛错
|
||||
try {
|
||||
return JSON.parse(
|
||||
JSON.stringify(target, function (key, value) {
|
||||
if (keys.includes(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
function useDeepEqualMemo<T>(value: T): T {
|
||||
const [state, setState] = useState<T>(value);
|
||||
const lastValueRef = useRef<T>(value);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
!dequal(
|
||||
cloneWithout(value, ['parent']),
|
||||
cloneWithout(lastValueRef.current, ['parent']),
|
||||
)
|
||||
) {
|
||||
setState(value);
|
||||
lastValueRef.current = value;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function generateUniqueId(): string {
|
||||
return Math.floor(Math.random() * 2e6).toString(36);
|
||||
}
|
||||
|
||||
function getSearchValue(textBefore: string) {
|
||||
const segments = ExpressionEditorParserBuiltin.toSegments(textBefore);
|
||||
|
||||
if (!segments) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lastSegment =
|
||||
segments[segments.length - 1].type ===
|
||||
ExpressionEditorSegmentType.ArrayIndex
|
||||
? segments[segments.length - 2] // 数组索引属于上一层级,需要去除防止影响到搜索值
|
||||
: segments[segments.length - 1];
|
||||
if (
|
||||
!lastSegment ||
|
||||
lastSegment.type !== ExpressionEditorSegmentType.ObjectKey
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return lastSegment.objectKey;
|
||||
}
|
||||
|
||||
export { useLatest, useDeepEqualMemo, getSearchValue, generateUniqueId };
|
||||
@@ -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 { useEffect, useLayoutEffect } from 'react';
|
||||
|
||||
import { useEditor, useInjector } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-expression';
|
||||
import { astDecorator } from '@coze-editor/editor';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import { useDeepEqualMemo, useLatest } from '../shared';
|
||||
import { type ExpressionEditorTreeNode } from '../core';
|
||||
import { validateExpression } from './validate';
|
||||
|
||||
interface Props {
|
||||
variableTree: ExpressionEditorTreeNode[];
|
||||
}
|
||||
|
||||
function Validation({ variableTree }: Props) {
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const editorRef = useLatest(editor);
|
||||
const injector = useInjector();
|
||||
const variableTreeRef = useLatest(variableTree);
|
||||
const changedVariableTree = useDeepEqualMemo(variableTree);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
editor.updateWholeDecorations();
|
||||
}
|
||||
|
||||
editor.$on('focus', handleFocus);
|
||||
|
||||
return () => {
|
||||
editor.$off('focus', handleFocus);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
editorRef.current.updateWholeDecorations();
|
||||
}, [changedVariableTree]);
|
||||
|
||||
useLayoutEffect(() =>
|
||||
injector.inject([
|
||||
[
|
||||
astDecorator.whole.of((cursor, state) => {
|
||||
if (!variableTreeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
cursor.name === 'JinjaExpression' &&
|
||||
// 由于 parser 存在容错能力
|
||||
// 可能出现缺少右花括号也被正常解析为 Interpolation 的情况
|
||||
// 如:{{variable
|
||||
cursor.node.firstChild?.name === 'JinjaExpressionStart' &&
|
||||
cursor.node.lastChild?.name === 'JinjaExpressionEnd'
|
||||
) {
|
||||
const source = state.sliceDoc(
|
||||
cursor.node.firstChild.to,
|
||||
cursor.node.lastChild.from,
|
||||
);
|
||||
|
||||
const isValid = validateExpression(source, variableTreeRef.current);
|
||||
|
||||
if (isValid) {
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'cm-decoration-interpolation-valid',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'cm-decoration-interpolation-invalid',
|
||||
};
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'.cm-decoration-interpolation-valid': {
|
||||
color: '#6675D9',
|
||||
caretColor: '#6675D9',
|
||||
},
|
||||
'.cm-decoration-interpolation-invalid': {
|
||||
color: '#060709CC',
|
||||
},
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export { Validation };
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ExpressionEditorParserBuiltin } from '../core/parser';
|
||||
import {
|
||||
ExpressionEditorSegmentType,
|
||||
ExpressionEditorTreeHelper,
|
||||
type ExpressionEditorTreeNode,
|
||||
} from '../core';
|
||||
|
||||
function validateExpression(source: string, tree: ExpressionEditorTreeNode[]) {
|
||||
const segments = ExpressionEditorParserBuiltin.toSegments(source);
|
||||
|
||||
if (!segments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
segments[segments.length - 1].type === ExpressionEditorSegmentType.EndEmpty
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. segments mix variable tree, match tree branch
|
||||
const treeBranch = ExpressionEditorTreeHelper.matchTreeBranch({
|
||||
tree,
|
||||
segments,
|
||||
});
|
||||
if (!treeBranch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. if full segments path could match one tree branch, the pattern is valid
|
||||
return true;
|
||||
}
|
||||
|
||||
export { validateExpression };
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// modified from @coze-workflow/nodes
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
/**
|
||||
* 前端变量类型
|
||||
*/
|
||||
export enum ViewVariableType {
|
||||
String = 1,
|
||||
Integer,
|
||||
Boolean,
|
||||
Number,
|
||||
Object = 6,
|
||||
Image,
|
||||
File,
|
||||
Doc,
|
||||
Code,
|
||||
Ppt,
|
||||
Txt,
|
||||
Excel,
|
||||
Audio,
|
||||
Zip,
|
||||
Video,
|
||||
Svg,
|
||||
Voice,
|
||||
Time,
|
||||
// 上面是 api 中定义的 InputType。下面是整合后的。从 99 开始,避免和后端定义撞车
|
||||
ArrayString = 99,
|
||||
ArrayInteger,
|
||||
ArrayBoolean,
|
||||
ArrayNumber,
|
||||
ArrayObject,
|
||||
ArrayImage,
|
||||
ArrayFile,
|
||||
ArrayDoc,
|
||||
ArrayCode,
|
||||
ArrayPpt,
|
||||
ArrayTxt,
|
||||
ArrayExcel,
|
||||
ArrayAudio,
|
||||
ArrayZip,
|
||||
ArrayVideo,
|
||||
ArraySvg,
|
||||
ArrayVoice,
|
||||
ArrayTime,
|
||||
}
|
||||
|
||||
const BASE_ARRAY_PAIR: [ViewVariableType, ViewVariableType][] = [
|
||||
[ViewVariableType.String, ViewVariableType.ArrayString],
|
||||
[ViewVariableType.Integer, ViewVariableType.ArrayInteger],
|
||||
[ViewVariableType.Boolean, ViewVariableType.ArrayBoolean],
|
||||
[ViewVariableType.Number, ViewVariableType.ArrayNumber],
|
||||
[ViewVariableType.Object, ViewVariableType.ArrayObject],
|
||||
[ViewVariableType.Image, ViewVariableType.ArrayImage],
|
||||
[ViewVariableType.File, ViewVariableType.ArrayFile],
|
||||
[ViewVariableType.Doc, ViewVariableType.ArrayDoc],
|
||||
[ViewVariableType.Code, ViewVariableType.ArrayCode],
|
||||
[ViewVariableType.Ppt, ViewVariableType.ArrayPpt],
|
||||
[ViewVariableType.Txt, ViewVariableType.ArrayTxt],
|
||||
[ViewVariableType.Excel, ViewVariableType.ArrayExcel],
|
||||
[ViewVariableType.Audio, ViewVariableType.ArrayAudio],
|
||||
[ViewVariableType.Zip, ViewVariableType.ArrayZip],
|
||||
[ViewVariableType.Video, ViewVariableType.ArrayVideo],
|
||||
[ViewVariableType.Svg, ViewVariableType.ArraySvg],
|
||||
[ViewVariableType.Voice, ViewVariableType.ArrayVoice],
|
||||
[ViewVariableType.Time, ViewVariableType.ArrayTime],
|
||||
];
|
||||
|
||||
const ArrayTypes = BASE_ARRAY_PAIR.map(_pair => _pair[1]);
|
||||
|
||||
export function isArrayType(type: ViewVariableType): boolean {
|
||||
return ArrayTypes.includes(type);
|
||||
}
|
||||
17
frontend/packages/common/editor-plugins/src/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { type Extension } from './types';
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
|
||||
import { TemplateParser } from '../../shared/utils/template-parser';
|
||||
const templateParser = new TemplateParser({ mark: 'InputSlot' });
|
||||
|
||||
export const insertInputSlot = (
|
||||
editor: EditorAPI,
|
||||
options?: {
|
||||
mode?: 'input' | 'configurable';
|
||||
placeholder?: string;
|
||||
},
|
||||
) => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
mode = 'input',
|
||||
placeholder = I18n.t('edit_block_guidance_text_placeholder'),
|
||||
} = options ?? {};
|
||||
const { selection } = editor.$view.state;
|
||||
const selectionRange = editor.$view.state.selection.main;
|
||||
const content = editor.$view.state.sliceDoc(
|
||||
selectionRange.from,
|
||||
selectionRange.to,
|
||||
);
|
||||
const extractedContent = templateParser.extractTemplateContent(content);
|
||||
const { open, template, textContent } = templateParser.generateTemplateJson({
|
||||
content: extractedContent,
|
||||
data: {
|
||||
placeholder,
|
||||
mode,
|
||||
},
|
||||
});
|
||||
const from = selectionRange.from + open.length;
|
||||
const to = from + textContent.length;
|
||||
editor.$view.dispatch({
|
||||
changes: {
|
||||
from: selectionRange.from,
|
||||
to: selectionRange.to,
|
||||
insert: template,
|
||||
},
|
||||
});
|
||||
setTimeout(() => {
|
||||
editor.$view.dispatch({
|
||||
selection: selection.replaceRange(EditorSelection.range(from, to)),
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useEditor } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
import { TemplateParser } from '../../shared/utils/template-parser';
|
||||
|
||||
const templateParser = new TemplateParser({ mark: 'InputSlot' });
|
||||
export const useCursorInInputSlot = () => {
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const [inInputSlot, setInInputSlot] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const handleViewUpdate = (update: ViewUpdate) => {
|
||||
if (update.selectionSet) {
|
||||
const markRangeInfo = templateParser.getSelectionInMarkNodeRange(
|
||||
update.state.selection.main,
|
||||
update.state,
|
||||
);
|
||||
if (markRangeInfo) {
|
||||
setInInputSlot(true);
|
||||
return;
|
||||
}
|
||||
setInInputSlot(false);
|
||||
}
|
||||
};
|
||||
editor.$on('viewUpdate', handleViewUpdate);
|
||||
return () => {
|
||||
editor.$off('viewUpdate', handleViewUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return inInputSlot;
|
||||
};
|
||||
|
||||
export const useSelectionInInputSlot = () => {
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const [inInputSlot, setInInputSlot] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const handleViewUpdate = (update: ViewUpdate) => {
|
||||
if (!update.state.selection.main.empty) {
|
||||
const markRangeInfo = templateParser.getSelectionInMarkNodeRange(
|
||||
update.state.selection.main,
|
||||
update.state,
|
||||
);
|
||||
if (markRangeInfo) {
|
||||
setInInputSlot(true);
|
||||
return;
|
||||
}
|
||||
setInInputSlot(false);
|
||||
}
|
||||
};
|
||||
editor.$on('viewUpdate', handleViewUpdate);
|
||||
return () => {
|
||||
editor.$off('viewUpdate', handleViewUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
return inInputSlot;
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useEditor } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
export const useSelectionInJinjaRaw = () => {
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const [inJinjaRaw, setInJinjaRaw] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkInJinjaRaw = () => {
|
||||
const selection = editor.getSelection();
|
||||
if (!selection) {
|
||||
setInJinjaRaw(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { state } = editor.$view;
|
||||
const tree = syntaxTree(state);
|
||||
const cursor = tree.cursor();
|
||||
|
||||
let isInRaw = false;
|
||||
do {
|
||||
if (cursor.name === 'RawText') {
|
||||
const isSelectionWithinNode =
|
||||
cursor.from <= selection.from && cursor.to >= selection.to;
|
||||
if (isSelectionWithinNode) {
|
||||
isInRaw = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} while (cursor.next());
|
||||
|
||||
setInJinjaRaw(isInRaw);
|
||||
};
|
||||
|
||||
editor.$on('viewUpdate', checkInJinjaRaw);
|
||||
|
||||
// 初始检查
|
||||
checkInJinjaRaw();
|
||||
|
||||
return () => {
|
||||
editor.$off('viewUpdate', checkInJinjaRaw);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return inJinjaRaw;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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 { InputSlotWidget } from './input-slot-widget';
|
||||
export { insertInputSlot } from './action/insert-input-slot-action';
|
||||
export {
|
||||
useCursorInInputSlot,
|
||||
useSelectionInInputSlot,
|
||||
} from './hooks/use-in-input-slot';
|
||||
export { useSelectionInJinjaRaw } from './hooks/use-in-jinja-raw';
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { PositionMirror } from '@coze-editor/editor/react';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Popover, Input, type PopoverProps } from '@coze-arch/coze-design';
|
||||
|
||||
interface InputConfigPopoverProps {
|
||||
visible: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
positon: number;
|
||||
direction?: PopoverProps['position'];
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onPlaceholderChange?: (placeholder: string) => void;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
export const InputConfigPopover = (props: InputConfigPopoverProps) => {
|
||||
const [reposKey, setReposKey] = useState('');
|
||||
const { direction, placeholder, value } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
rePosKey={reposKey}
|
||||
visible={props.visible}
|
||||
trigger="custom"
|
||||
position={direction}
|
||||
autoAdjustOverflow
|
||||
content={
|
||||
<div className="flex flex-col gap-2 pt-3 pb-4 px-4 w-[320px]">
|
||||
<div>
|
||||
<div>{I18n.t('edit_block_guidance_text_when_empty')}</div>
|
||||
<Input
|
||||
value={placeholder}
|
||||
placeholder={I18n.t('edit_block_guidance_text_placeholder')}
|
||||
onChange={v => props.onPlaceholderChange?.(v)}
|
||||
onBlur={() => {
|
||||
if (!placeholder) {
|
||||
props.onPlaceholderChange?.(
|
||||
I18n.t('edit_block_guidance_text_placeholder'),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div>{I18n.t('edit_block_prefilled_text')}</div>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={I18n.t('edit_block_default_guidance_text')}
|
||||
onChange={v => props.onValueChange?.(v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PositionMirror
|
||||
position={props.positon}
|
||||
onChange={() => setReposKey(String(Math.random()))}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useEditor } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
import { InputConfigPopover } from '../input-config-popover';
|
||||
import { type TemplateParser } from '../../shared/utils/template-parser';
|
||||
|
||||
export const ConfigModeWidgetPopover = (props: {
|
||||
direction: 'bottomLeft' | 'topLeft' | 'bottomRight' | 'topRight';
|
||||
templateParser: TemplateParser;
|
||||
}) => {
|
||||
const { direction, templateParser } = props;
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const [placeholder, setPlaceholder] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const [configPopoverVisible, setConfigPopoverVisible] = useState(false);
|
||||
const [popoverPosition, setPopoverPosition] = useState(-1);
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const handleViewUpdate = (e: ViewUpdate) => {
|
||||
if (e.docChanged) {
|
||||
// 判断当前光标是否在 slot 节点内
|
||||
const { state } = e;
|
||||
const range = templateParser.getCursorInMarkNodeRange(state);
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
const content = state.sliceDoc(range.open.to, range.close.from);
|
||||
if (content === value) {
|
||||
return;
|
||||
}
|
||||
setValue(content);
|
||||
}
|
||||
if (e.selectionSet) {
|
||||
const { state } = e;
|
||||
const range = templateParser.getCursorInMarkNodeRange(state);
|
||||
if (!range) {
|
||||
setPopoverPosition(-1);
|
||||
setConfigPopoverVisible(false);
|
||||
return;
|
||||
}
|
||||
const content = templateParser.getCursorTemplateContent(editor);
|
||||
const { placeholder: configPlaceholder } =
|
||||
templateParser.getCursorTemplateData(state) ?? {};
|
||||
setPlaceholder(configPlaceholder);
|
||||
setValue(content ?? '');
|
||||
setPopoverPosition(range.open.from);
|
||||
setConfigPopoverVisible(true);
|
||||
}
|
||||
};
|
||||
editor.$on('viewUpdate', handleViewUpdate);
|
||||
|
||||
return () => {
|
||||
editor.$off('viewUpdate', handleViewUpdate);
|
||||
};
|
||||
}, [editor, value]);
|
||||
|
||||
const handlePlaceholderChange = (configPlaceholder: string) => {
|
||||
if (!editor || !configPopoverVisible) {
|
||||
return;
|
||||
}
|
||||
setPlaceholder(configPlaceholder);
|
||||
templateParser.updateCursorTemplateData(editor, {
|
||||
placeholder: configPlaceholder,
|
||||
});
|
||||
};
|
||||
const handleValueChange = (configValue: string) => {
|
||||
if (!editor || !configPopoverVisible) {
|
||||
return;
|
||||
}
|
||||
setValue(configValue);
|
||||
templateParser.updateCursorTemplateContent(editor, configValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputConfigPopover
|
||||
visible={configPopoverVisible}
|
||||
positon={popoverPosition}
|
||||
direction={direction}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onPlaceholderChange={handlePlaceholderChange}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
|
||||
.cm-content {
|
||||
.slot-content {
|
||||
color: #4E40E5;
|
||||
word-break: break-all;
|
||||
background-color: rgba(186, 192, 255, 20%);
|
||||
|
||||
/* padding: 2px 0; */
|
||||
}
|
||||
|
||||
.slot-side-left {
|
||||
/* padding: 2px 0 2px 5px; */
|
||||
padding-left: 5px;
|
||||
background-color: rgba(186, 192, 255, 20%);
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.slot-side-right {
|
||||
margin-right: 4px;
|
||||
|
||||
/* padding: 2px 5px 2px 0; */
|
||||
padding-right: 5px;
|
||||
background-color: rgba(186, 192, 255, 20%);
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
.slot-placeholder {
|
||||
color: rgba(148, 152, 247, 70%);
|
||||
word-break: break-all;
|
||||
background-color: rgba(186, 192, 255, 20%);
|
||||
|
||||
/* padding: 2px 0; */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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 { ConfigModeWidgetPopover } from './config-mode-popover';
|
||||
|
||||
import './index.css';
|
||||
import { useEffect, useLayoutEffect } from 'react';
|
||||
|
||||
import { useEditor, useInjector } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import {
|
||||
astDecorator,
|
||||
SpanWidget,
|
||||
autoSelectRanges,
|
||||
selectionEnlarger,
|
||||
deletionEnlarger,
|
||||
} from '@coze-editor/editor';
|
||||
import { type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
import {
|
||||
type MarkRangeInfo,
|
||||
TemplateParser,
|
||||
} from '../../shared/utils/template-parser';
|
||||
import { useReadonly } from '../../shared/hooks/use-editor-readonly';
|
||||
interface InputSlotWidgetProps {
|
||||
mode?: 'input' | 'configurable';
|
||||
onSelectionInInputSlot?: (selection: MarkRangeInfo | undefined) => void;
|
||||
}
|
||||
|
||||
const templateParser = new TemplateParser({ mark: 'InputSlot' });
|
||||
|
||||
export const InputSlotWidget = (props: InputSlotWidgetProps) => {
|
||||
const { mode, onSelectionInInputSlot } = props;
|
||||
const injector = useInjector();
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const readonly = useReadonly();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const { markInfoField } = templateParser;
|
||||
|
||||
return injector.inject([
|
||||
astDecorator.whole.of((cursor, state) => {
|
||||
if (templateParser.isOpenNode(cursor.node, state)) {
|
||||
const open = cursor.node;
|
||||
const close = templateParser.findCloseNode(open, state);
|
||||
|
||||
if (close) {
|
||||
const openTemplate = state.sliceDoc(open.from, open.to);
|
||||
const data = templateParser.getData(openTemplate);
|
||||
const from = open.to;
|
||||
const to = close.from;
|
||||
|
||||
if (from === to) {
|
||||
return [
|
||||
{
|
||||
type: 'replace',
|
||||
widget: new SpanWidget({
|
||||
className: 'slot-side-left',
|
||||
}),
|
||||
atomicRange: true,
|
||||
from: open.from,
|
||||
to: open.to,
|
||||
},
|
||||
{
|
||||
type: 'widget',
|
||||
widget: new SpanWidget({
|
||||
text: data?.placeholder || '',
|
||||
className: 'slot-placeholder',
|
||||
}),
|
||||
from,
|
||||
atomicRange: true,
|
||||
side: 1,
|
||||
},
|
||||
{
|
||||
type: 'replace',
|
||||
widget: new SpanWidget({
|
||||
className: 'slot-side-right',
|
||||
}),
|
||||
atomicRange: true,
|
||||
from: close.from,
|
||||
to: close.to,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: 'replace',
|
||||
widget: new SpanWidget({
|
||||
className: 'slot-side-left',
|
||||
}),
|
||||
atomicRange: true,
|
||||
from: open.from,
|
||||
to: open.to,
|
||||
},
|
||||
{
|
||||
type: 'className',
|
||||
className: 'slot-content',
|
||||
from,
|
||||
to,
|
||||
},
|
||||
{
|
||||
type: 'replace',
|
||||
widget: new SpanWidget({ className: 'slot-side-right' }),
|
||||
atomicRange: true,
|
||||
from: close.from,
|
||||
to: close.to,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
markInfoField,
|
||||
|
||||
autoSelectRanges.of(state => state.field(markInfoField).contents),
|
||||
|
||||
selectionEnlarger.of(state => state.field(markInfoField).specs),
|
||||
|
||||
deletionEnlarger.of(state => state.field(markInfoField).specs),
|
||||
]);
|
||||
}, [injector]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const handleViewUpdate = (update: ViewUpdate) => {
|
||||
if (!update.state.selection.main.empty) {
|
||||
const markRangeInfo = templateParser.getSelectionInMarkNodeRange(
|
||||
update.state.selection.main,
|
||||
update.state,
|
||||
);
|
||||
if (markRangeInfo) {
|
||||
onSelectionInInputSlot?.(markRangeInfo);
|
||||
return;
|
||||
}
|
||||
onSelectionInInputSlot?.(undefined);
|
||||
}
|
||||
};
|
||||
editor.$on('viewUpdate', handleViewUpdate);
|
||||
return () => {
|
||||
editor.$off('viewUpdate', handleViewUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (mode === 'configurable' && !readonly) {
|
||||
return (
|
||||
<ConfigModeWidgetPopover
|
||||
direction="bottomLeft"
|
||||
templateParser={templateParser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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 { useLayoutEffect } from 'react';
|
||||
|
||||
import { useInjector } from '@coze-editor/editor/react';
|
||||
import { languageSupport } from '@coze-editor/editor/preset-prompt';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
function LanguageSupport() {
|
||||
const injector = useInjector();
|
||||
|
||||
useLayoutEffect(() => injector.inject([languageSupport]), [injector]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export { LanguageSupport };
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="coz_chat">
|
||||
<g id="Union">
|
||||
<path d="M5.24283 5.83325C5.24283 5.51109 5.504 5.24992 5.82617 5.24992H9.9095C10.2317 5.24992 10.4928 5.51109 10.4928 5.83325C10.4928 6.15542 10.2317 6.41659 9.9095 6.41659H5.82617C5.504 6.41659 5.24283 6.15542 5.24283 5.83325Z" fill="#5147FF"/>
|
||||
<path d="M5.82617 7.58325C5.504 7.58325 5.24283 7.84442 5.24283 8.16659C5.24283 8.48875 5.504 8.74992 5.82617 8.74992H8.1595C8.48167 8.74992 8.74283 8.48875 8.74283 8.16659C8.74283 7.84442 8.48167 7.58325 8.1595 7.58325H5.82617Z" fill="#5147FF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9928 6.99992C13.9928 10.5437 11.12 13.4166 7.57617 13.4166H3.27002C2.83638 13.4166 2.55434 12.9602 2.74827 12.5724L3.19186 11.6852C1.94111 10.5143 1.1595 8.84839 1.1595 6.99992C1.1595 3.45609 4.03234 0.583252 7.57617 0.583252C11.12 0.583252 13.9928 3.45609 13.9928 6.99992ZM4.62464 11.4284L4.21387 12.2499H7.57617C10.4757 12.2499 12.8262 9.89941 12.8262 6.99992C12.8262 4.10042 10.4757 1.74992 7.57617 1.74992C4.67667 1.74992 2.32617 4.10042 2.32617 6.99992C2.32617 8.51237 2.96421 9.87396 3.98918 10.8335L4.62464 11.4284Z" fill="#5147FF"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Component 2">
|
||||
<g id="Union">
|
||||
<path d="M11.7377 5.08875V10.3246C11.7377 10.4212 11.6594 10.4996 11.5627 10.4996H8.39925L8.39859 10.4996H3.41023C3.25433 10.4996 3.17625 10.3111 3.28649 10.2008L5.78067 7.70667C5.84901 7.63833 5.95982 7.63833 6.02816 7.70667L7.36272 9.04124L11.439 4.965C11.5492 4.85476 11.7377 4.93284 11.7377 5.08875Z" fill="#4E40E5"/>
|
||||
<path d="M4.73779 5.83293C5.38213 5.83293 5.90446 5.31059 5.90446 4.66626C5.90446 4.02193 5.38213 3.49959 4.73779 3.49959C4.09346 3.49959 3.57113 4.02193 3.57113 4.66626C3.57113 5.31059 4.09346 5.83293 4.73779 5.83293Z" fill="#4E40E5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.23779 2.33293C1.23779 1.69126 1.76279 1.16626 2.40446 1.16626H12.9045C13.5461 1.16626 14.0711 1.69126 14.0711 2.33293V11.6663C14.0711 12.3079 13.5461 12.8329 12.9045 12.8329H2.40446C1.76279 12.8329 1.23779 12.3079 1.23779 11.6663V2.33293ZM12.9045 2.33293H2.40446V11.6663H12.9045V2.33293Z" fill="#4E40E5"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Component 2">
|
||||
<g id="Union">
|
||||
<path d="M1.47082 1.39238C1.72422 1.27387 1.92817 1.06992 2.04668 0.816521C2.05886 0.790463 2.07015 0.763881 2.08049 0.736816C2.08663 0.720755 2.09243 0.704523 2.0979 0.688128L2.25921 0.204192C2.34996 -0.0680642 2.73506 -0.0680639 2.82581 0.204192L2.98712 0.688129C2.99259 0.704523 2.99839 0.720755 3.00453 0.736816C3.01487 0.763881 3.02615 0.790463 3.03834 0.816521C3.15685 1.06992 3.3608 1.27387 3.6142 1.39238C3.64026 1.40457 3.66684 1.41585 3.6939 1.42619C3.70997 1.43233 3.7262 1.43813 3.74259 1.4436L4.22653 1.60491C4.49878 1.69566 4.49878 2.08076 4.22653 2.17151L3.74259 2.33283C3.7262 2.33829 3.70997 2.3441 3.6939 2.35023C3.66684 2.36057 3.64026 2.37186 3.6142 2.38404C3.40047 2.484 3.22192 2.64474 3.10015 2.84466C3.07754 2.88177 3.05689 2.92023 3.03834 2.9599C3.02615 2.98596 3.01487 3.01254 3.00453 3.03961C2.99839 3.05567 2.99259 3.0719 2.98712 3.08829L2.82581 3.57223C2.78611 3.69134 2.69006 3.75834 2.58698 3.77323C2.45444 3.79238 2.31026 3.72538 2.25921 3.57223L2.0979 3.08829C2.09243 3.0719 2.08662 3.05567 2.08049 3.03961C2.07015 3.01254 2.05886 2.98596 2.04668 2.9599C1.92816 2.7065 1.72422 2.50256 1.47082 2.38404C1.44476 2.37186 1.41818 2.36057 1.39111 2.35023C1.37505 2.3441 1.35882 2.33829 1.34243 2.33282L0.858488 2.17151C0.586233 2.08076 0.586233 1.69566 0.858489 1.60491L1.34243 1.4436C1.35882 1.43813 1.37505 1.43233 1.39111 1.42619C1.41818 1.41585 1.44476 1.40457 1.47082 1.39238Z" fill="#4E40E5"/>
|
||||
<path d="M11.6944 11.616C11.9478 11.4974 12.1517 11.2935 12.2702 11.0401C12.2824 11.014 12.2937 10.9875 12.3041 10.9604C12.3102 10.9443 12.316 10.9281 12.3215 10.9117L12.4828 10.4278C12.5735 10.1555 12.9586 10.1555 13.0494 10.4278L13.2107 10.9117C13.2162 10.9281 13.222 10.9443 13.2281 10.9604C13.2384 10.9875 13.2497 11.014 13.2619 11.0401C13.3804 11.2935 13.5844 11.4974 13.8378 11.616C13.8638 11.6281 13.8904 11.6394 13.9175 11.6498C13.9335 11.6559 13.9498 11.6617 13.9662 11.6672L14.4501 11.8285C14.7224 11.9192 14.7224 12.3043 14.4501 12.3951L13.9662 12.5564C13.9498 12.5619 13.9335 12.5677 13.9175 12.5738C13.8904 12.5841 13.8638 12.5954 13.8378 12.6076C13.624 12.7076 13.4455 12.8683 13.3237 13.0682C13.3011 13.1053 13.2805 13.1438 13.2619 13.1835C13.2497 13.2095 13.2384 13.2361 13.2281 13.2632C13.222 13.2792 13.2162 13.2955 13.2107 13.3119L13.0494 13.7958C13.0097 13.9149 12.9136 13.9819 12.8105 13.9968C12.678 14.0159 12.5338 13.9489 12.4828 13.7958L12.3215 13.3119C12.316 13.2955 12.3102 13.2792 12.3041 13.2632C12.2937 13.2361 12.2824 13.2095 12.2702 13.1835C12.1517 12.9301 11.9478 12.7261 11.6944 12.6076C11.6683 12.5954 11.6417 12.5841 11.6147 12.5738C11.5986 12.5677 11.5824 12.5619 11.566 12.5564L11.0821 12.3951C10.8098 12.3043 10.8098 11.9192 11.0821 11.8285L11.566 11.6672C11.5824 11.6617 11.5986 11.6559 11.6147 11.6498C11.6417 11.6394 11.6683 11.6281 11.6944 11.616Z" fill="#4E40E5"/>
|
||||
<path d="M2.54396 4.39184C2.36112 4.39184 2.18523 4.33824 2.03515 4.23662C1.95199 4.18032 1.88005 4.1116 1.82096 4.03265V11.6667C1.82096 12.3083 2.34596 12.8333 2.98763 12.8333H10.6203C10.5422 12.7745 10.4743 12.7031 10.4186 12.6208C10.3171 12.4708 10.2633 12.2949 10.2633 12.1119C10.2633 11.9542 10.3032 11.8018 10.3792 11.6667H2.98763V4.27615C2.98011 4.28037 2.97254 4.28448 2.96492 4.28847C2.83574 4.3562 2.69174 4.39168 2.54306 4.39168L2.54396 4.39184Z" fill="#4E40E5"/>
|
||||
<path d="M13.4876 2.33333V9.96567C13.347 9.77914 13.1375 9.65251 12.8977 9.61784C12.8545 9.61165 12.8104 9.60848 12.7667 9.60848C12.6088 9.60848 12.4562 9.64838 12.321 9.7245V2.33333H4.93076C5.00684 2.19813 5.04667 2.04568 5.04667 1.88807C5.04667 1.70508 4.99307 1.52919 4.89161 1.37926C4.83597 1.29697 4.7681 1.22563 4.69018 1.16688L12.321 1.16667C12.9626 1.16667 13.4876 1.69167 13.4876 2.33333Z" fill="#4E40E5"/>
|
||||
<path d="M6.56987 10.4932L4.07594 10.4872C3.97929 10.487 3.90113 10.4084 3.90136 10.3118C3.90147 10.2655 3.9199 10.2212 3.95262 10.1884L6.36342 7.77765C6.45454 7.68652 6.60228 7.68652 6.6934 7.77765L7.80586 8.89011L10.756 5.94C10.8471 5.84888 10.9948 5.84888 11.086 5.94C11.1297 5.98376 11.1543 6.04311 11.1543 6.10499V10.325C11.1543 10.4217 11.0759 10.5 10.9793 10.5H6.61845C6.6016 10.5 6.5853 10.4976 6.56987 10.4932Z" fill="#4E40E5"/>
|
||||
<path d="M4.1543 3.5H5.9043V5.25H4.1543V3.5Z" fill="#4E40E5"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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 { default as pluginIcon } from './plugin.svg';
|
||||
export { default as workflowIcon } from './workflow.svg';
|
||||
export { default as imageflowIcon } from './imageflow.svg';
|
||||
export { default as tableIcon } from './table.svg';
|
||||
export { default as textIcon } from './text.svg';
|
||||
export { default as imageIcon } from './image.svg';
|
||||
export { default as chatflowIcon } from './chatflow.svg';
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Component 2">
|
||||
<path id="Union" fill-rule="evenodd" clip-rule="evenodd" d="M6.90598 0.696144C7.36911 0.428759 7.9397 0.428759 8.40282 0.696144L12.7393 3.19983C13.2025 3.46722 13.4878 3.96136 13.4878 4.49613V9.50351C13.4878 10.0383 13.2025 10.5324 12.7393 10.7998L8.40282 13.3035C7.9397 13.5709 7.3691 13.5709 6.90598 13.3035L2.56946 10.7998C2.10634 10.5324 1.82104 10.0383 1.82104 9.50351V4.49613C1.82104 3.96136 2.10634 3.46722 2.56946 3.19983L6.90598 0.696144ZM11.9063 4.06604L7.81949 1.70651C7.71733 1.64753 7.59147 1.64753 7.48931 1.70651L3.40043 4.06722L7.65235 6.52207L11.9063 4.06604ZM2.98771 5.17609V9.50351C2.98771 9.62147 3.05064 9.73047 3.1528 9.78945L7.06901 12.0505V7.53243L2.98771 5.17609ZM8.23568 12.0528L12.156 9.78945C12.2582 9.73047 12.3211 9.62147 12.3211 9.50351V5.17372L8.23568 7.53243V12.0528Z" fill="#4E40E5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 948 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Component 2">
|
||||
<path id="Union" d="M1.82104 2.33342C1.82104 1.68908 2.34338 1.16675 2.98771 1.16675H12.321C12.9654 1.16675 13.4877 1.68908 13.4877 2.33341V11.6667C13.4877 12.3111 12.9654 12.8334 12.321 12.8334H2.98771C2.34338 12.8334 1.82104 12.3111 1.82104 11.6667V2.33342ZM6.19605 2.33341V4.66675L12.321 4.66675V2.33341H6.19605ZM6.19605 5.83341V8.16675L12.321 8.16675V5.83342L6.19605 5.83341ZM5.02938 8.16675V5.83341H2.98771V8.16675H5.02938ZM2.98771 9.33342V11.6667H5.02938V9.33342H2.98771ZM6.19605 9.33342V11.6667H12.321V9.33342H6.19605ZM5.02938 2.33341H2.98771V4.66675L5.02938 4.66675V2.33341Z" fill="#4E40E5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 730 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Component 2">
|
||||
<g id="Union">
|
||||
<path d="M4.73763 4.9585C4.73763 4.63633 4.9988 4.37516 5.32096 4.37516H7.6543C7.97646 4.37516 8.23763 4.63633 8.23763 4.9585C8.23763 5.28066 7.97646 5.54183 7.6543 5.54183H5.32096C4.9988 5.54183 4.73763 5.28066 4.73763 4.9585Z" fill="#4E40E5"/>
|
||||
<path d="M4.73763 7.29183C4.73763 6.96966 4.9988 6.7085 5.32096 6.7085H9.98763C10.3098 6.7085 10.571 6.96966 10.571 7.29183C10.571 7.614 10.3098 7.87516 9.98763 7.87516H5.32096C4.9988 7.87516 4.73763 7.614 4.73763 7.29183Z" fill="#4E40E5"/>
|
||||
<path d="M4.73763 9.62516C4.73763 9.303 4.9988 9.04183 5.32096 9.04183H9.98763C10.3098 9.04183 10.571 9.303 10.571 9.62516C10.571 9.94733 10.3098 10.2085 9.98763 10.2085H5.32096C4.9988 10.2085 4.73763 9.94733 4.73763 9.62516Z" fill="#4E40E5"/>
|
||||
<path d="M2.4043 1.75016C2.4043 1.10583 2.92663 0.583496 3.57096 0.583496H11.7376C12.382 0.583496 12.9043 1.10583 12.9043 1.75016V12.2502C12.9043 12.8945 12.382 13.4168 11.7376 13.4168H3.57096C2.92663 13.4168 2.4043 12.8945 2.4043 12.2502V1.75016ZM3.57096 1.75016V12.2502H11.7376V1.75016H3.57096Z" fill="#4E40E5"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Component 2">
|
||||
<path id="Union" fill-rule="evenodd" clip-rule="evenodd" d="M9.06505 5.38087C9.27846 5.65615 9.61235 5.83333 9.98763 5.83333H12.6126C13.257 5.83333 13.7793 5.311 13.7793 4.66667V2.04167C13.7793 1.39733 13.257 0.875 12.6126 0.875H9.98763C9.3433 0.875 8.82096 1.39733 8.82096 2.04167V4.19269L8.80691 4.16985L6.48763 5.5971V5.25C6.48763 4.60567 5.9653 4.08333 5.32096 4.08333L2.69596 4.08333C2.05163 4.08333 1.5293 4.60567 1.5293 5.25V8.75C1.5293 9.39433 2.05163 9.91667 2.69596 9.91667H5.32096C5.9653 9.91667 6.48763 9.39433 6.48763 8.75V8.36035L8.82096 9.72859V11.9583C8.82096 12.6027 9.3433 13.125 9.98763 13.125H12.6126C13.257 13.125 13.7793 12.6027 13.7793 11.9583V9.33333C13.7793 8.689 13.257 8.16667 12.6126 8.16667H9.98763C9.64375 8.16667 9.33463 8.31544 9.12111 8.55214L6.48763 7.00789V6.96697L9.06505 5.38087ZM9.98763 2.04167V4.66667H12.6126V2.04167H9.98763ZM9.98763 9.33333V11.9583H12.6126V9.33333H9.98763ZM2.69596 8.75L2.69596 5.25L5.32096 5.25L5.32096 8.75H2.69596Z" fill="#4E40E5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 { useLayoutEffect } from 'react';
|
||||
|
||||
import { useInjector } from '@coze-editor/editor/react';
|
||||
import { keymap, type EditorView } from '@codemirror/view';
|
||||
export const useKeymap = (
|
||||
keyMap: string[],
|
||||
run: (view: EditorView) => boolean,
|
||||
) => {
|
||||
const injector = useInjector();
|
||||
useLayoutEffect(
|
||||
() => injector.inject([keymap.of(keyMap.map(key => ({ key, run })))]),
|
||||
[injector, keyMap, run],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { LibraryBlockWidget } from './library-block-widget';
|
||||
export { LibrarySearchPopover } from './library-search-popover';
|
||||
export {
|
||||
type ILibraryList,
|
||||
type ILibraryItems,
|
||||
type ILibraryItem,
|
||||
type LibraryBlockInfo,
|
||||
type LibraryType,
|
||||
getReferenceType,
|
||||
} from './types';
|
||||
@@ -0,0 +1,31 @@
|
||||
.cm-content {
|
||||
.library-block-container {
|
||||
cursor: default;
|
||||
|
||||
display: inline-block;
|
||||
|
||||
margin-right: 4px;
|
||||
padding: 0 5px;
|
||||
|
||||
color: #4E40E5;
|
||||
|
||||
background-color: rgba(186, 192, 255, 20%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.library-block-icon {
|
||||
margin-right: 2px;
|
||||
vertical-align: sub;
|
||||
}
|
||||
|
||||
.library-block-content {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.library-block-tooltip {
|
||||
.semi-tooltip-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { useInjector, useEditor } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { astDecorator } from '@coze-editor/editor';
|
||||
|
||||
import { getLibraryStatus } from '../utils/get-library-status';
|
||||
import {
|
||||
getLibraryBlockInfoFromTemplate,
|
||||
getLibraryInfoByBlockInfo,
|
||||
} from '../utils/get-library-info';
|
||||
import type { ILibraryItem, ILibraryList } from '../types';
|
||||
import { TemplateParser } from '../../shared/utils/template-parser';
|
||||
import { LibraryBlockWidgetType } from './library-block-widget-type';
|
||||
import './index.css';
|
||||
|
||||
const templateParser = new TemplateParser({ mark: 'LibraryBlock' });
|
||||
function useLatest<T>(value: T) {
|
||||
const ref = useRef<T>(value);
|
||||
ref.current = value;
|
||||
return ref;
|
||||
}
|
||||
interface LibraryBlockWidgetProps {
|
||||
librarys: ILibraryList;
|
||||
readonly?: boolean;
|
||||
spaceId?: string;
|
||||
className?: string;
|
||||
onAddLibrary?: (
|
||||
library: ILibraryItem,
|
||||
pos?: { from: number; to: number },
|
||||
) => void;
|
||||
projectId?: string;
|
||||
avatarBotId?: string;
|
||||
onRename?: (pos: { from: number; to: number }) => void;
|
||||
disabledTooltips?: boolean;
|
||||
}
|
||||
|
||||
export const LibraryBlockWidget = (props: LibraryBlockWidgetProps) => {
|
||||
const ref = useLatest(props);
|
||||
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const editorRef = useLatest(editor);
|
||||
const injector = useInjector();
|
||||
useLayoutEffect(
|
||||
() =>
|
||||
injector.inject([
|
||||
astDecorator.whole.of((cursor, state) => {
|
||||
const {
|
||||
librarys,
|
||||
readonly = false,
|
||||
spaceId,
|
||||
className,
|
||||
projectId,
|
||||
avatarBotId,
|
||||
onRename,
|
||||
disabledTooltips,
|
||||
} = ref.current;
|
||||
if (templateParser.isOpenNode(cursor.node, state)) {
|
||||
const open = cursor.node;
|
||||
const close = templateParser.findCloseNode(open, state);
|
||||
|
||||
if (close) {
|
||||
const openTemplate = state.sliceDoc(open.from, open.to);
|
||||
const contentFrom = open.to;
|
||||
const contentTo = close.from;
|
||||
const content = state.sliceDoc(contentFrom, contentTo);
|
||||
const dataInfo = getLibraryBlockInfoFromTemplate({
|
||||
template: openTemplate,
|
||||
templateParser,
|
||||
});
|
||||
const { libraryStatus } = getLibraryStatus({
|
||||
librarys,
|
||||
libraryBlockInfo: dataInfo,
|
||||
content,
|
||||
});
|
||||
const libraryInfo = dataInfo
|
||||
? getLibraryInfoByBlockInfo(librarys, dataInfo)
|
||||
: null;
|
||||
return [
|
||||
{
|
||||
type: 'replace',
|
||||
widget: new LibraryBlockWidgetType({
|
||||
editorRef,
|
||||
blockDataInfo: dataInfo,
|
||||
libraryItem: libraryInfo,
|
||||
readonly,
|
||||
content,
|
||||
spaceId,
|
||||
className,
|
||||
hightlight: libraryStatus === 'existing',
|
||||
libraryStatus,
|
||||
onAddLibrary(library) {
|
||||
if (typeof ref.current.onAddLibrary === 'function') {
|
||||
ref.current.onAddLibrary(library, {
|
||||
from: open.from,
|
||||
to: close.to,
|
||||
});
|
||||
}
|
||||
},
|
||||
range: {
|
||||
left: open.to,
|
||||
right: close.from,
|
||||
},
|
||||
projectId,
|
||||
avatarBotId,
|
||||
onRename,
|
||||
disabledTooltips,
|
||||
}),
|
||||
atomicRange: true,
|
||||
from: open.from,
|
||||
to: close.to,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}),
|
||||
templateParser.markInfoField,
|
||||
]),
|
||||
[injector],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
editor?.updateWholeDecorations();
|
||||
}, [editor, props.librarys]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* 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 RefObject, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { type Root, createRoot } from 'react-dom/client';
|
||||
import cls from 'classnames';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { Tooltip } from '@coze-arch/coze-design';
|
||||
import { WidgetType } from '@codemirror/view';
|
||||
|
||||
import { getLibraryTooltip } from '../utils/get-library-tooltip';
|
||||
import { type LibraryStatus } from '../utils/get-library-status';
|
||||
import { type LibraryBlockInfo, type ILibraryItem } from '../types';
|
||||
interface LibraryBlockWidgetOptions {
|
||||
editorRef: RefObject<EditorAPI>;
|
||||
blockDataInfo: LibraryBlockInfo | null;
|
||||
libraryItem: ILibraryItem | null;
|
||||
content: string;
|
||||
hightlight: boolean;
|
||||
libraryStatus: LibraryStatus;
|
||||
readonly: boolean;
|
||||
spaceId?: string;
|
||||
className?: string;
|
||||
onAddLibrary?: (library: ILibraryItem) => void;
|
||||
range: {
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
projectId?: string;
|
||||
avatarBotId?: string;
|
||||
onRename?: (pos: { from: number; to: number }) => void;
|
||||
disabledTooltips?: boolean;
|
||||
}
|
||||
|
||||
function createElement(
|
||||
name: string,
|
||||
attributes: Record<string, string>,
|
||||
children: (HTMLElement | string)[] = [],
|
||||
) {
|
||||
const el = document.createElement(name);
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
el.setAttribute(key, value);
|
||||
}
|
||||
for (const child of children) {
|
||||
if (typeof child === 'string') {
|
||||
const text = document.createTextNode(child);
|
||||
el.appendChild(text);
|
||||
} else {
|
||||
el.appendChild(child);
|
||||
}
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
export class LibraryBlockWidgetType extends WidgetType {
|
||||
private options: LibraryBlockWidgetOptions | null;
|
||||
private container: HTMLSpanElement;
|
||||
private root: Root | null;
|
||||
private dom: HTMLSpanElement | undefined;
|
||||
private mounted: boolean;
|
||||
|
||||
constructor(options: LibraryBlockWidgetOptions | null) {
|
||||
super();
|
||||
this.options = options;
|
||||
this.container = document.createElement('span');
|
||||
this.root = null;
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
if (!this.options) {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
if (this.root) {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
if (!this.mounted) {
|
||||
// 同步渲染,避免抖动
|
||||
this.renderLibraryBlock(this.options);
|
||||
this.renderTooltip(this.options);
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
return this.container;
|
||||
}
|
||||
|
||||
renderLibraryBlock(options: LibraryBlockWidgetOptions) {
|
||||
const dom = createElement(
|
||||
'span',
|
||||
{
|
||||
class: cls('library-block-container leading-5', options.className, {
|
||||
'!coz-mg-hglt !text-[#9498F7] !text-opacity-70': !options.hightlight,
|
||||
}),
|
||||
},
|
||||
[
|
||||
createElement('img', {
|
||||
src: options.blockDataInfo?.icon || '',
|
||||
class: cls('library-block-icon', {
|
||||
'!opacity-70': !options.hightlight,
|
||||
}),
|
||||
}),
|
||||
createElement(
|
||||
'span',
|
||||
{
|
||||
class: 'library-block-content',
|
||||
},
|
||||
[options.content],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
this.dom = dom;
|
||||
this.container.appendChild(dom);
|
||||
}
|
||||
|
||||
renderTooltip(options: LibraryBlockWidgetOptions) {
|
||||
const tooltipTriggerDOM = document.createElement('span');
|
||||
|
||||
this.root = createRoot(tooltipTriggerDOM);
|
||||
this.container.appendChild(tooltipTriggerDOM);
|
||||
|
||||
this.root.render(
|
||||
<LibraryBlockWidgetReactCom
|
||||
editorRef={options.editorRef}
|
||||
blockDataInfo={options?.blockDataInfo}
|
||||
libraryItem={options.libraryItem}
|
||||
content={options.content}
|
||||
hightlight={options.hightlight}
|
||||
readonly={options.readonly}
|
||||
libraryStatus={options.libraryStatus}
|
||||
onAddLibrary={options.onAddLibrary}
|
||||
onRename={options.onRename}
|
||||
spaceId={options.spaceId ?? ''}
|
||||
range={options.range}
|
||||
beforeMount={() => {
|
||||
const element = this.dom;
|
||||
if (element?.parentNode) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
}}
|
||||
projectId={options.projectId}
|
||||
avatarBotId={options.avatarBotId}
|
||||
disabledTooltips={options.disabledTooltips}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.mounted = false;
|
||||
|
||||
if (this.root) {
|
||||
/**
|
||||
* Fix React warning: Attempted to synchronously unmount a root while React was already rendering
|
||||
* https://stackoverflow.com/questions/73043828/how-to-unmount-something-created-with-createroot-properly
|
||||
*/
|
||||
setTimeout(() => {
|
||||
this.root?.unmount();
|
||||
}, 0);
|
||||
this.root = null;
|
||||
}
|
||||
|
||||
if (this.dom) {
|
||||
this.dom.remove();
|
||||
this.dom = undefined;
|
||||
}
|
||||
|
||||
this.options = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const LibraryBlockWidgetReactCom = (props: {
|
||||
editorRef: RefObject<EditorAPI>;
|
||||
blockDataInfo: LibraryBlockInfo | null;
|
||||
libraryItem: ILibraryItem | null;
|
||||
content: string;
|
||||
hightlight?: boolean;
|
||||
readonly: boolean;
|
||||
libraryStatus: LibraryStatus;
|
||||
onAddLibrary?: (library: ILibraryItem) => void;
|
||||
spaceId: string;
|
||||
range: {
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
beforeMount: () => void;
|
||||
className?: string;
|
||||
projectId?: string;
|
||||
avatarBotId?: string;
|
||||
onRename?: (pos: { from: number; to: number }) => void;
|
||||
disabledTooltips?: boolean;
|
||||
}) => {
|
||||
const {
|
||||
blockDataInfo,
|
||||
libraryItem,
|
||||
content,
|
||||
hightlight = true,
|
||||
libraryStatus,
|
||||
onAddLibrary,
|
||||
readonly,
|
||||
spaceId,
|
||||
range,
|
||||
editorRef,
|
||||
beforeMount,
|
||||
className,
|
||||
projectId,
|
||||
avatarBotId,
|
||||
onRename,
|
||||
disabledTooltips,
|
||||
} = props;
|
||||
|
||||
const [tooltipConfig, setTooltipConfig] = useState<
|
||||
React.ComponentProps<typeof Tooltip> | undefined
|
||||
>(undefined);
|
||||
|
||||
const loadTooltipConfig = async () => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await getLibraryTooltip({
|
||||
editorRef,
|
||||
libraryStatus,
|
||||
readonly,
|
||||
libraryItem,
|
||||
blockDataInfo,
|
||||
onAddLibrary,
|
||||
spaceId,
|
||||
range,
|
||||
projectId,
|
||||
avatarBotId,
|
||||
onRename,
|
||||
disabled: disabledTooltips,
|
||||
});
|
||||
setTooltipConfig(res.tooltipConfig);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
beforeMount();
|
||||
}, []);
|
||||
|
||||
const baseElement = (
|
||||
<span
|
||||
className={cls('library-block-container leading-5', className, {
|
||||
'!coz-mg-hglt !text-[#9498F7] !text-opacity-70': !hightlight,
|
||||
})}
|
||||
onMouseEnter={loadTooltipConfig}
|
||||
>
|
||||
<img
|
||||
src={blockDataInfo?.icon || ''}
|
||||
className={cls('library-block-icon', {
|
||||
'!opacity-70': !hightlight,
|
||||
})}
|
||||
/>
|
||||
<span className="library-block-content">{content}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
// 只在tooltipConfig存在时渲染Tooltip
|
||||
if (!tooltipConfig) {
|
||||
return baseElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
position="bottomLeft"
|
||||
spacing={{ y: 4, x: 0 }}
|
||||
showArrow={false}
|
||||
{...tooltipConfig}
|
||||
>
|
||||
{baseElement}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 { I18n } from '@coze-arch/i18n';
|
||||
import { Button } from '@coze-arch/coze-design';
|
||||
|
||||
import { type ILibraryItem } from '../../types';
|
||||
|
||||
interface AddLibraryActionProps {
|
||||
library: ILibraryItem;
|
||||
onClick: (library: ILibraryItem) => void;
|
||||
}
|
||||
export const AddLibraryAction = ({
|
||||
library,
|
||||
onClick,
|
||||
}: AddLibraryActionProps) => (
|
||||
<Button onClick={() => onClick(library)} color="primary" size="small">
|
||||
{I18n.t('add')}
|
||||
</Button>
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type RefObject } from 'react';
|
||||
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Button } from '@coze-arch/coze-design';
|
||||
|
||||
import { type ILibraryItem } from '../../types';
|
||||
|
||||
interface RenameLibraryActionProps {
|
||||
editorRef: RefObject<EditorAPI>;
|
||||
library: ILibraryItem;
|
||||
range: {
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
onRename?: (pos: { from: number; to: number }) => void;
|
||||
}
|
||||
export const RenameLibraryAction = ({
|
||||
editorRef,
|
||||
library,
|
||||
range,
|
||||
onRename,
|
||||
}: RenameLibraryActionProps) => {
|
||||
const handleRename = () => {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
editorRef.current?.$view.dispatch({
|
||||
changes: {
|
||||
from: range.left,
|
||||
to: range.right,
|
||||
insert: library.name,
|
||||
},
|
||||
});
|
||||
onRename?.({ from: range.left, to: range.right });
|
||||
};
|
||||
return (
|
||||
<Button onClick={handleRename} color="primary" size="small">
|
||||
{I18n.t('edit_block_api_rename')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
PositionMirror,
|
||||
useChangeListener,
|
||||
useEditor,
|
||||
} from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Popover } from '@coze-arch/coze-design';
|
||||
import { type ViewUpdate } from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
import type { ILibraryList } from '../types';
|
||||
import { TemplateParser } from '../../shared/utils/template-parser';
|
||||
import { LibraryList } from './library-list';
|
||||
interface LibrarySearchPopoverProps {
|
||||
librarys: ILibraryList;
|
||||
direction?: React.ComponentProps<typeof Popover>['position'];
|
||||
}
|
||||
const templateParser = new TemplateParser({ mark: 'LibraryBlock' });
|
||||
export const LibrarySearchPopover = ({
|
||||
librarys,
|
||||
direction = 'bottomLeft',
|
||||
}: LibrarySearchPopoverProps) => {
|
||||
const [reposKey, setReposKey] = useState('');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState(-1);
|
||||
const [toPosition, setToPosition] = useState(-1);
|
||||
const emptyLibrary = librarys.map(item => item.items).flat().length === 0;
|
||||
const editor = useEditor<EditorAPI>();
|
||||
|
||||
useChangeListener(e => {
|
||||
const [_fromA, _toA, fromB, toB, inserted] = e.change;
|
||||
if (['{}', '{'].includes(inserted.toString())) {
|
||||
const node = syntaxTree(e.view.state).resolve(
|
||||
inserted.toString() === '{}' ? toB - 1 : toB,
|
||||
);
|
||||
|
||||
if (node.name === 'JinjaText') {
|
||||
setPosition(fromB);
|
||||
setToPosition(toB);
|
||||
setVisible(true);
|
||||
} else {
|
||||
// 一些场景输入 { 不应该弹出提示面板
|
||||
// 如:光标在 {{ }} 内,或在 {% %} 内,或在 {# #} 内
|
||||
setVisible(false);
|
||||
}
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleViewUpdate(update: ViewUpdate) {
|
||||
if (update.transactions.some(tr => tr.isUserEvent('select'))) {
|
||||
setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
editor.$on('viewUpdate', handleViewUpdate);
|
||||
|
||||
return () => {
|
||||
editor.$off('viewUpdate', handleViewUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
rePosKey={reposKey}
|
||||
visible={visible}
|
||||
trigger="custom"
|
||||
position={emptyLibrary ? 'bottomLeft' : direction}
|
||||
onClickOutSide={() => setVisible(false)}
|
||||
autoAdjustOverflow
|
||||
content={
|
||||
!emptyLibrary ? (
|
||||
<div className="flex flex-col p-1 w-[352px] max-h-[330px] overflow-y-auto">
|
||||
<LibraryList
|
||||
librarys={librarys}
|
||||
onInsert={insertLibrary => {
|
||||
const { name = '', id = '', type } = insertLibrary;
|
||||
const uuid = nanoid();
|
||||
const template = templateParser.generateTemplate({
|
||||
content: name,
|
||||
data: {
|
||||
id,
|
||||
uuid,
|
||||
type,
|
||||
...(type === 'plugin' && {
|
||||
apiId: insertLibrary.api_id,
|
||||
}),
|
||||
},
|
||||
});
|
||||
// 去除插入的快捷键, 因为原来自动添加了
|
||||
templateParser.insertTemplateByRange(editor, template, {
|
||||
from: position,
|
||||
to: toPosition,
|
||||
});
|
||||
setVisible(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="coz-fg-primary text-sm font-medium px-3 py-2">
|
||||
{I18n.t('edit_block_api_empty')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<PositionMirror
|
||||
position={position}
|
||||
onChange={() => setReposKey(String(Math.random()))}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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, type ReactNode } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Typography, Highlight } from '@coze-arch/coze-design';
|
||||
|
||||
interface LibraryItemProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
avatar: string;
|
||||
icons?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
searchWords?: string[];
|
||||
}
|
||||
export const LibraryItem: FC<LibraryItemProps> = ({
|
||||
title,
|
||||
description,
|
||||
avatar,
|
||||
icons,
|
||||
actions,
|
||||
className,
|
||||
searchWords,
|
||||
}) => (
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full flex flex-row items-center coz-bg-max rounded-[8px]',
|
||||
' gap-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-row flex-1 min-w-[0px] justify-center items-center ',
|
||||
)}
|
||||
>
|
||||
{avatar ? (
|
||||
<img
|
||||
src={avatar}
|
||||
className={classNames(
|
||||
'w-[24px] h-[24px] rounded-[5px]',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={classNames(
|
||||
'ml-[8px]',
|
||||
'flex flex-col flex-1 min-w-[0px] w-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center overflow-hidden">
|
||||
<Typography.Paragraph
|
||||
ellipsis={{
|
||||
showTooltip: true,
|
||||
}}
|
||||
className={classNames(
|
||||
'text-[14px] leading-[20px]',
|
||||
'coz-fg-primary truncate flex-1 font-medium',
|
||||
)}
|
||||
>
|
||||
<Highlight
|
||||
sourceString={title}
|
||||
searchWords={searchWords}
|
||||
highlightStyle={{
|
||||
color: 'var(--coz-fg-hglt-yellow, #FF7300)',
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</Typography.Paragraph>
|
||||
|
||||
<div className="justify-self-end grid grid-flow-col gap-x-[2px]">
|
||||
{icons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames('grid grid-flow-col gap-x-[2px]')}>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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 classnames from 'classnames';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Typography } from '@coze-arch/coze-design';
|
||||
|
||||
import {
|
||||
type ILibraryList,
|
||||
type ILibraryItem,
|
||||
type LibraryType,
|
||||
} from '../types';
|
||||
import { LibraryItem } from './library-item';
|
||||
import { AddLibraryAction } from './actions/add-library-action';
|
||||
interface LibraryListProps {
|
||||
librarys: ILibraryList;
|
||||
onInsert?: (library: ILibraryItem) => void;
|
||||
libraryItemClassName?: string;
|
||||
searchWords?: string[];
|
||||
}
|
||||
const LibraryTypeTextMap: Record<LibraryType, string> = {
|
||||
plugin: I18n.t('edit_block_api_plugin'),
|
||||
workflow: I18n.t('edit_block_api_workflow'),
|
||||
imageflow: I18n.t('edit_block_api_imageflow'),
|
||||
text: I18n.t('edit_block_api_knowledge_text'),
|
||||
image: I18n.t('edit_block_api_knowledge_image'),
|
||||
table: I18n.t('edit_block_api_knowledge_table'),
|
||||
};
|
||||
export const LibraryList = ({
|
||||
librarys,
|
||||
onInsert,
|
||||
libraryItemClassName,
|
||||
searchWords,
|
||||
}: LibraryListProps) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.values(librarys).map(library => {
|
||||
const { items, type } = library;
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={type} className="flex flex-col">
|
||||
<Typography.Text className="coz-fg-tertiary text-xs mb-1 px-2 pt-2 pb-1">
|
||||
{LibraryTypeTextMap[type]}
|
||||
</Typography.Text>
|
||||
{items.map(item => {
|
||||
const { name, desc, icon_url } = item;
|
||||
return (
|
||||
<LibraryItem
|
||||
searchWords={searchWords}
|
||||
key={name}
|
||||
title={name || ''}
|
||||
description={desc || ''}
|
||||
avatar={icon_url || ''}
|
||||
className={classnames('p-[8px]', libraryItemClassName)}
|
||||
actions={
|
||||
<AddLibraryAction
|
||||
library={{ ...item, type }}
|
||||
onClick={libraryItem => {
|
||||
onInsert?.(libraryItem);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 {
|
||||
PromptReferenceType,
|
||||
type PromptReferenceInfo,
|
||||
} from '@coze-arch/idl/playground_api';
|
||||
|
||||
export type LibraryType =
|
||||
| 'plugin'
|
||||
| 'workflow'
|
||||
| 'imageflow'
|
||||
| 'table'
|
||||
| 'text'
|
||||
| 'image';
|
||||
|
||||
export interface ILibraryItems {
|
||||
type: LibraryType;
|
||||
items: ILibraryItem[];
|
||||
}
|
||||
|
||||
export type ILibraryList = ILibraryItems[];
|
||||
|
||||
export type ILibraryItem = PromptReferenceInfo & {
|
||||
type: LibraryType;
|
||||
id: string;
|
||||
icon_url: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
};
|
||||
|
||||
export const getReferenceType = (type: LibraryType): PromptReferenceType => {
|
||||
switch (type) {
|
||||
case 'plugin':
|
||||
return PromptReferenceType.Plugin;
|
||||
case 'workflow':
|
||||
return PromptReferenceType.Workflow;
|
||||
case 'imageflow':
|
||||
return PromptReferenceType.ImageFlow;
|
||||
case 'text':
|
||||
return PromptReferenceType.Knowledge;
|
||||
case 'image':
|
||||
return PromptReferenceType.Knowledge;
|
||||
case 'table':
|
||||
return PromptReferenceType.Knowledge;
|
||||
default:
|
||||
return PromptReferenceType.Plugin;
|
||||
}
|
||||
};
|
||||
export interface LibraryBlockInfo {
|
||||
[key: string]: string | undefined;
|
||||
icon: string;
|
||||
type: LibraryType;
|
||||
id: string;
|
||||
uuid: string;
|
||||
apiId?: string;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 { merge } from 'lodash-es';
|
||||
|
||||
import {
|
||||
type LibraryType,
|
||||
type ILibraryList,
|
||||
type ILibraryItem,
|
||||
type LibraryBlockInfo,
|
||||
} from '../types';
|
||||
import {
|
||||
pluginIcon,
|
||||
workflowIcon,
|
||||
imageflowIcon,
|
||||
tableIcon,
|
||||
textIcon,
|
||||
imageIcon,
|
||||
} from '../assets';
|
||||
import { type TemplateParser } from '../../shared/utils/template-parser';
|
||||
import { checkLibraryId } from './library-validate';
|
||||
const defaultLibraryBlockInfo: Record<
|
||||
LibraryType,
|
||||
{
|
||||
icon: string;
|
||||
}
|
||||
> = {
|
||||
plugin: {
|
||||
icon: pluginIcon,
|
||||
},
|
||||
workflow: {
|
||||
icon: workflowIcon,
|
||||
},
|
||||
imageflow: {
|
||||
icon: imageflowIcon,
|
||||
},
|
||||
table: {
|
||||
icon: tableIcon,
|
||||
},
|
||||
text: {
|
||||
icon: textIcon,
|
||||
},
|
||||
image: {
|
||||
icon: imageIcon,
|
||||
},
|
||||
};
|
||||
// 根据资源类型获取对应的信息
|
||||
export const getLibraryBlockInfoFromTemplate = (props: {
|
||||
template: string;
|
||||
templateParser: TemplateParser;
|
||||
}): LibraryBlockInfo | null => {
|
||||
const { template, templateParser } = props;
|
||||
const data = templateParser.getData(template);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
const { type, ...rest } = data as LibraryBlockInfo;
|
||||
const libraryBlockInfo = merge({}, defaultLibraryBlockInfo[type], {
|
||||
type,
|
||||
...rest,
|
||||
});
|
||||
return libraryBlockInfo;
|
||||
};
|
||||
|
||||
export const getLibraryInfoByBlockInfo = (
|
||||
librarys: ILibraryList,
|
||||
blockInfo: LibraryBlockInfo,
|
||||
): ILibraryItem | null => {
|
||||
if (!librarys || !blockInfo) {
|
||||
return null;
|
||||
}
|
||||
const libraryTypeList = librarys.find(
|
||||
library => library.type === blockInfo.type,
|
||||
);
|
||||
return (
|
||||
(libraryTypeList?.items as ILibraryItem[])?.find(item =>
|
||||
checkLibraryId(item, blockInfo),
|
||||
) ?? null
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 ILibraryList, type LibraryBlockInfo } from '../types';
|
||||
import { findTargetLibrary, isLibraryNameOutDate } from './library-validate';
|
||||
interface LibraryBlockTooltipProps {
|
||||
librarys: ILibraryList;
|
||||
libraryBlockInfo: LibraryBlockInfo | null;
|
||||
content: string;
|
||||
}
|
||||
export type LibraryStatus = 'disabled' | 'existing' | 'outdated';
|
||||
|
||||
export const getLibraryStatus = ({
|
||||
librarys,
|
||||
libraryBlockInfo,
|
||||
content,
|
||||
}: LibraryBlockTooltipProps): {
|
||||
libraryStatus: LibraryStatus;
|
||||
} => {
|
||||
let libraryStatus: LibraryStatus = 'disabled';
|
||||
|
||||
if (!libraryBlockInfo) {
|
||||
return {
|
||||
libraryStatus: 'disabled',
|
||||
};
|
||||
}
|
||||
const targetLibrary = findTargetLibrary(librarys, libraryBlockInfo);
|
||||
|
||||
if (!targetLibrary) {
|
||||
return {
|
||||
libraryStatus: 'disabled',
|
||||
};
|
||||
}
|
||||
|
||||
const isOutdated = isLibraryNameOutDate(content, targetLibrary);
|
||||
|
||||
if (isOutdated) {
|
||||
libraryStatus = 'outdated';
|
||||
} else {
|
||||
libraryStatus = 'existing';
|
||||
}
|
||||
|
||||
return {
|
||||
libraryStatus,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* 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 RefObject } from 'react';
|
||||
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { type Tooltip } from '@coze-arch/coze-design';
|
||||
|
||||
import { type ILibraryItem, type LibraryBlockInfo } from '../types';
|
||||
import { LibraryItem } from '../library-search-popover/library-item';
|
||||
import { RenameLibraryAction } from '../library-search-popover/actions/rename-library-action';
|
||||
import { AddLibraryAction } from '../library-search-popover/actions/add-library-action';
|
||||
import { requestLibraryInfo } from './library-validate';
|
||||
import type { LibraryStatus } from './get-library-status';
|
||||
interface GetLibraryTooltipProps {
|
||||
editorRef: RefObject<EditorAPI>;
|
||||
libraryStatus: LibraryStatus;
|
||||
libraryItem: ILibraryItem | null;
|
||||
readonly: boolean;
|
||||
blockDataInfo: LibraryBlockInfo | null;
|
||||
spaceId: string;
|
||||
onAddLibrary?: (library: ILibraryItem) => void;
|
||||
range: {
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
projectId?: string;
|
||||
avatarBotId?: string;
|
||||
onRename?: (pos: { from: number; to: number }) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const getLibraryTooltip = async ({
|
||||
editorRef,
|
||||
libraryStatus,
|
||||
libraryItem,
|
||||
blockDataInfo,
|
||||
onAddLibrary,
|
||||
readonly,
|
||||
spaceId,
|
||||
range,
|
||||
projectId,
|
||||
avatarBotId,
|
||||
onRename,
|
||||
disabled,
|
||||
}: GetLibraryTooltipProps): Promise<{
|
||||
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
|
||||
}> => {
|
||||
try {
|
||||
if (disabled) {
|
||||
return getHiddenLibraryTooltip();
|
||||
}
|
||||
|
||||
if (readonly) {
|
||||
return getDisabledLibraryTooltip();
|
||||
}
|
||||
|
||||
const { type, id } = blockDataInfo ?? {};
|
||||
if (!blockDataInfo || !type || !id) {
|
||||
return getDisabledLibraryTooltip();
|
||||
}
|
||||
|
||||
if (libraryStatus === 'disabled' || !libraryItem) {
|
||||
return await getPublicLibraryTooltip({
|
||||
blockDataInfo,
|
||||
spaceId,
|
||||
onAddLibrary,
|
||||
projectId,
|
||||
avatarBotId,
|
||||
});
|
||||
}
|
||||
|
||||
if (libraryStatus === 'outdated') {
|
||||
return getOutdatedLibraryTooltip({
|
||||
editorRef,
|
||||
item: libraryItem,
|
||||
range,
|
||||
onRename,
|
||||
});
|
||||
}
|
||||
|
||||
if (libraryStatus === 'existing') {
|
||||
return getExistingLibraryTooltip(libraryItem);
|
||||
}
|
||||
|
||||
return getDisabledLibraryTooltip();
|
||||
} catch (error) {
|
||||
const errorMsg = (error as { msg: string }).msg;
|
||||
if (errorMsg) {
|
||||
return {
|
||||
tooltipConfig: {
|
||||
content: errorMsg,
|
||||
},
|
||||
};
|
||||
}
|
||||
return getDisabledLibraryTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
const getExistingLibraryTooltip = (
|
||||
libraryItem: ILibraryItem,
|
||||
): {
|
||||
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
|
||||
} => ({
|
||||
tooltipConfig: {
|
||||
content: (
|
||||
<LibraryItem
|
||||
title={libraryItem.name ?? ''}
|
||||
description={libraryItem.desc ?? ''}
|
||||
avatar={libraryItem.icon_url ?? ''}
|
||||
className="!p-0"
|
||||
/>
|
||||
),
|
||||
className: 'library-block-tooltip !w-[310px] !p-2',
|
||||
},
|
||||
});
|
||||
|
||||
const getDisabledLibraryTooltip = (
|
||||
msg?: string,
|
||||
): {
|
||||
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
|
||||
} => ({
|
||||
tooltipConfig: {
|
||||
content: msg ?? I18n.t('edit_block_api_disable_tooltips'),
|
||||
},
|
||||
});
|
||||
|
||||
const getHiddenLibraryTooltip = (): {
|
||||
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
|
||||
} => ({
|
||||
tooltipConfig: {
|
||||
visible: false,
|
||||
trigger: 'custom',
|
||||
},
|
||||
});
|
||||
|
||||
const getOutdatedLibraryTooltip = ({
|
||||
item,
|
||||
range,
|
||||
onRename,
|
||||
editorRef,
|
||||
}: {
|
||||
editorRef: RefObject<EditorAPI>;
|
||||
item: ILibraryItem;
|
||||
range: {
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
onRename?: (pos: { from: number; to: number }) => void;
|
||||
}): {
|
||||
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
|
||||
} => ({
|
||||
tooltipConfig: {
|
||||
content: (
|
||||
<LibraryItem
|
||||
title={item.name ?? ''}
|
||||
description={item.desc ?? ''}
|
||||
avatar={item.icon_url ?? ''}
|
||||
className="!p-0"
|
||||
actions={
|
||||
<RenameLibraryAction
|
||||
library={item}
|
||||
range={range}
|
||||
editorRef={editorRef}
|
||||
onRename={onRename}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
className: 'library-block-tooltip !w-[310px] !p-2',
|
||||
},
|
||||
});
|
||||
|
||||
const getPublicLibraryTooltip = async ({
|
||||
blockDataInfo,
|
||||
spaceId,
|
||||
onAddLibrary,
|
||||
projectId,
|
||||
avatarBotId,
|
||||
}: {
|
||||
blockDataInfo: LibraryBlockInfo | null;
|
||||
spaceId: string;
|
||||
onAddLibrary?: (library: ILibraryItem) => void;
|
||||
projectId?: string;
|
||||
avatarBotId?: string;
|
||||
}): Promise<{
|
||||
tooltipConfig?: React.ComponentProps<typeof Tooltip>;
|
||||
}> => {
|
||||
const { id, type } = blockDataInfo ?? {};
|
||||
if (!id || !type) {
|
||||
return getDisabledLibraryTooltip();
|
||||
}
|
||||
|
||||
try {
|
||||
const libraryInfo = await requestLibraryInfo({
|
||||
id,
|
||||
type,
|
||||
...(type === 'plugin' && {
|
||||
apiId: blockDataInfo?.apiId,
|
||||
}),
|
||||
spaceId,
|
||||
projectId,
|
||||
avatarBotId,
|
||||
});
|
||||
if (!libraryInfo) {
|
||||
return getDisabledLibraryTooltip();
|
||||
}
|
||||
return {
|
||||
tooltipConfig: {
|
||||
content: (
|
||||
<LibraryItem
|
||||
title={libraryInfo.name ?? ''}
|
||||
description={libraryInfo.desc ?? ''}
|
||||
avatar={libraryInfo.icon_url ?? ''}
|
||||
className="!p-0"
|
||||
actions={
|
||||
onAddLibrary ? (
|
||||
<AddLibraryAction
|
||||
library={libraryInfo}
|
||||
onClick={onAddLibrary}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
className: 'library-block-tooltip !w-[310px] !p-2',
|
||||
showArrow: false,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return getDisabledLibraryTooltip((error as { msg: string }).msg);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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 { PlaygroundApi } from '@coze-arch/bot-api';
|
||||
|
||||
import {
|
||||
type ILibraryItem,
|
||||
type ILibraryList,
|
||||
type LibraryBlockInfo,
|
||||
getReferenceType,
|
||||
type LibraryType,
|
||||
} from '../types';
|
||||
|
||||
export const findTargetLibrary = (
|
||||
librarys: ILibraryList,
|
||||
libraryCheck: LibraryBlockInfo,
|
||||
): ILibraryItem | undefined => {
|
||||
const { type: checkType } = libraryCheck;
|
||||
const libraryGroup = librarys.find(
|
||||
({ type, items }) =>
|
||||
type === checkType &&
|
||||
items.some(item => checkLibraryId(item, libraryCheck)),
|
||||
);
|
||||
|
||||
const targetItem = libraryGroup?.items.find(item =>
|
||||
checkLibraryId(item, libraryCheck),
|
||||
);
|
||||
if (!targetItem) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...targetItem,
|
||||
type: checkType,
|
||||
};
|
||||
};
|
||||
|
||||
export const checkLibraryId = (
|
||||
library: ILibraryItem,
|
||||
libraryCheck: LibraryBlockInfo,
|
||||
) => {
|
||||
const { apiId: checkApiId, id: checkId } = libraryCheck;
|
||||
if (checkApiId) {
|
||||
return library.api_id === checkApiId;
|
||||
}
|
||||
return library.id === checkId;
|
||||
};
|
||||
|
||||
export const isLibraryNameOutDate = (
|
||||
content: string,
|
||||
latestLibrary: ILibraryItem,
|
||||
): boolean => content !== latestLibrary.name;
|
||||
|
||||
export const requestLibraryInfo = async (props: {
|
||||
id: string;
|
||||
type: LibraryType;
|
||||
spaceId?: string;
|
||||
apiId?: string;
|
||||
projectId?: string;
|
||||
avatarBotId?: string;
|
||||
}): Promise<ILibraryItem | undefined> => {
|
||||
const { id, type, apiId, spaceId, projectId, avatarBotId } = props;
|
||||
const { data } = await PlaygroundApi.GetPromptReferenceInfo(
|
||||
{
|
||||
reference_id: id,
|
||||
reference_type: getReferenceType(type),
|
||||
api_id: apiId,
|
||||
space_id: spaceId ?? '',
|
||||
project_id: projectId,
|
||||
avatar_bot_id: avatarBotId,
|
||||
},
|
||||
{
|
||||
__disableErrorToast: true,
|
||||
},
|
||||
);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(data as ILibraryItem),
|
||||
type,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* 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, { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
CursorMirror,
|
||||
SelectionSide,
|
||||
useEditor,
|
||||
} from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
Tabs,
|
||||
TabPane,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Popover,
|
||||
} from '@coze-arch/coze-design';
|
||||
|
||||
import { type ContentSearchPopoverProps } from '../types';
|
||||
import { generateUniqueId, useLatest } from '../../expression/shared';
|
||||
import {
|
||||
useCharacterTriggerContext,
|
||||
useInterpolationContent,
|
||||
useSelection,
|
||||
} from '../../expression/popover/hooks';
|
||||
import useVariablesTree from './variable/use-variables-tree';
|
||||
import useLibraryList from './library/use-library-list';
|
||||
|
||||
import styles from './style.module.less';
|
||||
|
||||
enum TabEnum {
|
||||
Variables = '1',
|
||||
Skills = '2',
|
||||
}
|
||||
|
||||
const genLibraryInsertPosition = (from?: number, to?: number) => {
|
||||
const _from = (from ?? 0) - 1 >= 0 ? (from ?? 0) - 1 : 0;
|
||||
|
||||
return {
|
||||
from: _from,
|
||||
to: (to ?? _from) + 1,
|
||||
};
|
||||
};
|
||||
|
||||
export const ContentSearchPopover = ({
|
||||
libraries = [],
|
||||
direction = 'bottomLeft',
|
||||
readonly,
|
||||
onInsert,
|
||||
variableTree = [],
|
||||
}: ContentSearchPopoverProps) => {
|
||||
const [activeTab, setActiveTab] = useState<string>(TabEnum.Variables);
|
||||
const [reposKey, setReposKey] = useState('');
|
||||
const emptyLibrary = libraries.map(item => item.items).flat().length === 0;
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const editorRef = useLatest(editor);
|
||||
const selection = useSelection(editor);
|
||||
|
||||
const interpolationContent = useInterpolationContent(
|
||||
editor,
|
||||
selection?.anchor,
|
||||
);
|
||||
|
||||
const triggerContext = useCharacterTriggerContext();
|
||||
|
||||
const completionContext = interpolationContent ?? triggerContext;
|
||||
|
||||
const visible = Boolean(completionContext);
|
||||
|
||||
const insertPosition = genLibraryInsertPosition(
|
||||
completionContext?.from,
|
||||
completionContext?.to,
|
||||
);
|
||||
|
||||
const { libraryListPanel } = useLibraryList({
|
||||
libraries,
|
||||
editor,
|
||||
onInsert,
|
||||
insertPosition,
|
||||
enableKeyboard: visible && activeTab === TabEnum.Skills,
|
||||
filterText: completionContext?.text,
|
||||
});
|
||||
|
||||
const { variablesTreePanel } = useVariablesTree({
|
||||
variableTree,
|
||||
enableKeyboard: visible && activeTab === TabEnum.Variables,
|
||||
completionContext,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
editorRef.current?.disableKeybindings(['ArrowUp', 'ArrowDown', 'Enter']);
|
||||
} else {
|
||||
editorRef.current?.disableKeybindings([]);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
style={{
|
||||
width: '360px',
|
||||
padding: '4px',
|
||||
minHeight: '240px',
|
||||
maxHeight: '400px',
|
||||
}}
|
||||
rePosKey={reposKey}
|
||||
visible={!readonly && visible}
|
||||
trigger="custom"
|
||||
position={emptyLibrary ? 'bottomLeft' : direction}
|
||||
autoAdjustOverflow
|
||||
content={
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={key => {
|
||||
setActiveTab(key);
|
||||
// 保证切换tab后光标仍然定位在编辑器上
|
||||
setTimeout(() => {
|
||||
editorRef.current?.$view.focus();
|
||||
}, 0);
|
||||
}}
|
||||
renderTabBar={tabBarProps => (
|
||||
<div className={styles['tab-bar-wrapper']}>
|
||||
<RadioGroup
|
||||
value={tabBarProps.activeKey}
|
||||
type="button"
|
||||
buttonSize="middle"
|
||||
>
|
||||
{tabBarProps.list?.map(tab => (
|
||||
<span
|
||||
onClick={event =>
|
||||
tabBarProps.onTabClick?.(tab.itemKey, event)
|
||||
}
|
||||
>
|
||||
<Radio value={tab.itemKey}>{tab.tab}</Radio>
|
||||
</span>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<TabPane
|
||||
tab={I18n.t('workflow_prompt_editor_variable')}
|
||||
itemKey={TabEnum.Variables}
|
||||
>
|
||||
{variablesTreePanel}
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={I18n.t('workflow_prompt_editor_skill')}
|
||||
itemKey={TabEnum.Skills}
|
||||
>
|
||||
{libraryListPanel}
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
}
|
||||
>
|
||||
<CursorMirror
|
||||
side={SelectionSide.Anchor}
|
||||
onChange={() => setReposKey(generateUniqueId())}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* 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, { forwardRef, useMemo, useRef } from 'react';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
|
||||
import styles from '../style.module.less';
|
||||
import { TemplateParser } from '../../../shared/utils/template-parser';
|
||||
import { LibraryList as DefaultLibraryList } from '../../../library-insert/library-search-popover/library-list';
|
||||
import type { ILibraryItem, ILibraryList } from '../../../library-insert';
|
||||
import { useKeyboard } from '../../../expression/popover/hooks';
|
||||
import useOptionsOperations from './use-options-operations';
|
||||
|
||||
interface LibraryListProps {
|
||||
libraries: ILibraryList;
|
||||
onInsert?: (library: ILibraryItem) => void;
|
||||
searchWords?: string[];
|
||||
}
|
||||
|
||||
const LibraryList = forwardRef<HTMLDivElement, LibraryListProps>(
|
||||
({ libraries, onInsert, searchWords }, ref) => {
|
||||
const emptyLibrary = libraries.map(item => item.items).flat().length === 0;
|
||||
|
||||
return !emptyLibrary ? (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex flex-col p-1 w-[352px] max-h-[330px] overflow-y-auto"
|
||||
>
|
||||
<DefaultLibraryList
|
||||
librarys={libraries}
|
||||
onInsert={onInsert}
|
||||
libraryItemClassName={styles['library-item']}
|
||||
searchWords={searchWords}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={ref} className="coz-fg-primary text-sm font-medium px-3 py-2">
|
||||
{I18n.t('edit_block_api_empty')}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface Props {
|
||||
editor: EditorAPI;
|
||||
insertPosition: {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
onInsert?: (insertPosition: { from: number; to: number }) => void;
|
||||
enableKeyboard: boolean;
|
||||
filterText?: string;
|
||||
libraries: ILibraryList;
|
||||
}
|
||||
|
||||
const templateParser = new TemplateParser({ mark: 'LibraryBlock' });
|
||||
|
||||
export default function useLibraryList({
|
||||
libraries,
|
||||
editor,
|
||||
insertPosition,
|
||||
onInsert,
|
||||
enableKeyboard,
|
||||
filterText,
|
||||
}: Props) {
|
||||
const libraryListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filterLibraries = useMemo(() => {
|
||||
if (!filterText) {
|
||||
return libraries;
|
||||
}
|
||||
|
||||
return libraries
|
||||
.map(item => ({
|
||||
...item,
|
||||
items: item.items.filter(i =>
|
||||
i.name.toLowerCase().includes(filterText.toLowerCase()),
|
||||
),
|
||||
}))
|
||||
.filter(item => item.items?.length);
|
||||
}, [libraries, filterText]);
|
||||
|
||||
const handleLibraryInsert = (library: ILibraryItem) => {
|
||||
const { name = '', id = '', type } = library;
|
||||
const uuid = nanoid();
|
||||
const template = templateParser.generateTemplate({
|
||||
content: name,
|
||||
data: {
|
||||
id,
|
||||
uuid,
|
||||
type,
|
||||
...(type === 'plugin' && {
|
||||
apiId: library.api_id,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
templateParser.insertTemplateByRange(editor, template, insertPosition);
|
||||
|
||||
onInsert?.({
|
||||
from: insertPosition.from,
|
||||
to: insertPosition.from + template.length,
|
||||
});
|
||||
};
|
||||
|
||||
const { prev, next, apply } = useOptionsOperations({
|
||||
rootRef: libraryListRef,
|
||||
libraries: filterLibraries,
|
||||
applyCallBack: handleLibraryInsert,
|
||||
});
|
||||
|
||||
useKeyboard(enableKeyboard, {
|
||||
ArrowUp: prev,
|
||||
ArrowDown: next,
|
||||
Enter: apply,
|
||||
});
|
||||
|
||||
const libraryListPanel = (
|
||||
<LibraryList
|
||||
ref={libraryListRef}
|
||||
libraries={filterLibraries}
|
||||
onInsert={handleLibraryInsert}
|
||||
searchWords={filterText ? [filterText] : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
libraryListPanel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type MutableRefObject } from 'react';
|
||||
|
||||
import styles from '../style.module.less';
|
||||
import type { ILibraryItem, ILibraryList } from '../../../library-insert';
|
||||
|
||||
const SELECTED_OPTION_CLASSNAME =
|
||||
styles['library-suggestion-keyboard-selected'];
|
||||
|
||||
interface OptionsInfo {
|
||||
elements: Element[];
|
||||
selectedIndex: number;
|
||||
selectedElement?: Element;
|
||||
}
|
||||
|
||||
const getOptionInfoFromDOM = (
|
||||
root: Element | null,
|
||||
): OptionsInfo | undefined => {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const foundNodes = root.querySelectorAll(`.${styles['library-item']}`);
|
||||
|
||||
if (foundNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionElements = [...foundNodes];
|
||||
|
||||
// 找到当前高亮的选项
|
||||
const selectedIndex = optionElements.findIndex(element =>
|
||||
element.classList.contains(SELECTED_OPTION_CLASSNAME),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: optionElements,
|
||||
selectedIndex,
|
||||
selectedElement: optionElements[selectedIndex],
|
||||
};
|
||||
};
|
||||
|
||||
const selectNodeByIndex = (elements: Element[], index: number) => {
|
||||
const newSelectedElement = elements[index];
|
||||
|
||||
if (!newSelectedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove old selected class
|
||||
elements.forEach(element => {
|
||||
if (element.classList.contains(SELECTED_OPTION_CLASSNAME)) {
|
||||
element.classList.remove(SELECTED_OPTION_CLASSNAME);
|
||||
}
|
||||
});
|
||||
|
||||
newSelectedElement.classList.add(SELECTED_OPTION_CLASSNAME);
|
||||
|
||||
newSelectedElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
};
|
||||
|
||||
interface Props {
|
||||
rootRef: MutableRefObject<HTMLDivElement | null>;
|
||||
libraries: ILibraryList;
|
||||
applyCallBack?: (item: ILibraryItem) => void;
|
||||
}
|
||||
|
||||
export default function useOptionsOperations({
|
||||
rootRef,
|
||||
libraries,
|
||||
applyCallBack,
|
||||
}: Props) {
|
||||
const prev = () => {
|
||||
const optionsInfo = getOptionInfoFromDOM(rootRef.current);
|
||||
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { elements, selectedIndex } = optionsInfo;
|
||||
|
||||
if (elements.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex =
|
||||
selectedIndex - 1 < 0 ? elements.length - 1 : selectedIndex - 1;
|
||||
selectNodeByIndex(elements, newIndex);
|
||||
};
|
||||
const next = () => {
|
||||
const optionsInfo = getOptionInfoFromDOM(rootRef.current);
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { elements, selectedIndex } = optionsInfo;
|
||||
|
||||
const newIndex =
|
||||
selectedIndex + 1 >= elements.length ? 0 : selectedIndex + 1;
|
||||
selectNodeByIndex(elements, newIndex);
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
const optionsInfo = getOptionInfoFromDOM(rootRef.current);
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedLibrary = libraries.reduce<ILibraryItem[]>(
|
||||
(previousValue, currentValue) => [
|
||||
...previousValue,
|
||||
...(currentValue?.items || []),
|
||||
],
|
||||
[],
|
||||
)[optionsInfo.selectedIndex];
|
||||
|
||||
if (!selectedLibrary) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyCallBack?.(selectedLibrary);
|
||||
};
|
||||
|
||||
return {
|
||||
prev,
|
||||
next,
|
||||
apply,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
.library-variable-insert-tree {
|
||||
:global {
|
||||
.semi-tree-option-empty {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.semi-tree-option-highlight {
|
||||
color: var(--coz-fg-hglt-yellow, #FF7300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-bar-wrapper {
|
||||
width: 100%;
|
||||
padding: 8px 8px 0;
|
||||
|
||||
:global {
|
||||
.semi-radio-buttonRadioGroup {
|
||||
padding: 4px 5px;
|
||||
}
|
||||
|
||||
.semi-radio-addon-buttonRadio {
|
||||
width: 158px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.library-item {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.library-suggestion-keyboard-selected {
|
||||
background: var(--semi-color-fill-0);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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/naming-convention */
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
|
||||
import { useEditor } from '@coze-editor/editor/react';
|
||||
import type { EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import type { TreeNodeData } from '@coze-arch/bot-semi/Tree';
|
||||
import { Tree } from '@coze-arch/bot-semi';
|
||||
|
||||
import styles from '../style.module.less';
|
||||
import { useDeepEqualMemo } from '../../../expression/shared';
|
||||
import {
|
||||
applyNode,
|
||||
getOptionInfoFromDOM,
|
||||
selectNodeByIndex,
|
||||
} from '../../../expression/popover/shared';
|
||||
import type { CompletionContext } from '../../../expression/popover/hooks/types';
|
||||
import {
|
||||
useDrillVariableTree,
|
||||
useFilteredVariableTree,
|
||||
useKeyboard,
|
||||
useOptionsOperations,
|
||||
useSelectedValue,
|
||||
useTreeRefresh,
|
||||
useTreeSearch,
|
||||
} from '../../../expression/popover/hooks';
|
||||
import type { ExpressionEditorTreeNode } from '../../../expression/core';
|
||||
|
||||
interface Props {
|
||||
variableTree: ExpressionEditorTreeNode[];
|
||||
enableKeyboard: boolean;
|
||||
completionContext?: CompletionContext;
|
||||
}
|
||||
|
||||
export default function useVariablesTree({
|
||||
variableTree: variableTreeFromProps,
|
||||
enableKeyboard,
|
||||
completionContext,
|
||||
}: Props) {
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const variableTree = useDeepEqualMemo(variableTreeFromProps);
|
||||
const treeRef = useRef<Tree | null>(null);
|
||||
const treeContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const drilledVariableTree = useDrillVariableTree(
|
||||
editor,
|
||||
variableTree,
|
||||
completionContext,
|
||||
);
|
||||
|
||||
const selected = useSelectedValue(completionContext?.text, variableTree);
|
||||
|
||||
// 基于用户选中项,替换所在 {{}} 中的内容
|
||||
const handleSelect = useCallback(
|
||||
(_: string, __: boolean, node: TreeNodeData) => {
|
||||
if (!editor || !completionContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyNode(editor, node as ExpressionEditorTreeNode, completionContext);
|
||||
|
||||
editor.$view.focus();
|
||||
},
|
||||
[editor, completionContext],
|
||||
);
|
||||
|
||||
const filteredVariableTree = useFilteredVariableTree(
|
||||
completionContext,
|
||||
drilledVariableTree,
|
||||
);
|
||||
|
||||
const treeRefreshKey = useTreeRefresh(filteredVariableTree);
|
||||
useTreeSearch(treeRefreshKey, treeRef, completionContext, () => {
|
||||
const optionsInfo = getOptionInfoFromDOM(treeContainerRef.current);
|
||||
if (!optionsInfo) {
|
||||
return;
|
||||
}
|
||||
const { elements } = optionsInfo;
|
||||
selectNodeByIndex(elements, 0);
|
||||
});
|
||||
|
||||
const { prev, next, apply } = useOptionsOperations(
|
||||
editor,
|
||||
completionContext,
|
||||
treeContainerRef,
|
||||
treeRef,
|
||||
);
|
||||
|
||||
// 上下键切换推荐项,回车填入
|
||||
useKeyboard(enableKeyboard, {
|
||||
ArrowUp: prev,
|
||||
ArrowDown: next,
|
||||
Enter: () => {
|
||||
apply();
|
||||
editor.$view.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const variablesTreePanel = (
|
||||
<div
|
||||
className={styles['library-variable-insert-tree']}
|
||||
ref={treeContainerRef}
|
||||
>
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
showFilteredOnly
|
||||
filterTreeNode
|
||||
onChangeWithObject
|
||||
treeData={drilledVariableTree}
|
||||
searchRender={false}
|
||||
value={selected}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return {
|
||||
variablesTreePanel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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 { ContentSearchPopover } from './content-search-popover';
|
||||
|
||||
export type { ContentSearchPopoverProps } from './types';
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 ComponentProps } from 'react';
|
||||
|
||||
import { type Popover } from '@coze-arch/coze-design';
|
||||
|
||||
import type { ILibraryList } from '../library-insert';
|
||||
import type { ExpressionEditorTreeNode } from '../expression/core';
|
||||
|
||||
export interface ContentSearchPopoverProps {
|
||||
libraries: ILibraryList;
|
||||
direction?: ComponentProps<typeof Popover>['position'];
|
||||
readonly?: boolean;
|
||||
onInsert?: (insertPosition: { from: number; to: number }) => void;
|
||||
variableTree?: ExpressionEditorTreeNode[];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useEditor } from '@coze-editor/editor/react';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { type ViewUpdate } from '@codemirror/view';
|
||||
export const useReadonly = () => {
|
||||
const editor = useEditor<EditorAPI>();
|
||||
const [isReadOnly, setIsReadOnly] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
setIsReadOnly(editor.$view.state.readOnly);
|
||||
const handleViewUpdate = (update: ViewUpdate) => {
|
||||
if (update.startState.readOnly !== update.state.readOnly) {
|
||||
setIsReadOnly(update.state.readOnly);
|
||||
}
|
||||
};
|
||||
editor.$on('viewUpdate', handleViewUpdate);
|
||||
return () => {
|
||||
editor.$off('viewUpdate', handleViewUpdate);
|
||||
};
|
||||
}, [editor]);
|
||||
return isReadOnly;
|
||||
};
|
||||
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
* 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 SyntaxNode } from '@lezer/common';
|
||||
import { type EditorAPI } from '@coze-editor/editor/preset-prompt';
|
||||
import { type SelectionEnlargerSpec } from '@coze-editor/editor';
|
||||
import { StateField, type EditorState } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
export interface MarkRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
export interface MarkRangeInfo {
|
||||
from: number;
|
||||
to: number;
|
||||
open: MarkRange;
|
||||
close: MarkRange;
|
||||
}
|
||||
|
||||
// 解析模板字符串: {#slot name="slot_name" #}xxx{#/slot#}
|
||||
export class TemplateParser {
|
||||
public mark!: 'LibraryBlock' | 'InputSlot';
|
||||
private openReg!: RegExp;
|
||||
private closeReg!: RegExp;
|
||||
public markInfoField!: StateField<{
|
||||
specs: SelectionEnlargerSpec[];
|
||||
contents: MarkRange[];
|
||||
marks: MarkRangeInfo[];
|
||||
}>;
|
||||
static instances = new Map<string, TemplateParser>();
|
||||
constructor(props: { mark: 'LibraryBlock' | 'InputSlot' }) {
|
||||
if (TemplateParser.instances.has(props.mark)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return TemplateParser.instances.get(props.mark)!;
|
||||
}
|
||||
const { mark } = props;
|
||||
this.mark = mark;
|
||||
this.openReg = new RegExp(`^\\{#\\s*${mark}`);
|
||||
this.closeReg = new RegExp(`^\\{#\\s*\/${mark}`);
|
||||
this.markInfoField = this.getMarkInfoField();
|
||||
TemplateParser.instances.set(mark, this);
|
||||
}
|
||||
|
||||
getMarkInfoField() {
|
||||
return StateField.define({
|
||||
create: state => this.getMarkSpecs(state),
|
||||
update: (value, tr) => {
|
||||
if (tr.docChanged) {
|
||||
return this.getMarkSpecs(tr.state);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
isOpenNode(node: SyntaxNode, state: EditorState) {
|
||||
if (!node || node.name !== 'JinjaComment') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = state.sliceDoc(node.from, node.to);
|
||||
return this.openReg.test(text);
|
||||
}
|
||||
|
||||
isCloseNode(node: SyntaxNode, state: EditorState) {
|
||||
if (!node || node.name !== 'JinjaComment') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = state.sliceDoc(node.from, node.to);
|
||||
return this.closeReg.test(text);
|
||||
}
|
||||
|
||||
getCursorInMarkNodeRange(state: EditorState): MarkRangeInfo | null {
|
||||
const cursor = state.selection.main.head;
|
||||
return this.getPostionInMarkNodeRange(cursor, state);
|
||||
}
|
||||
|
||||
getSelectionInMarkNodeRange(
|
||||
range: { from: number; to: number },
|
||||
state: EditorState,
|
||||
): MarkRangeInfo | null {
|
||||
return (
|
||||
this.getPostionInMarkNodeRange(range.from, state) &&
|
||||
this.getPostionInMarkNodeRange(range.to, state)
|
||||
);
|
||||
}
|
||||
|
||||
getPostionInMarkNodeRange(postion: number, state: EditorState) {
|
||||
const markRangeInfo = state
|
||||
.field(this.markInfoField)
|
||||
.marks.find(
|
||||
rangeInfo => rangeInfo.from < postion && postion < rangeInfo.to,
|
||||
);
|
||||
if (markRangeInfo) {
|
||||
return markRangeInfo;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
findCloseNode(node: SyntaxNode, state: EditorState) {
|
||||
let next = node.nextSibling;
|
||||
let close = null;
|
||||
while (next) {
|
||||
if (this.isCloseNode(next, state)) {
|
||||
close = next;
|
||||
break;
|
||||
}
|
||||
next = next.nextSibling;
|
||||
}
|
||||
|
||||
return close;
|
||||
}
|
||||
|
||||
// 解析模板字符串: {#slot id="slot_id" value="slot_value"#},获取所有属性
|
||||
getData(templateString: string): { [key: string]: string } | null {
|
||||
// 根据传入的类型构造正则表达式,例如 slot 或 block
|
||||
const regex = new RegExp(`\\{#${this.mark}\\s+([^#]+)#\\}`, 'g');
|
||||
const match = regex.exec(templateString);
|
||||
if (match !== null) {
|
||||
const attributes = match[1].trim(); // 匹配到的属性部分
|
||||
const attrRegex = /(\w+)\s*=\s*"([^"]*)"/g;
|
||||
const obj: { [key: string]: string } = {}; // 初始对象
|
||||
let attrMatch: RegExpExecArray | null;
|
||||
while (true) {
|
||||
attrMatch = attrRegex.exec(attributes);
|
||||
if (attrMatch === null) {
|
||||
break;
|
||||
}
|
||||
obj[attrMatch[1]] = attrMatch[2]; // 将匹配的键值对添加到对象中
|
||||
}
|
||||
|
||||
return obj; // 返回解析结果
|
||||
}
|
||||
|
||||
return null; // 没有匹配时返回 null
|
||||
}
|
||||
|
||||
getCursorTemplateData(state: EditorState) {
|
||||
const markRangeInfo = this.getCursorInMarkNodeRange(state);
|
||||
if (!markRangeInfo) {
|
||||
return;
|
||||
}
|
||||
const { from, to } = markRangeInfo.open;
|
||||
const text = state.sliceDoc(from, to);
|
||||
return this.getData(text);
|
||||
}
|
||||
|
||||
getAllMarksByState(state: EditorState): MarkRangeInfo[] {
|
||||
const marks: MarkRangeInfo[] = [];
|
||||
const tree = syntaxTree(state);
|
||||
const cursor = tree.cursor();
|
||||
do {
|
||||
if (this.isOpenNode(cursor.node, state)) {
|
||||
const open = cursor.node;
|
||||
const close = this.findCloseNode(cursor.node, state);
|
||||
if (close) {
|
||||
marks.push({
|
||||
from: open.from,
|
||||
to: close.to,
|
||||
open: { from: open.from, to: open.to },
|
||||
close: { from: close.from, to: close.to },
|
||||
});
|
||||
}
|
||||
}
|
||||
} while (cursor.next());
|
||||
return marks;
|
||||
}
|
||||
|
||||
getMarkSpecs(state: EditorState): {
|
||||
specs: SelectionEnlargerSpec[];
|
||||
contents: MarkRange[];
|
||||
marks: MarkRangeInfo[];
|
||||
} {
|
||||
const marks = this.getAllMarksByState(state);
|
||||
const specs: SelectionEnlargerSpec[] = [];
|
||||
const contents: MarkRange[] = [];
|
||||
marks.forEach((markRangeInfo: MarkRangeInfo) => {
|
||||
specs.push({
|
||||
source: {
|
||||
from: markRangeInfo.open.from,
|
||||
to: markRangeInfo.open.to,
|
||||
},
|
||||
target: {
|
||||
from: markRangeInfo.from,
|
||||
to: markRangeInfo.to,
|
||||
},
|
||||
});
|
||||
specs.push({
|
||||
source: {
|
||||
from: markRangeInfo.close.from,
|
||||
to: markRangeInfo.close.to,
|
||||
},
|
||||
target: {
|
||||
from: markRangeInfo.from,
|
||||
to: markRangeInfo.to,
|
||||
},
|
||||
});
|
||||
contents.push({
|
||||
from: markRangeInfo.open.to,
|
||||
to: markRangeInfo.close.from,
|
||||
});
|
||||
});
|
||||
return { specs, contents, marks };
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改当前光标所在位置的模板数据: {#slot placeholder="default_placeholder"#} 修改为 {#slot placeholder="new_placeholder"#}
|
||||
* 新增: {#slot placeholder="default_placeholder"#} 新增 {#slot value="new_value" placeholder="new_placeholder"#}
|
||||
*/
|
||||
updateCursorTemplateData(editor: EditorAPI, data: { [key: string]: string }) {
|
||||
const { state } = editor.$view;
|
||||
const markRangeInfo = this.getCursorInMarkNodeRange(state);
|
||||
if (!markRangeInfo) {
|
||||
return;
|
||||
}
|
||||
const { from, to } = markRangeInfo.open;
|
||||
const text = state.sliceDoc(from, to);
|
||||
const preData = this.getData(text);
|
||||
const newData = { ...preData, ...data };
|
||||
if (!preData) {
|
||||
this.addCursorTemplateData(editor, newData);
|
||||
return;
|
||||
}
|
||||
const newText = this.generateOpenTemplateByData(newData);
|
||||
editor.$view.dispatch({
|
||||
changes: {
|
||||
from,
|
||||
to,
|
||||
insert: newText,
|
||||
},
|
||||
});
|
||||
}
|
||||
addCursorTemplateData(editor: EditorAPI, data: { [key: string]: string }) {
|
||||
const { state } = editor.$view;
|
||||
const markRangeInfo = this.getCursorInMarkNodeRange(state);
|
||||
if (!markRangeInfo) {
|
||||
return;
|
||||
}
|
||||
const { from, to } = markRangeInfo.open;
|
||||
const text = state.sliceDoc(from, to);
|
||||
const preData = this.getData(text);
|
||||
const newData = { ...preData, ...data };
|
||||
const newText = this.generateOpenTemplateByData(newData);
|
||||
editor.replaceTextByRange({
|
||||
from,
|
||||
to,
|
||||
text: newText,
|
||||
});
|
||||
}
|
||||
generateOpenTemplateByData(data: { [key: string]: string }) {
|
||||
const keys = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const text = keys
|
||||
.map((key, index) => `${key}="${values[index]}"`)
|
||||
.join(' ');
|
||||
return `{#${this.mark} ${text}#}`;
|
||||
}
|
||||
generateTemplate({
|
||||
content,
|
||||
data,
|
||||
}: {
|
||||
content: string;
|
||||
data: { [key: string]: string };
|
||||
}) {
|
||||
const openTemplate = this.generateOpenTemplateByData(data);
|
||||
const closeTemplate = `{#/${this.mark}#}`;
|
||||
return `${openTemplate}${content}${closeTemplate}`;
|
||||
}
|
||||
generateTemplateJson({
|
||||
content,
|
||||
data,
|
||||
}: {
|
||||
content: string;
|
||||
data: { [key: string]: string };
|
||||
}) {
|
||||
const openTemplate = this.generateOpenTemplateByData(data);
|
||||
const closeTemplate = `{#/${this.mark}#}`;
|
||||
const textContent = this.extractTemplateContent(content);
|
||||
return {
|
||||
open: openTemplate,
|
||||
close: closeTemplate,
|
||||
textContent,
|
||||
template: `${openTemplate}${content}${closeTemplate}`,
|
||||
};
|
||||
}
|
||||
updateCursorTemplateContent(editor: EditorAPI, content: string) {
|
||||
const { state } = editor.$view;
|
||||
const markRangeInfo = this.getCursorInMarkNodeRange(state);
|
||||
if (!markRangeInfo) {
|
||||
return;
|
||||
}
|
||||
editor.replaceTextByRange({
|
||||
from: markRangeInfo.open.to,
|
||||
to: markRangeInfo.close.from,
|
||||
text: content,
|
||||
});
|
||||
}
|
||||
getCursorTemplateContent(editor: EditorAPI) {
|
||||
const { state } = editor.$view;
|
||||
const markRangeInfo = this.getCursorInMarkNodeRange(state);
|
||||
if (!markRangeInfo) {
|
||||
return;
|
||||
}
|
||||
return state.sliceDoc(markRangeInfo.open.to, markRangeInfo.close.from);
|
||||
}
|
||||
|
||||
insertTemplateByCursor(editor: EditorAPI, template: string) {
|
||||
const { state } = editor.$view;
|
||||
const cursorPosition = state.selection.main.head;
|
||||
if (!cursorPosition) {
|
||||
return;
|
||||
}
|
||||
editor.$view.dispatch({
|
||||
changes: {
|
||||
from: cursorPosition,
|
||||
to: cursorPosition,
|
||||
insert: template,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
insertTemplateByRange(
|
||||
editor: EditorAPI,
|
||||
template: string,
|
||||
range: { from: number; to: number },
|
||||
) {
|
||||
const { from, to } = range;
|
||||
editor.$view.dispatch({
|
||||
changes: {
|
||||
from,
|
||||
to,
|
||||
insert: template,
|
||||
},
|
||||
});
|
||||
}
|
||||
// 提取模板中内容{#InputSlot placeholder="placeholder"#}123{#/InputSlot#}xxx,嵌套模板下内部所有的content
|
||||
extractTemplateContent(template: string) {
|
||||
// 使用正则表达式匹配 {#InputSlot ... #} 的部分
|
||||
const regex = new RegExp(
|
||||
`\\{#${this.mark}\\s+[^#]+#\\}|\\{#\\/${this.mark}#\\}`,
|
||||
'g',
|
||||
);
|
||||
// 使用 replace 方法替换掉匹配的部分
|
||||
const result = template.replace(regex, '');
|
||||
console.log('extractTemplateContent', result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Markdown } from './markdown';
|
||||
import { Jinja } from './jinja';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const SyntaxHighlight = {
|
||||
Markdown,
|
||||
Jinja,
|
||||
};
|
||||
|
||||
export { SyntaxHighlight };
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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 { useLayoutEffect } from 'react';
|
||||
|
||||
import { useInjector } from '@coze-editor/editor/react';
|
||||
import { astDecorator } from '@coze-editor/editor';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
const prec = 'lowest';
|
||||
|
||||
function Jinja() {
|
||||
const injector = useInjector();
|
||||
|
||||
useLayoutEffect(
|
||||
() =>
|
||||
injector.inject([
|
||||
astDecorator.whole.of(cursor => {
|
||||
if (
|
||||
cursor.name === 'JinjaStatement' ||
|
||||
cursor.name === 'JinjaRawOpenStatement' ||
|
||||
cursor.name === 'JinjaRawCloseStatement'
|
||||
) {
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'jinja-statement',
|
||||
prec,
|
||||
};
|
||||
}
|
||||
|
||||
if (cursor.name === 'JinjaStringLiteral') {
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'jinja-string',
|
||||
prec,
|
||||
};
|
||||
}
|
||||
|
||||
if (cursor.name === 'JinjaComment') {
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'jinja-comment',
|
||||
prec,
|
||||
};
|
||||
}
|
||||
|
||||
if (cursor.name === 'JinjaExpression') {
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'jinja-expression',
|
||||
prec,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
cursor.name === 'JinjaFilterName' ||
|
||||
cursor.name === 'JinjaStatementStart' ||
|
||||
cursor.name === 'JinjaStatementEnd' ||
|
||||
cursor.name === 'JinjaKeyword' ||
|
||||
cursor.name === 'JinjaFilterName'
|
||||
) {
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'jinja-statement-keyword',
|
||||
prec,
|
||||
};
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'.jinja-statement': {
|
||||
color: '#060709CC',
|
||||
},
|
||||
'.jinja-statement-keyword': {
|
||||
color: '#D1009D',
|
||||
},
|
||||
'.jinja-string': {
|
||||
color: '#060709CC',
|
||||
},
|
||||
'.jinja-comment': {
|
||||
color: '#0607094D',
|
||||
},
|
||||
'.jinja-expression': {
|
||||
color: '#4E40E5',
|
||||
},
|
||||
}),
|
||||
]),
|
||||
[injector],
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export { Jinja };
|
||||
@@ -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 { useLayoutEffect } from 'react';
|
||||
|
||||
import { useInjector } from '@coze-editor/editor/react';
|
||||
import { astDecorator } from '@coze-editor/editor';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
const prec = 'lowest';
|
||||
|
||||
function Markdown() {
|
||||
const injector = useInjector();
|
||||
|
||||
useLayoutEffect(
|
||||
() =>
|
||||
injector.inject([
|
||||
astDecorator.whole.of(cursor => {
|
||||
// # heading
|
||||
if (cursor.name.startsWith('ATXHeading')) {
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'markdown-heading',
|
||||
prec,
|
||||
};
|
||||
}
|
||||
|
||||
// *italic*
|
||||
if (cursor.name === 'Emphasis') {
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'markdown-emphasis',
|
||||
prec,
|
||||
};
|
||||
}
|
||||
|
||||
// **bold**
|
||||
if (cursor.name === 'StrongEmphasis') {
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'markdown-strong-emphasis',
|
||||
prec,
|
||||
};
|
||||
}
|
||||
|
||||
// -
|
||||
// 1.
|
||||
// >
|
||||
if (cursor.name === 'ListMark' || cursor.name === 'QuoteMark') {
|
||||
return {
|
||||
type: 'className',
|
||||
className: 'markdown-mark',
|
||||
prec,
|
||||
};
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'.markdown-heading': {
|
||||
color: '#00818C',
|
||||
fontWeight: '500',
|
||||
},
|
||||
'.markdown-emphasis': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.markdown-strong-emphasis': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'.markdown-mark': {
|
||||
color: '#4E40E5',
|
||||
},
|
||||
}),
|
||||
]),
|
||||
[injector],
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export { Markdown };
|
||||
17
frontend/packages/common/editor-plugins/src/theme/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ThemeExtension } from './theme-extension';
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { useInjector } from '@coze-editor/editor/react';
|
||||
|
||||
import { type Extension } from '../types';
|
||||
|
||||
export const ThemeExtension: React.FC<{
|
||||
themes: Extension[];
|
||||
}> = ({ themes }) => {
|
||||
const injector = useInjector();
|
||||
useLayoutEffect(() => injector.inject(themes), [injector, themes]);
|
||||
return null;
|
||||
};
|
||||
28
frontend/packages/common/editor-plugins/src/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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 type Extension =
|
||||
| {
|
||||
extension: Extension;
|
||||
}
|
||||
| readonly Extension[];
|
||||
|
||||
export interface SelectionInfo {
|
||||
from: number;
|
||||
to: number;
|
||||
anchor: number;
|
||||
head: number;
|
||||
}
|
||||
17
frontend/packages/common/editor-plugins/src/typings.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/// <reference types='@coze-arch/bot-typings' />
|
||||
21
frontend/packages/common/editor-plugins/tailwind.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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 { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./src/**/*.{ts,tsx}', '../../packages/**/src/**/*.{ts,tsx}'],
|
||||
} satisfies Config;
|
||||
47
frontend/packages/common/editor-plugins/tsconfig.build.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@coze-arch/ts-config/tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"types": [],
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../arch/bot-api/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../arch/bot-flags/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../arch/bot-typings/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../arch/i18n/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../arch/idl/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../components/bot-semi/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/eslint-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/stylelint-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/ts-config/tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../config/vitest-config/tsconfig.build.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
frontend/packages/common/editor-plugins/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"exclude": ["**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.build.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.misc.json"
|
||||
}
|
||||
]
|
||||
}
|
||||