add wallnote

This commit is contained in:
abearxiong 2025-02-23 02:25:11 +08:00
parent 07d053abe7
commit a91f80c1ba
30 changed files with 3207 additions and 143 deletions

View File

@ -1,8 +1,9 @@
{
"name": "vite-react",
"name": "wallnote",
"private": true,
"version": "0.0.1",
"version": "0.0.2",
"type": "module",
"user": "apps",
"scripts": {
"dev": "vite",
"dev:web": "cross-env WEB_DEV=true vite --mode web",
@ -10,7 +11,8 @@
"lint": "eslint .",
"deploy": "rsync -avz --delete dist/ light:~/apps/ai/dist",
"preview": "vite preview",
"pub": "envision deploy ./dist -k vite-react -v 0.0.1",
"prepub": "envision switchOrg apps",
"pub": "envision deploy ./dist -k wallnote -v 0.0.2 -y y",
"ev": "npm run build && npm run deploy"
},
"stackblitz": {
@ -20,21 +22,40 @@
"license": "MIT",
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@kevisual/query": "0.0.7-alpha.3",
"@kevisual/router": "0.0.6-alpha-5",
"@kevisual/system-ui": "^0.0.3",
"@kevisual/ui": "^0.0.4-alpha-1",
"@mui/material": "^6.4.5",
"@tiptap/core": "^2.11.5",
"@tiptap/extension-code-block-lowlight": "^2.11.5",
"@tiptap/extension-highlight": "^2.11.5",
"@tiptap/extension-typography": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@types/lodash-es": "^4.17.12",
"@types/turndown": "^5.0.5",
"@xyflow/react": "^12.4.3",
"antd": "^5.24.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"highlight.js": "^11.11.1",
"idb": "^8.0.2",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
"lodash-es": "^4.17.21",
"lowlight": "^3.3.0",
"lucide-react": "^0.475.0",
"marked": "^15.0.7",
"nanoid": "^5.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.2.0",
"react-router-dom": "^7.2.0",
"react-toastify": "^11.0.3",
"tiptap-markdown": "^0.8.10",
"turndown": "^7.2.0",
"zustand": "^5.0.3"
},
"devDependencies": {

1515
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,27 @@
import { Flow } from './pages/wall';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Editor } from './pages/editor';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { List } from './pages/wall/pages/List';
import { Auth } from './modules/layouts/Auth';
export const App = () => {
return (
<div className='bg-slate-200 w-full h-full border'>
<div className='test-loading bg-black'>
<div></div>
</div>
</div>
<>
<BrowserRouter basename='/apps/wallnote'>
<Routes>
<Route element={<Auth auth={false} />}>
<Route path='/' element={<Flow checkLogin={false}/>} />
<Route path='/editor' element={<Editor />} />
</Route>
<Route element={<Auth auth={true} />}>
<Route path='/edit/:id' element={<Flow checkLogin={true}/>} />
<Route path='/list' element={<List />} />
</Route>
</Routes>
</BrowserRouter>
<ToastContainer />
</>
);
};

View File

@ -1,7 +1,35 @@
@import "tailwindcss";
@import 'tailwindcss';
@import './pages/wall/modules/CustomNode.css';
@layer components {
.test-loading {
@apply w-20 h-20 bg-gray-300 rounded-full animate-spin;
.node-editor {
@apply w-20 h-20 bg-gray-300 bg-white;
}
}
html,
body {
overflow: hidden;
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
body {
font-family: 'Roboto', sans-serif;
background-color: #f5f5f5;
font-size: 16px;
}
#root {
height: 100%;
width: 100%;
}
.react-flow__attribution {
display: none;
}
.drawer-editor {
.tiptap {
border: unset;
}
}

11
src/modules/dayjs.ts Normal file
View File

@ -0,0 +1,11 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
export const formatDate = (date?: string, format = 'YYYY-MM-DD HH:mm:ss') => {
return dayjs(date).format(format);
};
export const formatRelativeDate = (date?: string) => {
return dayjs(date).fromNow();
};

View File

@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { useUserWallStore } from '../../pages/wall/store/user-wall';
import { useShallow } from 'zustand/react/shallow';
import { Outlet } from 'react-router-dom';
export const Auth = ({ children, auth = true }: { children?: React.ReactNode; auth?: boolean }) => {
const userStore = useUserWallStore(
useShallow((state) => {
return { user: state.user, queryMe: state.queryMe };
}),
);
useEffect(() => {
if (!userStore.user) {
userStore.queryMe(auth);
}
}, []);
if (children) {
if (auth) {
return <>{userStore.user && children}</>;
}
return <>{children}</>;
}
return <>{<Outlet />}</>;
};

10
src/modules/md2html.ts Normal file
View File

@ -0,0 +1,10 @@
import { marked } from 'marked';
import TurndownService from 'turndown';
export const md2html = async (md: string) => {
return marked.parse(md);
};
export const html2md = async (html: string) => {
const turndownService = new TurndownService();
return turndownService.turndown(html);
};

23
src/modules/message.ts Normal file
View File

@ -0,0 +1,23 @@
import { toast, ToastOptions } from 'react-toastify';
export const message = {
error: (message: string, options?: ToastOptions) => {
toast.error(message, options);
},
success: (message: string, options?: ToastOptions) => {
toast.success(message, {
position: 'top-left',
autoClose: 1000,
...options,
});
},
warning: (message: string, options?: ToastOptions) => {
toast.warning(message, options);
},
info: (message: string, options?: ToastOptions) => {
toast.info(message, options);
},
default: (message: string, options?: ToastOptions) => {
toast(message, options);
},
};

17
src/modules/query.ts Normal file
View File

@ -0,0 +1,17 @@
import { QueryClient } from '@kevisual/query';
import { modal } from './require-to-login';
import { message } from './message';
export const query = new QueryClient();
query.afterResponse = async (res) => {
if (res.code === 401) {
modal.setOpen(true);
}
if (res.code === 403) {
if (!res?.message) {
message.error('Unauthorized');
}
}
return res;
};

View File

@ -0,0 +1,36 @@
import { DialogModal } from '@kevisual/system-ui/dist/modal';
// import '@kevisual/system-ui/dist/modal.css';
const content = document.createElement('div');
const loginUrl = '/root/center/user/login';
export const redirectToLogin = (open = true) => {
const url = new URL(loginUrl, window.location.href);
url.searchParams.set('redirect', window.location.href);
if (open) {
window.open(url.toString(), '_blank');
} else {
return url.toString();
}
};
content.innerHTML = `
<div class="bg-white p-8 rounded shadow-md w-full max-w-md text-center">
<h2 class="text-2xl font-bold mb-4">Token </h2>
<p class="mb-6"></p>
<a href="${redirectToLogin(false)}" class="inline-block bg-red-500 text-white py-2 px-4 rounded hover:bg-red-600 transition duration-200"></a>
</div>
`;
export const modal = DialogModal.render(content, {
id: 'redirect-to-login',
contentStyle: {
width: 'unset',
},
dialogTitleStyle: {
display: 'none',
padding: '0',
},
dialogContentStyle: {
padding: '0',
},
mask: true,
open: false,
});

View File

@ -0,0 +1,98 @@
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight';
import Typography from '@tiptap/extension-typography';
import { Markdown } from 'tiptap-markdown';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { all, createLowlight } from 'lowlight';
import 'highlight.js/styles/github.css';
// 根据需要引入的语言支持
import js from 'highlight.js/lib/languages/javascript';
import ts from 'highlight.js/lib/languages/typescript';
import html from 'highlight.js/lib/languages/xml';
import css from 'highlight.js/lib/languages/css';
import markdown from 'highlight.js/lib/languages/markdown';
const lowlight = createLowlight(all);
// you can also register individual languages
lowlight.register('html', html);
lowlight.register('css', css);
lowlight.register('js', js);
lowlight.register('ts', ts);
lowlight.register('markdown', markdown);
export class TextEditor {
private editor?: Editor;
constructor() {}
createEditor(el: HTMLElement, opts?: { markdown?: string; html?: string }) {
if (this.editor) {
this.destroy();
}
const html = opts?.html || '';
this.editor = new Editor({
element: el, // 指定编辑器容器
extensions: [
StarterKit, // 使用 StarterKit 包含基础功能
Highlight,
Typography,
Markdown,
CodeBlockLowlight.extend({
addKeyboardShortcuts() {
return {
Tab: () => {
const { state, dispatch } = this.editor.view;
const { tr, selection } = state;
const { from, to } = selection;
// 插入4个空格的缩进
dispatch(tr.insertText(' ', from, to));
return true;
},
'Shift-Tab': () => {
const { state, dispatch } = this.editor.view;
const { tr, selection } = state;
const { from, to } = selection;
// 获取当前选中的文本
const selectedText = state.doc.textBetween(from, to, '\n');
// 取消缩进移除前面的4个空格
const unindentedText = selectedText.replace(/^ {1,4}/gm, '');
dispatch(tr.insertText(unindentedText, from, to));
return true;
},
};
},
}).configure({
lowlight,
}),
],
content: html, // 初始化内容
});
}
setContent(html: string) {
this.editor?.commands.setContent(html);
}
getHtml() {
return this.editor?.getHTML();
}
getContent() {
return this.editor?.getText();
}
onContentChange(callback: (html: string) => void) {
this.editor?.off('update'); // 移除之前的监听
this.editor?.on('update', () => {
callback(this.editor?.getHTML() || '');
});
}
foucus() {
this.editor?.view?.focus?.();
}
destroy() {
this.editor?.destroy();
this.editor = undefined;
}
}

View File

@ -0,0 +1,32 @@
import { TextEditor } from '@/modules/tiptap/editor';
import { useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
type EditorProps = {
className?: string;
value?: string;
id?: string;
onChange?: (value: string) => void;
};
export const Editor = ({ className, value, onChange, id }: EditorProps) => {
const textEditorRef = useRef<TextEditor | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
const [mount, setMount] = useState(false);
useEffect(() => {
const editor = new TextEditor();
textEditorRef.current = editor;
editor.createEditor(editorRef.current!, { html: value });
editor.onContentChange((content) => {
onChange?.(content);
});
setMount(true);
return () => {
editor.destroy();
};
}, []);
useEffect(() => {
if (textEditorRef.current && id && mount) {
textEditorRef.current.setContent(value || '');
}
}, [id, mount]);
return <div ref={editorRef} className={clsx('w-full h-full node-editor', className)}></div>;
};

View File

@ -0,0 +1,21 @@
export function ResizeIcon({ className }: { className?: string }) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='20'
height='20'
viewBox='0 0 24 24'
strokeWidth='2'
stroke='#ff0071'
fill='none'
strokeLinecap='round'
strokeLinejoin='round'
className={className}>
<path stroke='none' d='M0 0h24v24H0z' fill='none' />
<polyline points='16 20 20 20 20 16' />
<line x1='14' y1='14' x2='20' y2='20' />
<polyline points='8 4 4 4 4 8' />
<line x1='4' y1='4' x2='10' y2='10' />
</svg>
);
}

View File

@ -0,0 +1 @@
export const BlankNoteText = '<i>double click to edit</i>';

View File

@ -0,0 +1,35 @@
import { useCallback, useRef } from 'react';
export const useCheckDoubleClick = ({
onPaneDoubleClick,
onPaneClick,
}: {
onPaneDoubleClick?: (e: React.MouseEvent) => void;
onPaneClick?: (e: React.MouseEvent) => void;
}) => {
const lastClickTime = useRef(0);
const clickTimeOut = useRef<NodeJS.Timeout | null>(null);
const onCheckPanelDoubleClick = useCallback(
(e: React.MouseEvent) => {
const currentTime = Date.now();
if (currentTime - lastClickTime.current < 300 && lastClickTime.current !== 0) {
onPaneDoubleClick?.(e); // Use optional chaining to call debounceClick if it's defined
clearTimeout(clickTimeOut.current!);
clickTimeOut.current = null;
lastClickTime.current = 0; // Reset
return;
} else if (lastClickTime.current === 0) {
// First click, setup a timeout to handle single click
lastClickTime.current = currentTime; // Update the last click time here as well
clickTimeOut.current = setTimeout(() => {
onPaneClick?.(e);
lastClickTime.current = 0; // Reset
}, 300);
}
},
[onPaneDoubleClick, onPaneClick],
);
return { onCheckPanelDoubleClick };
};

View File

@ -0,0 +1,62 @@
import { useReactFlow } from '@xyflow/react';
import { useEffect } from 'react';
export const useTabNode = () => {
const reactFlowInstance = useReactFlow();
useEffect(() => {
const listener = (event: any) => {
if (event.key === 'Tab') {
console.log('tab');
const nodes = reactFlowInstance.getNodes();
const selectedNode = nodes.find((node) => node.selected);
if (!selectedNode) return;
// 获取选中的节点
const { x, y } = selectedNode?.position || { x: 0, y: 0 };
// 根据nodes的position的x和y进行排序x小的在前x相等时y小的在前
const newNodes = nodes.sort((a, b) => {
const { x: ax, y: ay } = a.position || { x: 0, y: 0 };
const { x: bx, y: by } = b.position || { x: 0, y: 0 };
if (ax < bx) return -1;
if (ax > bx) return 1;
return ay - by;
});
const nextNode = newNodes.find((node) => {
if (node.id === selectedNode?.id) return false;
const { x: nx, y: ny } = node.position;
if (nx > x) {
return true;
} else if (nx === x) {
if (ny > y) {
return true;
}
}
return false;
});
if (nextNode) {
const newNodes = nodes.map((node) => {
if (node.id === nextNode.id) {
return { ...node, selected: true };
}
return { ...node, selected: false };
});
reactFlowInstance.setNodes(newNodes);
} else {
const newNodes = nodes.map((node) => {
if (node.id === nodes[0].id) {
return { ...node, selected: true };
}
return { ...node, selected: false };
});
reactFlowInstance.setNodes(newNodes);
}
event.preventDefault();
event.stopPropagation();
}
};
window.addEventListener('keydown', listener);
return () => {
window.removeEventListener('keydown', listener);
};
}, [reactFlowInstance]);
};

174
src/pages/wall/index.tsx Normal file
View File

@ -0,0 +1,174 @@
import {
ReactFlow,
MiniMap,
Controls,
Background,
useNodesState,
Node,
useReactFlow,
ReactFlowProvider,
Panel,
useStoreApi,
useStore,
XYPosition,
NodeChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useWallStore } from './store/wall';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCheckDoubleClick } from './hooks/check-double-click';
import { randomId } from './utils/random';
import { CustomNodeType } from './modules/CustomNode';
import Drawer from './modules/Drawer';
import { message } from '@/modules/message';
import { useShallow } from 'zustand/react/shallow';
import { BlankNoteText } from './constants';
import { Toolbar } from './modules/toolbar/Toolbar';
import { useUserWallStore } from './store/user-wall';
import { useNavigate, useParams } from 'react-router-dom';
import { SaveModal } from './modules/FormDialog';
import { useTabNode } from './hooks/tab-node';
import { Button } from '@mui/material';
type NodeData = {
id: string;
position: XYPosition;
data: any;
};
export function FlowContent() {
const reactFlowInstance = useReactFlow();
const [nodes, setNodes, onNodesChange] = useNodesState<NodeData>([]);
const wallStore = useWallStore((state) => state);
const store = useStore((state) => state);
const [mount, setMount] = useState(false);
const _onNodesChange = useCallback((changes: NodeChange[]) => {
const [change] = changes;
if (change.type === 'position' && change.dragging === false) {
// console.log('position changes', change);
getNewNodes();
}
onNodesChange(changes);
}, []);
useEffect(() => {
setNodes(wallStore.nodes);
setMount(true);
return () => {
setMount(false);
};
}, [wallStore.nodes]);
const onNodeDoubleClick = (event, node) => {
wallStore.setOpen(true);
wallStore.setSelectedNode(node);
};
const getNewNodes = () => {
const nodes = reactFlowInstance.getNodes();
wallStore.saveNodes(nodes);
};
useEffect(() => {
if (mount) {
// console.log('nodes', nodes);
}
}, [nodes, mount]);
useTabNode();
// 添加新节点的函数
const onPaneDoubleClick = (event) => {
// 计算节点位置
const x = event.clientX;
const y = event.clientY;
const postion = reactFlowInstance.screenToFlowPosition({ x, y });
const newNode = {
id: randomId(), // 确保每个节点有唯一的ID
type: 'wall', // 节点类型
position: postion, // 使用事件的客户端坐标
data: { html: BlankNoteText },
};
setNodes((nds) => {
const newNodes = nds.concat(newNode);
getNewNodes();
return newNodes;
});
message.success('添加节点成功');
setTimeout(() => {
wallStore.setSelectedNode(newNode);
wallStore.setOpen(true);
}, 200);
};
const hasFoucedNode = useMemo(() => {
return !!store.nodes.find((node) => node.selected);
}, [store.nodes]);
const { onCheckPanelDoubleClick } = useCheckDoubleClick({
onPaneDoubleClick,
});
return (
<ReactFlow
nodes={nodes}
// debug={DEV_SERVER}
fitView
onNodesChange={_onNodesChange}
onNodeDoubleClick={onNodeDoubleClick}
onPaneClick={onCheckPanelDoubleClick}
zoomOnScroll={true}
preventScrolling={!hasFoucedNode}
nodeTypes={CustomNodeType}>
<Controls />
<MiniMap />
<Background gap={[14, 14]} size={2} color='#E4E5E7' />
<Panel position='top-left'>
<Toolbar />
</Panel>
<Panel>
<Drawer />
<SaveModal />
</Panel>
</ReactFlow>
);
}
export const Flow = ({ checkLogin = true }: { checkLogin?: boolean }) => {
const { id } = useParams();
const navigate = useNavigate();
const wallStore = useWallStore(
useShallow((state) => {
return {
loaded: state.loaded,
init: state.init,
};
}),
);
useEffect(() => {
wallStore.init(id);
console.log('checkLogin', checkLogin, id);
}, [id, checkLogin]);
if (!wallStore.loaded) {
return <div>loading...</div>;
} else if (wallStore.loaded === 'error') {
return (
<div className='flex flex-col items-center justify-center h-screen gap-4'>
<div className='text-2xl font-bold'>,</div>
<Button
variant='contained'
onClick={() => {
navigate('/');
}}>
</Button>
</div>
);
}
return (
<ReactFlowProvider>
<FlowContent />
</ReactFlowProvider>
);
};
export const FlowStatus = () => {
const { nodes } = useWallStore();
const reactFlow = useReactFlow();
const flowStore = useStore((state) => state);
return (
<div>
<div>: {nodes.length}</div>
</div>
);
};

View File

@ -0,0 +1,149 @@
@import 'tailwindcss';
@layer components {
.node-editor {
@apply w-full h-full bg-white;
> div {
@apply w-full h-full outline-none;
}
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.scrollbar::-webkit-scrollbar {
display: block;
width: 2px;
height: 2px;
}
.scrollbar::-webkit-scrollbar-thumb {
background-color: #ccc;
border-radius: 2px;
}
.scrollbar::-webkit-scrollbar-thumb:horizontal {
background-color: #ccc;
border-radius: 2px;
}
}
:root {
--purple-light: #e0e0ff; /* 默认浅紫色背景 */
--black: #000000; /* 默认黑色 */
--white: #ffffff; /* 默认白色 */
--gray-3: #d3d3d3; /* 默认灰色3 */
--gray-2: #e5e5e5; /* 默认灰色2 */
}
.tiptap-preview {
.tiptap {
margin: 0;
padding: 0.5rem;
border: unset;
}
}
.tiptap {
margin: 0.5rem 1rem;
padding: 0.5rem;
border-radius: 5px;
border: 1px solid #ccc;
}
/* Basic editor styles */
.tiptap:first-child {
margin-top: 0;
}
/* List styles */
.tiptap ul,
.tiptap ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
}
.tiptap ul li p,
.tiptap ol li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
/* Heading styles */
.tiptap h1,
.tiptap h2,
.tiptap h3,
.tiptap h4,
.tiptap h5,
.tiptap h6 {
line-height: 1.1;
margin-top: 2.5rem;
text-wrap: pretty;
}
.tiptap h1,
.tiptap h2 {
/* margin-top: 3.5rem; */
margin-top: 1rem;
margin-bottom: .5rem;
}
.tiptap h1 {
font-size: 1.4rem;
font-weight: 800;
}
.tiptap h2 {
font-size: 1.2rem;
font-weight: 600;
}
.tiptap h3 {
font-size: 1.1rem;
font-weight: 500;
}
.tiptap h4,
.tiptap h5,
.tiptap h6 {
font-size: 1rem;
}
/* Code and preformatted text styles */
.tiptap code {
background-color: var(--purple-light);
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
}
.tiptap pre {
border: 1px solid #ccc;
/* background: var(--black); */
border-radius: 0.5rem;
/* color: var(--white); */
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
}
.tiptap pre code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
.tiptap mark {
background-color: #FAF594;
border-radius: 0.4rem;
box-decoration-break: clone;
padding: 0.1rem 0.3rem;
}
.tiptap blockquote {
border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem;
}
.tiptap hr {
border: none;
border-top: 1px solid var(--gray-2);
margin: 2rem 0;
}

View File

@ -0,0 +1,150 @@
import { useRef, memo, useEffect, useMemo, useState } from 'react';
import clsx from 'clsx';
import { NodeResizer, useStore } from '@xyflow/react';
import { useWallStore } from '../store/wall';
import { useShallow } from 'zustand/react/shallow';
import { toast } from 'react-toastify';
import { message } from '@/modules/message';
import hljs from 'highlight.js';
import { Edit } from 'lucide-react';
export type WallData<T = Record<string, any>> = {
html: string;
width?: number;
height?: number;
[key: string]: any;
} & T;
const ShowContent = (props: { data: WallData; selected: boolean }) => {
const html = props.data.html;
const selected = props.selected;
const showRef = useRef<HTMLDivElement>(null);
if (!html) return <div className='w-full h-full flex items-center justify-center '></div>;
const [highlightHtml, setHighlightHtml] = useState('');
const highlight = async (html: string) => {
const _html = html.replace(/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g, (match, p1, p2) => {
return `<pre><code class="language-${p1}">${hljs.highlight(p2, { language: p1 }).value}</code></pre>`;
});
return _html;
};
useEffect(() => {
highlight(html).then((res) => {
setHighlightHtml(res);
});
}, [html]);
return (
<div
ref={showRef}
className='p-2 w-full h-full overflow-y-auto scrollbar tiptap bg-white'
style={{
pointerEvents: selected ? 'auto' : 'none',
}}
dangerouslySetInnerHTML={{ __html: highlightHtml }}></div>
);
};
export const CustomNode = (props: { id: string; data: WallData; selected: boolean }) => {
const data = props.data;
const contentRef = useRef<HTMLDivElement>(null);
const selected = props.selected;
const wallStore = useWallStore(
useShallow((state) => {
return {
setOpen: state.setOpen,
setSelectedNode: state.setSelectedNode,
saveNodes: state.saveNodes,
};
}),
);
const store = useStore((state) => {
return {
updateWallRect: (id: string, rect: { width: number; height: number }) => {
const nodes = state.nodes.map((node) => {
if (node.id === id) {
node.data.width = rect.width;
node.data.height = rect.height;
}
return node;
});
state.setNodes(nodes);
wallStore.saveNodes(nodes);
},
getNode: (id: string) => {
return state.nodes.find((node) => node.id === id);
},
deleteNode: (id: string) => {
const nodes = state.nodes.filter((node) => node.id !== id);
state.setNodes(nodes);
wallStore.saveNodes(nodes);
},
};
});
useEffect(() => {
if (selected) {
const handleDelete = (e: KeyboardEvent) => {
if (e.key === 'Delete') {
store.deleteNode(props.id);
}
};
window.addEventListener('keydown', handleDelete);
return () => window.removeEventListener('keydown', handleDelete);
}
}, [selected]);
const width = data.width || 100;
const height = data.height || 100;
const style: React.CSSProperties = {};
style.width = width;
style.height = height;
const showOpen = () => {
const node = store.getNode(props.id);
if (node) {
wallStore.setOpen(true);
wallStore.setSelectedNode(node);
} else {
message.error('节点不存在');
}
};
return (
<>
<div
ref={contentRef}
onDoubleClick={(e) => {
showOpen();
// e.stopPropagation();
e.preventDefault();
}}
className={clsx('w-full h-full border relative border-gray-300 min-w-[100px] min-h-[50px] tiptap-preview')}
style={style}>
<ShowContent data={data} selected={props.selected} />
</div>
<div className={clsx('absolute top-0 right-0', props.selected ? 'opacity-100' : 'opacity-0')}>
<button
className='w-6 h-6 flex items-center justify-center'
onClick={() => {
showOpen();
}}>
<Edit className='w-4 h-4' />
</button>
</div>
<NodeResizer
minWidth={100}
minHeight={50}
onResizeStart={() => {}}
isVisible={props.selected}
onResizeEnd={(e) => {
const parent = contentRef.current?.parentElement;
if (!parent) return;
const width = parent.style.width;
const height = parent.style.height;
const widthNum = parseInt(width);
const heightNum = parseInt(height);
if (!heightNum || !widthNum) return;
store.updateWallRect(props.id, { width: widthNum, height: heightNum });
}}
/>
</>
);
};
export const WallNoteNode = memo(CustomNode);
export const CustomNodeType = {
wall: WallNoteNode,
};

View File

@ -0,0 +1,106 @@
import { useWallStore } from '../store/wall'; // 确保导入正确的路径
import clsx from 'clsx';
import { X } from 'lucide-react'; // 导入 Close 图标
import { Editor } from '@/pages/editor';
import { useEffect, useState } from 'react';
import { useStore, useStoreApi } from '@xyflow/react';
import { BlankNoteText } from '../constants';
import { message } from '@/modules/message';
import { useShallow } from 'zustand/react/shallow';
import { isMac } from '../utils/is-mac';
const Drawer = () => {
const { open, setOpen, selectedNode, setSelectedNode, editValue, setEditValue } = useWallStore(
useShallow((state) => ({
open: state.open,
setOpen: state.setOpen,
selectedNode: state.selectedNode,
setSelectedNode: state.setSelectedNode,
editValue: state.editValue,
setEditValue: state.setEditValue,
})),
);
const store = useStore((state) => state);
const storeApi = useStoreApi();
useEffect(() => {
if (open && selectedNode) {
setEditValue(selectedNode?.data.html);
}
}, [open, selectedNode]);
useEffect(() => {
return () => {
setOpen(false);
setSelectedNode(null);
};
}, []);
const listener = async (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false);
}
const systemKey = e.metaKey || e.ctrlKey;
// mac command+s windows ctrl+s
if (systemKey && e.key === 's') {
onSave();
e.preventDefault();
e.stopPropagation();
}
};
useEffect(() => {
window.addEventListener('keydown', listener);
return () => {
window.removeEventListener('keydown', listener);
};
}, []);
const onSave = () => {
const wallStore = useWallStore.getState();
const selectedNode = wallStore.selectedNode;
const _editValue = wallStore.editValue;
if (selectedNode && _editValue) {
selectedNode.data.html = _editValue;
const newNodes = storeApi.getState().nodes.map((node) => (node.id === selectedNode.id ? selectedNode : node));
storeApi.setState({ nodes: newNodes });
if (wallStore.id) {
message.success('保存成功', {
closeOnClick: true,
});
}
wallStore.saveNodes(newNodes);
}
};
let html = selectedNode?.data?.html || '';
if (html === BlankNoteText) {
html = '';
}
return (
<div
className={clsx(
'transition-all duration-300 bg-white flex flex-col gap-2 h-full w-full overflow-hidden fixed right-0 top-0 z-10',
open ? 'open' : 'hidden',
'w-[800px] xs:w-[100%] sm:w-[100%] md:w-[600px] lg:w-[600px] xl:w-[600px] 2xl:w-[800px]', // 默认宽度,根据屏幕大小适配,小屏幕全屏幕
)}>
<div className='flex justify-between items-center h-10'>
<button onClick={() => setOpen(false)}>
<X className='w-6 h-6 cursor-pointer ml-2' />
</button>
{selectedNode && (
<div>
<button className='bg-blue-500 text-white px-4 py-1 rounded-md mr-4 cursor-pointer' onClick={onSave}>
</button>
</div>
)}
</div>
<div
className='pr-4 mx-4 mb-4 rounded-md pb-4 box-border scrollbar border border-gray-300 '
style={{
height: 'calc(100vh - 2.5rem)',
overflowY: 'auto',
}}>
{selectedNode && open && <Editor className='drawer-editor' value={html} onChange={setEditValue} id={selectedNode.id} />}
</div>
</div>
);
};
export default Drawer;

View File

@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { Dialog, DialogTitle, DialogContent, TextField, DialogActions, Button, Chip } from '@mui/material';
import { useShallow } from 'zustand/react/shallow';
import { getNodeData, useWallStore } from '../store/wall';
import { useReactFlow, useStore } from '@xyflow/react';
import { useUserWallStore } from '../store/user-wall';
import { message } from '@/modules/message';
import { useNavigate } from 'react-router-dom';
function FormDialog({ open, handleClose, handleSubmit, initialData }) {
const [data, setData] = useState(initialData || { title: '', description: '', summary: '', tags: [] });
const handleChange = (event) => {
setData({ ...data, [event.target.name]: event.target.value });
};
const handleTagDelete = (tagToDelete) => {
setData({ ...data, tags: data.tags.filter((tag) => tag !== tagToDelete) });
};
const handleAddTag = (event) => {
if (event.key === 'Enter' && event.target.value !== '') {
setData({ ...data, tags: [...data.tags, event.target.value] });
event.target.value = ''; // Clear input after adding tag
}
};
return (
<Dialog open={open} onClose={handleClose}>
<DialogTitle>{initialData ? 'Edit Data' : 'Create Data'}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin='dense'
name='title'
label='Title'
type='text'
fullWidth
variant='outlined'
value={data.title}
onChange={handleChange}
required
/>
<TextField
margin='dense'
name='description'
label='Description'
type='text'
fullWidth
multiline
variant='outlined'
value={data.description}
onChange={handleChange}
/>
<TextField
margin='dense'
name='summary'
label='Summary'
type='text'
fullWidth
multiline
variant='outlined'
value={data.summary}
onChange={handleChange}
/>
<TextField
margin='dense'
name='tags'
label='Tags'
type='text'
fullWidth
variant='outlined'
placeholder='Press enter to add tags'
onKeyPress={handleAddTag}
/>
{data.tags.map((tag, index) => (
<Chip key={index} label={tag} onDelete={() => handleTagDelete(tag)} style={{ margin: '5px' }} />
))}
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={() => handleSubmit(data)}>Submit</Button>
</DialogActions>
</Dialog>
);
}
export default FormDialog;
export const SaveModal = () => {
const wallStore = useWallStore(useShallow((state) => state));
const userWallStore = useUserWallStore(useShallow((state) => state));
const { showFormDialog, setShowFormDialog, formDialogData, setFormDialogData } = wallStore;
const reactFlowInstance = useReactFlow();
const navigate = useNavigate();
const { id } = wallStore;
const onSubmit = async (values) => {
const nodes = reactFlowInstance.getNodes();
const data = {
nodes: getNodeData(nodes),
};
const fromData = {
title: values.title,
description: values.description,
summary: values.summary,
tags: values.tags,
markType: 'wall' as 'wall',
data,
};
const res = await userWallStore.saveWall(fromData, { refresh: true });
if (res.code === 200) {
setShowFormDialog(false);
if (!id) {
// 新创建
const data = res.data;
wallStore.clear();
setTimeout(() => {
navigate(`/wall/${data.id}`);
}, 2000);
} else {
// 编辑
wallStore.setData(res.data);
}
} else {
message.error('保存失败');
}
};
if (!showFormDialog) {
return null;
}
return <FormDialog open={showFormDialog} handleClose={() => setShowFormDialog(false)} handleSubmit={onSubmit} initialData={formDialogData} />;
};

View File

@ -0,0 +1,272 @@
import { PanelTopOpen, PanelTopClose, Save, Download, Upload, User, Trash, Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useWallStore } from '../../store/wall';
import clsx from 'clsx';
import { useUserWallStore } from '../../store/user-wall';
import { redirectToLogin } from '@/modules/require-to-login';
import { useStore } from '@xyflow/react';
import { message } from '@/modules/message';
import { useNavigate } from 'react-router-dom';
import { ClickAwayListener } from '@mui/material';
export const ToolbarItem = ({
children,
showBorder = true,
onClick,
className,
}: {
children: React.ReactNode;
showBorder?: boolean;
onClick?: () => any;
className?: string;
}) => {
return (
<div onClick={onClick} className={clsx('flex items-center w-full gap-4 p-2 border-b border-gray-300 cursor-pointer', showBorder && 'border-b', className)}>
{children}
</div>
);
};
// 空白处点击当不包函toolbar时候关闭toolbar
export const useBlankClick = () => {
const { setToolbarOpen } = useWallStore(
useShallow((state) => {
return {
setToolbarOpen: state.setToolbarOpen,
};
}),
);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
// 点击的内容closest('.toolbar')
const target = e.target as HTMLElement;
const toolbar = target.closest('.toolbar'); // 往上找找到toolbar为止
console.log('toolbar', target, toolbar);
// if (!toolbar) {
// setToolbarOpen(false);
// }
};
console.log('add event');
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, []);
};
export const ToolbarContent = ({ open }) => {
if (!open) {
return null;
}
const wallStore = useWallStore(useShallow((state) => state));
const userWallStore = useUserWallStore(useShallow((state) => state));
const store = useStore((state) => state);
const hasLogin = !!userWallStore.user;
const navigate = useNavigate();
type MenuItem = {
label: string;
key: string;
icon?: React.ReactNode;
children?: React.ReactNode;
className?: string;
onClick: () => any;
};
const menuList: MenuItem[] = [
{
label: '导出',
key: 'export',
icon: <Download />,
onClick: () => {
wallStore.exportWall(store.nodes);
},
},
{
label: '导入',
key: 'import',
icon: <Upload />,
children: (
<>
<div>
<Upload />
</div>
<div></div>
<input
type='file'
id='import-file'
accept='.json'
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const data = e.target?.result;
const json = JSON.parse(data as string);
const keys = ['id', 'type', 'position', 'data'];
if (Array.isArray(json) && json.every((item) => keys.every((key) => item[key]))) {
const nodes = store.nodes;
const newNodes = json.filter((item) => {
return !nodes.find((node) => node.id === item.id);
});
const _nodes = [...nodes, ...newNodes];
store.setNodes(_nodes);
wallStore.saveNodes(_nodes);
} else {
message.error('文件格式错误');
}
};
reader.readAsText(file);
}
}}
/>
</>
),
onClick: () => {
const input = document.querySelector('#import-file')! as HTMLInputElement;
if (input) {
input.click();
} else {
message.error('请选择文件');
}
},
},
{
label: '清空',
key: 'clear',
icon: <Trash />,
onClick: async () => {
await wallStore.clear();
message.success('清空成功');
store.setNodes([]);
},
},
];
if (!hasLogin) {
menuList.push({
label: '登录',
key: 'login',
icon: <User />,
onClick: () => {
redirectToLogin();
},
});
if (wallStore.id) {
menuList.push({
label: '删除',
key: 'delete',
icon: <Trash />,
onClick: async () => {
const res = await userWallStore.deleteWall(wallStore.id!);
if (res.code === 200) {
navigate('/');
}
},
});
}
} else {
if (!wallStore.id) {
menuList.push({
label: '保存到账号',
key: 'saveToAccount',
icon: <Save />,
onClick: () => {
wallStore.setShowFormDialog(true);
wallStore.setFormDialogData({
title: '',
description: '',
tags: [],
summary: '',
});
},
});
} else {
menuList.push({
label: '编辑信息',
key: 'saveToAccount',
icon: <Save />,
onClick: () => {
wallStore.setShowFormDialog(true);
const data = wallStore.data;
wallStore.setFormDialogData({
title: data?.title,
description: data?.description,
tags: data?.tags,
summary: data?.summary,
});
},
});
menuList.push({
label: '新增',
key: 'add',
icon: <Plus />,
onClick: () => {
navigate(`/`);
wallStore.clearQueryWall();
},
});
menuList.push({
label: '删除',
key: 'delete',
icon: <Trash />,
className: 'text-red-500',
onClick: async () => {
const res = await userWallStore.deleteWall(wallStore.id!);
if (res.code === 200) {
message.success('删除成功,返回首页');
wallStore.clearQueryWall();
navigate('/');
}
},
});
}
menuList.push({
label: '退出 ',
key: 'logout',
icon: <User />,
onClick: () => {
userWallStore.logout();
},
});
}
return (
<ClickAwayListener onClickAway={() => wallStore.setToolbarOpen(false)}>
<div className=' flex flex-col items-center w-[200px] bg-white border border-gray-300 rounded-md absolute top-0 left-8'>
{menuList.map((item) => (
<ToolbarItem
key={item.key}
className={item.className}
onClick={() => {
item.onClick?.();
if (item.key !== 'import') {
wallStore.setToolbarOpen(false);
}
}}>
{item.children ? (
<>{item.children}</>
) : (
<>
<div>{item.icon}</div>
<div>{item.label}</div>
</>
)}
</ToolbarItem>
))}
<div className='text-xs p-1 text-gray-500 italic'>{wallStore.id ? 'id:' + wallStore.id : '临时编辑,资源缓存在本地'}</div>
{hasLogin && <div className='text-xs p-1 -mt-1 text-gray-500 w-full text-right mr-2'>: {userWallStore.user?.username}</div>}
</div>
</ClickAwayListener>
);
};
export const Toolbar = () => {
const wallStore = useWallStore(useShallow((state) => state));
const { toolbarOpen, setToolbarOpen } = wallStore;
return (
<div className='toolbar flex items-center gap-2 relative'>
<div className='p-2 cursor-pointer' onClick={() => setToolbarOpen(!toolbarOpen)}>
<PanelTopClose className={clsx('w-4 h-4', toolbarOpen && 'hidden')} />
<PanelTopOpen className={clsx('w-4 h-4', !toolbarOpen && 'hidden')} />
</div>
<ToolbarContent open={toolbarOpen} />
</div>
);
};

View File

@ -0,0 +1,64 @@
import { useEffect } from 'react';
import { useWallStore } from '../store/wall';
import { useUserWallStore } from '../store/user-wall';
import { useShallow } from 'zustand/react/shallow';
import { formatDate, formatRelativeDate } from '../../../modules/dayjs';
import { useNavigate } from 'react-router-dom';
export const List = () => {
const navigate = useNavigate();
const wallStore = useUserWallStore(
useShallow((state) => {
return {
wallList: state.wallList,
queryWallList: state.queryWallList,
};
}),
);
useEffect(() => {
init();
}, []);
const init = () => {
wallStore.queryWallList();
};
return (
<div className='p-4 bg-white w-full h-full flex flex-col'>
<div className='flex justify-between h-10 items-center'>
<div className='text-2xl font-bold'>Wall Note</div>
</div>
<div className='flex flex-col flex-grow overflow-hidden'>
<div className='flex flex-wrap gap-4 overflow-y-auto'>
{wallStore.wallList.map((wall) => {
return (
<div
key={wall.id}
className='p-4 border border-gray-200 w-80 rounded-md'
onClick={() => {
navigate(`/wall/${wall.id}`);
}}>
<div>
<div>{wall.title}</div>
</div>
<div className='mt-2 flex flex-col gap-2'>
<div className='text-sm text-gray-500 line-clamp-2'>{wall.summary}</div>
<div className='text-sm text-gray-500 flex flex-wrap gap-2 '>
{wall?.tags?.map?.((tag) => {
return (
<div className='text-xs text-gray-500 border border-gray-200 rounded-md px-2 py-1' key={tag}>
{tag}
</div>
);
})}
</div>
</div>
<div className='mt-2 flex justify-between'>
<div className='text-sm text-gray-500'>{formatDate(wall?.createdAt, 'YYYY-MM-DD')}</div>
<div className='text-sm text-gray-500'>{formatRelativeDate(wall?.createdAt)}</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,104 @@
import { message } from '@/modules/message';
import { query } from '@/modules/query';
import { create } from 'zustand';
type User = {
id: string;
username: string;
avatar: string;
};
type Wall = {
id?: string;
title?: string;
description?: string;
type?: 'wall';
data?: {
[key: string]: any;
};
link?: string;
summary?: string;
tags?: string[];
createdAt?: string;
updatedAt?: string;
uid?: string;
[key: string]: any;
};
interface UserWallStore {
user?: User;
setUser: (user: User) => void;
queryMe: (openOnNoLogin?: boolean) => Promise<void>;
wallList: Wall[];
queryWallList: () => Promise<void>;
logout: () => void;
saveWall: (data: Wall, opts?: { refresh?: boolean, showMessage?: boolean }) => Promise<any>;
queryWall: (id: string) => Promise<any>;
deleteWall: (id: string) => Promise<any>;
}
export const useUserWallStore = create<UserWallStore>((set, get) => ({
user: undefined,
setUser: (user: User) => set({ user }),
queryMe: async (openOnNoLogin = true) => {
const res = await query.post(
{
path: 'user',
key: 'me',
},
{
afterResponse: !openOnNoLogin ? async (res) => res : undefined,
},
);
console.log('queryMe', res);
if (res.code === 200) {
set({ user: res.data });
}
},
wallList: [],
queryWallList: async () => {
const res = await query.post({
path: 'mark',
key: 'list',
markType: 'wall',
page: 1,
pageSize: 10,
});
if (res.code === 200) {
set({ wallList: res.data.list });
}
},
saveWall: async (data: Wall, opts?: { refresh?: boolean, showMessage?: boolean }) => {
const { queryWallList } = get();
const res = await query.post({
path: 'mark',
key: 'update',
data,
});
if (res.code === 200) {
// 刷新列表
opts?.refresh && (await queryWallList());
opts?.showMessage && message.success('保存成功');
return res;
}
return res;
},
queryWall: async (id: string) => {
const res = await query.post({
path: 'mark',
key: 'get',
id,
});
return res;
},
deleteWall: async (id: string) => {
const res = await query.post({
path: 'mark',
key: 'delete',
id,
});
return res;
},
logout: () => {
set({ user: undefined });
localStorage.removeItem('token');
},
}));

View File

@ -0,0 +1,162 @@
import { create } from 'zustand';
import { XYPosition } from '@xyflow/react';
import { getWallData, setWallData } from '../utils/db';
import { useUserWallStore } from './user-wall';
import { redirectToLogin } from '@/modules/require-to-login';
import { message } from '@/modules/message';
type NodeData<T = { [key: string]: any }> = {
id: string;
position: XYPosition;
data: T;
type?: string; // wall
};
export const getNodeData = (nodes: NodeData[]) => {
return nodes.map((node) => ({
id: node.id,
position: node.position,
data: node.data,
type: node.type,
}));
};
interface WallState {
// 只做传递
nodes: NodeData[];
setNodes: (nodes: NodeData[]) => void;
saveNodes: (nodes: NodeData[]) => Promise<void>;
open: boolean;
setOpen: (open: boolean) => void;
selectedNode: NodeData | null;
setSelectedNode: (node: NodeData | null) => void;
editValue: string;
setEditValue: (value: string) => void;
data?: any;
setData: (data: any) => void;
init: (id?: string | null) => Promise<void>;
id: string | null;
setId: (id: string | null) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
loaded: boolean | 'error';
toolbarOpen: boolean;
setToolbarOpen: (open: boolean) => void;
showFormDialog: boolean;
setShowFormDialog: (show: boolean) => void;
formDialogData: any;
setFormDialogData: (data: any) => void;
clear: () => Promise<void>;
exportWall: (nodes: NodeData[]) => Promise<void>;
clearQueryWall: () => Promise<void>;
}
const initialNodes = [
// { id: '1', type: 'wall', position: { x: 0, y: 0 }, data: { html: '1' } },
{
id: '1',
type: 'wall',
position: { x: 0, y: 0 },
data: { html: 'sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1', width: 410, height: 212 },
},
// { id: '2', type: 'wall', position: { x: 0, y: 100 }, data: { html: '3332' } },
];
export const useWallStore = create<WallState>((set, get) => ({
nodes: [],
loading: false,
setLoading: (loading) => set({ loading }),
setNodes: (nodes) => {
set({ nodes });
},
saveNodes: async (nodes: NodeData[]) => {
if (!get().id) {
const covertData = getNodeData(nodes);
setWallData({ nodes: covertData });
} else {
const { id } = get();
const userWallStore = useUserWallStore.getState();
console.log('saveNodes id', id);
if (id) {
const covertData = getNodeData(nodes);
const res = await userWallStore.saveWall({
id,
data: {
nodes: covertData,
},
});
if (res.code === 200) {
// console.log('saveNodes res', res);
message.success('保存成功', {
closeOnClick: true,
});
}
}
}
},
open: false,
setOpen: (open) => set({ open }),
selectedNode: null,
setSelectedNode: (node) => set({ selectedNode: node }),
editValue: '',
setEditValue: (value) => set({ editValue: value }),
data: null,
setData: (data) => set({ data }),
id: null,
setId: (id) => set({ id }),
loaded: false,
init: async (id?: string | null) => {
// 如果登陆了且如果有id从服务器获取
// 没有id获取缓存
const hasLogin = localStorage.getItem('token');
if (hasLogin && id) {
const res = await useUserWallStore.getState().queryWall(id);
if (res.code === 200) {
set({ nodes: res.data?.data?.nodes || [], loaded: true, id, data: res.data });
} else {
// message.error('获取失败,请稍后刷新重试');
set({ loaded: 'error' });
}
} else if (!hasLogin && id) {
// 没有登陆但是有id从服务器获取
// 跳转到登陆页面
redirectToLogin();
} else {
const data = await getWallData();
set({ nodes: data?.nodes || [], loaded: true });
}
},
toolbarOpen: false,
setToolbarOpen: (open) => set({ toolbarOpen: open }),
showFormDialog: false,
setShowFormDialog: (show) => set({ showFormDialog: show }),
formDialogData: null,
setFormDialogData: (data) => set({ formDialogData: data }),
clear: async () => {
if (get().id) {
set({ nodes: initialNodes, id: null, selectedNode: null, editValue: '', data: null });
await useUserWallStore.getState().saveWall({
id: get().id!,
data: {
nodes: [],
},
});
} else {
set({ nodes: initialNodes, id: null, selectedNode: null, editValue: '', data: null });
await setWallData({ nodes: [] });
}
},
exportWall: async (nodes: NodeData[]) => {
const covertData = getNodeData(nodes);
setWallData({ nodes: covertData });
// 导出为json
const json = JSON.stringify(covertData);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wall.json';
a.click();
},
clearQueryWall: async () => {
set({ nodes: initialNodes, id: null, selectedNode: null, editValue: '', data: null, toolbarOpen: false, loaded: false });
},
}));

View File

View File

@ -0,0 +1,14 @@
import { get, set, clear } from 'idb-keyval';
export async function getWallData() {
const data = await get('cacheWall');
return data;
}
export async function setWallData(data: any) {
await set('cacheWall', data);
}
export async function clearWallData() {
await clear();
}

View File

@ -0,0 +1,11 @@
export const isMac = async () => {
// Check if the newer API is available
const navigator = window.navigator as Navigator & { userAgentData: { getHighEntropyValues: (keys: string[]) => Promise<{ platform: string }> } };
if (navigator.userAgentData) {
const uaData = await navigator.userAgentData.getHighEntropyValues(['platform']);
return uaData.platform === 'macOS';
} else {
// Fallback to using the older userAgent string
return /Macintosh|Mac OS X/i.test(navigator.userAgent);
}
};

View File

@ -0,0 +1,10 @@
import { customAlphabet } from 'nanoid';
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
const alphabetLetters = 'abcdefghijklmnopqrstuvwxyz';
export const randomString = customAlphabet(alphabet, 10);
export const randomLetters = customAlphabet(alphabetLetters, 10);
export const randomId = () => {
const firstChar = randomLetters(1);
const restChars = randomString(21);
return firstChar + restChars;
};

View File

@ -1,4 +1,4 @@
import { defineConfig } from 'vite';
import { defineConfig, ProxyOptions } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import tailwindcss from '@tailwindcss/vite';
@ -9,12 +9,35 @@ const version = pkgs.version || '0.0.1';
const isDev = process.env.NODE_ENV === 'development';
const basename = isDev ? '/' : '/username/app';
const basename = isDev ? '/' : '/apps/wallnote';
const plugins = []
const isWeb = false;
const isKevisual = true;
if(isWeb) {
plugins.push(basicSsl())
}
let proxy:Record<string, string | ProxyOptions> = {
}
if(isKevisual) {
proxy = {
'/api': {
target: 'https://kevisual.xiongxiao.me',
changeOrigin: true,
},
'/api/router': {
target: 'ws://localhost:3000',
changeOrigin: true,
ws: true,
rewriteWsOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
'/root/center': {
target: 'https://kevisual.xiongxiao.me',
changeOrigin: true,
},
}
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), ...plugins],
@ -48,6 +71,7 @@ export default defineConfig({
rewriteWsOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
...proxy,
},
},
});