feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 { ResourceTypeEnum } from './type';
|
||||
import { BaseResourceContextMenuBtnType } from './hooks/use-right-click-panel/constant';
|
||||
|
||||
const ROOT_KEY = '$-ROOT-$';
|
||||
|
||||
const RESOURCE_FOLDER_WRAPPER_CLASS = 'resource-list-right-click-wrapper';
|
||||
|
||||
const ROOT_NODE = {
|
||||
id: ROOT_KEY,
|
||||
type: ResourceTypeEnum.Folder,
|
||||
name: 'root',
|
||||
};
|
||||
|
||||
const ITEM_HEIGHT = 24;
|
||||
|
||||
const HALF_ICON_WIDTH = 5;
|
||||
|
||||
const TAB_SIZE = 8;
|
||||
|
||||
const MAX_DEEP = 5;
|
||||
|
||||
const RESOURCE_FOLDER_CONTEXT_KEY = 'resourceFolderContextKey';
|
||||
|
||||
/**
|
||||
* 乐观 ui 创建文件的 ID 默认前缀
|
||||
*/
|
||||
const OPTIMISM_ID_PREFIX = 'resource-folder-optimism-id-';
|
||||
|
||||
const MOUSEUP_IGNORE_CLASS_NAME = 'mouseup-ignore-class-name';
|
||||
|
||||
const MORE_TOOLS_CLASS_NAME = 'more-tools-class-name';
|
||||
|
||||
enum ItemStatus {
|
||||
Normal = 'normal',
|
||||
Disabled = 'disabled', // 禁止操作
|
||||
}
|
||||
|
||||
const COLOR_CONFIG = {
|
||||
selectedItemBgColor: 'rgba(6, 7, 9, 0.14)',
|
||||
tempSelectedItemBgColor: 'rgba(6, 7, 9, 0.04)',
|
||||
errorItemBgColor: 'rgba(255, 241, 242, 1)',
|
||||
|
||||
dragFolderHoverBgColor: 'rgba(148, 152, 247, 0.44)',
|
||||
|
||||
textErrorColor: 'rgba(var(--blockwise-error-color))',
|
||||
textWarningColor: 'rgba(var(--blockwise-warning-color))',
|
||||
textSelectedColor: 'rgba(6, 7, 9, 0.96)',
|
||||
textNormalColor: 'rgba(6, 7, 9, 0.5)',
|
||||
};
|
||||
|
||||
export {
|
||||
ROOT_KEY,
|
||||
ITEM_HEIGHT,
|
||||
HALF_ICON_WIDTH,
|
||||
TAB_SIZE,
|
||||
MAX_DEEP,
|
||||
ROOT_NODE,
|
||||
ItemStatus,
|
||||
COLOR_CONFIG,
|
||||
OPTIMISM_ID_PREFIX,
|
||||
BaseResourceContextMenuBtnType,
|
||||
RESOURCE_FOLDER_WRAPPER_CLASS,
|
||||
MOUSEUP_IGNORE_CLASS_NAME,
|
||||
RESOURCE_FOLDER_CONTEXT_KEY,
|
||||
MORE_TOOLS_CLASS_NAME,
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 { useStateRef } from './uss-state-ref';
|
||||
|
||||
const useCollapsedMap = ({ _collapsedMap, _setCollapsedMap, resourceMap }) => {
|
||||
const [collapsedMapRef, setCollapsedMap, collapsedState] = useStateRef(
|
||||
_collapsedMap || {},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (_collapsedMap) {
|
||||
setCollapsedMap(_collapsedMap);
|
||||
}
|
||||
}, [_collapsedMap]);
|
||||
|
||||
const setCollapsed = v => {
|
||||
_setCollapsedMap?.(v);
|
||||
if (!_collapsedMap) {
|
||||
setCollapsedMap(v);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollapse = (id, v) => {
|
||||
if (resourceMap.current?.[id]?.type === 'folder') {
|
||||
setCollapsed({
|
||||
...collapsedMapRef.current,
|
||||
[id]: v,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return { handleCollapse, collapsedMapRef, setCollapsed, collapsedState };
|
||||
};
|
||||
|
||||
export { useCollapsedMap };
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
|
||||
import { ContextKeyService, useIDEService } from '@coze-project-ide/client';
|
||||
|
||||
import { type ResourceFolderContextType } from '../type';
|
||||
import { RESOURCE_FOLDER_CONTEXT_KEY } from '../constant';
|
||||
|
||||
const useContextChange = (id: string) => {
|
||||
const contextRef = useRef<Partial<ResourceFolderContextType>>({
|
||||
id,
|
||||
});
|
||||
|
||||
const contextService = useIDEService<ContextKeyService>(ContextKeyService);
|
||||
|
||||
const setContext = dispatch => {
|
||||
contextService.setContext(RESOURCE_FOLDER_CONTEXT_KEY, dispatch);
|
||||
};
|
||||
|
||||
const getContext = (): Partial<ResourceFolderContextType> =>
|
||||
contextService.getContext(RESOURCE_FOLDER_CONTEXT_KEY);
|
||||
|
||||
const updateContext = (other: Partial<ResourceFolderContextType>) => {
|
||||
if (getContext()?.id === id) {
|
||||
contextRef.current = {
|
||||
...contextRef.current,
|
||||
...other,
|
||||
};
|
||||
setContext(contextRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const updateId = () => {
|
||||
setContext(contextRef.current);
|
||||
};
|
||||
|
||||
const clearContext = () => {
|
||||
if (getContext()?.id === id) {
|
||||
contextService.setContext(RESOURCE_FOLDER_CONTEXT_KEY, undefined);
|
||||
}
|
||||
contextRef.current = {
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
return { updateContext, clearContext, updateId };
|
||||
};
|
||||
|
||||
export { useContextChange };
|
||||
@@ -0,0 +1,401 @@
|
||||
/*
|
||||
* 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 max-lines-per-function */
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Toast } from '@coze-arch/coze-design';
|
||||
|
||||
import { baseValidateNames, getCreateResourceIndex } from '../../utils';
|
||||
import {
|
||||
type EditItemType,
|
||||
type ChangeNameType,
|
||||
type ResourceMapType,
|
||||
type ItemType,
|
||||
type IdType,
|
||||
type CreateResourcePropType,
|
||||
type ResourceType,
|
||||
ResourceTypeEnum,
|
||||
type ValidatorConfigType,
|
||||
type ConfigType,
|
||||
} from '../../type';
|
||||
import {
|
||||
ItemStatus,
|
||||
MAX_DEEP,
|
||||
BaseResourceContextMenuBtnType,
|
||||
ROOT_KEY,
|
||||
} from '../../constant';
|
||||
import { useCustomValidator } from './use-custom-validator';
|
||||
|
||||
const CREATE_RESOURCE_ID = '-1';
|
||||
|
||||
const useCreateEditResource = ({
|
||||
folderEnable,
|
||||
defaultResourceType,
|
||||
tempSelectedMapRef,
|
||||
registerEvent,
|
||||
setCollapsedMap,
|
||||
onChangeName,
|
||||
onCreate,
|
||||
resourceMap,
|
||||
selectedIdRef,
|
||||
resourceTreeRef,
|
||||
onDelete,
|
||||
validateConfig,
|
||||
config,
|
||||
resourceList,
|
||||
}: {
|
||||
folderEnable?: boolean;
|
||||
defaultResourceType?: string;
|
||||
registerEvent: (key: BaseResourceContextMenuBtnType, fn: (e) => void) => void;
|
||||
setCollapsedMap: (id: IdType, v: boolean) => void;
|
||||
resourceTreeRef: React.MutableRefObject<ResourceType>;
|
||||
tempSelectedMapRef: React.MutableRefObject<Record<string, ResourceType>>;
|
||||
disabled: React.MutableRefObject<boolean>;
|
||||
isFocusRef: React.MutableRefObject<boolean>;
|
||||
onChangeName?: (v: ChangeNameType) => void;
|
||||
resourceMap: React.MutableRefObject<ResourceMapType>;
|
||||
onCreate?: (v: CreateResourcePropType) => void;
|
||||
onDelete?: (v: ResourceType[]) => void;
|
||||
selectedIdRef: React.MutableRefObject<string>;
|
||||
validateConfig?: ValidatorConfigType;
|
||||
config?: ConfigType;
|
||||
resourceList: React.MutableRefObject<ResourceType[]>;
|
||||
}): {
|
||||
context: EditItemType;
|
||||
onCreateResource: (type: ItemType) => void;
|
||||
isInEditModeRef: React.MutableRefObject<boolean>;
|
||||
handleRenderList: (
|
||||
list: ResourceType[],
|
||||
createResourceInfo?: EditItemType['createResourceInfo'],
|
||||
) => (typeof CREATE_RESOURCE_ID | ResourceType)[];
|
||||
handleRename: (resourceId: IdType) => void;
|
||||
} => {
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const errorMsgRef = useRef('');
|
||||
|
||||
const [isInEditMode, setIsInEditMode] = useState(false);
|
||||
const isInEditModeRef = useRef(false);
|
||||
const setInEditMode = (v: boolean) => {
|
||||
isInEditModeRef.current = v;
|
||||
setIsInEditMode(v);
|
||||
};
|
||||
|
||||
const [editResource, setEditResource] = useState<ResourceType | null>(null);
|
||||
const editResourceRef = useRef<ResourceType | null>(null);
|
||||
|
||||
const [createResourceInfo, setCreateResourceInfo] = useState<{
|
||||
/**
|
||||
* 用于定位渲染位置的 index
|
||||
* 资源在文件夹后面,所有资源前面
|
||||
* 文件夹在当前文件夹下最前面
|
||||
*/
|
||||
index: number;
|
||||
parentId: IdType;
|
||||
type: ItemType;
|
||||
} | null>(null);
|
||||
const createResourceInfoRef = useRef<{
|
||||
parentId: IdType;
|
||||
type: ItemType;
|
||||
} | null>(null);
|
||||
|
||||
const preName = useRef<null | string>(null);
|
||||
const nextName = useRef<null | string>(null);
|
||||
|
||||
const reset = () => {
|
||||
setEditResource(null);
|
||||
editResourceRef.current = null;
|
||||
|
||||
setCreateResourceInfo(null);
|
||||
createResourceInfoRef.current = null;
|
||||
|
||||
setInEditMode(false);
|
||||
setErrorMsg('');
|
||||
errorMsgRef.current = '';
|
||||
preName.current = null;
|
||||
nextName.current = null;
|
||||
};
|
||||
|
||||
const handleRename = (resourceId: IdType) => {
|
||||
const target = resourceMap.current[resourceId];
|
||||
if (target && target.status !== ItemStatus.Disabled) {
|
||||
setEditResource(target);
|
||||
editResourceRef.current = target;
|
||||
setInEditMode(true);
|
||||
preName.current = target.name;
|
||||
nextName.current = target.name;
|
||||
}
|
||||
};
|
||||
|
||||
const onEditName = () => {
|
||||
const v = tempSelectedMapRef.current;
|
||||
if (Object.keys(v).filter(key => key !== ROOT_KEY).length === 1) {
|
||||
const tempSelected = Object.values(v)[0];
|
||||
handleRename(tempSelected.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = (_nextValue?: string) => {
|
||||
const nextValue =
|
||||
_nextValue === undefined ? nextName.current || '' : _nextValue;
|
||||
if (editResourceRef.current) {
|
||||
if (editResourceRef.current.id === CREATE_RESOURCE_ID) {
|
||||
if (nextValue !== '' && createResourceInfoRef.current?.parentId) {
|
||||
onCreate?.({
|
||||
parentId: createResourceInfoRef.current?.parentId,
|
||||
name: nextValue,
|
||||
type: createResourceInfoRef.current?.type,
|
||||
path:
|
||||
resourceMap.current?.[createResourceInfoRef.current?.parentId]
|
||||
?.path || [],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
nextValue !== '' &&
|
||||
nextValue !== preName.current &&
|
||||
editResourceRef.current.id
|
||||
) {
|
||||
onChangeName?.({
|
||||
id: editResourceRef.current.id,
|
||||
name: nextValue,
|
||||
type: editResourceRef.current.type,
|
||||
path: resourceMap.current[editResourceRef.current.id].path,
|
||||
resource: resourceMap.current[
|
||||
editResourceRef.current.id
|
||||
] as ResourceType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
reset();
|
||||
};
|
||||
|
||||
const updateErrorMsg = (error: string) => {
|
||||
if (error) {
|
||||
setErrorMsg(error);
|
||||
errorMsgRef.current = error;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同名检测
|
||||
*/
|
||||
// if (editResourceRef.current) {
|
||||
// const parentFolder = createResourceInfoRef.current
|
||||
// ? getResourceById(
|
||||
// resourceTreeRef.current,
|
||||
// createResourceInfoRef.current.parentId,
|
||||
// )?.resource
|
||||
// : getParentResource(resourceTreeRef.current, editResourceRef.current);
|
||||
|
||||
// if (parentFolder) {
|
||||
// const sameNameValidate = validateSameNameInFolder({
|
||||
// folder: parentFolder,
|
||||
// editResource: {
|
||||
// ...editResourceRef.current,
|
||||
// name: v,
|
||||
// },
|
||||
// });
|
||||
|
||||
// if (sameNameValidate) {
|
||||
// setErrorMsg(sameNameValidate);
|
||||
// errorMsgRef.current = sameNameValidate;
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
setErrorMsg('');
|
||||
errorMsgRef.current = '';
|
||||
};
|
||||
|
||||
const { validateAndUpdate } = useCustomValidator({
|
||||
validator: validateConfig?.customValidator || (baseValidateNames as any),
|
||||
callback: updateErrorMsg,
|
||||
});
|
||||
|
||||
const handleChangeName = (v: null | string) => {
|
||||
nextName.current = v;
|
||||
|
||||
if (createResourceInfoRef?.current) {
|
||||
/**
|
||||
* 新建资源
|
||||
*/
|
||||
const parentPath =
|
||||
resourceMap.current[createResourceInfoRef?.current?.parentId]?.path;
|
||||
|
||||
validateAndUpdate({
|
||||
type: 'create',
|
||||
label: v || '',
|
||||
parentPath: parentPath || [],
|
||||
resourceTree: resourceTreeRef.current,
|
||||
id: CREATE_RESOURCE_ID,
|
||||
});
|
||||
} else if (editResourceRef?.current) {
|
||||
/**
|
||||
* 编辑资源
|
||||
*/
|
||||
const path = editResourceRef?.current?.path || [];
|
||||
const parentPath = path.slice(0, path?.length - 1);
|
||||
|
||||
validateAndUpdate({
|
||||
type: 'edit',
|
||||
label: v || '',
|
||||
parentPath: parentPath || [],
|
||||
resourceTree: resourceTreeRef.current,
|
||||
id: editResourceRef.current.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateResource = async (_type: ItemType) => {
|
||||
const type = _type || defaultResourceType;
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!folderEnable && type === ResourceTypeEnum.Folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
let selectResource = Object.values(tempSelectedMapRef.current || {})[0];
|
||||
|
||||
if (!selectResource) {
|
||||
selectResource = resourceMap.current[selectedIdRef.current];
|
||||
}
|
||||
|
||||
if (!selectResource) {
|
||||
selectResource = resourceMap.current[ROOT_KEY];
|
||||
}
|
||||
|
||||
if (selectResource.type !== 'folder' && selectResource?.path) {
|
||||
// 如果不是 folder 则选中父亲 folder
|
||||
selectResource =
|
||||
resourceMap.current[
|
||||
selectResource.path[selectResource.path.length - 2]
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
(selectResource?.path?.length || 0) +
|
||||
(type === ResourceTypeEnum.Folder ? 1 : 0) >
|
||||
(config?.maxDeep || MAX_DEEP)
|
||||
) {
|
||||
Toast.warning(`Can't create ${type}. MaxDeep is 5`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectResource) {
|
||||
let parentId = selectResource.id;
|
||||
if (selectResource.type !== 'folder') {
|
||||
parentId = selectResource.path?.[selectResource.path?.length - 2] || '';
|
||||
}
|
||||
|
||||
preName.current = '';
|
||||
nextName.current = '';
|
||||
|
||||
setCollapsedMap(parentId, false);
|
||||
|
||||
await new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve(null);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
editResourceRef.current = {
|
||||
id: CREATE_RESOURCE_ID,
|
||||
name: '',
|
||||
type,
|
||||
};
|
||||
setEditResource(editResourceRef.current);
|
||||
createResourceInfoRef.current = {
|
||||
parentId,
|
||||
type,
|
||||
};
|
||||
const index = getCreateResourceIndex({
|
||||
resourceList: resourceList.current,
|
||||
parentId,
|
||||
type,
|
||||
});
|
||||
setCreateResourceInfo({
|
||||
parentId,
|
||||
type,
|
||||
index,
|
||||
});
|
||||
setInEditMode(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
registerEvent(BaseResourceContextMenuBtnType.EditName, () => {
|
||||
if (!editResourceRef.current) {
|
||||
onEditName();
|
||||
}
|
||||
});
|
||||
registerEvent(BaseResourceContextMenuBtnType.Delete, () => {
|
||||
const selectedResource = Object.values(tempSelectedMapRef.current).filter(
|
||||
v => v.id !== ROOT_KEY,
|
||||
);
|
||||
if (selectedResource.length > 0) {
|
||||
onDelete?.(selectedResource);
|
||||
}
|
||||
});
|
||||
registerEvent(BaseResourceContextMenuBtnType.CreateFolder, () => {
|
||||
onCreateResource(ResourceTypeEnum.Folder);
|
||||
});
|
||||
registerEvent(BaseResourceContextMenuBtnType.CreateResource, type => {
|
||||
onCreateResource(type);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRenderList = (
|
||||
list: ResourceType[],
|
||||
info?: EditItemType['createResourceInfo'],
|
||||
) => {
|
||||
if (!info) {
|
||||
return list as (typeof CREATE_RESOURCE_ID | ResourceType)[];
|
||||
}
|
||||
const { index } = info;
|
||||
return [
|
||||
...list.slice(0, index),
|
||||
CREATE_RESOURCE_ID,
|
||||
...list.slice(index, list.length),
|
||||
] as (typeof CREATE_RESOURCE_ID | ResourceType)[];
|
||||
};
|
||||
|
||||
return {
|
||||
context: {
|
||||
isInEditMode,
|
||||
editResourceId: editResource?.id,
|
||||
createResourceInfo,
|
||||
handleChangeName,
|
||||
errorMsg,
|
||||
errorMsgRef,
|
||||
handleSave,
|
||||
},
|
||||
onCreateResource,
|
||||
isInEditModeRef,
|
||||
handleRenderList,
|
||||
handleRename,
|
||||
};
|
||||
};
|
||||
|
||||
export { useCreateEditResource, CREATE_RESOURCE_ID };
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
class PromiseController<T, O> {
|
||||
private lastVersion: number;
|
||||
private callbacks: Array<(v: O) => void> = [];
|
||||
private mainFunction: (v: T) => Promise<O>;
|
||||
|
||||
constructor() {
|
||||
this.lastVersion = 0;
|
||||
}
|
||||
|
||||
registerPromiseFn(fn: (v: T) => Promise<O>) {
|
||||
this.mainFunction = fn;
|
||||
return this;
|
||||
}
|
||||
|
||||
registerCallbackFb(cb: (v: O) => void) {
|
||||
this.callbacks.push(cb);
|
||||
return this;
|
||||
}
|
||||
|
||||
async excute(v: T) {
|
||||
if (!this.mainFunction) {
|
||||
return;
|
||||
}
|
||||
this.lastVersion += 1;
|
||||
const currentVersion = this.lastVersion;
|
||||
const res = await this.mainFunction(v);
|
||||
if (this.lastVersion === currentVersion) {
|
||||
this.callbacks.forEach(cb => cb(res));
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.callbacks = [];
|
||||
}
|
||||
}
|
||||
|
||||
export { PromiseController };
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
|
||||
import { type CustomValidatorPropsType } from '../../type';
|
||||
import { PromiseController } from './promise-controller';
|
||||
|
||||
const useCustomValidator = ({
|
||||
validator,
|
||||
callback,
|
||||
}: {
|
||||
validator: (data: CustomValidatorPropsType) => Promise<string>;
|
||||
callback: (label: string) => void;
|
||||
}) => {
|
||||
const promiseController = useRef(
|
||||
new PromiseController<CustomValidatorPropsType, string>(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
promiseController.current
|
||||
.registerPromiseFn(validator)
|
||||
.registerCallbackFb(callback);
|
||||
return () => {
|
||||
promiseController.current?.dispose?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const validateAndUpdate = (data: CustomValidatorPropsType) => {
|
||||
promiseController.current?.excute(data);
|
||||
};
|
||||
|
||||
return {
|
||||
validateAndUpdate,
|
||||
};
|
||||
};
|
||||
|
||||
export { useCustomValidator };
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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, useRef } from 'react';
|
||||
|
||||
enum EventKey {
|
||||
MouseDown = 'mouseDown',
|
||||
MouseDownInDiv = 'mouseDownInDiv',
|
||||
MouseUpInDiv = 'mouseUpInDiv',
|
||||
MouseUp = 'mouseUp',
|
||||
MouseMove = 'mouseMove',
|
||||
KeyDown = 'keyDown',
|
||||
}
|
||||
|
||||
const useEvent = () => {
|
||||
const mouseDownRef = useRef<Array<(e) => void>>([]);
|
||||
const mouseDownInDivRef = useRef<Array<(e) => void>>([]);
|
||||
const mouseUpInDivRef = useRef<Array<(e) => void>>([]);
|
||||
const mouseUpRef = useRef<Array<(e) => void>>([]);
|
||||
const mouseMoveRef = useRef<Array<(e) => void>>([]);
|
||||
const keyDownRef = useRef<Array<(e) => void>>([]);
|
||||
|
||||
const addEventListener = (key: EventKey, fn: (e) => void) => {
|
||||
if (key === EventKey.KeyDown) {
|
||||
keyDownRef.current.push(fn);
|
||||
} else if (key === EventKey.MouseDownInDiv) {
|
||||
mouseDownInDivRef.current.push(fn);
|
||||
} else if (key === EventKey.MouseUpInDiv) {
|
||||
mouseUpInDivRef.current.push(fn);
|
||||
} else if (key === EventKey.MouseDown) {
|
||||
mouseDownRef.current.push(fn);
|
||||
} else if (key === EventKey.MouseUp) {
|
||||
mouseUpRef.current.push(fn);
|
||||
} else if (key === EventKey.MouseMove) {
|
||||
mouseMoveRef.current.push(fn);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseDown = e => {
|
||||
mouseDownRef.current.forEach(fn => {
|
||||
fn(e);
|
||||
});
|
||||
};
|
||||
const onMouseDownInDiv = e => {
|
||||
mouseDownInDivRef.current.forEach(fn => {
|
||||
fn(e);
|
||||
});
|
||||
};
|
||||
const onMouseUpInDiv = e => {
|
||||
mouseUpInDivRef.current.forEach(fn => {
|
||||
fn(e);
|
||||
});
|
||||
};
|
||||
const onMouseUp = e => {
|
||||
mouseUpRef.current.forEach(fn => {
|
||||
fn(e);
|
||||
});
|
||||
};
|
||||
const onKeyDown = e => {
|
||||
keyDownRef.current.forEach(fn => {
|
||||
fn(e);
|
||||
});
|
||||
};
|
||||
const onMouseMove = e => {
|
||||
mouseMoveRef.current.forEach(fn => {
|
||||
fn(e);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mousedown', onMouseDown);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', onMouseDown);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const customEventBox = ({ children }) => (
|
||||
<div
|
||||
className={'resource-list-custom-event-wrapper'}
|
||||
onMouseDownCapture={onMouseDownInDiv}
|
||||
onMouseUp={onMouseUpInDiv}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
return { addEventListener, customEventBox, onMouseDownInDiv, onMouseUpInDiv };
|
||||
};
|
||||
|
||||
export { useEvent, EventKey };
|
||||
@@ -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 { useRef } from 'react';
|
||||
|
||||
import { calcOffsetTopByCollapsedMap } from '../../utils';
|
||||
import {
|
||||
type ResourceType,
|
||||
type ConfigType,
|
||||
type ResourceMapType,
|
||||
} from '../../type';
|
||||
import { ITEM_HEIGHT } from '../../constant';
|
||||
|
||||
const useFocusResource = ({
|
||||
resourceTreeRef,
|
||||
collapsedMapRef,
|
||||
resourceMap,
|
||||
config,
|
||||
}: {
|
||||
config?: ConfigType;
|
||||
resourceTreeRef: React.MutableRefObject<ResourceType>;
|
||||
collapsedMapRef: React.MutableRefObject<Record<string, boolean>>;
|
||||
resourceMap: React.MutableRefObject<ResourceMapType>;
|
||||
}) => {
|
||||
const scrollWrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollEnable = useRef(true);
|
||||
|
||||
const scrollInView = (selectedId: string) => {
|
||||
if (
|
||||
!scrollWrapper.current ||
|
||||
!scrollEnable.current ||
|
||||
!selectedId ||
|
||||
!resourceTreeRef.current ||
|
||||
!resourceMap?.current?.[selectedId]
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollTop = calcOffsetTopByCollapsedMap({
|
||||
selectedId,
|
||||
resourceTree: resourceTreeRef.current,
|
||||
collapsedMap: collapsedMapRef.current,
|
||||
itemHeight: config?.itemHeight || ITEM_HEIGHT,
|
||||
});
|
||||
|
||||
// 如果在视图内, 则不滚
|
||||
if (
|
||||
scrollTop > scrollWrapper.current.scrollTop &&
|
||||
scrollTop <
|
||||
scrollWrapper.current.offsetHeight + scrollWrapper.current.scrollTop
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollWrapper.current.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
const tempDisableScroll = (t?: number) => {
|
||||
scrollEnable.current = false;
|
||||
|
||||
setTimeout(() => {
|
||||
scrollEnable.current = true;
|
||||
}, t || 300);
|
||||
};
|
||||
|
||||
return {
|
||||
scrollInView,
|
||||
scrollWrapper,
|
||||
tempDisableScroll,
|
||||
};
|
||||
};
|
||||
export { useFocusResource };
|
||||
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable complexity */
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
/* eslint-disable max-lines-per-function */
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { DragService, type URI, useIDEService } from '@coze-project-ide/client';
|
||||
|
||||
import { EventKey } from '../use-custom-event';
|
||||
import { flatTree, getResourceListFromIdToId } from '../../utils';
|
||||
import {
|
||||
type DragAndDropType,
|
||||
type ResourceType,
|
||||
type DragPropType,
|
||||
type ResourceMapType,
|
||||
type IdType,
|
||||
type CommonRenderProps,
|
||||
ResourceTypeEnum,
|
||||
type ConfigType,
|
||||
} from '../../type';
|
||||
import { MORE_TOOLS_CLASS_NAME, ROOT_KEY, ROOT_NODE } from '../../constant';
|
||||
import {
|
||||
CLICK_OUTSIDE,
|
||||
CLICK_TOOL_BAR,
|
||||
type MousePosition,
|
||||
DATASET_PARENT_DATA_KEY_ID,
|
||||
findTargetElement,
|
||||
canStartDrag,
|
||||
getFolderIdFromPath,
|
||||
CLICK_CONTEXT_MENU,
|
||||
validateDrag,
|
||||
getElementByXY,
|
||||
DATASET_RESOURCE_FOLDER_KEY,
|
||||
} from './utils';
|
||||
import { useDragUI } from './use-drag-ui';
|
||||
|
||||
const useMouseEvent = ({
|
||||
draggable,
|
||||
uniqId,
|
||||
updateId,
|
||||
setTempSelectedMap,
|
||||
collapsedMapRef,
|
||||
setCollapsedMap,
|
||||
resourceTreeRef,
|
||||
onDrag,
|
||||
disabled,
|
||||
resourceMap,
|
||||
addEventListener,
|
||||
selectedIdRef,
|
||||
onSelected,
|
||||
tempSelectedMapRef,
|
||||
resourceTreeWrapperRef,
|
||||
iconRender,
|
||||
config,
|
||||
}: {
|
||||
draggable: boolean; // 是否可以拖拽
|
||||
uniqId: string;
|
||||
updateId: () => void;
|
||||
resourceTreeWrapperRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
setTempSelectedMap: (v: Record<string, ResourceType>) => void;
|
||||
collapsedMapRef: React.MutableRefObject<Record<string, boolean>>;
|
||||
setCollapsedMap: (id: IdType, v: boolean) => void;
|
||||
resourceTreeRef: React.MutableRefObject<ResourceType>;
|
||||
tempSelectedMapRef: React.MutableRefObject<Record<string, ResourceType>>;
|
||||
disabled?: React.MutableRefObject<boolean>;
|
||||
selectedIdRef?: React.MutableRefObject<string>;
|
||||
resourceMap: React.MutableRefObject<ResourceMapType>;
|
||||
onDrag: (v: DragPropType) => void;
|
||||
onSelected?: (id: string | number, resource: ResourceType) => void;
|
||||
addEventListener: (key: EventKey, fn: (e) => void) => void;
|
||||
iconRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
config?: ConfigType;
|
||||
}): {
|
||||
context: DragAndDropType;
|
||||
isFocusRef: React.MutableRefObject<boolean>;
|
||||
dragPreview: React.ReactElement;
|
||||
onMouseMove;
|
||||
handleFocus: () => void;
|
||||
handleBlur: () => void;
|
||||
} => {
|
||||
const isMouseDownRef = useRef<MousePosition | null>(null);
|
||||
const [isFocus, setIsFocus] = useState(false);
|
||||
const isFocusRef = useRef(false);
|
||||
const dragService = useIDEService<DragService>(DragService);
|
||||
/**
|
||||
* 存储拖拽过程中是否合法的字段
|
||||
*/
|
||||
const [draggingError, setDraggingError] = useState<string>('');
|
||||
|
||||
const multiModeFirstSelected = useRef<ResourceType | null>(null);
|
||||
|
||||
const { isDragging, isDraggingRef, handleDrag, dragPreview } = useDragUI({
|
||||
iconRender,
|
||||
selectedMap: tempSelectedMapRef.current,
|
||||
addEventListener,
|
||||
config,
|
||||
});
|
||||
|
||||
/**
|
||||
* 用于开启拖拽到 mainPanel 下打开资源的方法
|
||||
*/
|
||||
const dragAndOpenResource = e => {
|
||||
if (!config?.resourceUriHandler) {
|
||||
return;
|
||||
}
|
||||
const uris = Object.values(tempSelectedMapRef.current)
|
||||
.filter(resource => resource.type !== ResourceTypeEnum.Folder)
|
||||
.map(resource => config.resourceUriHandler?.(resource))
|
||||
.filter(Boolean);
|
||||
|
||||
if (!uris.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragService?.startDrag?.({
|
||||
uris: uris as URI[],
|
||||
position: {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
},
|
||||
callback: v => {
|
||||
//
|
||||
},
|
||||
backdropTransform: {
|
||||
/**
|
||||
* 通过边缘检测的方式,阻止 lm-cursor-backdrop 元素进入树组件内
|
||||
*/
|
||||
clientX: (eventX: number) =>
|
||||
Math.max(
|
||||
eventX,
|
||||
(resourceTreeWrapperRef.current?.clientWidth || 0) + 100,
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const startDrag = e => {
|
||||
if (draggable) {
|
||||
dragAndOpenResource(e);
|
||||
setDraggingError('');
|
||||
handleDrag(true);
|
||||
}
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
setDraggingError('');
|
||||
handleDrag(false);
|
||||
};
|
||||
|
||||
const changeTempSelectedMap = (v: Record<string, ResourceType>) => {
|
||||
const values = Object.values(v);
|
||||
if (values.length === 1) {
|
||||
multiModeFirstSelected.current = values[0];
|
||||
} else if (values.length === 0) {
|
||||
multiModeFirstSelected.current = null;
|
||||
}
|
||||
tempSelectedMapRef.current = v;
|
||||
setTempSelectedMap(v);
|
||||
};
|
||||
|
||||
const [currentHoverItem, setCurrentHoverItem] = useState<ResourceType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* 用于记录拖拽过程中,当前 hover 元素的 父元素 id。
|
||||
*/
|
||||
const hoverItemParentId = useRef<string | null>(null);
|
||||
/**
|
||||
* 用于记录拖拽过程中,当前 hover 元素的 父元素及其下钻所有节点的id表
|
||||
*/
|
||||
const [highlightItemMap, setHighlightItemMap] = useState<ResourceMapType>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentHoverItem?.id) {
|
||||
const parentId = getFolderIdFromPath(currentHoverItem);
|
||||
if (parentId !== hoverItemParentId.current) {
|
||||
const treeList = resourceMap.current[parentId]
|
||||
? flatTree(
|
||||
resourceMap.current[parentId],
|
||||
resourceMap.current,
|
||||
collapsedMapRef.current,
|
||||
)
|
||||
: [];
|
||||
|
||||
setHighlightItemMap(
|
||||
treeList.reduce(
|
||||
(pre, cur, index) => ({
|
||||
...pre,
|
||||
[cur.id]: {
|
||||
...cur,
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
hoverItemParentId.current = parentId;
|
||||
}
|
||||
} else {
|
||||
hoverItemParentId.current = null;
|
||||
setHighlightItemMap({});
|
||||
}
|
||||
}, [currentHoverItem?.id]);
|
||||
|
||||
const handleFocus = () => {
|
||||
updateId();
|
||||
setIsFocus(true);
|
||||
isFocusRef.current = true;
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
changeTempSelectedMap({});
|
||||
setIsFocus(false);
|
||||
isFocusRef.current = false;
|
||||
};
|
||||
|
||||
const getCurrentDragResourceList = () => {
|
||||
let resourceList = Object.values(tempSelectedMapRef.current).filter(
|
||||
item => item.id !== ROOT_KEY,
|
||||
);
|
||||
|
||||
/**
|
||||
* 将文件夹下的文件给过滤掉,防止拖拽之后失去层级结构
|
||||
*/
|
||||
resourceList = resourceList.filter(resource => {
|
||||
const { type, path } = resourceMap.current[resource.id];
|
||||
if (type !== ResourceTypeEnum.Folder) {
|
||||
const resourcePath = path || [];
|
||||
return !(resourcePath || [])
|
||||
.slice(0, resourcePath.length - 1)
|
||||
.some(id => id !== ROOT_KEY && tempSelectedMapRef.current[id]);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return resourceList;
|
||||
};
|
||||
|
||||
const onMouseDownInDiv = e => {
|
||||
if (disabled?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = findTargetElement(e.target, uniqId);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleFocus();
|
||||
|
||||
if (typeof target === 'object' && e.button === 0) {
|
||||
isMouseDownRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
};
|
||||
}
|
||||
|
||||
// 这里要选中一次是保证拖动前选中正确的 item
|
||||
if (typeof target === 'object' && !e.shiftKey && !e.metaKey) {
|
||||
let currentSelected: ResourceType | null = null;
|
||||
|
||||
if (target.id === ROOT_KEY) {
|
||||
currentSelected = ROOT_NODE;
|
||||
} else {
|
||||
currentSelected = resourceMap?.current?.[String(target?.id)] || {};
|
||||
}
|
||||
|
||||
if (
|
||||
currentSelected?.id &&
|
||||
!tempSelectedMapRef.current?.[currentSelected.id]
|
||||
) {
|
||||
changeTempSelectedMap({ [currentSelected.id]: currentSelected });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseDown = e => {
|
||||
if (disabled?.current || !isFocusRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = findTargetElement(e.target, uniqId);
|
||||
|
||||
stopDrag();
|
||||
|
||||
if (!target || target === CLICK_OUTSIDE) {
|
||||
handleBlur();
|
||||
return;
|
||||
}
|
||||
|
||||
// 点到右键的 panel 中 啥也别操作
|
||||
if (target === CLICK_CONTEXT_MENU) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUpInDiv = e => {
|
||||
if (disabled?.current || isDraggingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = findTargetElement(e.target, uniqId, MORE_TOOLS_CLASS_NAME);
|
||||
|
||||
if (typeof target === 'object' && target !== null) {
|
||||
let currentSelected: ResourceType | null = null;
|
||||
|
||||
if (target.id === ROOT_KEY) {
|
||||
currentSelected = ROOT_NODE;
|
||||
} else {
|
||||
currentSelected = resourceMap?.current?.[String(target?.id)] || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果点击了 more tools 三颗点,那需要先将临时选中表重置为只选中该 item。 用于右键菜单的消费。
|
||||
*/
|
||||
if (target.customTag === MORE_TOOLS_CLASS_NAME) {
|
||||
const nextSelected = { [currentSelected.id]: currentSelected };
|
||||
changeTempSelectedMap(nextSelected);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果右键, 并且点击在已经被选中的资源上,则不再做操作,因为要弹操作栏
|
||||
if (
|
||||
e.ctrlKey ||
|
||||
(e.button === 2 &&
|
||||
currentSelected?.id &&
|
||||
tempSelectedMapRef.current?.[currentSelected.id])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
const firstSelectedId =
|
||||
multiModeFirstSelected.current?.id || selectedIdRef?.current;
|
||||
if (
|
||||
!firstSelectedId ||
|
||||
!currentSelected?.id ||
|
||||
firstSelectedId === currentSelected?.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量一下子多选
|
||||
let nextSelected: any = getResourceListFromIdToId({
|
||||
resourceTree: resourceTreeRef.current,
|
||||
from: firstSelectedId,
|
||||
to: currentSelected.id,
|
||||
options: { collapsedMap: collapsedMapRef.current },
|
||||
});
|
||||
|
||||
nextSelected = (nextSelected || []).reduce((prev, next) => {
|
||||
const id = typeof next === 'object' ? String(next.id) : String(next);
|
||||
return {
|
||||
...prev,
|
||||
[id]: resourceMap?.current[id],
|
||||
} as any;
|
||||
}, {});
|
||||
|
||||
if (nextSelected[ROOT_KEY]) {
|
||||
delete nextSelected[ROOT_KEY];
|
||||
}
|
||||
changeTempSelectedMap(nextSelected);
|
||||
} else if (e.metaKey) {
|
||||
// 挑着多选
|
||||
let nextSelected = { ...tempSelectedMapRef.current };
|
||||
if (currentSelected?.id) {
|
||||
if (nextSelected[currentSelected.id]) {
|
||||
delete nextSelected[currentSelected.id];
|
||||
} else {
|
||||
nextSelected = {
|
||||
...nextSelected,
|
||||
[currentSelected.id]: currentSelected,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (nextSelected[ROOT_KEY]) {
|
||||
delete nextSelected[ROOT_KEY];
|
||||
}
|
||||
changeTempSelectedMap(nextSelected);
|
||||
} else {
|
||||
if (currentSelected?.type && currentSelected.type !== 'folder') {
|
||||
onSelected?.(currentSelected.id, currentSelected as any);
|
||||
}
|
||||
if (currentSelected?.id) {
|
||||
changeTempSelectedMap({ [currentSelected.id]: currentSelected });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isDraggingRef.current ||
|
||||
!Object.keys(tempSelectedMapRef.current).length
|
||||
) {
|
||||
if (
|
||||
typeof target === 'object' &&
|
||||
target !== null &&
|
||||
target.id !== ROOT_KEY
|
||||
) {
|
||||
if (e.button === 0 && !e.shiftKey && !e.metaKey) {
|
||||
setCollapsedMap(target.id, !collapsedMapRef.current[target.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = e => {
|
||||
if (disabled?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMouseDownRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectMapValue = Object.values(tempSelectedMapRef.current);
|
||||
|
||||
if (selectMapValue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectMapValue.length === 1 && selectMapValue[0].id === ROOT_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isStartDrag =
|
||||
isDraggingRef.current ||
|
||||
canStartDrag(isMouseDownRef.current, {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
|
||||
if (!isStartDrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始拖拽
|
||||
*/
|
||||
if (!isDraggingRef.current) {
|
||||
startDrag(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resourceTreeWrapperRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = getElementByXY({
|
||||
e,
|
||||
wrapperElm: resourceTreeWrapperRef.current,
|
||||
uniqId,
|
||||
});
|
||||
|
||||
// 点到右键的 panel 中 啥也别操作
|
||||
if (target === CLICK_CONTEXT_MENU) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
setCurrentHoverItem(null);
|
||||
return;
|
||||
}
|
||||
if (target === CLICK_OUTSIDE || target === CLICK_TOOL_BAR) {
|
||||
setCurrentHoverItem(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof target === 'object' && target?.id !== currentHoverItem?.id) {
|
||||
if (target?.id === ROOT_KEY) {
|
||||
setCurrentHoverItem(ROOT_NODE);
|
||||
} else {
|
||||
setCurrentHoverItem(resourceMap.current[String(target?.id)]);
|
||||
const toId = getFolderIdFromPath(resourceMap.current[target.id]);
|
||||
const resourceList = getCurrentDragResourceList();
|
||||
const error = validateDrag(
|
||||
resourceList,
|
||||
resourceMap.current[toId],
|
||||
config,
|
||||
);
|
||||
setDraggingError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = e => {
|
||||
if (disabled?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isDraggingRef.current &&
|
||||
Object.keys(tempSelectedMapRef.current).length &&
|
||||
resourceTreeWrapperRef.current
|
||||
) {
|
||||
const target = getElementByXY({
|
||||
e,
|
||||
wrapperElm: resourceTreeWrapperRef.current,
|
||||
uniqId,
|
||||
});
|
||||
|
||||
// 点到右键的 panel 中 啥也别操作
|
||||
if (target === CLICK_CONTEXT_MENU || !target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof target === 'object') {
|
||||
const resourceList = getCurrentDragResourceList();
|
||||
|
||||
const toId = getFolderIdFromPath(resourceMap.current[target.id]);
|
||||
|
||||
const error = validateDrag(
|
||||
resourceList,
|
||||
resourceMap.current[toId],
|
||||
config,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
onDrag?.({ errorMsg: error });
|
||||
} else {
|
||||
onDrag?.({
|
||||
resourceList,
|
||||
toId,
|
||||
});
|
||||
setCollapsedMap(toId, false);
|
||||
}
|
||||
}
|
||||
|
||||
stopDrag();
|
||||
}
|
||||
|
||||
isMouseDownRef.current = null;
|
||||
|
||||
setCurrentHoverItem(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
addEventListener(EventKey.MouseDown, onMouseDown);
|
||||
addEventListener(EventKey.MouseDownInDiv, onMouseDownInDiv);
|
||||
addEventListener(EventKey.MouseUpInDiv, onMouseUpInDiv);
|
||||
addEventListener(EventKey.MouseUp, onMouseUp);
|
||||
}, []);
|
||||
|
||||
const dataHandler = (resource: ResourceType) => ({
|
||||
[`data-${DATASET_PARENT_DATA_KEY_ID}`]: resource.id,
|
||||
[`data-${DATASET_RESOURCE_FOLDER_KEY}`]: uniqId,
|
||||
});
|
||||
|
||||
return {
|
||||
context: {
|
||||
isDragging,
|
||||
draggingError,
|
||||
isFocus,
|
||||
dataHandler,
|
||||
tempSelectedMapRef,
|
||||
currentHoverItem,
|
||||
highlightItemMap,
|
||||
},
|
||||
isFocusRef,
|
||||
onMouseMove,
|
||||
dragPreview,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
};
|
||||
};
|
||||
|
||||
export { useMouseEvent };
|
||||
@@ -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 React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { EventKey } from '../use-custom-event';
|
||||
import {
|
||||
type ConfigType,
|
||||
type CommonRenderProps,
|
||||
type ResourceType,
|
||||
} from '../../type';
|
||||
|
||||
export const useDragUI = ({
|
||||
iconRender,
|
||||
selectedMap,
|
||||
addEventListener,
|
||||
config,
|
||||
}: {
|
||||
iconRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
selectedMap: Record<string, ResourceType>;
|
||||
addEventListener: (key: EventKey, fn: (e) => void) => void;
|
||||
config?: ConfigType;
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const [mousePosition, setMousePosition] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleDrag = (v: boolean) => {
|
||||
isDraggingRef.current = v;
|
||||
setIsDragging(v);
|
||||
|
||||
setMousePosition(null);
|
||||
if (v) {
|
||||
document.body.style.cursor = 'grabbing';
|
||||
} else {
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = e => {
|
||||
if (isDraggingRef.current) {
|
||||
setMousePosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
addEventListener(EventKey.MouseMove, handleMouseMove);
|
||||
}, []);
|
||||
|
||||
const dragPreview =
|
||||
mousePosition && !config?.dragUi?.disable ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 99999,
|
||||
top: 5,
|
||||
left: 5,
|
||||
display: isDragging && mousePosition?.x ? 'block' : 'none',
|
||||
transform: `translate(${mousePosition?.x || 0}px, ${
|
||||
mousePosition?.y || 0
|
||||
}px)`,
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: 'rgba(6, 7, 9, 0.08)',
|
||||
borderRadius: 6,
|
||||
padding: '2px 4px',
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
...(config?.dragUi?.wrapperStyle || {}),
|
||||
}}
|
||||
className={config?.dragUi?.wrapperClassName || ''}
|
||||
>
|
||||
{Object.values(selectedMap).length > 1 ? (
|
||||
<>{Object.values(selectedMap).length}</>
|
||||
) : (
|
||||
Object.values(selectedMap).map(item => (
|
||||
<div
|
||||
key={item.name}
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{item?.type ? (
|
||||
<span style={{ marginRight: 4 }}>
|
||||
{iconRender?.({
|
||||
resource: item,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
|
||||
return { handleDrag, isDragging, isDraggingRef, dragPreview };
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable complexity */
|
||||
import { type ConfigType, type IdType, type ResourceType } from '../../type';
|
||||
import { MAX_DEEP, ROOT_KEY, ROOT_NODE } from '../../constant';
|
||||
|
||||
const DATASET_RESOURCE_FOLDER_KEY = 'resource_folder_key';
|
||||
const DATASET_PARENT_DATA_STOP_TAG = 'resource_folder_drag_and_drop_stop_tag';
|
||||
const DATASET_PARENT_DATA_KEY_ID = 'resource_folder_drag_and_drop_id';
|
||||
|
||||
const TOOL_BAR_CLASS_NAME = 'resource_folder_tool_bar_class_name';
|
||||
const CLICK_TOOL_BAR = 'click_tool_bar';
|
||||
|
||||
const CLICK_OUTSIDE = 'click_outside';
|
||||
|
||||
const CLICK_CONTEXT_MENU = 'click_context_menu';
|
||||
|
||||
const PATH_SPLIT_KEY = '-$$-';
|
||||
|
||||
const START_DRAG_GAP = 10;
|
||||
|
||||
export interface MousePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const isPointInRect = (
|
||||
point: { x: number; y: number },
|
||||
rect: { x: number; y: number; width: number; height: number },
|
||||
) =>
|
||||
point.x > rect.x &&
|
||||
point.x < rect.x + rect.width &&
|
||||
point.y > rect.y &&
|
||||
point.y < rect.y + rect.height;
|
||||
|
||||
const BORDER_GAP = 8; // 默认边缘阈值
|
||||
/**
|
||||
* 相对于 findTargetElement ,增加了边缘检测功能。
|
||||
* 当鼠标在 资源目录边缘, 会算作聚焦在 root 节点
|
||||
*/
|
||||
const getElementByXY = ({
|
||||
e,
|
||||
wrapperElm,
|
||||
uniqId,
|
||||
}: {
|
||||
e: MouseEvent;
|
||||
wrapperElm: HTMLDivElement;
|
||||
uniqId: string;
|
||||
}) => {
|
||||
const { pageX, pageY } = e;
|
||||
const { x, y, width, height } = wrapperElm.getBoundingClientRect();
|
||||
|
||||
if (
|
||||
isPointInRect({ x: pageX, y: pageY }, { x, y, width, height }) &&
|
||||
!isPointInRect(
|
||||
{ x: pageX, y: pageY },
|
||||
{
|
||||
x: x + BORDER_GAP,
|
||||
y: y + BORDER_GAP,
|
||||
width: width - BORDER_GAP * 2,
|
||||
height: height - BORDER_GAP * 2,
|
||||
},
|
||||
)
|
||||
) {
|
||||
return ROOT_NODE;
|
||||
}
|
||||
|
||||
return findTargetElement(e.target as HTMLElement, uniqId);
|
||||
};
|
||||
|
||||
type TargetType =
|
||||
| {
|
||||
id: IdType;
|
||||
customTag?: string;
|
||||
}
|
||||
| null
|
||||
| typeof CLICK_OUTSIDE
|
||||
| typeof CLICK_CONTEXT_MENU
|
||||
| typeof CLICK_TOOL_BAR;
|
||||
|
||||
const findTargetElement = (
|
||||
elm: HTMLElement | null,
|
||||
uniqueId: string,
|
||||
/**
|
||||
* 遇到该 className 会进行记录,并且返回 id 的时候会带上该 className
|
||||
*/
|
||||
customClassName?: string,
|
||||
): TargetType | string => {
|
||||
const extraProps: {
|
||||
customTag?: string;
|
||||
} = {};
|
||||
|
||||
if (customClassName && elm?.classList?.contains?.(customClassName)) {
|
||||
extraProps.customTag = customClassName;
|
||||
}
|
||||
if (!elm) {
|
||||
return CLICK_OUTSIDE;
|
||||
}
|
||||
|
||||
if (
|
||||
elm.dataset?.[DATASET_PARENT_DATA_KEY_ID] !== undefined &&
|
||||
elm.dataset?.[DATASET_RESOURCE_FOLDER_KEY] === uniqueId
|
||||
) {
|
||||
return {
|
||||
id: elm.dataset[DATASET_PARENT_DATA_KEY_ID],
|
||||
...extraProps,
|
||||
};
|
||||
} else if (
|
||||
elm.dataset?.[DATASET_PARENT_DATA_STOP_TAG] &&
|
||||
elm.dataset[DATASET_RESOURCE_FOLDER_KEY] === uniqueId
|
||||
) {
|
||||
return {
|
||||
id: ROOT_KEY,
|
||||
...extraProps,
|
||||
};
|
||||
} else if (elm.classList.contains(TOOL_BAR_CLASS_NAME)) {
|
||||
return CLICK_TOOL_BAR;
|
||||
}
|
||||
const result = findTargetElement(
|
||||
elm.parentElement,
|
||||
uniqueId,
|
||||
customClassName,
|
||||
);
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
return {
|
||||
...result,
|
||||
...extraProps,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const getFolderIdFromPath = (resource: ResourceType | null): string => {
|
||||
if (!resource) {
|
||||
return '';
|
||||
}
|
||||
if (resource.type === 'folder') {
|
||||
return String(resource.id);
|
||||
} else {
|
||||
return String(resource.path?.[resource.path.length - 2]);
|
||||
}
|
||||
};
|
||||
|
||||
const canStartDrag = (
|
||||
startPosition: MousePosition,
|
||||
currentPosition: MousePosition,
|
||||
): boolean => {
|
||||
if (
|
||||
Math.abs(currentPosition.x - startPosition.x) > START_DRAG_GAP ||
|
||||
Math.abs(currentPosition.y - startPosition.y) > START_DRAG_GAP
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const validateDrag = (
|
||||
resourceList: ResourceType[],
|
||||
target: ResourceType,
|
||||
config?: ConfigType,
|
||||
) => {
|
||||
const { name, path, id } = target;
|
||||
|
||||
/**
|
||||
* 只要有一个文件是自己层级挪动到自己层级的则 return
|
||||
*/
|
||||
const selfList = resourceList.filter(resource => {
|
||||
const resourcePath = resource.path || [];
|
||||
const parentId = resourcePath[resourcePath.length - 2];
|
||||
return parentId === id;
|
||||
});
|
||||
if (selfList.length) {
|
||||
return `Can't move ${selfList
|
||||
.map(item => item.name)
|
||||
.join(', ')} into ${name}`;
|
||||
}
|
||||
|
||||
// 校验是不是爹移到儿子
|
||||
const notAllowedList = resourceList.filter(resource =>
|
||||
(path || []).includes(String(resource.id)),
|
||||
);
|
||||
|
||||
if (notAllowedList.length) {
|
||||
return `Can't move ${notAllowedList
|
||||
.map(item => item.name)
|
||||
.join(', ')} into ${name}`;
|
||||
}
|
||||
|
||||
// 校验移动之后层级是不是过深
|
||||
const maxDeep = resourceList.reduce(
|
||||
(max, resource) => Math.max(max, (resource?.maxDeep || 0) + 1),
|
||||
0,
|
||||
);
|
||||
const targetDeep = (target.path || []).length - 1;
|
||||
|
||||
if (targetDeep + maxDeep > (config?.maxDeep || MAX_DEEP)) {
|
||||
return `Can't move into ${name}. MaxDeep is ${config?.maxDeep || MAX_DEEP}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export {
|
||||
DATASET_PARENT_DATA_STOP_TAG,
|
||||
DATASET_PARENT_DATA_KEY_ID,
|
||||
DATASET_RESOURCE_FOLDER_KEY,
|
||||
CLICK_CONTEXT_MENU,
|
||||
TOOL_BAR_CLASS_NAME,
|
||||
CLICK_TOOL_BAR,
|
||||
CLICK_OUTSIDE,
|
||||
PATH_SPLIT_KEY,
|
||||
findTargetElement,
|
||||
getElementByXY,
|
||||
getFolderIdFromPath,
|
||||
validateDrag,
|
||||
canStartDrag,
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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 */
|
||||
/* eslint-disable max-lines-per-function */
|
||||
import { useState } from 'react';
|
||||
|
||||
import { noop } from 'lodash-es';
|
||||
|
||||
import {
|
||||
getResourceById,
|
||||
findResourceByPath,
|
||||
sortResourceList,
|
||||
} from '../../utils';
|
||||
import {
|
||||
type ChangeNameType,
|
||||
type CreateResourcePropType,
|
||||
type DragPropType,
|
||||
type ResourceType,
|
||||
type ResourceMapType,
|
||||
type IdType,
|
||||
} from '../../type';
|
||||
import { OPTIMISM_ID_PREFIX } from '../../constant';
|
||||
|
||||
export const useOptimismUI = ({
|
||||
enable,
|
||||
onChange = noop,
|
||||
onDrag = noop,
|
||||
onChangeName = noop,
|
||||
onCreate = noop,
|
||||
onDelete = noop,
|
||||
onRevertDelete = noop,
|
||||
changeResourceTree,
|
||||
scrollInView,
|
||||
resourceTreeRef,
|
||||
resourceMap,
|
||||
}: {
|
||||
enable?: boolean;
|
||||
onChange?: (resource: ResourceType[]) => void;
|
||||
onDrag?: (v: DragPropType) => void;
|
||||
onChangeName?: (v: ChangeNameType) => void;
|
||||
onCreate?: (v: CreateResourcePropType) => void;
|
||||
onDelete?: (ids: ResourceType[]) => void;
|
||||
onRevertDelete?: (ids: ResourceType[]) => void;
|
||||
changeResourceTree: (v) => void;
|
||||
scrollInView: (selectedId: string) => void;
|
||||
resourceTreeRef: React.MutableRefObject<ResourceType>;
|
||||
resourceMap: React.MutableRefObject<ResourceMapType>;
|
||||
}) => {
|
||||
const [optimismSavingMap, setOptimismSavingMap] = useState<
|
||||
Record<IdType, true>
|
||||
>({});
|
||||
|
||||
const clearOptimismSavingMap = () => {
|
||||
setOptimismSavingMap({});
|
||||
};
|
||||
|
||||
const addOptimismSavingItems = (_ids: string | string[]) => {
|
||||
const ids = _ids instanceof Array ? _ids : [_ids];
|
||||
setOptimismSavingMap({
|
||||
...optimismSavingMap,
|
||||
...ids.reduce(
|
||||
(pre, cur) => ({
|
||||
[cur]: true,
|
||||
...pre,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDrag = (res: DragPropType) => {
|
||||
const { toId, resourceList } = res;
|
||||
if (!toId || !resourceList) {
|
||||
return;
|
||||
}
|
||||
resourceList.forEach(resource => {
|
||||
const target = findResourceByPath(
|
||||
resourceTreeRef.current,
|
||||
resource.path!.slice(0, resource.path!.length - 1),
|
||||
);
|
||||
if (target?.children) {
|
||||
target.children = target.children.filter(
|
||||
child => child.id !== resource.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
const toTarget = getResourceById(resourceTreeRef.current, toId)?.resource;
|
||||
if (toTarget) {
|
||||
toTarget.children = sortResourceList([
|
||||
...(toTarget.children || []),
|
||||
...resourceList,
|
||||
]);
|
||||
}
|
||||
|
||||
addOptimismSavingItems(resourceList.map(resource => resource.id));
|
||||
|
||||
changeResourceTree(resourceTreeRef.current);
|
||||
onDrag?.(res);
|
||||
onChange?.(resourceTreeRef.current?.children || []);
|
||||
};
|
||||
|
||||
const handleChangeName = (res: ChangeNameType) => {
|
||||
const target = findResourceByPath(resourceTreeRef.current, res.path || []);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.name = res.name;
|
||||
|
||||
addOptimismSavingItems(target.id);
|
||||
|
||||
changeResourceTree(resourceTreeRef.current);
|
||||
onChangeName?.(res);
|
||||
onChange?.(resourceTreeRef.current?.children || []);
|
||||
};
|
||||
const handleCreate = (res: CreateResourcePropType) => {
|
||||
const { path, type, name } = res;
|
||||
const target = findResourceByPath(resourceTreeRef.current, path);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const tempId = `${OPTIMISM_ID_PREFIX}${Number(new Date())}`;
|
||||
const tempFile = {
|
||||
id: tempId,
|
||||
type,
|
||||
name,
|
||||
};
|
||||
|
||||
addOptimismSavingItems(tempId);
|
||||
|
||||
target.children = sortResourceList([...(target.children || []), tempFile]);
|
||||
changeResourceTree(resourceTreeRef.current);
|
||||
onCreate(res);
|
||||
|
||||
resourceMap.current[tempId] = tempFile;
|
||||
scrollInView?.(tempId);
|
||||
onChange?.(resourceTreeRef.current?.children || []);
|
||||
};
|
||||
const handleDelete = (res: ResourceType[]) => {
|
||||
onDelete(res);
|
||||
};
|
||||
const handleRevertDelete = (res: ResourceType[]) => {
|
||||
res.forEach(source => {
|
||||
const target = findResourceByPath(
|
||||
resourceTreeRef.current,
|
||||
source.path || [],
|
||||
);
|
||||
if (target) {
|
||||
target.status = 'normal';
|
||||
}
|
||||
});
|
||||
changeResourceTree(resourceTreeRef.current);
|
||||
onRevertDelete?.(res);
|
||||
onChange?.(resourceTreeRef.current?.children || []);
|
||||
};
|
||||
|
||||
const commonArgs = { optimismSavingMap, clearOptimismSavingMap };
|
||||
|
||||
if (!enable) {
|
||||
return {
|
||||
handleDrag: onDrag,
|
||||
handleChangeName: onChangeName,
|
||||
handleCreate: onCreate,
|
||||
handleDelete: onDelete,
|
||||
handleRevertDelete: onRevertDelete,
|
||||
...commonArgs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
handleDrag,
|
||||
handleChangeName,
|
||||
handleCreate,
|
||||
handleDelete,
|
||||
handleRevertDelete,
|
||||
...commonArgs,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
|
||||
import { CommandRegistry, useIDEService } from '@coze-project-ide/client';
|
||||
|
||||
import { ContextMenuConfigMap } from '../use-right-click-panel/constant';
|
||||
import {
|
||||
type RightPanelConfigType,
|
||||
type ResourceFolderContextType,
|
||||
type ResourceType,
|
||||
} from '../../type';
|
||||
import { BaseResourceContextMenuBtnType } from '../../constant';
|
||||
|
||||
const useRegisterCommand = ({
|
||||
isFocus,
|
||||
id,
|
||||
updateContext,
|
||||
clearContext,
|
||||
selectedIdRef,
|
||||
tempSelectedMapRef,
|
||||
}: {
|
||||
isFocus: boolean;
|
||||
id: string;
|
||||
updateContext: (v: Partial<ResourceFolderContextType>) => void;
|
||||
clearContext: () => void;
|
||||
selectedIdRef: React.MutableRefObject<string>;
|
||||
tempSelectedMapRef: React.MutableRefObject<Record<string, ResourceType>>;
|
||||
}) => {
|
||||
const cbRef = useRef<Record<string, (v?: any) => void>>({});
|
||||
const commandRegistry = useIDEService<CommandRegistry>(CommandRegistry);
|
||||
|
||||
const dispatchInstance = useRef<ResourceFolderContextType>({
|
||||
onEnter: () => {
|
||||
cbRef.current[BaseResourceContextMenuBtnType.EditName]?.();
|
||||
},
|
||||
onDelete: () => {
|
||||
cbRef.current[BaseResourceContextMenuBtnType.Delete]?.();
|
||||
},
|
||||
onCreateFolder: () => {
|
||||
cbRef.current[BaseResourceContextMenuBtnType.CreateFolder]?.();
|
||||
},
|
||||
onCreateResource: () => {
|
||||
cbRef.current[BaseResourceContextMenuBtnType.CreateResource]?.();
|
||||
},
|
||||
});
|
||||
|
||||
const registerEvent = (type: BaseResourceContextMenuBtnType, cb) => {
|
||||
cbRef.current[type] = cb;
|
||||
};
|
||||
|
||||
const registerCommand = (config: RightPanelConfigType[]) => {
|
||||
config.forEach(command => {
|
||||
if ('type' in command) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ContextMenuConfigMap[command.id]) {
|
||||
if (command.label || command.shortLabel) {
|
||||
commandRegistry.updateCommand(command.id, {
|
||||
...(command.label ? { label: command.label } : {}),
|
||||
...(command.shortLabel ? { shortLabel: command.shortLabel } : {}),
|
||||
});
|
||||
}
|
||||
} else if (command.execute) {
|
||||
// 如果有自定义的 execute 函数才会需要重新注册
|
||||
if (commandRegistry.getCommand(command.id)) {
|
||||
commandRegistry.unregisterCommand(command.id);
|
||||
}
|
||||
commandRegistry.registerCommand(
|
||||
{
|
||||
id: command.id,
|
||||
label: command.label,
|
||||
shortLabel: command.label,
|
||||
},
|
||||
{
|
||||
execute: () => {
|
||||
command.execute?.();
|
||||
},
|
||||
isEnabled: opt => !opt.disabled,
|
||||
isVisible: opt => !opt.isHidden,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocus) {
|
||||
updateContext({
|
||||
...dispatchInstance.current,
|
||||
currentSelectedId: selectedIdRef.current,
|
||||
tempSelectedMap: tempSelectedMapRef.current,
|
||||
id,
|
||||
});
|
||||
} else {
|
||||
clearContext();
|
||||
}
|
||||
}, [isFocus]);
|
||||
|
||||
return {
|
||||
registerEvent,
|
||||
registerCommand,
|
||||
};
|
||||
};
|
||||
|
||||
export { useRegisterCommand };
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export enum BaseResourceContextMenuBtnType {
|
||||
CreateFolder = 'resource-folder-create-folder',
|
||||
CreateResource = 'resource-folder-create-resource',
|
||||
EditName = 'resource-folder-edit-name',
|
||||
Delete = 'resource-folder-delete',
|
||||
}
|
||||
|
||||
export const ContextMenuConfigMap: Record<
|
||||
BaseResourceContextMenuBtnType,
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
executeName: string;
|
||||
}
|
||||
> = {
|
||||
[BaseResourceContextMenuBtnType.CreateFolder]: {
|
||||
id: BaseResourceContextMenuBtnType.CreateFolder,
|
||||
label: 'Create Folder',
|
||||
executeName: 'onCreateFolder',
|
||||
},
|
||||
[BaseResourceContextMenuBtnType.CreateResource]: {
|
||||
id: BaseResourceContextMenuBtnType.CreateResource,
|
||||
label: 'Create Resource',
|
||||
executeName: 'onCreateResource',
|
||||
},
|
||||
[BaseResourceContextMenuBtnType.EditName]: {
|
||||
id: BaseResourceContextMenuBtnType.EditName,
|
||||
label: 'Edit Name',
|
||||
executeName: 'onEnter',
|
||||
},
|
||||
[BaseResourceContextMenuBtnType.Delete]: {
|
||||
id: BaseResourceContextMenuBtnType.Delete,
|
||||
label: 'Delete',
|
||||
executeName: 'onDelete',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type React from 'react';
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
import { MenuService, useIDEService } from '@coze-project-ide/client';
|
||||
|
||||
import { createUniqId } from '../../utils';
|
||||
import { type ResourceType, type RightPanelConfigType } from '../../type';
|
||||
import { RESOURCE_FOLDER_WRAPPER_CLASS } from '../../constant';
|
||||
import { handleConfig } from './util';
|
||||
import { ContextMenuConfigMap } from './constant';
|
||||
|
||||
const commandIdStashSet: Set<string> = new Set();
|
||||
|
||||
const RESOURCE_FOLDER_SEPARATOR_KEY = 'resource-folder-separator-key';
|
||||
|
||||
const useRightClickPanel = ({
|
||||
tempSelectedMapRef,
|
||||
contextMenuHandler,
|
||||
registerCommand,
|
||||
id,
|
||||
contextMenuDisabled,
|
||||
onContextMenuVisibleChange,
|
||||
}: {
|
||||
tempSelectedMapRef: React.MutableRefObject<Record<string, ResourceType>>;
|
||||
contextMenuHandler?: (v: ResourceType[]) => RightPanelConfigType[];
|
||||
registerCommand: (config: RightPanelConfigType[]) => void;
|
||||
id: string;
|
||||
contextMenuDisabled?: boolean;
|
||||
onContextMenuVisibleChange?: (v: boolean) => void;
|
||||
}) => {
|
||||
const menuService = useIDEService<MenuService>(MenuService);
|
||||
|
||||
const separatorNum = useRef(0);
|
||||
const menuNum = useRef(0);
|
||||
const rightPanelVisible = useRef(false);
|
||||
const changeRightPanelVisible = (v: boolean) => {
|
||||
if (rightPanelVisible.current !== v) {
|
||||
rightPanelVisible.current = v;
|
||||
onContextMenuVisibleChange?.(rightPanelVisible.current);
|
||||
}
|
||||
};
|
||||
|
||||
const clearMenuItems = () => {
|
||||
menuService.clearMenuItems(
|
||||
[
|
||||
...commandIdStashSet.keys(),
|
||||
...Object.keys(ContextMenuConfigMap),
|
||||
...new Array(separatorNum.current)
|
||||
.fill(null)
|
||||
.map(_ => command => command === RESOURCE_FOLDER_SEPARATOR_KEY),
|
||||
].filter(Boolean),
|
||||
);
|
||||
separatorNum.current = 0;
|
||||
menuNum.current = 0;
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
clearMenuItems();
|
||||
(menuService as any)?.contextMenu?.menu?.close?.();
|
||||
changeRightPanelVisible?.(false);
|
||||
};
|
||||
|
||||
const contextMenuCallback = (e, resources?: ResourceType[]) => {
|
||||
const baseConfig = contextMenuHandler
|
||||
? contextMenuHandler(
|
||||
resources || Object.values(tempSelectedMapRef.current),
|
||||
)
|
||||
: [];
|
||||
|
||||
const config = handleConfig(baseConfig);
|
||||
|
||||
registerCommand(config);
|
||||
|
||||
clearMenuItems();
|
||||
|
||||
config.forEach(v => {
|
||||
if ('type' in v) {
|
||||
separatorNum.current = separatorNum.current + 1;
|
||||
menuNum.current = menuNum.current + 1;
|
||||
menuService.addMenuItem({
|
||||
command: RESOURCE_FOLDER_SEPARATOR_KEY,
|
||||
type: 'separator',
|
||||
selector: `.${createUniqId(RESOURCE_FOLDER_WRAPPER_CLASS, id)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!v.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!commandIdStashSet.has(v.id)) {
|
||||
commandIdStashSet.add(v.id);
|
||||
}
|
||||
if (!contextMenuDisabled) {
|
||||
menuNum.current = menuNum.current + 1;
|
||||
menuService.addMenuItem({
|
||||
command: v.id,
|
||||
selector: `.${createUniqId(RESOURCE_FOLDER_WRAPPER_CLASS, id)}`,
|
||||
args: v,
|
||||
tooltip: v.tooltip,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!contextMenuDisabled && menuNum.current > 0) {
|
||||
menuService.open(e);
|
||||
setTimeout(() => {
|
||||
changeRightPanelVisible?.(true);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispose();
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(menuService as any)?.contextMenu?.menu?.aboutToClose?.connect?.(() => {
|
||||
if (rightPanelVisible.current) {
|
||||
changeRightPanelVisible?.(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { contextMenuCallback, closeContextMenu: dispose };
|
||||
};
|
||||
|
||||
export { useRightClickPanel };
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 RightPanelConfigType } from '../../type';
|
||||
import { ContextMenuConfigMap } from './constant';
|
||||
|
||||
/**
|
||||
* 主要替换资源树默认支持的 右键菜单配置,
|
||||
* 并且对三方注入的右键菜单的 id 进行包装
|
||||
*/
|
||||
export const handleConfig = (
|
||||
baseConfig: RightPanelConfigType[],
|
||||
): RightPanelConfigType[] =>
|
||||
baseConfig.map(config => {
|
||||
if ('type' in config) {
|
||||
return config;
|
||||
}
|
||||
if (ContextMenuConfigMap[config.id]) {
|
||||
return {
|
||||
...ContextMenuConfigMap[config.id],
|
||||
...config,
|
||||
id: config.id,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
id: config.id,
|
||||
};
|
||||
});
|
||||
@@ -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 { useEffect, useRef } from 'react';
|
||||
|
||||
const useSelectedChange = ({
|
||||
selected,
|
||||
resourceMap,
|
||||
collapsedMapRef,
|
||||
setCollapsed,
|
||||
tempSelectedMapRef,
|
||||
setTempSelectedMap,
|
||||
scrollInView,
|
||||
updateContext,
|
||||
}) => {
|
||||
const selectedIdRef = useRef<string>(selected || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
setTempSelectedMap({});
|
||||
return;
|
||||
}
|
||||
selectedIdRef.current = selected;
|
||||
|
||||
updateContext({ currentSelectedId: selected });
|
||||
|
||||
// 将聚焦的 path 上的文件夹都展开
|
||||
const path = resourceMap.current[selected]?.path || [];
|
||||
path.forEach(pathKey => {
|
||||
delete collapsedMapRef.current[pathKey];
|
||||
});
|
||||
setCollapsed({
|
||||
...collapsedMapRef.current,
|
||||
});
|
||||
|
||||
tempSelectedMapRef.current = {};
|
||||
if (resourceMap.current?.[selected]) {
|
||||
tempSelectedMapRef.current = {
|
||||
[selected]: resourceMap.current[selected],
|
||||
};
|
||||
}
|
||||
setTempSelectedMap(tempSelectedMapRef.current);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollInView(selected);
|
||||
}, 16);
|
||||
}, [selected]);
|
||||
|
||||
return selectedIdRef;
|
||||
};
|
||||
|
||||
export { useSelectedChange };
|
||||
@@ -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 { useRef, useState } from 'react';
|
||||
|
||||
const useStateRef = <T>(
|
||||
v: T,
|
||||
cb?: (v: T) => void,
|
||||
): [React.MutableRefObject<T>, (v: T) => void, T] => {
|
||||
const [state, setState] = useState<T>(v);
|
||||
const ref = useRef<T>(v);
|
||||
|
||||
const onChange = (nextV: T) => {
|
||||
ref.current = nextV;
|
||||
setState(nextV);
|
||||
cb?.(nextV);
|
||||
};
|
||||
|
||||
return [ref, onChange, state];
|
||||
};
|
||||
|
||||
export { useStateRef };
|
||||
@@ -0,0 +1,159 @@
|
||||
.resource-list-wrapper {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
|
||||
:global{
|
||||
.resource-list-custom-event-wrapper {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-list-scroll-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.resource-list-drag-and-drop-wrapper {
|
||||
height: 100%;
|
||||
|
||||
.item-wrapper {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
|
||||
.item-wrapper-indent-line {
|
||||
position: absolute;
|
||||
border-left-width: 1px;
|
||||
border-left-style: solid;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.base-item-hover-class {
|
||||
&:hover {
|
||||
background-color: rgba(6, 7, 9, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.item-is-temp-selected {
|
||||
background-color: rgba(6, 7, 9, 0.04);
|
||||
border-radius: 0px;
|
||||
}
|
||||
.item-is-in-edit {
|
||||
background-color: rgba(6, 7, 9, 0.04);
|
||||
}
|
||||
.item-is-selected {
|
||||
background-color: rgba(6, 7, 9, 0.14);
|
||||
}
|
||||
|
||||
.dragging-hover-class {
|
||||
background-color: rgba(148, 152, 247, 0.44);
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.file-item-wrapper {
|
||||
}
|
||||
|
||||
.base-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 8px;
|
||||
transition: all 0.1s ease-in-out;
|
||||
|
||||
.base-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.base-item-name-input {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.semi-input-wrapper {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.semi-input-wrapper-focus {
|
||||
background-color: white;
|
||||
}
|
||||
[class~='semi-input'] {
|
||||
padding: 0 4px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.base-item-name-input-error-msg-absolute {
|
||||
z-index: 999;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 26px;
|
||||
background-color: rgba(255, 241, 242, 1);
|
||||
border-width: 1px;
|
||||
border-color: rgba(242, 36, 53, 1);
|
||||
border-radius: 6px;
|
||||
border-style: solid;
|
||||
color: rgba(6, 7, 9, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.base-item-name-input-error {
|
||||
[class~='semi-input-wrapper'] {
|
||||
border-color: rgba(242, 36, 53, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.base-item-more-hover-display-class {
|
||||
display: none;
|
||||
}
|
||||
&:hover {
|
||||
.base-item-more-hover-display-class {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.base-item-more-btn {
|
||||
// 用于覆盖 coze-design 二次封装被固化的样式。。。
|
||||
min-width: 16px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
background-color: transparent;
|
||||
color: rgba(6, 7, 9, 0.96);
|
||||
&:hover {
|
||||
background-color: rgba(6, 7, 9, 0.14);
|
||||
}
|
||||
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.base-radius-class-first {
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.base-radius-class-last {
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
.base-radius-class-single {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
/*
|
||||
* 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 max-lines */
|
||||
/* eslint-disable max-lines-per-function */
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
|
||||
import ReactDOM from 'react-dom';
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { noop } from 'lodash-es';
|
||||
|
||||
import { createUniqId, flatTree, mapResourceTree } from './utils';
|
||||
import {
|
||||
type DragPropType,
|
||||
type ResourceType,
|
||||
type ChangeNameType,
|
||||
type ResourceMapType,
|
||||
type CreateResourcePropType,
|
||||
type CommonRenderProps,
|
||||
type RightPanelConfigType,
|
||||
ResourceTypeEnum,
|
||||
type ValidatorConfigType,
|
||||
type ConfigType,
|
||||
type RenderMoreSuffixType,
|
||||
type IdType,
|
||||
} from './type';
|
||||
import { BaseRender } from './render-components/base-render';
|
||||
import { useStateRef } from './hooks/uss-state-ref';
|
||||
import { useSelectedChange } from './hooks/use-selected-change';
|
||||
import { useRightClickPanel } from './hooks/use-right-click-panel';
|
||||
import { useRegisterCommand } from './hooks/use-register-command';
|
||||
import { useOptimismUI } from './hooks/use-optimism-ui';
|
||||
import {
|
||||
DATASET_PARENT_DATA_STOP_TAG,
|
||||
DATASET_RESOURCE_FOLDER_KEY,
|
||||
} from './hooks/use-mouse-event/utils';
|
||||
import { useMouseEvent } from './hooks/use-mouse-event';
|
||||
import { useFocusResource } from './hooks/use-focus-resource';
|
||||
import { useEvent } from './hooks/use-custom-event';
|
||||
import {
|
||||
CREATE_RESOURCE_ID,
|
||||
useCreateEditResource,
|
||||
} from './hooks/use-create-edit-resource';
|
||||
import { useContextChange } from './hooks/use-context-change';
|
||||
import { useCollapsedMap } from './hooks/use-collapsed-map';
|
||||
import { RESOURCE_FOLDER_WRAPPER_CLASS, ROOT_KEY, ROOT_NODE } from './constant';
|
||||
|
||||
import s from './index.module.less';
|
||||
|
||||
interface RefType {
|
||||
/**
|
||||
* 创建文件夹
|
||||
*/
|
||||
createFolder: () => void;
|
||||
/**
|
||||
* 创建资源
|
||||
*/
|
||||
createResource: (type: string) => void;
|
||||
/**
|
||||
* 重命名资源
|
||||
*/
|
||||
renameResource: (id: IdType) => void;
|
||||
/**
|
||||
* 手动关闭右键菜单
|
||||
*/
|
||||
closeContextMenu: () => void;
|
||||
/**
|
||||
* 收起所有文件夹
|
||||
*/
|
||||
collapseAll: () => void;
|
||||
/**
|
||||
* 展开所有文件夹
|
||||
*/
|
||||
expandAll: () => void;
|
||||
/**
|
||||
* 手动聚焦
|
||||
*/
|
||||
focus: () => void;
|
||||
/**
|
||||
* 手动失焦
|
||||
*/
|
||||
blur: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
style?: React.CSSProperties;
|
||||
resourceTree: ResourceType | ResourceType[];
|
||||
resourceMap: ResourceMapType;
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* 主要的资源类型,非必填。
|
||||
* 主要用于快捷键创建资源的默认类型。
|
||||
*/
|
||||
defaultResourceType?: string;
|
||||
/**
|
||||
* 是否使用乐观 ui;
|
||||
* false 时,onChange 失效;
|
||||
* default = true
|
||||
*
|
||||
* 传入 loadingRender 时,会对乐观保存的 item 尾部增加一个渲染块,由外部控制渲染
|
||||
*/
|
||||
useOptimismUI?:
|
||||
| boolean
|
||||
| {
|
||||
loadingRender?: () => React.ReactElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* 当前选中的资源 id, 受控的
|
||||
*/
|
||||
selected?: string;
|
||||
|
||||
/**
|
||||
* 是否渲染每个 item 末尾的 more 按钮,hover 等同于 右键
|
||||
*/
|
||||
renderMoreSuffix?: RenderMoreSuffixType;
|
||||
|
||||
/**
|
||||
* 用于 name 校验的配置
|
||||
*/
|
||||
validateConfig?: ValidatorConfigType;
|
||||
|
||||
/**
|
||||
* 支持搜索, 高亮展示
|
||||
*/
|
||||
searchConfig?: {
|
||||
searchKey?: string;
|
||||
highlightStyle?: React.CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* 可选。
|
||||
* 传入则是受控的收起展开树。
|
||||
* 不传则内部自己维护树
|
||||
*/
|
||||
collapsedMap?: Record<string, boolean>;
|
||||
setCollapsedMap?: (v: Record<string, boolean>) => void;
|
||||
|
||||
/**
|
||||
* 树变更的回调函数,依赖 useOptimismUI 为 true。
|
||||
*/
|
||||
onChange?: (resource: ResourceType[]) => void;
|
||||
|
||||
/**
|
||||
* 单击选中资源的回调,仅支持非 folder 类型资源
|
||||
*/
|
||||
onSelected?: (id: string | number, resource: ResourceType) => void;
|
||||
/**
|
||||
* 拖拽完成之后的回调
|
||||
*/
|
||||
onDrag?: (v: DragPropType) => void;
|
||||
/**
|
||||
* 修改 name 之后的回调
|
||||
*/
|
||||
onChangeName?: (v: ChangeNameType) => void;
|
||||
/**
|
||||
* 创建资源的回调
|
||||
*/
|
||||
onCreate?: (v: CreateResourcePropType) => void;
|
||||
/**
|
||||
* 删除的回调。该方法不会被乐观 ui 逻辑改写,最好业务层加一个二次确认逻辑,删除之后走数据更新的方式来更新树组件
|
||||
*/
|
||||
onDelete?: (ids: ResourceType[]) => void;
|
||||
|
||||
/**
|
||||
* 用于自定义配置渲染资源的 icon
|
||||
* @returns react 节点
|
||||
*/
|
||||
iconRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
|
||||
/**
|
||||
* 用于自定义配置每一项末尾的元素
|
||||
*/
|
||||
suffixRender?: {
|
||||
width: number; // 用于文本超长的 tooltip 偏移量计算的,是一个必填字段
|
||||
render: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用于自定义配置每一个资源的文本渲染器。
|
||||
* 如果采用自定义渲染,则需要自己实现搜索高亮能力
|
||||
* @returns react 节点
|
||||
*/
|
||||
textRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
|
||||
/**
|
||||
* 右键菜单配置
|
||||
* @param v 当前临时选中的资源列表。 可以通过 id 判断是否是根文件。(ROOT_KEY: 根文件 id)
|
||||
* @returns 组件内置注册好的命令和菜单,详见 BaseResourceContextMenuBtnType 枚举
|
||||
*/
|
||||
contextMenuHandler?: (v: ResourceType[]) => RightPanelConfigType[];
|
||||
|
||||
/**
|
||||
* 右键菜单弹窗展示和隐藏的回调。
|
||||
*/
|
||||
onContextMenuVisibleChange?: (v: boolean) => void;
|
||||
|
||||
/**
|
||||
* 禁用右键菜单,主要是兼容在 popover 的场景内
|
||||
*/
|
||||
contextMenuDisabled?: boolean;
|
||||
|
||||
/**
|
||||
* 一些用于杂七杂八的配置项
|
||||
*/
|
||||
config?: ConfigType;
|
||||
|
||||
/**
|
||||
* 能力黑名单
|
||||
*/
|
||||
powerBlackMap?: {
|
||||
dragAndDrop?: boolean;
|
||||
folder?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 列表为空的渲染组件
|
||||
*/
|
||||
empty?: React.ReactElement;
|
||||
}
|
||||
|
||||
let idIndex = 0;
|
||||
|
||||
const ResourceFolder = forwardRef<RefType, Props>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
resourceTree: _resourceTree,
|
||||
resourceMap: _resourceMap,
|
||||
selected,
|
||||
disabled,
|
||||
searchConfig,
|
||||
defaultResourceType,
|
||||
useOptimismUI: _useOptimismUI = false,
|
||||
style,
|
||||
collapsedMap: _collapsedMap,
|
||||
setCollapsedMap: _setCollapsedMap,
|
||||
validateConfig,
|
||||
onChange = noop,
|
||||
onSelected: _onSelected = noop,
|
||||
onDrag: _onDrag = noop,
|
||||
onChangeName: _onChangeName = noop,
|
||||
onCreate: _onCreate = noop,
|
||||
onDelete: _onDelete = noop,
|
||||
iconRender,
|
||||
suffixRender,
|
||||
textRender,
|
||||
renderMoreSuffix: _renderMoreSuffix = false,
|
||||
contextMenuHandler,
|
||||
onContextMenuVisibleChange,
|
||||
contextMenuDisabled,
|
||||
config,
|
||||
powerBlackMap,
|
||||
empty,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const uniqId = useRef(id ? id : `${idIndex++}`);
|
||||
|
||||
const resourceTreeWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { updateContext, clearContext, updateId } = useContextChange(
|
||||
uniqId.current,
|
||||
);
|
||||
|
||||
const renderMoreSuffix = contextMenuDisabled ? false : _renderMoreSuffix;
|
||||
|
||||
/**
|
||||
* 临时选中的表
|
||||
*/
|
||||
const [tempSelectedMapRef, setTempSelectedMap] = useStateRef<
|
||||
Record<string, ResourceType>
|
||||
>({}, v => {
|
||||
updateContext?.({ tempSelectedMap: v });
|
||||
});
|
||||
|
||||
/**
|
||||
* 打平的树
|
||||
*/
|
||||
const resourceMap = useRef<ResourceMapType>(_resourceMap || {});
|
||||
const changeResourceMap = nextMap => {
|
||||
resourceMap.current = nextMap;
|
||||
// 变更之后维护临时选中表
|
||||
tempSelectedMapRef.current = Object.keys(
|
||||
tempSelectedMapRef.current,
|
||||
).reduce((pre, cur) => {
|
||||
if (resourceMap.current[cur]) {
|
||||
return {
|
||||
...pre,
|
||||
[cur]: resourceMap.current[cur],
|
||||
};
|
||||
}
|
||||
return pre;
|
||||
}, {});
|
||||
setTempSelectedMap(tempSelectedMapRef.current);
|
||||
};
|
||||
useEffect(() => {
|
||||
changeResourceMap(_resourceMap);
|
||||
}, [_resourceMap]);
|
||||
|
||||
/**
|
||||
* 处理一系列收起展开的 hook
|
||||
*/
|
||||
const { collapsedMapRef, handleCollapse, setCollapsed, collapsedState } =
|
||||
useCollapsedMap({
|
||||
_collapsedMap,
|
||||
_setCollapsedMap,
|
||||
resourceMap,
|
||||
});
|
||||
|
||||
/**
|
||||
* 用于渲染的树
|
||||
*/
|
||||
const [resourceTreeRef, setResourceTree] = useStateRef<ResourceType>(
|
||||
{
|
||||
...ROOT_NODE,
|
||||
children:
|
||||
_resourceTree instanceof Array ? _resourceTree : [_resourceTree],
|
||||
} as unknown as ResourceType,
|
||||
v => {
|
||||
setResourceList(
|
||||
flatTree(v, resourceMap.current, collapsedMapRef.current),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const changeResourceTree = v => {
|
||||
resourceTreeRef.current = v;
|
||||
changeResourceMap(mapResourceTree(v?.children || []));
|
||||
setResourceTree(resourceTreeRef.current);
|
||||
};
|
||||
|
||||
const [resourceList, setResourceList] = useStateRef(
|
||||
flatTree(
|
||||
resourceTreeRef.current,
|
||||
resourceMap.current,
|
||||
collapsedMapRef.current,
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setResourceList(
|
||||
flatTree(
|
||||
resourceTreeRef.current,
|
||||
resourceMap.current,
|
||||
collapsedMapRef.current,
|
||||
),
|
||||
);
|
||||
}, [collapsedState]);
|
||||
|
||||
const disabledRef = useRef(!!disabled);
|
||||
useEffect(() => {
|
||||
disabledRef.current = !!disabled;
|
||||
}, [disabled]);
|
||||
|
||||
/**
|
||||
* 用于收敛树组件的滚动,聚焦逻辑的 hook
|
||||
*/
|
||||
const { scrollInView, scrollWrapper, tempDisableScroll } = useFocusResource(
|
||||
{
|
||||
resourceTreeRef,
|
||||
collapsedMapRef,
|
||||
resourceMap,
|
||||
config,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
handleDrag: onDrag,
|
||||
handleChangeName: onChangeName,
|
||||
handleCreate: onCreate,
|
||||
handleDelete: onDelete,
|
||||
optimismSavingMap,
|
||||
clearOptimismSavingMap,
|
||||
} = useOptimismUI({
|
||||
enable: !!_useOptimismUI,
|
||||
onDrag: _onDrag,
|
||||
onChangeName: _onChangeName,
|
||||
onCreate: _onCreate,
|
||||
onDelete: _onDelete,
|
||||
changeResourceTree,
|
||||
scrollInView,
|
||||
resourceTreeRef,
|
||||
resourceMap,
|
||||
onChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
resourceTreeRef.current = {
|
||||
...ROOT_NODE,
|
||||
children:
|
||||
_resourceTree instanceof Array ? _resourceTree : [_resourceTree],
|
||||
};
|
||||
setResourceTree(resourceTreeRef.current);
|
||||
clearOptimismSavingMap();
|
||||
}, [_resourceTree]);
|
||||
|
||||
/**
|
||||
* 处理选中的资源变更之后的副作用
|
||||
*/
|
||||
const selectedIdRef = useSelectedChange({
|
||||
selected,
|
||||
resourceMap,
|
||||
collapsedMapRef,
|
||||
setCollapsed,
|
||||
tempSelectedMapRef,
|
||||
setTempSelectedMap,
|
||||
scrollInView,
|
||||
updateContext,
|
||||
});
|
||||
|
||||
const { addEventListener, onMouseDownInDiv, onMouseUpInDiv } = useEvent();
|
||||
|
||||
const {
|
||||
onMouseMove,
|
||||
context: dragAndDropContext,
|
||||
context: { isFocus },
|
||||
isFocusRef,
|
||||
dragPreview,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
} = useMouseEvent({
|
||||
draggable: !powerBlackMap?.dragAndDrop,
|
||||
uniqId: uniqId.current,
|
||||
updateId,
|
||||
iconRender,
|
||||
resourceTreeWrapperRef,
|
||||
collapsedMapRef,
|
||||
tempSelectedMapRef,
|
||||
setTempSelectedMap,
|
||||
setCollapsedMap: handleCollapse,
|
||||
resourceTreeRef,
|
||||
selectedIdRef,
|
||||
onSelected: (...props) => {
|
||||
_onSelected(...props);
|
||||
tempDisableScroll();
|
||||
},
|
||||
onDrag,
|
||||
addEventListener,
|
||||
disabled: disabledRef,
|
||||
resourceMap,
|
||||
config,
|
||||
});
|
||||
|
||||
const { registerEvent, registerCommand } = useRegisterCommand({
|
||||
isFocus,
|
||||
updateContext,
|
||||
clearContext,
|
||||
id: uniqId.current,
|
||||
selectedIdRef,
|
||||
tempSelectedMapRef,
|
||||
});
|
||||
|
||||
const {
|
||||
context: createEditResourceContext,
|
||||
onCreateResource,
|
||||
isInEditModeRef,
|
||||
handleRenderList,
|
||||
handleRename,
|
||||
} = useCreateEditResource({
|
||||
folderEnable: !powerBlackMap?.folder,
|
||||
defaultResourceType,
|
||||
registerEvent,
|
||||
setCollapsedMap: handleCollapse,
|
||||
resourceTreeRef,
|
||||
tempSelectedMapRef,
|
||||
selectedIdRef,
|
||||
isFocusRef,
|
||||
resourceMap,
|
||||
onChangeName,
|
||||
onCreate,
|
||||
disabled: disabledRef,
|
||||
onDelete,
|
||||
validateConfig,
|
||||
config,
|
||||
resourceList,
|
||||
});
|
||||
|
||||
const { contextMenuCallback, closeContextMenu } = useRightClickPanel({
|
||||
tempSelectedMapRef,
|
||||
contextMenuHandler,
|
||||
registerCommand,
|
||||
id: uniqId.current,
|
||||
contextMenuDisabled,
|
||||
onContextMenuVisibleChange,
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
handleFocus();
|
||||
},
|
||||
blur: () => {
|
||||
handleBlur();
|
||||
},
|
||||
createResource: (type: string) => {
|
||||
if (isInEditModeRef.current || !type) {
|
||||
return;
|
||||
}
|
||||
onCreateResource?.(type);
|
||||
},
|
||||
renameResource: (resourceId: IdType) => {
|
||||
handleRename(resourceId);
|
||||
},
|
||||
createFolder: () => {
|
||||
if (isInEditModeRef.current) {
|
||||
return;
|
||||
}
|
||||
onCreateResource?.(ResourceTypeEnum.Folder);
|
||||
},
|
||||
expandAll: () => {
|
||||
collapsedMapRef.current = {};
|
||||
setCollapsed(collapsedMapRef.current);
|
||||
},
|
||||
collapseAll: () => {
|
||||
collapsedMapRef.current = Object.keys(resourceMap.current).reduce(
|
||||
(pre, cur) => {
|
||||
if (
|
||||
cur !== ROOT_KEY &&
|
||||
resourceMap.current?.[cur]?.type === 'folder'
|
||||
) {
|
||||
return {
|
||||
...pre,
|
||||
[cur]: true,
|
||||
};
|
||||
}
|
||||
return pre;
|
||||
},
|
||||
{},
|
||||
);
|
||||
setCollapsed(collapsedMapRef.current);
|
||||
},
|
||||
closeContextMenu,
|
||||
}));
|
||||
|
||||
const commonProps = {
|
||||
searchConfig,
|
||||
suffixRender,
|
||||
config,
|
||||
renderMoreSuffix,
|
||||
textRender,
|
||||
contextMenuCallback,
|
||||
resourceTreeWrapperRef,
|
||||
iconRender,
|
||||
isDragging: dragAndDropContext.isDragging,
|
||||
draggingError: dragAndDropContext.draggingError,
|
||||
currentHoverItem: dragAndDropContext.currentHoverItem,
|
||||
validateConfig,
|
||||
errorMsg: createEditResourceContext.errorMsg,
|
||||
errorMsgRef: createEditResourceContext.errorMsgRef,
|
||||
editResourceId: createEditResourceContext.editResourceId,
|
||||
handleChangeName: createEditResourceContext.handleChangeName,
|
||||
handleSave: createEditResourceContext.handleSave,
|
||||
useOptimismUI: _useOptimismUI,
|
||||
};
|
||||
|
||||
const createNode = createEditResourceContext?.createResourceInfo ? (
|
||||
<div key={CREATE_RESOURCE_ID}>
|
||||
<BaseRender
|
||||
resource={{
|
||||
id: CREATE_RESOURCE_ID,
|
||||
name: '',
|
||||
type: createEditResourceContext?.createResourceInfo.type,
|
||||
}}
|
||||
path={[
|
||||
...(resourceMap.current?.[
|
||||
createEditResourceContext?.createResourceInfo.parentId
|
||||
]?.path || []),
|
||||
CREATE_RESOURCE_ID,
|
||||
]}
|
||||
isInEdit={
|
||||
CREATE_RESOURCE_ID === createEditResourceContext.editResourceId
|
||||
}
|
||||
{...commonProps}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const renderResourceList = handleRenderList(
|
||||
resourceList.current,
|
||||
createEditResourceContext?.createResourceInfo,
|
||||
);
|
||||
|
||||
const emptyRender = () => {
|
||||
const list = renderResourceList || [];
|
||||
|
||||
/**
|
||||
* 为空数组,或者数组中只有一个 root 节点
|
||||
*/
|
||||
if (
|
||||
(list.length === 0 ||
|
||||
(list.length === 1 &&
|
||||
list[0] !== CREATE_RESOURCE_ID &&
|
||||
list[0].id === ROOT_KEY)) &&
|
||||
empty
|
||||
) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={uniqId.current}
|
||||
className={`resource-list-wrapper ${s['resource-list-wrapper']}`}
|
||||
ref={resourceTreeWrapperRef}
|
||||
style={style || {}}
|
||||
>
|
||||
<div
|
||||
{...{
|
||||
[`data-${DATASET_PARENT_DATA_STOP_TAG}`]: true,
|
||||
[`data-${DATASET_RESOURCE_FOLDER_KEY}`]: uniqId.current,
|
||||
}}
|
||||
ref={scrollWrapper}
|
||||
className={`${createUniqId(
|
||||
RESOURCE_FOLDER_WRAPPER_CLASS,
|
||||
uniqId.current,
|
||||
)} resource-list-drag-and-drop-wrapper resource-list-custom-event-wrapper resource-list-scroll-container`}
|
||||
onMouseDown={onMouseDownInDiv}
|
||||
onMouseUp={onMouseUpInDiv}
|
||||
onMouseMove={onMouseMove}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onContextMenuCapture={contextMenuCallback}
|
||||
>
|
||||
{renderResourceList.map((resource, i) => {
|
||||
if (resource === CREATE_RESOURCE_ID) {
|
||||
return createNode;
|
||||
}
|
||||
|
||||
if (resource.id === ROOT_KEY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!resource) {
|
||||
return <></>;
|
||||
}
|
||||
const isInEdit =
|
||||
String(resource.id) ===
|
||||
String(createEditResourceContext.editResourceId);
|
||||
const isSelected = String(selected) === String(resource.id);
|
||||
const isTempSelected = !!tempSelectedMapRef.current[resource.id];
|
||||
const preItemTempSelected =
|
||||
resourceList.current[i - 1]?.id !== ROOT_KEY &&
|
||||
!!tempSelectedMapRef.current[resourceList.current[i - 1]?.id];
|
||||
const nextItemTempSelected =
|
||||
!!tempSelectedMapRef.current[resourceList.current[i + 1]?.id];
|
||||
|
||||
const isExpand = !collapsedMapRef.current[resource.id];
|
||||
const highlightItem =
|
||||
!powerBlackMap?.folder && // 不支持文件夹则不需支持拖拽时候的高亮
|
||||
!!dragAndDropContext.highlightItemMap[resource.id];
|
||||
const preHighlightItem =
|
||||
resourceList.current[i - 1]?.id !== ROOT_KEY &&
|
||||
!!dragAndDropContext.highlightItemMap[
|
||||
resourceList.current[i - 1]?.id
|
||||
];
|
||||
const nextHighlightItem =
|
||||
!!dragAndDropContext.highlightItemMap[
|
||||
resourceList.current[i + 1]?.id
|
||||
];
|
||||
|
||||
const extraClassName = [
|
||||
/**
|
||||
* 拖拽过程中的样式
|
||||
*/
|
||||
...(highlightItem
|
||||
? [
|
||||
resource.id !== ROOT_KEY ? 'dragging-hover-class' : '',
|
||||
!preHighlightItem && !nextHighlightItem
|
||||
? 'base-radius-class-single'
|
||||
: '',
|
||||
!preHighlightItem ? 'base-radius-class-first' : '',
|
||||
!nextHighlightItem ? 'base-radius-class-last' : '',
|
||||
]
|
||||
: []),
|
||||
isSelected ? 'item-is-selected' : '',
|
||||
/**
|
||||
* 拖拽过程中的 hover 态优先级 大于 临时选中态的优先级
|
||||
*/
|
||||
...(isTempSelected && !highlightItem
|
||||
? [
|
||||
'item-is-temp-selected',
|
||||
!preItemTempSelected && !nextItemTempSelected
|
||||
? 'base-radius-class-single'
|
||||
: '',
|
||||
!preItemTempSelected ? 'base-radius-class-first' : '',
|
||||
!nextItemTempSelected ? 'base-radius-class-last' : '',
|
||||
]
|
||||
: []),
|
||||
isInEdit ? 'item-is-in-edit' : '',
|
||||
dragAndDropContext.isDragging || isSelected
|
||||
? ''
|
||||
: 'base-item-hover-class',
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
className={`item-wrapper ${extraClassName}`}
|
||||
{...dragAndDropContext.dataHandler(resource)}
|
||||
>
|
||||
<BaseRender
|
||||
resource={resource}
|
||||
path={resource.path || []}
|
||||
isSelected={isSelected}
|
||||
isTempSelected={isTempSelected}
|
||||
isInEdit={isInEdit}
|
||||
isExpand={isExpand}
|
||||
isOptimismSaving={
|
||||
_useOptimismUI && optimismSavingMap[resource.id]
|
||||
}
|
||||
{...commonProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{emptyRender()}
|
||||
{/* 添加 24px 底部间距,标识加载完全 */}
|
||||
<div style={{ padding: 12 }}></div>
|
||||
</div>
|
||||
</div>
|
||||
{ReactDOM.createPortal(dragPreview, document.body)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export {
|
||||
ResourceFolder,
|
||||
ROOT_KEY,
|
||||
type Props as ResourceFolderProps,
|
||||
type RefType as ResourceFolderRefType,
|
||||
mapResourceTree,
|
||||
};
|
||||
|
||||
export {
|
||||
type ResourceType,
|
||||
type ResourceMapType,
|
||||
type CommonRenderProps,
|
||||
type RightPanelConfigType,
|
||||
type RenderMoreSuffixType,
|
||||
ResourceTypeEnum,
|
||||
type ResourceFolderContextType as ResourceFolderShortCutContextType,
|
||||
type CreateResourcePropType,
|
||||
type IdType,
|
||||
} from './type';
|
||||
|
||||
export {
|
||||
BaseResourceContextMenuBtnType,
|
||||
RESOURCE_FOLDER_CONTEXT_KEY,
|
||||
} from './constant';
|
||||
@@ -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 React from 'react';
|
||||
|
||||
import { type CommonComponentProps, ResourceTypeEnum } from '../type';
|
||||
import { FolderRender } from './folder-render';
|
||||
import { FileRender } from './file-render';
|
||||
|
||||
const BaseRender: React.FC<CommonComponentProps> = ({ ...props }) => {
|
||||
const { resource, path } = props;
|
||||
|
||||
const Component =
|
||||
resource.type === ResourceTypeEnum.Folder ? FolderRender : FileRender;
|
||||
if (!Component) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
key={`base-render-${resource.id}`}
|
||||
{...props}
|
||||
path={[...path, resource.id]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { BaseRender };
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { type CommonComponentProps } from '../../type';
|
||||
import { ITEM_HEIGHT, ItemStatus, TAB_SIZE } from '../../constant';
|
||||
import { NameInput } from './name-input';
|
||||
import { MoreTools } from './more-tools';
|
||||
import { MemoText } from './memo-text';
|
||||
|
||||
const ItemRender = ({
|
||||
resource,
|
||||
path,
|
||||
icon,
|
||||
isSelected,
|
||||
isTempSelected,
|
||||
isInEdit,
|
||||
searchConfig,
|
||||
suffixRender,
|
||||
config,
|
||||
renderMoreSuffix,
|
||||
textRender,
|
||||
isDragging,
|
||||
useOptimismUI,
|
||||
isOptimismSaving,
|
||||
contextMenuCallback,
|
||||
resourceTreeWrapperRef,
|
||||
...props
|
||||
}: CommonComponentProps) => {
|
||||
const { name, status } = resource;
|
||||
|
||||
const optimismUILoading = useMemo(() => {
|
||||
if (isOptimismSaving) {
|
||||
if (typeof useOptimismUI === 'object' && useOptimismUI.loadingRender) {
|
||||
return useOptimismUI.loadingRender();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [isOptimismSaving]);
|
||||
|
||||
const suffix = useMemo(
|
||||
() =>
|
||||
!isInEdit &&
|
||||
suffixRender?.render?.({
|
||||
resource,
|
||||
isSelected,
|
||||
isTempSelected,
|
||||
}),
|
||||
[isSelected, isInEdit, resource, isTempSelected],
|
||||
);
|
||||
|
||||
const moreTools = useMemo(
|
||||
() =>
|
||||
!isInEdit && renderMoreSuffix ? (
|
||||
<MoreTools
|
||||
resource={resource}
|
||||
contextMenuCallback={contextMenuCallback}
|
||||
resourceTreeWrapperRef={resourceTreeWrapperRef}
|
||||
renderMoreSuffix={renderMoreSuffix}
|
||||
/>
|
||||
) : null,
|
||||
[isInEdit, resource, renderMoreSuffix],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`agent-ide.resource-item.${resource.type}.${resource.name}`}
|
||||
key={resource.id}
|
||||
className={'base-item'}
|
||||
style={{
|
||||
justifyContent: 'space-between',
|
||||
height: config?.itemHeight || ITEM_HEIGHT,
|
||||
borderRadius: 4,
|
||||
paddingLeft: (path.length - 1) * (config?.tabSize || TAB_SIZE) - 4,
|
||||
...(status === ItemStatus.Disabled
|
||||
? {
|
||||
fontStyle: 'italic',
|
||||
filter: 'opacity(0.5)',
|
||||
cursor: 'not-allowed',
|
||||
textDecoration: 'line-through',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
overflow: isInEdit ? 'visible' : 'hidden',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
className={'base-item-icon'}
|
||||
style={{
|
||||
color: 'rgba(6, 7, 9, 0.96)',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
{isInEdit ? (
|
||||
<NameInput
|
||||
resource={resource}
|
||||
initValue={name}
|
||||
handleSave={props.handleSave}
|
||||
handleChangeName={props.handleChangeName}
|
||||
errorMsg={props.errorMsg}
|
||||
errorMsgRef={props.errorMsgRef}
|
||||
validateConfig={props.validateConfig}
|
||||
config={config}
|
||||
/>
|
||||
) : (
|
||||
<MemoText
|
||||
isSelected={isSelected}
|
||||
resource={resource}
|
||||
name={name}
|
||||
searchConfig={searchConfig}
|
||||
tooltipSpace={
|
||||
(suffixRender?.width || 0) + (renderMoreSuffix ? 26 : 0)
|
||||
}
|
||||
textRender={textRender}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{optimismUILoading}
|
||||
{suffix}
|
||||
{moreTools}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ItemRender };
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* 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, { memo } from 'react';
|
||||
|
||||
import { Typography, Highlight } from '@coze-arch/coze-design';
|
||||
|
||||
import {
|
||||
type CommonRenderProps,
|
||||
// ResourceTypeEnum,
|
||||
// type ResourceStatusType,
|
||||
type ResourceType,
|
||||
} from '../../type';
|
||||
import { COLOR_CONFIG } from '../../constant';
|
||||
|
||||
const { Text: BaseText } = Typography;
|
||||
|
||||
// const ResourceBadge = (props: {
|
||||
// resource: ResourceType;
|
||||
// status?: ResourceStatusType;
|
||||
// }) => {
|
||||
// const { resource, status = {} } = props;
|
||||
// const badgeTextArr: string[] = [];
|
||||
|
||||
// if (status.problem?.number) {
|
||||
// badgeTextArr.push(`${status?.problem?.number}`);
|
||||
// }
|
||||
// if (status.draft) {
|
||||
// badgeTextArr.push('M');
|
||||
// }
|
||||
// const badgeText = badgeTextArr.join(', ');
|
||||
// const level =
|
||||
// status.problem?.status && status.problem.status !== 'normal'
|
||||
// ? status.problem.status
|
||||
// : status.draft
|
||||
// ? 'warning'
|
||||
// : '';
|
||||
|
||||
// return badgeText ? (
|
||||
// <span
|
||||
// style={{
|
||||
// marginRight: 4,
|
||||
// opacity: '0.75',
|
||||
// color:
|
||||
// level === 'error'
|
||||
// ? 'rgba(var(--blockwise-error-color))'
|
||||
// : 'rgba(var(--blockwise-warning-color))',
|
||||
// }}
|
||||
// >
|
||||
// {resource.type !== ResourceTypeEnum.Folder ? (
|
||||
// badgeText
|
||||
// ) : (
|
||||
// <Badge countStyle={{ backgroundColor: 'yellow' }} type="mini" />
|
||||
// )}
|
||||
// </span>
|
||||
// ) : (
|
||||
// <></>
|
||||
// );
|
||||
// };
|
||||
|
||||
const Text = ({
|
||||
name,
|
||||
resource,
|
||||
searchConfig,
|
||||
isSelected,
|
||||
tooltipSpace,
|
||||
textRender,
|
||||
}: {
|
||||
name: string;
|
||||
resource: ResourceType;
|
||||
searchConfig?: {
|
||||
searchKey?: string;
|
||||
highlightStyle?: React.CSSProperties;
|
||||
};
|
||||
isSelected?: boolean;
|
||||
tooltipSpace?: number;
|
||||
textRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
}) => {
|
||||
const color = (() => {
|
||||
if (resource.problem?.status === 'error') {
|
||||
return COLOR_CONFIG.textErrorColor;
|
||||
} else if (resource.problem?.status === 'warning') {
|
||||
return COLOR_CONFIG.textWarningColor;
|
||||
} else if (isSelected) {
|
||||
return COLOR_CONFIG.textSelectedColor;
|
||||
}
|
||||
return COLOR_CONFIG.textNormalColor;
|
||||
})();
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
overflow: 'hidden',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<BaseText
|
||||
style={{ flex: 1 }}
|
||||
ellipsis={{
|
||||
showTooltip: {
|
||||
opts: {
|
||||
content: `${name}`,
|
||||
style: { wordBreak: 'break-all' },
|
||||
position: 'right',
|
||||
spacing: 8 + (tooltipSpace || 0),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{textRender ? (
|
||||
textRender({ resource, isSelected })
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
color,
|
||||
}}
|
||||
>
|
||||
<Highlight
|
||||
sourceString={name}
|
||||
searchWords={[searchConfig?.searchKey || '']}
|
||||
highlightStyle={{
|
||||
...searchConfig?.highlightStyle,
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--semi-color-primary)',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</BaseText>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoText = memo(Text, (pre, cur) => {
|
||||
if (
|
||||
pre.name !== cur.name ||
|
||||
pre.searchConfig?.searchKey !== cur.searchConfig?.searchKey ||
|
||||
pre.resource !== cur.resource ||
|
||||
pre.isSelected !== cur.isSelected
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export { MemoText };
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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 { IconCozMore } from '@coze-arch/coze-design/icons';
|
||||
import { Button, Tooltip } from '@coze-arch/coze-design';
|
||||
|
||||
import { type RenderMoreSuffixType, type ResourceType } from '../../type';
|
||||
import { MORE_TOOLS_CLASS_NAME } from '../../constant';
|
||||
|
||||
const MoreTools = ({
|
||||
resource,
|
||||
contextMenuCallback,
|
||||
resourceTreeWrapperRef,
|
||||
renderMoreSuffix,
|
||||
}: {
|
||||
resource: ResourceType;
|
||||
contextMenuCallback: (e: any, resources?: ResourceType[]) => () => void;
|
||||
resourceTreeWrapperRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
renderMoreSuffix?: RenderMoreSuffixType;
|
||||
}) => {
|
||||
const handleClick = e => {
|
||||
/**
|
||||
* 这里将 event 的 currentTarget 设置成树组件的 wrapper 元素,保证 contextMenu 的 matchItems 方法可以正常遍历。
|
||||
*/
|
||||
e.currentTarget = resourceTreeWrapperRef.current;
|
||||
contextMenuCallback(e, [resource]);
|
||||
};
|
||||
|
||||
const btnElm = (
|
||||
<Button
|
||||
data-testid={`agent-ide.resource-item.${resource.type}.${resource.name}.more-tools`}
|
||||
{...(typeof renderMoreSuffix === 'object' && renderMoreSuffix?.extraProps
|
||||
? renderMoreSuffix?.extraProps
|
||||
: {})}
|
||||
className={`base-item-more-hover-display-class ${MORE_TOOLS_CLASS_NAME} base-item-more-btn ${
|
||||
typeof renderMoreSuffix === 'object' && renderMoreSuffix.className
|
||||
? renderMoreSuffix.className
|
||||
: ''
|
||||
}`}
|
||||
style={
|
||||
typeof renderMoreSuffix === 'object' && renderMoreSuffix.style
|
||||
? renderMoreSuffix.style
|
||||
: {}
|
||||
}
|
||||
icon={<IconCozMore />}
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onMouseUp={handleClick}
|
||||
/>
|
||||
);
|
||||
|
||||
if (typeof renderMoreSuffix === 'object' && renderMoreSuffix.render) {
|
||||
return renderMoreSuffix.render({
|
||||
onActive: handleClick,
|
||||
baseBtn: btnElm,
|
||||
resource,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof renderMoreSuffix === 'object' && renderMoreSuffix.tooltip) {
|
||||
if (typeof renderMoreSuffix.tooltip === 'string') {
|
||||
return <Tooltip content={renderMoreSuffix.tooltip}>{btnElm}</Tooltip>;
|
||||
}
|
||||
return <Tooltip {...renderMoreSuffix.tooltip}>{btnElm}</Tooltip>;
|
||||
}
|
||||
|
||||
return btnElm;
|
||||
};
|
||||
|
||||
export { MoreTools };
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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, useRef, useState } from 'react';
|
||||
|
||||
import { Input } from '@coze-arch/coze-design';
|
||||
|
||||
import { type CommonComponentProps } from '../../type';
|
||||
import { MOUSEUP_IGNORE_CLASS_NAME } from '../../constant';
|
||||
|
||||
const DATASET_PARENT_DATA_KEY_ID = 'name_input_wrapper';
|
||||
|
||||
const isClickOutside = (elm, deep = 0) => {
|
||||
if (!elm || deep > 10) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (elm.dataset?.[DATASET_PARENT_DATA_KEY_ID] !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isClickOutside(elm.parentElement, deep + 1);
|
||||
};
|
||||
|
||||
const NameInput = ({
|
||||
resource,
|
||||
initValue,
|
||||
handleSave: onSave,
|
||||
handleChangeName,
|
||||
errorMsg,
|
||||
errorMsgRef,
|
||||
validateConfig,
|
||||
config,
|
||||
}: { initValue: string } & Pick<
|
||||
CommonComponentProps,
|
||||
| 'resource'
|
||||
| 'handleSave'
|
||||
| 'handleChangeName'
|
||||
| 'errorMsg'
|
||||
| 'errorMsgRef'
|
||||
| 'validateConfig'
|
||||
| 'config'
|
||||
>) => {
|
||||
const [value, setValue] = useState(initValue);
|
||||
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(errorMsgRef?.current ? initValue : undefined);
|
||||
};
|
||||
|
||||
const loaded = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
loaded.current = true;
|
||||
}, 0);
|
||||
|
||||
const handleBlur = (e: MouseEvent) => {
|
||||
const clickOutside = isClickOutside(e.target);
|
||||
if (clickOutside) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener('mousedown', handleBlur, true);
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handleBlur, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...{
|
||||
[`data-${DATASET_PARENT_DATA_KEY_ID}`]: true,
|
||||
}}
|
||||
className={`base-item-name-input ${MOUSEUP_IGNORE_CLASS_NAME} ${
|
||||
errorMsg ? 'base-item-name-input-error' : ''
|
||||
}`}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Input
|
||||
className={config?.input?.className || ''}
|
||||
style={{ padding: 0, ...config?.input?.style }}
|
||||
ref={ref}
|
||||
placeholder={config?.input?.placeholder}
|
||||
onMouseDown={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.code === 'Escape') {
|
||||
onSave('');
|
||||
}
|
||||
}}
|
||||
onEnterPress={e => {
|
||||
if (!loaded.current) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
onChange={v => {
|
||||
setValue(v);
|
||||
handleChangeName(v);
|
||||
}}
|
||||
value={value}
|
||||
autoFocus
|
||||
/>
|
||||
{errorMsg ? (
|
||||
validateConfig?.errorMsgRender ? (
|
||||
validateConfig?.errorMsgRender?.(errorMsg, resource)
|
||||
) : (
|
||||
<div
|
||||
style={validateConfig?.errorMsgStyle || {}}
|
||||
className={`base-item-name-input-error-msg-absolute ${validateConfig?.errorMsgClassName}`}
|
||||
>
|
||||
{errorMsg}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export { NameInput };
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 CommonComponentProps } from '../type';
|
||||
import { ItemRender } from './components/item-render';
|
||||
|
||||
const FileRender: React.FC<CommonComponentProps> = ({
|
||||
resource,
|
||||
path,
|
||||
...props
|
||||
}) => {
|
||||
const { isDragging, draggingError, isSelected, isTempSelected, iconRender } =
|
||||
props;
|
||||
|
||||
const cursor = (() => {
|
||||
if (draggingError) {
|
||||
return 'not-allowed';
|
||||
} else if (isDragging) {
|
||||
return 'grabbing';
|
||||
}
|
||||
return 'pointer';
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`file-${resource.id}`}
|
||||
style={{
|
||||
cursor,
|
||||
}}
|
||||
>
|
||||
<ItemRender
|
||||
resource={resource}
|
||||
path={path}
|
||||
icon={
|
||||
resource?.type
|
||||
? iconRender?.({
|
||||
resource,
|
||||
isSelected,
|
||||
isTempSelected,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { FileRender };
|
||||
@@ -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 from 'react';
|
||||
|
||||
import { type CommonComponentProps } from '../type';
|
||||
import { ItemRender } from './components/item-render';
|
||||
|
||||
const FolderRender: React.FC<CommonComponentProps> = ({
|
||||
resource,
|
||||
path,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const { id } = resource;
|
||||
|
||||
const {
|
||||
iconRender,
|
||||
isSelected,
|
||||
isTempSelected,
|
||||
isExpand,
|
||||
draggingError,
|
||||
isDragging,
|
||||
} = props;
|
||||
|
||||
const cursor = (() => {
|
||||
if (draggingError) {
|
||||
return 'not-allowed';
|
||||
} else if (isDragging) {
|
||||
return 'grabbing';
|
||||
}
|
||||
return 'default';
|
||||
})();
|
||||
|
||||
const isRoot = path.length === 1;
|
||||
|
||||
// const { parentId: createResourceParentId, type: createResourceType } =
|
||||
// createResourceInfo || {};
|
||||
|
||||
// const { renderCreateNode, appendIndex, itemElm } = (() => {
|
||||
// if (String(createResourceParentId) === String(id)) {
|
||||
// return {
|
||||
// renderCreateNode: true,
|
||||
// appendIndex:
|
||||
// createResourceType === ResourceTypeEnum.Folder
|
||||
// ? 0
|
||||
// : children?.findIndex(
|
||||
// child => child.type !== ResourceTypeEnum.Folder,
|
||||
// ),
|
||||
// itemElm: (
|
||||
// <BaseRender
|
||||
// resource={{
|
||||
// id: CREATE_RESOURCE_ID,
|
||||
// name: '',
|
||||
// type: createResourceType,
|
||||
// }}
|
||||
// path={path}
|
||||
// />
|
||||
// ),
|
||||
// };
|
||||
// }
|
||||
|
||||
// return {
|
||||
// renderCreateNode: false,
|
||||
// appendIndex: -1,
|
||||
// itemElm: null,
|
||||
// };
|
||||
// })();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={`folder-${id}`}
|
||||
style={{
|
||||
...(style || {}),
|
||||
cursor,
|
||||
}}
|
||||
>
|
||||
{!isRoot && (
|
||||
<ItemRender
|
||||
resource={resource}
|
||||
path={path}
|
||||
icon={
|
||||
iconRender
|
||||
? iconRender({
|
||||
resource,
|
||||
isSelected,
|
||||
isTempSelected,
|
||||
isExpand,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { FolderRender };
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type React from 'react';
|
||||
import { type MutableRefObject } from 'react';
|
||||
|
||||
import {
|
||||
type TooltipProps,
|
||||
type ButtonProps,
|
||||
type SemiButton,
|
||||
} from '@coze-arch/coze-design';
|
||||
import { type URI } from '@coze-project-ide/client';
|
||||
|
||||
import { type BaseResourceContextMenuBtnType } from './hooks/use-right-click-panel/constant';
|
||||
import { type CREATE_RESOURCE_ID } from './hooks/use-create-edit-resource';
|
||||
|
||||
export type IdType = string;
|
||||
|
||||
export enum ResourceTypeEnum {
|
||||
Folder = 'folder',
|
||||
}
|
||||
|
||||
export type ItemType = ResourceTypeEnum | string;
|
||||
|
||||
export interface ResourceStatusType {
|
||||
// 是否是草稿态
|
||||
draft?: boolean;
|
||||
// 错误内容 & 个数
|
||||
problem?: {
|
||||
status?: 'normal' | 'error' | 'warning';
|
||||
number?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResourceType {
|
||||
id: IdType;
|
||||
type?: ItemType;
|
||||
name: string;
|
||||
description?: string;
|
||||
children?: ResourceType[];
|
||||
path?: string[];
|
||||
maxDeep?: number;
|
||||
[T: string]: any;
|
||||
}
|
||||
|
||||
export type ResourceMapType<
|
||||
T = {
|
||||
[T: string]: any;
|
||||
},
|
||||
> = Record<IdType, ResourceType & ResourceStatusType & T>;
|
||||
|
||||
export interface CustomResourceConfigType extends ResourceStatusType {
|
||||
[T: string]: any;
|
||||
}
|
||||
|
||||
export type CustomResourceConfigMapType = Record<
|
||||
IdType,
|
||||
CustomResourceConfigType
|
||||
>;
|
||||
|
||||
export type RenderMoreSuffixType =
|
||||
| boolean
|
||||
| {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
extraProps?: ButtonProps & React.RefAttributes<SemiButton>;
|
||||
render?: (
|
||||
v: {
|
||||
onActive: (e: React.MouseEventHandler<HTMLButtonElement>) => void;
|
||||
baseBtn: React.ReactElement;
|
||||
} & CommonRenderProps,
|
||||
) => React.ReactElement;
|
||||
tooltip?: string | TooltipProps;
|
||||
};
|
||||
|
||||
export interface CommonComponentProps {
|
||||
resource: ResourceType;
|
||||
path: Array<IdType>;
|
||||
style?: React.CSSProperties;
|
||||
isTempSelected?: boolean;
|
||||
isSelected?: boolean;
|
||||
isInEdit?: boolean;
|
||||
isDraggingHover?: boolean;
|
||||
icon?: React.ReactElement;
|
||||
searchConfig?: {
|
||||
searchKey?: string;
|
||||
highlightStyle?: React.CSSProperties;
|
||||
};
|
||||
suffixRender?: {
|
||||
width: number;
|
||||
render: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
};
|
||||
config?: ConfigType;
|
||||
renderMoreSuffix?: RenderMoreSuffixType;
|
||||
textRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
isDragging: boolean;
|
||||
isExpand?: boolean;
|
||||
/**
|
||||
* 是否处于乐观 ui 的保存阶段
|
||||
*/
|
||||
isOptimismSaving?: boolean;
|
||||
useOptimismUI?:
|
||||
| boolean
|
||||
| {
|
||||
loadingRender?: () => React.ReactElement;
|
||||
};
|
||||
draggingError?: string;
|
||||
contextMenuCallback: (e: any, resources?: ResourceType[]) => () => void;
|
||||
resourceTreeWrapperRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
iconRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
currentHoverItem: ResourceType | null;
|
||||
validateConfig?: ValidatorConfigType;
|
||||
errorMsg?: string;
|
||||
errorMsgRef?: MutableRefObject<string>;
|
||||
editResourceId: IdType | undefined;
|
||||
handleChangeName: (v: string) => void;
|
||||
handleSave: (v?: string) => void;
|
||||
}
|
||||
|
||||
export interface DragPropType {
|
||||
resourceList?: ResourceType[];
|
||||
toId?: IdType;
|
||||
errorMsg?: string;
|
||||
}
|
||||
|
||||
export interface ChangeNameType {
|
||||
id: IdType;
|
||||
name: string;
|
||||
type?: ItemType;
|
||||
path?: IdType[];
|
||||
resource?: ResourceType;
|
||||
}
|
||||
export interface DragAndDropType {
|
||||
isDragging: boolean;
|
||||
draggingError?: string;
|
||||
isFocus: boolean;
|
||||
tempSelectedMapRef: React.MutableRefObject<Record<string, ResourceType>>;
|
||||
dataHandler: (resource: ResourceType) => Record<string, IdType>;
|
||||
currentHoverItem: ResourceType | null;
|
||||
highlightItemMap: ResourceMapType;
|
||||
}
|
||||
|
||||
export interface EditItemType {
|
||||
isInEditMode: boolean;
|
||||
errorMsg?: string;
|
||||
errorMsgRef?: MutableRefObject<string>;
|
||||
editResourceId: IdType | undefined;
|
||||
createResourceInfo: {
|
||||
parentId: IdType;
|
||||
type: ItemType;
|
||||
index: number;
|
||||
} | null;
|
||||
handleChangeName: (v: string) => void;
|
||||
handleSave: (v?: string) => void;
|
||||
}
|
||||
|
||||
export interface CommonRenderProps {
|
||||
resource: ResourceType;
|
||||
isSelected?: boolean;
|
||||
isTempSelected?: boolean;
|
||||
isExpand?: boolean /** 只有 resource.type === folder 的时候才会有该字段 */;
|
||||
}
|
||||
|
||||
export interface ContextType {
|
||||
selected?: IdType;
|
||||
disabled?: boolean;
|
||||
collapsedMapRef: React.MutableRefObject<Record<IdType, boolean> | null>;
|
||||
setCollapsed: (id: IdType, v: boolean) => void;
|
||||
searchConfig?: {
|
||||
searchKey?: string;
|
||||
highlightStyle?: React.CSSProperties;
|
||||
};
|
||||
resourceTreeWrapperRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
resourceMap: React.MutableRefObject<ResourceMapType>;
|
||||
dragAndDropContext: DragAndDropType;
|
||||
createEditResourceContext: EditItemType;
|
||||
renderMoreSuffix?:
|
||||
| boolean
|
||||
| {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
render?: () => React.ReactElement;
|
||||
};
|
||||
validateConfig?: ValidatorConfigType;
|
||||
contextMenuCallback: (e: any, resources?: ResourceType[]) => () => void;
|
||||
iconRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
suffixRender?: {
|
||||
width: number;
|
||||
render: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
};
|
||||
textRender?: (v: CommonRenderProps) => React.ReactElement | undefined;
|
||||
config?: ConfigType;
|
||||
optimismSavingMap: Record<IdType, true>;
|
||||
useOptimismUI?:
|
||||
| boolean
|
||||
| {
|
||||
loadingRender?: () => React.ReactElement;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomValidatorPropsType {
|
||||
type: 'create' | 'edit';
|
||||
label: string;
|
||||
parentPath: string[];
|
||||
resourceTree: ResourceType;
|
||||
id: IdType | typeof CREATE_RESOURCE_ID;
|
||||
}
|
||||
|
||||
export interface ValidatorConfigType {
|
||||
/**
|
||||
* @param label 当前输入框的文本
|
||||
* @param parentPath 从 root 开始到 父级的路径
|
||||
* @param resourceTree 当前的资源树
|
||||
* @returns Promise<string>
|
||||
*/
|
||||
/** */
|
||||
customValidator?: (
|
||||
data: CustomValidatorPropsType,
|
||||
) => string | Promise<string>;
|
||||
|
||||
/**
|
||||
* 默认: absolute;
|
||||
* absolute: 不占用树的文档流,错误文案覆盖上去;
|
||||
*/
|
||||
errorMsgPosition?: 'absolute';
|
||||
errorMsgStyle?: React.CSSProperties;
|
||||
errorMsgClassName?: string;
|
||||
errorMsgRender?: (msg: string, resource: ResourceType) => React.ReactElement;
|
||||
}
|
||||
|
||||
export interface RightOptionsType {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactElement;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
disabledMsg?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateResourcePropType {
|
||||
type: ItemType;
|
||||
parentId: IdType;
|
||||
path: IdType[];
|
||||
name: string;
|
||||
schema?: any;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CommandOption {
|
||||
id: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
disabledMsg?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/** 内置的顶部工具栏的按钮组 */
|
||||
export enum BuildInToolOperations {
|
||||
/** 搜索过滤按钮 */
|
||||
CreateFile = 'CreateFile',
|
||||
CreateFolder = 'CreateFolder',
|
||||
/** 展开收起文件夹 */
|
||||
ExpandFolder = 'ExpandFolder',
|
||||
}
|
||||
|
||||
export type RightPanelConfigType =
|
||||
| {
|
||||
// command id
|
||||
id: BaseResourceContextMenuBtnType | string;
|
||||
label?: string;
|
||||
shortLabel?: string;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
|
||||
/**
|
||||
* 这里有两类 命令。
|
||||
* 1. 不在外部插件中提前注册好的,需要组件内部动态注册的。往往是该组件树定制化的菜单项。比如 校验并运行该资源
|
||||
* 那这个 execute 字段是一个必填项
|
||||
* 2. 在外部插件中提前注册好的,往往是需要配合快捷键一起使用的,并且具有一定的普适性的命令。比如创建资源,复制资源等。
|
||||
* 那这个 execute 不需要配置,的回调在 plugin 中注册的地方触发
|
||||
* 上下文可以通过 RESOURCE_FOLDER_CONTEXT_KEY 这个 key 从 ide 上下文中获取
|
||||
*/
|
||||
execute?: () => void;
|
||||
}
|
||||
| {
|
||||
// 分割线
|
||||
type: 'separator';
|
||||
};
|
||||
|
||||
export interface ConfigType {
|
||||
/**
|
||||
* 每个资源的高度
|
||||
*/
|
||||
itemHeight?: number;
|
||||
/**
|
||||
* 半个 icon 的宽度,用于左侧折线的计算。
|
||||
*/
|
||||
halfIconWidth?: number;
|
||||
/**
|
||||
* 每个文件夹下缩进的宽度
|
||||
*/
|
||||
tabSize?: number;
|
||||
|
||||
/**
|
||||
* 文件夹下钻最大的深度
|
||||
*/
|
||||
maxDeep?: number;
|
||||
|
||||
/**
|
||||
* 资源 name 输入框配置
|
||||
*/
|
||||
input?: {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 拖拽过程中的预览框配置项
|
||||
*/
|
||||
dragUi?: {
|
||||
disable?: boolean;
|
||||
wrapperClassName?: string;
|
||||
|
||||
/**
|
||||
* 特别说明: 可以通过配置这里的 top 和 left 来设置相对鼠标的偏移量
|
||||
*/
|
||||
wrapperStyle?: React.CSSProperties;
|
||||
};
|
||||
|
||||
resourceUriHandler?: (resource: ResourceType) => URI | null;
|
||||
}
|
||||
|
||||
export interface ResourceFolderContextType {
|
||||
id?: string; // folder 组件唯一的 id
|
||||
currentSelectedId?: IdType; // 当前选中的资源 id
|
||||
tempSelectedMap?: Record<string, ResourceType>; // 当前临时选中的资源 map
|
||||
onEnter?: () => void;
|
||||
onDelete?: () => void;
|
||||
onCreateFolder?: () => void;
|
||||
onCreateResource?: (type?: ResourceTypeEnum) => void;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Any = any;
|
||||
|
||||
type BaseEventParamsType = Record<string, Any>;
|
||||
|
||||
export class BaseEvent<T extends string, O extends BaseEventParamsType> {
|
||||
protected deps: Record<T, ((data?: Any) => Any)[]>;
|
||||
|
||||
constructor() {
|
||||
this.deps = {} as any;
|
||||
}
|
||||
|
||||
on(name: T, func: (data?: O[T]) => Any): void {
|
||||
if (this.deps[name]) {
|
||||
this.deps[name].push(func);
|
||||
} else {
|
||||
this.deps[name] = [func];
|
||||
}
|
||||
}
|
||||
|
||||
once(name: T, func: (data?: O[T]) => Any): void {
|
||||
const f = (data: Any) => {
|
||||
func(data);
|
||||
this.un(name, f);
|
||||
};
|
||||
if (this.deps[name]) {
|
||||
this.deps[name].push(f);
|
||||
} else {
|
||||
this.deps[name] = [f];
|
||||
}
|
||||
}
|
||||
|
||||
un(name?: T, func?: (data?: O[T]) => Any): void {
|
||||
if (!name) {
|
||||
this.deps = {} as any;
|
||||
return;
|
||||
}
|
||||
if (func) {
|
||||
this.deps[name] = this.deps[name].filter(fn => fn !== func);
|
||||
} else {
|
||||
delete this.deps[name];
|
||||
}
|
||||
}
|
||||
|
||||
emit(name: T, data?: O[T]): void {
|
||||
if (this.deps[name]) {
|
||||
this.deps[name].forEach(fn => fn(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
/*
|
||||
* 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 IdType,
|
||||
type ResourceType,
|
||||
type ResourceMapType,
|
||||
ResourceTypeEnum,
|
||||
} from '../type';
|
||||
import { ROOT_KEY, ROOT_NODE } from '../constant';
|
||||
|
||||
export const RESOURCE_FOLDER_COMMAND_PREFIX = 'resource-folder-command-prefix';
|
||||
|
||||
export const createUniqId = (key: string, suffix: string) =>
|
||||
`${RESOURCE_FOLDER_COMMAND_PREFIX}_${key}_${suffix}`;
|
||||
|
||||
export const findResourceByPath = (
|
||||
resourceTree: ResourceType,
|
||||
path: IdType[],
|
||||
): ResourceType | undefined => {
|
||||
let currentIndex = 0;
|
||||
let currentResource: undefined | ResourceType = resourceTree;
|
||||
|
||||
if (String(currentResource.id) !== String(path[currentIndex])) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
|
||||
while (currentIndex < path.length && currentResource) {
|
||||
currentResource = (currentResource.children || []).find(
|
||||
child => String(child.id) === String(path[currentIndex]),
|
||||
);
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
return currentResource;
|
||||
};
|
||||
|
||||
export const getParentResource = (
|
||||
resourceTree: ResourceType,
|
||||
targetResource: ResourceType,
|
||||
) => getResourceById(resourceTree, targetResource.id)?.parent;
|
||||
|
||||
export const getResourceById = (
|
||||
resourceTree: ResourceType,
|
||||
id: IdType,
|
||||
): {
|
||||
resource: ResourceType | null;
|
||||
parent: ResourceType | null;
|
||||
path: IdType[];
|
||||
} | null => {
|
||||
let parent: ResourceType | null = null;
|
||||
|
||||
let result: {
|
||||
resource: ResourceType | null;
|
||||
parent: ResourceType | null;
|
||||
path: IdType[];
|
||||
} | null = null;
|
||||
const dfs = (resource: ResourceType, _path: IdType[]) => {
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(resource.id) === String(id)) {
|
||||
result = {
|
||||
resource,
|
||||
parent,
|
||||
path: _path,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (resource.children) {
|
||||
const currentParent = parent;
|
||||
parent = resource;
|
||||
resource.children.forEach(child => {
|
||||
dfs(child, [..._path, child.id]);
|
||||
});
|
||||
parent = currentParent;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
dfs(resourceTree, [resourceTree.id]);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用 shift 修饰键的时候, 从 from 的到 to 的中间全部选中,包括下钻文件。 dfs
|
||||
*/
|
||||
export const getResourceListFromIdToId = ({
|
||||
resourceTree,
|
||||
from,
|
||||
to,
|
||||
options,
|
||||
}: {
|
||||
resourceTree: ResourceType;
|
||||
from: IdType;
|
||||
to: IdType;
|
||||
options?: { collapsedMap?: Record<string, boolean> };
|
||||
}): ResourceType[] | IdType[] => {
|
||||
const { collapsedMap } = options || {};
|
||||
const result: Array<IdType> = [];
|
||||
let isStart = false;
|
||||
|
||||
const dfs = (resource: ResourceType, _path: IdType[]) => {
|
||||
const shot =
|
||||
String(resource.id) === String(from) ||
|
||||
String(resource.id) === String(to);
|
||||
if (!isStart && shot) {
|
||||
isStart = true;
|
||||
result.push(resource.id);
|
||||
} else if (isStart) {
|
||||
result.push(resource.id);
|
||||
if (shot) {
|
||||
isStart = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (resource.children && !collapsedMap?.[resource.id]) {
|
||||
resource.children.forEach(child => {
|
||||
dfs(child, [..._path, child.id]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
dfs(resourceTree, [resourceTree.id]);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getAllResourcesInFolder = (
|
||||
resourceTree: ResourceType | ResourceType[],
|
||||
) => {
|
||||
const folders = resourceTree instanceof Array ? resourceTree : [resourceTree];
|
||||
|
||||
const result: ResourceType[] = [];
|
||||
|
||||
const dfs = (resource: ResourceType) => {
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resource.children) {
|
||||
resource.children.forEach(child => {
|
||||
dfs(child);
|
||||
});
|
||||
}
|
||||
|
||||
if (resource.type !== 'folder') {
|
||||
result.push(resource);
|
||||
}
|
||||
};
|
||||
|
||||
folders.forEach(folder => {
|
||||
dfs(folder);
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const sortResourceList = (
|
||||
resourceList: ResourceType[],
|
||||
): ResourceType[] => {
|
||||
const sortFunc = (a, b) => {
|
||||
const leftName = a.name?.toLowerCase?.() || '';
|
||||
const rightName = b.name?.toLowerCase?.() || '';
|
||||
if (leftName < rightName) {
|
||||
return -1;
|
||||
}
|
||||
if (leftName > rightName) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const folderList = resourceList
|
||||
.filter(source => source.type === ResourceTypeEnum.Folder)
|
||||
.sort((a, b) => sortFunc(a, b));
|
||||
const sourceList = resourceList
|
||||
.filter(source => source.type !== ResourceTypeEnum.Folder)
|
||||
.sort((a, b) => sortFunc(a, b));
|
||||
|
||||
return folderList.concat(sourceList) as ResourceType[];
|
||||
};
|
||||
|
||||
// 后续要优化算法的话 ,得在树打平的算法中,记录每个文件夹的高度,这样在 change 的时候不需要重复计算。 packages/api-builder/base/src/utils/resource-folder/index.ts mapResourceTree
|
||||
export const calcOffsetTopByCollapsedMap = (props: {
|
||||
selectedId: string;
|
||||
resourceTree: ResourceType;
|
||||
collapsedMap: Record<IdType, boolean>;
|
||||
itemHeight: number;
|
||||
}) => {
|
||||
const { selectedId, resourceTree, collapsedMap, itemHeight } = props;
|
||||
|
||||
let num = -1; // 因为从 root 开始, root 不展示,所以从 -1 开始算
|
||||
let finish = false;
|
||||
|
||||
const dfs = (resource: ResourceType) => {
|
||||
if (!resource || finish) {
|
||||
return;
|
||||
}
|
||||
|
||||
num += 1;
|
||||
|
||||
if (selectedId === resource.id) {
|
||||
finish = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (resource.children && !collapsedMap[resource.id]) {
|
||||
resource.children.forEach(child => {
|
||||
dfs(child);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
dfs(resourceTree);
|
||||
|
||||
return (num - 1) * itemHeight;
|
||||
};
|
||||
|
||||
export const travelResource = (
|
||||
resource: ResourceType,
|
||||
cb: (item: ResourceType) => boolean,
|
||||
) => {
|
||||
if (resource) {
|
||||
const shouldContinue = cb(resource);
|
||||
|
||||
if (!shouldContinue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resource.children) {
|
||||
resource.children.forEach(child => {
|
||||
travelResource(child, cb);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getResourceTravelIds = (ctx: {
|
||||
resource: ResourceType;
|
||||
resourceMap: Record<string, ResourceType>;
|
||||
collapsedMap: Record<string, boolean>;
|
||||
}): string[] => {
|
||||
const { resource, resourceMap, collapsedMap } = ctx;
|
||||
|
||||
const ids: string[] = [];
|
||||
|
||||
travelResource(resource, item => {
|
||||
const info = resourceMap[item.id];
|
||||
|
||||
// 被删除的资源、文件夹不展示
|
||||
if (!info || info.status === 'deprecated') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 折叠的文件夹折叠后,不遍历只节点
|
||||
if (info.type === ResourceTypeEnum.Folder && collapsedMap[info.id]) {
|
||||
ids.push(item.id);
|
||||
return false;
|
||||
}
|
||||
ids.push(item.id);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return ids.filter(id => id !== ROOT_KEY);
|
||||
};
|
||||
|
||||
export const findLastResource = (ctx: {
|
||||
id: string;
|
||||
resource: ResourceType;
|
||||
resourceMap: Record<string, ResourceType>;
|
||||
collapsedMap: Record<string, boolean>;
|
||||
}) => {
|
||||
const { resource, resourceMap, collapsedMap, id } = ctx;
|
||||
|
||||
const ids = getResourceTravelIds({
|
||||
resource,
|
||||
resourceMap,
|
||||
collapsedMap,
|
||||
});
|
||||
|
||||
const index = ids.findIndex(item => item === id);
|
||||
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalIndex = (index - 1 + ids.length) % ids.length;
|
||||
|
||||
return {
|
||||
id: ids[finalIndex],
|
||||
info: resourceMap[ids[finalIndex]],
|
||||
};
|
||||
};
|
||||
|
||||
export const findNextResource = (ctx: {
|
||||
id: string;
|
||||
resource: ResourceType;
|
||||
resourceMap: Record<string, ResourceType>;
|
||||
collapsedMap: Record<string, boolean>;
|
||||
}) => {
|
||||
const { resource, resourceMap, collapsedMap, id } = ctx;
|
||||
|
||||
const ids = getResourceTravelIds({
|
||||
resource,
|
||||
resourceMap,
|
||||
collapsedMap,
|
||||
});
|
||||
|
||||
const index = ids.findIndex(item => item === id);
|
||||
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const final = (index + 1) % ids.length;
|
||||
|
||||
return {
|
||||
id: ids[final],
|
||||
info: resourceMap[ids[final]],
|
||||
};
|
||||
};
|
||||
|
||||
export const validateSameNameInFolder = ({
|
||||
folder,
|
||||
editResource,
|
||||
}: {
|
||||
folder: ResourceType;
|
||||
editResource: ResourceType;
|
||||
}): string => {
|
||||
if (!folder || !editResource) {
|
||||
return '';
|
||||
}
|
||||
const children = (folder.children || []).filter(
|
||||
child => child.id !== editResource.id,
|
||||
);
|
||||
|
||||
const hasSameName = children.some(child => child.name === editResource.name);
|
||||
|
||||
return hasSameName
|
||||
? `有一个文件或文件夹 ${editResource.name} 已经存在在当前位置,请使用一个不同的名称`
|
||||
: '';
|
||||
};
|
||||
|
||||
export const mapResourceTree = (resourceTree): ResourceMapType => {
|
||||
if (!resourceTree) {
|
||||
return {};
|
||||
}
|
||||
const fullTree = {
|
||||
...ROOT_NODE,
|
||||
children: resourceTree instanceof Array ? resourceTree : [resourceTree],
|
||||
};
|
||||
|
||||
const result: ResourceMapType = {};
|
||||
|
||||
const dfs = (
|
||||
resource,
|
||||
path: string[],
|
||||
): { maxDeep: number; editDraft?: boolean } => {
|
||||
if (!resource) {
|
||||
return { maxDeep: path.length - 1 };
|
||||
}
|
||||
|
||||
// 文件夹要加一,因为能加文件
|
||||
let maxDeep = path.length + (resource.type === 'folder' ? 1 : 0);
|
||||
|
||||
// 当前资源是否处于提交状态
|
||||
let editDraft = resource.edit_status === 'draft';
|
||||
|
||||
if (resource.children) {
|
||||
resource.children.forEach(child => {
|
||||
const { maxDeep: deep, editDraft: status } = dfs(child, [
|
||||
...path,
|
||||
child.id,
|
||||
]);
|
||||
maxDeep = Math.max(maxDeep, deep);
|
||||
|
||||
// 文件夹 editDraft 跟随草稿走,只要内部有一个为草稿,本文件夹也为草稿
|
||||
editDraft = !!(editDraft || status);
|
||||
});
|
||||
}
|
||||
|
||||
result[String(resource.id)] = {
|
||||
...resource,
|
||||
path,
|
||||
maxDeep: maxDeep - path.length,
|
||||
/**
|
||||
* 随业务放开
|
||||
*/
|
||||
// draft: editDraft,
|
||||
// problem: {
|
||||
// status: 'warning',
|
||||
// number: 12,
|
||||
// },
|
||||
};
|
||||
|
||||
return { maxDeep, editDraft };
|
||||
};
|
||||
|
||||
dfs(fullTree, [fullTree.id]);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const combineExtraObject = (
|
||||
mainObj: Record<IdType, any>,
|
||||
extraMap: Record<IdType, any>,
|
||||
) =>
|
||||
Object.keys(mainObj).reduce(
|
||||
(pre, cur) => ({
|
||||
...pre,
|
||||
[cur]: {
|
||||
...mainObj[cur],
|
||||
...extraMap[cur],
|
||||
},
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
export const flatTree = (
|
||||
tree: ResourceType,
|
||||
map: ResourceMapType,
|
||||
collapsedMap: Record<string, boolean>,
|
||||
) => {
|
||||
const result: ResourceType[] = [];
|
||||
|
||||
const dfs = (resource: ResourceType) => {
|
||||
result.push(map[resource.id]);
|
||||
|
||||
if (resource.children && !collapsedMap[resource.id]) {
|
||||
resource.children.forEach(child => {
|
||||
dfs(child);
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
dfs(tree);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算当前文件夹下,新建的资源所处的位置。
|
||||
* 文件夹:在当前文件夹下顶部
|
||||
* 资源:在当前文件夹下的文件夹末尾,所有资源顶部
|
||||
*/
|
||||
export const getCreateResourceIndex = ({
|
||||
resourceList,
|
||||
parentId,
|
||||
type,
|
||||
}: {
|
||||
resourceList: ResourceType[];
|
||||
parentId: string;
|
||||
type: ResourceTypeEnum | string;
|
||||
}) => {
|
||||
let i = 0;
|
||||
let inFolder = false;
|
||||
let parentPath: string[] = [];
|
||||
while (i < resourceList.length) {
|
||||
const resource = resourceList[i];
|
||||
|
||||
if (inFolder && (resource.path?.length || 0) <= parentPath.length) {
|
||||
return i;
|
||||
}
|
||||
if (
|
||||
inFolder &&
|
||||
resource.type !== ResourceTypeEnum.Folder &&
|
||||
(resource.path?.length || 0) - 1 === parentPath.length
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
|
||||
if (resource.id === parentId) {
|
||||
inFolder = true;
|
||||
parentPath = resource.path || [];
|
||||
if (type === ResourceTypeEnum.Folder) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
return i;
|
||||
};
|
||||
|
||||
export function baseValidateNames(props: { label: string; nameTitle: string }) {
|
||||
const { label, nameTitle } = props;
|
||||
|
||||
const simple = true;
|
||||
|
||||
// 设定默认值,避免 propExtra 只传入一个配置
|
||||
|
||||
// 检测 name 是否空
|
||||
if (!label) {
|
||||
return simple ? 'Empty Key' : `${nameTitle} name can not be empty`;
|
||||
}
|
||||
|
||||
if (label.length > 64) {
|
||||
return simple ? 'Length exceeds' : `${nameTitle} name length exceeds limit`;
|
||||
}
|
||||
|
||||
// 必须由字母开头
|
||||
if (!/^[A-Za-z]/.test(label)) {
|
||||
return simple
|
||||
? 'Must start with letter'
|
||||
: `${nameTitle} name must start with a letter`;
|
||||
}
|
||||
|
||||
// 检测 name 的命名规则
|
||||
if (!/^[A-Za-z][0-9a-zA-Z_]*$/.test(label)) {
|
||||
return simple
|
||||
? 'only ASCII letters, digits, and _'
|
||||
: `${nameTitle} name can only contain ASCII letters, digits, and _`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
Reference in New Issue
Block a user