generated from template/vite-react-template
	add wallnote
This commit is contained in:
		
							
								
								
									
										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>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
		Reference in New Issue
	
	Block a user