diff --git a/components/board/BoardCanvas.module.css b/components/board/BoardCanvas.module.css index 193b1112..af8ddc56 100644 --- a/components/board/BoardCanvas.module.css +++ b/components/board/BoardCanvas.module.css @@ -380,43 +380,6 @@ } /* Context menu - matching ContextMenu.module.css */ -.context_menu { - display: flex; - flex-direction: column; - width: 220px; - z-index: 1000; - position: fixed; - padding-block: 6px; - overflow-y: auto; - border-radius: 12px; - background-color: var(--context-menu-bg); - box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); -} - -.context_menu_item { - display: flex; - align-items: center; - gap: 10px; - - padding-block: 6px; - padding-inline: 12px; - font-size: 14px; - cursor: pointer; -} - -.context_menu_item:hover { - background-color: var(--context-menu-item-hover); -} - -.context_menu_item_disabled { - opacity: 0.45; - cursor: default; -} - -.context_menu_item_disabled:hover { - background-color: transparent; -} - /* Recording indicator: a pill in the top toolbar row, right of the zoom controls. */ .recording_indicator { position: absolute; @@ -474,33 +437,6 @@ background-color: #d33b40; } -.context_menu_colors { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 10px 12px; - border-bottom: 1px solid var(--separator); -} - -.context_menu_color_swatch { - width: 20px; - height: 20px; - border: 2px solid transparent; - border-radius: 50%; - cursor: pointer; - transition: - transform 0.15s ease, - border-color 0.15s ease; -} - -.context_menu_color_swatch:hover { - transform: scale(1.15); -} - -.context_menu_color_swatch_active { - border-color: white; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); -} /* Zoom controls - styled like the panel switcher buttons (secondary pill, no border, secondary-hover on hover) and sitting in the same top toolbar row, diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index 267a5041..e9598ab1 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -2,8 +2,14 @@ import { useContext, useRef, useState, useCallback, useEffect, useMemo } from "react"; import { ProjectContext } from "@src/context/ProjectContext"; +import { UserContext } from "@src/context/UserContext"; import { BoardCardData, BoardArrowData } from "@src/lib/project/project-state"; import BoardCard from "./BoardCard"; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuColorRow, +} from "@components/utils/ContextMenu"; import styles from "./BoardCanvas.module.css"; import { v7 as uuidv7 } from "uuid"; import { Trash2, Plus, Minus, Copy, ListTree, Mic, Square } from "lucide-react"; @@ -34,27 +40,10 @@ function formatRecordingTime(seconds: number): string { return `${m}:${s.toString().padStart(2, "0")}`; } -interface CardContextMenuState { - position: { x: number; y: number }; - card: BoardCardData; -} - -interface ArrowContextMenuState { - position: { x: number; y: number }; - arrow: BoardArrowData; -} - -interface CanvasContextMenuState { - /** Viewport position for placing the menu. */ - position: { x: number; y: number }; - /** Canvas-space coords where a new card should be created. */ - canvasX: number; - canvasY: number; -} - -const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }) => { +const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) => { const { projectId, repository, isYjsReady, isReadOnly, boardFocusCardId, setBoardFocusCardId } = useContext(ProjectContext); + const { updateContextMenu } = useContext(UserContext); const t = useTranslations("board"); const projectState = repository?.getState(); const containerRef = useRef(null); @@ -67,19 +56,11 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const [isPanning, setIsPanning] = useState(false); const [isDraggingFile, setIsDraggingFile] = useState(false); const [isSnapping, setIsSnapping] = useState(true); - const [cardContextMenu, setCardContextMenu] = useState(null); - const [arrowContextMenu, setArrowContextMenu] = useState(null); - const [canvasContextMenu, setCanvasContextMenu] = useState(null); const recorder = useAudioRecorder(); const [prevIsVisible, setPrevIsVisible] = useState(isVisible); if (prevIsVisible !== isVisible) { setPrevIsVisible(isVisible); - if (!isVisible) { - setIsSnapping(true); - if (cardContextMenu) setCardContextMenu(null); - if (arrowContextMenu) setArrowContextMenu(null); - if (canvasContextMenu) setCanvasContextMenu(null); - } + if (!isVisible) setIsSnapping(true); } const [isCameraReady, setIsCameraReady] = useState(false); const [connectingFrom, setConnectingFrom] = useState<{ cardId: string; side: string } | null>( @@ -263,22 +244,6 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } }; }, [isVisible]); - // Close context menus on click anywhere - useEffect(() => { - if (!isVisible) return; - - const handleClick = () => { - if (cardContextMenu) setCardContextMenu(null); - if (arrowContextMenu) setArrowContextMenu(null); - if (canvasContextMenu) setCanvasContextMenu(null); - }; - - window.addEventListener("click", handleClick); - return () => { - window.removeEventListener("click", handleClick); - }; - }, [cardContextMenu, arrowContextMenu, canvasContextMenu, isVisible]); - // Panning with middle-click const handlePanMouseDown = useCallback( (e: React.MouseEvent) => { @@ -302,7 +267,7 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } if (e.button !== 0) return; if ((e.target as HTMLElement).closest(`.${styles.card}`)) return; if ((e.target as HTMLElement).closest(`.${styles.zoom_controls}`)) return; - if ((e.target as HTMLElement).closest(`.${styles.context_menu}`)) return; + if ((e.target as HTMLElement).closest("[data-context-menu]")) return; const container = containerRef.current; if (!container) return; @@ -628,70 +593,41 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } [isReadOnly, projectId, offset, scale, saveCards], ); - // Right-clicking empty canvas opens a menu (create card / record audio). - // Cards and arrows have their own menus, so bail when the click landed on one. - const handleCanvasContextMenu = useCallback( - (e: React.MouseEvent) => { - if (isReadOnly) return; - const target = e.target as HTMLElement; - if ( - target.closest(`.${styles.card}`) || - target.closest(`.${styles.arrow_group}`) || - target.closest(`.${styles.context_menu}`) || - target.closest(`.${styles.zoom_controls}`) - ) - return; + // Create a text card at the given canvas-space coords (from the canvas menu). + const handleCreateCard = useCallback( + (x: number, y: number) => { + setSelectedCardIds(new Set()); - const container = containerRef.current; - if (!container) return; - const rect = container.getBoundingClientRect(); - e.preventDefault(); - setCardContextMenu(null); - setArrowContextMenu(null); - setCanvasContextMenu({ - // Match the card/arrow menus: positioned with raw viewport coords. - position: { x: e.clientX, y: e.clientY }, - canvasX: (e.clientX - rect.left - offset.x) / scale, - canvasY: (e.clientY - rect.top - offset.y) / scale, - }); + const newCard: BoardCardData = { + id: uuidv7(), + title: "", + description: "", + color: randomCardColor(), + x: isSnapping ? Math.round(x / GRID_SIZE) * GRID_SIZE : x, + y: isSnapping ? Math.round(y / GRID_SIZE) * GRID_SIZE : y, + width: 450, + height: 280, + }; + + const newCards = [...cardsRef.current, newCard]; + setCards(newCards); + saveCards(newCards); }, - [isReadOnly, offset, scale], + [isSnapping, saveCards], ); - // Create a text card at the spot the canvas menu was opened. - const handleCreateCard = useCallback(() => { - if (!canvasContextMenu) return; - const { canvasX: x, canvasY: y } = canvasContextMenu; - setCanvasContextMenu(null); - setSelectedCardIds(new Set()); - - const newCard: BoardCardData = { - id: uuidv7(), - title: "", - description: "", - color: randomCardColor(), - x: isSnapping ? Math.round(x / GRID_SIZE) * GRID_SIZE : x, - y: isSnapping ? Math.round(y / GRID_SIZE) * GRID_SIZE : y, - width: 450, - height: 280, - }; - - const newCards = [...cardsRef.current, newCard]; - setCards(newCards); - saveCards(newCards); - }, [canvasContextMenu, isSnapping, saveCards]); - - // Begin recording from the canvas menu; remember where to drop the card. - const handleStartRecording = useCallback(async () => { - if (!canvasContextMenu) return; - recordCoords.current = { x: canvasContextMenu.canvasX, y: canvasContextMenu.canvasY }; - setCanvasContextMenu(null); - try { - await recorder.start(); - } catch (err) { - console.error("[BoardCanvas] Microphone access failed:", err); - } - }, [canvasContextMenu, recorder]); + // Begin recording; remember where to drop the resulting card. + const handleStartRecording = useCallback( + async (x: number, y: number) => { + recordCoords.current = { x, y }; + try { + await recorder.start(); + } catch (err) { + console.error("[BoardCanvas] Microphone access failed:", err); + } + }, + [recorder], + ); // Stop recording, store the clip as an asset, and drop an audio card. const handleStopRecording = useCallback(async () => { @@ -720,6 +656,49 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } } }, [recorder, projectId, saveCards]); + // Right-clicking empty canvas opens a menu (create card / record audio). + // Cards and arrows have their own menus, so bail when the click landed on one. + const handleCanvasContextMenu = useCallback( + (e: React.MouseEvent) => { + if (isReadOnly) return; + const target = e.target as HTMLElement; + if ( + target.closest(`.${styles.card}`) || + target.closest(`.${styles.arrow_group}`) || + target.closest("[data-context-menu]") || + target.closest(`.${styles.zoom_controls}`) + ) + return; + + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + e.preventDefault(); + const canvasX = (e.clientX - rect.left - offset.x) / scale; + const canvasY = (e.clientY - rect.top - offset.y) / scale; + updateContextMenu({ + position: { x: e.clientX, y: e.clientY }, + content: ( + <> + handleCreateCard(canvasX, canvasY)} + /> + handleStartRecording(canvasX, canvasY)} + disabled={!recorder.isSupported} + title={recorder.isSupported ? undefined : t("audioUnsupported")} + /> + + ), + }); + }, + [isReadOnly, offset, scale, updateContextMenu, t, handleCreateCard, handleStartRecording, recorder.isSupported], + ); + // Update card (with multi-drag support) const handleUpdateCard = useCallback( (updatedCard: BoardCardData) => { @@ -766,7 +745,6 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const newArrows = arrows.filter((a) => a.fromCardId !== id && a.toCardId !== id); setArrows(newArrows); saveArrows(newArrows); - setCardContextMenu(null); // Deleting an image card may orphan its asset — reconcile (debounced). if (projectId && projectState) scheduleAssetGc(projectId, projectState); }, @@ -779,7 +757,6 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const newCards = cards.map((c) => (c.id === id ? { ...c, color } : c)); setCards(newCards); saveCards(newCards); - setCardContextMenu(null); }, [cards, saveCards], ); @@ -796,7 +773,6 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const newCards = [...cards, newCard]; setCards(newCards); saveCards(newCards); - setCardContextMenu(null); }, [cards, saveCards], ); @@ -813,40 +789,81 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } color: card.color, parentId: null, }); - setCardContextMenu(null); }, [repository, docId], ); - // Context menu for card - const handleCardContextMenu = useCallback((e: React.MouseEvent, card: BoardCardData) => { - setCardContextMenu({ - position: { x: e.clientX, y: e.clientY }, - card, - }); - }, []); - - // Context menu for arrow - const handleArrowContextMenu = useCallback((e: React.MouseEvent, arrow: BoardArrowData) => { - e.preventDefault(); - e.stopPropagation(); - setArrowContextMenu({ - position: { x: e.clientX, y: e.clientY }, - arrow, - }); - }, []); - // Delete arrow const handleDeleteArrow = useCallback( (id: string) => { const newArrows = arrows.filter((a) => a.id !== id); setArrows(newArrows); saveArrows(newArrows); - setArrowContextMenu(null); }, [arrows, saveArrows], ); + // Open the shared context-menu host for a card. + const handleCardContextMenu = useCallback( + (e: React.MouseEvent, card: BoardCardData) => { + updateContextMenu({ + position: { x: e.clientX, y: e.clientY }, + content: ( + <> + {/* Color applies to text + audio notes; image cards have none. */} + {card.type !== "image" && ( + <> + handleChangeCardColor(card.id, color)} + /> + + + )} + handleDuplicateCard(card)} + /> + {(card.type ?? "text") === "text" && ( + handleSendToOutline(card)} + /> + )} + handleDeleteCard(card.id)} + /> + + ), + }); + }, + [updateContextMenu, t, handleChangeCardColor, handleDuplicateCard, handleSendToOutline, handleDeleteCard], + ); + + // Open the shared context-menu host for an arrow. + const handleArrowContextMenu = useCallback( + (e: React.MouseEvent, arrow: BoardArrowData) => { + e.preventDefault(); + e.stopPropagation(); + updateContextMenu({ + position: { x: e.clientX, y: e.clientY }, + content: ( + handleDeleteArrow(arrow.id)} + /> + ), + }); + }, + [updateContextMenu, t, handleDeleteArrow], + ); + // Get connection point position for a card const getConnectionPoint = useCallback( (card: BoardCardData, side: "top" | "right" | "bottom" | "left") => { @@ -1189,80 +1206,6 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } )} - {/* Card Context Menu */} - {cardContextMenu && ( -
- {/* Color applies to text + audio notes; image cards have none. */} - {cardContextMenu.card.type !== "image" && ( -
- {DEFAULT_ITEM_COLORS.map((color) => ( -
- )} -
handleDuplicateCard(cardContextMenu.card)} - > - -

{t("duplicate")}

-
- {(cardContextMenu.card.type ?? "text") === "text" && ( -
handleSendToOutline(cardContextMenu.card)} - > - -

{t("sendToOutline")}

-
- )} -
handleDeleteCard(cardContextMenu.card.id)} - > - -

{t("delete")}

-
-
- )} - - {/* Canvas Context Menu (empty-area right-click) */} - {canvasContextMenu && ( -
-
- -

{t("createCard")}

-
-
- -

{t("recordAudio")}

-
-
- )} - {/* Recording indicator */} {recorder.isRecording && (
@@ -1277,25 +1220,6 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
)} - {/* Arrow Context Menu */} - {arrowContextMenu && ( -
-
handleDeleteArrow(arrowContextMenu.arrow.id)} - > - -

{t("delete")}

-
-
- )} -
+ +
+ )} + + + + {isEditing ? ( + <> +