This commit is contained in:
2025-03-29 17:48:21 +08:00
parent a12d23be73
commit ac4542c6ff
256 changed files with 1361 additions and 214 deletions

View File

@@ -1,13 +1,53 @@
import { useEffect, useState } from 'react';
import { basename } from './modules/basename';
import { getHistoryState, setHistoryState } from '@kevisual/store/web-page.js';
import { Draw } from './pages/Draw';
import { App as Manager } from './manager/Manager';
// import { App as Manager } from './manager/Manager';
import { Manager } from '@kevisual/mark/src/Module';
console.log('basename', basename);
export const App = () => {
const [id, setId] = useState('');
useEffect(() => {
const state = getHistoryState();
if (state?.id) {
setId(state.id);
}
}, []);
return (
<div className='bg-slate-200 w-full h-full'>
{/* <Draw /> */}
<Manager />
<div className='bg-slate-200 w-full h-full flex'>
<Manager
showSearch={true}
showAdd={true}
markType={'excalidraw'}
onClick={(data) => {
console.log('data', data);
setHistoryState({
id: data.id,
});
if (data.id !== id) {
setId('');
setTimeout(() => {
setId(data.id);
}, 200);
}
}}>
<div className='h-full grow'>
{id ? (
<Draw
id={id}
onClose={() => {
setId('');
setHistoryState({
id: '',
});
}}
/>
) : (
<div></div>
)}
</div>
</Manager>
</div>
);
};

View File

@@ -1 +1,17 @@
@import "tailwindcss";
@import 'tailwindcss';
@import '@kevisual/components/theme/wind-theme.css';
/* .sidebar-trigger__label-element {
display: none;
} */
.HelpDialog__btn[href='https://plus.excalidraw.com/blog'] {
display: none;
}
.HelpDialog__btn[href='https://youtube.com/@excalidraw'] {
display: none;
}
/* .Excalidraw__loading {
display: none;
} */

View File

@@ -1,6 +1,15 @@
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import { ToastContainer } from 'react-toastify';
import './index.css';
import { I18NextProvider, initI18n } from '@kevisual/components/translate/index.tsx';
import { basename } from './modules/basename';
createRoot(document.getElementById('root')!).render(<App />);
createRoot(document.getElementById('root')!).render(
<>
<I18NextProvider basename={basename} noUse={false}>
<App />
</I18NextProvider>
<ToastContainer />
</>,
);

View File

@@ -1,28 +0,0 @@
import { useManagerStore } from './store';
import { useEffect } from 'react';
import { useShallow } from 'zustand/shallow';
import { ManagerProvider } from './Provider';
export const Manager = () => {
const { list, init } = useManagerStore(
useShallow((state) => {
console.log('state', state);
return {
list: state.list,
init: state.init,
};
}),
);
useEffect(() => {
init('md');
}, []);
return <div>Manager</div>;
};
export const App = () => {
return (
<ManagerProvider>
<Manager />
</ManagerProvider>
);
};

View File

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

View File

@@ -1,5 +0,0 @@
# 左侧的管理界面
获取列表,筛选,选中。
## 右侧的是画布

View File

@@ -1,55 +0,0 @@
import { StoreManager } from '@kevisual/store';
import { useContextKey } from '@kevisual/store/context';
// import { StateCreator, StoreApi, UseBoundStore } from 'zustand';
import { queryMark, queryClient } from '../../modules/query';
import { QueryMark } from '@kevisual/query-mark';
import { useStore, BoundStore } from '@kevisual/store/react';
export const store = useContextKey('store', () => {
return new StoreManager();
});
type ManagerStore = {
/** 当前选中的Mark */
currrentMark: any;
setCurrentMark: (mark: any) => void;
/** 获取Mark列表 */
getList: () => Promise<void>;
/** Mark列表 */
list: any[];
setList: (list: any[]) => void;
/** 初始化 */
init: (markType: string) => Promise<void>;
queryMark?: QueryMark;
markType: string;
};
export const createManagerStore = (set: any, get: any, store: any): ManagerStore => {
return {
currrentMark: null,
setCurrentMark: (mark: any) => set(() => ({ currrentMark: mark })),
getList: async () => {
const queryMark = get().queryMark;
const res = await queryMark.getMarkList({ page: 1, pageSize: 10 });
if (res.code === 200) {
set(() => ({ list: res.data }));
}
},
list: [],
setList: (list: any[]) => set(() => ({ list })),
init: async (markType: string = 'wallnote') => {
// await get().getList();
// console.log('init', set, );
const queryMark = new QueryMark({
query: queryClient as any,
markType,
});
set({ queryMark, markType });
setTimeout(() => {
queryMark.getMarkList({ page: 1, pageSize: 10 });
}, 1000);
},
queryMark: undefined,
markType: 'simple',
};
};
export const useManagerStore = useStore as BoundStore<ManagerStore>;

View File

@@ -5,53 +5,36 @@ import { StoreContextProvider } from '@kevisual/store/react';
import { LineChart } from 'lucide-react';
import { useShallow } from 'zustand/shallow';
import { Core } from './core/Excalidraw';
export const DrawLayout = ({ children }: { children: React.ReactNode }) => {
export const Draw = ({ id, onClose }: { id: string; onClose: () => void }) => {
useLayoutEffect(() => {
// @ts-ignore
window.EXCALIDRAW_ASSET_PATH = 'https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/';
// window.EXCALIDRAW_ASSET_PATH = '/';
}, []);
return (
<StoreContextProvider id='draw'>
<div className='h-full w-full'>{children}</div>
<DrawHeader />
<StoreContextProvider id={id} stateCreator={createMarkStore}>
<ExcaliDrawComponent id={id} onClose={onClose} />
</StoreContextProvider>
);
};
export const Draw = () => {
useLayoutEffect(() => {
// @ts-ignore
// window.EXCALIDRAW_ASSET_PATH = 'https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/';
window.EXCALIDRAW_ASSET_PATH = '/';
}, []);
return (
<DrawLayout>
<ExcaliDrawComponent />
</DrawLayout>
);
type ExcaliDrawComponentProps = {
/** 修改的id */
id: string;
/** 关闭 */
onClose: () => void;
};
export const DrawHeader = () => {
export const ExcaliDrawComponent = ({ id, onClose }: ExcaliDrawComponentProps) => {
const store = useMarkStore(
useShallow((value) => {
useShallow((state) => {
return {
mark: value.mark,
setMark: value.setMark,
setInfo: value.setInfo,
getList: value.getList,
id: state.id,
setId: state.setId,
getMark: state.getMark,
};
}),
);
console.log('store show', store);
useEffect(() => {
store.getList();
}, []);
return (
<div className='fixed left-0 top-0 z-10 h-10 w-10 bg-red-500'>
<button>
<LineChart />
</button>
</div>
);
};
export const ExcaliDrawComponent = () => {
return (
<StoreContextProvider id='draw2' stateCreator={createMarkStore}>
<Core />
</StoreContextProvider>
);
return <Core onClose={onClose} id={id} />;
};

View File

@@ -1,15 +1,245 @@
import { Excalidraw } from '@excalidraw/excalidraw';
import '@excalidraw/excalidraw/index.css';
import { OrderedExcalidrawElement } from '@excalidraw/excalidraw/element/types';
import { useState } from 'react';
export const Core = () => {
import { useEffect, useRef, useState } from 'react';
import { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types';
import { Languages, LogOut, Save } from 'lucide-react';
import { MainMenu, Sidebar, Footer } from '@excalidraw/excalidraw';
import { throttle } from 'lodash-es';
import { useMarkStore } from '@/store';
import { useShallow } from 'zustand/shallow';
import { useListenLang } from './hooks/listen-lang';
import { toast } from 'react-toastify';
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 (
<Excalidraw
initialData={{ elements: [] }}
onChange={(elements) => {
console.log('elements', elements);
}}
/>
<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 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 { setCache, loading } = useMarkStore.getState(id);
if (loading) {
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 (!isChange) {
if (JSON.stringify(cacheFiles) !== JSON.stringify(filesObject)) {
isChange = true;
}
}
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) => {
const story = useMarkStore.getState(id);
if (story.loading) return;
onSave(elements, appState, filesObject);
}}
langCode={lang || 'en'}
onPaste={async (e) => {
toast.info('paste is not allowed, is development');
return false;
}}
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 '1.png';
}}>
<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

@@ -3,31 +3,212 @@ import { useContextKey } from '@kevisual/store/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 'react-toastify';
import { BinaryFileData, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types';
export const cacheStore = createStore('excalidraw-store', 'excalidraw');
export const store = useContextKey('store', () => {
return new StoreManager();
});
type MarkStore = {
mark: string;
setMark: (mark: string) => void;
info: string;
setInfo: (info: string) => void;
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 = (get: any, set: any, store: any): MarkStore => {
export const createMarkStore: StateCreator<MarkStore, [], [], MarkStore> = (set, get, store) => {
return {
mark: 'test',
setMark: (mark: string) => set(() => ({ mark })),
info: 'test info',
setInfo: (info: string) => set(() => ({ info })),
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 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,
},
];