chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:55 +08:00
commit 7baeb17845
15305 changed files with 2077301 additions and 0 deletions
@@ -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;
@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});
@@ -0,0 +1,16 @@
# @coze-data/knowledge-common-components
> 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"]
}
]
}
@@ -0,0 +1,9 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {
'@typescript-eslint/naming-convention': 'off',
},
});
@@ -0,0 +1,91 @@
{
"name": "@coze-data/knowledge-common-components",
"version": "0.0.1",
"description": "@coze-data/knowledge-common-components",
"license": "Apache-2.0",
"author": "haozhenfei@bytedance.com",
"maintainers": [],
"exports": {
".": "./src/index.tsx",
"./file-picker": "./src/file-picker/index.tsx",
"./text-knowledge-editor": "./src/text-knowledge-editor/index.tsx",
"./text-knowledge-editor/*": "./src/text-knowledge-editor/*/index.tsx"
},
"main": "src/index.tsx",
"typesVersions": {
"*": {
"file-picker": [
"./src/file-picker/index.tsx"
],
"text-knowledge-editor": [
"./src/text-knowledge-editor/index.tsx"
],
"text-knowledge-editor/*": [
"./src/text-knowledge-editor/*/index.tsx"
]
}
},
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/bot-api": "workspace:*",
"@coze-arch/bot-error": "workspace:*",
"@coze-arch/bot-md-box-adapter": "workspace:*",
"@coze-arch/bot-semi": "workspace:*",
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
"@coze-arch/i18n": "workspace:*",
"@coze-arch/report-events": "workspace:*",
"@coze-common/virtual-list": "workspace:*",
"@coze-data/e2e": "workspace:*",
"@coze-data/feature-register": "workspace:*",
"@coze-data/knowledge-stores": "workspace:*",
"@coze-data/reporter": "workspace:*",
"@tiptap/core": "^2.12.0",
"@tiptap/extension-hard-break": "^2.12.0",
"@tiptap/extension-image": "^2.12.0",
"@tiptap/extension-table": "^2.12.0",
"@tiptap/extension-table-cell": "^2.12.0",
"@tiptap/extension-table-header": "^2.12.0",
"@tiptap/extension-table-row": "^2.12.0",
"@tiptap/pm": "^2.12.0",
"@tiptap/react": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"ahooks": "^3.7.8",
"classnames": "^2.3.2",
"dompurify": "3.0.8",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"nanoid": "^4.0.2",
"react-arborist": "^3.4.0",
"react-pdf": "9.1.1",
"use-resize-observer": "^9.1.0"
},
"devDependencies": {
"@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/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",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

@@ -0,0 +1,8 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M0.832031 5H19.1654V15.8333C19.1654 16.7538 18.4192 17.5 17.4987 17.5H2.4987C1.57822 17.5 0.832031 16.7538 0.832031 15.8333V5Z"
fill="#FFC60A" />
<path
d="M0.832031 3.33329C0.832031 2.41282 1.57822 1.66663 2.4987 1.66663H6.89425C7.29396 1.66663 7.6904 1.73852 8.06466 1.87886L10.2661 2.70439C10.6403 2.84474 11.0368 2.91663 11.4365 2.91663H17.4987C18.4192 2.91663 19.1654 3.66282 19.1654 4.58329V4.99996H0.832031V3.33329Z"
fill="#FF9D4C" />
</svg>

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

@@ -0,0 +1,14 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2.5 2.50004C2.5 1.57957 3.24619 0.833374 4.16667 0.833374H12.1548C12.3758 0.833374 12.5878 0.921171 12.7441 1.07745L17.2559 5.5893C17.4122 5.74558 17.5 5.95754 17.5 6.17855V17.5C17.5 18.4205 16.7538 19.1667 15.8333 19.1667H4.16667C3.24619 19.1667 2.5 18.4205 2.5 17.5V2.50004Z"
fill="#336DF4" />
<path
d="M2.5 2.50004C2.5 1.57957 3.24619 0.833374 4.16667 0.833374H12.1548C12.3758 0.833374 12.5878 0.921171 12.7441 1.07745L17.2559 5.5893C17.4122 5.74558 17.5 5.95754 17.5 6.17855V17.5C17.5 18.4205 16.7538 19.1667 15.8333 19.1667H4.16667C3.24619 19.1667 2.5 18.4205 2.5 17.5V2.50004Z"
fill="#35BD4B" />
<path
d="M5.625 7.43201H12.3864C13.4847 7.43201 14.375 8.32235 14.375 9.42064V16.182H7.61364C6.51534 16.182 5.625 15.2917 5.625 14.1934V7.43201ZM10.5638 8.62519V11.2104H13.1818V9.42064C13.1818 8.98133 12.8257 8.62519 12.3864 8.62519H10.5638ZM9.37066 8.62519H6.81818V11.2104H9.37066V8.62519ZM6.81818 12.4036V14.1934C6.81818 14.6327 7.17432 14.9888 7.61364 14.9888H9.37066V12.4036H6.81818ZM10.5638 14.9888H13.1818V12.4036H10.5638V14.9888Z"
fill="white" />
<path opacity="0.7"
d="M12.5 1.23574C12.5 1.08726 12.6795 1.0129 12.7845 1.11789L17.2155 5.54886C17.3205 5.65385 17.2461 5.83337 17.0976 5.83337H14.1667C13.2462 5.83337 12.5 5.08718 12.5 4.16671V1.23574Z"
fill="#298C38" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,17 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2.5 2.50004C2.5 1.57957 3.24619 0.833374 4.16667 0.833374H12.1548C12.3758 0.833374 12.5878 0.921171 12.7441 1.07745L17.2559 5.5893C17.4122 5.74558 17.5 5.95754 17.5 6.17855V17.5C17.5 18.4205 16.7538 19.1667 15.8333 19.1667H4.16667C3.24619 19.1667 2.5 18.4205 2.5 17.5V2.50004Z"
fill="#336DF4" />
<path
d="M2.5 2.50004C2.5 1.57957 3.24619 0.833374 4.16667 0.833374H12.1548C12.3758 0.833374 12.5878 0.921171 12.7441 1.07745L17.2559 5.5893C17.4122 5.74558 17.5 5.95754 17.5 6.17855V17.5C17.5 18.4205 16.7538 19.1667 15.8333 19.1667H4.16667C3.24619 19.1667 2.5 18.4205 2.5 17.5V2.50004Z"
fill="#336DF4" />
<path
d="M5.83203 13.5773C5.83203 13.2321 6.11185 12.9523 6.45703 12.9523H11.0404C11.3855 12.9523 11.6654 13.2321 11.6654 13.5773C11.6654 13.9224 11.3855 14.2023 11.0404 14.2023H6.45703C6.11185 14.2023 5.83203 13.9224 5.83203 13.5773Z"
fill="white" />
<path
d="M6.45703 9.20227C6.11185 9.20227 5.83203 9.48209 5.83203 9.82727C5.83203 10.1724 6.11185 10.4523 6.45703 10.4523H13.5404C13.8855 10.4523 14.1654 10.1724 14.1654 9.82727C14.1654 9.48209 13.8855 9.20227 13.5404 9.20227H6.45703Z"
fill="white" />
<path opacity="0.7"
d="M12.5 1.23574C12.5 1.08726 12.6795 1.0129 12.7845 1.11789L17.2155 5.54886C17.3205 5.65385 17.2461 5.83337 17.0976 5.83337H14.1667C13.2462 5.83337 12.5 5.08718 12.5 4.16671V1.23574Z"
fill="#0442D2" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,89 @@
/*
* 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, useRef } from 'react';
import { Spin } from '@coze-arch/coze-design';
interface IPreviewMdProps {
fileUrl: string;
}
function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const PreviewMd = (props: IPreviewMdProps) => {
const { fileUrl } = props;
const [mdContent, setMdContent] = useState<string>('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(fileUrl)
.then(res => res.text())
.then(text => {
setLoading(false);
setMdContent(text);
});
}, [fileUrl]);
const ref = useRef<HTMLPreElement>(null);
useEffect(() => {
async function render() {
if (ref.current) {
for (
let i = 0, len = mdContent.length;
i < Math.ceil(len / 50_000);
i++
) {
await wait(10);
ref.current.textContent += mdContent.slice(
i * 50_000,
(i + 1) * 50_000,
);
}
ref.current.textContent = mdContent;
}
}
render();
}, [mdContent]);
return (
<div className="flex flex-col items-center w-full h-full flex-1 py-2 px-4">
<Spin
wrapperClassName="w-full h-full grow"
spinning={loading}
childStyle={{
width: '100%',
height: '100%',
flexGrow: 1,
}}
>
{mdContent ? (
<div>
<pre
className="max-w-full overflow-auto whitespace-pre-wrap break-all text-[14px] leading-[22px]"
ref={ref}
>
{/* {mdContent} */}
</pre>
</div>
) : null}
</Spin>
</div>
);
};
@@ -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.
*/
import { useEffect, useRef, useState } from 'react';
import { Spin } from '@coze-arch/coze-design';
interface IPreviewTxtProps {
fileUrl: string;
}
function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const PreviewTxt = (props: IPreviewTxtProps) => {
const { fileUrl } = props;
const [txtContent, setTxtContent] = useState<string>('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(fileUrl)
.then(res => res.text())
.then(text => {
setLoading(false);
setTxtContent(text);
});
}, [fileUrl]);
const ref = useRef<HTMLPreElement>(null);
useEffect(() => {
async function render() {
if (ref.current) {
for (
let i = 0, len = txtContent.length;
i < Math.ceil(len / 50_000);
i++
) {
await wait(10);
if (ref.current) {
ref.current.textContent += txtContent.slice(
i * 50_000,
(i + 1) * 50_000,
);
}
}
if (ref.current) {
ref.current.textContent = txtContent;
}
}
}
render();
}, [txtContent]);
return (
<div className="flex flex-col items-center w-full h-full flex-1 py-2 px-4">
<Spin
wrapperClassName="w-full h-full grow"
spinning={loading}
childStyle={{
width: '100%',
height: '100%',
flexGrow: 1,
}}
>
<pre
className="max-w-full overflow-auto whitespace-pre-wrap break-all text-[14px] leading-[22px]"
ref={ref}
>
{/* {txtContent} */}
</pre>
</Spin>
</div>
);
};
@@ -0,0 +1,200 @@
/*
* 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 @coze-arch/max-line-per-function */
import { Document, Page, pdfjs } from 'react-pdf';
import { useMemo, useRef, useState } from 'react';
import cls from 'classnames';
import { FixedSizeList as List, AutoSizer } from '@coze-common/virtual-list';
import { Spin } from '@coze-arch/coze-design';
import 'react-pdf/dist/Page/TextLayer.css';
import 'react-pdf/dist/Page/AnnotationLayer.css';
interface IUsePreviewPdfProps {
fileUrl: string;
}
pdfjs.GlobalWorkerOptions.workerSrc =
REGION === 'cn'
? // cp-disable-next-line
`//lf-cdn.coze.cn/obj/unpkg/pdfjs-dist/${pdfjs.version}/build/pdf.worker.min.mjs`
: // cp-disable-next-line
`//sf-cdn.coze.com/obj/unpkg-va/pdfjs-dist/${pdfjs.version}/build/pdf.worker.min.mjs`;
const options = {
cMapUrl:
REGION === 'cn'
? // cp-disable-next-line
`//lf-cdn.coze.cn/obj/unpkg/pdfjs-dist/${pdfjs.version}/cmaps/`
: // cp-disable-next-line
`//sf-cdn.coze.com/obj/unpkg-va/pdfjs-dist/${pdfjs.version}/cmaps/`,
// Boost performance
cMapPacked: true,
};
export const usePreviewPdf = (props: IUsePreviewPdfProps) => {
const { fileUrl } = props;
const [loading, setLoading] = useState(true);
const [numPages, setNumPages] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(0);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<List>(null);
const [pageHeight, setPageHeight] = useState(
containerRef.current?.clientHeight,
);
const itemSize = Math.floor((pageHeight ?? 500) + 20);
const onNext = () => {
if (currentPage < numPages - 1) {
const nextPage = currentPage + 1;
setCurrentPage(nextPage);
listRef.current?.scrollToItem(nextPage, 'start');
}
};
const onBack = () => {
if (currentPage > 0) {
const prevPage = currentPage - 1;
setCurrentPage(prevPage);
listRef.current?.scrollToItem(prevPage, 'start');
}
};
const onDocumentLoadSuccess = ({
numPages: totalPages,
}: {
numPages: number;
}) => {
setLoading(false);
setNumPages(totalPages);
};
const handleScroll = ({ scrollOffset }: { scrollOffset: number }) => {
const newIndex = Math.floor(scrollOffset / itemSize);
setCurrentPage(newIndex);
};
const [scale, setScale] = useState(1);
const increaseScale = () => {
setScale(prevScale => Math.min(prevScale + 0.1, 2));
};
const decreaseScale = () => {
setScale(prevScale => Math.max(prevScale - 0.1, 0.5));
};
const memoizedList = useMemo(
() =>
!loading ? (
<AutoSizer>
{({ height, width }) => (
<List
height={height ?? 0}
itemCount={numPages}
itemSize={itemSize}
width={width ?? 0}
onScroll={handleScroll}
ref={listRef}
>
{({
index,
style,
}: {
index: number;
style: React.CSSProperties;
}) => (
<div key={`page_${index + 1}`} style={style}>
<Page
pageNumber={index + 1}
className={cls(
'flex items-center justify-center !coz-bg-primary',
)}
width={(width ?? 100) - 32}
scale={scale}
onLoadSuccess={page => {
setPageHeight(page.height);
}}
loading={
<div
style={{
height: containerRef.current?.clientHeight,
}}
></div>
}
/>
</div>
)}
</List>
)}
</AutoSizer>
) : null,
[loading, numPages, pageHeight, scale, itemSize],
);
const pdfNode = (
<>
<div className="flex flex-col items-center w-full h-full relative coz-bg-primary">
<div
className={cls(
'absolute top-0 left-0 right-0 bot-0 flex items-center justify-center h-full',
'z-10',
!loading && 'invisible',
)}
>
<Spin />
</div>
<div
className={cls(
'flex absolute top-0 left-0 right-0 overflow-auto px-5 w-full h-full justify-center',
loading && 'invisible',
)}
ref={containerRef}
>
<Document
file={fileUrl}
// bug fix https://github.com/wojtekmaj/react-pdf/issues/974
key={fileUrl}
onLoadSuccess={onDocumentLoadSuccess}
options={options}
className={cls('flex w-full h-full')}
loading={
<Spin
wrapperClassName="w-full h-full"
childStyle={{
width: containerRef.current?.clientWidth,
height: '100%',
}}
></Spin>
}
>
{containerRef.current ? memoizedList : null}
</Document>
</div>
</div>
</>
);
return {
pdfNode,
numPages,
currentPage: currentPage + 1,
onNext,
onBack,
scale,
increaseScale,
decreaseScale,
};
};
@@ -0,0 +1,152 @@
/* stylelint-disable declaration-no-important */
.expand-icon {
transform: rotate(90deg) !important;
display: flex !important;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin: 0 !important;
line-height: 20px;
}
.common-file-picker-wrapper {
:global {
.semi-tree-option-selected {
background-color: var(--coz-mg-hglt-hovered) !important;
&:hover {
background-color: unset;
}
}
.semi-tree-option-disable {
cursor: not-allowed;
}
.semi-tree-option-readonly {
cursor: default;
&:hover{
background: transparent !important;
}
}
.semi-tree-option {
height: 32px !important;
margin: 1px 0 !important;
line-height: 32px !important;
border-radius: 4px;
&:hover {
background-color: var(--coz-mg-secondary-hovered);
}
}
.semi-tree-option-expand-icon {
.expand-icon();
}
.expand-placeholder {
.expand-icon();
}
.semi-tree-option-list .semi-tree-option-collapsed .semi-tree-option-expand-icon {
transform: unset !important;
}
.file-selector {
width: 16px;
height: 16px;
margin: 0 12px;
.semi-checkbox-inner {
height: 16px;
}
.semi-radio {
height: 16px;
min-height: 16px;
line-height: 16px;
vertical-align: top;
.semi-radio-inner {
margin-top: 0;
}
}
}
.file-node-row-content {
display: flex;
flex: 1;
align-items: center;
height: 36px;
margin: 2px 0;
padding: 8px 13px;
border-radius: 4px;
}
.file-icon {
display: flex;
align-items: center;
align-self: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 4px;
line-height: 20px;
.semi-spin-middle>.semi-spin-wrapper svg {
width: 16px;
height: 16px;
}
img {
width: 20px;
height: 20px;
line-height: 20px;
}
}
.file-content {
display: flex;
flex: 1;
justify-content: space-between;
height: 20px;
line-height: 20px;
.file-name {
overflow: hidden;
width: 80%;
height: 20px;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: var(--coz-fg-primary);
white-space: nowrap;
}
.file-loading-info {
height: 20px;
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 20px;
color: var(--coz-fg-hglt);
}
}
}
}
@@ -0,0 +1,329 @@
/*
* 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, useImperativeHandle, useRef, useState } from 'react';
import { Tree } from '@coze-arch/coze-design';
import type { TreeProps, RenderFullLabelProps } from '@coze-arch/bot-semi/Tree';
import { CommonE2e } from '@coze-data/e2e';
import { distinctFileNodes, levelMapTreeNodesToMap } from '../utils';
import {
isFileNodeArray,
type FileNode,
type PickerRef,
type TransSelectedFilesMiddleware,
type FileId,
type CalcCurrentSelectFilesMiddleware,
} from '../types';
import { useDefaultLabelRenderer } from '../hooks/useDefaultLabelRenderer';
import {
DEFAULT_FILENODE_LEVEL_INDENT,
DEFAULT_VIRTUAL_CONTAINER_HEIGHT,
DEFAULT_VIRTUAL_ITEM_HEIGHT,
} from '../consts';
import styles from './common-file-picker.module.less';
export interface CommonFilePickerProps
extends Omit<TreeProps, 'onChange' | 'onSelect'> {
/** Tree data used for rendering */
treeData: FileNode[];
/** Provide a customized render tree node */
customTreeDataRenderer?: (
renderProps: RenderFullLabelProps,
) => React.ReactNode;
/** Whether only leaf nodes can be selected, invalid if customRenderTreeData is passed in */
onlySelectLeaf?: boolean;
/** Whether to multi-select, if the custom customTreeDataRenderer must be passed in, the option will affect customTreeDataRenderer imported parameter */
multiple?: boolean;
/**
* Whether to enable virtualization
*/
enableVirtualize?: boolean;
/** virtualization options */
/** virtualized container height */
virtualizeHeight?: number;
/** Height of each item */
virtualizeItemSize?: number;
/** The content already selected by default can be used as initValue or as a substitute for value */
defaultValue?: FileNode[] | FileId[];
/** style rendering feature */
normalLabelStyle?: React.CSSProperties;
selectedLabelStyle?: React.CSSProperties;
halfSelectedLabelStyle?: React.CSSProperties;
/** Indent size, default size 25px If backgroundMode is position, it will react to left If it is padding, it will react to padding-left. */
indentSize?: number;
/** Tree component expansion icon */
expandIcon?: React.ReactNode;
/** onChange business layer can be passed through */
onChange?: (args?: FileNode[]) => void;
/** onSelect business layer can pass through */
onSelect?: (key: string, selected: boolean, node: FileNode) => void;
/** Used to convert selectedFiles, occurs in, sets the selected state, to submit to the upper component, between, note that there is a sequence of processing, the return of the previous middleware will be used as the input of the latter */
transSelectedFilesMiddlewares?: TransSelectedFilesMiddleware[];
/** The hook for setting the selection state occurs between clicking the check box and setting the selection state, and the processing is in sequence */
selectFilesMiddlewares?: CalcCurrentSelectFilesMiddleware[];
/** Disable select disable select */
disableSelect?: boolean;
/** checkRelation: Whether the selected state of the parent and child nodes is related */
checkRelation?: 'related' | 'unRelated';
/** Default expanded node key */
defaultExpandKeys?: FileId[];
}
function diffChangeNodes(
prevChangeNodes: FileNode[],
changeNodes: FileNode[],
): [FileNode[], FileNode[], FileNode[]] {
const addNodes: FileNode[] = [];
const removeNodes: FileNode[] = [];
const retainNodes: FileNode[] = [];
const prevChangeKeysSet = new Set(prevChangeNodes.map(node => node.key));
const changeKeysSet = new Set(changeNodes.map(node => node.key));
for (const changeNode of prevChangeNodes) {
if (!changeKeysSet.has(changeNode.key)) {
removeNodes.push(changeNode);
}
}
for (const changeNode of changeNodes) {
if (prevChangeKeysSet.has(changeNode.key)) {
retainNodes.push(changeNode);
} else {
addNodes.push(changeNode);
}
}
return [addNodes, removeNodes, retainNodes];
}
function getFirstKeyOfDefaultValue(defaultValue?: FileId[] | FileNode[]) {
if (!defaultValue || defaultValue.length === 0) {
return '';
}
if (typeof defaultValue[0] === 'string') {
return defaultValue[0];
}
return defaultValue[0].key;
}
function transDefaultValueToFileNodes(
treeData: FileNode[],
defaultValue?: FileId[] | FileNode[],
): FileNode[] {
const treeDataMap = levelMapTreeNodesToMap(treeData);
return (
defaultValue?.map(element => {
// Because onChangeWithObject is opened, the selected state here should be stored with object.
if (typeof element === 'string') {
return (
treeDataMap.get(element) ?? {
key: element,
}
);
}
return element;
}) ?? []
);
}
function transDefaultValueToRenderNode(defaultValue?: FileId[] | FileNode[]) {
return (
defaultValue?.map(element => {
// Because onChangeWithObject is opened, the selected state here should be stored with object.
if (typeof element === 'string') {
return {
key: element,
};
}
return element;
}) ?? []
);
}
/**
* ------------------
* common file picker
* Select file for data upload
* ------------------
* useImperativeHandle:
* Search: Provides tree search capability
* ------------------
* props: FilePickerProps
* ------------------
*/
export const CommonFilePicker = React.forwardRef(
(props: CommonFilePickerProps, ref: React.ForwardedRef<PickerRef>) => {
const {
treeData,
customTreeDataRenderer,
onlySelectLeaf,
multiple,
virtualizeHeight,
virtualizeItemSize,
indentSize,
expandIcon,
onChange,
transSelectedFilesMiddlewares,
defaultValue,
selectFilesMiddlewares = [],
disableSelect = false,
checkRelation = 'related',
defaultExpandKeys = [],
enableVirtualize = true,
} = props;
const treeRef = useRef<Tree>(null);
// Use controlled mode
const [selectValue, setSelectValue] = useState<FileNode[]>(
transDefaultValueToRenderNode(defaultValue),
);
const [expandedKeys, setExpandedKeys] =
useState<string[]>(defaultExpandKeys);
const prevChangeNodes = useRef<FileNode[]>([]);
useEffect(() => {
const defaultFileNodes = transDefaultValueToFileNodes(
treeData,
defaultValue,
);
setSelectValue(transDefaultValueToRenderNode(defaultValue));
prevChangeNodes.current = defaultFileNodes;
}, [defaultValue]);
const renderTreeData = useDefaultLabelRenderer(
!!multiple,
!!onlySelectLeaf,
{
indentSize: indentSize ?? DEFAULT_FILENODE_LEVEL_INDENT,
expandIcon,
disableSelect,
defaultSingleSelectKey: !multiple
? getFirstKeyOfDefaultValue(defaultValue)
: '',
},
);
useImperativeHandle(ref, () => ({
search: treeRef.current?.search,
}));
return (
<div className={styles['common-file-picker-wrapper']}>
<Tree
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)}
data-testid={CommonE2e.CommonFilePicker}
checkRelation={checkRelation}
value={selectValue}
treeData={treeData}
ref={treeRef}
renderFullLabel={
customTreeDataRenderer ??
(renderTreeData as (
renderProps: RenderFullLabelProps,
) => React.ReactNode)
}
multiple={multiple}
virtualize={
enableVirtualize
? {
height: virtualizeHeight ?? DEFAULT_VIRTUAL_CONTAINER_HEIGHT,
itemSize: virtualizeItemSize ?? DEFAULT_VIRTUAL_ITEM_HEIGHT,
}
: undefined
}
expandedKeys={expandedKeys}
onExpand={(currentExpandedKeys, current) => {
setExpandedKeys(currentExpandedKeys);
}}
onChangeWithObject
onChange={changeNodes => {
let transedChangeNodes: FileNode[];
if (multiple) {
if (
!changeNodes ||
!Array.isArray(changeNodes) ||
!isFileNodeArray(changeNodes)
) {
return;
}
transedChangeNodes = changeNodes;
} else {
if (!changeNodes) {
return;
}
transedChangeNodes = [changeNodes as unknown as FileNode];
}
// Calculate diff
const [addNodes, removeNodes, retainNodes] = diffChangeNodes(
prevChangeNodes.current,
transedChangeNodes as FileNode[],
);
// The middleware here is more used in scenarios where the selected state is customized, such as wanting to unselect all sub-nodes but keep the parent node selected
transedChangeNodes = distinctFileNodes(
selectFilesMiddlewares.reduce(
(selectedElements, middleware) =>
middleware(
selectedElements,
addNodes,
removeNodes,
retainNodes,
),
transedChangeNodes,
),
);
prevChangeNodes.current = transedChangeNodes;
setSelectValue(
transedChangeNodes.map(transedNode => ({
key: transedNode.key,
})),
);
// Although the parent node with children is returned here, because they are all snapshots of the backend interface
// What data to report to the business party?
// The middleware here is mainly used in scenarios where the selected data is reported to the upper component, such as checkRelation'related 'mode, where only the data of the parent node is returned, but the parent component wants all the data
// Warning: If you can't get the unrequested sub-node when using loadData here (in other words, the last layer of data you get is not necessarily a leaf node), the same is true here. After selecting it, it is only a parent node, and it cannot be guaranteed to be in a snapshot.
if (transSelectedFilesMiddlewares) {
transedChangeNodes = transSelectedFilesMiddlewares.reduce(
(selectedElements, middleware) => middleware(selectedElements),
transedChangeNodes,
);
}
onChange?.(transedChangeNodes);
}}
/>
</div>
);
},
);
@@ -0,0 +1,22 @@
/*
* 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.
*/
// Indent related data
export const DEFAULT_FILENODE_LEVEL_INDENT = 24;
// Virtualization configuration related data
export const DEFAULT_VIRTUAL_CONTAINER_HEIGHT = 540;
export const DEFAULT_VIRTUAL_ITEM_HEIGHT = 28;
@@ -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' />
@@ -0,0 +1,389 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable complexity */
/* eslint-disable @coze-arch/max-line-per-function */
import React, { useState } from 'react';
import type { RenderFullLabelProps } from '@coze-arch/bot-semi/Tree';
import { Checkbox, Radio, Spin, Typography } from '@coze-arch/bot-semi';
import { ReactComponent as Text } from '@/assets/text-file.svg';
import { ReactComponent as Sheet } from '@/assets/sheet-file.svg';
import { ReactComponent as Folder } from '@/assets/folder.svg';
import {
TreeNodeType,
type FileSelectCheckStatus,
type FileNode,
} from '../types';
const noRealExpandClassName = 'no-real-expand';
const ActionComponent = (props: {
checkStatus: Partial<FileSelectCheckStatus>;
isLeaf: boolean;
renderExpandIcon: React.ReactElement;
onlySelectLeaf: boolean;
multiple: boolean;
disableSelect: boolean;
unCheckable: boolean;
}) => {
const {
checkStatus,
isLeaf,
renderExpandIcon,
onlySelectLeaf,
multiple,
disableSelect,
unCheckable = false,
} = props;
const getSingalSelectAction = (className: string) => (
<span
role="radio"
tabIndex={0}
aria-checked={checkStatus.checked}
className={`file-selector ${className}`}
>
<Radio checked={checkStatus.checked} disabled={disableSelect} />
</span>
);
const getMultiSelectAction = (className: string) => (
<span
role="checkbox"
tabIndex={0}
aria-checked={checkStatus.checked}
className={`file-selector ${className}`}
>
<Checkbox
indeterminate={checkStatus.halfChecked}
checked={checkStatus.checked}
disabled={disableSelect}
/>
</span>
);
const expandPlaceHolder = <span className="expand-placeholder"></span>;
const selectActionPlaceHolder = (
<span className="action-placeholder file-selector"></span>
);
// At present, the entire tree is multi-selected, and only leaf nodes can be selected
if (multiple && onlySelectLeaf) {
return isLeaf && !unCheckable ? (
<>
{expandPlaceHolder}
{getMultiSelectAction(noRealExpandClassName)}
</>
) : (
<>
{renderExpandIcon} {selectActionPlaceHolder}
</>
);
}
// The current entire tree is multi-selected, and all nodes can be selected
if (multiple && !onlySelectLeaf) {
if (unCheckable) {
return (
<>
{renderExpandIcon} {selectActionPlaceHolder}
</>
);
}
return (
<>
{isLeaf ? expandPlaceHolder : renderExpandIcon}
{getMultiSelectAction(isLeaf ? noRealExpandClassName : '')}
</>
);
}
// The current entire tree is radio-selected, and only leaf nodes can be selected
if (!multiple && onlySelectLeaf) {
return isLeaf && !unCheckable ? (
<>
{expandPlaceHolder}
{getSingalSelectAction(noRealExpandClassName)}
</>
) : (
<>
{renderExpandIcon}
{selectActionPlaceHolder}
</>
);
}
// The current entire tree is radio-selected, and all nodes can be selected
if (!multiple && !onlySelectLeaf) {
if (unCheckable) {
return (
<>
{renderExpandIcon}
{selectActionPlaceHolder}
</>
);
}
return (
<>
{isLeaf ? expandPlaceHolder : renderExpandIcon}
{getSingalSelectAction(isLeaf ? noRealExpandClassName : '')}
</>
);
}
return <></>;
};
const LabelContent = (props: {
iconUrl?: string;
label: React.ReactNode;
type?: TreeNodeType;
isLoading?: boolean;
loadingInfo?: string;
}) => {
const { iconUrl, label, type, isLoading, loadingInfo } = props;
return (
<>
<span className="file-icon">
{isLoading ? (
<Spin spinning />
) : iconUrl ? (
<img src={iconUrl} />
) : (
{
[TreeNodeType.FILE_TABLE]: <Sheet />,
[TreeNodeType.FOLDER]: <Folder />,
[TreeNodeType.FILE_TEXT]: <Text />,
}[type ?? TreeNodeType.FILE_TEXT]
)}
</span>
<span className="file-content">
<Typography.Text
ellipsis={{
showTooltip: {
type: 'tooltip',
opts: {
style: {
maxWidth: '800px',
},
},
},
}}
className="file-name"
>
{label}
</Typography.Text>
{isLoading ? (
<span className="file-loading-info">{loadingInfo}</span>
) : null}
</span>
</>
);
};
/**
* -----------------------------
* Get the default file tree label renderer layer that is not platform aware
* -----------------------------
* @param {boolean} multiple or not
* @param {boolean} onlySelectLeaf Whether only leaf nodes can be selected
* @param {{indentSize: tree component indent size, expandIcon: tree component expandable node, expand icon, disableSelect: selected disabled state}} renderOption Render-related customization options
* @Returns label rendering function
*/
export function useDefaultLabelRenderer(
multiple: boolean,
onlySelectLeaf: boolean,
renderOption: {
indentSize: number;
expandIcon?: React.ReactNode;
disableSelect?: boolean;
defaultSingleSelectKey?: string;
},
) {
const [singleSelectedKey, setSingleSelectKey] = useState(
renderOption.defaultSingleSelectKey ?? '',
);
const getExpandIcon = (
labelDefaultIcon: React.ReactElement,
customIcon?: React.ReactNode,
) => {
if (!customIcon) {
return labelDefaultIcon;
}
const {
props: { onClick, className, role },
} = labelDefaultIcon;
return (
<span
role={role}
className={`${className} semi-tree-option-expand-icon`}
onClick={onClick}
>
{customIcon}
</span>
);
};
const getFileContentClassName = (isChecked: boolean) =>
`w-full file-node-row-content flex items-center ${
isChecked ? 'file-node-selected' : 'fileNodeNormal'
}`;
/**
* The handler that clicks on the entire line, not the handler that clicks on checkbox or radio or expandIcon
* @Param isLeaf: whether it is a leaf node
* @Param onExpand: expand the line, handle function
* @param onCheck: toggle in selected state
**/
const getItemAction = (params: {
isLeaf: boolean;
onExpand: (e: React.MouseEvent<Element, MouseEvent>) => void;
onCheck: (e: React.MouseEvent<Element, MouseEvent>) => void;
unCheckable: boolean;
}) => {
const { onExpand, onCheck, isLeaf, unCheckable } = params;
return function (e: React.MouseEvent<Element, MouseEvent>) {
// If only the leaf node can be selected, then no matter whether it is multi-select/single-select, the parent node can only be expanded, and the leaf node can be selected
// On the contrary, the parent node sub-node is selected, regardless of the multi-select radio, if you want to expand, click the expand icon.
if (onlySelectLeaf) {
if (!isLeaf) {
onExpand(e);
} else {
if (!unCheckable) {
onCheck(e);
}
}
} else {
if (!unCheckable) {
onCheck(e);
}
}
};
};
const labelRenderer = (
renderProps: RenderFullLabelProps & {
data: FileNode;
},
) => {
const {
data,
className,
level,
onCheck,
onExpand,
onClick,
checkStatus,
style,
expandIcon,
} = renderProps;
const { indentSize, disableSelect } = renderOption;
const {
label,
key,
type,
isLoading,
loadingInfo,
render,
readonly,
unCheckable = false,
} = data;
// Radio select the key of the selected option
const singleSelectCheckStatus = singleSelectedKey === key;
const rowCheckStatus = multiple
? checkStatus
: {
checked: singleSelectCheckStatus,
};
// As long as data isLeaf is not empty, always look at data.isLeaf first
const isLeaf = data?.isLeaf ?? !(data.children && data.children.length);
const checkItem = multiple
? (e: React.MouseEvent<Element, MouseEvent>) => {
if (disableSelect) {
e.stopPropagation();
return;
}
onCheck(e);
}
: (e: React.MouseEvent<Element, MouseEvent>) => {
if (disableSelect) {
e.stopPropagation();
return;
}
onClick(e);
setSingleSelectKey(key);
};
const indent = indentSize * level;
const rowStyle = {
...style,
paddingLeft: indent,
};
const renderExpandIcon = getExpandIcon(
expandIcon as React.ReactElement,
renderOption.expandIcon,
);
return (
<li
style={{
...rowStyle,
}}
className={`${className} ${
checkStatus.checked ? 'semi-tree-option-selected' : ''
} ${disableSelect ? 'semi-tree-option-disable' : ''} ${
readonly ? 'semi-tree-option-readonly' : ''
}`}
role="treeitem"
onClick={
readonly
? undefined
: getItemAction({
isLeaf,
onExpand,
onCheck: checkItem,
unCheckable,
})
}
>
<div className={getFileContentClassName(rowCheckStatus.checked)}>
{render ? (
render()
) : (
<>
<ActionComponent
checkStatus={rowCheckStatus}
isLeaf={isLeaf}
renderExpandIcon={renderExpandIcon}
onlySelectLeaf={onlySelectLeaf}
multiple={multiple}
disableSelect={!!disableSelect}
unCheckable={unCheckable}
/>
<LabelContent
iconUrl={data.icon}
label={label}
type={type}
isLoading={isLoading}
loadingInfo={loadingInfo}
/>
</>
)}
</div>
</li>
);
};
return labelRenderer;
}
@@ -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 {
CommonFilePicker,
type CommonFilePickerProps,
} from './components/common-file-picker';
export { appendAllAddFiles, getLeafFiles } from './utils';
export {
type PickerRef,
type FileNode,
TreeNodeType,
type FileId,
} from './types';
@@ -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 ReactNode } from 'react';
import { isObject } from 'lodash-es';
import type { TreeNodeData } from '@coze-arch/bot-semi/Tree';
export enum TreeNodeType {
FILE_TEXT = 2,
FILE_TABLE = 3,
FOLDER = 1,
}
export interface PickerRef {
search?: (searchText: string) => void;
}
/**
* file selection tree node
*/
export interface FileNode extends TreeNodeData {
/** Unique key identifier, can be used, file id */
key: string;
value?: string;
label?: React.ReactNode;
type?: TreeNodeType;
// URL of the icon
icon?: string;
children?: FileNode[];
/** Required when identifying whether the current node is a leaf node loadData */
isLeaf?: boolean;
/** Can the node be selected? */
selectable?: boolean;
/** The loading status of the node, after opening, the loading default replaces the icon, and displays the loadingInfo.
* Note that this is different from the loading of the semi itself. The loading of the semi refers to the unfolded loading state. */
isLoading?: boolean;
/** Node loading prompt, the default is "getting" */
loadingInfo?: string;
/** Specific document types, such as doc docx txt, etc */
file_type?: string;
/** Three-way document link */
file_url?: string;
/** [Feishu scene] wiki space id,*/
space_id?: string;
/** [Feishu scene] wiki leaf id,*/
obj_token?: string;
/** Custom Rendering Items */
render?: () => ReactNode;
/** Read-only, not interactive */
readonly?: boolean;
/** Whether the node is not selectable, the default is false */
unCheckable?: boolean;
}
export type FileId = string;
/**
* File selection tree, node selection status
*/
export interface FileSelectCheckStatus {
checked: boolean;
halfChecked: boolean;
}
// Three parts, currently selected, newly selected, unselected from last time, unchanged from last time
export type TransSelectedFilesMiddleware = (
fileNodes: FileNode[],
) => FileNode[];
export type CalcCurrentSelectFilesMiddleware = (
fileNodes: FileNode[],
addNodes?: FileNode[],
removeNodes?: FileNode[],
retainNodes?: FileNode[],
) => FileNode[];
/** Type assertion, node is not fileNode */
export function isFileNode(fileNode: unknown): fileNode is FileNode {
return !!fileNode && isObject(fileNode) && !!(fileNode as FileNode).key;
}
/** Type assertion, array is not a fileNode array */
export function isFileNodeArray(fileNodes: unknown[]): fileNodes is FileNode[] {
return fileNodes.every(fileNode => isFileNode(fileNode));
}
@@ -0,0 +1,120 @@
/*
* 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 { FileNode, TransSelectedFilesMiddleware } from './types';
export const getLeafFiles: TransSelectedFilesMiddleware = (
files?: FileNode[],
) => {
if (!files || files.length === 0) {
return [];
}
const leafFiles: FileNode[] = [];
const helpQueue = Array.from(files);
while (helpQueue.length > 0) {
const curFile = helpQueue.shift();
if (
curFile?.isLeaf ||
!curFile?.children ||
curFile.children.length === 0
) {
curFile && leafFiles.push(curFile);
} else {
helpQueue.push(...curFile.children);
}
}
return leafFiles;
};
function levelMapTreeNodes<
T extends {
children?: T[];
isLeaf?: boolean;
},
>(treeNodes: T[]): T[] {
const allNode: T[] = [];
const helpRemoveQueue = Array.from(treeNodes);
while (helpRemoveQueue.length > 0) {
const curFile = helpRemoveQueue.shift();
curFile && allNode.push(curFile);
if (!curFile?.isLeaf && curFile?.children && curFile.children.length > 0) {
helpRemoveQueue.push(...curFile.children);
}
}
return allNode;
}
export function levelMapTreeNodesToMap<
T extends {
key: string;
children?: T[];
isLeaf?: boolean;
},
>(treeNodes: T[]): Map<string, T> {
const allNodeMap: Map<string, T> = new Map();
const helpRemoveQueue = Array.from(treeNodes);
while (helpRemoveQueue.length > 0) {
const curFile = helpRemoveQueue.shift();
curFile && allNodeMap.set(curFile.key, curFile);
if (!curFile?.isLeaf && curFile?.children && curFile.children.length > 0) {
helpRemoveQueue.push(...curFile.children);
}
}
return allNodeMap;
}
export const appendAllAddFiles: TransSelectedFilesMiddleware = (
files?: FileNode[],
addNodes?: FileNode[],
removeNodes?: FileNode[],
retainNodes: FileNode[] = [],
// eslint-disable-next-line max-params
) => {
if (!files || files.length === 0) {
return [];
}
if (!addNodes) {
return files;
}
const allRemoveFiles: FileNode[] = levelMapTreeNodes<FileNode>(
removeNodes ?? [],
);
const removeKeys = new Set(allRemoveFiles.map(file => file.key));
const allAddFiles: FileNode[] = levelMapTreeNodes<FileNode>(addNodes ?? []);
return [...allAddFiles, ...retainNodes].filter(
file => !removeKeys.has(file.key),
);
};
export const distinctFileNodes: TransSelectedFilesMiddleware = (
files?: FileNode[],
) => {
if (!files) {
return [];
}
const distinctFiles: FileNode[] = [];
const distinctFileKey: Set<string> = new Set();
for (const file of files) {
if (distinctFileKey.has(file.key)) {
continue;
}
distinctFileKey.add(file.key);
distinctFiles.push(file);
}
return distinctFiles;
};
@@ -0,0 +1,24 @@
/*
* 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 { PreviewMd } from './doc-preview/preview-md';
export { PreviewTxt } from './doc-preview/preview-txt';
export { usePreviewPdf } from './doc-preview/use-preview-pdf';
export { default as SegmentMenu } from './segment-menu';
export { DocumentEditor } from './text-knowledge-editor/features/editor';
export { DocumentPreview } from './text-knowledge-editor/features/preview';
export { LevelTextKnowledgeEditor } from './text-knowledge-editor/scenes/level';
export { BaseTextKnowledgeEditor } from './text-knowledge-editor/scenes/base';
@@ -0,0 +1,60 @@
/*
* 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 ReactNode } from 'react';
import cls from 'classnames';
import { Typography } from '@coze-arch/coze-design';
export interface IDocumentItemProps {
id: string;
title: string;
selected?: boolean;
onClick?: (id: string) => void;
label?: ReactNode;
tag?: ReactNode;
}
const DocumentItem: React.FC<IDocumentItemProps> = props => {
const { id, onClick, title, selected, tag, label } = props;
return (
<div
className={cls(
'w-full h-8 px-2 py-[6px] rounded-[8px] hover:coz-mg-primary cursor-pointer',
'flex items-center',
selected && 'coz-mg-primary',
)}
onClick={() => onClick?.(id)}
>
{label ? (
<div className="w-full">{label}</div>
) : (
<>
<Typography.Text
ellipsis={{ showTooltip: true }}
className="w-full coz-fg-primary text-[14px] leading-[20px] grow truncate"
>
{title}
</Typography.Text>
<div className="flex items-center shrink-0">{tag}</div>
</>
)}
</div>
);
};
export default DocumentItem;
@@ -0,0 +1,184 @@
/*
* 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 ReactNode, useState } from 'react';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import { Search, Tooltip } from '@coze-arch/coze-design';
import { type ILevelSegment } from '@coze-data/knowledge-stores';
import MergeOperation from '../assets/merge-operation.png';
import MergeOperationEn from '../assets/merge-operation-en.png';
import LevelOperation from '../assets/level-operation.png';
import DeleteOperation from '../assets/delete-operation.png';
import DeleteOperationEn from '../assets/delete-operation-en.png';
import { SegmentTree } from './segment-tree';
import DocumentItem from './document-item';
interface IMenuItem {
id: string;
title: string;
label?: ReactNode;
tag?: ReactNode;
tosUrl?: string;
}
interface ISegmentMenuProps {
list: IMenuItem[];
onClick?: (id: string) => void;
selectedID?: string;
isSearchable?: boolean;
treeVisible?: boolean;
treeDisabled?: boolean;
levelSegments?: ILevelSegment[];
setLevelSegments?: (segments: ILevelSegment[]) => void;
setSelectionIDs?: (ids: string[]) => void;
}
const SegmentMenu: React.FC<ISegmentMenuProps> = props => {
const {
isSearchable,
list,
onClick,
selectedID,
levelSegments,
setLevelSegments,
setSelectionIDs,
treeDisabled,
treeVisible,
} = props;
const [searchValue, setSearchValue] = useState('');
return (
<div className="flex flex-col grow w-full h-full">
{isSearchable ? (
<Search
value={searchValue}
placeholder={I18n.t('datasets_placeholder_search')}
onChange={setSearchValue}
/>
) : null}
<div className="pl-2 h-6 mt-4 mb-1 flex items-center">
<div className="coz-fg-secondary text-[12px] font-[400] leading-4 shrink-0">
{/**document list */}
{I18n.t('knowledge_level_012')}
</div>
</div>
<div className="flex flex-col grow w-full">
<div className="flex flex-col gap-1 h-[150px] grow !overflow-auto shrink-0">
{list
.filter(item => item.title.includes(searchValue))
.map(document => {
if (document.id !== '') {
return (
<DocumentItem
key={document.id}
id={document.id}
selected={document.id === selectedID}
onClick={onClick}
title={document.title}
tag={document.tag}
label={document.label}
/>
);
} else {
return null;
}
})}
</div>
{levelSegments?.length && treeVisible ? (
<>
<div className="h-4 flex justify-center items-center px-[8px] mb-[8px]">
<div
className={cls(
'border border-solid border-[0.5px] transition w-full',
'coz-stroke-primary',
)}
/>
</div>
<div className="flex flex-col gap-1 !overflow-auto">
<div className="w-full pl-2 h-6 items-center flex gap-[4px]">
<div className="coz-fg-secondary text-[12px] font-[400] leading-4 shrink-0">
{/**segment hierarchy */}
{I18n.t('knowledge_level_adjust')}
</div>
{treeDisabled ? null : (
<Tooltip
style={{
maxWidth: 602,
}}
position="left"
content={
<>
<div className="coz-fg-plus text-[14px] font-[500] leading-[20px] mb-3">
{I18n.t('knowledge_hierarchies_categories')}
</div>
<div className="flex gap-2">
<div className="flex flex-col gap-1 justify-between w-[182px]">
<span className="coz-fg-primary text-[12px] leading-[16px] font-[400]">
{I18n.t('level_999')}
</span>
<img src={LevelOperation} className="w-[182px]" />
</div>
<div className="flex flex-col gap-1 justify-between w-[182px]">
<span className="coz-fg-primary text-[12px] leading-[16px] font-[400]">
{I18n.t('level_998')}
</span>
<img
src={
IS_OVERSEA ? MergeOperationEn : MergeOperation
}
className="w-[182px]"
/>
</div>
<div className="flex flex-col gap-1 justify-between w-[182px]">
<span className="coz-fg-primary text-[12px] leading-[16px] font-[400]">
{I18n.t('level_997')}
</span>
<img
src={
IS_OVERSEA ? DeleteOperationEn : DeleteOperation
}
className="w-[182px]"
/>
</div>
</div>
</>
}
>
<IconCozInfoCircle className="coz-fg-secondary" />
</Tooltip>
)}
</div>
<div className="h-[360px]">
<SegmentTree
segments={levelSegments}
setLevelSegments={setLevelSegments}
setSelectionIDs={setSelectionIDs}
disabled={treeDisabled}
/>
</div>
</div>
</>
) : null}
</div>
</div>
);
};
export default SegmentMenu;
@@ -0,0 +1,207 @@
/*
* 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 { Tree, type NodeRendererProps, type CursorProps } from 'react-arborist';
import { useState, type CSSProperties } from 'react';
import useResizeObserver from 'use-resize-observer';
import cls from 'classnames';
import { type ILevelSegment } from '@coze-data/knowledge-stores';
import { I18n } from '@coze-arch/i18n';
import { IconCozArrowRight } from '@coze-arch/coze-design/icons';
import { IconButton, Toast } from '@coze-arch/coze-design';
import {
findDescendantIDs,
getTreeNodes,
handleDeleteNode,
handleMergeNodes,
handleTreeNodeMove,
} from './utils/level-tree-op';
import { useSegmentContextMenu } from './use-context-menu';
import { type LevelDocumentTree } from './types';
interface ISegmentTreeProps {
segments: ILevelSegment[];
setLevelSegments?: (segments: ILevelSegment[]) => void;
setSelectionIDs?: (ids: string[]) => void;
disabled?: boolean;
}
export const SegmentTree: React.FC<ISegmentTreeProps> = ({
segments,
setLevelSegments,
setSelectionIDs,
disabled,
}) => {
/**
* select function
*/
const [selected, setSelected] = useState(new Set<string>());
// Sharding id
const [selectedThroughParent, setSelectedThroughParent] = useState(
new Set<string>(),
);
const { ref, width, height } = useResizeObserver<HTMLDivElement>();
const onSelect = (node: LevelDocumentTree) => {
setSelected(new Set([node.id]));
setSelectedThroughParent(findDescendantIDs(node));
setSelectionIDs?.([node.id, ...findDescendantIDs(node)]);
};
/**
* render
*/
const Node = ({
node,
style,
dragHandle,
}: NodeRendererProps<LevelDocumentTree>) => {
const { isOpen, data } = node;
const isLeaf = !data.children?.length;
const expandIcon = (
<IconButton
size="small"
color="secondary"
icon={
<IconCozArrowRight
className={cls(
isOpen && 'rotate-90',
'transition duration-150 ease-in-out',
)}
/>
}
onClick={e => {
e.stopPropagation();
node.toggle();
}}
className={cls('bg-transparent ml-[4px] shrink-0')}
/>
);
return (
<div
className={cls(
'flex items-center gap-[4px]',
'h-[32px] py-[4px] pr-[8px] mb-[2px]',
'hover:coz-mg-primary cursor-pointer',
'transition duration-150 ease-in-out',
'rounded-[8px]',
(selected.has(data.id) || selectedThroughParent.has(data.id)) &&
'coz-mg-primary',
)}
onClick={() => {
onSelect(data);
}}
onContextMenu={e => {
if (disabled) {
return;
}
onContextMenu(e, node);
}}
style={style}
ref={dragHandle}
>
{isLeaf ? <span className="w-6 ml-[4px] shrink-0" /> : expandIcon}
<span
className={cls('text-[14px] leading-[20px] coz-fg-primary truncate')}
>
{data.type !== 'image'
? data.text.slice(0, 50)
: I18n.t('knowledge_level_110')}
</span>
</div>
);
};
/**
* context menu
*/
const { popoverNode, onContextMenu } = useSegmentContextMenu({
onMerge: node => {
const { segments: newSegments, errMsg } = handleMergeNodes(
node.id,
Array.from(findDescendantIDs(node)),
segments,
);
if (errMsg) {
Toast.error(errMsg);
}
if (newSegments?.length) {
setLevelSegments?.(newSegments);
}
},
onDelete: node => {
const newSegments = handleDeleteNode(
[node.id, ...findDescendantIDs(node)],
segments,
);
setLevelSegments?.(newSegments);
},
});
return (
<div ref={ref} className="w-full h-full relative translate-z-0">
<Tree
data={getTreeNodes(segments)}
disableDrag={disabled}
disableDrop={disabled}
onMove={({ dragIds, parentId, index }) => {
const { segments: newSegments, errMsg } = handleTreeNodeMove(
{ dragIDs: dragIds, parentID: parentId, dropIndex: index },
segments,
);
if (errMsg) {
Toast.error(errMsg);
}
if (newSegments?.length) {
setLevelSegments?.(newSegments);
}
}}
rowHeight={34}
paddingTop={4}
paddingBottom={4}
width={width}
height={height}
renderCursor={Cursor}
>
{Node}
</Tree>
{popoverNode}
</div>
);
};
const Cursor = ({ top, left, indent }: CursorProps) => {
const placeholderStyle = {
display: 'flex',
alignItems: 'center',
zIndex: 1,
};
const style: CSSProperties = {
position: 'absolute',
pointerEvents: 'none',
top: `${top - 2}px`,
left: `${left}px`,
right: `${indent}px`,
};
return (
<div style={{ ...placeholderStyle, ...style }}>
<div className={cls('flex-1 h-[2px] coz-mg-hglt-plus')}></div>
</div>
);
};
@@ -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 { type ILevelSegment } from '@coze-data/knowledge-stores';
export type LevelDocumentTree = Omit<
ILevelSegment,
'children' | 'id' | 'parent'
> & {
id: string;
parent: string;
children: LevelDocumentTree[];
};
@@ -0,0 +1,136 @@
/*
* 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 NodeApi } from 'react-arborist';
import { useState } from 'react';
import { useKnowledgeParams } from '@coze-data/knowledge-stores';
import { I18n } from '@coze-arch/i18n';
import { Menu, MenuSubMenu } from '@coze-arch/coze-design';
import { type LevelDocumentTree } from './types';
interface IUseSegmentContextMenuProps {
onDelete: (node: LevelDocumentTree) => void;
onMerge: (node: LevelDocumentTree) => void;
}
export function useSegmentContextMenu({
onDelete,
onMerge,
}: IUseSegmentContextMenuProps): {
popoverNode: React.ReactNode;
onContainerScroll: () => void;
onContextMenu: (
e: React.MouseEvent<HTMLDivElement>,
treeNode: NodeApi<LevelDocumentTree>,
) => void;
} {
const [treeNode, setTreeNode] = useState<NodeApi<LevelDocumentTree> | null>();
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const params = useKnowledgeParams();
return {
popoverNode: (
<Menu
visible={visible}
onVisibleChange={setVisible}
onClickOutSide={() => {
setVisible(false);
setTreeNode(null);
}}
trigger="custom"
position="bottomLeft"
render={
<MenuSubMenu mode="menu">
{treeNode && !treeNode.children?.length ? (
<>
<Menu.Item
isMenu
onClick={() => {
onDelete(treeNode.data);
setVisible(false);
}}
>
{I18n.t('knowledge_level_028')}
</Menu.Item>
</>
) : null}
{treeNode && treeNode.children?.length ? (
<>
<Menu.Item
isMenu
onClick={() => {
onMerge(treeNode.data);
setVisible(false);
}}
>
{I18n.t('knowledge_level_029')}
</Menu.Item>
<Menu.Item
isMenu
onClick={() => {
onDelete(treeNode.data);
setVisible(false);
}}
>
{I18n.t('knowledge_level_028')}
</Menu.Item>
</>
) : null}
</MenuSubMenu>
}
>
<div
style={{
height: 0,
width: 0,
position: 'fixed',
top: position.top,
left: position.left,
}}
/>
</Menu>
),
onContainerScroll: () => {
if (visible) {
setVisible(false);
}
},
onContextMenu: (e, node: NodeApi<LevelDocumentTree>) => {
e.preventDefault();
setTreeNode(node);
/** In the project ide, the ide container is set to contain: strict, which will cause a fixed position.
* The base of the offset is wrong, so you need to subtract the left and top values of the ide container here
*/
let clickX = e.pageX;
let clickY = e.pageY;
const ideDom = document.getElementById(
`coze-project:///knowledge/${params.datasetID}`,
);
if (ideDom) {
const { left, top } = ideDom.getBoundingClientRect();
clickX = clickX - left;
clickY = clickY - top;
}
setPosition({ left: clickX, top: clickY });
setVisible(true);
},
};
}
@@ -0,0 +1,257 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cloneDeep } from 'lodash-es';
import { type ILevelSegment } from '@coze-data/knowledge-stores';
import { I18n } from '@coze-arch/i18n';
import { type LevelDocumentTree } from '../types';
export const getTreeNodes = (
segments: ILevelSegment[],
): LevelDocumentTree[] => {
const root = segments.find(f => f.parent === -1 && f.type === 'title');
if (!root) {
return segments.map(item => ({
...item,
id: item.id.toString(),
parent: item.parent?.toString(),
children: [],
}));
}
return [
{
...root,
id: root.id?.toString(),
parent: root.parent?.toString(),
children: getChildren(root, segments),
},
];
};
/** Segments to TreeNodes */
const getChildren = (
target: ILevelSegment,
list: ILevelSegment[],
): LevelDocumentTree[] =>
(target.children ?? []).reduce<LevelDocumentTree[]>((acc, cur) => {
const found = list.find(f => f.id === cur);
if (found) {
return [
...acc,
{
...found,
id: found.id?.toString(),
parent: found.parent?.toString(),
children: getChildren(found, list),
},
];
} else {
return [...acc];
}
}, []);
/**TreeNodes related */
export const findDescendantIDs = (node: LevelDocumentTree) => {
const ids = new Set<string>();
const findChild = (item: LevelDocumentTree) => {
if (!item || !item.id) {
return;
}
const { children } = item;
if (children && children.length) {
children.forEach(child => {
if (child && child.id) {
ids.add(child.id);
findChild(child);
}
});
}
};
findChild(node);
return ids;
};
export const findTreeNodeByID = (
nodes: LevelDocumentTree[],
id: string,
): LevelDocumentTree | null => {
for (const node of nodes) {
if (node.id === id) {
return node;
}
if (node.children) {
const found = findTreeNodeByID(node.children, id);
if (found) {
return found;
}
}
}
return null;
};
export const handleTreeNodeMove = (
positions: { dragIDs: string[]; parentID: string | null; dropIndex: number },
segments: ILevelSegment[],
): {
segments: ILevelSegment[] | null;
errMsg: string | null;
} => {
if (positions.parentID === null) {
return {
segments: null,
errMsg: I18n.t('knowledge_hierarchies_categories_01'),
};
}
const resSegments = cloneDeep(segments);
for (const id of positions.dragIDs) {
const dragSegmentIdx = resSegments.findIndex(
segment => segment.id.toString() === id,
);
if (dragSegmentIdx === -1) {
continue;
}
const dragSegment = resSegments[dragSegmentIdx];
const parentSegment = resSegments.find(
segment => segment.id.toString() === positions.parentID,
);
if (!parentSegment) {
return {
segments: null,
errMsg: I18n.t('knowledge_hierarchies_categories_02'),
};
}
// If it is the same parent and the dragged position is before the current position, the dropIndex is reduced by 1.
const originalIndex = parentSegment.children.indexOf(dragSegment.id);
const dropIndex =
originalIndex < positions.dropIndex &&
dragSegment.parent === parentSegment.id
? positions.dropIndex - 1
: positions.dropIndex;
if (dragSegment.parent !== parentSegment.id) {
// Remove from old parent's children
const oldParent = resSegments.find(s => s.id === dragSegment.parent);
oldParent?.children.splice(oldParent.children.indexOf(dragSegment.id), 1);
dragSegment.parent = parentSegment.id;
}
// Reorder in parent's children
parentSegment.children = parentSegment.children.filter(
child => child !== dragSegment.id,
);
parentSegment.children.splice(dropIndex, 0, dragSegment.id);
}
return {
segments: resSegments,
errMsg: null,
};
};
export const handleDeleteNode = (ids: string[], segments: ILevelSegment[]) => {
const resSegments = cloneDeep(segments);
for (const id of ids) {
const index = resSegments.findIndex(item => item.id.toString() === id);
const parentSegment = resSegments.find(
item => item.id === resSegments[index].parent,
);
if (parentSegment) {
parentSegment.children = parentSegment.children.filter(
item => item !== resSegments[index].id,
);
}
resSegments.splice(index, 1);
}
return resSegments;
};
export const handleMergeNodes = (
id: string,
descendants: string[],
segments: ILevelSegment[],
): {
segments: ILevelSegment[] | null;
errMsg: string | null;
} => {
const resSegments = cloneDeep(segments);
const mergedSegment = resSegments.find(item => item.id.toString() === id);
if (!mergedSegment) {
return {
segments: null,
errMsg: I18n.t('knowledge_hierarchies_categories_03'),
};
}
if (mergedSegment.parent === -1 && mergedSegment.type === 'title') {
return {
segments: null,
errMsg: I18n.t('knowledge_hierarchies_categories_03'),
};
}
mergedSegment.children = [];
mergedSegment.type = 'section-text';
for (const descendant of descendants) {
const segmentToMerge = resSegments.find(
item => item.id.toString() === descendant,
);
if (!segmentToMerge) {
return {
segments: null,
errMsg: I18n.t('knowledge_hierarchies_categories_04'),
};
}
// Remove the node from the children of the original parent
const parentSegment = resSegments.find(
item => item.id === segmentToMerge.parent,
);
if (parentSegment) {
parentSegment.children = parentSegment.children.filter(
childId => childId !== segmentToMerge.id,
);
}
if (!['table', 'image', 'title'].includes(segmentToMerge?.type ?? '')) {
// Merge text content and delete nodes
mergedSegment.text += segmentToMerge.text;
const index = resSegments.findIndex(
item => item.id === segmentToMerge.id,
);
if (index !== -1) {
resSegments.splice(index, 1);
}
} else {
// Nodes of type non-section-text, move them to the children of the merged node
segmentToMerge.parent = mergedSegment.id;
mergedSegment.children.push(segmentToMerge.id);
}
}
return {
segments: resSegments,
errMsg: null,
};
};
@@ -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 DOMPurify from 'dompurify';
import classNames from 'classnames';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { getRenderHtmlContent } from '@/text-knowledge-editor/services/use-case/get-render-editor-content';
import { getEditorWordsCls } from '@/text-knowledge-editor/services/inner/get-editor-words-cls';
import { getEditorTableClassname } from '@/text-knowledge-editor/services/inner/get-editor-table-cls';
import { getEditorImgClassname } from '@/text-knowledge-editor/services/inner/get-editor-img-cls';
export const DocumentChunkPreview = ({
chunk,
locateId,
}: {
chunk: Chunk;
locateId: string;
}) => (
<div
id={locateId}
className={classNames(
// layout
'relative',
// spacing
'mb-2 p-2',
// Text Style
'text-sm leading-5',
// color
'coz-fg-primary hover:coz-mg-hglt-secondary-hovered coz-mg-secondary',
// border
'border border-solid coz-stroke-primary rounded-lg',
// table style
getEditorTableClassname(),
// image style
getEditorImgClassname(),
// line feed
getEditorWordsCls(),
)}
>
<p
// Filtered xss with DOMPurify
// eslint-disable-next-line risxss/catch-potential-xss-react
dangerouslySetInnerHTML={{
__html:
DOMPurify.sanitize(getRenderHtmlContent(chunk.content ?? ''), {
/**
* 1. Prevent CSS injection attacks
* 2. Prevent users from writing the style tag by mistake, resulting in the global style being modified and the page display being abnormal
*/
FORBID_TAGS: ['style'],
}) ?? '',
}}
/>
</div>
);
@@ -0,0 +1,67 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import DOMPurify from 'dompurify';
import cls from 'classnames';
export const ImageChunkPreview = ({
base64,
htmlText,
link,
caption,
locateId,
selected,
}: {
base64?: string;
htmlText?: string;
link?: string;
caption?: string;
locateId: string;
selected?: boolean;
}) => (
<div
id={locateId}
className={cls(
'flex items-center flex-col gap-2',
'w-full p-2 coz-mg-secondary',
'border border-solid coz-stroke-primary rounded-[8px]',
selected && '!coz-mg-hglt',
)}
>
{base64 ? (
<img
src={`data:image/jpeg;base64, ${base64}`}
className="w-full h-full"
/>
) : null}
{htmlText ? (
<div
className="w-full h-full overflow-auto [&>*]:w-full [&>*]:h-full"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(htmlText) }}
/>
) : null}
{link ? (
<div className="coz-fg-primary text-[14px] leading-[20px] font-[400] break-all">
{link}
</div>
) : null}
{caption ? (
<div className="coz-fg-primary text-[14px] leading-[20px] font-[400] break-all">
{caption}
</div>
) : null}
</div>
);
@@ -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.
*/
import cls from 'classnames';
export const TitleChunkPreview = ({
title,
id,
}: {
title: string;
id: string;
}) => (
<div
id={id}
className={cls('w-full text-[14px] font-[500] leading-[20px] coz-fg-plus')}
>
{title}
</div>
);
@@ -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 { useEffect } from 'react';
import mitt from 'mitt';
import type { Emitter, Handler, EventType } from 'mitt';
import { type Chunk } from '../types/chunk';
// Define event name literal type
export type EventTypeName =
| 'previewContextMenuItemAction'
| 'hoverEditBarAction';
/**
* event type definition
*/
export interface EventTypes extends Record<EventType, unknown> {
// right-click menu related events
previewContextMenuItemAction: {
type: 'add-after' | 'add-before' | 'delete' | 'edit';
targetChunk: Chunk;
newChunk?: Chunk;
chunks?: Chunk[];
};
// Floating edit bar related events
hoverEditBarAction: {
type: 'add-after' | 'add-before' | 'delete' | 'edit';
targetChunk: Chunk;
newChunk?: Chunk;
chunks?: Chunk[];
};
}
/**
* event handler type
*/
export type EventHandler<T extends EventTypeName> = Handler<EventTypes[T]>;
/**
* Create an event bus instance
*/
export const createEventBus = (): Emitter<EventTypes> => mitt<EventTypes>();
/**
* Global Event Bus instance
*/
export const eventBus = createEventBus();
/**
* event bus hook
* Used to use the event bus in components
*/
export const useEventBus = () => eventBus;
/**
* listen event hook
* Used to listen for events in a component
* @param eventName
* @param handler event handler
* @Param deps dependency arrays, rebind events when dependencies change
*/
export const useEventListener = <T extends EventTypeName>(
eventName: T,
handler: EventHandler<T>,
deps: React.DependencyList = [],
) => {
useEffect(() => {
// binding event
eventBus.on(eventName, handler as Handler<unknown>);
// Unbind event when component is unmounted
return () => {
eventBus.off(eventName, handler as Handler<unknown>);
};
}, deps);
};
@@ -0,0 +1,75 @@
/*
* 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 Image from '@tiptap/extension-image';
// Extend TipTap Image to support a custom data-tos-key HTML attribute
declare module '@tiptap/core' {
interface Commands<ReturnType> {
imageWithTosKey: {
setImageWithTosKey: (options: {
src: string;
alt?: string;
title?: string;
dataTosKey?: string | null;
}) => ReturnType;
};
}
}
const ImageWithTosKey = Image.extend({
addAttributes() {
const parentAttributes = (this.parent?.() as Record<string, unknown>) || {};
return {
...parentAttributes,
dataTosKey: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-tos-key'),
renderHTML: (attributes: { dataTosKey?: string | null }) => {
if (!attributes.dataTosKey) {
return {};
}
return { 'data-tos-key': attributes.dataTosKey };
},
},
};
},
addCommands() {
return {
setImageWithTosKey:
(options: {
src: string;
alt?: string;
title?: string;
dataTosKey?: string | null;
}) =>
({ commands }) =>
commands.insertContent({
type: this.name,
attrs: {
src: options.src,
alt: options.alt,
title: options.title,
dataTosKey: options.dataTosKey ?? null,
},
}),
};
},
});
export default ImageWithTosKey;
@@ -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 {
UploadImageMenu,
UploadImageButton,
BaseUploadImage,
UploadImageIcon,
} from './upload-image';
export {
createEditorActionFeatureRegistry,
EditorActionRegistry,
} from './registry';
export { type EditorActionModule } from './module';
@@ -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 type React from 'react';
import { type Editor } from '@tiptap/react';
export interface EditorActionProps {
editor: Editor | null;
disabled?: boolean;
onlyIcon?: boolean;
showTooltip?: boolean;
}
export interface EditorActionModule {
Component: React.ComponentType<EditorActionProps>;
showTooltip?: boolean;
disabled?: boolean;
onlyIcon?: boolean;
}
@@ -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 { FeatureRegistry } from '@coze-data/feature-register';
import { type EditorActionModule } from './module';
export type EditorActionFeatureType = 'upload-image';
export type EditorActionRegistry = FeatureRegistry<
EditorActionFeatureType,
EditorActionModule
>;
export const createEditorActionFeatureRegistry = (
name: string,
): EditorActionRegistry =>
new FeatureRegistry<EditorActionFeatureType, EditorActionModule>({
name,
});
@@ -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 React from 'react';
import { type Editor } from '@tiptap/react';
import { I18n } from '@coze-arch/i18n';
import { Tooltip, type customRequestArgs } from '@coze-arch/coze-design';
import { type EditorActionProps } from '../module';
import { CustomUpload, handleCustomUploadRequest } from './custom-upload';
export interface BaseUploadImageProps extends EditorActionProps {
editor: Editor | null;
renderUI: (props: {
disabled?: boolean;
showTooltip?: boolean;
}) => React.ReactNode;
}
export const BaseUploadImage = ({
editor,
disabled,
showTooltip,
renderUI,
}: BaseUploadImageProps) => {
// Handle image upload
const handleImageUpload = (object: customRequestArgs) => {
if (!editor) {
return;
}
const { fileInstance } = object;
if (!fileInstance) {
return;
}
return handleCustomUploadRequest({
object,
options: {
onFinish: (result: { url?: string; tosKey?: string }) => {
if (result.url && editor) {
// Insert pictures into the editor and set the tosKey
editor
.chain()
.focus()
.setImageWithTosKey({
src: result.url,
dataTosKey: result.tosKey ?? null,
})
.run();
}
},
},
});
};
const TooltipWrapper = showTooltip ? Tooltip : React.Fragment;
return (
<CustomUpload customRequest={handleImageUpload}>
<TooltipWrapper
content={I18n.t('knowledge_insert_img_002')}
clickToHide
autoAdjustOverflow
>
{renderUI({ disabled, showTooltip })}
</TooltipWrapper>
</CustomUpload>
);
};
@@ -0,0 +1,39 @@
/*
* 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 { IconCozImage } from '@coze-arch/coze-design/icons';
import { Button } from '@coze-arch/coze-design';
import { BaseUploadImage, type BaseUploadImageProps } from './base';
export const UploadImageButton = (
props: Omit<BaseUploadImageProps, 'renderUI'>,
) => (
<BaseUploadImage
{...props}
renderUI={({ disabled }) => (
<Button
disabled={disabled}
color="primary"
className="coz-fg-primary leading-none"
icon={<IconCozImage className="text-[14px]" />}
>
{I18n.t('knowledge_insert_img_002')}
</Button>
)}
/>
);
@@ -0,0 +1,131 @@
/*
* 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 } from 'react';
import { DataNamespace, dataReporter } from '@coze-data/reporter';
import { REPORT_EVENTS as ReportEventNames } from '@coze-arch/report-events';
import { I18n } from '@coze-arch/i18n';
import { Upload, Toast, type customRequestArgs } from '@coze-arch/coze-design';
import { type UploadProps } from '@coze-arch/bot-semi/Upload';
import { CustomError } from '@coze-arch/bot-error';
import { FileBizType } from '@coze-arch/bot-api/developer_api';
import { DeveloperApi } from '@coze-arch/bot-api';
import {
getBase64,
getFileExtension,
isValidSize,
} from '@/text-knowledge-editor/utils/upload';
interface CustomUploadProps {
customRequest: UploadProps['customRequest'];
}
export const CustomUpload: React.FC<PropsWithChildren<CustomUploadProps>> = ({
customRequest,
children,
}) => (
<Upload
accept="image/*"
maxSize={20480}
fileList={[]}
customRequest={customRequest}
onChange={fileItem => {
const { currentFile } = fileItem;
if (currentFile) {
const isValid = isValidSize(currentFile?.fileInstance?.size || 0);
if (!isValid) {
Toast.error(I18n.t('knowledge_insert_img_013'));
}
}
}}
>
{children}
</Upload>
);
export interface CustomRequestParams {
object: customRequestArgs;
options: {
onFinish?: (result: { url?: string; tosKey?: string }) => void;
onFinally?: () => void;
onBeforeUpload?: () => void;
};
}
export const handleCustomUploadRequest = async ({
object,
options,
}: CustomRequestParams) => {
const { onSuccess, onProgress, file } = object;
const { onFinish, onFinally, onBeforeUpload } = options;
if (typeof file === 'string') {
return;
}
try {
// business logic
onBeforeUpload?.();
const { name, fileInstance } = file;
if (fileInstance) {
const extension = getFileExtension(name);
const base64 = await getBase64(fileInstance);
const result = await DeveloperApi.UploadFile(
{
file_head: {
file_type: extension,
biz_type: FileBizType.BIZ_BOT_DATASET,
},
data: base64,
},
{
onUploadProgress: e => {
onProgress({
total: e.total ?? fileInstance.size,
loaded: e.loaded,
});
},
},
);
onSuccess(result.data);
if (result.data) {
onFinish?.({
url: result.data.upload_url,
tosKey: result.data.upload_uri,
});
}
} else {
dataReporter.errorEvent(DataNamespace.KNOWLEDGE, {
eventName: ReportEventNames.KnowledgeUploadFile,
error: new CustomError(
ReportEventNames.KnowledgeUploadFile,
`${ReportEventNames.KnowledgeUploadFile}: Failed to upload image`,
),
});
}
} catch (error) {
dataReporter.errorEvent(DataNamespace.KNOWLEDGE, {
eventName: ReportEventNames.KnowledgeUploadFile,
error: error as Error,
});
} finally {
onFinally?.();
}
};
@@ -0,0 +1,38 @@
/*
* 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 { IconCozImage } from '@coze-arch/coze-design/icons';
import { IconButton } from '@coze-arch/coze-design';
import { BaseUploadImage, type BaseUploadImageProps } from './base';
export const UploadImageIcon = (
props: Omit<BaseUploadImageProps, 'renderUI'>,
) => (
<BaseUploadImage
{...props}
renderUI={({ disabled }) => (
<IconButton
disabled={disabled}
size="small"
color="secondary"
iconPosition="left"
className="coz-fg-secondary leading-none !w-6 !h-6"
icon={<IconCozImage className="text-[14px]" />}
/>
)}
/>
);
@@ -0,0 +1,20 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { UploadImageIcon } from './icon-action';
export { UploadImageButton } from './button-action';
export { UploadImageMenu } from './menu-action';
export { BaseUploadImage } from './base';
@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozImage } from '@coze-arch/coze-design/icons';
import { Menu } from '@coze-arch/coze-design';
import { BaseUploadImage, type BaseUploadImageProps } from './base';
export const UploadImageMenu = (
props: Omit<BaseUploadImageProps, 'renderUI'>,
) => (
<BaseUploadImage
{...props}
renderUI={({ disabled }) => (
<Menu.Item
disabled={disabled}
icon={
<IconCozImage
className={classNames('w-3.5 h-3.5', {
'opacity-30': disabled,
})}
/>
}
className={classNames('h-8 p-2 text-xs rounded-lg', {
'cursor-not-allowed': disabled,
})}
>
{I18n.t('knowledge_insert_img_002')}
</Menu.Item>
)}
/>
);
@@ -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 React from 'react';
import classNames from 'classnames';
import { type Editor } from '@tiptap/react';
import { Menu } from '@coze-arch/coze-design';
import { type EditorActionRegistry } from '@/text-knowledge-editor/features/editor-actions/registry';
interface EditorContextMenuProps {
x: number;
y: number;
editor: Editor | null;
readonly?: boolean;
contextMenuRef: React.RefObject<HTMLDivElement>;
editorActionRegistry: EditorActionRegistry;
}
export const EditorContextMenu: React.FC<EditorContextMenuProps> = props => {
const { editorActionRegistry, readonly, contextMenuRef, x, y, editor } =
props;
if (readonly) {
return null;
}
return (
<div
ref={contextMenuRef}
className="absolute bg-white shadow-lg rounded-md py-1 z-50"
style={{
top: `${y}px`,
left: `${x}px`,
}}
>
<Menu
visible
clickToHide
keepDOM
position="bottomLeft"
spacing={-4}
trigger="custom"
getPopupContainer={() => contextMenuRef.current ?? document.body}
className={classNames('coz-shadow-large')}
render={
<Menu.SubMenu className={classNames('p-1')} mode="menu">
{editorActionRegistry.entries().map(([key, { Component }]) => (
<Component key={key} editor={editor} />
))}
</Menu.SubMenu>
}
/>
</div>
);
};
@@ -0,0 +1,35 @@
/*
* 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 Editor } from '@tiptap/react';
import { type EditorActionRegistry } from '../editor-actions/registry';
export interface EditorToolbarProps {
editor: Editor | null;
actionRegistry: EditorActionRegistry;
}
export const EditorToolbar = ({
editor,
actionRegistry,
}: EditorToolbarProps) => (
<div className="h-[32px] box-content px-2 pt-2">
{actionRegistry.entries().map(([key, { Component }]) => (
<Component key={key} editor={editor} />
))}
</div>
);
@@ -0,0 +1,119 @@
/*
* 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, { useRef } from 'react';
import classNames from 'classnames';
import { EditorContent, type Editor } from '@tiptap/react';
import { getEditorContent } from '@/text-knowledge-editor/services/use-case/get-editor-content';
import { getEditorWordsCls } from '@/text-knowledge-editor/services/inner/get-editor-words-cls';
import { getEditorTableClassname } from '@/text-knowledge-editor/services/inner/get-editor-table-cls';
import { getEditorImgClassname } from '@/text-knowledge-editor/services/inner/get-editor-img-cls';
import { useOutEditorMode } from '@/text-knowledge-editor/hooks/inner/use-out-editor-mode';
import { useControlContextMenu } from '@/text-knowledge-editor/hooks/inner/use-control-context-menu';
import { EditorContextMenu } from '../editor-context-menu';
import { type EditorActionRegistry } from '../editor-actions/registry';
interface DocumentEditorProps {
editor: Editor | null;
placeholder?: string;
editorContextMenuItemsRegistry?: EditorActionRegistry;
editorBottomSlot?: React.ReactNode;
onBlur?: (newContent: string) => void;
}
export const DocumentEditor: React.FC<DocumentEditorProps> = props => {
const {
editor,
placeholder,
editorContextMenuItemsRegistry,
editorBottomSlot,
onBlur,
} = props;
const editorRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
/**
* When right-clicking on the editor, the context menu is displayed
*/
const { contextMenuPosition, openContextMenu } = useControlContextMenu({
contextMenuRef,
});
/**
* When clicking outside the editor
*/
useOutEditorMode({
editorRef,
exclude: [contextMenuRef],
onExitEditMode: () => {
const newContent = getEditorContent(editor);
onBlur?.(newContent);
},
});
if (!editor) {
return null;
}
return (
<div className="relative">
<div
ref={editorRef}
className={classNames(
// layout
'relative',
// spacing
'mb-2 p-2',
// Text Style
'text-sm leading-5',
// color
'coz-fg-primary coz-bg-max',
// border
'border border-solid coz-stroke-hglt rounded-lg',
)}
onContextMenu={openContextMenu}
>
<div
className={classNames(
// table style
getEditorTableClassname(),
// image style
getEditorImgClassname(),
// line feed
getEditorWordsCls(),
)}
>
<EditorContent editor={editor} placeholder={placeholder} />
{editorBottomSlot}
</div>
</div>
{/* right-click menu */}
{contextMenuPosition && editorContextMenuItemsRegistry ? (
<EditorContextMenu
x={contextMenuPosition.x}
y={contextMenuPosition.y}
contextMenuRef={contextMenuRef}
editor={editor}
editorActionRegistry={editorContextMenuItemsRegistry}
/>
) : null}
</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 { DocumentEditor } from './editor';
@@ -0,0 +1,65 @@
/*
* 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 from 'react';
import { KnowledgeE2e } from '@coze-data/e2e';
import { I18n } from '@coze-arch/i18n';
import { IconCozDocumentAddBottom } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { useAddEmptyChunkAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type HoverEditBarActionProps } from './module';
export const AddAfterAction: React.FC<HoverEditBarActionProps> = ({
chunk,
chunks,
disabled,
}) => {
// Add new shardings after specific shardings
const { addEmptyChunkAfter } = useAddEmptyChunkAction({
chunks: chunks || [],
onChunksChange: ({ newChunk, chunks: newChunks }) => {
eventBus.emit('hoverEditBarAction', {
type: 'add-after',
targetChunk: chunk,
chunks: newChunks,
newChunk,
});
},
});
return (
<Tooltip
content={I18n.t('knowledge_optimize_016')}
clickToHide
autoAdjustOverflow
>
<IconButton
data-dtestid={`${KnowledgeE2e.SegmentDetailContentItemAddBottomIcon}.${chunk.text_knowledge_editor_chunk_uuid}`}
size="small"
color="secondary"
disabled={disabled}
icon={<IconCozDocumentAddBottom className="text-[14px]" />}
iconPosition="left"
className="coz-fg-secondary leading-none !w-6 !h-6"
onClick={() => addEmptyChunkAfter(chunk)}
/>
</Tooltip>
);
};
@@ -0,0 +1,68 @@
/*
* 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 from 'react';
import { KnowledgeE2e } from '@coze-data/e2e';
import { I18n } from '@coze-arch/i18n';
import { IconCozDocumentAddTop } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { useAddEmptyChunkAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type HoverEditBarActionProps } from './module';
/**
* Add the action component of a new sharding before a specific sharding
*/
export const AddBeforeAction: React.FC<HoverEditBarActionProps> = ({
chunk,
chunks = [],
disabled,
}) => {
// Add new shardings before specific shardings
const { addEmptyChunkBefore } = useAddEmptyChunkAction({
chunks,
onChunksChange: ({ newChunk, chunks: newChunks }) => {
eventBus.emit('hoverEditBarAction', {
type: 'add-before',
targetChunk: chunk,
chunks: newChunks,
newChunk,
});
},
});
return (
<Tooltip
content={I18n.t('knowledge_optimize_017')}
clickToHide
autoAdjustOverflow
>
<IconButton
data-dtestid={`${KnowledgeE2e.SegmentDetailContentItemAddTopIcon}.${chunk.text_knowledge_editor_chunk_uuid}`}
size="small"
color="secondary"
disabled={disabled}
icon={<IconCozDocumentAddTop className="text-[14px]" />}
iconPosition="left"
className="coz-fg-secondary leading-none !w-6 !h-6"
onClick={() => addEmptyChunkBefore(chunk)}
/>
</Tooltip>
);
};
@@ -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 React from 'react';
import { I18n } from '@coze-arch/i18n';
import { IconCozTrashCan } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { useDeleteAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type HoverEditBarActionProps } from './module';
/**
* Remove the action component for specific shardings
*
* The logic to remove specific shardings is implemented internally
* If an onDelete callback is passed, it will be called on click
* If chunks, onChunksChange are provided, the deletion logic is handled internally.
* No need to rely on external usePreviewContextMenu
*/
export const DeleteAction: React.FC<HoverEditBarActionProps> = ({
chunk,
chunks = [],
disabled,
}) => {
// Remove specific shardings
const { deleteChunk } = useDeleteAction({
chunks,
onChunksChange: ({ chunks: newChunks }) => {
eventBus.emit('hoverEditBarAction', {
type: 'delete',
targetChunk: chunk,
chunks: newChunks,
});
},
});
return (
<Tooltip
content={I18n.t('knowledge_level_028')}
clickToHide
autoAdjustOverflow
>
<IconButton
size="small"
color="secondary"
disabled={disabled}
icon={<IconCozTrashCan className="text-[14px]" />}
iconPosition="left"
className="coz-fg-secondary leading-none !w-6 !h-6"
onClick={() => deleteChunk(chunk)}
/>
</Tooltip>
);
};
@@ -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 React from 'react';
import { KnowledgeE2e } from '@coze-data/e2e';
import { I18n } from '@coze-arch/i18n';
import { IconCozEdit } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip } from '@coze-arch/coze-design';
import { eventBus } from '@/text-knowledge-editor/event';
import { type HoverEditBarActionProps } from './module';
/**
* Edit action component
*
* The logic to activate the edit mode for specific shardings is implemented internally
* If an onEdit callback is passed, it will be called on click
*/
export const EditAction: React.FC<HoverEditBarActionProps> = ({
chunk,
disabled,
}) => (
<Tooltip
content={I18n.t('datasets_segment_edit')}
clickToHide
autoAdjustOverflow
>
<IconButton
data-dtestid={`${KnowledgeE2e.SegmentDetailContentItemEditIcon}.${chunk.text_knowledge_editor_chunk_uuid}`}
size="small"
color="secondary"
disabled={disabled}
icon={<IconCozEdit className="text-[14px]" />}
iconPosition="left"
className="coz-fg-secondary leading-none !w-6 !h-6"
onClick={() => {
eventBus.emit('hoverEditBarAction', {
type: 'edit',
targetChunk: chunk,
});
}}
/>
</Tooltip>
);
@@ -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.
*/
export { EditAction } from './edit-action';
export { DeleteAction } from './delete-action';
export { AddBeforeAction } from './add-before-action';
export { AddAfterAction } from './add-after-action';
export {
createHoverEditBarActionFeatureRegistry,
type HoverEditBarActionFeatureType,
type HoverEditBarActionRegistry,
} from './registry';
export type {
HoverEditBarActionModule,
HoverEditBarActionProps,
} from './module';
@@ -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 React from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
export interface HoverEditBarActionProps {
chunk: Chunk;
chunks?: Chunk[];
disabled?: boolean;
onChunksChange?: (chunks: Chunk[]) => void;
}
export interface HoverEditBarActionModule {
Component: React.ComponentType<HoverEditBarActionProps>;
}
@@ -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 { FeatureRegistry } from '@coze-data/feature-register';
import { type HoverEditBarActionModule } from './module';
export type HoverEditBarActionFeatureType =
| 'edit'
| 'delete'
| 'add-before'
| 'add-after';
export type HoverEditBarActionRegistry = FeatureRegistry<
HoverEditBarActionFeatureType,
HoverEditBarActionModule
>;
export const createHoverEditBarActionFeatureRegistry = (
name: string,
): HoverEditBarActionRegistry =>
new FeatureRegistry<HoverEditBarActionFeatureType, HoverEditBarActionModule>({
name,
});
@@ -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 React from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import { IconButton, Space, Tooltip } from '@coze-arch/coze-design';
import { SliceStatus } from '@coze-arch/bot-api/knowledge';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { type HoverEditBarActionRegistry } from '@/text-knowledge-editor/features/hover-edit-bar-actions/registry';
export interface HoverEditBarProps {
chunk: Chunk;
chunks: Chunk[];
disabled?: boolean;
hoverEditBarActionsRegistry: HoverEditBarActionRegistry;
onChunksChange?: (chunks: Chunk[]) => void;
}
export const HoverEditBar: React.FC<HoverEditBarProps> = ({
chunk,
chunks,
disabled,
hoverEditBarActionsRegistry,
onChunksChange,
}) => {
const isAudiFailed = chunk.status === SliceStatus.AuditFailed;
const iconButtonCommonClasses = 'coz-fg-secondary leading-none !w-6 !h-6';
if (!hoverEditBarActionsRegistry) {
return null;
}
return (
<div className="absolute top-[2px] right-[2px] flex z-10">
{!disabled ? (
<div
className={classNames(
'p-1 coz-bg-plus rounded-lg',
'coz-shadow-default',
)}
>
<Space spacing={3}>
{hoverEditBarActionsRegistry
.entries()
.map(([key, { Component }]) => (
<Component
key={key}
chunk={chunk}
chunks={chunks}
onChunksChange={onChunksChange}
/>
))}
</Space>
</div>
) : null}
{isAudiFailed ? (
<div
className={classNames(
'p-1 coz-bg-plus rounded-lg',
'coz-shadow-default',
'ml-1',
)}
>
<Tooltip
content={I18n.t('community_This_is_a_toast_Machine_review_failed')}
clickToHide
autoAdjustOverflow
>
<IconButton
icon={
<IconCozInfoCircle className="text-[14px] coz-fg-hglt-red" />
}
size="small"
color="secondary"
className={iconButtonCommonClasses}
/>
</Tooltip>
</div>
) : null}
</div>
);
};
@@ -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.
*/
import { HoverEditBar, type HoverEditBarProps } from './hover-edit-bar';
export { HoverEditBar, type HoverEditBarProps };
@@ -0,0 +1,71 @@
/*
* 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 from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozDocumentAddBottom } from '@coze-arch/coze-design/icons';
import { Menu } from '@coze-arch/coze-design';
import { useAddEmptyChunkAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type PreviewContextMenuItemProps } from './module';
/**
* Add a new sharding's menu item component after a specific sharding
*/
export const AddAfterAction: React.FC<PreviewContextMenuItemProps> = ({
chunk,
chunks = [],
disabled,
}) => {
const getIconStyles = (isDisabled: boolean) =>
classNames('w-3.5 h-3.5', {
'opacity-30': isDisabled,
});
const getMenuItemStyles = (isDisabled: boolean) =>
classNames('h-8 px-2 py-2 text-xs rounded-lg', {
'cursor-not-allowed': isDisabled,
});
// Add new shardings after specific shardings
const { addEmptyChunkAfter } = useAddEmptyChunkAction({
chunks,
onChunksChange: ({ newChunk, chunks: newChunks }) => {
// Issue an event to add a new sharding after a specific sharding
eventBus.emit('previewContextMenuItemAction', {
type: 'add-after',
newChunk,
targetChunk: chunk,
chunks: newChunks,
});
},
});
return (
<Menu.Item
disabled={disabled}
icon={<IconCozDocumentAddBottom className={getIconStyles(!!disabled)} />}
onClick={() => addEmptyChunkAfter(chunk)}
className={getMenuItemStyles(!!disabled)}
>
{I18n.t('knowledge_optimize_016')}
</Menu.Item>
);
};
@@ -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 React from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozDocumentAddTop } from '@coze-arch/coze-design/icons';
import { Menu } from '@coze-arch/coze-design';
import { useAddEmptyChunkAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type PreviewContextMenuItemProps } from './module';
/**
* Add a new sharding's menu item component before a specific sharding
*/
export const AddBeforeAction: React.FC<PreviewContextMenuItemProps> = ({
chunk,
chunks = [],
disabled,
}) => {
const getIconStyles = (isDisabled: boolean) =>
classNames('w-3.5 h-3.5', {
'opacity-30': isDisabled,
});
const getMenuItemStyles = (isDisabled: boolean) =>
classNames('h-8 px-2 py-2 text-xs rounded-lg', {
'cursor-not-allowed': isDisabled,
});
// Add new shardings before specific shardings
const { addEmptyChunkBefore } = useAddEmptyChunkAction({
chunks,
onChunksChange: ({ newChunk, chunks: newChunks }) => {
eventBus.emit('previewContextMenuItemAction', {
type: 'add-before',
targetChunk: chunk,
newChunk,
chunks: newChunks,
});
},
});
return (
<Menu.Item
disabled={disabled}
icon={<IconCozDocumentAddTop className={getIconStyles(!!disabled)} />}
onClick={() => addEmptyChunkBefore(chunk)}
className={getMenuItemStyles(!!disabled)}
>
{I18n.t('knowledge_optimize_017')}
</Menu.Item>
);
};
@@ -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 React from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozTrashCan } from '@coze-arch/coze-design/icons';
import { Menu } from '@coze-arch/coze-design';
import { useDeleteAction } from '@/text-knowledge-editor/hooks/use-case/chunk-actions';
import { eventBus } from '@/text-knowledge-editor/event';
import { type PreviewContextMenuItemProps } from './module';
/**
* Remove a specific sharding menu item component
*/
export const DeleteAction: React.FC<PreviewContextMenuItemProps> = ({
chunk,
chunks = [],
disabled,
}) => {
const getIconStyles = (isDisabled: boolean) =>
classNames('w-3.5 h-3.5', {
'opacity-30': isDisabled,
});
const getMenuItemStyles = (isDisabled: boolean) =>
classNames('h-8 px-2 py-2 text-xs rounded-lg', {
'cursor-not-allowed': isDisabled,
});
// Remove specific shardings
const { deleteChunk } = useDeleteAction({
chunks,
onChunksChange: ({ chunks: newChunks }) => {
eventBus.emit('previewContextMenuItemAction', {
type: 'delete',
targetChunk: chunk,
chunks: newChunks,
});
},
});
return (
<Menu.Item
disabled={disabled}
icon={<IconCozTrashCan className={getIconStyles(!!disabled)} />}
onClick={() => deleteChunk(chunk)}
className={getMenuItemStyles(!!disabled)}
>
{I18n.t('Delete')}
</Menu.Item>
);
};
@@ -0,0 +1,63 @@
/*
* 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 from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozEdit } from '@coze-arch/coze-design/icons';
import { Menu } from '@coze-arch/coze-design';
import { eventBus } from '@/text-knowledge-editor/event';
import { type PreviewContextMenuItemProps } from './module';
/**
* Edit Action Menu Item Component
*
* The logic to activate the edit mode for specific shardings is implemented internally
* If an onEdit callback is passed, it will be called on click
*/
export const EditAction: React.FC<PreviewContextMenuItemProps> = ({
chunk,
disabled,
}) => {
const getIconStyles = (isDisabled: boolean) =>
classNames('w-3.5 h-3.5', {
'opacity-30': isDisabled,
});
const getMenuItemStyles = (isDisabled: boolean) =>
classNames('h-8 px-2 py-2 text-xs rounded-lg', {
'cursor-not-allowed': isDisabled,
});
return (
<Menu.Item
disabled={disabled}
icon={<IconCozEdit className={getIconStyles(!!disabled)} />}
onClick={() => {
eventBus.emit('previewContextMenuItemAction', {
type: 'edit',
targetChunk: chunk,
});
}}
className={getMenuItemStyles(!!disabled)}
>
{I18n.t('Edit')}
</Menu.Item>
);
};
@@ -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.
*/
export { EditAction } from './edit-action';
export { DeleteAction } from './delete-action';
export { AddBeforeAction } from './add-before-action';
export { AddAfterAction } from './add-after-action';
export {
createPreviewContextMenuItemFeatureRegistry,
type PreviewContextMenuItemFeatureType,
type PreviewContextMenuItemRegistry,
} from './registry';
export type {
PreviewContextMenuItemModule,
PreviewContextMenuItemProps,
} from './module';
@@ -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 type React from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
export interface PreviewContextMenuItemProps {
chunk: Chunk;
chunks?: Chunk[];
disabled?: boolean;
}
export interface PreviewContextMenuItemModule {
Component: React.ComponentType<PreviewContextMenuItemProps>;
}
@@ -0,0 +1,40 @@
/*
* 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 { FeatureRegistry } from '@coze-data/feature-register';
import { type PreviewContextMenuItemModule } from './module';
export type PreviewContextMenuItemFeatureType =
| 'edit'
| 'delete'
| 'add-before'
| 'add-after';
export type PreviewContextMenuItemRegistry = FeatureRegistry<
PreviewContextMenuItemFeatureType,
PreviewContextMenuItemModule
>;
export const createPreviewContextMenuItemFeatureRegistry = (
name: string,
): PreviewContextMenuItemRegistry =>
new FeatureRegistry<
PreviewContextMenuItemFeatureType,
PreviewContextMenuItemModule
>({
name,
});
@@ -0,0 +1,79 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { Menu } from '@coze-arch/coze-design';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { type PreviewContextMenuItemRegistry } from '@/text-knowledge-editor/features/preview-context-menu-items/registry';
interface PreviewContextMenuProps {
x: number;
y: number;
chunk: Chunk;
chunks: Chunk[];
readonly?: boolean;
contextMenuRef: React.RefObject<HTMLDivElement>;
previewContextMenuItemsRegistry: PreviewContextMenuItemRegistry;
}
const PreviewContextMenu: React.FC<PreviewContextMenuProps> = props => {
const {
previewContextMenuItemsRegistry,
chunk,
chunks,
readonly,
contextMenuRef,
x,
y,
} = props;
if (readonly) {
return null;
}
return (
<div
ref={contextMenuRef}
className="absolute bg-white shadow-lg rounded-md py-1 z-50"
style={{
top: `${y}px`,
left: `${x}px`,
}}
>
<Menu
visible
position="bottomLeft"
spacing={-4}
trigger="custom"
getPopupContainer={() => contextMenuRef.current ?? document.body}
className={classNames('rounded-lg')}
render={
<Menu.SubMenu className={classNames('w-40 p-1')} mode="menu">
{previewContextMenuItemsRegistry
.entries()
.map(([key, { Component }]) => (
<Component key={key} chunk={chunk} chunks={chunks} />
))}
</Menu.SubMenu>
}
/>
</div>
);
};
export default PreviewContextMenu;
@@ -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 { DocumentPreview } from './preview';
@@ -0,0 +1,114 @@
/*
* 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, { useRef } from 'react';
import classNames from 'classnames';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { useHoverEffect } from '@/text-knowledge-editor/hooks/inner/use-hover-effect';
import { useControlContextMenu } from '@/text-knowledge-editor/hooks/inner/use-control-context-menu';
import { DocumentChunkPreview } from '@/text-knowledge-editor/components/preview-chunk/document';
import { type PreviewContextMenuItemRegistry } from '../preview-context-menu-items/registry';
import PreviewContextMenu from '../preview-context-menu';
import { type HoverEditBarActionRegistry } from '../hover-edit-bar-actions/registry';
import { HoverEditBar } from '../hover-edit-bar';
interface DocumentPreviewProps {
chunk: Chunk;
chunks: Chunk[];
readonly?: boolean;
locateId?: string;
hoverEditBarActionsRegistry: HoverEditBarActionRegistry;
previewContextMenuItemsRegistry: PreviewContextMenuItemRegistry;
onActivateEditMode?: (chunk: Chunk) => void;
}
const DocumentPreviewComponent: React.FC<DocumentPreviewProps> = props => {
const {
chunk,
chunks,
readonly = false,
locateId,
onActivateEditMode,
hoverEditBarActionsRegistry,
previewContextMenuItemsRegistry,
} = props;
const contextMenuRef = useRef<HTMLDivElement>(null);
const { hoveredChunk, handleMouseEnter, handleMouseLeave } = useHoverEffect();
const { contextMenuPosition, openContextMenu } = useControlContextMenu({
contextMenuRef,
});
return (
<div className="relative">
<div
className={classNames(
// layout
'relative overflow-hidden',
)}
onContextMenu={readonly ? undefined : e => openContextMenu(e)}
onMouseEnter={
readonly
? undefined
: () => handleMouseEnter(chunk.text_knowledge_editor_chunk_uuid)
}
onMouseLeave={readonly ? undefined : handleMouseLeave}
onDoubleClick={readonly ? undefined : () => onActivateEditMode?.(chunk)}
>
{/* The action bar displayed when hovering */}
{hoveredChunk === chunk.text_knowledge_editor_chunk_uuid &&
!readonly ? (
<HoverEditBar
chunk={chunk}
chunks={chunks}
hoverEditBarActionsRegistry={hoverEditBarActionsRegistry}
/>
) : null}
<DocumentChunkPreview chunk={chunk} locateId={locateId || ''} />
</div>
{/* right-click menu */}
{contextMenuPosition ? (
<PreviewContextMenu
previewContextMenuItemsRegistry={previewContextMenuItemsRegistry}
x={contextMenuPosition.x}
y={contextMenuPosition.y}
chunk={chunk}
chunks={chunks}
readonly={readonly}
contextMenuRef={contextMenuRef}
/>
) : null}
</div>
);
};
// Wrap components with React.memo to avoid unnecessary re-rendering
export const DocumentPreview = React.memo(
DocumentPreviewComponent,
(prevProps, nextProps) => {
// If the sharding content changes, it needs to be re-rendered
if (prevProps.chunk.content !== nextProps.chunk.content) {
return false;
}
// In other cases, no re-rendering is required
return true;
},
);
@@ -0,0 +1,74 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
interface UseControlContextMenuProps {
contextMenuRef: React.RefObject<HTMLDivElement>;
}
export const useControlContextMenu = ({
contextMenuRef,
}: UseControlContextMenuProps) => {
const [contextMenuPosition, setContextMenuPosition] = useState<{
x: number;
y: number;
} | null>(null);
// Handle right-click menus
const openContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
// Calculate the position relative to the event target element
const rect = e.currentTarget.getBoundingClientRect();
const relativeX = e.clientX - rect.left;
const relativeY = e.clientY - rect.top;
setContextMenuPosition({
x: relativeX,
y: relativeY,
});
};
// Close the right-click menu
const closeContextMenu = () => {
setContextMenuPosition(null);
};
// Process Click Document Other Locations
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// If you click outside the right-click menu, close the menu
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(event.target as Node)
) {
closeContextMenu();
}
};
window.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return {
contextMenuPosition,
openContextMenu,
closeContextMenu,
};
};
@@ -0,0 +1,68 @@
/*
* 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';
interface UseControlEditorContextMenuProps {
contextMenuRef: React.RefObject<HTMLDivElement>;
}
export const useControlEditorContextMenu = ({
contextMenuRef,
}: UseControlEditorContextMenuProps) => {
const [contextMenuPosition, setContextMenuPosition] = useState<{
x: number;
y: number;
} | null>(null);
// Handle right-click menus
const openContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setContextMenuPosition({
x: e.clientX,
y: e.clientY,
});
};
// Close the right-click menu
const closeContextMenu = () => {
setContextMenuPosition(null);
};
// Process Click Document Other Locations
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// If you click outside the right-click menu, close the menu
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(event.target as Node)
) {
closeContextMenu();
}
};
window.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return {
contextMenuPosition,
openContextMenu,
closeContextMenu,
};
};
@@ -0,0 +1,67 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useRef, useEffect } from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
export const useControlPreviewContextMenu = () => {
const [contextMenuInfo, setContextMenuInfo] = useState<{
x: number;
y: number;
chunk: Chunk;
} | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
// Handling right-click events
const openContextMenu = (e: React.MouseEvent, chunk: Chunk) => {
e.preventDefault();
setContextMenuInfo({
x: e.clientX,
y: e.clientY,
chunk,
});
};
// Close the right-click menu
const closeContextMenu = () => {
setContextMenuInfo(null);
};
// Click elsewhere in the document to close the right-click menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(event.target as Node)
) {
closeContextMenu();
}
};
window.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return {
contextMenuInfo,
contextMenuRef,
openContextMenu,
closeContextMenu,
};
};
@@ -0,0 +1,65 @@
/*
* 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 { useRequest } from 'ahooks';
import { DataNamespace, dataReporter } from '@coze-data/reporter';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { CustomError } from '@coze-arch/bot-error';
import { KnowledgeApi } from '@coze-arch/bot-api';
import { createRemoteChunk } from '@/text-knowledge-editor/services/inner/chunk-op.service';
export interface UseCreateChunkProps {
documentId: string;
}
export const useCreateChunk = ({ documentId }: UseCreateChunkProps) => {
const { runAsync } = useRequest(
async (props: { content: string; sequence: string }) => {
const { content, sequence } = props;
if (!documentId) {
throw new CustomError('normal_error', 'missing doc_id');
}
const data = await KnowledgeApi.CreateSlice({
document_id: documentId,
raw_text: content,
sequence,
});
const chunk = createRemoteChunk({
slice_id: data?.slice_id ?? '',
sequence,
content,
});
return chunk;
},
{
manual: true,
onError: error => {
dataReporter.errorEvent(DataNamespace.KNOWLEDGE, {
eventName: REPORT_EVENTS.KnowledgeCreateSlice,
error,
});
},
},
);
return {
createChunk: runAsync,
};
};
@@ -0,0 +1,38 @@
/*
* 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 { useRequest } from 'ahooks';
import { CustomError } from '@coze-arch/bot-error';
import { KnowledgeApi } from '@coze-arch/bot-api';
export const useDeleteChunk = () => {
const { runAsync: deleteSlice } = useRequest(
async (sliceId: string) => {
if (!sliceId) {
throw new CustomError('normal_error', 'missing slice_id');
}
await KnowledgeApi.DeleteSlice({
slice_ids: [sliceId],
});
},
{
manual: true,
},
);
return {
deleteSlice,
};
};
@@ -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 { useState } from 'react';
export const useHoverEffect = () => {
const [hoveredChunk, setHoveredChunk] = useState<string | null>(null);
// Handling mouse hover events
const handleMouseEnter = (chunkId: string) => {
setHoveredChunk(chunkId);
};
// Handling mouse away events
const handleMouseLeave = () => {
setHoveredChunk(null);
};
return {
hoveredChunk,
handleMouseEnter,
handleMouseLeave,
};
};
@@ -0,0 +1,48 @@
/*
* 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';
export interface UseOutEditorModeProps {
editorRef: React.RefObject<HTMLDivElement>;
exclude?: React.RefObject<HTMLDivElement>[];
onExitEditMode?: () => void;
}
export const useOutEditorMode = ({
editorRef,
exclude,
onExitEditMode,
}: UseOutEditorModeProps) => {
// Process Click Document Other Locations
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// If you click outside the editor, exit editing mode
if (
editorRef.current &&
!editorRef.current.contains(event.target as Node) &&
!exclude?.some(ref => ref.current?.contains(event.target as Node))
) {
onExitEditMode?.();
}
};
window.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, [editorRef, exclude, onExitEditMode]);
};
@@ -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 { useRequest } from 'ahooks';
import { CustomError } from '@coze-arch/bot-error';
import { KnowledgeApi } from '@coze-arch/bot-api';
export const useUpdateChunk = () => {
const { runAsync: updateSlice, loading: updateLoading } = useRequest(
async (sliceId: string, updateContent: string) => {
if (!sliceId) {
throw new CustomError('normal_error', 'missing slice_id');
}
await KnowledgeApi.UpdateSlice({
slice_id: sliceId,
raw_text: updateContent,
});
return updateContent;
},
{
manual: true,
},
);
return {
updateSlice,
updateLoading,
};
};
@@ -0,0 +1,18 @@
/*
* 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 { useAddEmptyChunkAction } from './use-add-empty-chunk-action';
export { useDeleteAction } from './use-delete-action';
@@ -0,0 +1,122 @@
/*
* 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 { useRef, useEffect } from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { createLocalChunk } from '@/text-knowledge-editor/services/inner/chunk-op.service';
interface UseAddEmptyChunkActionProps {
chunks: Chunk[];
onChunksChange?: (params: { newChunk: Chunk; chunks: Chunk[] }) => void;
}
/**
* Add new sharding hook after specific sharding
*
* Provides the ability to add new shardings after specific shardings
*/
export const useAddEmptyChunkAction = ({
chunks,
onChunksChange,
}: UseAddEmptyChunkActionProps) => {
// Use ref to save the latest chunks reference
const chunksRef = useRef<Chunk[]>(chunks);
// Every time props.chunks is updated, update the ref.
useEffect(() => {
chunksRef.current = chunks;
}, [chunks]);
/**
* Add new shardings after specific shardings
* @Returns the result object containing the new sharding and the updated sharding list
*/
const handleAddEmptyChunkAfter = (chunk: Chunk) => {
// Get the latest chunks from the ref
const currentChunks = chunksRef.current;
const index = currentChunks.findIndex(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
);
if (index === -1) {
return;
}
const sequence =
currentChunks.find(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
)?.sequence ?? '1';
const newChunk = createLocalChunk({
sequence: String(Number(sequence) + 1),
});
const updatedChunks = [
...currentChunks.slice(0, index + 1),
newChunk,
...currentChunks.slice(index + 1),
];
onChunksChange?.({
newChunk,
chunks: updatedChunks,
});
};
/**
* Add new shardings before specific shardings
*/
const handleAddEmptyChunkBefore = (chunk: Chunk) => {
// Get the latest chunks from the ref
const currentChunks = chunksRef.current;
const index = currentChunks.findIndex(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
);
const sequence =
currentChunks.find(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
)?.sequence ?? '1';
if (index === -1) {
return;
}
const newChunk = createLocalChunk({
sequence,
});
const updatedChunks = [
...currentChunks.slice(0, index),
newChunk,
...currentChunks.slice(index),
];
onChunksChange?.({
newChunk,
chunks: updatedChunks,
});
};
return {
addEmptyChunkAfter: handleAddEmptyChunkAfter,
addEmptyChunkBefore: handleAddEmptyChunkBefore,
};
};
@@ -0,0 +1,73 @@
/*
* 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 { useCallback, useRef, useEffect } from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { useDeleteChunk } from '@/text-knowledge-editor/hooks/inner/use-delete-chunk';
interface UseDeleteActionProps {
chunks: Chunk[];
onChunksChange?: (params: { chunks: Chunk[]; targetChunk: Chunk }) => void;
}
/**
* Remove sharding hook
*
* Provides the ability to remove specific shardings
*/
export const useDeleteAction = ({
chunks,
onChunksChange,
}: UseDeleteActionProps) => {
// Use ref to save the latest chunks reference
const chunksRef = useRef<Chunk[]>(chunks);
const { deleteSlice } = useDeleteChunk();
// Every time props.chunks is updated, update the ref.
useEffect(() => {
chunksRef.current = chunks;
}, [chunks]);
/**
* Remove specific shardings
*/
const handleDeleteChunk = useCallback(
(chunk: Chunk) => {
// Get the latest chunks from the ref
const currentChunks = chunksRef.current;
const updatedChunks = currentChunks.filter(
c =>
c.text_knowledge_editor_chunk_uuid !==
chunk.text_knowledge_editor_chunk_uuid,
);
if (!chunk.slice_id) {
return;
}
deleteSlice(chunk.slice_id).then(() => {
onChunksChange?.({
chunks: updatedChunks,
targetChunk: chunk,
});
});
},
[onChunksChange, deleteSlice],
);
return {
deleteChunk: handleDeleteChunk,
};
};
@@ -0,0 +1,61 @@
/*
* 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 Chunk } from '@/text-knowledge-editor/types/chunk';
import { updateLocalChunk } from '@/text-knowledge-editor/services/inner/chunk-op.service';
import { useCreateChunk } from '@/text-knowledge-editor/hooks/inner/use-create-chunk';
export interface UseCreateLocalChunkProps {
chunks: Chunk[];
documentId: string;
onChunksChange?: (chunks: Chunk[]) => void;
onAddChunk?: (chunk: Chunk) => void;
}
export const useCreateLocalChunk = ({
chunks,
documentId,
onChunksChange,
onAddChunk,
}: UseCreateLocalChunkProps) => {
const { createChunk } = useCreateChunk({
documentId,
});
/**
* Handle the creation of local shardings
*/
const createLocalChunk = async (chunk: Chunk) => {
if (!chunk.local_slice_id) {
return;
}
const newChunk = await createChunk({
content: chunk.content ?? '',
sequence: chunk.sequence ?? '1',
});
const newChunks = updateLocalChunk({
chunks,
localChunkSliceId: chunk.local_slice_id,
newChunk,
});
onAddChunk?.(newChunk);
onChunksChange?.(newChunks);
};
return {
createLocalChunk,
};
};
@@ -0,0 +1,43 @@
/*
* 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 Chunk } from '@/text-knowledge-editor/types/chunk';
import { deleteLocalChunk as deleteLocalChunkService } from '@/text-knowledge-editor/services/inner/chunk-op.service';
export interface UseDeleteLocalChunkProps {
chunks: Chunk[];
onChunksChange?: (chunks: Chunk[]) => void;
}
export const useDeleteLocalChunk = ({
chunks,
onChunksChange,
}: UseDeleteLocalChunkProps) => {
/**
* Handle deletion of local shardings
*/
const deleteLocalChunk = (chunk: Chunk) => {
if (!chunk.local_slice_id) {
return;
}
const newChunks = deleteLocalChunkService(chunks, chunk.local_slice_id);
onChunksChange?.(newChunks);
};
return {
deleteLocalChunk,
};
};
@@ -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 { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { deleteRemoteChunk as deleteRemoteChunkService } from '@/text-knowledge-editor/services/inner/chunk-op.service';
import { useDeleteChunk } from '@/text-knowledge-editor/hooks/inner/use-delete-chunk';
export interface UseDeleteRemoteChunkProps {
chunks: Chunk[];
onChunksChange?: (chunks: Chunk[]) => void;
onDeleteChunk?: (chunk: Chunk) => void;
}
export const useDeleteRemoteChunk = ({
chunks,
onChunksChange,
onDeleteChunk,
}: UseDeleteRemoteChunkProps) => {
const { deleteSlice } = useDeleteChunk();
/**
* Handling remotely sharding deletion operations
*/
const deleteRemoteChunk = async (chunk: Chunk) => {
if (!chunk.slice_id) {
return;
}
await deleteSlice(chunk.slice_id);
const newChunks = deleteRemoteChunkService(chunks, chunk.slice_id);
onChunksChange?.(newChunks);
onDeleteChunk?.(chunk);
};
return {
deleteRemoteChunk,
};
};
@@ -0,0 +1,123 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import StarterKit from '@tiptap/starter-kit';
import { useEditor, type Editor } from '@tiptap/react';
import { type EditorProps } from '@tiptap/pm/view';
import TableRow from '@tiptap/extension-table-row';
import TableHeader from '@tiptap/extension-table-header';
import TableCell from '@tiptap/extension-table-cell';
import Table from '@tiptap/extension-table';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { getRenderHtmlContent } from '@/text-knowledge-editor/services/use-case/get-render-editor-content';
import { getEditorContent } from '@/text-knowledge-editor/services/use-case/get-editor-content';
import ImageWithTosKey from '@/text-knowledge-editor/extensions/image-with-tos-key';
interface UseDocumentEditorProps {
chunk: Chunk | null;
editorProps?: EditorProps;
onChange?: (chunk: Chunk) => void;
}
export const useInitEditor = ({
chunk,
editorProps,
onChange,
}: UseDocumentEditorProps) => {
// Create an editor instance
const editor: Editor | null = useEditor({
extensions: [
StarterKit.configure({
hardBreak: {
// force wrap
keepMarks: false,
},
paragraph: {
// Configure paragraphs to avoid generating extra empty paragraphs
HTMLAttributes: {
class: 'text-knowledge-tiptap-editor-paragraph',
},
},
}),
Table.configure({
resizable: true,
}),
TableRow,
TableCell,
TableHeader,
ImageWithTosKey.configure({
inline: false,
allowBase64: true,
}),
],
content: getRenderHtmlContent(chunk?.content || ''),
parseOptions: {
preserveWhitespace: 'full',
},
onUpdate: ({ editor: editorInstance }) => {
if (!chunk || !editorInstance) {
return;
}
const newContent = getEditorContent(editorInstance);
onChange?.({
...chunk,
content: newContent,
});
},
editorProps: {
...editorProps,
handlePaste(view, event, slice) {
if (!editor) {
return false;
}
const text = event.clipboardData?.getData('text/plain');
// If the pasted plain text contains a newline character
if (text?.includes('\n')) {
event.preventDefault(); // Block default paste behavior
const html = getRenderHtmlContent(text);
// Insert the converted HTML into the editor
editor.chain().focus().insertContent(html).run();
return true; // It means we have dealt with it.
}
return false; // Use default behavior
},
},
});
// Update editor content when active sharding changes
useEffect(() => {
if (!editor || !chunk) {
return;
}
const htmlContent = getRenderHtmlContent(chunk.content || '');
// Set content, keep newlines
editor.commands.setContent(htmlContent || '', false, {
preserveWhitespace: 'full',
});
}, [chunk, editor]);
return {
editor,
};
};
@@ -0,0 +1,160 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useRef, useEffect, useCallback } from 'react';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { createLocalChunk } from '@/text-knowledge-editor/services/inner/chunk-op.service';
import { useDeleteChunk } from '../inner/use-delete-chunk';
interface UsePreviewContextMenuProps {
chunks: Chunk[];
documentId: string;
onChunksChange?: (chunks: Chunk[]) => void;
onActiveChunkChange?: (chunk: Chunk) => void;
onAddChunk?: (chunk: Chunk) => void;
}
// eslint-disable-next-line max-lines-per-function
export const usePreviewContextMenu = ({
chunks,
documentId,
onChunksChange,
onActiveChunkChange,
onAddChunk,
}: UsePreviewContextMenuProps) => {
// Use ref to save the latest chunks reference
const chunksRef = useRef(chunks);
const { deleteSlice } = useDeleteChunk();
// Every time props.chunks is updated, update the ref.
useEffect(() => {
chunksRef.current = chunks;
}, [chunks]);
// Activate edit mode for specific shardings
const handleActivateEditMode = useCallback(
(chunk: Chunk) => {
onActiveChunkChange?.(chunk);
},
[onActiveChunkChange],
);
// Add new shardings before specific shardings
const handleAddChunkBefore = useCallback(
(chunk: Chunk) => {
// Get the latest chunks from the ref
const currentChunks = chunksRef.current;
const index = currentChunks.findIndex(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
);
const sequence =
currentChunks.find(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
)?.sequence ?? '1';
if (index === -1) {
return;
}
const newChunk = createLocalChunk({
sequence,
});
const updatedChunks = [
...currentChunks.slice(0, index),
newChunk,
...currentChunks.slice(index),
];
onChunksChange?.(updatedChunks);
// Automatically activate editing mode for new shardings
onActiveChunkChange?.(newChunk);
onAddChunk?.(newChunk);
},
[onChunksChange, onActiveChunkChange, documentId, onAddChunk],
);
// Add new shardings after specific shardings
const handleAddChunkAfter = useCallback(
(chunk: Chunk) => {
// Get the latest chunks from the ref
const currentChunks = chunksRef.current;
const index = currentChunks.findIndex(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
);
if (index === -1) {
return;
}
const sequence =
currentChunks.find(
c =>
c.text_knowledge_editor_chunk_uuid ===
chunk.text_knowledge_editor_chunk_uuid,
)?.sequence ?? '1';
const newChunk = createLocalChunk({
sequence: String(Number(sequence) + 1),
});
const updatedChunks = [
...currentChunks.slice(0, index + 1),
newChunk,
...currentChunks.slice(index + 1),
];
// Automatically activate editing mode for new shardings
onActiveChunkChange?.(newChunk);
onChunksChange?.(updatedChunks);
onAddChunk?.(newChunk);
},
[onChunksChange, onActiveChunkChange, documentId, onAddChunk],
);
// Remove specific shardings
const handleDeleteChunk = useCallback(
(chunk: Chunk) => {
// Get the latest chunks from the ref
const currentChunks = chunksRef.current;
const updatedChunks = currentChunks.filter(
c =>
c.text_knowledge_editor_chunk_uuid !==
chunk.text_knowledge_editor_chunk_uuid,
);
if (!chunk.slice_id) {
return;
}
deleteSlice(chunk.slice_id).then(() => {
onChunksChange?.(updatedChunks);
});
},
[onChunksChange, deleteSlice],
);
return {
handleActivateEditMode,
handleAddChunkBefore,
handleAddChunkAfter,
handleDeleteChunk,
};
};
@@ -0,0 +1,101 @@
/*
* 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 Chunk } from '@/text-knowledge-editor/types/chunk';
import { useUpdateRemoteChunk } from './use-update-remote-chunk';
import { useDeleteRemoteChunk } from './use-delete-remote-chunk';
import { useDeleteLocalChunk } from './use-delete-local-chunk';
import { useCreateLocalChunk } from './use-create-local-chunk';
export interface UseSaveChunkProps {
chunks: Chunk[];
documentId: string;
onChunksChange?: (chunks: Chunk[]) => void;
onAddChunk?: (chunk: Chunk) => void;
onUpdateChunk?: (chunk: Chunk) => void;
onDeleteChunk?: (chunk: Chunk) => void;
}
export const useSaveChunk = ({
chunks,
documentId,
onAddChunk,
onUpdateChunk,
onChunksChange,
onDeleteChunk,
}: UseSaveChunkProps) => {
const { createLocalChunk } = useCreateLocalChunk({
chunks,
documentId,
onChunksChange,
onAddChunk,
});
const { updateRemoteChunk } = useUpdateRemoteChunk({
chunks,
onChunksChange,
onUpdateChunk,
});
const { deleteLocalChunk } = useDeleteLocalChunk({
chunks,
onChunksChange,
});
const { deleteRemoteChunk } = useDeleteRemoteChunk({
chunks,
onChunksChange,
onDeleteChunk,
});
/**
* Handling save logic for remote shardings
*/
const saveRemoteChunk = async (chunk: Chunk) => {
if (chunk.content === '') {
await deleteRemoteChunk(chunk);
return;
}
await updateRemoteChunk(chunk);
};
/**
* Save logic for handling local shardings
*/
const saveLocalChunk = async (chunk: Chunk) => {
if (chunk.content === '') {
deleteLocalChunk(chunk);
} else {
await createLocalChunk(chunk);
}
};
/**
* Save the main function of sharding
*/
const saveChunk = async (chunk: Chunk) => {
if (!chunk.local_slice_id) {
await saveRemoteChunk(chunk);
return;
}
await saveLocalChunk(chunk);
};
return {
saveChunk,
};
};
@@ -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 { Toast } from '@coze-arch/coze-design';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
import { isEditorContentChange } from '@/text-knowledge-editor/services/use-case/is-editor-content-change';
import { updateChunks } from '@/text-knowledge-editor/services/inner/chunk-op.service';
import { useUpdateChunk } from '@/text-knowledge-editor/hooks/inner/use-update-chunk';
export interface UseUpdateRemoteChunkProps {
chunks: Chunk[];
onChunksChange?: (chunks: Chunk[]) => void;
onUpdateChunk?: (chunk: Chunk) => void;
}
export const useUpdateRemoteChunk = ({
chunks,
onChunksChange,
onUpdateChunk,
}: UseUpdateRemoteChunkProps) => {
const { updateSlice } = useUpdateChunk();
/**
* Handling updates for remote sharding
*/
const updateRemoteChunk = async (chunk: Chunk) => {
if (!chunk.slice_id) {
Toast.error('The slice ID does not exist. Please refresh the page');
return;
}
if (!isEditorContentChange(chunks, chunk)) {
onChunksChange?.(chunks);
return;
}
await updateSlice(chunk.slice_id, chunk.content ?? '');
const newChunks = updateChunks(chunks, chunk);
onUpdateChunk?.(chunk);
onChunksChange?.(newChunks);
};
return {
updateRemoteChunk,
};
};
@@ -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.
*/
export { type Chunk } from './types/chunk';
export { DocumentEditor } from './features/editor';
export { DocumentPreview } from './features/preview';
export { useSaveChunk } from './hooks/use-case/use-save-chunk';
export { useInitEditor } from './hooks/use-case/use-init-editor';
export { EditorToolbar } from './features/editor-toolbar';
export {
LevelTextKnowledgeEditor,
type LevelDocumentChunk,
type LevelDocumentTree,
} from './scenes/level';
export { BaseTextKnowledgeEditor } from './scenes/base';
export type { Editor } from '@tiptap/react';
// Add component export
export { HoverEditBar } from './features/hover-edit-bar/hover-edit-bar';
export {
EditAction,
AddBeforeAction,
AddAfterAction,
DeleteAction,
} from './features/hover-edit-bar-actions';
// Event Bus Dependent Export
export {
eventBus,
createEventBus,
useEventBus,
useEventListener,
type EventTypes,
type EventTypeName,
type EventHandler,
} from './event';
@@ -0,0 +1,35 @@
/*
* 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 { UploadImageMenu } from '@/text-knowledge-editor/features/editor-actions/upload-image';
import {
createEditorActionFeatureRegistry,
type EditorActionRegistry,
} from '@/text-knowledge-editor/features/editor-actions/registry';
export const editorContextActionRegistry: EditorActionRegistry = (() => {
const editorContextActionFeatureRegistry = createEditorActionFeatureRegistry(
'editor-context-actions',
);
editorContextActionFeatureRegistry.registerSome([
{
type: 'upload-image',
module: {
Component: UploadImageMenu,
},
},
]);
return editorContextActionFeatureRegistry;
})();
@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createHoverEditBarActionFeatureRegistry,
type HoverEditBarActionRegistry,
} from '@/text-knowledge-editor/features/hover-edit-bar-actions/registry';
import { EditAction } from '@/text-knowledge-editor/features/hover-edit-bar-actions/edit-action';
import { DeleteAction } from '@/text-knowledge-editor/features/hover-edit-bar-actions/delete-action';
import { AddBeforeAction } from '@/text-knowledge-editor/features/hover-edit-bar-actions/add-before-action';
import { AddAfterAction } from '@/text-knowledge-editor/features/hover-edit-bar-actions/add-after-action';
export const hoverEditBarActionsContributes: HoverEditBarActionRegistry =
(() => {
const hoverEditBarActionFeatureRegistry =
createHoverEditBarActionFeatureRegistry('hover-edit-bar-actions');
hoverEditBarActionFeatureRegistry.registerSome([
{
type: 'edit',
module: {
Component: EditAction,
},
},
{
type: 'add-before',
module: {
Component: AddBeforeAction,
},
},
{
type: 'add-after',
module: {
Component: AddAfterAction,
},
},
{
type: 'delete',
module: {
Component: DeleteAction,
},
},
]);
return hoverEditBarActionFeatureRegistry;
})();
@@ -0,0 +1,18 @@
/*
* 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 { BaseTextKnowledgeEditor } from './main';
export { type DocumentChunk } from './types/base-document';
@@ -0,0 +1,160 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useEffect, useState } from 'react';
import { useSaveChunk } from '@/text-knowledge-editor/hooks/use-case/use-save-chunk';
import { useInitEditor } from '@/text-knowledge-editor/hooks/use-case/use-init-editor';
import { useEventListener } from '@/text-knowledge-editor/event';
import { DocumentPreview } from '../../features/preview';
import { DocumentEditor } from '../../features/editor';
import { type DocumentChunk } from './types/base-document';
import { previewContextMenuItemsContributes } from './preview-context-menu-items-contributes';
import { hoverEditBarActionsContributes } from './hover-edit-bar-actions-contributes';
import { editorContextActionRegistry } from './editor-context-actions-contributes';
export interface BaseTextKnowledgeEditorProps {
chunks: DocumentChunk[];
documentId: string;
readonly?: boolean;
onChange?: (chunks: DocumentChunk[]) => void;
onAddChunk?: (chunk: DocumentChunk) => void;
onDeleteChunk?: (chunk: DocumentChunk) => void;
}
export const BaseTextKnowledgeEditor = ({
chunks: initialChunks,
documentId,
readonly = false,
onChange,
onAddChunk,
onDeleteChunk,
}: BaseTextKnowledgeEditorProps) => {
const [chunks, setChunks] = useState<DocumentChunk[]>(initialChunks);
const [activeChunk, setActiveChunk] = useState<DocumentChunk | null>(null);
// Using the core editor functions
const { editor } = useInitEditor({
chunk: activeChunk,
});
// Exit the new sharding feature
const { saveChunk } = useSaveChunk({
chunks,
documentId,
onChunksChange: newChunks => {
onChange?.(newChunks);
setActiveChunk(null);
},
onAddChunk,
onDeleteChunk,
});
// Monitor right-click menu events
useEventListener(
'previewContextMenuItemAction',
useCallback(
({ type, newChunk, chunks: newChunks, targetChunk }) => {
if (type === 'add-after') {
newChunk && setActiveChunk(newChunk);
newChunks && setChunks(newChunks);
}
if (type === 'add-before') {
newChunk && setActiveChunk(newChunk);
newChunks && setChunks(newChunks);
}
if (type === 'delete') {
onDeleteChunk?.(targetChunk);
newChunks && onChange?.(newChunks);
}
if (type === 'edit') {
setActiveChunk(targetChunk);
}
},
[onDeleteChunk, onChange],
),
);
// Monitor floating edit bar events
useEventListener(
'hoverEditBarAction',
useCallback(
({ type, targetChunk, chunks: newChunks, newChunk }) => {
if (type === 'add-after') {
newChunk && setActiveChunk(newChunk);
newChunks && setChunks(newChunks);
}
if (type === 'add-before') {
newChunk && setActiveChunk(newChunk);
newChunks && setChunks(newChunks);
}
if (type === 'delete') {
onDeleteChunk?.(targetChunk);
newChunks && onChange?.(newChunks);
}
if (type === 'edit') {
setActiveChunk(targetChunk);
}
},
[onDeleteChunk, onChange],
),
);
useEffect(() => {
setChunks(initialChunks);
}, [initialChunks]);
return (
<>
{chunks.map(chunk => (
<div key={chunk.text_knowledge_editor_chunk_uuid}>
{(() => {
if (
chunk.text_knowledge_editor_chunk_uuid ===
activeChunk?.text_knowledge_editor_chunk_uuid &&
activeChunk
) {
return (
<DocumentEditor
editor={editor}
editorContextMenuItemsRegistry={editorContextActionRegistry}
onBlur={content => {
saveChunk({
...activeChunk,
content,
});
}}
/>
);
}
return (
<DocumentPreview
chunk={chunk}
chunks={chunks}
readonly={readonly}
onActivateEditMode={setActiveChunk}
hoverEditBarActionsRegistry={hoverEditBarActionsContributes}
previewContextMenuItemsRegistry={
previewContextMenuItemsContributes
}
/>
);
})()}
</div>
))}
</>
);
};
@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createPreviewContextMenuItemFeatureRegistry,
type PreviewContextMenuItemRegistry,
} from '@/text-knowledge-editor/features/preview-context-menu-items/registry';
import { EditAction } from '@/text-knowledge-editor/features/preview-context-menu-items/edit-action';
import { DeleteAction } from '@/text-knowledge-editor/features/preview-context-menu-items/delete-action';
import { AddBeforeAction } from '@/text-knowledge-editor/features/preview-context-menu-items/add-before-action';
import { AddAfterAction } from '@/text-knowledge-editor/features/preview-context-menu-items/add-after-action';
export const previewContextMenuItemsContributes: PreviewContextMenuItemRegistry =
(() => {
const previewContextMenuItemFeatureRegistry =
createPreviewContextMenuItemFeatureRegistry('preview-context-menu-items');
previewContextMenuItemFeatureRegistry.registerSome([
{
type: 'edit',
module: {
Component: EditAction,
},
},
{
type: 'add-before',
module: {
Component: AddBeforeAction,
},
},
{
type: 'add-after',
module: {
Component: AddAfterAction,
},
},
{
type: 'delete',
module: {
Component: DeleteAction,
},
},
]);
return previewContextMenuItemFeatureRegistry;
})();
@@ -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 SliceInfo } from '@coze-arch/bot-api/knowledge';
import { type Chunk } from '@/text-knowledge-editor/types/chunk';
export type DocumentChunk = Chunk & SliceInfo;
@@ -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 { UploadImageMenu } from '@/text-knowledge-editor/features/editor-actions/upload-image';
import {
createEditorActionFeatureRegistry,
type EditorActionRegistry,
} from '@/text-knowledge-editor/features/editor-actions/registry';
export const editorActionRegistry: EditorActionRegistry = (() => {
const editorActionFeatureRegistry =
createEditorActionFeatureRegistry('editor-actions');
editorActionFeatureRegistry.registerSome([
{
type: 'upload-image',
module: {
Component: UploadImageMenu,
},
},
]);
return editorActionFeatureRegistry;
})();
@@ -0,0 +1,79 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import { type LevelDocumentTreeNode } from '../../types/level-document';
export interface ActiveChunkInfo {
chunk: LevelDocumentTreeNode | null;
renderLevel: string | null;
}
/**
* Manage active chunks in documents
* Use the renderLevel field to uniquely identify the render location of the chunk
*/
export const useActiveChunk = () => {
// Store the active chunk and its renderLevel
const [activeChunkInfo, setActiveChunkInfo] = useState<ActiveChunkInfo>({
chunk: null,
renderLevel: null,
});
/**
* Clear active chunks
*/
const clearActiveChunk = () => {
setActiveChunkInfo({
chunk: null,
renderLevel: null,
});
};
/**
* Set the active chunk and its renderLevel
* Use during user interaction (e.g. double-clicking)
*/
const setActiveChunkWithLevel = (chunk: LevelDocumentTreeNode) => {
if (!chunk.renderLevel) {
console.warn('Chunk does not have renderLevel field', chunk);
return;
}
setActiveChunkInfo({
chunk,
renderLevel: chunk.renderLevel,
});
};
/**
* Checks whether the given chunk is the currently active chunk
*/
const isActiveChunk = (renderLevel: string | undefined) => {
if (!renderLevel) {
return false;
}
return renderLevel === activeChunkInfo.renderLevel;
};
return {
activeChunkInfo,
clearActiveChunk,
setActiveChunkWithLevel,
isActiveChunk,
};
};
@@ -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 { useState } from 'react';
import { type LevelDocumentChunk } from '../../types/level-document';
export interface ActiveChunkInfo {
chunk: LevelDocumentChunk | null;
renderPath: string | null;
}
/**
* Manage the rendering path for chunks with the same ID in a document
* Solve the problem of duplicate IDs by assigning a unique render path to each chunk instance
*/
export const useChunkRenderPath = () => {
// Store the active chunk and its render path
const [activeChunkInfo, setActiveChunkInfo] = useState<ActiveChunkInfo>({
chunk: null,
renderPath: null,
});
/**
* Set the active chunk, but not the render path
* Usually used in external logic, such as usePreviewContextMenu
*/
const setActiveChunk = (chunk: LevelDocumentChunk | null) => {
setActiveChunkInfo(prev => ({
...prev,
chunk,
}));
};
/**
* Clear active chunks
*/
const clearActiveChunk = () => {
setActiveChunkInfo({
chunk: null,
renderPath: null,
});
};
/**
* Set the active chunk and its rendering path
* Use during user interaction (e.g. double-clicking)
*/
const setActiveChunkWithPath = (
chunk: LevelDocumentChunk,
renderPath: string,
) => {
setActiveChunkInfo({
chunk,
renderPath,
});
};
/**
* Checks whether the given chunk and rendering path match the currently active chunk
*/
const isActiveChunk = (chunkId: string, renderPath: string) =>
chunkId === activeChunkInfo.chunk?.text_knowledge_editor_chunk_uuid &&
renderPath === activeChunkInfo.renderPath;
/**
* Generate a unique render path for chunks
*/
const generateRenderPath = (basePath: string, chunkId: string) =>
`${basePath}-${chunkId}`;
return {
activeChunkInfo,
setActiveChunk,
clearActiveChunk,
setActiveChunkWithPath,
isActiveChunk,
generateRenderPath,
};
};
@@ -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 { useScrollToSelection } from './use-scroll-to-selection';

Some files were not shown because too many files have changed in this diff Show More