add wallnote

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

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>
);
};