diff --git a/README.md b/README.md index 95e2957..a4daee0 100644 --- a/README.md +++ b/README.md @@ -1 +1,9 @@ -# vite-react-template \ No newline at end of file +# wallnote + +ai快速开发,微应用。 + +![alt text](docs/image.png) + +## 功能介绍 + +网页存储,灵感编辑。 \ No newline at end of file diff --git a/docs/image.png b/docs/image.png new file mode 100644 index 0000000..9fa0ccb Binary files /dev/null and b/docs/image.png differ diff --git a/index.html b/index.html index e4b78ea..a7cd346 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite + React + TS + Wall Note
diff --git a/package.json b/package.json index 848b971..cc4af0d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wallnote", "private": true, - "version": "0.0.2", + "version": "0.0.3", "type": "module", "user": "apps", "scripts": { @@ -12,7 +12,7 @@ "deploy": "rsync -avz --delete dist/ light:~/apps/ai/dist", "preview": "vite preview", "prepub": "envision switchOrg apps", - "pub": "envision deploy ./dist -k wallnote -v 0.0.2 -y y", + "pub": "envision deploy ./dist -k wallnote -v 0.0.3 -y y", "ev": "npm run build && npm run deploy" }, "stackblitz": { @@ -40,6 +40,7 @@ "antd": "^5.24.1", "clsx": "^2.1.1", "dayjs": "^1.11.13", + "github-markdown-css": "^5.8.1", "highlight.js": "^11.11.1", "idb": "^8.0.2", "idb-keyval": "^6.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 982a87f..54e32cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: dayjs: specifier: ^1.11.13 version: 1.11.13 + github-markdown-css: + specifier: ^5.8.1 + version: 5.8.1 highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -1547,6 +1550,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + github-markdown-css@5.8.1: + resolution: {integrity: sha512-8G+PFvqigBQSWLQjyzgpa2ThD9bo7+kDsriUIidGcRhXgmcaAWUIpCZf8DavJgc+xifjbCG+GvMyWr0XMXmc7g==} + engines: {node: '>=10'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -4011,6 +4018,8 @@ snapshots: gensync@1.0.0-beta.2: {} + github-markdown-css@5.8.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 diff --git a/src/App.tsx b/src/App.tsx index 40ed76b..0f97a59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,18 +5,20 @@ import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { List } from './pages/wall/pages/List'; import { Auth } from './modules/layouts/Auth'; +import { basename } from './modules/basename'; +import 'github-markdown-css/github-markdown.css'; export const App = () => { return ( <> - + }> - } /> - } /> + } /> + } /> }> - } /> + } /> } /> diff --git a/src/index.css b/src/index.css index d72c2a8..38696b2 100644 --- a/src/index.css +++ b/src/index.css @@ -32,4 +32,14 @@ body { .tiptap { border: unset; } +} + +.markdown-body,.tiptap { + ul, + li { + list-style: unset; + } + ol { + list-style: decimal; + } } \ No newline at end of file diff --git a/src/modules/message.ts b/src/modules/message.ts index 58fd1a2..6320fe4 100644 --- a/src/modules/message.ts +++ b/src/modules/message.ts @@ -5,19 +5,29 @@ export const message = { toast.error(message, options); }, success: (message: string, options?: ToastOptions) => { - toast.success(message, { + return toast.success(message, { position: 'top-left', autoClose: 1000, ...options, }); }, warning: (message: string, options?: ToastOptions) => { - toast.warning(message, options); + return toast.warning(message, options); }, info: (message: string, options?: ToastOptions) => { - toast.info(message, options); + return toast.info(message, options); }, default: (message: string, options?: ToastOptions) => { - toast(message, options); + return toast(message, options); + }, + loading: (message: string, options?: ToastOptions) => { + return toast(message, { + position: 'top-left', + autoClose: false, + ...options, + }); + }, + close: (id: number | string) => { + toast.dismiss(id); }, }; diff --git a/src/pages/wall/hooks/listen-copy.ts b/src/pages/wall/hooks/listen-copy.ts new file mode 100644 index 0000000..c836598 --- /dev/null +++ b/src/pages/wall/hooks/listen-copy.ts @@ -0,0 +1,81 @@ +import { message } from '@/modules/message'; +import { useEffect } from 'react'; +export const parseIfJson = (str: string) => { + try { + const js = JSON.parse(str); + // 判断js是否是正规的json对象, 初略判断 + if (js && typeof js === 'object') { + return js; + } + return null; + } catch (e) { + return null; + } +}; +export const clipboardRead = async () => { + const read = await navigator.clipboard.read(); + const [clipboardItem] = read; + if (!clipboardItem) { + return []; + } + const types = clipboardItem.types; + const typesDataList: { type: string; data: string; blob?: any; base64?: string }[] = []; + for (let i = 0; i < types.length; i++) { + const type = types[i]; + const data = await clipboardItem.getType(type); + switch (type) { + case 'text/plain': + const textPlain = await data.text(); + // const jsonContent = parseIfJson(textPlain); + // if (jsonContent) { + // typesDataList.push({ type: 'text/json', data: jsonContent, blob: data }); + // } else { + // typesDataList.push({ type: 'text/plain', data: textPlain, blob: data }); + // } + typesDataList.push({ type: 'text/plain', data: textPlain, blob: data }); + break; + case 'text/html': + const textHtml = await data.text(); + typesDataList.push({ type: 'text/html', data: textHtml, blob: data }); + break; + case 'image/png': + const imagePng = await data.arrayBuffer(); + const arrayBufferToBase64 = (buffer) => { + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + }; + const imagePngBase64 = arrayBufferToBase64(imagePng); + const imageData = `data:image/png;base64,${imagePngBase64}`; + const imageHtml = ``; + typesDataList.push({ type, data: imageHtml, blob: data, base64: imageData }); + break; + default: + message.error('暂不支持该类型粘贴'); + break; + } + } + + return typesDataList; +}; +/** + * 监听 wind: ctrl+v mac: command+v的粘贴事件 + */ +export const useListenPaster = () => { + useEffect(() => { + const listener = async (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'v') { + const r = await clipboardRead(); + return; + } + }; + document.addEventListener('keydown', listener); + return () => { + document.removeEventListener('keydown', listener); + }; + }, []); +}; diff --git a/src/pages/wall/index.tsx b/src/pages/wall/index.tsx index cdaa1af..9dfd916 100644 --- a/src/pages/wall/index.tsx +++ b/src/pages/wall/index.tsx @@ -30,6 +30,8 @@ import { useNavigate, useParams } from 'react-router-dom'; import { SaveModal } from './modules/FormDialog'; import { useTabNode } from './hooks/tab-node'; import { Button } from '@mui/material'; +import { useListenPaster } from './hooks/listen-copy'; +import { ContextMenu } from './modules/ContextMenu'; type NodeData = { id: string; position: XYPosition; @@ -41,11 +43,17 @@ export function FlowContent() { const wallStore = useWallStore((state) => state); const store = useStore((state) => state); const [mount, setMount] = useState(false); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const _onNodesChange = useCallback((changes: NodeChange[]) => { const [change] = changes; + + if (change.type === 'remove') { + wallStore.saveNodes(reactFlowInstance.getNodes().filter((item) => item.id !== change.id)); + } if (change.type === 'position' && change.dragging === false) { // console.log('position changes', change); - getNewNodes(); + getNewNodes(false); } onNodesChange(changes); }, []); @@ -60,9 +68,10 @@ export function FlowContent() { wallStore.setOpen(true); wallStore.setSelectedNode(node); }; - const getNewNodes = () => { + const getNewNodes = (showMessage = true) => { const nodes = reactFlowInstance.getNodes(); - wallStore.saveNodes(nodes); + console.log('showMessage', showMessage); + wallStore.saveNodes(nodes, { showMessage: showMessage }); }; useEffect(() => { if (mount) { @@ -70,6 +79,7 @@ export function FlowContent() { } }, [nodes, mount]); useTabNode(); + useListenPaster(); // 添加新节点的函数 const onPaneDoubleClick = (event) => { // 计算节点位置 @@ -78,19 +88,18 @@ export function FlowContent() { const postion = reactFlowInstance.screenToFlowPosition({ x, y }); const newNode = { id: randomId(), // 确保每个节点有唯一的ID - type: 'wall', // 节点类型 + type: 'wallnote', // 节点类型 position: postion, // 使用事件的客户端坐标 data: { html: BlankNoteText }, }; setNodes((nds) => { const newNodes = nds.concat(newNode); - getNewNodes(); return newNodes; }); - message.success('添加节点成功'); setTimeout(() => { wallStore.setSelectedNode(newNode); wallStore.setOpen(true); + getNewNodes(); }, 200); }; const hasFoucedNode = useMemo(() => { @@ -99,6 +108,13 @@ export function FlowContent() { const { onCheckPanelDoubleClick } = useCheckDoubleClick({ onPaneDoubleClick, }); + const handleContextMenu = (event) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY }); + }; + const handleCloseContextMenu = () => { + setContextMenu(null); + }; return ( @@ -119,6 +136,7 @@ export function FlowContent() { + {contextMenu && } ); diff --git a/src/pages/wall/modules/ContextMenu.tsx b/src/pages/wall/modules/ContextMenu.tsx new file mode 100644 index 0000000..309d979 --- /dev/null +++ b/src/pages/wall/modules/ContextMenu.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { ToolbarItem, MenuItem } from './toolbar/Toolbar'; +import { ClipboardPaste } from 'lucide-react'; +import { clipboardRead } from '../hooks/listen-copy'; +import { useReactFlow, useStore } from '@xyflow/react'; +import { randomId } from '../utils/random'; +import { message } from '@/modules/message'; +import { useWallStore } from '../store/wall'; +import { useShallow } from 'zustand/react/shallow'; +import { min, max } from 'lodash-es'; +import { getImageWidthHeightByBase64 } from '../utils/get-image-rect'; +interface ContextMenuProps { + x: number; + y: number; + onClose: () => void; +} +class HasTypeCheck { + constructor(list: any[]) { + this.list = list; + } + list: { type?: string; data: any }[]; + hasType = (type = 'type/html') => { + return this.list.some((item) => item.type === type); + }; + getType = (type = 'type/html') => { + return this.list.find((item) => item.type === type); + }; + getText = () => { + const hasHtml = this.hasType('text/html'); + if (hasHtml) { + return { + code: 200, + data: this.getType('text/html')?.data || '', + }; + } + const hasText = this.hasType('text/plain'); + if (hasText) { + return { + code: 200, + data: this.getType('text/plain')?.data || '', + }; + } + return { + code: 404, + }; + }; + getJson() { + const hasJson = this.hasType('text/json'); + if (hasJson) { + const data = this.getType('text/json')?.data || ''; + return { + code: 200, + data: data, + }; + } + return { + code: 404, + }; + } +} +export const ContextMenu: React.FC = ({ x, y, onClose }) => { + const reactFlowInstance = useReactFlow(); + const store = useStore((state) => state); + const wallStore = useWallStore( + useShallow((state) => { + return { + setNodes: state.setNodes, + saveNodes: state.saveNodes, + }; + }), + ); + // const + const menuList: MenuItem[] = [ + { + label: '粘贴', + icon: , + key: 'paste', + onClick: async () => { + const readList = await clipboardRead(); + const check = new HasTypeCheck(readList); + if (readList.length <= 0) { + message.error('粘贴为空'); + return; + } + let content: string = ''; + let hasContent = false; + const text = check.getText(); + let width = 100; + let height = 100; + if (text.code === 200) { + content = text.data; + hasContent = true; + width = min([content.length * 16, 600])!; + height = max([200, (content.length * 16) / 400])!; + } + console.log('result', readList); + if (!hasContent) { + const json = check.getJson(); + if (json.code === 200) { + content = JSON.stringify(json.data, null, 2); + hasContent = true; + } + } + let noEdit = false; + if (!hasContent) { + content = readList[0].data || ''; + const base64 = readList[0].base64; + const rect = await getImageWidthHeightByBase64(base64); + width = rect.width; + height = rect.height; + noEdit = true; + } + + const flowPosition = reactFlowInstance.screenToFlowPosition({ x, y }); + const nodes = store.nodes; + const newNodeData: any = { + id: randomId(), + type: 'wallnote', + position: flowPosition, + data: { + width, + height, + html: content, + }, + }; + if (noEdit) { + newNodeData.data.noEdit = true; + } + const newNodes = [newNodeData]; + const _nodes = [...nodes, ...newNodes]; + wallStore.setNodes(_nodes); + wallStore.saveNodes(_nodes); + // reactFlowInstance.setNodes(_nodes); + }, + }, // + ]; + return ( +
+ {menuList.map((item) => ( + { + item.onClick?.(); + }}> + {item.children ? ( + <>{item.children} + ) : ( + <> +
{item.icon}
+
{item.label}
+ + )} +
+ ))} +
+ ); +}; + +export default ContextMenu; diff --git a/src/pages/wall/modules/CustomNode.tsx b/src/pages/wall/modules/CustomNode.tsx index c716046..12f95a0 100644 --- a/src/pages/wall/modules/CustomNode.tsx +++ b/src/pages/wall/modules/CustomNode.tsx @@ -34,7 +34,7 @@ const ShowContent = (props: { data: WallData; selected: boolean }) => { return (
{ + wallStore.saveNodes(nodes); + }; const store = useStore((state) => { return { updateWallRect: (id: string, rect: { width: number; height: number }) => { @@ -66,7 +69,7 @@ export const CustomNode = (props: { id: string; data: WallData; selected: boolea return node; }); state.setNodes(nodes); - wallStore.saveNodes(nodes); + save(nodes); }, getNode: (id: string) => { return state.nodes.find((node) => node.id === id); @@ -74,21 +77,22 @@ export const CustomNode = (props: { id: string; data: WallData; selected: boolea deleteNode: (id: string) => { const nodes = state.nodes.filter((node) => node.id !== id); state.setNodes(nodes); - wallStore.saveNodes(nodes); + console.log('save', nodes, id); + save(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]); + // 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 = {}; @@ -96,7 +100,12 @@ export const CustomNode = (props: { id: string; data: WallData; selected: boolea style.height = height; const showOpen = () => { const node = store.getNode(props.id); + console.log('node eidt', node); if (node) { + if (node.data?.noEdit) { + message.error('不支持编辑'); + return; + } wallStore.setOpen(true); wallStore.setSelectedNode(node); } else { @@ -146,5 +155,5 @@ export const CustomNode = (props: { id: string; data: WallData; selected: boolea }; export const WallNoteNode = memo(CustomNode); export const CustomNodeType = { - wall: WallNoteNode, + wallnote: WallNoteNode, }; diff --git a/src/pages/wall/modules/Drawer.tsx b/src/pages/wall/modules/Drawer.tsx index e7eefab..eb929f4 100644 --- a/src/pages/wall/modules/Drawer.tsx +++ b/src/pages/wall/modules/Drawer.tsx @@ -61,11 +61,15 @@ const Drawer = () => { const newNodes = storeApi.getState().nodes.map((node) => (node.id === selectedNode.id ? selectedNode : node)); storeApi.setState({ nodes: newNodes }); if (wallStore.id) { - message.success('保存成功', { + message.success('保存到服务器成功', { + closeOnClick: true, + }); + } else { + message.success('保存到本地成功', { closeOnClick: true, }); } - wallStore.saveNodes(newNodes); + wallStore.saveNodes(newNodes, { showMessage: false }); } }; let html = selectedNode?.data?.html || ''; diff --git a/src/pages/wall/modules/FormDialog.tsx b/src/pages/wall/modules/FormDialog.tsx index 6a5c6e9..7cb30b3 100644 --- a/src/pages/wall/modules/FormDialog.tsx +++ b/src/pages/wall/modules/FormDialog.tsx @@ -104,18 +104,21 @@ export const SaveModal = () => { description: values.description, summary: values.summary, tags: values.tags, - markType: 'wall' as 'wall', + markType: 'wallnote' as 'wallnote', data, }; - const res = await userWallStore.saveWall(fromData, { refresh: true }); + const loading = message.loading('保存中...'); + const res = await userWallStore.saveWall(fromData, { refresh: false }); + message.close(loading); if (res.code === 200) { setShowFormDialog(false); if (!id) { // 新创建 const data = res.data; + message.info('redirect to edit page'); wallStore.clear(); setTimeout(() => { - navigate(`/wall/${data.id}`); + navigate(`/edit/${data.id}`); }, 2000); } else { // 编辑 diff --git a/src/pages/wall/modules/toolbar/Toolbar.tsx b/src/pages/wall/modules/toolbar/Toolbar.tsx index c2939b7..cea820e 100644 --- a/src/pages/wall/modules/toolbar/Toolbar.tsx +++ b/src/pages/wall/modules/toolbar/Toolbar.tsx @@ -1,4 +1,4 @@ -import { PanelTopOpen, PanelTopClose, Save, Download, Upload, User, Trash, Plus } from 'lucide-react'; +import { PanelTopOpen, PanelTopClose, Save, Download, Upload, User, Trash, Plus, BrickWall } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { useWallStore } from '../../store/wall'; @@ -26,6 +26,14 @@ export const ToolbarItem = ({
); }; +export type MenuItem = { + label: string; + key: string; + icon?: React.ReactNode; + children?: React.ReactNode; + className?: string; + onClick: () => any; +}; // 空白处点击,当不包函toolbar时候,关闭toolbar export const useBlankClick = () => { const { setToolbarOpen } = useWallStore( @@ -61,14 +69,7 @@ export const ToolbarContent = ({ open }) => { 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: '导出', @@ -97,7 +98,7 @@ export const ToolbarContent = ({ open }) => { const file = e.target.files?.[0]; if (file) { const reader = new FileReader(); - reader.onload = (e) => { + reader.onload = async (e) => { const data = e.target?.result; const json = JSON.parse(data as string); const keys = ['id', 'type', 'position', 'data']; @@ -108,7 +109,10 @@ export const ToolbarContent = ({ open }) => { }); const _nodes = [...nodes, ...newNodes]; store.setNodes(_nodes); - wallStore.saveNodes(_nodes); + // window.location.reload(); + wallStore.setNodes(_nodes); + await wallStore.saveNodes(_nodes); + message.success('导入成功'); } else { message.error('文件格式错误'); } @@ -139,7 +143,16 @@ export const ToolbarContent = ({ open }) => { }, }, ]; - + if (hasLogin) { + menuList.unshift({ + label: '我的笔记', + key: 'myWall', + icon: , + onClick: () => { + navigate('/list'); + }, + }); + } if (!hasLogin) { menuList.push({ label: '登录', @@ -218,6 +231,7 @@ export const ToolbarContent = ({ open }) => { }, }); } + menuList.push({ label: '退出 ', key: 'logout', diff --git a/src/pages/wall/pages/List.tsx b/src/pages/wall/pages/List.tsx index b4b921f..f49d089 100644 --- a/src/pages/wall/pages/List.tsx +++ b/src/pages/wall/pages/List.tsx @@ -22,7 +22,11 @@ export const List = () => { }; return (
-
+
{ + navigate('/'); + }}>
Wall Note
@@ -33,7 +37,7 @@ export const List = () => { key={wall.id} className='p-4 border border-gray-200 w-80 rounded-md' onClick={() => { - navigate(`/wall/${wall.id}`); + navigate(`/edit/${wall.id}`); }}>
{wall.title}
diff --git a/src/pages/wall/store/user-wall.ts b/src/pages/wall/store/user-wall.ts index fe6fc0f..20d6057 100644 --- a/src/pages/wall/store/user-wall.ts +++ b/src/pages/wall/store/user-wall.ts @@ -58,7 +58,7 @@ export const useUserWallStore = create((set, get) => ({ const res = await query.post({ path: 'mark', key: 'list', - markType: 'wall', + markType: 'wallnote', page: 1, pageSize: 10, }); diff --git a/src/pages/wall/store/wall.ts b/src/pages/wall/store/wall.ts index f5e7623..9c9255b 100644 --- a/src/pages/wall/store/wall.ts +++ b/src/pages/wall/store/wall.ts @@ -24,7 +24,7 @@ interface WallState { // 只做传递 nodes: NodeData[]; setNodes: (nodes: NodeData[]) => void; - saveNodes: (nodes: NodeData[]) => Promise; + saveNodes: (nodes: NodeData[], opts?: { showMessage?: boolean }) => Promise; open: boolean; setOpen: (open: boolean) => void; selectedNode: NodeData | null; @@ -67,10 +67,13 @@ export const useWallStore = create((set, get) => ({ setNodes: (nodes) => { set({ nodes }); }, - saveNodes: async (nodes: NodeData[]) => { + saveNodes: async (nodes: NodeData[], opts) => { + console.log('nodes', nodes, opts, opts?.showMessage ?? true); if (!get().id) { const covertData = getNodeData(nodes); setWallData({ nodes: covertData }); + const showMessage = opts?.showMessage ?? true; + showMessage && message.success('保存到本地'); } else { const { id } = get(); const userWallStore = useUserWallStore.getState(); diff --git a/src/pages/wall/utils/get-image-rect.ts b/src/pages/wall/utils/get-image-rect.ts new file mode 100644 index 0000000..4737d02 --- /dev/null +++ b/src/pages/wall/utils/get-image-rect.ts @@ -0,0 +1,31 @@ +export const getImageWidthHeightByBase64 = async ( + b64str: any, +): Promise<{ + width: number; + height: number; +}> => { + return new Promise((resolve, reject) => { + // 创建 Canvas 对象 + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + + // 创建 Image 对象 + const img = new Image(); + img.onload = () => { + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + const width = img.width; + const height = img.height; + console.log(`宽度: ${width}, 高度: ${height}`); + resolve({ width, height }); + canvas.remove(); + }; + img.onerror = () => { + console.error('无法加载图片'); + reject(new Error('无法加载图片')); + canvas.remove(); + }; + img.src = b64str; + }); +};