diff --git a/components/board/BoardCanvas.module.css b/components/board/BoardCanvas.module.css index e943ad0e..24174924 100644 --- a/components/board/BoardCanvas.module.css +++ b/components/board/BoardCanvas.module.css @@ -17,7 +17,13 @@ pointer-events: none; background: linear-gradient(to bottom, var(--editor-shadow) 0%, transparent 100%); mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%); - -webkit-mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%); + -webkit-mask-image: linear-gradient( + to right, + transparent 0%, + black 20%, + black 80%, + transparent 100% + ); } .container { @@ -32,6 +38,12 @@ cursor: grabbing; } +/* Highlight the canvas while an image file is dragged over it. */ +.container.drag_over { + outline: 2px dashed var(--secondary-text); + outline-offset: -8px; +} + .grid { position: absolute; inset: 0; @@ -58,8 +70,6 @@ position: absolute; display: flex; flex-direction: column; - border-radius: 8px; - border: 8px solid; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); cursor: move; user-select: none; @@ -76,6 +86,147 @@ z-index: 1000; } +/* Image resource card: bare image, no colored header/border chrome. */ +.card.image_card { + border: 1px solid rgba(0, 0, 0, 0.12); + background-color: var(--main-bg); + padding: 0; + overflow: hidden; +} + +.card_image { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + pointer-events: none; + user-select: none; +} + +.card_image_placeholder { + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.05); +} + +/* Audio voice-note card: a colored header (title) over a play/timeline row. */ +.card.audio_card { + overflow: hidden; +} + +.audio_content { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + padding: 0 12px; + overflow: hidden; +} + +/* Remaining-time readout next to the play button. */ +.audio_time { + flex-shrink: 0; + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--board-card-body-text); + user-select: none; +} + +/* Seekable timeline. The card content is always a light tint, so the track uses + theme greys (--separator / --secondary-text) that read across every theme. + `--audio-progress` (set from JS) fills the track up to the cursor on WebKit; + Firefox uses its native ::-moz-range-progress. */ +.audio_timeline { + flex: 1; + min-width: 0; + height: 16px; + margin: 0; + padding: 0; + cursor: pointer; + background: transparent; + -webkit-appearance: none; + appearance: none; + --audio-progress: 0%; +} + +.audio_timeline::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: linear-gradient( + to right, + var(--secondary-text) var(--audio-progress), + var(--separator) var(--audio-progress) + ); +} + +.audio_timeline::-moz-range-track { + height: 4px; + border-radius: 999px; + background: var(--separator); +} + +.audio_timeline::-moz-range-progress { + height: 4px; + border-radius: 999px; + background: var(--secondary-text); +} + +.audio_timeline::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + box-sizing: border-box; + width: 12px; + height: 12px; + margin-top: -4px; + border: 2px solid var(--secondary); + border-radius: 50%; + background: var(--secondary-text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); + transition: transform 0.1s ease; +} + +.audio_timeline::-moz-range-thumb { + box-sizing: border-box; + width: 12px; + height: 12px; + border: 2px solid var(--secondary); + border-radius: 50%; + background: var(--secondary-text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); +} + +.audio_timeline:hover:not(:disabled)::-webkit-slider-thumb { + transform: scale(1.15); +} + +.audio_timeline:disabled { + cursor: default; + opacity: 0.5; +} + +.audio_play_btn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background-color: var(--secondary); + color: var(--secondary-text); + cursor: pointer; +} + +.audio_play_btn:hover:not(:disabled) { + background-color: var(--secondary-hover); +} + +.audio_play_btn:disabled { + opacity: 0.4; + cursor: default; +} + .card_header { position: relative; display: flex; @@ -257,6 +408,72 @@ 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; + top: 8px; + left: 180px; + z-index: 11; + display: flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 10px; + border-radius: 16px; + background-color: var(--secondary); + color: var(--secondary-text); + user-select: none; +} + +.recording_dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #e5484d; + animation: recording_pulse 1s ease-in-out infinite; +} + +@keyframes recording_pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +.recording_time { + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.recording_stop { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: none; + border-radius: 12px; + background-color: #e5484d; + color: white; + font-size: 13px; + cursor: pointer; +} + +.recording_stop:hover { + background-color: #d33b40; +} + .context_menu_colors { display: flex; flex-wrap: wrap; diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index ad1ed280..cd187e44 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -6,13 +6,33 @@ import { BoardCardData, BoardArrowData } from "@src/lib/project/project-state"; import BoardCard from "./BoardCard"; import styles from "./BoardCanvas.module.css"; import { v7 as uuidv7 } from "uuid"; -import { Trash2, Plus, Minus, Copy, ListTree } from "lucide-react"; +import { Trash2, Plus, Minus, Copy, ListTree, Mic, Square } from "lucide-react"; import { useTranslations } from "next-intl"; import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; +import { importImageFile, importAudioFile } from "@src/lib/assets/asset-store"; +import { scheduleAssetGc } from "@src/lib/assets/asset-gc"; +import { useAudioRecorder } from "./use-audio-recorder"; const GRID_SIZE = 20; const MIN_SCALE = 0.25; const MAX_SCALE = 2; +/** Largest edge (in canvas px) an image card is sized to on first drop. */ +const MAX_IMAGE_CARD_SIZE = 400; +/** Default size (in canvas px) of an audio voice-note card. */ +const AUDIO_CARD_WIDTH = 260; +const AUDIO_CARD_HEIGHT = 96; + +/** A random swatch from the default palette (used for new colored cards). */ +function randomCardColor(): string { + return DEFAULT_ITEM_COLORS[Math.floor(Math.random() * DEFAULT_ITEM_COLORS.length)]; +} + +/** Seconds → `m:ss` for the recording indicator. */ +function formatRecordingTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, "0")}`; +} interface CardContextMenuState { position: { x: number; y: number }; @@ -24,8 +44,16 @@ interface ArrowContextMenuState { 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 { repository, isYjsReady, isReadOnly, boardFocusCardId, setBoardFocusCardId } = + const { projectId, repository, isYjsReady, isReadOnly, boardFocusCardId, setBoardFocusCardId } = useContext(ProjectContext); const t = useTranslations("board"); const projectState = repository?.getState(); @@ -37,9 +65,12 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const [offset, setOffset] = useState({ x: 0, y: 0 }); const [scale, setScale] = useState(1); 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); @@ -47,10 +78,13 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } setIsSnapping(true); if (cardContextMenu) setCardContextMenu(null); if (arrowContextMenu) setArrowContextMenu(null); + if (canvasContextMenu) setCanvasContextMenu(null); } } const [isCameraReady, setIsCameraReady] = useState(false); - const [connectingFrom, setConnectingFrom] = useState<{ cardId: string; side: string } | null>(null); + const [connectingFrom, setConnectingFrom] = useState<{ cardId: string; side: string } | null>( + null, + ); const [connectingLine, setConnectingLine] = useState<{ x: number; y: number } | null>(null); const [selectedCardIds, setSelectedCardIds] = useState>(new Set()); const [selectionRect, setSelectionRect] = useState<{ @@ -132,7 +166,8 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const cardsData = boardMap.get("cards"); if (cardsData) { try { - const parsed = typeof cardsData === "string" ? JSON.parse(cardsData) : cardsData; + const parsed = + typeof cardsData === "string" ? JSON.parse(cardsData) : cardsData; setCards(parsed); // Center camera on first load @@ -163,7 +198,8 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const arrowsData = boardMap.get("arrows"); if (arrowsData) { try { - const parsed = typeof arrowsData === "string" ? JSON.parse(arrowsData) : arrowsData; + const parsed = + typeof arrowsData === "string" ? JSON.parse(arrowsData) : arrowsData; setArrows(parsed); } catch (e) { console.error("[BoardCanvas] Failed to parse arrows:", e); @@ -234,13 +270,14 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } 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, isVisible]); + }, [cardContextMenu, arrowContextMenu, canvasContextMenu, isVisible]); // Panning with middle-click const handlePanMouseDown = useCallback( @@ -325,6 +362,8 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const offsetRef = useRef(offset); const scaleRef = useRef(scale); const cardsRef = useRef(cards); + /** Canvas-space coords captured when recording starts, for the resulting card. */ + const recordCoords = useRef({ x: 0, y: 0 }); useEffect(() => { offsetRef.current = offset; }, [offset]); @@ -382,7 +421,12 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const cardRight = card.x + card.width; const cardBottom = card.y + card.height; - if (card.x < right && cardRight > left && card.y < bottom && cardBottom > top) { + if ( + card.x < right && + cardRight > left && + card.y < bottom && + cardBottom > top + ) { selected.add(card.id); } } @@ -476,13 +520,11 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const x = (e.clientX - rect.left - offset.x) / scale; const y = (e.clientY - rect.top - offset.y) / scale; - const randomColor = DEFAULT_ITEM_COLORS[Math.floor(Math.random() * DEFAULT_ITEM_COLORS.length)]; - const newCard: BoardCardData = { id: uuidv7(), title: "", description: "", - color: randomColor, + color: randomCardColor(), x: isSnapping ? Math.round(x / GRID_SIZE) * GRID_SIZE : x, y: isSnapping ? Math.round(y / GRID_SIZE) * GRID_SIZE : y, width: 450, @@ -496,6 +538,159 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } [cards, offset, scale, isSnapping, saveCards], ); + // Highlight the canvas while an OS file drag hovers over it. + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (isReadOnly) return; + if (!Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setIsDraggingFile(true); + }, + [isReadOnly], + ); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + // Ignore leave events fired when moving between the container's children. + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setIsDraggingFile(false); + }, []); + + // Drop image files → store each in IndexedDB (deduped) and drop an image + // card referencing its hash at the cursor. + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + setIsDraggingFile(false); + if (isReadOnly || !projectId) return; + + const files = Array.from(e.dataTransfer.files).filter( + (f) => f.type.startsWith("image/") || f.type.startsWith("audio/"), + ); + if (files.length === 0) return; + + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const dropX = (e.clientX - rect.left - offset.x) / scale; + const dropY = (e.clientY - rect.top - offset.y) / scale; + + const created: BoardCardData[] = []; + for (const file of files) { + const i = created.length; + try { + if (file.type.startsWith("audio/")) { + const { hash } = await importAudioFile(projectId, file); + created.push({ + id: uuidv7(), + type: "audio", + assetId: hash, + title: "", + description: "", + color: randomCardColor(), + x: dropX + i * 24, + y: dropY + i * 24, + width: AUDIO_CARD_WIDTH, + height: AUDIO_CARD_HEIGHT, + }); + continue; + } + const { hash, width, height } = await importImageFile(projectId, file); + const fit = Math.min(1, MAX_IMAGE_CARD_SIZE / Math.max(width, height, 1)); + created.push({ + id: uuidv7(), + type: "image", + assetId: hash, + title: "", + description: "", + color: "transparent", + x: dropX + i * 24, + y: dropY + i * 24, + width: Math.max(60, Math.round(width * fit)), + height: Math.max(60, Math.round(height * fit)), + }); + } catch (err) { + console.error("[BoardCanvas] Failed to import dropped file:", err); + } + } + if (created.length === 0) return; + + const newCards = [...cardsRef.current, ...created]; + setCards(newCards); + saveCards(newCards); + }, + [isReadOnly, projectId, offset, scale, saveCards], + ); + + // Right-clicking empty canvas opens a menu (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}`) || + target.closest(`.${styles.hints}`) + ) + return; + + 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, + }); + }, + [isReadOnly, offset, scale], + ); + + // 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]); + + // Stop recording, store the clip as an asset, and drop an audio card. + const handleStopRecording = useCallback(async () => { + const blob = await recorder.stop(); + if (!blob || !projectId) return; + try { + const { hash } = await importAudioFile(projectId, blob); + const { x, y } = recordCoords.current; + const newCard: BoardCardData = { + id: uuidv7(), + type: "audio", + assetId: hash, + title: "", + description: "", + color: randomCardColor(), + x, + y, + width: AUDIO_CARD_WIDTH, + height: AUDIO_CARD_HEIGHT, + }; + const newCards = [...cardsRef.current, newCard]; + setCards(newCards); + saveCards(newCards); + } catch (err) { + console.error("[BoardCanvas] Failed to store recording:", err); + } + }, [recorder, projectId, saveCards]); + // Update card (with multi-drag support) const handleUpdateCard = useCallback( (updatedCard: BoardCardData) => { @@ -507,11 +702,14 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const dy = updatedCard.y - oldCard.y; // Only apply multi-drag for position changes, not resize if (dx !== 0 || dy !== 0) { - const isResize = updatedCard.width !== oldCard.width || updatedCard.height !== oldCard.height; + const isResize = + updatedCard.width !== oldCard.width || + updatedCard.height !== oldCard.height; if (!isResize) { const newCards = cards.map((c) => { if (c.id === updatedCard.id) return updatedCard; - if (selectedCardIds.has(c.id)) return { ...c, x: c.x + dx, y: c.y + dy }; + if (selectedCardIds.has(c.id)) + return { ...c, x: c.x + dx, y: c.y + dy }; return c; }); setCards(newCards); @@ -540,8 +738,10 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } setArrows(newArrows); saveArrows(newArrows); setCardContextMenu(null); + // Deleting an image card may orphan its asset — reconcile (debounced). + if (projectId && projectState) scheduleAssetGc(projectId, projectState); }, - [cards, arrows, saveCards, saveArrows], + [cards, arrows, saveCards, saveArrows, projectId, projectState], ); // Change card color @@ -619,26 +819,32 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } ); // Get connection point position for a card - const getConnectionPoint = useCallback((card: BoardCardData, side: "top" | "right" | "bottom" | "left") => { - const centerX = card.x + card.width / 2; - const centerY = card.y + card.height / 2; - - switch (side) { - case "top": - return { x: centerX, y: card.y }; - case "right": - return { x: card.x + card.width, y: centerY }; - case "bottom": - return { x: centerX, y: card.y + card.height }; - case "left": - return { x: card.x, y: centerY }; - } - }, []); + const getConnectionPoint = useCallback( + (card: BoardCardData, side: "top" | "right" | "bottom" | "left") => { + const centerX = card.x + card.width / 2; + const centerY = card.y + card.height / 2; + + switch (side) { + case "top": + return { x: centerX, y: card.y }; + case "right": + return { x: card.x + card.width, y: centerY }; + case "bottom": + return { x: centerX, y: card.y + card.height }; + case "left": + return { x: card.x, y: centerY }; + } + }, + [], + ); // Calculate best connection points between two cards with perpendicular tangent directions const getArrowPoints = useCallback( (fromCard: BoardCardData, toCard: BoardCardData) => { - const fromCenter = { x: fromCard.x + fromCard.width / 2, y: fromCard.y + fromCard.height / 2 }; + const fromCenter = { + x: fromCard.x + fromCard.width / 2, + y: fromCard.y + fromCard.height / 2, + }; const toCenter = { x: toCard.x + toCard.width / 2, y: toCard.y + toCard.height / 2 }; const dx = toCenter.x - fromCenter.x; @@ -682,10 +888,13 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } ); // Handle starting a connection from a card - const handleStartConnection = useCallback((cardId: string, side: string, initialX: number, initialY: number) => { - setConnectingFrom({ cardId, side }); - setConnectingLine({ x: initialX, y: initialY }); - }, []); + const handleStartConnection = useCallback( + (cardId: string, side: string, initialX: number, initialY: number) => { + setConnectingFrom({ cardId, side }); + setConnectingLine({ x: initialX, y: initialY }); + }, + [], + ); // Handle mouse move while connecting const handleConnectionMouseMove = useCallback( @@ -769,10 +978,14 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
@@ -793,7 +1006,10 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const points = getArrowPoints(fromCard, toCard); // Calculate distance for control point offset (perpendicular to border) - const dist = Math.hypot(points.to.x - points.from.x, points.to.y - points.from.y); + const dist = Math.hypot( + points.to.x - points.from.x, + points.to.y - points.from.y, + ); const controlDist = Math.max(50, dist * 0.4); // Control points extend perpendicular to the borders @@ -809,10 +1025,22 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } // Arrowhead points matching original marker shape: M 0 0 L 12 4 L 0 8 L 3 4 Z // Back corners (perpendicular to arrow direction) - const ax1 = points.to.x - arrowLength * Math.cos(angle) + arrowWidth * Math.sin(angle); - const ay1 = points.to.y - arrowLength * Math.sin(angle) - arrowWidth * Math.cos(angle); - const ax2 = points.to.x - arrowLength * Math.cos(angle) - arrowWidth * Math.sin(angle); - const ay2 = points.to.y - arrowLength * Math.sin(angle) + arrowWidth * Math.cos(angle); + const ax1 = + points.to.x - + arrowLength * Math.cos(angle) + + arrowWidth * Math.sin(angle); + const ay1 = + points.to.y - + arrowLength * Math.sin(angle) - + arrowWidth * Math.cos(angle); + const ax2 = + points.to.x - + arrowLength * Math.cos(angle) - + arrowWidth * Math.sin(angle); + const ay2 = + points.to.y - + arrowLength * Math.sin(angle) + + arrowWidth * Math.cos(angle); // Inner notch (25% from back toward tip) const notchDepth = arrowLength * 0.75; const axm = points.to.x - notchDepth * Math.cos(angle); @@ -843,7 +1071,11 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } strokeWidth={2.5} /> {/* Arrowhead */} - + ); })} @@ -874,7 +1106,10 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } } const fromPoint = getConnectionPoint(fromCard, fromSide); - const dist = Math.hypot(connectingLine.x - fromPoint.x, connectingLine.y - fromPoint.y); + const dist = Math.hypot( + connectingLine.x - fromPoint.x, + connectingLine.y - fromPoint.y, + ); const controlDist = Math.max(30, dist * 0.3); const cx = fromPoint.x + fromDir.x * controlDist; @@ -899,6 +1134,7 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } -
- {DEFAULT_ITEM_COLORS.map((color) => ( -
+ {/* Color applies to text + audio notes; image cards have none. */} + {cardContextMenu.card.type !== "image" && ( +
+ {DEFAULT_ITEM_COLORS.map((color) => ( +
+ )}
handleDuplicateCard(cardContextMenu.card)} @@ -951,13 +1192,15 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }

{t("duplicate")}

-
handleSendToOutline(cardContextMenu.card)} - > - -

{t("sendToOutline")}

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

{t("sendToOutline")}

+
+ )}
handleDeleteCard(cardContextMenu.card.id)} @@ -968,6 +1211,40 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
)} + {/* Canvas Context Menu (empty-area right-click) */} + {canvasContextMenu && ( +
+
+ +

{t("recordAudio")}

+
+
+ )} + + {/* Recording indicator */} + {recorder.isRecording && ( +
+ + + {formatRecordingTime(recorder.elapsed)} + + +
+ )} + {/* Arrow Context Menu */} {arrowContextMenu && (
parts.filter(Boolean).join(" "); + +/** The card kind, defaulting legacy/undefined cards to plain text notes. */ +type CardKind = NonNullable; +const kindOf = (card: BoardCardData): CardKind => card.type ?? "text"; + +/** Smallest height (canvas px) a card can be resized to, by kind. */ +const minHeightFor = (kind: CardKind) => (kind === "audio" ? 76 : 100); + +/** Seconds → `m:ss` (clamped at 0), for the audio timer. */ +function formatTime(seconds: number): string { + const total = Math.max(0, Math.floor(seconds || 0)); + const m = Math.floor(total / 60); + const s = total % 60; + return `${m}:${s.toString().padStart(2, "0")}`; +} + +/** + * Inline color for the card chrome. Images are bare; text and audio notes carry + * a colored header/border like every other card. + */ +function cardColorStyle(card: BoardCardData): React.CSSProperties { + if (kindOf(card) === "image") return {}; + return { borderColor: card.color, backgroundColor: card.color }; +} + +// ── Shared inline-editable title ───────────────────────────────────────────── + +interface TitleEditing { + editing: boolean; + value: string; + onChange: (value: string) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onStartEdit: (e: React.MouseEvent) => void; +} + +interface EditableTitleProps extends TitleEditing { + /** Text shown when not editing (e.g. the title or a placeholder hint). */ + display: string; + placeholder: string; + inputClassName: string; + labelClassName: string; + /** Optional tooltip on the read-only label. */ + labelTitle?: string; +} + +const EditableTitle = ({ + editing, + value, + display, + placeholder, + inputClassName, + labelClassName, + labelTitle, + onChange, + onBlur, + onKeyDown, + onStartEdit, +}: EditableTitleProps) => + editing ? ( + onChange(e.target.value)} + onBlur={onBlur} + onKeyDown={onKeyDown} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + placeholder={placeholder} + autoFocus + /> + ) : ( + + {display} + + ); + +// ── Card body variants ─────────────────────────────────────────────────────── + +const ImageBody = ({ projectId, card }: { projectId: string; card: BoardCardData }) => { + const imageUrl = useAssetUrl(projectId, card.assetId); + if (!imageUrl) return
; + // Blob object URLs can't be optimized by next/image; a plain is correct here. + // eslint-disable-next-line @next/next/no-img-element + return ; +}; + +const AudioBody = ({ + projectId, + card, + title, +}: { + projectId: string; + card: BoardCardData; + title: TitleEditing; +}) => { + const t = useTranslations("board"); + const assetUrl = useAssetUrl(projectId, card.assetId); + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [duration, setDuration] = useState(0); + const [currentTime, setCurrentTime] = useState(0); + // MediaRecorder blobs report duration as Infinity until forced to seek past + // the end; this flag drives the one-shot fix-up below. + const fixDuration = useRef(false); + + const togglePlay = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) void audio.play(); + else audio.pause(); + }, []); + + const handleLoadedMetadata = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + if (isFinite(audio.duration)) { + setDuration(audio.duration); + } else { + fixDuration.current = true; + audio.currentTime = 1e7; // nudge the browser to compute real duration + } + }, []); + + const handleTimeUpdate = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + if (fixDuration.current) { + fixDuration.current = false; + setDuration(isFinite(audio.duration) ? audio.duration : 0); + audio.currentTime = 0; + setCurrentTime(0); + return; + } + setCurrentTime(audio.currentTime); + }, []); + + const handleSeek = useCallback((e: React.ChangeEvent) => { + const value = Number(e.target.value); + if (audioRef.current) audioRef.current.currentTime = value; + setCurrentTime(value); + }, []); + + return ( + <> +
+ +
+ +
+ + + {formatTime(duration ? duration - currentTime : 0)} + + e.stopPropagation()} + disabled={!assetUrl || !duration} + style={ + { + "--audio-progress": `${duration ? (Math.min(currentTime, duration) / duration) * 100 : 0}%`, + } as React.CSSProperties + } + /> + {assetUrl && ( +
+ + ); +}; + +interface DescriptionEditing { + editing: boolean; + value: string; + onChange: (value: string) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; +} + +const TextBody = ({ + card, + title, + description, + onStartEditDescription, +}: { + card: BoardCardData; + title: TitleEditing; + description: DescriptionEditing; + onStartEditDescription: (e: React.MouseEvent) => void; +}) => { + const t = useTranslations("board"); + return ( + <> +
+ +
+ +
+

+ {card.description} +

+ {description.editing && ( +