This commit is contained in:
2026-02-14 02:31:51 +08:00
parent fde8721583
commit 8de453811b
33 changed files with 4415 additions and 228 deletions

34
src/pages/auth/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { QueryLoginBrowser } from '@kevisual/api/login'
import { query } from '@/modules/query'
import { useLayoutEffect, useState } from 'react';
import { queryResources } from '../draw/modules/query';
type Props = {
children?: any;
}
export const Auth = (props: Props) => {
const [mount, setMount] = useState(false);
useLayoutEffect(() => {
// Your layout effect logic here
init()
}, []);
const init = async () => {
const login = new QueryLoginBrowser({ query: query })
const resMe = await login.getMe()
if (resMe) {
// User is logged in
setMount(true)
console.log('res', resMe)
const username = resMe.data?.username;
queryResources.setUsername(username)
} else {
// User is not logged in
setMount(false)
}
}
if (!mount) return null;
return <>
{props.children}
</>
}

40
src/pages/draw/Draw.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createMarkStore, useMarkStore } from './store';
import { StoreContextProvider } from '@kevisual/store/react';
import { useShallow } from 'zustand/shallow';
import { Core } from './core/Excalidraw';
export const Draw = ({ id, onClose }: { id: string; onClose: () => void }) => {
useLayoutEffect(() => {
// @ts-ignore
// window.EXCALIDRAW_ASSET_PATH = 'https://esm.kevisual.cn/@excalidraw/excalidraw@0.18.0/dist/prod/';
window.EXCALIDRAW_ASSET_PATH = 'https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/';
// window.EXCALIDRAW_ASSET_PATH = '/';
}, []);
return (
<StoreContextProvider id={id} stateCreator={createMarkStore}>
<ExcaliDrawComponent id={id} onClose={onClose} />
</StoreContextProvider>
);
};
type ExcaliDrawComponentProps = {
/** 修改的id */
id: string;
/** 关闭 */
onClose: () => void;
};
export const ExcaliDrawComponent = ({ id, onClose }: ExcaliDrawComponentProps) => {
const store = useMarkStore(
useShallow((state) => {
return {
id: state.id,
setId: state.setId,
getMark: state.getMark,
};
}),
);
const memo = useMemo(() => <Core onClose={onClose} id={id} />, [id, onClose]);
return <>{memo}</>;
};

View File

@@ -0,0 +1,283 @@
import { Excalidraw } from '@excalidraw/excalidraw';
import '@excalidraw/excalidraw/index.css'
import { useEffect, useRef, useState } from 'react';
import { BinaryFileData, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types';
import { Languages, LogOut, Save } from 'lucide-react';
import { MainMenu, Sidebar, Footer } from '@excalidraw/excalidraw';
import { throttle } from 'es-toolkit';
import { useMarkStore, store as StoreManager } from '../store';
import { useShallow } from 'zustand/shallow';
import { useListenLang } from './hooks/listen-lang';
import { toast } from 'sonner';
import { hashFile } from '../modules/hash-file';
import { toFile } from '@kevisual/api/query-upload'
import dayjs from 'dayjs';
import { queryResources } from '../modules/query'
type ImageResource = {};
export const ImagesResources = (props: ImageResource) => {
const [images, setImages] = useState<string[]>([]);
useEffect(() => {
initImages();
}, []);
const refFiles = useRef<any>({});
const { api } = useMarkStore(
useShallow((state) => {
return {
api: state.api,
};
}),
);
const initImages = async () => {
if (!api) return;
const res = api.getFiles();
console.log('res from ImageResource', res);
setImages(Object.keys(res));
refFiles.current = res;
};
return (
<div className='w-full h-full'>
{images.map((image) => {
const isUrl = refFiles.current[image]?.dataURL?.startsWith?.('http');
const dataURL = refFiles.current[image]?.dataURL;
return (
<div className='flex items-center gap-2 w-full overflow-hidden p-2 m-2 border border-gray-200 rounded-md shadow ' key={image}>
<img className='w-10 h-10 m-4' src={dataURL} alt='image' />
<div className='flex flex-col gap-1'>
{isUrl && <div className='text-xs line-clamp-4 break-all'> {dataURL}</div>}
<div className='text-xs'>{refFiles.current[image]?.name}</div>
<div className='text-xs'>{refFiles.current[image]?.type}</div>
<div className='text-xs'>{refFiles.current[image]?.id}</div>
</div>
</div>
);
})}
</div>
);
};
export const ExcalidrawExpand = (props: { onClose: () => void }) => {
const { onClose } = props;
const [docked, setDocked] = useState(false);
const store = useMarkStore(
useShallow((state) => {
return {
loading: state.loading,
updateMark: state.updateMark,
api: state.api,
};
}),
);
const { lang, setLang, isZh } = useListenLang();
return (
<>
{store.loading && (
<div className='w-full h-full flex items-center justify-center absolute top-0 left-0 z-10'>
<div className='w-full h-full bg-black opacity-10 absolute top-0 left-0 z-1'></div>
</div>
)}
<MainMenu>
<MainMenu.Item
onSelect={() => {
store.updateMark();
}}>
<div className='flex items-center gap-2'>
<Save />
<span>{isZh ? '保存' : 'Save'}</span>
</div>
</MainMenu.Item>
<MainMenu.DefaultItems.LoadScene />
<MainMenu.DefaultItems.Export />
<MainMenu.DefaultItems.SaveToActiveFile />
<MainMenu.DefaultItems.SaveAsImage />
{/* <MainMenu.DefaultItems.LiveCollaborationTrigger onSelect={() => {}} isCollaborating={false} /> */}
{/* <MainMenu.DefaultItems.CommandPalette /> */}
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.DefaultItems.ChangeCanvasBackground />
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.Separator />
<MainMenu.Item
onSelect={(e) => {
const newLang = lang === 'zh-CN' ? 'en' : 'zh-CN';
setLang(newLang);
}}>
<div className='flex items-center gap-2'>
<Languages />
<span>{isZh ? 'English' : '中文'}</span>
</div>
</MainMenu.Item>
{onClose && (
<>
<MainMenu.Separator />
<MainMenu.Item onSelect={onClose}>
<div className='flex items-center gap-2'>
<LogOut />
<span>{isZh ? '退出当前画布' : 'Exit current canvas'}</span>
</div>
</MainMenu.Item>
</>
)}
</MainMenu>
<Sidebar name='custom' docked={docked} onDock={setDocked}>
<Sidebar.Header />
<Sidebar.Tabs>
<Sidebar.Tab tab='one'>{<ImagesResources />}</Sidebar.Tab>
<Sidebar.Tab tab='two'>Tab two!</Sidebar.Tab>
<Sidebar.TabTriggers>
<Sidebar.TabTrigger tab='one'>Image Resources</Sidebar.TabTrigger>
<Sidebar.TabTrigger tab='two'>Two</Sidebar.TabTrigger>
</Sidebar.TabTriggers>
</Sidebar.Tabs>
</Sidebar>
<Footer>
<Sidebar.Trigger
name='custom'
tab='one'
style={{
marginLeft: '0.5rem',
background: '#70b1ec',
color: 'white',
}}>
{isZh ? '图片资源' : 'Image Resources'}
</Sidebar.Trigger>
</Footer>
</>
);
};
type CoreProps = {
onClose: () => void;
id: string;
};
export const Core = ({ onClose, id }: CoreProps) => {
const ref = useRef<ExcalidrawImperativeAPI>(null);
const { lang } = useListenLang();
const uploadLoadingRef = useRef<boolean>(false);
const store = useMarkStore(
useShallow((state) => {
return {
id: state.id,
loading: state.loading,
getMark: state.getMark,
api: state.api,
setApi: state.setApi,
getCache: state.getCache,
updateMark: state.updateMark,
};
}),
);
const cacheDataRef = useRef<{
elements: { [key: string]: number };
filesObject: Record<string, any>;
}>({
elements: {},
filesObject: {},
});
useEffect(() => {
id && store.getMark(id);
}, [id]);
const onSave = throttle(async (elements, appState, filesObject) => {
const { elements: cacheElements, filesObject: cacheFiles } = cacheDataRef.current;
const _store = StoreManager.getStore(id);
if (!_store) {
console.error('Store not found for id:', id);
return;
}
const { setCache, loading } = _store.getState();
if (loading) {
return;
}
if (uploadLoadingRef.current) return;
let isChange = false;
const elementsObj = elements.reduce((acc, e) => {
acc[e.id] = e.version;
return acc;
}, {});
if (JSON.stringify(elementsObj) !== JSON.stringify(cacheElements)) {
isChange = true;
}
if (JSON.stringify(cacheFiles) !== JSON.stringify(filesObject)) {
isChange = true;
const files = Object.values(filesObject) as any as BinaryFileData[];
uploadLoadingRef.current = true;
for (const file of files) {
if (file.dataURL.startsWith('data')) {
const _file = toFile(file.dataURL, file.id);
const day = dayjs().format('YYYY-MM');
const directory = `excalidraw/${day}/${id}`;
const filePath = `upload/1.0.0/${directory}/${file.id}`;
const res = (await queryResources.uploadFile(filePath, _file)) as any;
if (res.code === 200) {
toast.success('上传图片成功');
} else {
toast.error('上传图片失败');
continue;
}
const fullFilePath = queryResources.prefix + filePath;
filesObject[file.id] = {
...filesObject[file.id],
dataURL: fullFilePath,
};
} else {
continue;
}
}
uploadLoadingRef.current = false;
}
console.log('onSave', elements, appState, filesObject, 'isChange', isChange);
if (!isChange) {
return;
}
cacheDataRef.current = { elements: elementsObj, filesObject };
setCache({
data: {
elements,
filesObject,
},
});
}, 2000);
return (
<Excalidraw
initialData={{}}
onChange={(elements, appState, filesObject) => {
if (store.loading) return;
onSave(elements, appState, filesObject);
}}
langCode={lang || 'zh-CN'}
renderTopRightUI={() => {
return <div></div>;
}}
excalidrawAPI={async (api) => {
ref.current = api;
store.setApi(api);
const cache = await store.getCache(id, true);
if (!cache) return;
const elementsObj = cache.elements.reduce((acc, e) => {
acc[e.id] = e.version;
return acc;
}, {});
cacheDataRef.current = {
elements: elementsObj,
filesObject: cache.filesObject,
};
}}
generateIdForFile={async (file) => {
// return dayjs().format('YYYY-MM-DD-HH-mm-ss') + '.' + file.type.split('/')[1];
const hash = await hashFile(file);
console.log('hash', hash, 'filetype', file.type);
const fileId = hash + '.' + file.type.split('/')[1];
console.log('fileId', fileId);
return fileId;
}}>
<ExcalidrawExpand onClose={onClose} />
</Excalidraw>
);
};

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
export const useListenLang = () => {
const [lang, setLang] = useState('zh-CN');
useEffect(() => {
const lang = localStorage.getItem('excalidrawLang');
if (lang) {
setLang(lang);
}
// 监听 localStorage中excalidrawLang的变化
const onStorage = (e: StorageEvent) => {
if (e.key === 'excalidrawLang') {
e.newValue && setLang(e.newValue);
}
};
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener('storage', onStorage);
};
}, []);
return {
lang,
setLang: (lang: string) => {
// setLang(lang);
localStorage.setItem('excalidrawLang', lang);
},
isZh: lang === 'zh-CN',
};
};

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react';
export const useListenLibrary = () => {
useEffect(() => {
addLibraryItem();
}, []);
const addLibraryItem = async () => {
const hash = window.location.hash; // 获取哈希值
const addLibrary = hash.split('addLibrary=')[1];
if (!addLibrary || addLibrary === 'undefined') {
return;
}
const token = hash.split('token=')[1];
console.log('addLibrary', addLibrary, token);
const _fetchURL = decodeURIComponent(addLibrary);
const fetchURL = _fetchURL.split('&')[0];
console.log('fetchURL', fetchURL);
const res = await fetch(fetchURL, {
method: 'GET',
mode: 'cors',
});
const data = await res.json();
console.log('data', data);
};
return {
addLibraryItem,
};
};

View File

@@ -0,0 +1,32 @@
import MD5 from 'crypto-js/md5';
export const hashFile = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (event) => {
try {
const content = event.target?.result;
if (content instanceof ArrayBuffer) {
const contentString = new TextDecoder().decode(content);
const hashHex = MD5(contentString).toString();
resolve(hashHex);
} else if (typeof content === 'string') {
const hashHex = MD5(content).toString();
resolve(hashHex);
} else {
throw new Error('Invalid content type');
}
} catch (error) {
console.error('hashFile error', error);
reject(error);
}
};
reader.onerror = (error) => {
reject(error);
};
// 读取文件为 ArrayBuffer
reader.readAsArrayBuffer(file);
});
};

View File

@@ -0,0 +1,9 @@
import { query } from '@/modules/query';
import { QueryResources } from '@kevisual/api/query-resources'
import { QueryMark } from '@kevisual/api/query-mark';
export const queryMark = new QueryMark({
query: query,
markType: 'excalidraw',
});
export const queryResources = new QueryResources({})

96
src/pages/draw/page.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { getHistoryState, setHistoryState } from '@kevisual/store/web-page.js';
import { Draw } from './Draw';
import { App as Manager, ProviderManagerName, useManagerStore } from '../mark/manager/Manager';
import { useShallow } from 'zustand/shallow';
import { toast } from 'sonner';
export const App = () => {
return <DrawApp />;
};
export default App;
export const getUrlId = () => {
const url = new URL(window.location.href);
return url.searchParams.get('id') || '';
};
export const DrawApp = () => {
const [id, setId] = useState('');
const urlId = getUrlId();
useLayoutEffect(() => {
const state = getHistoryState();
if (state?.id) {
setId(state.id);
return;
}
if (urlId) {
setId(urlId);
}
}, []);
return (
<div className='bg-white w-full h-full flex'>
<Manager
showSearch={true}
showAdd={true}
markType={'excalidraw'}
openMenu={!urlId}
onClick={(data) => {
if (data.id !== id) {
setId('');
const url = new URL(location.href);
url.searchParams.set('id', data.id);
console.log('set url', url.toString());
setHistoryState({}, url.toString());
setTimeout(() => {
setId(data.id);
}, 200);
const _store = useManagerStore.getState();
if (_store.markData) {
_store.setCurrentMarkId('');
// _store.setOpen(false);
_store.setMarkData(undefined);
}
} else if (data.id === id) {
toast.success('已选择当前画布');
}
console.log('onClick', data, id);
}}>
<div className='h-full grow'>
<DrawWrapper id={id} setId={setId} />
</div>
</Manager>
</div>
);
};
export const DrawWrapper = (props: { id?: string; setId: (id: string) => void }) => {
const { id, setId } = props;
const store = useManagerStore(
useShallow((state) => {
return {
currentMarkId: state.currentMarkId,
};
}),
);
console.log('DrawApp store', store);
return (
<>
{id ? (
<Draw
id={id}
onClose={() => {
setId('');
setHistoryState({
id: '',
});
}}
/>
) : (
<div className='flex items-center justify-center h-full text-gray-500'> {store.currentMarkId ? '' : '请先选择一个画布'} </div>
)}
</>
);
};

View File

@@ -0,0 +1,215 @@
import { StoreManager } from '@kevisual/store';
import { useContextKey } from '@kevisual/context';
import { StateCreator, StoreApi, UseBoundStore } from 'zustand';
import { queryMark } from '../modules/query';
import { useStore, BoundStore } from '@kevisual/store/react';
import { createStore, set as setCache, get as getCache } from 'idb-keyval';
import { OrderedExcalidrawElement } from '@excalidraw/excalidraw/element/types';
import { toast } from 'sonner';
import { BinaryFileData, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types';
export const cacheStore = createStore('excalidraw-store', 'excalidraw');
export const store = useContextKey('store', () => {
return new StoreManager();
});
type MarkStore = {
id: string;
setId: (id: string) => void;
mark: any;
setMark: (mark: any) => void;
info: any;
setInfo: (info: any) => void;
getList: () => Promise<void>;
list: any[];
setList: (list: any[]) => void;
getMark: (markId: string) => Promise<void>;
updateMark: () => Promise<void>;
getCache: (
id: string,
updateApiData?: boolean,
) => Promise<
| {
elements: OrderedExcalidrawElement[];
filesObject: Record<string, any>;
}
| undefined
>;
setCache: (cache: any, version?: number) => Promise<void>;
loading: boolean;
setLoading: (loading: boolean) => void;
// excalidraw
api: ExcalidrawImperativeAPI | null;
setApi: (api: ExcalidrawImperativeAPI) => void;
};
export const createMarkStore: StateCreator<MarkStore, [], [], MarkStore> = (set, get, store) => {
return {
id: '',
setId: (id: string) => set(() => ({ id })),
mark: null,
setMark: (mark: any) => set(() => ({ mark })),
loading: true,
setLoading: (loading: boolean) => set(() => ({ loading })),
info: null,
setCache: async (cache: any, version?: number) => {
const { id, mark } = get();
console.log('cacheData setCache ,id', cache, id);
if (!id) {
return;
}
const cacheData = (await getCache(`${id}`, cacheStore)) || {};
await setCache(
`${id}`,
{
...cacheData,
...cache,
data: {
...cacheData?.data,
...cache?.data,
},
version: version || mark?.version || 0,
},
cacheStore,
);
},
updateMark: async () => {
const { id } = get();
if (!id) {
return;
}
const cacheData = await getCache(id, cacheStore);
let mark = cacheData || {};
if (!mark) {
return;
}
const { data } = mark;
const { elements, filesObject } = data;
console.log('updateMark', elements, filesObject);
const res = await queryMark.updateMark({ id, data });
if (res.code === 200) {
set(() => ({ mark: res.data }));
toast.success('更新成功');
get().setCache({}, res.data!.version);
}
},
getCache: async (id: string, updateApiData?: boolean) => {
if (!id) {
return;
}
// 获取缓存
let cacheData = (await getCache(`${id}`, cacheStore)) || { data: { elements: [], filesObject: {} } };
console.log('getCache', id, cacheData);
if (cacheData) {
if (updateApiData) {
const api = get().api;
if (api) {
const files = Object.values(cacheData.data.filesObject || {}) as BinaryFileData[];
api.addFiles(files || []);
api.updateScene({
elements: [...(cacheData.data?.elements || [])],
appState: {},
});
}
}
}
return {
elements: cacheData.data.elements || [],
filesObject: cacheData.data.filesObject || {},
};
},
setInfo: (info: any) => set(() => ({ info })),
getList: async () => {
const res = await queryMark.getMarkList({ page: 1, pageSize: 10 });
console.log(res);
},
list: [],
setList: (list: any[]) => set(() => ({ list })),
getMark: async (markId: string) => {
set(() => ({ loading: true, id: markId }));
const toastId = toast.loading(`获取数据中...`);
const now = new Date().getTime();
const cacheData = await getCache(markId, cacheStore);
const checkVersion = await queryMark.checkVersion(markId, cacheData?.version);
if (checkVersion) {
const res = await queryMark.getMark(markId);
if (res.code === 200) {
set(() => ({
mark: res.data,
id: markId,
}));
const mark = res.data!;
const excalidrawData = mark.data || {};
await get().setCache({
data: {
elements: excalidrawData.elements,
filesObject: excalidrawData.filesObject,
},
version: mark.version,
});
get().getCache(markId, true);
} else {
toast.error(res.message || '获取数据失败');
}
}
const end = new Date().getTime();
const getTime = end - now;
if (getTime < 2 * 1000) {
await new Promise((resolve) => setTimeout(resolve, 2 * 1000 - getTime));
}
toast.dismiss(toastId);
set(() => ({ loading: false }));
},
api: null,
setApi: (api: ExcalidrawImperativeAPI) => set(() => ({ api })),
};
};
export const useMarkStore = useStore as BoundStore<MarkStore>;
export const fileDemo = {
abc: {
dataURL: 'https://kevisual.xiongxiao.me/root/center/panda.png' as any,
// @ts-ignore
id: 'abc',
name: 'test2.png',
type: 'image/png',
},
};
export const demoElements: OrderedExcalidrawElement[] = [
{
id: '1',
type: 'image',
x: 100,
y: 100,
width: 100,
height: 100,
fileId: 'abc' as any,
version: 2,
versionNonce: 28180243,
index: 'a0' as any,
isDeleted: false,
fillStyle: 'solid',
strokeWidth: 2,
strokeStyle: 'solid',
roughness: 1,
opacity: 100,
angle: 0,
strokeColor: '#1e1e1e',
backgroundColor: 'transparent',
seed: 1,
groupIds: [],
frameId: null,
roundness: null,
boundElements: [],
updated: 1743219351869,
link: null,
locked: false,
status: 'pending',
scale: [1, 1],
crop: null,
},
];

View File

@@ -0,0 +1,331 @@
import { useManagerStore } from './store';
import { useEffect, useMemo, useState } from 'react';
import { useShallow } from 'zustand/shallow';
import { ChevronDown, X, Edit, Plus, Search, Trash, Menu as MenuIcon, MenuSquare } from 'lucide-react';
import dayjs from 'dayjs';
import { EditMark as EditMarkComponent } from './edit/Edit';
import { toast } from 'sonner';
import clsx from 'clsx';
import { Controller, useForm } from 'react-hook-form';
import { IconButton } from '@/components/a/button';
import { MarkType } from '@kevisual/api/query-mark';
import { Menu } from '@/components/a/menu';
import { MarkTypes } from './constant';
type ManagerProps = {
showSearch?: boolean;
showAdd?: boolean;
onClick?: (data?: any, e?: Event) => void;
markType?: MarkType;
showSelect?: boolean;
};
export { useManagerStore };
export const Manager = (props: ManagerProps) => {
const { showSearch = true, showAdd = false, onClick, showSelect = true } = props;
const { control } = useForm({ defaultValues: { search: '' } });
const { list, init, setCurrentMarkId, currentMarkId, markData, deleteMark, getMark, setMarkData, pagination, setPagination, getList, search, setSearch } =
useManagerStore(
useShallow((state) => {
return {
list: state.list,
init: state.init,
markData: state.markData,
currentMarkId: state.currentMarkId,
setCurrentMarkId: state.setCurrentMarkId,
deleteMark: state.deleteMark,
getMark: state.getMark,
setMarkData: state.setMarkData,
pagination: state.pagination,
setPagination: state.setPagination,
search: state.search,
setSearch: state.setSearch,
getList: state.getList,
};
}),
);
const handleMenuItemClick = (option: string) => {
console.log('option', option);
init(option as any);
};
useEffect(() => {
const url = new URL(window.location.href);
let markType = url.searchParams.get('markType') || '';
if (!markType && props.markType) {
markType = props.markType;
}
init((markType as any) || 'md');
}, []);
useEffect(() => {
if (search) {
getList();
} else if (pagination.current > 1) {
getList();
}
}, [pagination.current, search]);
const onEditMark = async (markId: string) => {
setCurrentMarkId(markId);
const res = await getMark(markId);
console.log('mark', res);
if (res.code === 200) {
setMarkData(res.data!);
}
};
const onDeleteMark = async (markId: string) => {
const res = await deleteMark(markId);
if (res.code === 200) {
toast.success('删除成功');
}
};
return (
<div className='w-full h-full p-4 bg-white border-r border-r-gray-200 relative'>
<div className='flex px-4 mb-4 justify-between items-center absolute top-0 left-0 h-[56px] w-full'>
<div className='flex ml-12 items-center space-x-2'>
<Controller
name='search'
control={control}
render={({ field }) => (
<div className={`relative ${showSearch ? 'block' : 'hidden'}`}>
<input
{...field}
type='text'
className='py-2 px-3 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500'
onKeyDown={(event) => {
if (event.key === 'Enter') {
setSearch(field.value);
if (!field.value) {
getList();
}
}
}}
/>
<div className='absolute inset-y-0 right-0 flex items-center pr-3 cursor-pointer'>
<Search className='w-4 h-4' onClick={() => setSearch(field.value)} />
</div>
</div>
)}
/>
</div>
<div className={'flex items-center space-x-2'}>
{showSelect && (
<>
<Menu
options={MarkTypes.map((item) => {
return { label: item, value: item };
})}
onSelect={handleMenuItemClick}>
<MenuIcon className='w-4 h-4' />
</Menu>
</>
)}
<button
className={clsx(
'text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200',
showAdd ? '' : 'hidden',
)}>
<Plus
className={clsx('w-4 h-4 ')}
onClick={() => {
setCurrentMarkId('');
setMarkData({
id: '',
title: '',
description: '',
markType: props.markType || ('md' as any),
summary: '',
tags: [],
link: '',
});
}}
/>
</button>
{markData && (
<button
className='text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200'
onClick={() => {
setCurrentMarkId('');
setMarkData(undefined);
}}>
<X className='w-4 h-4 ' />
</button>
)}
</div>
</div>
<div className='mt-[56px] overflow-auto scrollbar' style={{ height: 'calc(100% - 56px)' }}>
{list.map((item, index) => {
const isCurrent = item.id === currentMarkId;
return (
<div
key={item.id}
className={`border rounded-lg p-4 mb-4 shadow-md bg-white border-gray-200 ${isCurrent ? 'border-blue-500' : ''}`}
onClick={(e) => {
onClick?.(item, e as any);
e.stopPropagation();
e.preventDefault();
}}>
<div className='flex justify-between items-center'>
<div className={`text-lg font-bold truncate cursor-pointer ${isCurrent ? 'text-blue-500' : ''}`}>{item.title}</div>
<div className='flex space-x-2'>
<button
className='text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200'
onClick={(e) => {
e.stopPropagation();
onEditMark(item.id);
}}>
<Edit className='w-4 h-4 ' />
</button>
<button
className='text-red-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-red-100 transition duration-200'
onClick={(e) => {
e.stopPropagation();
onDeleteMark(item.id);
}}>
<Trash className='w-4 h-4 ' />
</button>
</div>
</div>
<div className='text-sm text-gray-600'>: {item.markType}</div>
<div className='text-sm text-gray-600'>: {item.summary}</div>
<div className='text-sm text-gray-600'>: {item.tags?.join?.(', ')}</div>
{/* <div className='text-sm text-gray-600 hidden sm:block'>描述: {item.description}</div> */}
<div
className='text-sm text-gray-600 hidden sm:block truncate'
onClick={() => {
window.open(item.link, '_blank');
}}>
: {item.link}
</div>
<div className='text-sm text-gray-600 hidden sm:block'>: {dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}</div>
<div className='text-sm text-gray-600 hidden sm:block'>: {dayjs(item.updatedAt).format('YYYY-MM-DD HH:mm:ss')}</div>
</div>
);
})}
<div className='flex justify-center items-center'>
{list.length < pagination.total && (
<button
className='text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200'
onClick={() => {
setPagination({ ...pagination, current: pagination.current + 1 });
}}>
<ChevronDown className='w-4 h-4 ' />
</button>
)}
</div>
</div>
</div>
);
};
export const EditMark = () => {
const { markData } = useManagerStore(
useShallow((state) => {
return {
markData: state.markData,
};
}),
);
const mark = markData;
if (!mark) {
return null;
}
if (mark) {
return <EditMarkComponent />;
}
return <div className='w-full h-full'></div>;
};
export const LayoutMain = (props: { children?: React.ReactNode; expandChildren?: React.ReactNode; open?: boolean; hasTopTitle?: boolean }) => {
const getDocumentHeight = () => {
return document.documentElement.scrollHeight;
};
const mStore = useManagerStore(
useShallow((state) => {
return {
open: state.open,
setOpen: state.setOpen,
markData: state.markData,
};
}),
);
const markData = mStore.markData;
const openMenu = mStore.open;
const setOpenMenu = mStore.setOpen;
useEffect(() => {
if (props.open !== undefined) {
setOpenMenu!(props.open);
}
}, []);
const isEdit = !!markData;
const hasExpandChildren = !!props.expandChildren;
const style = useMemo(() => {
const top = props.hasTopTitle ? 70 : 0; // Adjust top based on whether there's a title
if (!hasExpandChildren || openMenu) {
return {
top,
};
}
return {
top: getDocumentHeight() / 2 + 10 + top,
};
}, [getDocumentHeight, hasExpandChildren, openMenu, props.hasTopTitle]);
return (
<div className='w-full h-full flex'>
<div className={clsx('absolute top-4 z-10', openMenu ? 'left-4' : '-left-4')} style={style}>
<IconButton
onClick={() => {
setOpenMenu(!openMenu);
}}>
<MenuSquare className='w-4 h-4' />
</IconButton>
</div>
<div className={clsx('h-full w-full sm:w-1/3', openMenu ? '' : 'hidden')}>{props.children}</div>
{(!props.expandChildren || isEdit) && (
<div className={clsx('h-full hidden sm:block sm:w-2/3', openMenu ? '' : 'hidden')}>
<EditMark />
</div>
)}
{props.expandChildren && <div className='h-full grow'>{props.expandChildren}</div>}
</div>
);
};
export type AppProps = {
/**
* 标记类型, wallnote md excalidraw
*/
markType?: MarkType;
/**
* 是否显示搜索框
*/
showSearch?: boolean;
/**
* 是否显示添加按钮
*/
showAdd?: boolean;
/**
* 点击事件
*/
onClick?: (data?: any) => void;
/**
* 管理器id, 存储到store的id
*/
managerId?: string;
children?: React.ReactNode;
showSelect?: boolean;
openMenu?: boolean;
hasTopTitle?: boolean; // 是否有顶部标题
};
export const ProviderManagerName = 'mark-manager';
export const App = (props: AppProps) => {
return (
<LayoutMain expandChildren={props.children} open={props.openMenu} hasTopTitle={props.hasTopTitle}>
<Manager
markType={props.markType}
showSearch={props.showSearch}
showAdd={props.showAdd}
onClick={props.onClick}
showSelect={props.showSelect}></Manager>
</LayoutMain>
);
};

View File

@@ -0,0 +1,9 @@
import { StoreContextProvider } from '@kevisual/store/react';
import { createManagerStore } from './store/index';
export const ManagerProvider = ({ children, id }: { children: React.ReactNode; id?: string }) => {
return (
<StoreContextProvider id={id || 'mark-manager'} stateCreator={createManagerStore}>
{children}
</StoreContextProvider>
);
};

View File

@@ -0,0 +1,27 @@
import { Button as aButton } from '@/components/a/button';
import { Input } from '@/components/a/input';
export const Button = aButton;
export const TextField = Input;
export const IconButton = aButton;
export const Menu = () => {
return <>dev</>;
};
export const InputAdornment = () => {
return <>dev</>;
};
export const MenuItem = () => {
return <>dev</>;
};
export const Autocomplete = () => {
return <>dev</>;
};
export const Box = () => {
return <>dev</>;
};

View File

@@ -0,0 +1 @@
export const MarkTypes = ['md', 'wallnote', 'excalidraw', 'chat'];

View File

@@ -0,0 +1,152 @@
import { useForm, Controller } from 'react-hook-form';
import { Button } from '@/components/a/button';
import { AutoComplate } from '@/components/a/auto-complate';
import { TagsInput } from '@/components/a/input';
import { useManagerStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { useEffect } from 'react';
import { pick } from 'es-toolkit';
import { toast } from 'sonner';
import { MarkTypes } from '../constant';
export const EditMark = () => {
const { control, handleSubmit, reset } = useForm();
const { updateMark, markData, setCurrentMarkId, setMarkData } = useManagerStore(
useShallow((state) => {
return {
updateMark: state.updateMark,
markData: state.markData,
setCurrentMarkId: state.setCurrentMarkId,
currentMarkId: state.currentMarkId,
setMarkData: state.setMarkData,
};
}),
);
// const [mark, setMark] = useState<Mark | undefined>(markData);
const mark = pick(markData!, ['id', 'title', 'description', 'markType', 'summary', 'tags', 'link', 'thumbnail']);
useEffect(() => {
reset(mark);
console.log('markData', markData);
}, [markData?.id]);
const onSubmit = async (data: any) => {
const res = await updateMark({ ...mark, ...data });
if (res.code === 200) {
toast.success('编辑成功');
}
// setCurrentMarkId('');
// setMarkData(undefined);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate autoComplete='off' className='w-full h-full overflow-auto px-2 py-1'>
<Controller
name='title'
control={control}
defaultValue={mark?.title || ''}
render={({ field }) => (
<div className='mb-4'>
<label className='block text-sm font-medium mb-1'></label>
<input {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' type='text' />
</div>
)}
/>
<Controller
name='description'
control={control}
defaultValue={mark?.description || ''}
render={({ field }) => (
<div className='mb-4'>
<label className='block text-sm font-medium mb-1'></label>
<textarea {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' rows={3} />
</div>
)}
/>
<Controller
name='markType'
control={control}
defaultValue={mark?.markType || ''}
render={({ field }) => {
return (
<div className='mb-4'>
<label className='block text-sm font-medium mb-1'></label>
<AutoComplate
{...field}
options={MarkTypes.map((item) => {
return { label: item, value: item };
})}
onChange={(value) => field.onChange(value)}
/>
</div>
);
}}
/>
<Controller
name='summary'
control={control}
defaultValue={mark?.summary || ''}
render={({ field }) => (
<div className='mb-4'>
<label className='block text-sm font-medium mb-1'></label>
<textarea {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' rows={2} />
</div>
)}
/>
<Controller
name='tags'
control={control}
defaultValue={mark?.tags || ''}
render={({ field }) => {
const label = '标签';
return (
<div className='mb-4'>
<label className='block text-sm font-medium mb-1'></label>
<TagsInput
{...field}
options={field.value?.map((tag: string) => ({ label: tag, value: tag })) || []}
placeholder={label}
onChange={(value) => {
field.onChange(value);
console.log('tags', value);
}}
/>
</div>
);
}}
/>
<Controller
name='link'
control={control}
defaultValue={mark?.link || ''}
render={({ field }) => (
<div className='mb-4'>
<label className='block text-sm font-medium mb-1'></label>
<input {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' type='text' />
</div>
)}
/>
<Controller
name='thumbnail'
control={control}
defaultValue={mark?.thumbnail || ''}
render={({ field }) => (
<div className='mb-4'>
<label className='block text-sm font-medium mb-1'></label>
<input {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' type='text' />
</div>
)}
/>
<div className='flex gap-2'>
<Button type='submit' >
</Button>
<Button
onClick={() => {
setCurrentMarkId('');
setMarkData(undefined);
}}>
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,131 @@
import { create, StateCreator, StoreApi, UseBoundStore } from 'zustand';
import { query as queryClient } from '@/modules/query';
import { Result } from '@kevisual/query/query';
import { MarkType, Mark, QueryMark } from '@kevisual/api/query-mark';
import { uniqBy } from 'es-toolkit';
type ManagerStore = {
/** 当前选中的Mark */
currentMarkId: string;
setCurrentMarkId: (markId: string) => void;
markData: Mark | undefined;
setMarkData: (mark?: Partial<Mark>) => void;
/** 获取Mark列表 */
getList: () => Promise<any>;
getMarkFromList: (markId: string) => Mark | undefined;
updateMark: (mark: Mark) => Promise<any>;
getMark: (markId: string) => Promise<Result<Mark>>;
deleteMark: (markId: string) => Promise<any>;
/** Mark列表 */
list: Mark[];
setList: (list: Mark[]) => void;
pagination: {
current: number;
pageSize: number;
total: number;
};
setPagination: (pagination: { current: number; pageSize: number; total: number }) => void;
/** 搜索 */
search: string;
setSearch: (search: string) => void;
/** 初始化 */
init: (markType: MarkType) => Promise<void>;
queryMark: QueryMark;
markType: MarkType;
open: boolean;
setOpen: (open?: boolean) => void;
};
export const createManagerStore: StateCreator<ManagerStore, [], [], any> = (set, get, store) => {
return {
currentMarkId: '',
setCurrentMarkId: (markId: string) => set(() => ({ currentMarkId: markId })),
open: false,
setOpen: (open: boolean) => set(() => ({ open })),
getList: async () => {
const queryMark = get().queryMark;
const { search, pagination } = get();
const res = await queryMark.getMarkList({ page: pagination.current, pageSize: pagination.pageSize, search });
const oldList = get().list;
if (res.code === 200) {
const { pagination, list } = res.data || {};
const newList = [...oldList, ...list!];
const uniqueList = uniqBy(newList, (item) => item.id);
set(() => ({ list: uniqueList }));
set(() => ({ pagination: { current: pagination!.current, pageSize: pagination!.pageSize, total: pagination!.total } }));
}
},
getMarkFromList: (markId: string) => {
return get().list.find((item) => item.id === markId);
},
updateMark: async (mark: Mark) => {
const queryMark = get().queryMark;
const res = await queryMark.updateMark(mark);
if (res.code === 200) {
set((state) => {
const oldList = state.list;
const resMark = res.data!;
const newList = oldList.map((item) => (item.id === mark.id ? mark : item));
if (!mark.id) {
newList.unshift(resMark);
}
return {
list: newList,
};
});
}
return res;
},
getMark: async (markId: string) => {
const queryMark = get().queryMark;
const res = await queryMark.getMark(markId);
return res;
},
list: [],
setList: (list: any[]) => set(() => ({ list })),
init: async (markType: MarkType = 'wallnote') => {
// await get().getList();
console.log('init', set, get);
const queryMark = new QueryMark({
query: queryClient as any,
markType,
});
const url = new URL(window.location.href);
const pageSize = url.searchParams.get('pageSize') || '10';
set({ queryMark, markType, list: [], pagination: { current: 1, pageSize: parseInt(pageSize), total: 0 }, currentMarkId: '', markData: undefined });
setTimeout(async () => {
console.log('get', get);
get().getList();
}, 1000);
},
deleteMark: async (markId: string) => {
const queryMark = get().queryMark;
const res = await queryMark.deleteMark(markId);
const currentMarkId = get().currentMarkId;
if (res.code === 200) {
// get().getList();
set((state) => ({
list: state.list.filter((item) => item.id !== markId),
}));
if (currentMarkId === markId) {
set(() => ({ currentMarkId: '', markData: undefined }));
}
}
return res;
},
queryMark: undefined,
markType: 'simple',
markData: undefined,
setMarkData: (mark: Mark) => set(() => ({ markData: mark })),
pagination: {
current: 1,
pageSize: 10,
total: 0,
},
setPagination: (pagination: { current: number; pageSize: number; total: number }) => set(() => ({ pagination })),
/** 搜索 */
search: '',
setSearch: (search: string) => set(() => ({ search, list: [], pagination: { current: 1, pageSize: 10, total: 0 } })),
};
};
export const useManagerStore = create<ManagerStore>(createManagerStore);

51
src/pages/mark/page.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { App as Manager, useManagerStore } from './manager/Manager';
import { getUrlId } from '../draw/page';
import { toast } from 'sonner';
import { useLayoutEffect, useState } from 'react';
import { getHistoryState, setHistoryState } from '@kevisual/store/web-page.js';
export const App = () => {
const [id, setId] = useState('');
const urlId = getUrlId();
useLayoutEffect(() => {
const state = getHistoryState();
if (state?.id) {
setId(state.id);
return;
}
if (urlId) {
setId(urlId);
}
}, []);
return <>
<Manager
showSearch={true}
showAdd={true}
markType={'excalidraw'}
openMenu={!urlId}
onClick={(data) => {
if (data.id !== id) {
setId('');
const url = new URL(location.href);
url.searchParams.set('id', data.id);
console.log('set url', url.toString());
setHistoryState({}, url.toString());
setTimeout(() => {
setId(data.id);
}, 200);
const _store = useManagerStore.getState();
if (_store.markData) {
_store.setCurrentMarkId('');
// _store.setOpen(false);
_store.setMarkData(undefined);
}
} else if (data.id === id) {
toast.success('已选择当前画布');
}
console.log('onClick', data, id);
}}>
</Manager>
</>
};
export default App;

View File

@@ -1,5 +1,3 @@
const Home = () => {
return <div>Home Page</div>
}
import App from './draw/page'
export default Home;
export default App;