diff --git a/app/components/ContextMenuViewer/contextMenuViewer.module.css b/app/components/ContextMenuViewer/contextMenuViewer.module.css new file mode 100644 index 0000000..059e5f3 --- /dev/null +++ b/app/components/ContextMenuViewer/contextMenuViewer.module.css @@ -0,0 +1,23 @@ +.container { + position: absolute; + background-color: var(--background); + border-radius: 6px; + border: 1px solid var(--grey-normal); + margin: 0; + padding: 0; + animation: var(--intro-animation); + box-sizing: border-box; + overflow: hidden; +} + +.item { + min-width: 100px; + cursor: default; + list-style: none; + margin: 0; + padding: 10px; +} + +.item:hover { + background-color: var(--grey-normal); +} diff --git a/app/components/ContextMenuViewer/index.tsx b/app/components/ContextMenuViewer/index.tsx new file mode 100644 index 0000000..134d86a --- /dev/null +++ b/app/components/ContextMenuViewer/index.tsx @@ -0,0 +1,39 @@ +import { useContextMenu } from '@/states/contextMenu'; +import styles from './contextMenuViewer.module.css'; +import { CSSProperties, useEffect } from 'react'; + +export default function ContextMenuViewer() { + const { menu, setMenu } = useContextMenu((state) => ({ + menu: state.menu, + setMenu: state.setMenu, + })); + + useEffect(() => { + if (menu === null) return; + const pointerDown = () => setMenu(null); + document.addEventListener('pointerdown', pointerDown); + return () => document.removeEventListener('pointerdown', pointerDown); + }, [menu, setMenu]); + + if (menu === null) return <>; + + const style: CSSProperties = {}; + // check x overflow + if (menu.position.x < window.innerWidth / 2) style.left = menu.position.x; + else style.right = window.innerWidth - menu.position.x; + // check y overflow + if (menu.position.y < window.innerHeight / 2) style.top = menu.position.y; + else style.bottom = window.innerHeight - menu.position.y; + + return ( + + ); +} diff --git a/app/components/DocumentCreateDialogue/index.tsx b/app/components/DocumentCreateDialogue/index.tsx index 829a10f..b76daa8 100644 --- a/app/components/DocumentCreateDialogue/index.tsx +++ b/app/components/DocumentCreateDialogue/index.tsx @@ -4,7 +4,6 @@ import Dialogue from '@/components/Dialogue'; import FileInput from '@/components/Dialogue/Input/FileInput'; import TextInput from '@/components/Dialogue/Input/TextInput'; import { useToast } from '@/states/toast'; -import { useViewer } from '@/states/viewer'; import { createDocument, uploadFile } from '@/utils/api'; import { throwError } from '@/utils/ui'; import { useRouter } from 'next/navigation'; @@ -21,14 +20,13 @@ export default function DocumentCreateDialogue({ }: DocumentCreateDialogueProps) { const router = useRouter(); const [load, setLoad] = useState(false); - const documentId = useViewer((state) => state.document?.documentId ?? 0); const pushToast = useToast((state) => state.pushToast); return (
{ e.preventDefault(); @@ -37,19 +35,27 @@ export default function DocumentCreateDialogue({ const titleInput = (e.target as any).title as HTMLInputElement; const fileInput = (e.target as any).file as HTMLInputElement; + // check file + if (fileInput.files === null || fileInput.files.length === 0) { + throwError('요약할 파일을 입력해 주세요'); + return setLoad(false); + } + // check title if (titleInput.value.length === 0) { - throwError('문서의 제목을 입력해 주세요'); - return setLoad(false); + // throwError('문서의 제목을 입력해 주세요'); + // return setLoad(false); + + // set to filename if left blank + titleInput.value = fileInput.files[0].name; } (async () => { // create new Document const newDocument = await createDocument(titleInput.value); + // upload file (if exists) - if (fileInput.files !== null && fileInput.files.length > 0) { - await uploadFile(documentId, fileInput.files[0]); - } + await uploadFile(newDocument.documentId, fileInput.files![0]); pushToast({ id: new Date().getTime(), diff --git a/app/components/Header/index.tsx b/app/components/Header/index.tsx index d783e08..dbf2c46 100644 --- a/app/components/Header/index.tsx +++ b/app/components/Header/index.tsx @@ -6,6 +6,7 @@ import { useOverlay } from '@/states/overlay'; import DocumentCreateDialogue from '../DocumentCreateDialogue'; import Logo from '@/components/Logo'; import { useUser } from '@/states/user'; +import { useViewer } from '@/states/viewer'; export default function Header() { const { setOverlay } = useOverlay((state) => ({ setOverlay: state.setOverlay, @@ -21,7 +22,7 @@ export default function Header() { {userDataExists ? ( setOverlay( @@ -33,7 +34,6 @@ export default function Header() { } /> ) : undefined} -
diff --git a/app/document/[documentId]/components/BottomToolBar/index.tsx b/app/document/[documentId]/components/BottomToolBar/index.tsx index a1bd5ef..cd2f8e3 100644 --- a/app/document/[documentId]/components/BottomToolBar/index.tsx +++ b/app/document/[documentId]/components/BottomToolBar/index.tsx @@ -4,11 +4,14 @@ import ClickableBackgroundButton from '@/components/BackgroundButton/ClickableBa import { useViewer } from '@/states/viewer'; import 'material-symbols'; import SmallIconButton from '@/components/Button/SmallIconButton'; +import { useOverlay } from '@/states/overlay'; +import MixModePanel from '@/components/GraphCanvas/MixModePanel'; export default function BottomToolBar() { const elementRef = useRef(null); const [pos, setPos] = useState({ x: 0, y: 0 }); const [drag, setDrag] = useState(undefined); + const setOverlay = useOverlay((state) => state.setOverlay); useEffect(() => { if (!drag) return; @@ -67,6 +70,13 @@ export default function BottomToolBar() { invert={true} onClick={() => setView('DATA')} /> + setOverlay()} + /> ); diff --git a/app/document/[documentId]/page.tsx b/app/document/[documentId]/page.tsx index 405b1b2..01dbbd1 100644 --- a/app/document/[documentId]/page.tsx +++ b/app/document/[documentId]/page.tsx @@ -11,10 +11,6 @@ const GraphCanvas = dynamic(() => import('@/components/GraphCanvas'), { ssr: fal import 'material-symbols'; import AudioViewer from '@/components/DataViewer/AudioViewer'; import ClickableBackgroundButton from '@/components/BackgroundButton/ClickableBackgroundButton'; -import Dialogue from '@/components/Dialogue'; -import FileInput from '@/components/Dialogue/Input/FileInput'; -import { useToast } from '@/states/toast'; -import { uploadFile } from '@/utils/api'; import { useViewerEvents } from '@/utils/ui'; import Divider, { useDivider } from '@/components/Divider'; @@ -46,16 +42,17 @@ export default function DoucumentsPage({ params }: DocumentPageProps) { const eventCallback = useCallback( (type: ViewerEventType) => { // reload on event + if (documentData === null) return loadDocument(documentId); // TODO: implement other types of events syncDocument(); }, - [syncDocument], + [documentData, documentId, loadDocument, syncDocument], ); // subscribe to events useViewerEvents(documentId, eventCallback); - if (documentData === null) return ; + if (documentData === null) return ; return (
@@ -122,49 +119,17 @@ function DataViewer({ type }: DataViewerProps) { return ; case 'audio': return ; - case 'none': - return ; } } -function DataUploadDialogue() { - const pushToast = useToast((state) => state.pushToast); - const documentId = useViewer((state) => state.document?.documentId); - return ( -
- { - if (documentId === undefined) return; - const fileInput = (e.target as any).file as HTMLInputElement; - // upload file (if exists) - (async () => { - if (fileInput.files !== null && fileInput.files.length > 0) { - await uploadFile(documentId, fileInput.files[0]); - } - })(); - }} - > - pushToast({ id: new Date().getTime(), duraton: 3000, msg: msg })} - /> - -
- ); -} - function GraphViewer() { - const hasGraph = useViewer((state) => state.mindMapData?.keywords.length !== 0); - if (!hasGraph) return

no graph available

; + const mindMapData = useViewer((state) => state.mindMapData); + if (mindMapData === null) return

no graph available

; return ( <>
- }> - + }> +
diff --git a/app/globals.css b/app/globals.css index ca9e89e..dec1606 100644 --- a/app/globals.css +++ b/app/globals.css @@ -14,6 +14,7 @@ --grey-darker: #383b40; --foreground-disabled: #c7cfd0; --foreground: #ffffff; + --warning-color: #c2599a; --background: #f4f6f8; --background-accent: #e6f5f5; --intro-animation: show 0.5s; @@ -76,10 +77,10 @@ div.loading-container { @keyframes barAnimation { 0% { - background-color: var(--primary-light); + background-color: var(--primary-dark); } 50% { - background-color: var(--primary-dark); + background-color: var(--highlight); } 100% { background-color: var(--primary-light); @@ -116,9 +117,9 @@ div.loading-bar { } mark { - text-decoration: dotted; + text-decoration: underline dotted; text-decoration-color: red; - text-decoration-thickness: 2px; + text-decoration-thickness: 3px; color: transparent; background: transparent; } diff --git a/app/layout.tsx b/app/layout.tsx index f607247..0ddd847 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,7 @@ import { Inter } from 'next/font/google'; import ToastViewer from './components/ToastViewer'; import OverlayViewer from './components/OverlayViewer'; import Header from './components/Header'; +import ContextMenuViewer from './components/ContextMenuViewer'; const inter = Inter({ subsets: ['latin'] }); @@ -20,6 +21,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children}
+ ); diff --git a/components/DataViewer/AudioViewer/index.tsx b/components/DataViewer/AudioViewer/index.tsx index ebea449..1e3a349 100644 --- a/components/DataViewer/AudioViewer/index.tsx +++ b/components/DataViewer/AudioViewer/index.tsx @@ -67,7 +67,9 @@ function useFocusKeyword( useEffect(() => { setCallback((keyword, location) => { setAllKeyword(false); - setKeyWord(keyword, true); + setKeyWord(keyword, { + enabled: true, + }); setFocusIndex(location[0]); setSelectIndex(location[0]); if (audioRef.current === null) return; diff --git a/components/DataViewer/OptionPanel/index.tsx b/components/DataViewer/OptionPanel/index.tsx index caef3d8..1f9bc3c 100644 --- a/components/DataViewer/OptionPanel/index.tsx +++ b/components/DataViewer/OptionPanel/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode, SetStateAction, useEffect, useMemo, useState } from 'react'; +import { ReactNode, useEffect, useMemo, useState } from 'react'; import styles from './optionPanel.module.css'; import 'material-symbols'; import { useViewer } from '@/states/viewer'; @@ -6,6 +6,7 @@ import SmallIconButton from '@/components/Button/SmallIconButton'; import ClickableBackgroundButton from '@/components/BackgroundButton/ClickableBackgroundButton'; import { updateKeywords } from '@/utils/api'; import Divider, { useDivider } from '@/components/Divider'; +import { ContextMenuItem, useContextMenu } from '@/states/contextMenu'; const DEFAULT_WIDTH = 350; @@ -52,8 +53,6 @@ export default function OptionPanel({ children }: OptionPanelProps) { clearSyncDocument(); // sync local changes await updateKeywords(documentId, addedKeys, removedKeys); - // wait for sync to finish - // TODO: implement this })(); }; @@ -88,9 +87,7 @@ export default function OptionPanel({ children }: OptionPanelProps) { icon={'sync'} onClick={syncKeywords} /> - ) : ( - <> - )} + ) : null} {addedKeys.map((v) => ( @@ -130,19 +127,72 @@ interface KeywordViewProps { } function KeywordView({ label }: KeywordViewProps) { - const { state, setKeyword } = useViewer((state) => ({ + const setMenu = useContextMenu((state) => state.setMenu); + const { state, setKeyword, deleteKeyword } = useViewer((state) => ({ state: state.keywords.get(label), setKeyword: state.setKeyword, + deleteKeyword: state.deleteKeyword, })); + const items = useMemo(() => { + const trimmedLabel = label.substring(0, 10); + const items: ContextMenuItem[] = []; + if (state === undefined) return items; + switch (state.type) { + case 'ADD': + items.push({ + label: `'${trimmedLabel}' 추가 취소`, + onClick: () => deleteKeyword(label), + }); + break; + case 'STABLE': + items.push({ + label: `'${trimmedLabel}' 삭제`, + onClick: () => setKeyword(label, { enabled: false, type: 'DELETE' }), + }); + break; + case 'DELETE': + items.push({ + label: `'${trimmedLabel}' 삭제 취소`, + onClick: () => setKeyword(label, { ...state, type: 'STABLE' }), + }); + break; + } + + return items; + }, [deleteKeyword, label, setKeyword, state]); + if (state === undefined) return <>; + const icon = typeToIcon(state.type); + return (
setKeyword(label, !state.enabled)} + onClick={() => setKeyword(label, { ...state, enabled: !state.enabled })} + onContextMenu={(e) => { + e.preventDefault(); + setMenu({ + items: items, + position: { x: e.clientX, y: e.clientY }, + }); + }} > + {icon !== undefined ? ( + {icon} + ) : null} {label}
); } + +function typeToIcon(type: KeywordType) { + switch (type) { + case 'ADD': + return 'add'; + case 'STABLE': + return undefined; + case 'DELETE': + return 'remove'; + } +} diff --git a/components/DataViewer/OptionPanel/optionPanel.module.css b/components/DataViewer/OptionPanel/optionPanel.module.css index 873c9f4..0c5f64f 100644 --- a/components/DataViewer/OptionPanel/optionPanel.module.css +++ b/components/DataViewer/OptionPanel/optionPanel.module.css @@ -61,6 +61,10 @@ } .keyword { + animation: var(--intro-animation); + display: flex; + gap: 5px; + align-items: center; padding: 12px 18px; font-size: 16px; color: var(--foreground); @@ -81,6 +85,11 @@ user-select: none; } +.keywordIcon { + font-size: 18px; + color: var(--grey-darker); +} + .tools { gap: 10px; height: 46px; diff --git a/components/DataViewer/PdfViewer/index.tsx b/components/DataViewer/PdfViewer/index.tsx index 8d3dddb..f5505c7 100644 --- a/components/DataViewer/PdfViewer/index.tsx +++ b/components/DataViewer/PdfViewer/index.tsx @@ -100,7 +100,9 @@ function useFocusKeyword(setPage: Dispatch>) { setCallback((keyword, location) => { setPage(location[0]); setAllKeyword(false); - setKeyWord(keyword, true); + setKeyWord(keyword, { + enabled: true, + }); }); return () => setCallback(undefined); }, [setAllKeyword, setCallback, setKeyWord, setPage]); diff --git a/components/Dialogue/Input/FileInput.tsx b/components/Dialogue/Input/FileInput.tsx index 71bf92b..ac5bf45 100644 --- a/components/Dialogue/Input/FileInput.tsx +++ b/components/Dialogue/Input/FileInput.tsx @@ -28,7 +28,7 @@ export default function FileInput({ name, types, onError, typeNames }: FileInput return (
-

요약할 파일

+

학습 자료

e.preventDefault()} diff --git a/components/GraphCanvas/GraphRenderer.tsx b/components/GraphCanvas/GraphRenderer.tsx index e48288a..90134ea 100644 --- a/components/GraphCanvas/GraphRenderer.tsx +++ b/components/GraphCanvas/GraphRenderer.tsx @@ -2,7 +2,7 @@ import { MutableRefObject, RefObject, useCallback, useEffect, useMemo, useRef } from 'react'; import { invalidate, useFrame, useThree } from '@react-three/fiber'; import * as THREE from 'three'; -import { boundNumber, calculateLineGeometry, getPos } from '@/utils/whiteboardHelper'; +import { boundNumber, calculateLineGeometry, genColor, getPos } from '@/utils/whiteboardHelper'; import * as d3 from 'd3'; import SpriteText from 'three-spritetext'; import { useViewer } from '@/states/viewer'; @@ -26,7 +26,7 @@ const COLOR_HIGHLIGHT_STRING = '#027373'; const COLOR_HIGHLIGHT = new THREE.Color(COLOR_HIGHLIGHT_STRING); interface GraphRendererProps { - data: GraphData; + data: Map; // documentId : graphData } export default function GraphRenderer({ data }: GraphRendererProps) { @@ -41,13 +41,36 @@ export default function GraphRenderer({ data }: GraphRendererProps) { ); const lineMesh = useRef(); const circleMesh = useRef(); + const currentDocumentId = useViewer((state) => state.document?.documentId); + const staleColorMapRef = useRef>(new Map()); + + const meshFactory = useCallback( + (nodes: NodeData[], links: LinkData[]) => { + // create node instancedMesh + circleMesh.current = new THREE.InstancedMesh(geometry, nodeMaterial, nodes.length); + circleMesh.current.position.setZ(10); + circleMesh.current.frustumCulled = false; + // need to set color once before render! + circleMesh.current.setColorAt(0, COLOR_STALE); + groupRef.current!.add(circleMesh.current); + + // create line instancedMesh + lineMesh.current = new THREE.InstancedMesh(lineGeometry, lineMaterial, links.length); + lineMesh.current.frustumCulled = false; + groupRef.current!.add(lineMesh.current); + }, + [geometry, lineGeometry, lineMaterial, nodeMaterial], + ); const nodeDataFactory = useCallback( - (entries: IterableIterator<[number, string]>) => { + (documentId: number, entries: IterableIterator<[number, string]>) => { const nodes: NodeData[] = []; + // generate color + const color = documentId === currentDocumentId ? COLOR_STALE_STRING : genColor(); + staleColorMapRef.current.set(documentId, new THREE.Color(color)); for (const [id, keyword] of entries) { // label - const text = new SpriteText(keyword, 28, 'black') as ExtendedSpriteText; + const text = new SpriteText(keyword, 28, color) as ExtendedSpriteText; text.position.setZ(15); text.strokeColor = 'white'; text.strokeWidth = 2; @@ -55,7 +78,8 @@ export default function GraphRenderer({ data }: GraphRendererProps) { groupRef.current!.add(text); nodes.push({ - id: id, + documentId: documentId, + id: `${documentId}-${id}`, children: [], label: keyword, labelMesh: text, @@ -64,35 +88,27 @@ export default function GraphRenderer({ data }: GraphRendererProps) { } as NodeData); } - // create instancedMesh - circleMesh.current = new THREE.InstancedMesh(geometry, nodeMaterial, nodes.length); - circleMesh.current.position.setZ(10); - circleMesh.current.frustumCulled = false; - // need to set color once before render! - circleMesh.current.setColorAt(0, COLOR_STALE); - groupRef.current!.add(circleMesh.current); - return nodes; }, - [geometry, nodeMaterial], + [currentDocumentId], ); const linkDataFactory = useCallback( - (entries: IterableIterator<[string, number[]]>) => { + (documentId: number, entries: IterableIterator<[string, number[]]>) => { const links: LinkData[] = []; for (const [key, children] of entries) { const id = parseInt(key); for (const child of children) { - links.push({ source: id, target: child } as LinkData); + links.push({ + source: `${documentId}-${id}`, + target: `${documentId}-${child}`, + documentId: documentId, + } as LinkData); } } - // create instancedMesh - lineMesh.current = new THREE.InstancedMesh(lineGeometry, lineMaterial, links.length); - lineMesh.current.frustumCulled = false; - groupRef.current!.add(lineMesh.current); return links; }, - [lineGeometry, lineMaterial], + [], ); const { simulationRef, linksRef } = useSimulation( @@ -100,6 +116,7 @@ export default function GraphRenderer({ data }: GraphRendererProps) { groupRef, nodeDataFactory, linkDataFactory, + meshFactory, ); const { selectedNodeRef, hoverNodeRef, pointerDownRef } = usePointer(simulationRef, circleMesh); @@ -127,8 +144,10 @@ export default function GraphRenderer({ data }: GraphRendererProps) { const labelOpacity = needFocusOnNode ? Math.min(0.4, labelOpacityBasedOnZoom) : labelOpacityBasedOnZoom; - const defaultColor = needFocusOnNode ? COLOR_STALE_BACK : COLOR_STALE; + nodes.forEach((node, i) => { + const staleColor = staleColorMapRef.current.get(node.documentId) ?? COLOR_STALE; + const defaultColor = needFocusOnNode ? COLOR_STALE_BACK : staleColor; const m = new THREE.Matrix4(); if (node === selectedNodeRef.current?.node) { // selected node @@ -348,10 +367,14 @@ function usePointer( } function useSimulation( - data: GraphData, + data: Map, groupRef: RefObject, - nodeDataFactory: (entries: IterableIterator<[number, string]>) => NodeData[], - linkDataFactory: (entries: IterableIterator<[string, number[]]>) => LinkData[], + nodeDataFactory: (documentId: number, entries: IterableIterator<[number, string]>) => NodeData[], + linkDataFactory: ( + documentId: number, + entries: IterableIterator<[string, number[]]>, + ) => LinkData[], + meshFactory: (nodes: NodeData[], links: LinkData[]) => void, ) { const simulationRef = useRef>(d3.forceSimulation()); const linksRef = useRef([]); @@ -361,7 +384,9 @@ function useSimulation( if (simulationRef.current != null) simulationRef.current.stop(); // regenerated on graph data update - if (groupRef.current == null) return; + if (groupRef.current == null) { + return; + } // remove items from mesh group for (const child of groupRef.current.children) { @@ -371,19 +396,34 @@ function useSimulation( groupRef.current.clear(); - // generate nodes - const nodes: NodeData[] = nodeDataFactory(data.keywords.entries()); + const nodes: NodeData[] = []; + linksRef.current = []; + + // support multiple data + for (const [documentId, graphData] of data) { + // generate nodes + const newNodes = nodeDataFactory(documentId, graphData.keywords.entries()); + const nodesMap: Map = new Map(); + for (const node of newNodes) { + nodes.push(node); + nodesMap.set(node.id, node); + } - // generate links - const graph = new Map(Object.entries(data.graph)); - linksRef.current = linkDataFactory(graph.entries()); + // generate links + const graph = new Map(Object.entries(graphData.graph)); + const newLinks = linkDataFactory(documentId, graph.entries()); - // save links in nodes for future use - for (const link of linksRef.current) { - nodes[link.source as number].children?.push(nodes[link.target as number]); - nodes[link.target as number].parent = nodes[link.source as number]; + // save links in nodes for future use + for (const link of newLinks) { + linksRef.current.push(link); + nodesMap.get(link.source as string)!.children?.push(nodesMap.get(link.target as string)!); + nodesMap.get(link.target as string)!.parent = nodesMap.get(link.source as string); + } } + // generate meshes (from graphRenderer context) + meshFactory(nodes, linksRef.current); + simulationRef.current .nodes(nodes) .force('charge', d3.forceManyBody().strength(-500).distanceMax(500)) @@ -391,7 +431,7 @@ function useSimulation( 'link', d3 .forceLink(linksRef.current) - .id((d) => d.id) + .id((node, i, nodesData) => node.id) .strength(1), ) .force('collision', d3.forceCollide(GAP)) @@ -399,7 +439,7 @@ function useSimulation( simulationRef.current.alpha(1).stop(); invalidate(); - }, [data.graph, data.keywords, groupRef, linkDataFactory, nodeDataFactory]); + }, [data, groupRef, linkDataFactory, nodeDataFactory]); // simulation tick on frame useFrame((_state, delta) => { diff --git a/components/GraphCanvas/MixModePanel/index.tsx b/components/GraphCanvas/MixModePanel/index.tsx new file mode 100644 index 0000000..e1de8ad --- /dev/null +++ b/components/GraphCanvas/MixModePanel/index.tsx @@ -0,0 +1,130 @@ +'use client'; +import { useViewer } from '@/states/viewer'; +import styles from './mixModePanel.module.css'; +import ClickableBackgroundButton from '@/components/BackgroundButton/ClickableBackgroundButton'; +import { useDocument } from '@/states/document'; +import { useOverlay } from '@/states/overlay'; +import { useEffect, useState } from 'react'; +import { getMindMapData } from '@/utils/api'; + +export default function MixModePanel() { + const { mindMapData, setMindMapData, currentDocumentId } = useViewer((state) => ({ + currentDocumentId: state.document?.documentId ?? -1, + mindMapData: state.mindMapData, + setMindMapData: state.setMindMapData, + })); + + const { documentList, fetchDocumentList } = useDocument((state) => ({ + documentList: state.documentList, + fetchDocumentList: state.fetchDocumentList, + })); + + const [selection, setSelection] = useState>(new Map()); + + useEffect(() => { + fetchDocumentList(); + }, [fetchDocumentList]); + + const setOverlay = useOverlay((state) => state.setOverlay); + + if (mindMapData === null) return <>; + + const applyChanges = () => { + // loading dialogue + setOverlay(); + // create new mindmap state + const newMindMapData = new Map([...mindMapData]); + (async () => { + // fetch new Mindmaps + for (const [id, v] of selection.entries()) { + if (v === false) { + // remove mindmap + newMindMapData.delete(id); + continue; + } + const newData = await getMindMapData(id); + if (newData === null) continue; + newMindMapData.set(id, newData); + } + // apply new state + setMindMapData(newMindMapData); + // close dialogue + setOverlay(null); + })(); + }; + + return ( +
+
+

현재 {mindMapData.size}개의 문서 동시에 보는중

+ fetchDocumentList()} + /> + applyChanges()} + /> + setOverlay(null)} + /> +
+
+ {documentList.map((v) => { + const selected = + (mindMapData.get(v.documentId) !== undefined && + selection.get(v.documentId) === undefined) || + selection.get(v.documentId) === true; + const fixed = v.documentId === currentDocumentId; + return ( + { + if (!fixed) setSelection((state) => new Map([...state, [v.documentId, !selected]])); + }} + /> + ); + })} +
+
+ ); +} + +type MinCardState = 'selected' | 'fixed' | 'unselected'; + +interface MiniCardProps { + document: WBDocument; + state: MinCardState; + onClick: () => void; +} + +function MiniCard({ document, state, onClick }: MiniCardProps) { + return ( +
+

{document.documentName}

+
+ ); +} + +function LoadingDialogue() { + return ( +
+

요청한 문서들을 불러오는 중입니다. 잠시만 기다려 주세요.

+
+ ); +} diff --git a/components/GraphCanvas/MixModePanel/mixModePanel.module.css b/components/GraphCanvas/MixModePanel/mixModePanel.module.css new file mode 100644 index 0000000..78d2edc --- /dev/null +++ b/components/GraphCanvas/MixModePanel/mixModePanel.module.css @@ -0,0 +1,61 @@ +.container { + max-height: 90%; + overflow-y: auto; + max-width: 1000px; + padding: 20px; + display: flex; + border-radius: 12px; + background-color: var(--foreground); + border: 1px solid var(--grey-normal); + flex-direction: column; + animation: var(--intro-animation); +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + flex: 1; +} + +.bar { + background-color: var(--foreground); + position: sticky; + top: 0; + color: var(--grey-darker); + gap: 20px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 0 0 10px 0; +} + +.card { + border: 2px solid var(--background); + box-sizing: border-box; + padding: 10px; + width: 500px; + color: var(--grey-darker); + border-radius: 10px; + background-color: var(--background); +} + +.selected { + box-sizing: border-box; + border: 2px solid var(--primary-dark); +} + +.fixed { + border: 2px solid var(--primary-dark); + background-color: var(--primary-dark); + color: var(--foreground); +} + +.cardText { + overflow: hidden; + text-overflow: ellipse; + margin: 0; + padding: 0; +} diff --git a/components/GraphCanvas/index.tsx b/components/GraphCanvas/index.tsx index f7f89e3..1a518aa 100644 --- a/components/GraphCanvas/index.tsx +++ b/components/GraphCanvas/index.tsx @@ -1,18 +1,15 @@ 'use client'; -import { CSSProperties, useEffect } from 'react'; +import { CSSProperties } from 'react'; import { Canvas } from '@react-three/fiber'; -import { useGraph } from '@/states/graph'; import dynamic from 'next/dynamic'; -import { useViewer } from '@/states/viewer'; const GraphRenderer = dynamic(() => import('./GraphRenderer'), { ssr: false }); interface GraphCanvasProps { style?: CSSProperties; + mindMapData: Map; } -export default function GraphCanvas({ style }: GraphCanvasProps) { - const mindMapData = useViewer((state) => state.mindMapData); - +export default function GraphCanvas({ style, mindMapData }: GraphCanvasProps) { if (mindMapData === null) return <>; return ( diff --git a/components/NodeInfo/index.tsx b/components/NodeInfo/index.tsx index 644b6f7..7f7e03d 100644 --- a/components/NodeInfo/index.tsx +++ b/components/NodeInfo/index.tsx @@ -5,15 +5,19 @@ import { useViewer } from '@/states/viewer'; import RelationView from './RelationView'; import SourceDataView from './SourceDataView'; import { getKeywordInfo } from '@/utils/api'; +import Link from 'next/link'; interface NodeInfoProps { node?: NodeData; } + export default function NodeInfo({ node }: NodeInfoProps) { const [data, setData] = useState(); const [sourceView, setSourceView] = useState(true); const [relationView, setRelationView] = useState(true); - const documentId = useViewer((state) => state.document?.documentId); + const currentDocumentId = useViewer((state) => state.document?.documentId); + const documentId = node?.documentId; + const isLocalNode = documentId === currentDocumentId; useEffect(() => { if (node === undefined || documentId === undefined) return; @@ -35,6 +39,7 @@ export default function NodeInfo({ node }: NodeInfoProps) { return (

{node?.label}

+ {isLocalNode ? null : }
setRelationView((show) => !show)} /> - setSourceView((show) => !show)} - /> + {isLocalNode ? ( + setSourceView((show) => !show)} + /> + ) : null} +
); } +interface MixedNodeNoticeProps { + documentId: number; +} + +function MixedNodeNotice({ documentId }: MixedNodeNoticeProps) { + return ( +
+ warning +

+ 해당 노드는 MIX 로 불러온 외부 노드입니다. 수정하려면{' '} + + 여기 + + 를 클릭하여 원본 문서로 이동하세요 +

+
+ ); +} + function EmptyNodeInfo() { return (
diff --git a/components/NodeInfo/nodeInfo.module.css b/components/NodeInfo/nodeInfo.module.css index ed2a46e..c663042 100644 --- a/components/NodeInfo/nodeInfo.module.css +++ b/components/NodeInfo/nodeInfo.module.css @@ -26,3 +26,25 @@ flex-direction: row; gap: 10px; } + +.notice { + box-sizing: border-box; + gap: 10px; + padding: 10px 20px; + display: flex; + border-radius: 20px; + align-items: center; + border: 2px solid var(--warning-color); + color: var(--warning-color) !important; + animation: var(--intro-animation); +} + +.noticeText { + padding: 0; + margin: 0; +} + +.noticeLink { + color: var(--warning-color); + font-weight: bold; +} diff --git a/components/OverlayWrapper/OverlayWrapper.module.css b/components/OverlayWrapper/OverlayWrapper.module.css index ed4ee3b..8fb14e4 100644 --- a/components/OverlayWrapper/OverlayWrapper.module.css +++ b/components/OverlayWrapper/OverlayWrapper.module.css @@ -1,4 +1,5 @@ .overlay { + z-index: 500; animation: fadeIn 0.3s; overflow: auto; display: flex; diff --git a/global.d.ts b/global.d.ts index e146b50..c9368fc 100644 --- a/global.d.ts +++ b/global.d.ts @@ -66,9 +66,10 @@ type ExtendedSpriteText = import('three-spritetext').default & { }; interface NodeData { + documentId: number; children: NodeData[]; parent?: NodeData; - id: number; + id: string; label: string; labelMesh?: ExtendedSpriteText; scale: number; @@ -90,8 +91,9 @@ interface NodeDataLegacy { } interface LinkData { - source: number | NodeData; - target: number | NodeData; + documentId: number; + source: string | NodeData; + target: string | NodeData; } interface LinkDataLegacy { @@ -189,7 +191,7 @@ interface GraphData { graph: Map; } -type WBSourceDataType = 'pdf' | 'audio' | 'none'; +type WBSourceDataType = 'pdf' | 'audio'; interface WBSourceData { url: string; diff --git a/states/contextMenu.ts b/states/contextMenu.ts new file mode 100644 index 0000000..9b6b9bb --- /dev/null +++ b/states/contextMenu.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand'; + +interface ContextMenuState { + menu: ContextMenu | null; + setMenu: (menus: ContextMenu | null) => void; +} + +export interface ContextMenu { + items: ContextMenuItem[]; + position: Coord; +} + +export interface ContextMenuItem { + label: string; + onClick: () => void; +} + +export const useContextMenu = create()((set) => ({ + menu: null, + setMenu: (menu) => set({ menu: menu }), +})); diff --git a/states/viewer.ts b/states/viewer.ts index 21b7c0b..c9dc406 100644 --- a/states/viewer.ts +++ b/states/viewer.ts @@ -3,7 +3,7 @@ import { create } from 'zustand'; interface ViewerState { dataSource: WBSourceData | null; - mindMapData: GraphData | null; + mindMapData: Map | null; nextState: ViewerStateSlice | null; view: ViewerPage; document: WBDocument | null; @@ -25,7 +25,7 @@ interface ViewerActions { setFocusNodeCallback: (callback: ((node: NodeData) => void) | undefined) => void; addKeyword: (keyword: string, state?: boolean) => void; deleteKeyword: (keyword: string) => void; - setKeyword: (keyword: string, state: boolean) => void; + setKeyword: (keyword: string, state: KeywordStateSlice) => void; setAllKeyword: (state: boolean) => void; setDataStr: (dataStr: string[][]) => void; findDataStr: (from: number[], keyword: string) => KeywordSourceResult | undefined; @@ -37,14 +37,20 @@ interface ViewerActions { syncDocument: () => void; applySyncDocument: () => void; clearSyncDocument: () => void; + setMindMapData: (mindMapData: Map) => void; } interface ViewerStateSlice { dataSource?: WBSourceData; - mindMapData?: GraphData; + mindMapData?: Map; document?: WBDocument; } +interface KeywordStateSlice { + enabled?: boolean; + type?: KeywordType; +} + const initialState = { dataSource: null, mindMapData: null, @@ -80,11 +86,10 @@ export const useViewer = create()((set, get) => ({ }), deleteKeyword: (keyword) => { const keywords = get().keywords; + const newKeywords = new Map([...keywords]); + newKeywords.delete(keyword); set({ - keywords: new Map([...keywords]).set(keyword, { - enabled: false, - type: 'DELETE', - }), + keywords: newKeywords, }); }, setKeyword: (keyword, state) => { @@ -92,7 +97,7 @@ export const useViewer = create()((set, get) => ({ const prevState = keywords.get(keyword); if (prevState === undefined) return; set({ - keywords: new Map([...keywords]).set(keyword, { ...prevState, enabled: state }), + keywords: new Map([...keywords]).set(keyword, { ...prevState, ...state }), }); }, setAllKeyword: (state) => { @@ -133,11 +138,18 @@ export const useViewer = create()((set, get) => ({ // fetch sourceData const newDataSource = await getDataSource(documentId, newDocument.dataType); // fetch mindMapData - const newMindMapData = await getMindMapData(documentId); + const newMindMap = await getMindMapData(documentId); + + // mindmap generation is in progress + if (newMindMap === null) return; + + // mindMapData map + const newMindMapData = new Map(); + newMindMapData.set(documentId, newMindMap); // local keyword map const newKeywords = new Map(); - for (const keyword of newMindMapData.keywords) { + for (const keyword of newMindMap.keywords) { newKeywords.set(keyword, { enabled: false, type: 'STABLE', @@ -159,17 +171,13 @@ export const useViewer = create()((set, get) => ({ const document = get().document; const nextState: ViewerStateSlice = {}; if (document === null) return; - // fetch documentMetaData - const newDocumentData = await getDocument(document.documentId); - // fetch mindMapData: always update - nextState.mindMapData = await getMindMapData(document.documentId); - // fetch datasource: update only if datasource has changed - const newDataSource = await getDataSource(document.documentId, newDocumentData.dataType); - if (newDataSource.url !== get().dataSource?.url) { - nextState.dataSource = newDataSource; - nextState.document = newDocumentData; + // fetch mindMapData: always update + const newMindMap = await getMindMapData(document.documentId); + if (newMindMap !== null) { + nextState.mindMapData = new Map([[document.documentId, newMindMap]]); } + set({ nextState: nextState }); }, applySyncDocument: () => { @@ -178,11 +186,13 @@ export const useViewer = create()((set, get) => ({ // local keyword map const newKeywords = new Map(); if (newState.mindMapData !== undefined) { - for (const keyword of newState.mindMapData.keywords) { - newKeywords.set(keyword, { - enabled: false, - type: 'STABLE', - }); + for (const [documentId, mindMap] of newState.mindMapData) { + for (const keyword of mindMap.keywords) { + newKeywords.set(keyword, { + enabled: false, + type: 'STABLE', + }); + } } } set({ @@ -196,4 +206,7 @@ export const useViewer = create()((set, get) => ({ clearSyncDocument: () => { set({ nextState: null }); }, + setMindMapData: (mindMapData: Map) => { + set({ mindMapData: mindMapData, selectedNode: undefined }); + }, })); diff --git a/utils/api.ts b/utils/api.ts index 85bca13..d02bc02 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -30,8 +30,10 @@ export async function refreshToken(): Promise { ); } -export async function getMindMapData(documentId: number): Promise { - return (await httpGet(`${API_BASE_URL}/documents/${documentId}/mindmap`))?.json(); +export async function getMindMapData(documentId: number): Promise { + const res = await httpGet(`${API_BASE_URL}/documents/${documentId}/mindmap`); + if (res?.status === 200) return res.json(); + return null; } export async function getDataSource( @@ -59,7 +61,10 @@ export async function getKeywordInfo( export async function uploadFile(documentId: number, file: File) { const type = uploadFileType(file.type); - return await httpPost(`${API_BASE_URL}/documents/${documentId}/${type}`, file, true, false); + if (type === undefined) return; + const form = new FormData(); + form.append(type, file); + return await httpPost(`${API_BASE_URL}/documents/${documentId}/${type}`, form, false, false); } function uploadFileType(type: string) { diff --git a/utils/whiteboardHelper.ts b/utils/whiteboardHelper.ts index 4f78cf5..7ea6749 100644 --- a/utils/whiteboardHelper.ts +++ b/utils/whiteboardHelper.ts @@ -16,9 +16,9 @@ export function genId(): string { * @return {string} Generated random color value in rgb string */ export function genColor(): string { - return `rgb(${Math.floor(Math.random() * 255)},${Math.floor(Math.random() * 255)},${Math.floor( - Math.random() * 255, - )})`; + return `rgb(${Math.floor(Math.random() * 50) + 130},${Math.floor(Math.random() * 50) + 130},${ + Math.floor(Math.random() * 50) + 130 + })`; } /**