generated from template/vite-react-template
add wallnote
This commit is contained in:
parent
07d053abe7
commit
a91f80c1ba
27
package.json
27
package.json
@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "vite-react",
|
"name": "wallnote",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"user": "apps",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:web": "cross-env WEB_DEV=true vite --mode web",
|
"dev:web": "cross-env WEB_DEV=true vite --mode web",
|
||||||
@ -10,7 +11,8 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"deploy": "rsync -avz --delete dist/ light:~/apps/ai/dist",
|
"deploy": "rsync -avz --delete dist/ light:~/apps/ai/dist",
|
||||||
"preview": "vite preview",
|
"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"
|
"ev": "npm run build && npm run deploy"
|
||||||
},
|
},
|
||||||
"stackblitz": {
|
"stackblitz": {
|
||||||
@ -20,21 +22,40 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.6.1",
|
"@ant-design/icons": "^5.6.1",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.0",
|
||||||
"@kevisual/query": "0.0.7-alpha.3",
|
"@kevisual/query": "0.0.7-alpha.3",
|
||||||
"@kevisual/router": "0.0.6-alpha-5",
|
"@kevisual/router": "0.0.6-alpha-5",
|
||||||
"@kevisual/system-ui": "^0.0.3",
|
"@kevisual/system-ui": "^0.0.3",
|
||||||
"@kevisual/ui": "^0.0.4-alpha-1",
|
"@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",
|
"antd": "^5.24.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"idb": "^8.0.2",
|
||||||
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
|
"lucide-react": "^0.475.0",
|
||||||
|
"marked": "^15.0.7",
|
||||||
"nanoid": "^5.1.0",
|
"nanoid": "^5.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router": "^7.2.0",
|
"react-router": "^7.2.0",
|
||||||
"react-router-dom": "^7.2.0",
|
"react-router-dom": "^7.2.0",
|
||||||
"react-toastify": "^11.0.3",
|
"react-toastify": "^11.0.3",
|
||||||
|
"tiptap-markdown": "^0.8.10",
|
||||||
|
"turndown": "^7.2.0",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
1515
pnpm-lock.yaml
generated
1515
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
28
src/App.tsx
28
src/App.tsx
@ -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 = () => {
|
export const App = () => {
|
||||||
return (
|
return (
|
||||||
<div className='bg-slate-200 w-full h-full border'>
|
<>
|
||||||
<div className='test-loading bg-black'>
|
<BrowserRouter basename='/apps/wallnote'>
|
||||||
<div></div>
|
<Routes>
|
||||||
</div>
|
<Route element={<Auth auth={false} />}>
|
||||||
</div>
|
<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 />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,35 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@import './pages/wall/modules/CustomNode.css';
|
||||||
@layer components {
|
@layer components {
|
||||||
.test-loading {
|
.node-editor {
|
||||||
@apply w-20 h-20 bg-gray-300 rounded-full animate-spin;
|
@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
11
src/modules/dayjs.ts
Normal 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();
|
||||||
|
};
|
24
src/modules/layouts/Auth.tsx
Normal file
24
src/modules/layouts/Auth.tsx
Normal 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
10
src/modules/md2html.ts
Normal 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
23
src/modules/message.ts
Normal 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
17
src/modules/query.ts
Normal 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;
|
||||||
|
};
|
36
src/modules/require-to-login.ts
Normal file
36
src/modules/require-to-login.ts
Normal 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,
|
||||||
|
});
|
98
src/modules/tiptap/editor.ts
Normal file
98
src/modules/tiptap/editor.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
32
src/pages/editor/index.tsx
Normal file
32
src/pages/editor/index.tsx
Normal 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>;
|
||||||
|
};
|
21
src/pages/wall/components/Icon.tsx
Normal file
21
src/pages/wall/components/Icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
1
src/pages/wall/constants.ts
Normal file
1
src/pages/wall/constants.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const BlankNoteText = '<i>double click to edit</i>';
|
35
src/pages/wall/hooks/check-double-click.ts
Normal file
35
src/pages/wall/hooks/check-double-click.ts
Normal 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 };
|
||||||
|
};
|
62
src/pages/wall/hooks/tab-node.ts
Normal file
62
src/pages/wall/hooks/tab-node.ts
Normal 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
174
src/pages/wall/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
149
src/pages/wall/modules/CustomNode.css
Normal file
149
src/pages/wall/modules/CustomNode.css
Normal 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;
|
||||||
|
}
|
150
src/pages/wall/modules/CustomNode.tsx
Normal file
150
src/pages/wall/modules/CustomNode.tsx
Normal 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,
|
||||||
|
};
|
106
src/pages/wall/modules/Drawer.tsx
Normal file
106
src/pages/wall/modules/Drawer.tsx
Normal 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;
|
132
src/pages/wall/modules/FormDialog.tsx
Normal file
132
src/pages/wall/modules/FormDialog.tsx
Normal 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} />;
|
||||||
|
};
|
272
src/pages/wall/modules/toolbar/Toolbar.tsx
Normal file
272
src/pages/wall/modules/toolbar/Toolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
64
src/pages/wall/pages/List.tsx
Normal file
64
src/pages/wall/pages/List.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
104
src/pages/wall/store/user-wall.ts
Normal file
104
src/pages/wall/store/user-wall.ts
Normal 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');
|
||||||
|
},
|
||||||
|
}));
|
162
src/pages/wall/store/wall.ts
Normal file
162
src/pages/wall/store/wall.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
}));
|
0
src/pages/wall/utils/convet.ts
Normal file
0
src/pages/wall/utils/convet.ts
Normal file
14
src/pages/wall/utils/db.ts
Normal file
14
src/pages/wall/utils/db.ts
Normal 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();
|
||||||
|
}
|
11
src/pages/wall/utils/is-mac.ts
Normal file
11
src/pages/wall/utils/is-mac.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
10
src/pages/wall/utils/random.ts
Normal file
10
src/pages/wall/utils/random.ts
Normal 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;
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, ProxyOptions } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
@ -9,12 +9,35 @@ const version = pkgs.version || '0.0.1';
|
|||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
const basename = isDev ? '/' : '/username/app';
|
const basename = isDev ? '/' : '/apps/wallnote';
|
||||||
const plugins = []
|
const plugins = []
|
||||||
const isWeb = false;
|
const isWeb = false;
|
||||||
|
const isKevisual = true;
|
||||||
|
|
||||||
if(isWeb) {
|
if(isWeb) {
|
||||||
plugins.push(basicSsl())
|
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/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss(), ...plugins],
|
plugins: [react(), tailwindcss(), ...plugins],
|
||||||
@ -48,6 +71,7 @@ export default defineConfig({
|
|||||||
rewriteWsOrigin: true,
|
rewriteWsOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, '/api'),
|
rewrite: (path) => path.replace(/^\/api/, '/api'),
|
||||||
},
|
},
|
||||||
|
...proxy,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user