diff --git a/components/board/BoardArrows.tsx b/components/board/BoardArrows.tsx new file mode 100644 index 00000000..f638de4d --- /dev/null +++ b/components/board/BoardArrows.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useMemo } from "react"; +import { BoardArrowData, BoardCardData } from "@src/lib/project/project-state"; +import styles from "./BoardCanvas.module.css"; +import { buildArrowPath, buildConnectingPath, Point } from "./board-geometry"; + +type BoardArrowsProps = { + cards: BoardCardData[]; + arrows: BoardArrowData[]; + /** The cut tool is armed: widen the hitboxes and let clicks sever links. */ + cutMode: boolean; + /** Card a link is currently being dragged out of, and where the pointer is. */ + connectingFromCardId: string | null; + connectingLine: Point | null; + onArrowContextMenu: (e: React.MouseEvent, arrow: BoardArrowData) => void; + onCutArrow: (id: string) => void; +}; + +/** The links between cards, drawn under them. */ +const BoardArrows = ({ + cards, + arrows, + cutMode, + connectingFromCardId, + connectingLine, + onArrowContextMenu, + onCutArrow, +}: BoardArrowsProps) => { + const cardsById = useMemo(() => new Map(cards.map((c) => [c.id, c])), [cards]); + const connectingFromCard = connectingFromCardId ? cardsById.get(connectingFromCardId) : null; + + return ( + + {arrows.map((arrow) => { + const fromCard = cardsById.get(arrow.fromCardId); + const toCard = cardsById.get(arrow.toCardId); + if (!fromCard || !toCard) return null; + + const { pathD, arrowheadD } = buildArrowPath(fromCard, toCard); + + return ( + + {/* Invisible hitbox for easier clicking. Also what the cut + tool hit-tests against, hence the id (see cutArrowAt). */} + onArrowContextMenu(e, arrow)} + // Trackpad/mouse counterpart of the slash — an iPad + // reports a coarse pointer with a Magic Keyboard + // attached, so the tool has to answer to both. + onMouseDown={ + cutMode + ? (e) => { + e.stopPropagation(); + onCutArrow(arrow.id); + } + : undefined + } + /> + + + + ); + })} + + {/* Pending link, following the pointer */} + {connectingFromCard && connectingLine && ( + + )} + + ); +}; + +export default BoardArrows; diff --git a/components/board/BoardCanvas.module.css b/components/board/BoardCanvas.module.css index fa847513..f89dcd47 100644 --- a/components/board/BoardCanvas.module.css +++ b/components/board/BoardCanvas.module.css @@ -400,6 +400,76 @@ opacity: 1; } +/* ── Resize tool armed ────────────────────────────────────────────────────── + The corner stops being a mark you have to know about and becomes a button: an + 88px round chip straddling the card's bottom-right corner, in the same surface + as the toolbar pill that armed it, wearing the same corner glyph the card + carries at rest, just scaled up. + + Centred *on* the corner rather than tucked inside it. Half the button hanging + past the card is what makes it read as a grip on that corner rather than as + decoration in it, it keeps the card's own content clear, and it doubles the + room around the corner a finger can land in. Everything here is deliberately + larger than the rest of the board's chrome: at rest this is an 8px chevron a + mouse hovers into, and even at the invisible 40px box it grows to on a finger, + resizing on a tablet means aiming at something you cannot see. */ +.resize_mode .card { + /* Image and audio cards clip their content, which would cut the grip in half. + Safe to lift for the duration: the image is object-fit:contain at 100% and + cannot spill, and the audio row clips itself (.audio_content). */ + overflow: visible; +} + +.resize_mode .card_resize_handle { + opacity: 1; + width: 112px; + height: 112px; + /* Half the box past each edge, so its centre lands on the card's corner. */ + bottom: -56px; + right: -56px; +} + +/* The chip. On ::before so it paints under ::after, which draws the glyph — the + two pseudo-elements stack in that order on their own, no z-index needed. + + Wears the same surface as the toolbar pill it was armed from (--secondary over + --separator), which is also what carries it across themes. The card underneath + is a colour of the user's choosing, so the chip has to be its own opaque + surface rather than tinting with what it sits on. */ +.resize_mode .card_resize_handle::before { + content: ""; + position: absolute; + inset: 12px; + border-radius: 50%; + background: var(--secondary); + border: 1.5px solid var(--separator); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3); +} + +/* The corner glyph, at twice its resting size and in --primary-text so it reads + at full contrast on that surface (the resting mark is --secondary-text, which + is the right weight for something meant to stay quiet, and the wrong one here). + Offset 46px rather than the 40px that would centre its box: the two strokes sit + along the box's bottom and right edges, so the L's centre of mass is ~10px + nearer the corner than the box's own centre, and the box has to sit that much + further out for the mark to look centred in the circle. */ +.resize_mode .card_resize_handle::after { + bottom: 46px; + right: 46px; + width: 32px; + height: 32px; + border-right: 5px solid var(--primary-text); + border-bottom: 5px solid var(--primary-text); + border-bottom-right-radius: 6px; +} + +/* The connect node is only in the way while resizing, and its own 40px box would + eat touches meant for the grip on a small card. */ +.resize_mode .connection_handle { + opacity: 0; + pointer-events: none; +} + /* Color picker - horizontal bar below header */ .color_picker { display: flex; @@ -551,6 +621,121 @@ color: var(--secondary-text); } +/* Link / cut tools, the third pill in the toolbar row. Rendered on a coarse + pointer only (see BoardCanvas.tsx), so unlike the zoom pill this is sized for a + finger from the start rather than growing into it in a media query. + + Anchored here for the phone, where pinch replaces the zoom buttons and this + takes the slot they would have had — 4px right of the panel-switcher handles + (28px each + a 4px gap from left:8 = 68px). The tablet block below moves it + right of the zoom pill instead. */ +.tool_controls { + position: absolute; + top: 8px; + left: 72px; + z-index: 11; + display: flex; + align-items: center; + gap: 2px; + height: 44px; + padding: 0 4px; + border-radius: 22px; + background-color: var(--secondary); + color: var(--secondary-text); + user-select: none; +} + +.tool_btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 18px; + background: transparent; + color: var(--secondary-text); + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +/* Armed. Matches the panel menu's active row (PanelMenu.module.css) rather than the + accent colour, so a held-down tool reads as the same kind of state as every + other toggle in the app. */ +.tool_btn_active { + background-color: var(--secondary-hover); + color: var(--primary-text); +} + +/* ── Toolbar row geometry on a finger ─────────────────────────────────────── + Everything above is anchored off the desktop panel-switcher handles: 20x36 + each, so two of them plus a 4px gap from left:8 end at 52px. On a coarse + pointer those handles grow to 28x44 (SplitPanelContainer.module.css), which + pushes their right edge to 68px — over the top of the zoom pill's left:56px + anchor — and leaves every pill in the row 8px shorter than they are. + + So re-derive the row from the touch sizes. Kept below the rules it overrides, + since a media query adds no specificity and source order is all that separates + them. */ +@media (pointer: coarse) { + /* Onto a second row, rather than further along the first: the tools pill + already runs to 192px on a phone and to 306px on a tablet, and a fourth + pill after that would hang off the right edge of a narrow split panel. + 60px clears the 44px row above by 8px. */ + .recording_indicator { + top: 60px; + left: 72px; + height: 44px; + border-radius: 22px; + } +} + +@media (pointer: coarse) and (min-width: 768px) { + /* 4px right of the handles' new 68px edge, and squared off against them. */ + .zoom_controls { + left: 72px; + height: 44px; + border-radius: 22px; + } + + /* Grown with the pill, so the buttons stay finger-sized inside it rather + than floating in 8px of new dead space. */ + .zoom_btn { + width: 30px; + height: 36px; + border-radius: 18px; + } + + /* Right of the zoom pill, on the same 4px gap: the pill runs 72 → 182 + (4 + 30 + 2 + 38 + 2 + 30 + 4). */ + .tool_controls { + left: 186px; + } +} + +/* The armed tool's instruction, along the bottom edge — the far side of the + board from the toolbar row that armed it, and a corner nothing else claims + (the editor's floating bottom bar doesn't render over a board at all, see + EditorBottomBar). */ +.tool_hint { + position: absolute; + bottom: calc(8px + var(--safe-bottom)); + left: 50%; + transform: translateX(-50%); + z-index: 11; + max-width: 80%; + padding: 8px 14px; + border-radius: 16px; + background-color: var(--secondary); + color: var(--secondary-text); + font-size: 13px; + text-align: center; + pointer-events: none; + user-select: none; +} + /* Arrows SVG layer */ .arrows_svg { position: absolute; @@ -591,6 +776,23 @@ pointer-events: none; } +/* Cut tool armed: every link turns red and grows a far fatter hitbox. The stroke + is in canvas units, so it shrinks with the zoom just as the line does — 36 + holds a fingertip down to roughly 50% zoom, and the tool is a tap *or* a slash + precisely because a stroke crosses links a tap has to be aimed at. */ +.arrow_group_cut .arrow_line { + stroke: var(--error); +} + +.arrow_group_cut .arrow_head { + fill: var(--error); +} + +.arrow_group_cut .arrow_hitbox { + stroke-width: 36; + cursor: crosshair; +} + /* Single connection handle on cards */ .connection_handle { position: absolute; @@ -642,6 +844,15 @@ 0 4px 16px rgba(0, 0, 0, 0.2); } +/* Link tool: the card tapped first, held until its target is picked. A ring + rather than the selection highlight, so a card that is both selected and the + pending link end still reads as the link end. */ +.card_link_source { + box-shadow: + 0 0 0 3px var(--primary-hover), + 0 4px 16px rgba(0, 0, 0, 0.2); +} + /* Selection rectangle */ .selection_rect { position: absolute; diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index bb00850f..54a28050 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -1,211 +1,152 @@ "use client"; -import { useContext, useRef, useState, useCallback, useEffect, useMemo } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { ProjectContext } from "@src/context/ProjectContext"; -import { UserContext } from "@src/context/UserContext"; -import { BoardCardData, BoardArrowData, TimelineLayer } from "@src/lib/project/project-state"; +import { BoardCardData } from "@src/lib/project/project-state"; +import { useIsPhone, useIsTouch } from "@src/lib/utils/hooks"; +import BoardArrows from "./BoardArrows"; import BoardCard from "./BoardCard"; import { - ContextMenuItem, - ContextMenuSeparator, - ContextMenuColorRow, - ContextMenuSubmenu, -} from "@components/utils/ContextMenu"; + BoardToolControls, + BoardToolHint, + BoardZoomControls, + RecordingIndicator, +} from "./BoardOverlays"; import styles from "./BoardCanvas.module.css"; -import { v7 as uuidv7 } from "uuid"; -import { Trash2, Plus, Minus, Copy, ListTree, Layers, Mic, Square, Image as ImageIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; -import { importImageFile, importAudioFile, syncAssetToCloud } from "@src/lib/assets/asset-store"; -import { CloudQuotaError } from "@src/lib/assets/cloud-asset-sync"; -import { scheduleAssetGc } from "@src/lib/assets/asset-gc"; -import { useIsPhone, useIsTouch } from "@src/lib/utils/hooks"; -import { useAudioRecorder } from "./use-audio-recorder"; +import { BoardTool, GRID_SIZE } from "./board-constants"; +import { useBoardMenus } from "./board-menus"; +import { useBoardAssets } from "./use-board-assets"; +import { useBoardCamera } from "./use-board-camera"; +import { useBoardCardActions } from "./use-board-card-actions"; +import { useBoardConnections } from "./use-board-connections"; +import { useBoardDocument } from "./use-board-document"; +import { useBoardSelection } from "./use-board-selection"; +import { useBoardTouch } from "./use-board-touch"; + +/** Delay before framing the board on load, to let the container lay out first. */ +const INITIAL_FRAME_DELAY = 50; -const GRID_SIZE = 20; -const MIN_SCALE = 0.25; -const MAX_SCALE = 2; /** - * Tile size (screen px) the grid's `background-size` is rounded to. Changing - * background-size is a paint op, so a continuous pinch would otherwise repaint - * a full-viewport gradient every frame; snapping to 4px steps means a full - * MIN_SCALE→MAX_SCALE sweep repaints under a dozen times total instead of once - * per frame, with no visible difference in dot spacing. + * The corkboard: a pannable, zoomable canvas of cards linked by arrows. + * + * Everything with state of its own lives in a hook beside this file — the + * camera, the Yjs-backed card/arrow document, selection, card actions, media + * imports, links, touch gestures, menus — and this component wires them + * together and lays them out. */ -const GRID_TILE_QUANTUM = 4; -/** 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")}`; -} - -const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) => { - const { projectId, repository, isYjsReady, isReadOnly, boardFocusCardId, setBoardFocusCardId, timelineLayers } = +const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }) => { + const { projectId, isReadOnly, boardFocusCardId, setBoardFocusCardId } = useContext(ProjectContext); - const { updateContextMenu } = useContext(UserContext); - const t = useTranslations("board"); - // Used only to name the default lanes when the board seeds them (below). - const tTimeline = useTranslations("timeline"); - const projectState = repository?.getState(); const containerRef = useRef(null); - const canvasRef = useRef(null); - - const [cards, setCards] = useState([]); - const [arrows, setArrows] = useState([]); - 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); - /** Transient banner shown when an asset can't be saved (e.g. cloud quota). */ - const [assetError, setAssetError] = useState(null); - const assetErrorTimer = useRef | null>(null); - const recorder = useAudioRecorder(); - const [prevIsVisible, setPrevIsVisible] = useState(isVisible); - if (prevIsVisible !== isVisible) { - setPrevIsVisible(isVisible); - if (!isVisible) setIsSnapping(true); - } - const [isCameraReady, setIsCameraReady] = useState(false); - 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<{ - startX: number; - startY: number; - endX: number; - endY: number; - } | null>(null); - const hasInitializedCamera = useRef(false); - /** Last `cards` payload this client wrote, to recognise the observer's echo. */ - const lastSavedCards = useRef(null); - const panStart = useRef({ x: 0, y: 0, offsetX: 0, offsetY: 0 }); - const selectionStart = useRef<{ x: number; y: number } | null>(null); - const isSelecting = useRef(false); - /** - * The container's viewport rect, captured once when a gesture starts. - * - * Every move handler needs it to map a pointer to canvas space, but reading - * it per frame is a `getBoundingClientRect()` on a document the board has - * just dirtied — a forced synchronous layout of *everything* still on - * screen (the navigation drawer's scene list, the timeline strip, the - * parked screenplay editor) on every single move event. The panel itself - * cannot move mid-gesture, so the rect taken at gesture start stays correct - * and the whole per-frame relayout goes away. - */ - const gestureRect = useRef(null); - const captureGestureRect = useCallback(() => { - const rect = containerRef.current?.getBoundingClientRect() ?? null; - gestureRect.current = rect; - return rect; - }, []); - - // ── Touch (mobile) state ────────────────────────────────────────────────── // isPhone gates *layout* (how much room the chrome has); isTouch gates the // *gestures* (pan/pinch/long-press), which a tablet needs just as much as a // phone even though it renders the desktop layout. const isPhone = useIsPhone(); const isTouch = useIsTouch(); - const imageInputRef = useRef(null); - const imageImportCoords = useRef({ x: 0, y: 0 }); - const gesture = useRef<{ - mode: "none" | "pan" | "pinch"; - startX: number; - startY: number; - startOffset: { x: number; y: number }; - startDist: number; - startScale: number; - pinchCanvasX: number; - pinchCanvasY: number; - moved: boolean; - }>({ - mode: "none", - startX: 0, - startY: 0, - startOffset: { x: 0, y: 0 }, - startDist: 0, - startScale: 1, - pinchCanvasX: 0, - pinchCanvasY: 0, - moved: false, - }); - const longPressTimer = useRef | null>(null); - const lastTap = useRef({ time: 0, x: 0, y: 0 }); - const lastTouchPoint = useRef({ x: 0, y: 0 }); - - // Timestamp of the most recent touch activity, used to ignore the mouse - // events WebKit synthesizes at the end of a touch gesture. - // - // The mouse handlers stay attached even on touch devices, because an iPad - // reports `pointer: coarse` whether or not a trackpad is attached — dropping - // them would leave Magic Keyboard users unable to pan or drag. Without this - // guard a one-finger pan would also fire the synthesized mousedown and start - // a marquee selection on top of the pan. Refreshed on every touch event (not - // just touchstart) so a long drag doesn't age out of the window mid-gesture. - const lastTouch = useRef(0); - const isSyntheticMouse = () => Date.now() - lastTouch.current < 700; - - // Center camera to fit all cards - const centerCameraOnCards = useCallback((cardsToFit: BoardCardData[]) => { - const container = containerRef.current; - if (!container || cardsToFit.length === 0) return; - // Calculate bounding box of all cards - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - for (const card of cardsToFit) { - minX = Math.min(minX, card.x); - minY = Math.min(minY, card.y); - maxX = Math.max(maxX, card.x + card.width); - maxY = Math.max(maxY, card.y + card.height); - } - - // Add padding around the bounding box - const padding = 100; - minX -= padding; - minY -= padding; - maxX += padding; - maxY += padding; + const [tool, setTool] = useState("select"); + /** Grid snapping, held off while Shift is down. */ + const [isSnapping, setIsSnapping] = useState(true); + /** The board stays hidden until its camera is placed, to avoid a jump on open. */ + const [isCameraReady, setIsCameraReady] = useState(false); - const boundsWidth = maxX - minX; - const boundsHeight = maxY - minY; - const boundsCenterX = (minX + maxX) / 2; - const boundsCenterY = (minY + maxY) / 2; + const [prevIsVisible, setPrevIsVisible] = useState(isVisible); + if (prevIsVisible !== isVisible) { + setPrevIsVisible(isVisible); + if (!isVisible) setIsSnapping(true); + } - const rect = container.getBoundingClientRect(); - const viewportWidth = rect.width; - const viewportHeight = rect.height; + const camera = useBoardCamera(containerRef); + const { + offset, + scale, + gridPattern, + isPanning, + centerCameraOnCards, + toCanvasPoint, + handlePanMouseDown, + zoomFromCenter, + } = camera; + + // Frame the board on the cards it opened with, then reveal it. + const handleFirstLoad = useCallback( + (loaded: BoardCardData[]) => { + if (loaded.length === 0) { + setIsCameraReady(true); + return; + } + setTimeout(() => { + centerCameraOnCards(loaded); + setIsCameraReady(true); + }, INITIAL_FRAME_DELAY); + }, + [centerCameraOnCards], + ); - // Calculate scale to fit bounds in viewport - const scaleX = viewportWidth / boundsWidth; - const scaleY = viewportHeight / boundsHeight; - const newScale = Math.min(Math.max(Math.min(scaleX, scaleY), MIN_SCALE), MAX_SCALE); + const doc = useBoardDocument(docId, handleFirstLoad); + const { cards, arrows, removeArrow } = doc; - // Calculate offset to center the bounds - const newOffsetX = viewportWidth / 2 - boundsCenterX * newScale; - const newOffsetY = viewportHeight / 2 - boundsCenterY * newScale; + const selection = useBoardSelection(camera, doc.getCards); + const { selectedCardIds, clearSelection, selectionRect, handleSelectionMouseDown } = selection; - setScale(newScale); - setOffset({ x: newOffsetX, y: newOffsetY }); - }, []); + const cardActions = useBoardCardActions(doc, { + docId, + isSnapping, + selectedCardIds, + clearSelection, + }); + const { createCard, updateCard } = cardActions; + + const { + recorder, + assetError, + isDraggingFile, + handleDragOver, + handleDragLeave, + handleDrop, + setImageInput, + handleImageInputChange, + openImagePicker, + startRecording, + stopRecording, + } = useBoardAssets(camera, cardActions); + + const { + connectingFrom, + connectingLine, + startConnection, + completeConnection, + linkSource, + clearLinkSource, + handleLinkTap, + cutArrowAt, + } = useBoardConnections(camera, doc); + + const { showCanvasMenu, handleCanvasContextMenu, showCardMenu, showArrowMenu } = useBoardMenus( + camera, + { + canRecord: recorder.isSupported, + createCard, + importImage: openImagePicker, + recordAudio: startRecording, + changeCardColor: cardActions.changeCardColor, + duplicateCard: cardActions.duplicateCard, + sendToTimeline: cardActions.sendToTimeline, + deleteCard: cardActions.deleteCard, + deleteArrow: removeArrow, + }, + ); + + const { handleTouchStart, handleTouchMove, handleTouchEnd, isSyntheticMouse } = useBoardTouch({ + camera, + tool, + isConnecting: !!connectingFrom, + onCutAt: cutArrowAt, + onLongPress: showCanvasMenu, + onDoubleTap: createCard, + onCancelLink: clearLinkSource, + }); // Focus a specific card when navigated to from the Timeline. Waits until the // board's cards have loaded and the target exists on this board, then centers @@ -218,163 +159,33 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) setBoardFocusCardId(null); }, [boardFocusCardId, isVisible, cards, centerCameraOnCards, setBoardFocusCardId]); - // Sync cards with Yjs - useEffect(() => { - if (!projectState || !isYjsReady) return; - - const boardMap = projectState.boardData(docId); - - const syncCards = () => { - const cardsData = boardMap.get("cards"); - // Y.Map observers fire for local writes too, so our own save echoes - // straight back. Re-parsing it would rebuild every card object and - // re-render the whole board a second time for a state it is already - // in — pure waste, and paid on every committed drag. (Arrows below - // are still synced: a peer may have touched those and nothing else.) - const isOwnEcho = - hasInitializedCamera.current && - typeof cardsData === "string" && - cardsData === lastSavedCards.current; - if (isOwnEcho) { - // nothing to apply: local state already is this payload - } else if (cardsData) { - try { - const parsed = - typeof cardsData === "string" ? JSON.parse(cardsData) : cardsData; - setCards(parsed); - - // Center camera on first load - if (!hasInitializedCamera.current) { - hasInitializedCamera.current = true; - if (parsed.length > 0) { - // Small delay to ensure container is rendered - setTimeout(() => { - centerCameraOnCards(parsed); - setIsCameraReady(true); - }, 50); - } else { - setIsCameraReady(true); - } - } - } catch (e) { - console.error("[BoardCanvas] Failed to parse cards:", e); - } - } else { - // No cards data yet, mark camera as ready - if (!hasInitializedCamera.current) { - hasInitializedCamera.current = true; - setIsCameraReady(true); - } - } - - // Sync arrows - const arrowsData = boardMap.get("arrows"); - if (arrowsData) { - try { - const parsed = - typeof arrowsData === "string" ? JSON.parse(arrowsData) : arrowsData; - setArrows(parsed); - } catch (e) { - console.error("[BoardCanvas] Failed to parse arrows:", e); - } - } - }; - - syncCards(); - boardMap.observe(syncCards); - - return () => { - boardMap.unobserve(syncCards); - }; - }, [projectState, isYjsReady, docId, centerCameraOnCards]); - - // Save cards to Yjs - const saveCards = useCallback( - (newCards: BoardCardData[]) => { - if (!projectState || !isYjsReady || isReadOnly) return; - const boardMap = projectState.boardData(docId); - const payload = JSON.stringify(newCards); - lastSavedCards.current = payload; // so the observer can skip its echo - boardMap.set("cards", payload); - }, - [projectState, isYjsReady, isReadOnly, docId], - ); - - // Save arrows to Yjs - const saveArrows = useCallback( - (newArrows: BoardArrowData[]) => { - if (!projectState || !isYjsReady || isReadOnly) return; - const boardMap = projectState.boardData(docId); - boardMap.set("arrows", JSON.stringify(newArrows)); - }, - [projectState, isYjsReady, isReadOnly, docId], - ); - - // Handle keyboard events for snapping + // Shift suspends grid snapping; Escape drops the selection. useEffect(() => { if (!isVisible) return; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Shift") { - setIsSnapping(false); - } - if (e.key === "Escape") { - setSelectedCardIds(new Set()); - } + if (e.key === "Shift") setIsSnapping(false); + if (e.key === "Escape") clearSelection(); }; - const handleKeyUp = (e: KeyboardEvent) => { - if (e.key === "Shift") { - setIsSnapping(true); - } + if (e.key === "Shift") setIsSnapping(true); }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); - return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; - }, [isVisible]); - - // Panning with middle-click - const handlePanMouseDown = useCallback( - (e: React.MouseEvent) => { - if (e.button !== 1) return; - e.preventDefault(); // Prevent autoscroll on middle-click - - setIsPanning(true); - panStart.current = { - x: e.clientX, - y: e.clientY, - offsetX: offset.x, - offsetY: offset.y, - }; - }, - [offset], - ); - - // Selection rectangle with left-click on empty canvas - const handleSelectionMouseDown = useCallback( - (e: React.MouseEvent) => { - 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("[data-context-menu]")) return; - - const rect = captureGestureRect(); - if (!rect) return; - - const canvasX = (e.clientX - rect.left - offset.x) / scale; - const canvasY = (e.clientY - rect.top - offset.y) / scale; + }, [isVisible, clearSelection]); - selectionStart.current = { x: canvasX, y: canvasY }; - isSelecting.current = true; - setSelectionRect({ startX: canvasX, startY: canvasY, endX: canvasX, endY: canvasY }); - setSelectedCardIds(new Set()); + /** Arm a tool, or disarm it when it is already the active one. */ + const selectTool = useCallback( + (next: BoardTool) => { + setTool((prev) => (prev === next ? "select" : next)); + clearLinkSource(); }, - [offset, scale, captureGestureRect], + [clearLinkSource], ); const handleContainerMouseDown = useCallback( @@ -383,1104 +194,38 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) handlePanMouseDown(e); handleSelectionMouseDown(e); }, - [handlePanMouseDown, handleSelectionMouseDown], + [isSyntheticMouse, handlePanMouseDown, handleSelectionMouseDown], ); - const handlePanMouseMove = useCallback( - (e: MouseEvent) => { - if (!isPanning) return; - - const dx = e.clientX - panStart.current.x; - const dy = e.clientY - panStart.current.y; - - setOffset({ - x: panStart.current.offsetX + dx, - y: panStart.current.offsetY + dy, - }); - }, - [isPanning], - ); - - const handlePanMouseUp = useCallback(() => { - setIsPanning(false); - }, []); - - useEffect(() => { - if (isPanning) { - window.addEventListener("mousemove", handlePanMouseMove); - window.addEventListener("mouseup", handlePanMouseUp); - return () => { - window.removeEventListener("mousemove", handlePanMouseMove); - window.removeEventListener("mouseup", handlePanMouseUp); - }; - } - }, [isPanning, handlePanMouseMove, handlePanMouseUp]); - - // Keep refs in sync for selection handlers to avoid stale closures - 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]); - useEffect(() => { - scaleRef.current = scale; - }, [scale]); - useEffect(() => { - cardsRef.current = cards; - }, [cards]); - - // Selection rectangle global listeners - useEffect(() => { - if (!selectionRect) return; - - const onMouseMove = (e: MouseEvent) => { - if (!isSelecting.current) return; - - const rect = gestureRect.current; - if (!rect) return; - - const currentOffset = offsetRef.current; - const currentScale = scaleRef.current; - const canvasX = (e.clientX - rect.left - currentOffset.x) / currentScale; - const canvasY = (e.clientY - rect.top - currentOffset.y) / currentScale; - - setSelectionRect((prev) => (prev ? { ...prev, endX: canvasX, endY: canvasY } : null)); - }; - - const onMouseUp = () => { - if (!isSelecting.current) { - isSelecting.current = false; - selectionStart.current = null; - gestureRect.current = null; - setSelectionRect(null); - return; - } - - setSelectionRect((currentRect) => { - if (!currentRect) return null; - - // Calculate normalized selection box - const left = Math.min(currentRect.startX, currentRect.endX); - const top = Math.min(currentRect.startY, currentRect.endY); - const right = Math.max(currentRect.startX, currentRect.endX); - const bottom = Math.max(currentRect.startY, currentRect.endY); - - // Only select if the rectangle has meaningful size (prevent click-only) - const width = right - left; - const height = bottom - top; - - if (width > 5 || height > 5) { - // Find cards that intersect the selection box - const selected = new Set(); - for (const card of cardsRef.current) { - const cardRight = card.x + card.width; - const cardBottom = card.y + card.height; - - if ( - card.x < right && - cardRight > left && - card.y < bottom && - cardBottom > top - ) { - selected.add(card.id); - } - } - setSelectedCardIds(selected); - } - - return null; // Clear the selection rect - }); - - isSelecting.current = false; - selectionStart.current = null; - gestureRect.current = null; - }; - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [selectionRect !== null]); // eslint-disable-line react-hooks/exhaustive-deps - - // Zoom with mouse wheel - centered on cursor. - // Attached as a native non-passive listener (see effect below) because React - // registers onWheel as passive, which makes preventDefault() a no-op and warns. - const handleWheel = useCallback( - (e: WheelEvent) => { - e.preventDefault(); - - const container = containerRef.current; - if (!container) return; - - const rect = container.getBoundingClientRect(); - const cursorX = e.clientX - rect.left; - const cursorY = e.clientY - rect.top; - - const delta = e.deltaY > 0 ? 0.9 : 1.1; - const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale * delta)); - - // Calculate the point in canvas space under the cursor - const canvasX = (cursorX - offset.x) / scale; - const canvasY = (cursorY - offset.y) / scale; - - // Calculate new offset so the same canvas point stays under cursor - const newOffsetX = cursorX - canvasX * newScale; - const newOffsetY = cursorY - canvasY * newScale; - - setScale(newScale); - setOffset({ x: newOffsetX, y: newOffsetY }); - }, - [scale, offset], - ); - - useEffect(() => { - const container = containerRef.current; - if (!container) return; - container.addEventListener("wheel", handleWheel, { passive: false }); - return () => container.removeEventListener("wheel", handleWheel); - }, [handleWheel]); - - // Zoom from buttons - centered on viewport - const zoomFromCenter = useCallback( - (zoomIn: boolean) => { - const container = containerRef.current; - if (!container) return; - - const rect = container.getBoundingClientRect(); - const centerX = rect.width / 2; - const centerY = rect.height / 2; - - const delta = zoomIn ? 1.2 : 0.8; - const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale * delta)); - - const canvasX = (centerX - offset.x) / scale; - const canvasY = (centerY - offset.y) / scale; - - const newOffsetX = centerX - canvasX * newScale; - const newOffsetY = centerY - canvasY * newScale; - - setScale(newScale); - setOffset({ x: newOffsetX, y: newOffsetY }); - }, - [scale, offset], - ); - - // Create new card on double-click + // Create a card on double-click. const handleDoubleClick = useCallback( (e: React.MouseEvent) => { - if (isSyntheticMouse()) return; // double-tap is handled in handleContainerTouchEnd + if (isSyntheticMouse()) return; // double-tap is handled by the touch hook e.preventDefault(); - if ((e.target as HTMLElement).closest(`.${styles.card}`)) return; - if ((e.target as HTMLElement).closest(`.${styles.zoom_controls}`)) return; - - // Clear selection when creating a new card - setSelectedCardIds(new Set()); - - const container = containerRef.current; - if (!container) return; - - const rect = container.getBoundingClientRect(); - const x = (e.clientX - rect.left - offset.x) / scale; - const y = (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 = [...cards, newCard]; - setCards(newCards); - saveCards(newCards); - }, - [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); - }, []); - - // Show a transient error banner (auto-dismissed). Used when an asset can't be - // persisted, e.g. the owner is out of cloud storage. - const showAssetError = useCallback((message: string) => { - setAssetError(message); - if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current); - assetErrorTimer.current = setTimeout(() => setAssetError(null), 4000); - }, []); - - useEffect(() => () => { - if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current); - }, []); - - // Remove cards by id (used to roll back a card whose asset can't be saved). - const removeCards = useCallback( - (ids: Set) => { - const next = cardsRef.current.filter((c) => !ids.has(c.id)); - cardsRef.current = next; // keep the ref current so concurrent removals don't race - setCards(next); - saveCards(next); - }, - [saveCards], - ); - - // Upload the new cards' assets to the cloud in the background, so the cards - // appear instantly (the bytes are already cached locally and render offline). - // If an upload is rejected for quota, roll back that card and explain why. - const syncCreatedAssets = useCallback( - (createdCards: BoardCardData[], pid: string) => { - for (const card of createdCards) { - if (!card.assetId) continue; - const cardId = card.id; - void syncAssetToCloud(pid, card.assetId).catch((err) => { - if (err instanceof CloudQuotaError) { - removeCards(new Set([cardId])); - showAssetError(t("storageLimitReached")); - } else { - console.error("[BoardCanvas] cloud asset upload failed:", err); - } - }); - } - }, - [removeCards, showAssetError, t], - ); - - // 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); - - // Upload to the cloud in the background (cards already show locally). - syncCreatedAssets(created, projectId); - }, - [isReadOnly, projectId, offset, scale, saveCards, syncCreatedAssets], - ); - - // 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 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); - }, - [isSnapping, saveCards], - ); - - // 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 () => { - 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); - - // Upload to the cloud in the background (card already shows locally). - syncCreatedAssets([newCard], projectId); - } catch (err) { - console.error("[BoardCanvas] Failed to store recording:", err); - } - }, [recorder, projectId, saveCards, syncCreatedAssets]); - - // Import an image file as a card at the given canvas-space coords. Shared by - // OS file drops and the "Import image" menu action (mobile has no drag-drop). - const addImageCard = useCallback( - async (file: File, x: number, y: number) => { - if (isReadOnly || !projectId) return; - try { - const { hash, width, height } = await importImageFile(projectId, file); - const fit = Math.min(1, MAX_IMAGE_CARD_SIZE / Math.max(width, height, 1)); - const newCard: BoardCardData = { - id: uuidv7(), - type: "image", - assetId: hash, - title: "", - description: "", - color: "transparent", - x, - y, - width: Math.max(60, Math.round(width * fit)), - height: Math.max(60, Math.round(height * fit)), - }; - const newCards = [...cardsRef.current, newCard]; - setCards(newCards); - saveCards(newCards); - syncCreatedAssets([newCard], projectId); - } catch (err) { - console.error("[BoardCanvas] Failed to import image:", err); - } - }, - [isReadOnly, projectId, saveCards, syncCreatedAssets], - ); - - const openImagePicker = useCallback((x: number, y: number) => { - imageImportCoords.current = { x, y }; - imageInputRef.current?.click(); - }, []); - - const handleImageInputChange = useCallback( - (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - e.target.value = ""; - if (file) { - const { x, y } = imageImportCoords.current; - void addImageCard(file, x, y); - } - }, - [addImageCard], - ); - - // Build the empty-canvas menu (create card / import image / record audio) at - // the given *screen* coords, resolving the canvas-space drop point via refs. - const showCanvasMenu = useCallback( - (screenX: number, screenY: number) => { - if (isReadOnly) return; - const container = containerRef.current; - if (!container) return; - const rect = container.getBoundingClientRect(); - const canvasX = (screenX - rect.left - offsetRef.current.x) / scaleRef.current; - const canvasY = (screenY - rect.top - offsetRef.current.y) / scaleRef.current; - updateContextMenu({ - position: { x: screenX, y: screenY }, - content: ( - <> - handleCreateCard(canvasX, canvasY)} - /> - openImagePicker(canvasX, canvasY)} - /> - handleStartRecording(canvasX, canvasY)} - disabled={!recorder.isSupported} - title={recorder.isSupported ? undefined : t("audioUnsupported")} - /> - - ), - }); - }, - [isReadOnly, updateContextMenu, t, handleCreateCard, openImagePicker, handleStartRecording, recorder.isSupported], - ); - - // Right-clicking empty canvas opens the menu. 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}`) - ) + if (target.closest(`.${styles.card}`) || target.closest(`.${styles.zoom_controls}`)) return; - e.preventDefault(); - showCanvasMenu(e.clientX, e.clientY); + const { x, y } = toCanvasPoint(e.clientX, e.clientY); + createCard(x, y); }, - [isReadOnly, showCanvasMenu], + [isSyntheticMouse, toCanvasPoint, createCard], ); - /** - * Update card (with multi-drag support). - * - * `transient` marks a frame of a live drag/resize. Those only move local - * state: writing to Yjs per frame stringifies the entire board, opens a - * transaction (which fans out to persistence and to every peer) and then - * echoes back through the observer — tens of times a second, for a position - * that is about to change again. The gesture's last update is sent without - * the flag and is what actually gets stored (see BoardCard's commit). - */ - const handleUpdateCard = useCallback( - (updatedCard: BoardCardData, options?: { transient?: boolean }) => { - const persist = (next: BoardCardData[]) => { - setCards(next); - cardsRef.current = next; // gesture commits read this back - if (!options?.transient) saveCards(next); - }; - // Read through the ref, not the `cards` state: a drag can emit - // several moves between two commits, and it also keeps this callback - // stable across frames (a new identity would re-render every card). - const current = cardsRef.current; - - if (selectedCardIds.has(updatedCard.id) && selectedCardIds.size > 1) { - // Multi-drag: apply same delta to all selected cards - const oldCard = current.find((c) => c.id === updatedCard.id); - if (oldCard) { - const dx = updatedCard.x - oldCard.x; - 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; - if (!isResize) { - persist( - current.map((c) => { - if (c.id === updatedCard.id) return updatedCard; - if (selectedCardIds.has(c.id)) - return { ...c, x: c.x + dx, y: c.y + dy }; - return c; - }), - ); - return; - } - } - } - } - // Single card update (existing logic) - persist(current.map((c) => (c.id === updatedCard.id ? updatedCard : c))); - }, - [selectedCardIds, saveCards], - ); - - // Card-menu actions below read through cardsRef rather than the `cards` - // state so their identity survives a drag's transient frames — BoardCard is - // memoised, and a fresh onContextMenu identity per frame would re-render - // every card on every frame of a drag. - - // Delete card (and connected arrows) - const handleDeleteCard = useCallback( + // Clicking a link with the cut tool armed severs it. The touch path already + // cut this one, so ignore its mouse echo. + const handleCutArrow = useCallback( (id: string) => { - removeCards(new Set([id])); - // Also delete arrows connected to this card - const newArrows = arrows.filter((a) => a.fromCardId !== id && a.toCardId !== id); - setArrows(newArrows); - saveArrows(newArrows); - // Deleting an image card may orphan its asset — reconcile (debounced). - if (projectId && projectState) scheduleAssetGc(projectId, projectState); - }, - [removeCards, arrows, saveArrows, projectId, projectState], - ); - - // Change card color - const handleChangeCardColor = useCallback( - (id: string, color: string) => { - const newCards = cardsRef.current.map((c) => (c.id === id ? { ...c, color } : c)); - cardsRef.current = newCards; - setCards(newCards); - saveCards(newCards); - }, - [saveCards], - ); - - // Duplicate card - const handleDuplicateCard = useCallback( - (card: BoardCardData) => { - const newCard: BoardCardData = { - ...card, - id: uuidv7(), - x: card.x + 20, - y: card.y + 20, - }; - const newCards = [...cardsRef.current, newCard]; - cardsRef.current = newCards; - setCards(newCards); - saveCards(newCards); - }, - [saveCards], - ); - - // Send card to the Timeline — to a specific layer when `layerId` is given, - // otherwise to the default (first root) lane. - const handleSendToTimeline = useCallback( - (card: BoardCardData, layerId?: string) => { - repository?.appendTimelineClip( - { - source: "card", - refDocId: docId, - refId: card.id, - title: card.title, - preview: card.description, - color: card.color, - }, - undefined, - layerId, - ); - }, - [repository, docId], - ); - - // Timeline layers flattened into display order (depth-annotated), mirroring - // the Timeline panel's tree so the "Send to timeline" submenu matches it. - const orderedLayers = useMemo(() => { - const out: { layer: TimelineLayer; depth: number }[] = []; - const childrenOf = (parentId: string | null) => - Object.values(timelineLayers) - .filter((l) => (l.parentId ?? null) === parentId) - .sort((a, b) => a.order - b.order); - const walk = (parentId: string | null, depth: number) => { - for (const layer of childrenOf(parentId)) { - out.push({ layer, depth }); - walk(layer.id, depth + 1); - } - }; - walk(null, 0); - return out; - }, [timelineLayers]); - - /** - * Lanes to offer in the "Send to timeline" submenu. The default lanes are - * otherwise only seeded when the Timeline panel is first opened, so a - * board-first user got an empty list (and a flat menu item) until they'd - * either opened the timeline or sent a card once — `appendTimelineClip` - * seeds too. Seeding here as well keeps the lanes listed on the very first - * right-click. Idempotent, and a no-op on a read-only project. - */ - const resolveTimelineLayers = useCallback(() => { - if (orderedLayers.length > 0) return orderedLayers; - const seeded = repository?.ensureTimelineLayers(2, (i) => `${tTimeline("layer")} ${i + 1}`) ?? []; - return seeded.map((layer) => ({ layer, depth: 0 })); - }, [orderedLayers, repository, tTimeline]); - - // Delete arrow - const handleDeleteArrow = useCallback( - (id: string) => { - const newArrows = arrows.filter((a) => a.id !== id); - setArrows(newArrows); - saveArrows(newArrows); - }, - [arrows, saveArrows], - ); - - // Open the shared context-menu host for a card. - const handleCardContextMenu = useCallback( - (e: React.MouseEvent, card: BoardCardData) => { - const layers = resolveTimelineLayers(); - 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" && - (layers.length > 0 ? ( - - {layers.map(({ layer, depth }) => ( - handleSendToTimeline(card, layer.id)} - /> - ))} - - ) : ( - handleSendToTimeline(card)} - /> - ))} - handleDeleteCard(card.id)} - /> - - ), - }); - }, - [updateContextMenu, t, handleChangeCardColor, handleDuplicateCard, handleSendToTimeline, handleDeleteCard, resolveTimelineLayers], - ); - - // 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") => { - 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 toCenter = { x: toCard.x + toCard.width / 2, y: toCard.y + toCard.height / 2 }; - - const dx = toCenter.x - fromCenter.x; - const dy = toCenter.y - fromCenter.y; - - let fromSide: "top" | "right" | "bottom" | "left"; - let toSide: "top" | "right" | "bottom" | "left"; - - if (Math.abs(dx) > Math.abs(dy)) { - // Horizontal dominant - fromSide = dx > 0 ? "right" : "left"; - toSide = dx > 0 ? "left" : "right"; - } else { - // Vertical dominant - fromSide = dy > 0 ? "bottom" : "top"; - toSide = dy > 0 ? "top" : "bottom"; - } - - // Get perpendicular direction vectors for each side - const getDirection = (side: "top" | "right" | "bottom" | "left") => { - switch (side) { - case "top": - return { x: 0, y: -1 }; - case "right": - return { x: 1, y: 0 }; - case "bottom": - return { x: 0, y: 1 }; - case "left": - return { x: -1, y: 0 }; - } - }; - - return { - from: getConnectionPoint(fromCard, fromSide), - to: getConnectionPoint(toCard, toSide), - fromDir: getDirection(fromSide), - toDir: getDirection(toSide), - }; - }, - [getConnectionPoint], - ); - - // Handle starting a connection from a card - const handleStartConnection = useCallback( - (cardId: string, side: string, initialX: number, initialY: number) => { - captureGestureRect(); - setConnectingFrom({ cardId, side }); - setConnectingLine({ x: initialX, y: initialY }); - }, - [captureGestureRect], - ); - - // Handle mouse move while connecting - const handleConnectionMouseMove = useCallback( - (e: MouseEvent) => { - const rect = gestureRect.current; - if (!connectingFrom || !rect) return; - - const x = (e.clientX - rect.left - offset.x) / scale; - const y = (e.clientY - rect.top - offset.y) / scale; - - setConnectingLine({ x, y }); - }, - [connectingFrom, offset, scale], - ); - - // Handle completing a connection - const handleCompleteConnection = useCallback( - (toCardId: string) => { - if (!connectingFrom || connectingFrom.cardId === toCardId) { - setConnectingFrom(null); - setConnectingLine(null); - return; - } - - // Check if arrow already exists - const exists = arrows.some( - (a) => - (a.fromCardId === connectingFrom.cardId && a.toCardId === toCardId) || - (a.fromCardId === toCardId && a.toCardId === connectingFrom.cardId), - ); - - if (!exists) { - const newArrow: BoardArrowData = { - id: uuidv7(), - fromCardId: connectingFrom.cardId, - toCardId, - }; - const newArrows = [...arrows, newArrow]; - setArrows(newArrows); - saveArrows(newArrows); - } - - setConnectingFrom(null); - setConnectingLine(null); + if (!isSyntheticMouse()) removeArrow(id); }, - [connectingFrom, arrows, saveArrows], + [isSyntheticMouse, removeArrow], ); - // Cancel connection on mouse up if not on a card - const handleConnectionMouseUp = useCallback(() => { - setConnectingFrom(null); - setConnectingLine(null); - }, []); - - // Setup connection mouse + touch events - useEffect(() => { - if (!connectingFrom) return; - window.addEventListener("mousemove", handleConnectionMouseMove); - window.addEventListener("mouseup", handleConnectionMouseUp); - - // Touch: track the finger to draw the pending line, and on release resolve - // the card under the finger via elementFromPoint to complete the link. - const onTouchMove = (e: TouchEvent) => { - const t = e.touches[0]; - const rect = gestureRect.current; - if (!t || !rect) return; - lastTouchPoint.current = { x: t.clientX, y: t.clientY }; - setConnectingLine({ - x: (t.clientX - rect.left - offsetRef.current.x) / scaleRef.current, - y: (t.clientY - rect.top - offsetRef.current.y) / scaleRef.current, - }); - }; - const onTouchEnd = () => { - const p = lastTouchPoint.current; - const el = document.elementFromPoint(p.x, p.y) as HTMLElement | null; - const targetId = el?.closest("[data-card-id]")?.getAttribute("data-card-id"); - if (targetId) handleCompleteConnection(targetId); - else handleConnectionMouseUp(); - }; - window.addEventListener("touchmove", onTouchMove, { passive: true }); - window.addEventListener("touchend", onTouchEnd); - - return () => { - window.removeEventListener("mousemove", handleConnectionMouseMove); - window.removeEventListener("mouseup", handleConnectionMouseUp); - window.removeEventListener("touchmove", onTouchMove); - window.removeEventListener("touchend", onTouchEnd); - gestureRect.current = null; - }; - }, [connectingFrom, handleConnectionMouseMove, handleConnectionMouseUp, handleCompleteConnection]); - - // ── Container touch gestures (mobile) ───────────────────────────────────── - // One finger pans the canvas; two fingers pinch-zoom (centred on the pinch); - // a double-tap on empty canvas creates a card; a long-press opens the canvas - // menu. Cards/handles stop propagation, so their touches never reach here. - useEffect( - () => () => { - if (longPressTimer.current) clearTimeout(longPressTimer.current); - }, - [], - ); - - // Uses the gesture's captured rect when there is one (a live pan/pinch), and - // otherwise measures — the discrete taps that call this outside a gesture - // (double-tap to create, long-press menu) are cheap enough to measure fresh. - const toCanvasPoint = (clientX: number, clientY: number) => { - const rect = gestureRect.current ?? captureGestureRect(); - if (!rect) return { x: 0, y: 0 }; - return { - x: (clientX - rect.left - offsetRef.current.x) / scaleRef.current, - y: (clientY - rect.top - offsetRef.current.y) / scaleRef.current, - }; - }; - - const cancelLongPress = () => { - if (longPressTimer.current) { - clearTimeout(longPressTimer.current); - longPressTimer.current = null; - } - }; - - const isChromeTarget = (target: HTMLElement) => - !!( - target.closest(`.${styles.card}`) || - target.closest(`.${styles.zoom_controls}`) || - target.closest(`.${styles.recording_indicator}`) || - target.closest("[data-context-menu]") - ); - - const handleContainerTouchStart = (e: React.TouchEvent) => { - lastTouch.current = Date.now(); - if (connectingFrom) return; - if (isChromeTarget(e.target as HTMLElement)) return; - - if (e.touches.length === 1) { - const t = e.touches[0]; - gesture.current = { - ...gesture.current, - mode: "pan", - startX: t.clientX, - startY: t.clientY, - startOffset: { ...offsetRef.current }, - moved: false, - }; - cancelLongPress(); - const lpX = t.clientX; - const lpY = t.clientY; - longPressTimer.current = setTimeout(() => { - gesture.current.mode = "none"; - showCanvasMenu(lpX, lpY); - }, 500); - } else if (e.touches.length === 2) { - cancelLongPress(); - captureGestureRect(); - const a = e.touches[0]; - const b = e.touches[1]; - const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); - const mid = toCanvasPoint((a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2); - gesture.current = { - ...gesture.current, - mode: "pinch", - startDist: dist || 1, - startScale: scaleRef.current, - pinchCanvasX: mid.x, - pinchCanvasY: mid.y, - moved: true, - }; - } - }; - - const handleContainerTouchMove = (e: React.TouchEvent) => { - lastTouch.current = Date.now(); - const g = gesture.current; - if (g.mode === "pan" && e.touches.length === 1) { - const t = e.touches[0]; - const dx = t.clientX - g.startX; - const dy = t.clientY - g.startY; - if (!g.moved && Math.hypot(dx, dy) > 8) { - g.moved = true; - cancelLongPress(); - } - if (g.moved) setOffset({ x: g.startOffset.x + dx, y: g.startOffset.y + dy }); - } else if (g.mode === "pinch" && e.touches.length >= 2) { - const rect = gestureRect.current; - if (!rect) return; - const a = e.touches[0]; - const b = e.touches[1]; - const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); - const midX = (a.clientX + b.clientX) / 2; - const midY = (a.clientY + b.clientY) / 2; - const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, g.startScale * (dist / g.startDist))); - setScale(newScale); - setOffset({ - x: midX - rect.left - g.pinchCanvasX * newScale, - y: midY - rect.top - g.pinchCanvasY * newScale, - }); - } - }; - - const handleContainerTouchEnd = (e: React.TouchEvent) => { - lastTouch.current = Date.now(); - cancelLongPress(); - const g = gesture.current; - if (g.mode === "pan" && !g.moved && e.changedTouches.length > 0) { - const t = e.changedTouches[0]; - const now = Date.now(); - const isDoubleTap = - now - lastTap.current.time < 300 && - Math.hypot(t.clientX - lastTap.current.x, t.clientY - lastTap.current.y) < 30; - if (isDoubleTap) { - const c = toCanvasPoint(t.clientX, t.clientY); - setSelectedCardIds(new Set()); - handleCreateCard(c.x, c.y); - lastTap.current = { time: 0, x: 0, y: 0 }; - } else { - lastTap.current = { time: now, x: t.clientX, y: t.clientY }; - } - } - if (e.touches.length === 0) { - g.mode = "none"; - // Gesture over: drop the captured rect so the next one measures fresh - // (the panel may have moved since — a drawer, a split resize). - gestureRect.current = null; - } else if (e.touches.length === 1) { - // A finger lifted from a pinch — resume panning with the one that remains. - const t = e.touches[0]; - gesture.current = { - ...g, - mode: "pan", - startX: t.clientX, - startY: t.clientY, - startOffset: { ...offsetRef.current }, - moved: true, - }; - } - }; - - // Grid placement. Pan is expressed purely as a transform, so a pan frame - // writes nothing but `transform` and the compositor does the rest; the tile - // size is quantized (see GRID_TILE_QUANTUM) so zoom only touches - // background-size a handful of times across a gesture, not every frame. - const gridPattern = useMemo(() => { - const tile = Math.max( - GRID_TILE_QUANTUM, - Math.round((GRID_SIZE * scale) / GRID_TILE_QUANTUM) * GRID_TILE_QUANTUM, - ); - return { - backgroundSize: `${tile}px ${tile}px`, - inset: `${-tile}px`, - transform: `translate3d(${offset.x % tile}px, ${offset.y % tile}px, 0)`, - }; - }, [scale, offset]); - return (
{/* Hidden picker for the mobile "Import image" menu action. */}
- {/* SVG layer for arrows */} - - {arrows.map((arrow) => { - const fromCard = cards.find((c) => c.id === arrow.fromCardId); - const toCard = cards.find((c) => c.id === arrow.toCardId); - if (!fromCard || !toCard) return null; - - 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 controlDist = Math.max(50, dist * 0.4); - - // Control points extend perpendicular to the borders - const cx1 = points.from.x + points.fromDir.x * controlDist; - const cy1 = points.from.y + points.fromDir.y * controlDist; - const cx2 = points.to.x + points.toDir.x * controlDist; - const cy2 = points.to.y + points.toDir.y * controlDist; - - // Calculate arrowhead angle from the curve's end tangent - const angle = Math.atan2(points.to.y - cy2, points.to.x - cx2); - const arrowLength = 24; - const arrowWidth = 8; // half-width - - // 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); - // Inner notch (25% from back toward tip) - const notchDepth = arrowLength * 0.75; - const axm = points.to.x - notchDepth * Math.cos(angle); - const aym = points.to.y - notchDepth * Math.sin(angle); - - // Shorten the line to end at the notch point so it doesn't extend past the arrowhead - const lineEndX = axm; - const lineEndY = aym; - - const pathD = `M ${points.from.x} ${points.from.y} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${lineEndX} ${lineEndY}`; - const arrowheadD = `M ${ax1} ${ay1} L ${points.to.x} ${points.to.y} L ${ax2} ${ay2} L ${axm} ${aym} Z`; - - return ( - - {/* Invisible hitbox for easier clicking */} - handleArrowContextMenu(e, arrow)} - /> - {/* Visible arrow line */} - - {/* Arrowhead */} - - - ); - })} - {/* Line while connecting */} - {connectingFrom && - connectingLine && - (() => { - const fromCard = cards.find((c) => c.id === connectingFrom.cardId); - if (!fromCard) return null; - - const fromCenter = { - x: fromCard.x + fromCard.width / 2, - y: fromCard.y + fromCard.height / 2, - }; - const dx = connectingLine.x - fromCenter.x; - const dy = connectingLine.y - fromCenter.y; - - // Determine best exit side based on cursor direction - let fromSide: "top" | "right" | "bottom" | "left"; - let fromDir: { x: number; y: number }; - - if (Math.abs(dx) > Math.abs(dy)) { - fromSide = dx > 0 ? "right" : "left"; - fromDir = dx > 0 ? { x: 1, y: 0 } : { x: -1, y: 0 }; - } else { - fromSide = dy > 0 ? "bottom" : "top"; - fromDir = dy > 0 ? { x: 0, y: 1 } : { x: 0, y: -1 }; - } - - const fromPoint = getConnectionPoint(fromCard, fromSide); - 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; - const cy = fromPoint.y + fromDir.y * controlDist; - - const pathD = `M ${fromPoint.x} ${fromPoint.y} Q ${cx} ${cy}, ${connectingLine.x} ${connectingLine.y}`; - - return ( - - ); - })()} - + {cards.map((card) => ( ))} - {/* Selection rectangle */} {selectionRect && (
{assetError}
} - {/* Recording indicator */} {recorder.isRecording && ( -
- - - {formatRecordingTime(recorder.elapsed)} - - -
+ )} - {/* Zoom controls hidden on phone — pinch-to-zoom replaces them. */} - {!isPhone && ( -
- - {Math.round(scale * 100)}% - -
- )} + {/* Touch tools, hidden in a read-only session where they'd be dead buttons. */} + {isTouch && !isReadOnly && } + + + {!isPhone && }
); diff --git a/components/board/BoardCard.tsx b/components/board/BoardCard.tsx index 6dda3219..27392df3 100644 --- a/components/board/BoardCard.tsx +++ b/components/board/BoardCard.tsx @@ -297,6 +297,11 @@ interface BoardCardProps { onCompleteConnection: (cardId: string) => void; isConnecting: boolean; isSelected: boolean; + /** The board's link tool is armed: a tap on this card picks a link end. */ + linkMode: boolean; + /** This card is the link already picked, waiting for its target. */ + isLinkSource: boolean; + onLinkTap: (cardId: string) => void; } const BoardCard = ({ @@ -311,6 +316,9 @@ const BoardCard = ({ onCompleteConnection, isConnecting, isSelected, + linkMode, + isLinkSource, + onLinkTap, }: BoardCardProps) => { const kind = kindOf(card); // Coarse pointer, not phone width: a tablet renders the desktop board but is @@ -581,6 +589,14 @@ const BoardCard = ({ const state = touchDrag.current; touchDrag.current = null; if (state && !state.moved && !state.suppressed) { + // The link tool takes the tap ahead of everything else: while + // it is armed a tap means "this end of the link", not + // double-tap-to-edit. Dragging the card is untouched — that + // path needs movement, which rules a tap out. + if (linkMode) { + onLinkTap(card.id); + return; + } if (isConnecting) { onCompleteConnection(card.id); return; @@ -606,11 +622,13 @@ const BoardCard = ({ card, kind, isConnecting, + linkMode, applyDrag, commitMove, captureCanvasOrigin, onContextMenu, onCompleteConnection, + onLinkTap, ], ); @@ -727,12 +745,22 @@ const BoardCard = ({ const handleCardMouseUp = useCallback( (e: React.MouseEvent) => { if (isSyntheticMouse()) return; // ignore the mouse echo of a touch + // Trackpad counterpart of the link tap — an iPad reports a coarse + // pointer with a Magic Keyboard attached, so the tool has to answer to + // both. A pending commit means this mouseup ends a drag rather than a + // click, and dragging a card must not link it. (The window mouseup + // that clears it runs after this delegated handler.) + if (linkMode && !pendingCommit.current) { + e.stopPropagation(); + onLinkTap(card.id); + return; + } if (isConnecting) { e.stopPropagation(); onCompleteConnection(card.id); } }, - [card.id, isConnecting, onCompleteConnection], + [card.id, isConnecting, linkMode, onCompleteConnection, onLinkTap], ); const titleEditing: TitleEditing = { @@ -777,8 +805,9 @@ const BoardCard = ({ kind === "image" && styles.image_card, kind === "audio" && styles.audio_card, isDragging && styles.card_dragging, - isConnecting && styles.card_connecting, + (isConnecting || linkMode) && styles.card_connecting, isSelected && styles.card_selected, + isLinkSource && styles.card_link_source, )} style={{ // Position via transform, not left/top — a drag frame is then a diff --git a/components/board/BoardOverlays.tsx b/components/board/BoardOverlays.tsx new file mode 100644 index 00000000..03730c1a --- /dev/null +++ b/components/board/BoardOverlays.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Link, Minus, MoveDiagonal2, Plus, Scissors, Square } from "lucide-react"; +import styles from "./BoardCanvas.module.css"; +import { BoardTool } from "./board-constants"; + +/** 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")}`; +} + +export const RecordingIndicator = ({ + elapsed, + onStop, +}: { + elapsed: number; + onStop: () => void; +}) => { + const t = useTranslations("board"); + return ( +
+ + {formatRecordingTime(elapsed)} + +
+ ); +}; + +/** + * Link / cut / resize tools. Touch only: with a pointer the corner node and the + * link's right-click menu are already precise enough, and the board keeps its + * uncluttered desktop chrome. + */ +export const BoardToolControls = ({ + tool, + onSelectTool, +}: { + tool: BoardTool; + onSelectTool: (tool: BoardTool) => void; +}) => { + const t = useTranslations("board"); + const tools: { id: BoardTool; icon: typeof Link; label: string }[] = [ + { id: "link", icon: Link, label: t("linkCards") }, + { id: "cut", icon: Scissors, label: t("cutLinks") }, + { id: "resize", icon: MoveDiagonal2, label: t("resizeCards") }, + ]; + + return ( +
+ {tools.map(({ id, icon: Icon, label }) => ( + + ))} +
+ ); +}; + +/** + * What the armed tool is waiting for. The tools are modal and the pressed + * button is the only other sign of it, so the step being asked for is spelled + * out until the tool is put down. + */ +export const BoardToolHint = ({ + tool, + hasLinkSource, +}: { + tool: BoardTool; + hasLinkSource: boolean; +}) => { + const t = useTranslations("board"); + if (tool === "select") return null; + + const hint = + tool === "cut" + ? t("cutHint") + : tool === "resize" + ? t("resizeHint") + : hasLinkSource + ? t("linkHintTarget") + : t("linkHintSource"); + + return
{hint}
; +}; + +/** Zoom buttons — hidden on phone, where pinch-to-zoom replaces them. */ +export const BoardZoomControls = ({ + scale, + onZoom, +}: { + scale: number; + onZoom: (zoomIn: boolean) => void; +}) => ( +
+ + {Math.round(scale * 100)}% + +
+); diff --git a/components/board/board-cards.ts b/components/board/board-cards.ts new file mode 100644 index 00000000..5ff5664e --- /dev/null +++ b/components/board/board-cards.ts @@ -0,0 +1,76 @@ +import { BoardCardData } from "@src/lib/project/project-state"; +import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; +import { v7 as uuidv7 } from "uuid"; +import { + AUDIO_CARD_HEIGHT, + AUDIO_CARD_WIDTH, + GRID_SIZE, + MAX_IMAGE_CARD_SIZE, + TEXT_CARD_HEIGHT, + TEXT_CARD_WIDTH, +} from "./board-constants"; + +/** A random swatch from the default palette (used for new colored cards). */ +export function randomCardColor(): string { + return DEFAULT_ITEM_COLORS[Math.floor(Math.random() * DEFAULT_ITEM_COLORS.length)]; +} + +/** Round a canvas coordinate onto the grid, unless snapping is held off (Shift). */ +export function snapToGrid(value: number, isSnapping: boolean): number { + return isSnapping ? Math.round(value / GRID_SIZE) * GRID_SIZE : value; +} + +export function createTextCard(x: number, y: number, isSnapping: boolean): BoardCardData { + return { + id: uuidv7(), + title: "", + description: "", + color: randomCardColor(), + x: snapToGrid(x, isSnapping), + y: snapToGrid(y, isSnapping), + width: TEXT_CARD_WIDTH, + height: TEXT_CARD_HEIGHT, + }; +} + +/** + * An image card sized from the source image, scaled down to fit + * MAX_IMAGE_CARD_SIZE on its longest edge (never up, so small images keep + * their pixel size). + */ +export function createImageCard( + assetId: string, + imageWidth: number, + imageHeight: number, + x: number, + y: number, +): BoardCardData { + const fit = Math.min(1, MAX_IMAGE_CARD_SIZE / Math.max(imageWidth, imageHeight, 1)); + return { + id: uuidv7(), + type: "image", + assetId, + title: "", + description: "", + color: "transparent", + x, + y, + width: Math.max(60, Math.round(imageWidth * fit)), + height: Math.max(60, Math.round(imageHeight * fit)), + }; +} + +export function createAudioCard(assetId: string, x: number, y: number): BoardCardData { + return { + id: uuidv7(), + type: "audio", + assetId, + title: "", + description: "", + color: randomCardColor(), + x, + y, + width: AUDIO_CARD_WIDTH, + height: AUDIO_CARD_HEIGHT, + }; +} diff --git a/components/board/board-constants.ts b/components/board/board-constants.ts new file mode 100644 index 00000000..ee2c7c24 --- /dev/null +++ b/components/board/board-constants.ts @@ -0,0 +1,30 @@ +/** Tunables shared by the board canvas, its hooks and its geometry helpers. */ + +export const GRID_SIZE = 20; +export const MIN_SCALE = 0.25; +export const MAX_SCALE = 2; +/** + * Tile size (screen px) the grid's `background-size` is rounded to. Changing + * background-size is a paint op, so a continuous pinch would otherwise repaint + * a full-viewport gradient every frame; snapping to 4px steps means a full + * MIN_SCALE→MAX_SCALE sweep repaints under a dozen times total instead of once + * per frame, with no visible difference in dot spacing. + */ +export const GRID_TILE_QUANTUM = 4; +/** Largest edge (in canvas px) an image card is sized to on first drop. */ +export const MAX_IMAGE_CARD_SIZE = 400; +/** Default size (in canvas px) of an audio voice-note card. */ +export const AUDIO_CARD_WIDTH = 260; +export const AUDIO_CARD_HEIGHT = 96; +/** Default size (in canvas px) of a text card. */ +export const TEXT_CARD_WIDTH = 450; +export const TEXT_CARD_HEIGHT = 280; + +/** + * The board's active tool. "select" is the plain board — drag cards, pan, marquee. + * The rest are offered on touch only (see [tool_controls]), each standing in for + * something a finger can't land on the plain board: dragging out of a card's + * corner node to link, right-clicking a 2.5px line to cut it, and finding the + * 8px corner chevron that resizes a card. + */ +export type BoardTool = "select" | "link" | "cut" | "resize"; diff --git a/components/board/board-geometry.ts b/components/board/board-geometry.ts new file mode 100644 index 00000000..1eff6f7b --- /dev/null +++ b/components/board/board-geometry.ts @@ -0,0 +1,191 @@ +import { BoardCardData } from "@src/lib/project/project-state"; +import { MAX_SCALE, MIN_SCALE } from "./board-constants"; + +export type CardSide = "top" | "right" | "bottom" | "left"; + +export type Point = { x: number; y: number }; + +/** Where a link meets a card: the midpoint of the given edge. */ +export function getConnectionPoint(card: BoardCardData, side: CardSide): Point { + 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 }; + } +} + +/** Outward unit normal of an edge — the direction a link leaves the card in. */ +export function getSideDirection(side: CardSide): Point { + switch (side) { + case "top": + return { x: 0, y: -1 }; + case "right": + return { x: 1, y: 0 }; + case "bottom": + return { x: 0, y: 1 }; + case "left": + return { x: -1, y: 0 }; + } +} + +/** The edge a link should leave/enter through, given the direction it travels. */ +function sideForDelta(dx: number, dy: number): { from: CardSide; to: CardSide } { + if (Math.abs(dx) > Math.abs(dy)) { + // Horizontal dominant + return dx > 0 ? { from: "right", to: "left" } : { from: "left", to: "right" }; + } + // Vertical dominant + return dy > 0 ? { from: "bottom", to: "top" } : { from: "top", to: "bottom" }; +} + +/** Best connection points between two cards, with their perpendicular tangents. */ +export function getArrowPoints(fromCard: BoardCardData, toCard: BoardCardData) { + 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 { from: fromSide, to: toSide } = sideForDelta( + toCenter.x - fromCenter.x, + toCenter.y - fromCenter.y, + ); + + return { + from: getConnectionPoint(fromCard, fromSide), + to: getConnectionPoint(toCard, toSide), + fromDir: getSideDirection(fromSide), + toDir: getSideDirection(toSide), + }; +} + +/** Arrowhead dimensions (canvas px); `ARROW_WIDTH` is a half-width. */ +const ARROW_LENGTH = 24; +const ARROW_WIDTH = 8; + +/** + * The two paths that draw a link between cards: the bezier body and the + * arrowhead at its end. + * + * The body stops at the arrowhead's inner notch rather than at the card edge, + * so the stroke never pokes through the tip. + */ +export function buildArrowPath(fromCard: BoardCardData, toCard: BoardCardData) { + const points = getArrowPoints(fromCard, toCard); + + // Control points extend perpendicular to the borders + const dist = Math.hypot(points.to.x - points.from.x, points.to.y - points.from.y); + const controlDist = Math.max(50, dist * 0.4); + const cx1 = points.from.x + points.fromDir.x * controlDist; + const cy1 = points.from.y + points.fromDir.y * controlDist; + const cx2 = points.to.x + points.toDir.x * controlDist; + const cy2 = points.to.y + points.toDir.y * controlDist; + + // Arrowhead angle comes from the curve's end tangent + const angle = Math.atan2(points.to.y - cy2, points.to.x - cx2); + + // Back corners (perpendicular to arrow direction) + const ax1 = points.to.x - ARROW_LENGTH * Math.cos(angle) + ARROW_WIDTH * Math.sin(angle); + const ay1 = points.to.y - ARROW_LENGTH * Math.sin(angle) - ARROW_WIDTH * Math.cos(angle); + const ax2 = points.to.x - ARROW_LENGTH * Math.cos(angle) - ARROW_WIDTH * Math.sin(angle); + const ay2 = points.to.y - ARROW_LENGTH * Math.sin(angle) + ARROW_WIDTH * Math.cos(angle); + // Inner notch (25% from back toward tip), where the line stops + const notchDepth = ARROW_LENGTH * 0.75; + const axm = points.to.x - notchDepth * Math.cos(angle); + const aym = points.to.y - notchDepth * Math.sin(angle); + + return { + pathD: `M ${points.from.x} ${points.from.y} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${axm} ${aym}`, + arrowheadD: `M ${ax1} ${ay1} L ${points.to.x} ${points.to.y} L ${ax2} ${ay2} L ${axm} ${aym} Z`, + }; +} + +/** + * The dashed line drawn while a link is being dragged out of a card, from the + * edge facing the cursor to the cursor itself. + */ +export function buildConnectingPath(fromCard: BoardCardData, to: Point): string { + const fromCenter = { x: fromCard.x + fromCard.width / 2, y: fromCard.y + fromCard.height / 2 }; + const { from: fromSide } = sideForDelta(to.x - fromCenter.x, to.y - fromCenter.y); + const fromDir = getSideDirection(fromSide); + const fromPoint = getConnectionPoint(fromCard, fromSide); + + const dist = Math.hypot(to.x - fromPoint.x, to.y - fromPoint.y); + const controlDist = Math.max(30, dist * 0.3); + const cx = fromPoint.x + fromDir.x * controlDist; + const cy = fromPoint.y + fromDir.y * controlDist; + + return `M ${fromPoint.x} ${fromPoint.y} Q ${cx} ${cy}, ${to.x} ${to.y}`; +} + +/** Padding (canvas px) left around the cards when fitting the camera to them. */ +const FIT_PADDING = 100; + +/** + * Camera that frames every given card inside a viewport, clamped to the zoom + * range. Returns null when there is nothing to frame. + */ +export function fitCameraToCards( + cards: BoardCardData[], + viewport: { width: number; height: number }, +): { scale: number; offset: Point } | null { + if (cards.length === 0) return null; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const card of cards) { + minX = Math.min(minX, card.x); + minY = Math.min(minY, card.y); + maxX = Math.max(maxX, card.x + card.width); + maxY = Math.max(maxY, card.y + card.height); + } + + minX -= FIT_PADDING; + minY -= FIT_PADDING; + maxX += FIT_PADDING; + maxY += FIT_PADDING; + + const boundsCenterX = (minX + maxX) / 2; + const boundsCenterY = (minY + maxY) / 2; + + const scaleX = viewport.width / (maxX - minX); + const scaleY = viewport.height / (maxY - minY); + const scale = clampScale(Math.min(scaleX, scaleY)); + + return { + scale, + offset: { + x: viewport.width / 2 - boundsCenterX * scale, + y: viewport.height / 2 - boundsCenterY * scale, + }, + }; +} + +export function clampScale(scale: number): number { + return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale)); +} + +/** + * Zoom about a fixed screen point: the canvas point under it stays put. + * `anchor` is relative to the container's top-left. + */ +export function zoomAround( + anchor: Point, + camera: { offset: Point; scale: number }, + factor: number, +): { scale: number; offset: Point } { + const newScale = clampScale(camera.scale * factor); + const canvasX = (anchor.x - camera.offset.x) / camera.scale; + const canvasY = (anchor.y - camera.offset.y) / camera.scale; + return { + scale: newScale, + offset: { x: anchor.x - canvasX * newScale, y: anchor.y - canvasY * newScale }, + }; +} diff --git a/components/board/board-menus.tsx b/components/board/board-menus.tsx new file mode 100644 index 00000000..c459ec2d --- /dev/null +++ b/components/board/board-menus.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { Copy, Image as ImageIcon, Layers, ListTree, Mic, Plus, Trash2 } from "lucide-react"; +import { + ContextMenuColorRow, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSubmenu, +} from "@components/utils/ContextMenu"; +import { ProjectContext } from "@src/context/ProjectContext"; +import { UserContext } from "@src/context/UserContext"; +import { BoardArrowData, BoardCardData, TimelineLayer } from "@src/lib/project/project-state"; +import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; +import styles from "./BoardCanvas.module.css"; +import { BoardCamera } from "./use-board-camera"; + +type BoardMenuActions = { + canRecord: boolean; + createCard: (x: number, y: number) => void; + importImage: (x: number, y: number) => void; + recordAudio: (x: number, y: number) => void; + changeCardColor: (id: string, color: string) => void; + duplicateCard: (card: BoardCardData) => void; + sendToTimeline: (card: BoardCardData, layerId?: string) => void; + deleteCard: (id: string) => void; + deleteArrow: (id: string) => void; +}; + +/** + * The board's three context menus — empty canvas, card, arrow — as openers that + * hand their content to the shared menu host. + */ +export function useBoardMenus(camera: BoardCamera, actions: BoardMenuActions) { + const { isReadOnly, repository, timelineLayers } = useContext(ProjectContext); + const { updateContextMenu } = useContext(UserContext); + const t = useTranslations("board"); + // Used only to name the default lanes when the board seeds them (below). + const tTimeline = useTranslations("timeline"); + const { toCanvasPoint } = camera; + + // The openers must keep a stable identity — `showCardMenu` is a prop of a + // memoised BoardCard, so a new one per render would re-render every card on + // the board. Reading the actions through a ref means the caller can pass a + // plain object literal without any of that leaking out. Menus are only ever + // built from an event handler, long after this has been filled in. + const actionsRef = useRef(actions); + useEffect(() => { + actionsRef.current = actions; + }); + + // Timeline layers flattened into display order (depth-annotated), mirroring + // the Timeline panel's tree so the "Send to timeline" submenu matches it. + const orderedLayers = useMemo(() => { + const out: { layer: TimelineLayer; depth: number }[] = []; + const childrenOf = (parentId: string | null) => + Object.values(timelineLayers) + .filter((l) => (l.parentId ?? null) === parentId) + .sort((a, b) => a.order - b.order); + const walk = (parentId: string | null, depth: number) => { + for (const layer of childrenOf(parentId)) { + out.push({ layer, depth }); + walk(layer.id, depth + 1); + } + }; + walk(null, 0); + return out; + }, [timelineLayers]); + + /** + * Lanes to offer in the "Send to timeline" submenu. The default lanes are + * otherwise only seeded when the Timeline panel is first opened, so a + * board-first user got an empty list (and a flat menu item) until they'd + * either opened the timeline or sent a card once — `appendTimelineClip` + * seeds too. Seeding here as well keeps the lanes listed on the very first + * right-click. Idempotent, and a no-op on a read-only project. + */ + const resolveTimelineLayers = useCallback(() => { + if (orderedLayers.length > 0) return orderedLayers; + const seeded = + repository?.ensureTimelineLayers(2, (i) => `${tTimeline("layer")} ${i + 1}`) ?? []; + return seeded.map((layer) => ({ layer, depth: 0 })); + }, [orderedLayers, repository, tTimeline]); + + /** + * Build the empty-canvas menu (create card / import image / record audio) at + * the given *screen* coords, resolving the canvas-space drop point via the camera. + */ + const showCanvasMenu = useCallback( + (screenX: number, screenY: number) => { + if (isReadOnly) return; + const { x, y } = toCanvasPoint(screenX, screenY); + updateContextMenu({ + position: { x: screenX, y: screenY }, + content: ( + <> + actionsRef.current.createCard(x, y)} + /> + actionsRef.current.importImage(x, y)} + /> + actionsRef.current.recordAudio(x, y)} + disabled={!actionsRef.current.canRecord} + title={actionsRef.current.canRecord ? undefined : t("audioUnsupported")} + /> + + ), + }); + }, + [isReadOnly, toCanvasPoint, updateContextMenu, t], + ); + + // Right-clicking empty canvas opens the menu. 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; + e.preventDefault(); + showCanvasMenu(e.clientX, e.clientY); + }, + [isReadOnly, showCanvasMenu], + ); + + const showCardMenu = useCallback( + (e: React.MouseEvent, card: BoardCardData) => { + const layers = resolveTimelineLayers(); + updateContextMenu({ + position: { x: e.clientX, y: e.clientY }, + content: ( + <> + {/* Color applies to text + audio notes; image cards have none. */} + {card.type !== "image" && ( + <> + actionsRef.current.changeCardColor(card.id, color)} + /> + + + )} + actionsRef.current.duplicateCard(card)} + /> + {(card.type ?? "text") === "text" && + (layers.length > 0 ? ( + + {layers.map(({ layer, depth }) => ( + actionsRef.current.sendToTimeline(card, layer.id)} + /> + ))} + + ) : ( + actionsRef.current.sendToTimeline(card)} + /> + ))} + actionsRef.current.deleteCard(card.id)} + /> + + ), + }); + }, + [updateContextMenu, t, resolveTimelineLayers], + ); + + const showArrowMenu = useCallback( + (e: React.MouseEvent, arrow: BoardArrowData) => { + e.preventDefault(); + e.stopPropagation(); + updateContextMenu({ + position: { x: e.clientX, y: e.clientY }, + content: ( + actionsRef.current.deleteArrow(arrow.id)} + /> + ), + }); + }, + [updateContextMenu, t], + ); + + return { showCanvasMenu, handleCanvasContextMenu, showCardMenu, showArrowMenu }; +} diff --git a/components/board/use-board-assets.ts b/components/board/use-board-assets.ts new file mode 100644 index 00000000..94c920e5 --- /dev/null +++ b/components/board/use-board-assets.ts @@ -0,0 +1,214 @@ +"use client"; + +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { ProjectContext } from "@src/context/ProjectContext"; +import { BoardCardData } from "@src/lib/project/project-state"; +import { importAudioFile, importImageFile, syncAssetToCloud } from "@src/lib/assets/asset-store"; +import { CloudQuotaError } from "@src/lib/assets/cloud-asset-sync"; +import { createAudioCard, createImageCard } from "./board-cards"; +import { useAudioRecorder } from "./use-audio-recorder"; +import { BoardCamera } from "./use-board-camera"; + +/** How long the transient asset-error banner stays up. */ +const ASSET_ERROR_MS = 4000; +/** Canvas-px stagger between cards created from a multi-file drop. */ +const DROP_STAGGER = 24; + +/** + * Bringing media onto the board: OS file drops, the image picker, and voice + * notes. Each import stores the bytes locally first and drops its card + * immediately, then uploads in the background — so the card appears at once and + * still renders offline. + */ +export function useBoardAssets( + camera: BoardCamera, + cards: { + addCards: (cards: BoardCardData[]) => void; + removeCards: (ids: Set) => void; + }, +) { + const { projectId, isReadOnly } = useContext(ProjectContext); + const t = useTranslations("board"); + const recorder = useAudioRecorder(); + const { addCards, removeCards } = cards; + const { toCanvasPoint } = camera; + + const [isDraggingFile, setIsDraggingFile] = useState(false); + /** Transient banner shown when an asset can't be saved (e.g. cloud quota). */ + const [assetError, setAssetError] = useState(null); + const assetErrorTimer = useRef | null>(null); + + const imageInput = useRef(null); + /** Callback ref, so the input element is wired up without handing a ref out. */ + const setImageInput = useCallback((el: HTMLInputElement | null) => { + imageInput.current = el; + }, []); + const imageImportCoords = useRef({ x: 0, y: 0 }); + /** Canvas-space coords captured when recording starts, for the resulting card. */ + const recordCoords = useRef({ x: 0, y: 0 }); + + const showAssetError = useCallback((message: string) => { + setAssetError(message); + if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current); + assetErrorTimer.current = setTimeout(() => setAssetError(null), ASSET_ERROR_MS); + }, []); + + useEffect( + () => () => { + if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current); + }, + [], + ); + + // Upload the new cards' assets to the cloud in the background. If an upload + // is rejected for quota, roll that card back and explain why. + const syncCreatedAssets = useCallback( + (createdCards: BoardCardData[], pid: string) => { + for (const card of createdCards) { + if (!card.assetId) continue; + const cardId = card.id; + void syncAssetToCloud(pid, card.assetId).catch((err) => { + if (err instanceof CloudQuotaError) { + removeCards(new Set([cardId])); + showAssetError(t("storageLimitReached")); + } else { + console.error("[BoardCanvas] cloud asset upload failed:", err); + } + }); + } + }, + [removeCards, showAssetError, t], + ); + + /** Add already-built asset cards to the board and start their upload. */ + const commitAssetCards = useCallback( + (created: BoardCardData[], pid: string) => { + if (created.length === 0) return; + addCards(created); + syncCreatedAssets(created, pid); + }, + [addCards, syncCreatedAssets], + ); + + // ── OS file drag & drop ─────────────────────────────────────────────────── + + /** 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); + }, []); + + 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 drop = toCanvasPoint(e.clientX, e.clientY); + + const created: BoardCardData[] = []; + for (const file of files) { + const x = drop.x + created.length * DROP_STAGGER; + const y = drop.y + created.length * DROP_STAGGER; + try { + if (file.type.startsWith("audio/")) { + const { hash } = await importAudioFile(projectId, file); + created.push(createAudioCard(hash, x, y)); + } else { + const { hash, width, height } = await importImageFile(projectId, file); + created.push(createImageCard(hash, width, height, x, y)); + } + } catch (err) { + console.error("[BoardCanvas] Failed to import dropped file:", err); + } + } + + commitAssetCards(created, projectId); + }, + [isReadOnly, projectId, toCanvasPoint, commitAssetCards], + ); + + // ── Image picker (mobile has no drag & drop) ────────────────────────────── + + const openImagePicker = useCallback((x: number, y: number) => { + imageImportCoords.current = { x, y }; + imageInput.current?.click(); + }, []); + + const handleImageInputChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file || isReadOnly || !projectId) return; + const { x, y } = imageImportCoords.current; + void (async () => { + try { + const { hash, width, height } = await importImageFile(projectId, file); + commitAssetCards([createImageCard(hash, width, height, x, y)], projectId); + } catch (err) { + console.error("[BoardCanvas] Failed to import image:", err); + } + })(); + }, + [isReadOnly, projectId, commitAssetCards], + ); + + // ── Voice notes ─────────────────────────────────────────────────────────── + + /** Begin recording; remember where to drop the resulting card. */ + const startRecording = 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 stopRecording = useCallback(async () => { + const blob = await recorder.stop(); + if (!blob || !projectId) return; + try { + const { hash } = await importAudioFile(projectId, blob); + const { x, y } = recordCoords.current; + commitAssetCards([createAudioCard(hash, x, y)], projectId); + } catch (err) { + console.error("[BoardCanvas] Failed to store recording:", err); + } + }, [recorder, projectId, commitAssetCards]); + + return { + recorder, + assetError, + isDraggingFile, + handleDragOver, + handleDragLeave, + handleDrop, + setImageInput, + handleImageInputChange, + openImagePicker, + startRecording, + stopRecording, + }; +} diff --git a/components/board/use-board-camera.ts b/components/board/use-board-camera.ts new file mode 100644 index 00000000..64fa8749 --- /dev/null +++ b/components/board/use-board-camera.ts @@ -0,0 +1,199 @@ +"use client"; + +import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { BoardCardData } from "@src/lib/project/project-state"; +import { GRID_SIZE, GRID_TILE_QUANTUM } from "./board-constants"; +import { fitCameraToCards, Point, zoomAround } from "./board-geometry"; + +export type BoardCamera = ReturnType; + +/** + * The board's viewport: pan offset, zoom, and every way they change (wheel, + * buttons, middle-drag, fitting to cards), plus the screen↔canvas conversion + * everything else on the board goes through. + */ +export function useBoardCamera(containerRef: RefObject) { + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [scale, setScale] = useState(1); + const [isPanning, setIsPanning] = useState(false); + + // Mirrors of the state above, so handlers that run many times per frame + // (gestures, global listeners) can read the current camera without being + // re-created — and re-subscribed — on every frame they cause. + const offsetRef = useRef(offset); + const scaleRef = useRef(scale); + useEffect(() => { + offsetRef.current = offset; + }, [offset]); + useEffect(() => { + scaleRef.current = scale; + }, [scale]); + + const getOffset = useCallback(() => offsetRef.current, []); + const getScale = useCallback(() => scaleRef.current, []); + + const panStart = useRef({ x: 0, y: 0, offsetX: 0, offsetY: 0 }); + + /** + * The container's viewport rect, captured once when a gesture starts. + * + * Every move handler needs it to map a pointer to canvas space, but reading + * it per frame is a `getBoundingClientRect()` on a document the board has + * just dirtied — a forced synchronous layout of *everything* still on + * screen (the navigation drawer's scene list, the timeline strip, the + * parked screenplay editor) on every single move event. The panel itself + * cannot move mid-gesture, so the rect taken at gesture start stays correct + * and the whole per-frame relayout goes away. + */ + const gestureRect = useRef(null); + const captureGestureRect = useCallback(() => { + const rect = containerRef.current?.getBoundingClientRect() ?? null; + gestureRect.current = rect; + return rect; + }, [containerRef]); + + const getGestureRect = useCallback(() => gestureRect.current, []); + + /** + * Drop the captured rect, so the next gesture measures fresh — the panel may + * have moved since (a drawer, a split resize). + */ + const releaseGestureRect = useCallback(() => { + gestureRect.current = null; + }, []); + + /** + * Screen point → canvas point. Uses the gesture's captured rect when there + * is one, and otherwise measures: the discrete taps that call this outside a + * gesture (double-tap to create, long-press menu, file drop) are cheap + * enough to measure fresh, and must not leave a rect behind for the next + * gesture to trust. + */ + const toCanvasPoint = useCallback( + (clientX: number, clientY: number): Point => { + const rect = gestureRect.current ?? containerRef.current?.getBoundingClientRect(); + if (!rect) return { x: 0, y: 0 }; + return { + x: (clientX - rect.left - offsetRef.current.x) / scaleRef.current, + y: (clientY - rect.top - offsetRef.current.y) / scaleRef.current, + }; + }, + [containerRef], + ); + + /** Frame the given cards, centered, at whatever zoom fits them. */ + const centerCameraOnCards = useCallback( + (cardsToFit: BoardCardData[]) => { + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const camera = fitCameraToCards(cardsToFit, { + width: rect.width, + height: rect.height, + }); + if (!camera) return; + setScale(camera.scale); + setOffset(camera.offset); + }, + [containerRef], + ); + + const applyZoom = useCallback((anchor: Point, factor: number) => { + const next = zoomAround(anchor, { offset: offsetRef.current, scale: scaleRef.current }, factor); + setScale(next.scale); + setOffset(next.offset); + }, []); + + // Zoom with the mouse wheel, centered on the cursor. Attached as a native + // non-passive listener because React registers onWheel as passive, which + // makes preventDefault() a no-op and warns. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = container.getBoundingClientRect(); + applyZoom({ x: e.clientX - rect.left, y: e.clientY - rect.top }, e.deltaY > 0 ? 0.9 : 1.1); + }; + container.addEventListener("wheel", onWheel, { passive: false }); + return () => container.removeEventListener("wheel", onWheel); + }, [containerRef, applyZoom]); + + /** Zoom from the buttons — centered on the viewport. */ + const zoomFromCenter = useCallback( + (zoomIn: boolean) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + applyZoom({ x: rect.width / 2, y: rect.height / 2 }, zoomIn ? 1.2 : 0.8); + }, + [containerRef, applyZoom], + ); + + /** Panning with middle-click. */ + const handlePanMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 1) return; + e.preventDefault(); // Prevent autoscroll on middle-click + + setIsPanning(true); + panStart.current = { + x: e.clientX, + y: e.clientY, + offsetX: offsetRef.current.x, + offsetY: offsetRef.current.y, + }; + }, []); + + useEffect(() => { + if (!isPanning) return; + + const onMouseMove = (e: MouseEvent) => { + setOffset({ + x: panStart.current.offsetX + (e.clientX - panStart.current.x), + y: panStart.current.offsetY + (e.clientY - panStart.current.y), + }); + }; + const onMouseUp = () => setIsPanning(false); + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [isPanning]); + + // Grid placement. Pan is expressed purely as a transform, so a pan frame + // writes nothing but `transform` and the compositor does the rest; the tile + // size is quantized (see GRID_TILE_QUANTUM) so zoom only touches + // background-size a handful of times across a gesture, not every frame. + const gridPattern = useMemo(() => { + const tile = Math.max( + GRID_TILE_QUANTUM, + Math.round((GRID_SIZE * scale) / GRID_TILE_QUANTUM) * GRID_TILE_QUANTUM, + ); + return { + backgroundSize: `${tile}px ${tile}px`, + inset: `${-tile}px`, + transform: `translate3d(${offset.x % tile}px, ${offset.y % tile}px, 0)`, + }; + }, [scale, offset]); + + return { + offset, + setOffset, + /** The live camera, for handlers that run between renders. */ + getOffset, + scale, + setScale, + getScale, + isPanning, + handlePanMouseDown, + zoomFromCenter, + centerCameraOnCards, + captureGestureRect, + releaseGestureRect, + getGestureRect, + toCanvasPoint, + gridPattern, + }; +} diff --git a/components/board/use-board-card-actions.ts b/components/board/use-board-card-actions.ts new file mode 100644 index 00000000..8000c5b4 --- /dev/null +++ b/components/board/use-board-card-actions.ts @@ -0,0 +1,151 @@ +"use client"; + +import { useCallback, useContext } from "react"; +import { v7 as uuidv7 } from "uuid"; +import { ProjectContext } from "@src/context/ProjectContext"; +import { BoardCardData } from "@src/lib/project/project-state"; +import { scheduleAssetGc } from "@src/lib/assets/asset-gc"; +import { createTextCard } from "./board-cards"; +import { BoardDocument } from "./use-board-document"; + +export type BoardCardActions = ReturnType; + +/** + * Everything the board does *to* cards — create, move, recolour, duplicate, + * delete, send to the timeline. + * + * All of it reads the card list through the document's ref rather than through + * render state, both to compose correctly within a batch and to keep these + * callbacks identity-stable: several are props of a memoised BoardCard, where a + * new identity per frame would re-render every card on the board. + */ +export function useBoardCardActions( + doc: BoardDocument, + options: { + docId: string; + isSnapping: boolean; + selectedCardIds: Set; + clearSelection: () => void; + }, +) { + const { projectId, repository } = useContext(ProjectContext); + const projectState = repository?.getState(); + const { getCards, commitCards, removeArrowsForCard } = doc; + const { docId, isSnapping, selectedCardIds, clearSelection } = options; + + const addCards = useCallback( + (newCards: BoardCardData[]) => commitCards([...getCards(), ...newCards]), + [getCards, commitCards], + ); + + /** Remove cards by id (also used to roll back a card whose asset can't be saved). */ + const removeCards = useCallback( + (ids: Set) => commitCards(getCards().filter((c) => !ids.has(c.id))), + [getCards, commitCards], + ); + + /** Create a text card at the given canvas-space coords. */ + const createCard = useCallback( + (x: number, y: number) => { + clearSelection(); + addCards([createTextCard(x, y, isSnapping)]); + }, + [clearSelection, addCards, isSnapping], + ); + + const duplicateCard = useCallback( + (card: BoardCardData) => { + addCards([{ ...card, id: uuidv7(), x: card.x + 20, y: card.y + 20 }]); + }, + [addCards], + ); + + const changeCardColor = useCallback( + (id: string, color: string) => { + commitCards(getCards().map((c) => (c.id === id ? { ...c, color } : c))); + }, + [getCards, commitCards], + ); + + /** Delete a card, the links that hung off it, and any asset it orphaned. */ + const deleteCard = useCallback( + (id: string) => { + removeCards(new Set([id])); + removeArrowsForCard(id); + // Deleting an image card may orphan its asset — reconcile (debounced). + if (projectId && projectState) scheduleAssetGc(projectId, projectState); + }, + [removeCards, removeArrowsForCard, projectId, projectState], + ); + + /** + * Apply a card's new geometry, dragging the rest of the selection along when + * the card being moved is part of a multi-selection. Resizes stay single-card. + */ + const updateCard = useCallback( + (updatedCard: BoardCardData, options?: { transient?: boolean }) => { + const current = getCards(); + const isMultiDrag = selectedCardIds.has(updatedCard.id) && selectedCardIds.size > 1; + + if (isMultiDrag) { + const oldCard = current.find((c) => c.id === updatedCard.id); + const dx = oldCard ? updatedCard.x - oldCard.x : 0; + const dy = oldCard ? updatedCard.y - oldCard.y : 0; + const isResize = + !!oldCard && + (updatedCard.width !== oldCard.width || updatedCard.height !== oldCard.height); + + if ((dx !== 0 || dy !== 0) && !isResize) { + commitCards( + current.map((c) => { + if (c.id === updatedCard.id) return updatedCard; + if (selectedCardIds.has(c.id)) return { ...c, x: c.x + dx, y: c.y + dy }; + return c; + }), + options, + ); + return; + } + } + + commitCards( + current.map((c) => (c.id === updatedCard.id ? updatedCard : c)), + options, + ); + }, + [getCards, commitCards, selectedCardIds], + ); + + /** + * Send a card to the Timeline — to a specific layer when `layerId` is given, + * otherwise to the default (first root) lane. + */ + const sendToTimeline = useCallback( + (card: BoardCardData, layerId?: string) => { + repository?.appendTimelineClip( + { + source: "card", + refDocId: docId, + refId: card.id, + title: card.title, + preview: card.description, + color: card.color, + }, + undefined, + layerId, + ); + }, + [repository, docId], + ); + + return { + addCards, + removeCards, + createCard, + duplicateCard, + changeCardColor, + deleteCard, + updateCard, + sendToTimeline, + }; +} diff --git a/components/board/use-board-connections.ts b/components/board/use-board-connections.ts new file mode 100644 index 00000000..d1a7153c --- /dev/null +++ b/components/board/use-board-connections.ts @@ -0,0 +1,154 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Point } from "./board-geometry"; +import { BoardCamera } from "./use-board-camera"; + +/** + * Linking cards, both ways in: dragging out of a card's corner node (pointer, + * and its touch equivalent), and the tap-then-tap link tool. Plus the cut tool, + * which is the same subject from the other end. + */ +export function useBoardConnections( + camera: BoardCamera, + arrows: { addArrow: (fromCardId: string, toCardId: string) => void; removeArrow: (id: string) => void }, +) { + const { toCanvasPoint, captureGestureRect, releaseGestureRect, getGestureRect } = camera; + const { addArrow, removeArrow } = arrows; + + const [connectingFrom, setConnectingFrom] = useState<{ cardId: string; side: string } | null>( + null, + ); + const [connectingLine, setConnectingLine] = useState(null); + + /** Link tool: the card tapped first, waiting for the target tap. */ + const [linkSource, setLinkSource] = useState(null); + // Mirrored into a ref so the card tap handler can stay identity-stable: it is + // a BoardCard prop, and a callback that changed on every tap would re-render + // every card on the board (see the memo note at the bottom of BoardCard). + const linkSourceRef = useRef(null); + + const startConnection = useCallback( + (cardId: string, side: string, initialX: number, initialY: number) => { + captureGestureRect(); + setConnectingFrom({ cardId, side }); + setConnectingLine({ x: initialX, y: initialY }); + }, + [captureGestureRect], + ); + + const cancelConnection = useCallback(() => { + setConnectingFrom(null); + setConnectingLine(null); + }, []); + + const completeConnection = useCallback( + (toCardId: string) => { + if (connectingFrom) addArrow(connectingFrom.cardId, toCardId); + cancelConnection(); + }, + [connectingFrom, addArrow, cancelConnection], + ); + + // Track the pointer/finger while a connection is being dragged out. + useEffect(() => { + if (!connectingFrom) return; + + const trackTo = (clientX: number, clientY: number) => { + if (!getGestureRect()) return; + setConnectingLine(toCanvasPoint(clientX, clientY)); + }; + + const onMouseMove = (e: MouseEvent) => trackTo(e.clientX, e.clientY); + const onTouchMove = (e: TouchEvent) => { + const touch = e.touches[0]; + if (touch) trackTo(touch.clientX, touch.clientY); + }; + // Read from the release point itself, not from the last point touchmove + // happened to report: a connection begun and let go without the finger + // moving fires no touchmove at all, and resolving a stale point drops the + // link on whatever card sat under the *previous* gesture's endpoint. + const onTouchEnd = (e: TouchEvent) => { + const touch = e.changedTouches[0]; + if (!touch) return cancelConnection(); + const el = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement | null; + const targetId = el?.closest("[data-card-id]")?.getAttribute("data-card-id"); + if (targetId) completeConnection(targetId); + else cancelConnection(); + }; + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", cancelConnection); + window.addEventListener("touchmove", onTouchMove, { passive: true }); + window.addEventListener("touchend", onTouchEnd); + + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", cancelConnection); + window.removeEventListener("touchmove", onTouchMove); + window.removeEventListener("touchend", onTouchEnd); + releaseGestureRect(); + }; + }, [ + connectingFrom, + completeConnection, + cancelConnection, + toCanvasPoint, + getGestureRect, + releaseGestureRect, + ]); + + const clearLinkSource = useCallback(() => { + linkSourceRef.current = null; + setLinkSource(null); + }, []); + + /** + * A tap on a card with the link tool armed. The first tap picks the source, + * the second draws the arrow to it; tapping the source again lets go of it. + * The tool stays armed after a link lands, so building a chain is one + * uninterrupted run of taps rather than a round trip to the toolbar each time. + */ + const handleLinkTap = useCallback( + (cardId: string) => { + const source = linkSourceRef.current; + if (!source) { + linkSourceRef.current = cardId; + setLinkSource(cardId); + return; + } + if (source !== cardId) addArrow(source, cardId); + clearLinkSource(); + }, + [addArrow, clearLinkSource], + ); + + /** + * Cut whatever link lies under a viewport point, if any. + * + * Hit-testing through `elementFromPoint` reuses the arrows' own hitbox + * strokes — widened while the tool is armed, see .arrow_group_cut — instead + * of re-deriving every bezier here. Cards render above the arrow layer, so a + * slash passing over one simply cuts nothing for that stretch, which is the + * right answer anyway: the link is hidden behind the card there. + */ + const cutArrowAt = useCallback( + (clientX: number, clientY: number) => { + const el = document.elementFromPoint(clientX, clientY); + const id = el?.closest("[data-arrow-id]")?.getAttribute("data-arrow-id"); + if (id) removeArrow(id); + }, + [removeArrow], + ); + + return { + connectingFrom, + connectingLine, + startConnection, + completeConnection, + linkSource, + clearLinkSource, + handleLinkTap, + cutArrowAt, + }; +} diff --git a/components/board/use-board-document.ts b/components/board/use-board-document.ts new file mode 100644 index 00000000..1b2ac081 --- /dev/null +++ b/components/board/use-board-document.ts @@ -0,0 +1,185 @@ +"use client"; + +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { v7 as uuidv7 } from "uuid"; +import { ProjectContext } from "@src/context/ProjectContext"; +import { BoardArrowData, BoardCardData } from "@src/lib/project/project-state"; + +export type BoardDocument = ReturnType; + +/** + * The board's cards and arrows, kept in sync with the project's Yjs document. + * + * Both lists are mirrored into refs alongside their state. Every mutation + * composes onto the ref rather than onto the state its closure captured: a + * gesture emits several updates inside a single React batch (one slash of the + * cut tool deletes several arrows across consecutive move frames), and starting + * from stale state would undo the ones before it. It also keeps the mutation + * callbacks identity-stable, which matters because they are props of a memoised + * BoardCard — a fresh identity per frame re-renders every card on the board. + * + * `onFirstLoad` fires once, with the cards the board opened on, for the caller + * to place its camera. + */ +export function useBoardDocument(docId: string, onFirstLoad: (cards: BoardCardData[]) => void) { + const { repository, isYjsReady, isReadOnly } = useContext(ProjectContext); + const projectState = repository?.getState(); + + const [cards, setCards] = useState([]); + const cardsRef = useRef([]); + const [arrows, setArrows] = useState([]); + const arrowsRef = useRef([]); + + const hasLoaded = useRef(false); + /** Last `cards` payload this client wrote, to recognise the observer's echo. */ + const lastSavedCards = useRef(null); + + // Read through a ref so a caller that rebuilds the callback doesn't tear the + // board's Yjs subscription down and back up. + const onFirstLoadRef = useRef(onFirstLoad); + useEffect(() => { + onFirstLoadRef.current = onFirstLoad; + }, [onFirstLoad]); + + useEffect(() => { + if (!projectState || !isYjsReady) return; + + const boardMap = projectState.boardData(docId); + + const syncFromDoc = () => { + const cardsData = boardMap.get("cards"); + // Y.Map observers fire for local writes too, so our own save echoes + // straight back. Re-parsing it would rebuild every card object and + // re-render the whole board a second time for a state it is already + // in — pure waste, and paid on every committed drag. (Arrows below + // are still synced: a peer may have touched those and nothing else.) + const isOwnEcho = + hasLoaded.current && + typeof cardsData === "string" && + cardsData === lastSavedCards.current; + + if (isOwnEcho) { + // nothing to apply: local state already is this payload + } else if (cardsData) { + try { + const parsed: BoardCardData[] = + typeof cardsData === "string" ? JSON.parse(cardsData) : cardsData; + cardsRef.current = parsed; + setCards(parsed); + if (!hasLoaded.current) { + hasLoaded.current = true; + onFirstLoadRef.current(parsed); + } + } catch (e) { + console.error("[BoardCanvas] Failed to parse cards:", e); + } + } else if (!hasLoaded.current) { + // Empty board — nothing to frame, but the camera is settled. + hasLoaded.current = true; + onFirstLoadRef.current([]); + } + + const arrowsData = boardMap.get("arrows"); + if (arrowsData) { + try { + const parsed: BoardArrowData[] = + typeof arrowsData === "string" ? JSON.parse(arrowsData) : arrowsData; + arrowsRef.current = parsed; + setArrows(parsed); + } catch (e) { + console.error("[BoardCanvas] Failed to parse arrows:", e); + } + } + }; + + syncFromDoc(); + boardMap.observe(syncFromDoc); + return () => boardMap.unobserve(syncFromDoc); + }, [projectState, isYjsReady, docId]); + + const getCards = useCallback(() => cardsRef.current, []); + + /** + * Land a card-list change everywhere it has to go: the ref the next mutation + * composes onto, the render state, and Yjs. + * + * `transient` marks a frame of a live drag/resize. Those only move local + * state: writing to Yjs per frame stringifies the entire board, opens a + * transaction (which fans out to persistence and to every peer) and then + * echoes back through the observer — tens of times a second, for a position + * that is about to change again. The gesture's last update is sent without + * the flag and is what actually gets stored (see BoardCard's commit). + */ + const commitCards = useCallback( + (newCards: BoardCardData[], options?: { transient?: boolean }) => { + cardsRef.current = newCards; + setCards(newCards); + if (options?.transient || !projectState || !isYjsReady || isReadOnly) return; + const payload = JSON.stringify(newCards); + lastSavedCards.current = payload; // so the observer can skip its echo + projectState.boardData(docId).set("cards", payload); + }, + [projectState, isYjsReady, isReadOnly, docId], + ); + + /** + * Same for arrows. Read-only sessions stop before touching local state + * rather than just skipping the save, which would leave the board showing + * links the project doesn't have. + */ + const commitArrows = useCallback( + (newArrows: BoardArrowData[]) => { + if (isReadOnly) return; + arrowsRef.current = newArrows; + setArrows(newArrows); + if (!projectState || !isYjsReady) return; + projectState.boardData(docId).set("arrows", JSON.stringify(newArrows)); + }, + [projectState, isYjsReady, isReadOnly, docId], + ); + + /** Link two cards. Self-links and pairs already joined either way are no-ops. */ + const addArrow = useCallback( + (fromCardId: string, toCardId: string) => { + if (fromCardId === toCardId) return; + const exists = arrowsRef.current.some( + (a) => + (a.fromCardId === fromCardId && a.toCardId === toCardId) || + (a.fromCardId === toCardId && a.toCardId === fromCardId), + ); + if (exists) return; + commitArrows([...arrowsRef.current, { id: uuidv7(), fromCardId, toCardId }]); + }, + [commitArrows], + ); + + const removeArrow = useCallback( + (id: string) => { + const newArrows = arrowsRef.current.filter((a) => a.id !== id); + if (newArrows.length !== arrowsRef.current.length) commitArrows(newArrows); + }, + [commitArrows], + ); + + /** Drop every arrow touching the given card (it is going away). */ + const removeArrowsForCard = useCallback( + (cardId: string) => { + commitArrows( + arrowsRef.current.filter((a) => a.fromCardId !== cardId && a.toCardId !== cardId), + ); + }, + [commitArrows], + ); + + return { + cards, + /** The live card list, for handlers that compose several edits per frame. */ + getCards, + commitCards, + arrows, + commitArrows, + addArrow, + removeArrow, + removeArrowsForCard, + }; +} diff --git a/components/board/use-board-selection.ts b/components/board/use-board-selection.ts new file mode 100644 index 00000000..04330626 --- /dev/null +++ b/components/board/use-board-selection.ts @@ -0,0 +1,102 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { BoardCardData } from "@src/lib/project/project-state"; +import styles from "./BoardCanvas.module.css"; +import { BoardCamera } from "./use-board-camera"; + +export type SelectionRect = { startX: number; startY: number; endX: number; endY: number }; + +/** A marquee under this size (canvas px) counts as a click, not a selection. */ +const MIN_MARQUEE_SIZE = 5; + +/** + * Multi-selection: the set of selected cards and the left-drag marquee that + * fills it. Coordinates are canvas-space, so the rectangle keeps its grip on + * the board if the camera moves mid-drag. + */ +export function useBoardSelection(camera: BoardCamera, getCards: () => BoardCardData[]) { + const [selectedCardIds, setSelectedCardIds] = useState>(new Set()); + const [selectionRect, setSelectionRect] = useState(null); + const isSelecting = useRef(false); + + const clearSelection = useCallback(() => setSelectedCardIds(new Set()), []); + + const { toCanvasPoint, captureGestureRect, releaseGestureRect, getGestureRect } = camera; + + /** Left-click on empty canvas starts a marquee. */ + const handleSelectionMouseDown = useCallback( + (e: React.MouseEvent) => { + if (e.button !== 0) return; + const target = e.target as HTMLElement; + if ( + target.closest(`.${styles.card}`) || + target.closest(`.${styles.zoom_controls}`) || + target.closest("[data-context-menu]") + ) + return; + + if (!captureGestureRect()) return; + const { x, y } = toCanvasPoint(e.clientX, e.clientY); + + isSelecting.current = true; + setSelectionRect({ startX: x, startY: y, endX: x, endY: y }); + clearSelection(); + }, + [captureGestureRect, toCanvasPoint, clearSelection], + ); + + const isMarqueeActive = selectionRect !== null; + useEffect(() => { + if (!isMarqueeActive) return; + + const onMouseMove = (e: MouseEvent) => { + if (!isSelecting.current || !getGestureRect()) return; + const { x, y } = toCanvasPoint(e.clientX, e.clientY); + setSelectionRect((prev) => (prev ? { ...prev, endX: x, endY: y } : null)); + }; + + const onMouseUp = () => { + if (isSelecting.current) { + setSelectionRect((rect) => { + if (rect) selectCardsIn(rect, getCards(), setSelectedCardIds); + return null; // Clear the marquee + }); + } else { + setSelectionRect(null); + } + isSelecting.current = false; + releaseGestureRect(); + }; + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [isMarqueeActive, toCanvasPoint, releaseGestureRect, getGestureRect, getCards]); + + return { selectedCardIds, clearSelection, selectionRect, handleSelectionMouseDown }; +} + +/** Select every card the marquee touches, unless it is click-sized. */ +function selectCardsIn( + rect: SelectionRect, + cards: BoardCardData[], + setSelected: (ids: Set) => void, +) { + const left = Math.min(rect.startX, rect.endX); + const top = Math.min(rect.startY, rect.endY); + const right = Math.max(rect.startX, rect.endX); + const bottom = Math.max(rect.startY, rect.endY); + + if (right - left <= MIN_MARQUEE_SIZE && bottom - top <= MIN_MARQUEE_SIZE) return; + + const selected = new Set(); + for (const card of cards) { + if (card.x < right && card.x + card.width > left && card.y < bottom && card.y + card.height > top) + selected.add(card.id); + } + setSelected(selected); +} diff --git a/components/board/use-board-touch.ts b/components/board/use-board-touch.ts new file mode 100644 index 00000000..2f89f924 --- /dev/null +++ b/components/board/use-board-touch.ts @@ -0,0 +1,225 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import styles from "./BoardCanvas.module.css"; +import { BoardTool } from "./board-constants"; +import { clampScale } from "./board-geometry"; +import { BoardCamera } from "./use-board-camera"; + +/** Hold time before a press on empty canvas opens the canvas menu. */ +const LONG_PRESS_MS = 500; +/** Window and slop within which two taps count as a double-tap. */ +const DOUBLE_TAP_MS = 300; +const DOUBLE_TAP_SLOP = 30; +/** Movement (screen px) that turns a press into a pan. */ +const PAN_SLOP = 8; +/** How long after a touch the mouse events WebKit synthesizes keep arriving. */ +const SYNTHETIC_MOUSE_MS = 700; + +type TouchGesture = { + mode: "none" | "pan" | "pinch" | "cut"; + startX: number; + startY: number; + startOffset: { x: number; y: number }; + startDist: number; + startScale: number; + pinchCanvasX: number; + pinchCanvasY: number; + moved: boolean; +}; + +/** + * Container touch gestures. One finger pans the canvas; two fingers pinch-zoom + * (centred on the pinch); a double-tap on empty canvas creates a card; a + * long-press opens the canvas menu. Cards and handles stop propagation, so + * their touches never reach here. + */ +export function useBoardTouch(options: { + camera: BoardCamera; + tool: BoardTool; + isConnecting: boolean; + onCutAt: (clientX: number, clientY: number) => void; + onLongPress: (clientX: number, clientY: number) => void; + onDoubleTap: (canvasX: number, canvasY: number) => void; + onCancelLink: () => void; +}) { + const { camera, tool, isConnecting, onCutAt, onLongPress, onDoubleTap, onCancelLink } = options; + const { setOffset, setScale, getOffset, getScale, toCanvasPoint } = camera; + + const gesture = useRef({ + mode: "none", + startX: 0, + startY: 0, + startOffset: { x: 0, y: 0 }, + startDist: 0, + startScale: 1, + pinchCanvasX: 0, + pinchCanvasY: 0, + moved: false, + }); + const longPressTimer = useRef | null>(null); + const lastTap = useRef({ time: 0, x: 0, y: 0 }); + + // Timestamp of the most recent touch activity, used to ignore the mouse + // events WebKit synthesizes at the end of a touch gesture. + // + // The mouse handlers stay attached even on touch devices, because an iPad + // reports `pointer: coarse` whether or not a trackpad is attached — dropping + // them would leave Magic Keyboard users unable to pan or drag. Without this + // guard a one-finger pan would also fire the synthesized mousedown and start + // a marquee selection on top of the pan. Refreshed on every touch event (not + // just touchstart) so a long drag doesn't age out of the window mid-gesture. + const lastTouch = useRef(0); + const isSyntheticMouse = useCallback( + () => Date.now() - lastTouch.current < SYNTHETIC_MOUSE_MS, + [], + ); + + const cancelLongPress = () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }; + useEffect(() => cancelLongPress, []); + + /** Board chrome and cards handle their own touches — the canvas ignores them. */ + const isChromeTarget = (target: HTMLElement) => + !!( + target.closest(`.${styles.card}`) || + target.closest(`.${styles.zoom_controls}`) || + target.closest(`.${styles.tool_controls}`) || + target.closest(`.${styles.recording_indicator}`) || + target.closest("[data-context-menu]") + ); + + const handleTouchStart = (e: React.TouchEvent) => { + lastTouch.current = Date.now(); + if (isConnecting) return; + if (isChromeTarget(e.target as HTMLElement)) return; + + // Cut tool: one finger slashes through links rather than panning. Two + // still pinch/pan, so the board stays navigable without disarming the + // tool between cuts. + if (tool === "cut" && e.touches.length === 1) { + cancelLongPress(); + gesture.current = { ...gesture.current, mode: "cut", moved: false }; + onCutAt(e.touches[0].clientX, e.touches[0].clientY); + return; + } + + if (e.touches.length === 1) { + const touch = e.touches[0]; + gesture.current = { + ...gesture.current, + mode: "pan", + startX: touch.clientX, + startY: touch.clientY, + startOffset: { ...getOffset() }, + moved: false, + }; + cancelLongPress(); + const pressX = touch.clientX; + const pressY = touch.clientY; + longPressTimer.current = setTimeout(() => { + gesture.current.mode = "none"; + onLongPress(pressX, pressY); + }, LONG_PRESS_MS); + } else if (e.touches.length === 2) { + cancelLongPress(); + camera.captureGestureRect(); + const [a, b] = [e.touches[0], e.touches[1]]; + const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); + const mid = toCanvasPoint((a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2); + gesture.current = { + ...gesture.current, + mode: "pinch", + startDist: dist || 1, + startScale: getScale(), + pinchCanvasX: mid.x, + pinchCanvasY: mid.y, + moved: true, + }; + } + }; + + const handleTouchMove = (e: React.TouchEvent) => { + lastTouch.current = Date.now(); + const g = gesture.current; + + if (g.mode === "cut" && e.touches.length === 1) { + // Sampled per move event, so a fast flick can step over a link + // between frames — the deliberate stroke the tool asks for lands + // every time, and a stationary tap cuts on touchstart regardless. + onCutAt(e.touches[0].clientX, e.touches[0].clientY); + } else if (g.mode === "pan" && e.touches.length === 1) { + const touch = e.touches[0]; + const dx = touch.clientX - g.startX; + const dy = touch.clientY - g.startY; + if (!g.moved && Math.hypot(dx, dy) > PAN_SLOP) { + g.moved = true; + cancelLongPress(); + } + if (g.moved) setOffset({ x: g.startOffset.x + dx, y: g.startOffset.y + dy }); + } else if (g.mode === "pinch" && e.touches.length >= 2) { + const rect = camera.getGestureRect(); + if (!rect) return; + const [a, b] = [e.touches[0], e.touches[1]]; + const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); + const midX = (a.clientX + b.clientX) / 2; + const midY = (a.clientY + b.clientY) / 2; + const newScale = clampScale(g.startScale * (dist / g.startDist)); + setScale(newScale); + setOffset({ + x: midX - rect.left - g.pinchCanvasX * newScale, + y: midY - rect.top - g.pinchCanvasY * newScale, + }); + } + }; + + const handleTouchEnd = (e: React.TouchEvent) => { + lastTouch.current = Date.now(); + cancelLongPress(); + const g = gesture.current; + const isTap = g.mode === "pan" && !g.moved; + + // A tap on empty canvas with the link tool armed drops the pending source + // instead of creating a card — double-tap-to-create belongs to the plain + // board, and reaching for it mid-link is far more likely to be a miss. + if (tool === "link" && isTap) { + onCancelLink(); + } else if (isTap && e.changedTouches.length > 0) { + const touch = e.changedTouches[0]; + const now = Date.now(); + const isDoubleTap = + now - lastTap.current.time < DOUBLE_TAP_MS && + Math.hypot(touch.clientX - lastTap.current.x, touch.clientY - lastTap.current.y) < + DOUBLE_TAP_SLOP; + if (isDoubleTap) { + const point = toCanvasPoint(touch.clientX, touch.clientY); + onDoubleTap(point.x, point.y); + lastTap.current = { time: 0, x: 0, y: 0 }; + } else { + lastTap.current = { time: now, x: touch.clientX, y: touch.clientY }; + } + } + + if (e.touches.length === 0) { + g.mode = "none"; + camera.releaseGestureRect(); + } else if (e.touches.length === 1) { + // A finger lifted from a pinch — resume panning with the one that remains. + const touch = e.touches[0]; + gesture.current = { + ...g, + mode: "pan", + startX: touch.clientX, + startY: touch.clientY, + startOffset: { ...getOffset() }, + moved: true, + }; + } + }; + + return { handleTouchStart, handleTouchMove, handleTouchEnd, isSyntheticMouse }; +} diff --git a/components/dashboard/DashboardModal.module.css b/components/dashboard/DashboardModal.module.css index 88038ab1..784acfb9 100644 --- a/components/dashboard/DashboardModal.module.css +++ b/components/dashboard/DashboardModal.module.css @@ -9,19 +9,28 @@ z-index: 1000; } +/* Sized off the viewport rather than to a fixed box, so the modal keeps roughly + * the same share of the screen through a rotation. The old `900px/max-width:90vw` + * pair read well in tablet portrait (834w → 90vw wins, filling the screen) but + * fell apart in landscape: past 1000w the 900px width takes over and stops + * growing while `80vh` simultaneously *takes away* height (834h → 667px), so the + * modal ended up both relatively narrower and absolutely shorter than the layout + * it had just rotated from. The min() pairs below scale on both axes instead. */ .modal { - width: 900px; - max-width: 90vw; - height: 800px; - max-height: 80vh; + width: min(1100px, 92vw); + height: min(840px, 90vh); display: flex; border-radius: 16px; overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); } +/* Fixed rather than `flex: 1`: the nav is a list of eight short labels, so a + * wider modal (landscape, desktop) has nothing to spend the extra width on here. + * Pinning it sends all of the growth to .content, which does. */ .sidebar { - flex: 1; + width: 260px; + flex: none; background: var(--editor-sidebar); padding: 32px 0; border-right: 1px solid var(--separator); @@ -288,6 +297,9 @@ * home Settings button). */ .sidebar { display: none; + /* Undo the desktop nav's fixed width: here it is a whole drawer screen, + * so it fills the drawer rather than sitting in a column of its own. */ + width: auto; flex: 1; padding: 12px calc(var(--drawer-gutter) + var(--safe-right)) calc(16px + var(--safe-bottom)) var(--drawer-gutter); diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 2c7aa094..9516e61a 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -1241,7 +1241,13 @@ const DocumentEditorPanel = ({ if (!isLocalAccess && (!membership || isLoading)) return ; return ( -
+
{ + const t = useTranslations("navbar"); + const [open, setOpen] = useState(false); + const islandRef = useRef(null); + + // Dismiss on a tap outside the island. Registered in the *capture* phase so a + // handler that stops propagation — every control in this row does, to keep the + // tap off the editor — can't leave the menu stranded open. + useEffect(() => { + if (!open) return; + const onDown = (e: PointerEvent) => { + if (islandRef.current && !islandRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("pointerdown", onDown, true); + return () => document.removeEventListener("pointerdown", onDown, true); + }, [open]); + + return ( +
+
+ +
+ +
+ ); +}; + +/** + * The row of floating chrome along the bottom of the screen while something is + * being written on a touch device: undo/redo on the left, the format pill + * ([MobileFormatToolbar]) in the middle, the view-mode burger on the right. + * + * Owns the positioning for all three. `--vv-inset` is how much an on-screen + * keyboard covers, so the row rides its top edge; below KEYBOARD_MIN_HEIGHT it is + * zeroed and the CSS floors the row at its resting height instead. See + * .bar_row for why that cutoff is not simply "anything at all". + * + * Present exactly while an editor holds focus, and nothing else. It used to stay + * mounted throughout and change shape underneath the reader: a lone burger over a + * board — where every control behind it acts on a text editor that isn't there — + * then undo/redo added once a panel with an editor was up, then the format pill + * added again on focus. Three shapes for chrome nobody had asked for yet. Now + * there is one shape and it means one thing. The price is that undo/redo and the + * view toggles, which have nowhere else to live on a tablet, want a tap into the + * script first. + * + * The flanking islands stay tablet-only: a phone has both elsewhere already — + * undo/redo in the navbar's edit-mode cluster, the view toggles in the footer + * bubble — and no width to spare for three islands. + */ +const EditorBottomBar = () => { + const isTouch = useIsTouch(); + const isPhone = useIsPhone(); + // The editor being written in, resolved from the *panel* so a split answers + // with the side holding focus rather than the one that happens to be primary. + const historyEditor = useActiveEditor(); + // Whether it actually holds focus — which is not the same question as "is the + // keyboard up": the screenplay search raises one over an of its own, + // and none of this row applies to that. + const editorFocused = useEditorFocused(historyEditor); + // Handed null, that hook keeps reporting whatever it last latched — it has + // nothing to re-seed from and its consumers all need an editor anyway (see + // useEditorFocused). Switching to a board is exactly that case, so pair it + // with the editor still being there or the row survives the switch. + const isWriting = !!historyEditor && editorFocused; + + // How much is covered at the bottom of the viewport right now. Only a real + // keyboard is worth riding: dismissing one on an iPad leaves ~173px still + // reported as covered (the region iOS reserves around its shortcuts bar, + // nearly all of it empty), and following that parks the row a hand's width off + // the screen edge with nothing under it. + const bottomInset = useViewportBottomInset(isTouch); + const keyboardCover = bottomInset >= KEYBOARD_MIN_HEIGHT ? bottomInset : 0; + + const hasIslands = isTouch && !isPhone; + + if (!isTouch || !isWriting) return null; + + return ( +
e.preventDefault()} + > + {/* Undo/redo, outside the pill rather than in it: they act on the + document as a whole, not on the caret's block, and the pill's width + is derived from the controls that do (--tb-base-width). */} + {hasIslands && historyEditor && ( +
+ +
+ )} + + + + {hasIslands && } +
+ ); +}; + +export default EditorBottomBar; diff --git a/components/editor/EditorPanel.module.css b/components/editor/EditorPanel.module.css index b1f2f7a3..435d7609 100644 --- a/components/editor/EditorPanel.module.css +++ b/components/editor/EditorPanel.module.css @@ -7,6 +7,26 @@ background-color: var(--main-bg); } +/* + * The editor is mounted but not being shown. Take its subtree out of layout and + * paint — the same treatment, and for the same reason, as .panel_hidden in + * SplitPanelContainer.module.css: a 120-page screenplay is an enormous + * ProseMirror DOM, and while it sits in the layout tree *every* forced reflow + * anywhere on screen re-lays it out too. + * + * .panel_hidden already covers the case where the whole panel is swapped away. + * This covers the one it can't see: a panel that is itself visible while its + * editor is parked behind an overlay — the index-card grid (SceneCardsPanel), + * which reads card rects on every frame of a drag and would otherwise flush a + * full-document relayout on each one. + * + * Layout containment only; the editor keeps its rendered state, so coming back + * to the script is instant rather than a re-initialisation. + */ +.parked { + content-visibility: hidden; +} + .container { position: relative; flex: 1; diff --git a/components/editor/MobileFormatToolbar.module.css b/components/editor/MobileFormatToolbar.module.css index a2b89032..9b137b7b 100644 --- a/components/editor/MobileFormatToolbar.module.css +++ b/components/editor/MobileFormatToolbar.module.css @@ -1,16 +1,22 @@ -/* Touch-only formatting bar that floats just above the on-screen keyboard. - * `bottom` is set inline from the tracked keyboard inset (see - * MobileFormatToolbar.tsx). */ +/* The format pill: one item in the bottom chrome row, which positions it (see + * EditorBottomBar.module.css). A rounded island rather than a full-width docked + * bar, so it echoes the iOS keyboard's rounded shape and reads as part of the + * same floating chrome instead of a hard-edged bar butted against it. */ .toolbar { - position: fixed; - left: 0; - right: 0; - z-index: 60; + /* Anchor for the spelling panel and the element menu, both of which hang off + * the pill's own edges — without this they would resolve against the row, + * which is the full width of the screen. */ + position: relative; + /* The row is click-through so its gaps don't swallow taps meant for the + * script; each island opts back in for itself. */ + pointer-events: auto; /* Metrics of the controls, kept here rather than inlined at each rule so the - * width cap below can be *derived* from them and stays true if a control is + * width below can be *derived* from them and stays true if a control is * resized. Consumed by .btn, .group, .separator, .element_trigger and - * .format_group. */ + * .format_group — all inside this pill. The row's own islands deliberately do + * not read them: they are a separate stylesheet and match the 40px button size + * by hand rather than reaching across for a private custom property. */ --tb-btn-size: 40px; --tb-btn-gap: 4px; --tb-group-gap: 6px; @@ -32,26 +38,17 @@ (2 * var(--tb-group-width)) + (2 * var(--tb-group-gap)) + var(--tb-separator-width) ); - /* Float as a rounded island rather than a full-width docked bar: inset from - * the screen edges and lifted off the keyboard's top edge (margins), with - * rounded corners of its own, so it echoes the iOS keyboard's rounded shape - * and reads as part of the same floating chrome instead of a hard-edged bar - * butted against it. `bottom: keyboardInset` (inline) pins it to the keyboard - * top; the bottom margin is the visible gap between the two. */ - margin: 0 auto 8px; - /* The 8px side gutter, expressed as a width rather than a margin so the cap - * below can centre the bar. On a tablet the gutter alone would leave the bar - * an iPad-wide pill around a control row that needs barely half of it — a - * third of it empty, with the controls flung to the far edges. Capping at - * --tb-base-width shrink-wraps it to the controls instead (auto margins then - * centre it) and, because that is the width of the *always-present* ones, the - * advanced actions land past the edge of .format_group and are reached by + /* Basis rather than a width: on a tablet the bar shrink-wraps to + * --tb-base-width, which is the width of the *always-present* controls, so + * the advanced actions land past the edge of .format_group and are reached by * scrolling the row rather than by widening the bar to hold them. Sizing off * the constant set also keeps the width from twitching as the caret moves in - * and out of the contexts those actions apply to. The cap never binds on a - * phone, where the gutter width is the smaller of the two. */ - width: calc(100% - 16px); - max-width: var(--tb-base-width); + * and out of the contexts those actions apply to. On a phone the row is + * narrower than that basis, so the bar shrinks to the available width instead + * and the controls scroll — hence min-width: 0, without which a flex item + * refuses to shrink below its content. */ + flex: 0 1 var(--tb-base-width); + min-width: 0; display: flex; flex-direction: row; diff --git a/components/editor/MobileFormatToolbar.tsx b/components/editor/MobileFormatToolbar.tsx index 0f731a3e..6f39b797 100644 --- a/components/editor/MobileFormatToolbar.tsx +++ b/components/editor/MobileFormatToolbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { AlignCenter, @@ -20,13 +20,14 @@ import { import { ProjectContext } from "@src/context/ProjectContext"; import { useSpellcheck } from "@src/context/SpellcheckContext"; -import { useIsTouch, useKeyboardInset } from "@src/lib/utils/hooks"; +import { useIsTouch } from "@src/lib/utils/hooks"; import { applyElement, applyMarkToggle } from "@src/lib/screenplay/editor"; import { applyTitlePageElement, applyTitlePageMarkToggle } from "@src/lib/titlepage/editor"; -import { canMakeDualDialogue, makeDualDialogue } from "@src/lib/screenplay/dual-dialogue"; -import { getNodeIdAtPos } from "@src/lib/screenplay/comment-anchors"; -import { getSpellErrorAt, refreshSpellcheck } from "@src/lib/spellcheck/spellcheck-extension"; +import { makeDualDialogue } from "@src/lib/screenplay/dual-dialogue"; +import { refreshSpellcheck } from "@src/lib/spellcheck/spellcheck-extension"; import { getAddComment } from "@src/lib/editor/comment-actions"; +import { useCaretState } from "@src/lib/editor/use-caret-state"; +import { useEditorFocused } from "@src/lib/editor/use-editor-focused"; import { ScreenplayElement, Style, TitlePageElement } from "@src/lib/utils/enums"; import { join } from "@src/lib/utils/misc"; @@ -55,63 +56,27 @@ const TITLEPAGE_ELEMENTS_ORDER: TitlePageElement[] = [ ]; /** - * Everything about the caret's surroundings the bar renders from: the block's - * alignment plus which of the advanced (right-click-only on desktop) actions - * apply here. Recomputed on every transaction, so it is kept - * as one value that is only replaced when something actually differs — see - * sameCaretState — to keep typing from re-rendering the bar on every keystroke. - */ -type CaretState = { - align: string; - /** Misspelling under the caret, if the editor has spellcheck decorations. */ - spellError: { word: string; from: number; to: number } | null; - /** Node the caret sits in, when it can anchor a comment. */ - commentNodeId: string | null; - /** Top-level block under the caret + whether it already forces a break. */ - pageBreak: { pos: number; active: boolean } | null; - /** - * Start of a character block that can be merged with the one after it. - * The block's start rather than the caret, though makeDualDialogue accepts - * either: the caret moves with every keystroke inside the block, which would - * fail sameCaretState and re-render the bar for a value that never changed. - */ - dualDialoguePos: number | null; -}; - -const EMPTY_CARET_STATE: CaretState = { - align: "left", - spellError: null, - commentNodeId: null, - pageBreak: null, - dualDialoguePos: null, -}; - -const sameCaretState = (a: CaretState, b: CaretState) => - a.align === b.align && - a.commentNodeId === b.commentNodeId && - a.dualDialoguePos === b.dualDialoguePos && - a.spellError?.word === b.spellError?.word && - a.spellError?.from === b.spellError?.from && - a.spellError?.to === b.spellError?.to && - a.pageBreak?.pos === b.pageBreak?.pos && - a.pageBreak?.active === b.pageBreak?.active; - -/** - * Touch-device formatting bar that rides just above the on-screen keyboard while - * a screenplay/title editor is focused. Surfaces the element-type selector (moved - * here from the navbar so it's within thumb reach while writing) plus the inline - * styling (bold, italic, underline) and alignment controls. + * Touch-device formatting bar shown while a screenplay/title editor is focused. + * Surfaces the element-type selector (moved here from the navbar so it's within + * thumb reach while writing) plus the inline styling (bold, italic, underline) + * and alignment controls. * * Gated on the pointer type rather than the phone width so tablets get it too — * an iPad writing with the on-screen keyboard needs the element picker in thumb - * reach just as much as a phone does. The keyboard-inset check below keeps it out - * of the way when a hardware keyboard is attached (no inset, so nothing renders). + * reach just as much as a phone does. On touch this is the *only* route to these + * controls, the desktop bar having dropped its format dropdown in its favour, so + * it shows whenever an editor is focused and never mind what the keyboard is + * doing. * * Past those, the scrollable row continues into the actions a touch device has no * other way to reach: they live behind a right-click on desktop, which has no * touch equivalent — comment, manual page break, dual dialogue, and spelling - * suggestions. Each only appears where it applies (see CaretState), so scrolling - * that far only ever turns up something usable. + * suggestions. Each only appears where it applies (see {@link useCaretState}), so + * scrolling that far only ever turns up something usable. + * + * Renders as one item in [EditorBottomBar], which owns where the row sits — over + * the keyboard, or resting near the bottom edge when there isn't one — and the + * islands that flank it. Nothing here should reach for the viewport. * * The bar is only as wide as the controls in that first group (see * --tb-base-width), so on a tablet it stays a compact centred pill instead of an @@ -139,8 +104,6 @@ const MobileFormatToolbar = () => { } = useContext(ProjectContext); const { worker } = useSpellcheck(); - const keyboardInset = useKeyboardInset(isTouch); - const isTitleContext = focusedEditorType === "title"; const isDraftContext = focusedEditorType === "draft"; // Both the shelf draft and a tree document report "draft" as their focus type @@ -197,155 +160,37 @@ const MobileFormatToolbar = () => { const elementOrder = isTitleContext ? TITLEPAGE_ELEMENTS_ORDER : SCREENPLAY_ELEMENTS_ORDER; const currentElement = isTitleContext ? selectedTitlePageElement : selectedElement; - // Whether the target editor's contenteditable is actually focused. The keyboard - // being up isn't enough: opening the screenplay search focuses a plain - // (its own keyboard), and focusedEditorType is never cleared on blur — without - // this the toolbar would wrongly ride the search keyboard too. + const editorFocused = useEditorFocused(activeEditor); + + // Only mount on a touch device, once the target editor itself is focused. + // editorFocused excludes the case where another field (e.g. search) holds focus + // while a stale focusedEditorType lingers. // - // Subscribed to as an external store rather than mirrored into state by an - // effect: the editor may already hold focus by the time we subscribe (a swap - // between the shelf draft and a tree document lands on one that is focused - // already, its focus event long gone), so the flag has to be seeded from - // `isFocused` — and a seeding setState in an effect body is a cascading render - // on every editor swap, which is what react-hooks/set-state-in-effect flags. + // Deliberately NOT gated on whether a keyboard is up. The desktop bar drops its + // format dropdown on touch precisely because this pill owns those controls + // ([ProjectNavbarDesktop]), so an iPad on a Magic Keyboard was left with + // neither, and no way at all to reach the element picker, comments, spelling or + // page breaks. Where the pill sits is [EditorBottomBar]'s problem; whether it + // exists is this. // - // Latched in a ref instead of read straight off `activeEditor.isFocused` - // because a blur is acted on 150ms late: an editor mutation (e.g. a mark - // toggle) can blur and immediately re-focus within a tick, and that transient - // must not tear the toolbar down. A real blur (tapping the search field, - // dismissing the keyboard) stays blurred past the window and then hides it. - // getSnapshot has to be pure and synchronous, so the delay lives in the - // subscription, which latches the settled value and notifies. - const focusedCache = useRef(false); - const editorFocused = useSyncExternalStore( - useCallback( - (callback: () => void) => { - // Nothing to track, and nothing to reset: the bar needs an editor - // to show at all, and re-subscribing seeds from the new one. - if (!activeEditor) return () => {}; - let blurTimer: ReturnType | null = null; - const settle = (focused: boolean) => { - if (focusedCache.current === focused) return; - focusedCache.current = focused; - callback(); - }; - const onFocus = () => { - if (blurTimer) clearTimeout(blurTimer); - settle(true); - }; - const onBlur = () => { - if (blurTimer) clearTimeout(blurTimer); - blurTimer = setTimeout(() => settle(false), 150); - }; - activeEditor.on("focus", onFocus); - activeEditor.on("blur", onBlur); - settle(activeEditor.isFocused); - return () => { - if (blurTimer) clearTimeout(blurTimer); - activeEditor.off("focus", onFocus); - activeEditor.off("blur", onBlur); - }; - }, - [activeEditor], - ), - () => focusedCache.current, - () => false, - ); - - // Only mount on a touch device, once the target editor itself is focused and the - // on-screen keyboard is up. editorFocused excludes the case where another field - // (e.g. search) holds focus while a stale focusedEditorType lingers. // Declared up here, above its first use rather than next to the render, because // the caret subscription below is scoped to it. - const isVisible = - isTouch && keyboardInset > 0 && !!activeEditor && focusedEditorType !== null && editorFocused; - - // Keep the alignment highlight and the advanced actions in sync with the - // caret. `transaction` rather than `selectionUpdate`: every dispatch emits it, - // selection-only ones included, so the pair would only run this twice per - // caret move — and half of what the bar reads changes under a *stationary* - // caret anyway (spellcheck decorations landing from the worker, a page-break - // attribute flipping, a collaborator's edit), which selectionUpdate misses. - // That does mean running on every keystroke, so the whole read is a handful - // of position lookups (all off ProseMirror's resolve cache) and the result is - // only committed when it differs (sameCaretState), leaving typing - // re-render-free. - // - // Scoped to isVisible rather than just to touch, so the read costs nothing in - // the cases where it would only ever be thrown away: a mouse device, an iPad - // driving the editor from a hardware keyboard (no inset, so the bar never - // comes up), or any moment the editor doesn't hold focus. - // - // An external store like the focus flag above, for the same reason: the read - // has to be seeded from the editor's current state on subscribe, and doing - // that through setState renders twice every time the bar comes up. Here - // sameCaretState doubles as the snapshot's stability check — getSnapshot must - // return the same reference until something actually changes, or React would - // see a new value on every render and loop. - const caretCache = useRef(EMPTY_CARET_STATE); - const caret = useSyncExternalStore( - useCallback( - (callback: () => void) => { - // A stale caret while there is nothing to track is harmless — the - // bar is hidden, and read() below re-seeds it as part of - // re-subscribing, before it can show again. - if (!isVisible || !activeEditor) return () => {}; - const read = () => { - const { state } = activeEditor; - const { from, to } = state.selection; - const next: CaretState = { - ...EMPTY_CARET_STATE, - align: state.selection.$anchor.parent.attrs.textAlign || "left", - }; - - // The advanced actions are all screenplay-shaped; the title - // page has neither the nodes nor the pagination they act on. - if (!isTitleContext) { - next.spellError = - getSpellErrorAt(state, from) ?? - (to !== from ? getSpellErrorAt(state, to) : null); - next.commentNodeId = getNodeIdAtPos(state, from); - - const $pos = state.doc.resolve(from); - if ($pos.depth >= 1) { - const nodeStart = $pos.before(1); - // Never the document's first block — nothing to break - // before it. - if (nodeStart > 0) { - next.pageBreak = { - pos: nodeStart, - active: !!$pos.node(1).attrs.pageBreak, - }; - } - if ( - $pos.node(1).attrs.class === ScreenplayElement.Character && - canMakeDualDialogue(activeEditor, nodeStart) - ) { - next.dualDialoguePos = nodeStart; - } - } - } - - if (sameCaretState(caretCache.current, next)) return; - caretCache.current = next; - callback(); - }; - activeEditor.on("transaction", read); - read(); - return () => { - activeEditor.off("transaction", read); - }; - }, - [activeEditor, isVisible, isTitleContext], - ), - () => caretCache.current, - () => EMPTY_CARET_STATE, - ); + const isVisible = isTouch && !!activeEditor && focusedEditorType !== null && editorFocused; + - // Close an open menu only when the pointer-down lands outside the whole - // toolbar. Taps on the style/alignment controls (which sit outside the element - // wrapper but inside the bar) must not dismiss the menu. Scoping to the toolbar - // also means these taps never steal the editor focus / drop the keyboard. + // Keeps the alignment highlight and the advanced actions in sync with the + // caret. Scoped to isVisible so the per-transaction read costs nothing while + // the bar is hidden. + const caret = useCaretState(activeEditor, isVisible, isTitleContext); + + // Close an open menu only when the pointer-down lands outside the whole pill. + // Taps on the style/alignment controls (which sit outside the element wrapper + // but inside the pill) must not dismiss the menu. + // + // Capture phase, so a tap on one of the sibling islands ([EditorBottomBar]) + // still dismisses these: every control in that row stops propagation to keep + // the tap off the editor, which a bubble-phase listener on document would + // never see. useEffect(() => { if (!elementMenuOpen && !spellMenuWord) return; const onDown = (e: PointerEvent) => { @@ -354,8 +199,8 @@ const MobileFormatToolbar = () => { setSpellMenuWord(null); } }; - document.addEventListener("pointerdown", onDown); - return () => document.removeEventListener("pointerdown", onDown); + document.addEventListener("pointerdown", onDown, true); + return () => document.removeEventListener("pointerdown", onDown, true); }, [elementMenuOpen, spellMenuWord]); const spellWord = caret.spellError?.word ?? null; @@ -597,21 +442,7 @@ const MobileFormatToolbar = () => { {tapGuard && (
e.preventDefault()} /> )} -
e.preventDefault()} - > +
{/* Element-type selector — the primary control, opens a menu upward. */}
{elementMenuOpen && ( diff --git a/components/editor/SceneCardsPanel.module.css b/components/editor/SceneCardsPanel.module.css new file mode 100644 index 00000000..cc98cedb --- /dev/null +++ b/components/editor/SceneCardsPanel.module.css @@ -0,0 +1,364 @@ +/* The card grid sits *over* the screenplay editor rather than replacing it: the + editor stays mounted behind (see PanelRenderer) so switching views never + reinitialises it or drops the ProjectContext editor handle. z-index 12 clears + the comment gutter, which floats at that level inside the editor beneath; + equal specificity plus later DOM order puts this on top. Still below the + panel switcher (13), which has to stay reachable to switch back. */ +.container { + position: absolute; + inset: 0; + z-index: 12; + overflow-y: auto; + overflow-x: hidden; + background-color: var(--main-bg); +} + +/* While a card is lifted, the cursor says so everywhere on the grid — including + over the gutters between cards, where there is no card to carry it. */ +.dragging { + cursor: grabbing; +} + +.grid { + display: grid; + grid-template-columns: repeat(var(--cards-per-row, 3), minmax(0, 1fr)); + gap: calc(16px * var(--card-zoom, 1)); + /* Top clearance for the panel switcher and zoom pill, which float over this + corner. */ + padding: 52px 20px 40px; +} + +.empty_state { + padding: 60px 20px; + text-align: center; + font-size: 0.9rem; + color: var(--secondary-text); +} + +/* ── Card ─────────────────────────────────────────────────────────────────── */ + +/* One grid cell. Exists so the drop bar has somewhere unclipped to live: the + card below clips its overflow, which would swallow a bar drawn in the gutter + beside it. Carries no visuals of its own. */ +.card_slot { + position: relative; + display: flex; + min-width: 0; +} + +/* Deliberately the board card's shape — same header/body split, same shadow and + hover lift — so a scene reads the same whether it is on the corkboard or in + this grid. Geometry differs because these are laid out by the grid rather + than positioned on a canvas, and scale with the zoom (--card-zoom) so that + widening a card enlarges it instead of just stretching it. */ +.card { + position: relative; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + min-height: calc(170px * var(--card-zoom, 1)); + border-radius: 4px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + cursor: grab; + user-select: none; + transition: + box-shadow 0.15s ease, + opacity 0.15s ease, + transform 0.15s ease; +} + +/* Hover affordances only where a pointer can actually hover — same reasoning as + the board's cards: iOS emulates hover from the last tap point and re-resolves + it as content moves, which turns a transitioned shadow into a repaint storm + while cards sweep under a stale point. */ +@media (hover: hover) { + .card:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + } +} + +/* The hole the lifted card came out of: flattened, faded, and outlined so the + space reads as reserved rather than as a card that has gone strange. */ +.card_source { + opacity: 0.25; + box-shadow: none; + outline: 2px dashed var(--secondary-text); + outline-offset: -2px; +} + +/* The copy under the pointer. Its shadow is what sells the lift; the wrapper + (.drag_ghost) supplies the position, size, tilt and scale. */ +.card_ghost { + cursor: grabbing; + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.32); +} + +.drag_ghost { + position: fixed; + top: 0; + left: 0; + z-index: 200; + pointer-events: none; + /* Flex so the slot inside stretches to the width/height captured from the + grabbed card, rather than the card falling back to its content height. */ + display: flex; + /* Own compositor layer: each pointer frame is then a translate of this one + element rather than a repaint over the grid behind it. */ + will-change: transform; +} + +/* Drop indicator: a bar centred in the gutter on the side the scene would land. + Drawn on the neighbouring slot rather than inserted as an extra grid child, + which would push every following card into the wrong column for the length of + the drag. It grows out of the edge so the eye catches the bar *changing + position*, not merely the presence of a line somewhere. */ +.drop_before::before, +.drop_after::after { + content: ""; + position: absolute; + top: -2px; + bottom: -2px; + width: 4px; + border-radius: 4px; + background-color: var(--primary-text); + box-shadow: 0 0 8px color-mix(in srgb, var(--primary-text) 45%, transparent); + animation: drop_bar_in 0.12s ease-out; + /* Above the neighbouring cards, so it reads as sitting between them rather + than tucked behind one. */ + z-index: 1; +} + +/* Half the gap (which is 16px × zoom) out from the edge, less half the bar. */ +.drop_before::before { + right: 100%; + margin-right: calc(8px * var(--card-zoom, 1) - 2px); +} + +.drop_after::after { + left: 100%; + margin-left: calc(8px * var(--card-zoom, 1) - 2px); +} + +@keyframes drop_bar_in { + from { + transform: scaleY(0.4); + opacity: 0; + } + to { + transform: scaleY(1); + opacity: 1; + } +} + +.card_header { + display: flex; + flex-direction: row; + align-items: baseline; + gap: 8px; + flex-shrink: 0; + padding: calc(8px * var(--card-zoom, 1)) calc(10px * var(--card-zoom, 1)); +} + +/* Holds the heading's box. The editor is absolutely positioned inside it, over + the text, so opening or closing an edit never changes the header's height — + the two boxes measure differently (the text wraps to two lines, an input + never does) and swapping one for the other made the card jump. */ +.card_title_slot { + position: relative; + flex: 1; + min-width: 0; +} + +.card_title { + font-family: var(--font-courier); + font-size: calc(0.9rem * var(--card-zoom, 1)); + font-weight: bold; + line-height: 1.3; + color: white; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.scene_number { + opacity: 0.75; + margin-right: 2px; +} + +/* Editing a heading in place. Tinted rather than white so the field reads as + open on top of the header's own color, whatever that color is. */ +/* Hidden, not unmounted, while its editor is open: it is what holds the + header's height steady underneath. */ +.card_title_hidden { + visibility: hidden; +} + +.card_title_input { + position: absolute; + inset: 0; + width: 100%; + /* The card sets user-select:none so a drag can't smear a text selection + across the grid; an open editor has to opt back in or its own text can't + be selected. */ + user-select: text; + font-family: var(--font-courier); + font-size: calc(0.9rem * var(--card-zoom, 1)); + font-weight: bold; + line-height: 1.3; + color: white; + background: rgba(0, 0, 0, 0.2); + border: none; + border-radius: 4px; + padding: 0 4px; + margin: 0; + outline: none; +} + +.card_title_input::placeholder { + color: rgba(255, 255, 255, 0.6); +} + +/* Page count, in the header so the body stays pure synopsis. */ +.card_length { + flex-shrink: 0; + font-size: calc(11px * var(--card-zoom, 1)); + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.85); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +/* The body is always a light tint of the scene color (mixed toward white by the + inline style), so black text reads on it in every theme. */ +.card_content { + flex: 1; + display: flex; + padding: calc(10px * var(--card-zoom, 1)) calc(12px * var(--card-zoom, 1)); + overflow: hidden; +} + +.card_synopsis { + flex: 1; + font-family: var(--font-courier); + font-size: calc(0.85rem * var(--card-zoom, 1)); + line-height: 1.4; + color: black; + white-space: pre-wrap; + overflow: hidden; +} + +.card_synopsis_empty { + color: rgba(0, 0, 0, 0.4); + font-style: italic; +} + +.card_synopsis_input { + flex: 1; + min-width: 0; + user-select: text; + font-family: var(--font-courier); + font-size: calc(0.85rem * var(--card-zoom, 1)); + line-height: 1.4; + color: black; + background: transparent; + border: none; + padding: 0; + margin: 0; + resize: none; + outline: none; + white-space: pre-wrap; +} + +.card_synopsis_input::placeholder { + color: rgba(0, 0, 0, 0.4); + font-style: italic; +} + +/* ── Zoom pill ────────────────────────────────────────────────────────────── */ + +/* Same pill, same offsets as the board canvas's zoom control (see + BoardCanvas.module.css): both sit immediately right of the panel-switcher + handles, so the chrome lands in the same place whichever view is up. A + sibling of the scroll container rather than a child of it, so it stays put + while the cards scroll under it. */ +.zoom_controls { + position: absolute; + top: 8px; + left: 56px; + z-index: 13; + display: flex; + align-items: center; + gap: 2px; + height: 36px; + padding: 0 4px; + border-radius: 16px; + background-color: var(--secondary); + color: var(--secondary-text); + user-select: none; +} + +.zoom_btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 28px; + border: none; + border-radius: 14px; + background: transparent; + color: var(--secondary-text); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.zoom_btn:hover:not(:disabled) { + background-color: var(--secondary-hover); +} + +.zoom_btn:disabled { + opacity: 0.35; + cursor: default; +} + +.zoom_level { + min-width: 38px; + text-align: center; + font-size: 12px; + font-weight: 500; + color: var(--secondary-text); +} + +/* Finger-sized on a touch device, matching the board pill's own coarse block. */ +@media (pointer: coarse) { + .zoom_controls { + left: 72px; + height: 44px; + border-radius: 22px; + } + + .zoom_btn { + width: 30px; + height: 36px; + border-radius: 18px; + } +} + +/* One card per row once there is no width for several side by side — which also + makes the zoom pill meaningless, so SceneCardsPanel drops it on phone. + The phone's navbar is a fixed overlay and the panel starts underneath it, so + the first row needs that clearance on top of the switcher's — unless the + timeline strip is open, in which case the panel already starts below the bar + (the same pair of cases .panel_switcher_anchor handles). */ +@media (max-width: 767px) { + .grid { + grid-template-columns: minmax(0, 1fr); + padding: calc(var(--navbar-height) + 52px) 16px 40px; + } + + .timeline_open .grid { + padding-top: 52px; + } +} diff --git a/components/editor/SceneCardsPanel.tsx b/components/editor/SceneCardsPanel.tsx new file mode 100644 index 00000000..7ffbf774 --- /dev/null +++ b/components/editor/SceneCardsPanel.tsx @@ -0,0 +1,762 @@ +"use client"; + +import { + memo, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslations } from "next-intl"; +import { Minus, Plus } from "lucide-react"; +import { ProjectContext } from "@src/context/ProjectContext"; +import { + SCENE_CARD_COLUMNS_MAX, + SCENE_CARD_COLUMNS_MIN, + SCENE_CARD_COLUMNS_DEFAULT, + useViewContext, +} from "@src/context/ViewContext"; +import { Scene } from "@src/lib/screenplay/scenes"; +import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; +import { moveScene } from "@src/lib/screenplay/scene-reorder"; +import { useIsPhone } from "@src/lib/utils/hooks"; +import { join } from "@src/lib/utils/misc"; + +import styles from "./SceneCardsPanel.module.css"; + +/** Card chrome for a scene with no color of its own (the palette's grey). */ +const DEFAULT_SCENE_COLOR = "#6b7280"; + +// Touch reordering, same shape as the navigation sidebar's: a swipe scrolls the +// grid, so a card is only picked up after the finger is held roughly still for +// this long. Moving farther than the cancel threshold before then is read as a +// scroll and abandons the pending pick-up. +const TOUCH_DRAG_HOLD_MS = 300; +const TOUCH_DRAG_CANCEL_PX = 10; + +/** + * How far a mouse has to travel before a press becomes a drag. Below this a + * press is still a click — which is what keeps a double-click (to edit) from + * flashing the lifted card and a drop indicator on its way through. + */ +const MOUSE_DRAG_START_PX = 4; + +/** + * Card scale for a given column count, relative to the 3-per-row default. It is + * this factor the zoom readout reports, and it drives the card's type and + * height so that widening a card actually enlarges it rather than just + * stretching it. Clamped because 1 column across a wide panel is a poster and 5 + * across a narrow one is unreadable. + */ +const cardZoom = (columns: number) => Math.min(2, Math.max(0.6, SCENE_CARD_COLUMNS_DEFAULT / columns)); + +/** Page count in eighths, matching the sidebar's SceneLengthItem. */ +const sceneLength = (scene: Scene) => { + const totalEighths = Math.max(1, Math.round(((scene.nextPosition - scene.position) / 1100) * 8)); + const fullPages = Math.floor(totalEighths / 8); + const remainder = totalEighths % 8; + if (fullPages > 0 && remainder > 0) return `${fullPages}+${remainder}/8 p`; + return fullPages > 0 ? `${fullPages} p` : `${remainder}/8 p`; +}; + +/** Which of a card's two fields is being edited in place. */ +type EditField = "title" | "synopsis"; + +type SceneCardProps = { + scene: Scene; + index: number; + label: string; + isOmitted: boolean; + /** This card is the one being dragged: it stays as the hole left behind. */ + isSource: boolean; + /** Rendered as the lifted copy under the pointer — inert, no handlers. */ + isGhost?: boolean; + /** The scene would be inserted before / after this card on release. */ + showDropBefore: boolean; + showDropAfter: boolean; + editField: EditField | null; + editValue: string; + canEdit: boolean; + onEditChange: (value: string) => void; + onEditCommit: () => void; + onEditCancel: () => void; + onStartEdit: (index: number, field: EditField) => void; + onPointerDown: (index: number, e: React.PointerEvent) => void; + onTouchStart: (index: number, e: React.TouchEvent) => void; +}; + +const SceneCard = memo( + ({ + scene, + index, + label, + isOmitted, + isSource, + isGhost = false, + showDropBefore, + showDropAfter, + editField, + editValue, + canEdit, + onEditChange, + onEditCommit, + onEditCancel, + onStartEdit, + onPointerDown, + onTouchStart, + }: SceneCardProps) => { + const t = useTranslations("popup.scene"); + const color = scene.color || DEFAULT_SCENE_COLOR; + // Only the synopsis the writer wrote on the scene — never the opening + // lines of the scene body. An index card carries the intent for a scene, + // which is a different thing from the first thing said in it. + const synopsis = scene.synopsis ?? ""; + + // Enter commits a heading (it is one line); in the synopsis it is a + // newline, so only Escape and blur end that edit. + const handleKeyDown = (e: React.KeyboardEvent, field: EditField) => { + e.stopPropagation(); + if (e.key === "Escape") onEditCancel(); + else if (e.key === "Enter" && field === "title") { + e.preventDefault(); + onEditCommit(); + } + }; + + // A double-click inside a field means "edit this", so it must not also + // reach the card and be read as one of the board-style gestures. + const startEdit = (e: React.MouseEvent, field: EditField) => { + if (!canEdit || isGhost) return; + e.stopPropagation(); + onStartEdit(index, field); + }; + + return ( + // The drop bars are drawn on this wrapper rather than on the card + // itself: the card clips its own overflow (to round the header's + // corners into it), which would erase a bar out in the gutter. +
+
onPointerDown(index, e)} + onTouchStart={isGhost ? undefined : (e) => onTouchStart(index, e)} + > +
+ {/* The heading keeps its box whether or not it is being edited: + the input is laid *over* the text rather than swapped in for + it, because the two boxes measure differently (one wraps to + two lines, the other never does) and the header would resize + under the pointer the moment an edit opened. */} +
+

startEdit(e, "title")} + title={canEdit && !isOmitted ? t("edit") : undefined} + > + {label}.{" "} + {isOmitted ? "OMITTED" : scene.title} +

+ {editField === "title" && ( + onEditChange(e.target.value)} + onBlur={onEditCommit} + onKeyDown={(e) => handleKeyDown(e, "title")} + onPointerDown={(e) => e.stopPropagation()} + autoFocus + /> + )} +
+ {sceneLength(scene)} +
+ +
startEdit(e, "synopsis")} + > + {editField === "synopsis" ? ( +