diff --git a/packages/frontend/src/components/Edge.tsx b/packages/frontend/src/components/Edge.tsx index 413538f..5608d5f 100644 --- a/packages/frontend/src/components/Edge.tsx +++ b/packages/frontend/src/components/Edge.tsx @@ -1,11 +1,70 @@ import { useEffect, useState } from "react"; -import { Group, Line } from "react-konva"; +import { Circle, Group, Line, Text } from "react-konva"; import Konva from "konva"; +import { KonvaEventObject } from "konva/lib/Node"; import type { Edge } from "shared/types"; type EdgeProps = Edge & Konva.LineConfig; +const BUTTON_RADIUS = 12; + +type EdgeEditButtonProps = { + points: number[]; + onTap: (edgeId: string) => void; +}; + +function EdgeEditButton({ points, onTap }: EdgeEditButtonProps) { + const [isTouch, setIsTouch] = useState(false); + + useEffect(() => { + setIsTouch("ontouchstart" in window || navigator.maxTouchPoints > 0); + }, []); + + if (!isTouch || points.length < 4) return null; + + const handleTap = (e: KonvaEventObject) => { + const targetGroup = e.target + .findAncestor("Group") + .findAncestor("Group") as Konva.Group; + + if (!targetGroup) return; + + const targetEdge = targetGroup.children.find( + (konvaNode) => konvaNode.attrs.name === "edge", + ); + + if (!targetEdge) return; + + onTap(targetEdge.attrs.id); + }; + + const middleX = (points[0] + points[2]) / 2; + const middleY = (points[1] + points[3]) / 2; + + return ( + + + + + ); +} + function calculateOffsets( from: { x: number; y: number }, to: { x: number; y: number }, @@ -26,6 +85,7 @@ export default function Edge({ to, id, onContextMenu, + onDelete, ...rest }: EdgeProps) { const [points, setPoints] = useState([]); @@ -66,6 +126,7 @@ export default function Edge({ name="edge" id={id} /> + ); } diff --git a/packages/frontend/src/components/Node.tsx b/packages/frontend/src/components/Node.tsx index dc60972..03f748c 100644 --- a/packages/frontend/src/components/Node.tsx +++ b/packages/frontend/src/components/Node.tsx @@ -3,9 +3,15 @@ import { Circle, Group, KonvaNodeEvents, Text } from "react-konva"; import { useNavigate } from "react-router-dom"; import Konva from "konva"; +import { + KonvaEventObject, + Node as KonvaNode, + NodeConfig, +} from "konva/lib/Node"; import { Vector2d } from "konva/lib/types"; const RADIUS = 64; +const MORE_BUTTON_RADIUS = 12; type NodeProps = { id: string; @@ -72,6 +78,7 @@ Node.Text = function NodeText({ fontSize, fontStyle, width, + ...rest }: NodeTextProps) { const ref = useRef(null); const [offset, setOffset] = useState(undefined); @@ -95,10 +102,76 @@ Node.Text = function NodeText({ align="center" wrap="none" ellipsis + {...rest} /> ); }; +type NodeMoreButtonProps = { + onTap?: + | ((evt: KonvaEventObject>) => void) + | undefined; + content: string; +}; + +Node.MoreButton = function NodeMoreButton({ content }: NodeMoreButtonProps) { + const [isTouch, setIsTouch] = useState(false); + + useEffect(() => { + setIsTouch("ontouchstart" in window || navigator.maxTouchPoints > 0); + }, []); + + if (!isTouch) return null; + + const handleTap = (e: KonvaEventObject) => { + e.cancelBubble = true; + + const parentNode = e.target.findAncestor("Group").findAncestor("Group"); + + if (!parentNode) return; + + const absolutePosition = parentNode.getAbsolutePosition(); + + const contextMenuEvent = new MouseEvent("contextmenu", { + button: 2, + buttons: 2, + clientX: absolutePosition.x, + clientY: absolutePosition.y, + bubbles: true, + }); + + parentNode.fire("contextmenu", { + evt: contextMenuEvent, + target: parentNode, + }); + }; + + return ( + + + + + ); +}; + export type HeadNodeProps = { id: string; name: string; @@ -126,7 +199,15 @@ export type NoteNodeProps = { name: string; } & NodeHandlers; -export function NoteNode({ id, x, y, name, src, ...rest }: NoteNodeProps) { +export function NoteNode({ + id, + x, + y, + name, + src, + onContextMenu, + ...rest +}: NoteNodeProps) { // TODO: src 적용 필요 const navigate = useNavigate(); return ( @@ -139,10 +220,12 @@ export function NoteNode({ id, x, y, name, src, ...rest }: NoteNodeProps) { navigate(`/note/${src}`); } }} + onContextMenu={onContextMenu} {...rest} > + ); } @@ -161,6 +244,7 @@ export function SubspaceNode({ y, name, src, + onContextMenu, ...rest }: SubspaceNodeProps) { const navigate = useNavigate(); @@ -171,10 +255,17 @@ export function SubspaceNode({ x={x} y={y} onClick={() => navigate(`/space/${src}`)} + onContextMenu={onContextMenu} {...rest} > - + + ); } diff --git a/packages/frontend/src/components/space/SpaceView.tsx b/packages/frontend/src/components/space/SpaceView.tsx index 7b550e9..884725c 100644 --- a/packages/frontend/src/components/space/SpaceView.tsx +++ b/packages/frontend/src/components/space/SpaceView.tsx @@ -146,7 +146,7 @@ export default function SpaceView({ spaceId, autofitTo }: SpaceViewProps) { }; }, [autofitTo]); - const handleContextMenu = (e: KonvaEventObject) => { + const handleContextMenu = (e: KonvaEventObject) => { clearSelection(); const { target } = e; @@ -159,7 +159,9 @@ export default function SpaceView({ spaceId, autofitTo }: SpaceViewProps) { return; } - const group = target.findAncestor("Group"); + // Mobile 환경에서는 group을 대상으로 임의로 이벤트 발생시킴 + const group = + target instanceof Konva.Group ? target : target.findAncestor("Group"); const nodeId = group?.attrs?.id as string | undefined; @@ -299,6 +301,7 @@ export default function SpaceView({ spaceId, autofitTo }: SpaceViewProps) { to={edge.to} nodes={nodes} onContextMenu={handleContextMenu} + onDelete={deleteEdge} /> ));