From 50c8649e28a3d99dd663f630f183f76e5f5526b0 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Sun, 18 Jan 2026 14:30:57 +0000 Subject: [PATCH 1/2] Add separate control panel window with move controls --- app/[locale]/calibrate/page.tsx | 84 ++ app/[locale]/control/page.tsx | 1022 ++++++++++++++++++++++ app/_components/buttons/icon-button.tsx | 3 + app/_components/control-panel-bridge.tsx | 532 +++++++++++ app/_hooks/use-broadcast-channel.ts | 149 ++++ app/_icons/open-in-new-icon.tsx | 14 + messages/en.json | 33 +- 7 files changed, 1836 insertions(+), 1 deletion(-) create mode 100644 app/[locale]/control/page.tsx create mode 100644 app/_components/control-panel-bridge.tsx create mode 100644 app/_hooks/use-broadcast-channel.ts create mode 100644 app/_icons/open-in-new-icon.tsx diff --git a/app/[locale]/calibrate/page.tsx b/app/[locale]/calibrate/page.tsx index 4dca18c0..7fe5c54e 100644 --- a/app/[locale]/calibrate/page.tsx +++ b/app/[locale]/calibrate/page.tsx @@ -70,6 +70,7 @@ import Modal from "@/_components/modal/modal"; import { ModalTitle } from "@/_components/modal/modal-title"; import ModalContent from "@/_components/modal/modal-content"; import { ModalText } from "@/_components/modal/modal-text"; +import { ControlPanelBridge } from "@/_components/control-panel-bridge"; import { ModalActions } from "@/_components/modal/modal-actions"; import { Button } from "@/_components/buttons/button"; import { erosionFilter } from "@/_lib/erode"; @@ -139,6 +140,9 @@ export default function Page() { const [mailOpen, setMailOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + // Ref for file input to allow control panel to trigger file open + const fileInputRef = useRef(null); + const [points, dispatch] = useReducer(pointsReducer, []); const [stitchSettings, dispatchStitchSettings] = useReducer( stitchSettingsReducer, @@ -526,6 +530,14 @@ export default function Page() { ref={noZoomRefCallback} className={`${menusHidden && "cursor-none"} ${isDarkTheme(displaySettings.theme) && "dark bg-black"} w-screen h-screen absolute overflow-hidden touch-none`} > + {/* Hidden file input for control panel to trigger */} +
+ { + setDisplaySettings(newSettings); + if (newSettings) { + updateLocalSettings(newSettings); + } + }} + zoomedOut={zoomedOut} + setZoomedOut={setZoomedOut} + magnifying={magnifying} + setMagnifying={setMagnifying} + measuring={measuring} + setMeasuring={setMeasuring} + file={file} + setFile={setFile} + lineThickness={lineThickness} + setLineThickness={(newLineThickness) => { + setLineThickness(newLineThickness); + if (file) { + localStorage.setItem( + `lineThickness:${file.name}`, + String(newLineThickness), + ); + } + }} + pageCount={pageCount} + patternScale={patternScale} + dispatchPatternScaleAction={dispatchPatternScaleAction} + menuStates={menuStates} + setMenuStates={setMenuStates} + widthInput={widthInput} + heightInput={heightInput} + handleWidthChange={handleWidthChange} + handleHeightChange={handleHeightChange} + unitOfMeasure={unitOfMeasure} + setUnitOfMeasure={(newUnit) => { + setUnitOfMeasure(newUnit); + updateLocalSettings({ unitOfMeasure: newUnit }); + }} + handleResetCalibration={() => { + localStorage.setItem( + "calibrationContext", + JSON.stringify( + getCalibrationContext(fullScreenHandle.active), + ), + ); + dispatch({ type: "set", points: getDefaultPoints() }); + }} + fileInputRef={fileInputRef} + width={width} + height={height} + layoutWidth={layoutWidth} + layoutHeight={layoutHeight} + getCalibrationCenterPoint={getCalibrationCenterPoint} + layers={layers} + dispatchLayerAction={dispatchLayersAction} + stitchSettings={stitchSettings} + dispatchStitchSettings={dispatchStitchSettings} + showingMovePad={showingMovePad} + setShowingMovePad={(show) => { + setShowingMovePad(show); + updateLocalSettings({ showingMovePad: show }); + }} + corners={corners} + setCorners={setCorners} + dispatchPoints={dispatch} + setCalibrationValidated={setCalibrationValidated} + fullScreenActive={fullScreenHandle.active} + /> void) => React.ReactNode); + className?: string; + closeOnSelect?: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const close = () => setIsOpen(false); + + return ( +
+
setIsOpen(!isOpen)}>{trigger}
+ {isOpen && ( +
+ {typeof children === "function" ? children(close) : children} +
+ )} +
+ ); +} + +// Checkbox menu item +function CheckboxMenuItem({ + icon, + label, + checked, + onChange, + disabled = false, +}: { + icon?: React.ReactNode; + label: string; + checked: boolean; + onChange: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// Section header for grouping +function SectionHeader({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +// Movement pad constants +const PIXEL_LIST = [1, 4, 8, 16]; +const REPEAT_MS = 100; +const REPEAT_PX_COUNT = 6; + +// Movement pad for control panel - can be used for calibration (moving corners) or projecting (panning view) +function MovementPadControl({ + mode, + corners, + handleAction, + t, +}: { + mode: "calibrate" | "project"; + corners: number[]; + handleAction: (action: string, params?: unknown) => void; + t: ReturnType>; +}) { + const [intervalFunc, setIntervalFunc] = useState(null); + const [shiftHeld, setShiftHeld] = useState(false); + const border = "border-2 border-purple-600"; + + // Track shift key for 10x speed + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Shift") setShiftHeld(true); + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === "Shift") setShiftHeld(false); + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, []); + + // Get effective pixels based on shift key + const getEffectivePixels = (basePixels: number) => { + return shiftHeld ? basePixels * 10 : basePixels; + }; + + const handleStart = (direction: Direction) => { + // First immediate move + const initialPixels = getEffectivePixels(PIXEL_LIST[0]); + if (mode === "calibrate") { + handleAction("moveCorner", { direction, pixels: initialPixels }); + } else { + handleAction("panView", { direction, pixels: initialPixels }); + } + + // Then repeated moves with acceleration + let i = 0; + const interval = setInterval(() => { + if (i < PIXEL_LIST.length * REPEAT_PX_COUNT - 1) { + ++i; + } + const pixels = getEffectivePixels(PIXEL_LIST[Math.floor(i / REPEAT_PX_COUNT)]); + if (mode === "calibrate") { + handleAction("moveCorner", { direction, pixels }); + } else { + handleAction("panView", { direction, pixels }); + } + }, REPEAT_MS); + setIntervalFunc(interval); + }; + + const handleStop = () => { + if (intervalFunc) { + clearInterval(intervalFunc); + setIntervalFunc(null); + } + // Save calibration context after move (only in calibrate mode) + if (mode === "calibrate") { + handleAction("saveCalibrationContext"); + } + }; + + const handleCycle = () => { + if (mode === "calibrate") { + handleAction("cycleCorner"); + } else { + // In project mode, the center button rotates the view + handleAction("rotateView", 15); + } + }; + + // Get corner label for calibrate mode + const getCornerLabel = () => { + if (corners.length === 0) return ""; + if (corners.length === 4) return t("allCorners"); + const labels = ["TL", "TR", "BR", "BL"]; + return corners.map((c) => labels[c]).join(", "); + }; + + return ( +
+ {mode === "calibrate" && ( +
+ {t("selectedCorner")}: {getCornerLabel()} +
+ )} + + handleStart(Direction.Up)} + onPointerUp={handleStop} + onPointerLeave={handleStop} + className={`${border} col-start-2`} + > + + + + handleStart(Direction.Left)} + onPointerUp={handleStop} + onPointerLeave={handleStop} + className={`${border} col-start-1`} + > + + + + + {mode === "calibrate" ? ( + + ) : ( + + )} + + + handleStart(Direction.Right)} + onPointerUp={handleStop} + onPointerLeave={handleStop} + className={`${border} col-start-3`} + > + + + + handleStart(Direction.Down)} + onPointerUp={handleStop} + onPointerLeave={handleStop} + className={`${border} col-start-2`} + > + + + +
+ ); +} + +export default function ControlPanelPage() { + const t = useTranslations("ControlPanel"); + const tHeader = useTranslations("Header"); + const tStitch = useTranslations("StitchMenu"); + const tLayers = useTranslations("LayerMenu"); + const tScale = useTranslations("ScaleMenu"); + const tMove = useTranslations("MovementPad"); + const [state, setState] = useState(defaultSyncedState); + const [lastSync, setLastSync] = useState(null); + const fileInputRef = useRef(null); + // Local state for which side panel is open (like main window) + const [activePanel, setActivePanel] = useState< + "stitch" | "layers" | "scale" | null + >(null); + // Local state for control panel move pads (independent from main window) + const [showCalibrateMovepad, setShowCalibrateMovepad] = useState(false); + const [showProjectMovepad, setShowProjectMovepad] = useState(false); + + // Handle incoming messages from main window + const handleMessage = useCallback((message: BroadcastMessage) => { + if (message.type === "state-sync") { + const payload = message.payload as Record; + setState((prev) => ({ + ...prev, + ...payload, + connected: true, + })); + setLastSync(message.timestamp); + } + }, []); + + const { sendAction, requestSync, sendFile } = + useBroadcastChannel(handleMessage); + + // Request initial sync on mount and periodically + useEffect(() => { + requestSync(); + const interval = setInterval(() => { + requestSync(); + }, 1000); + return () => clearInterval(interval); + }, [requestSync]); + + const handleAction = (action: string, params?: unknown) => { + sendAction(action, params); + }; + + // Handle file selection in control panel - send to main window + const handleFileChange = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const arrayBuffer = await file.arrayBuffer(); + sendFile(file.name, file.type, arrayBuffer); + } catch (error) { + console.error("Error reading file:", error); + } + e.target.value = ""; + }; + + const handleOpenFile = () => { + fileInputRef.current?.click(); + }; + + const isConnected = + state.connected && lastSync && Date.now() - lastSync < 5000; + const hasFile = state.file !== null; + const isPdf = state.file?.type === "application/pdf"; + const isProjecting = !state.isCalibrating; + const isDark = isDarkTheme(state.displaySettings.theme); + const overlaysDisabled = state.displaySettings.overlay?.disabled; + + const lineThicknessOptions = [0, 1, 2, 3, 4, 5, 6, 7]; + + return ( +
+
+ {/* Header */} +
+
+
+

{t("title")}

+
+
+ + {isConnected ? t("connected") : t("disconnected")} + +
+
+ {/* Mode Toggle Button */} + +
+
+ + {/* ===== CALIBRATE MODE ===== */} + {state.isCalibrating && ( +
+ {/* Display Options - matches left menu group */} +
+ {t("displaySettings")} +
+ + handleAction("toggleTheme")}> + {isDark ? ( + + ) : ( + + )} + + +
+
+ + {/* Calibration Size - matches center group */} +
+ {t("calibrationSize")} +
+ handleAction("setWidth", e.target.value)} + id="width" + label={tHeader("width")} + labelRight={state.unitOfMeasure.toLocaleLowerCase()} + name="width" + value={state.widthInput} + type="number" + min="0" + /> + + handleAction("setHeight", e.target.value) + } + id="height" + label={tHeader("height")} + labelRight={state.unitOfMeasure.toLocaleLowerCase()} + name="height" + value={state.heightInput} + type="number" + min="0" + /> + handleAction("setUnit", e.target.value)} + id="unit_of_measure" + name="unit_of_measure" + value={state.unitOfMeasure} + options={[ + { value: IN, label: "in" }, + { value: CM, label: "cm" }, + ]} + /> + + handleAction("resetCalibration")} + > + + + + + setShowCalibrateMovepad(!showCalibrateMovepad)} + active={showCalibrateMovepad} + > + + + +
+ {/* Movement Pad for Calibration */} + {showCalibrateMovepad && ( +
+ +
+ )} +
+
+ )} + + {/* ===== PROJECT MODE ===== */} + {isProjecting && ( +
+ {/* Open File */} +
+ + +
+ + {/* Display Options - matches left menu group */} +
+ {t("displaySettings")} +
+ {/* Invert Colors */} + + handleAction("toggleTheme")}> + {isDark ? ( + + ) : ( + + )} + + + + {/* Overlay Options Dropdown */} + + + {overlaysDisabled ? ( + + ) : ( + + )} + + + } + > +
+ } + label={tHeader("overlayOptionDisabled")} + checked={!!state.displaySettings.overlay?.disabled} + onChange={() => handleAction("toggleOverlay", "disabled")} + /> + } + label={tHeader("overlayOptionGrid")} + checked={!!state.displaySettings.overlay?.grid} + onChange={() => handleAction("toggleOverlay", "grid")} + disabled={overlaysDisabled} + /> + } + label={tHeader("overlayOptionBorder")} + checked={!!state.displaySettings.overlay?.border} + onChange={() => handleAction("toggleOverlay", "border")} + disabled={overlaysDisabled} + /> + } + label={tHeader("overlayOptionPaper")} + checked={!!state.displaySettings.overlay?.paper} + onChange={() => handleAction("toggleOverlay", "paper")} + disabled={overlaysDisabled} + /> + } + label={tHeader("overlayOptionFliplines")} + checked={!!state.displaySettings.overlay?.flipLines} + onChange={() => + handleAction("toggleOverlay", "flipLines") + } + disabled={overlaysDisabled} + /> + } + label={tHeader("overlayOptionFlippedPattern")} + checked={!!state.displaySettings.overlay?.flippedPattern} + onChange={() => + handleAction("toggleOverlay", "flippedPattern") + } + disabled={overlaysDisabled} + /> +
+
+ + {/* Line Weight Dropdown */} + + + + + + } + > +
+ {lineThicknessOptions.map((thickness) => ( + + ))} +
+
+
+
+ + {/* Pattern Controls - matches right menu group */} +
+ {t("patternControls")} +
+ + handleAction("flipHorizontal")} + disabled={state.zoomedOut || state.magnifying} + > + + + + + handleAction("flipVertical")} + disabled={state.zoomedOut || state.magnifying} + > + + + + + handleAction("rotate")} + disabled={state.zoomedOut || state.magnifying} + > + + + + + handleAction("recenter")} + disabled={state.zoomedOut || state.magnifying} + > + + + +
+ + setShowProjectMovepad(!showProjectMovepad)} + active={showProjectMovepad} + disabled={state.zoomedOut || state.magnifying} + > + + + + + handleAction("toggleMagnify")} + active={state.magnifying} + disabled={state.zoomedOut} + > + + + + + handleAction("toggleZoom")} + active={state.zoomedOut} + disabled={state.magnifying} + > + + + + + handleAction("toggleMeasure")} + active={state.measuring} + disabled={state.magnifying} + > + + + +
+ {/* Movement Pad for Panning/Rotating View */} + {showProjectMovepad && ( +
+ +
+ )} +
+ + {/* Stitch / Layers / Scale - grouped icon bar like main window */} +
+ {t("advancedOptions")} +
+ {/* Stitch Icon - only for multi-page PDFs */} + 1 + ? tHeader("stitchMenuShow") + : tHeader("stitchMenuDisabled") + } + > + + setActivePanel(activePanel === "stitch" ? null : "stitch") + } + > + + + + + {/* Layers Icon */} + 0 + ? activePanel === "layers" + ? tLayers("layersOff") + : tLayers("layersOn") + : tLayers("noLayers") + } + > + + setActivePanel(activePanel === "layers" ? null : "layers") + } + > + + + + + {/* Scale Icon */} + + + setActivePanel(activePanel === "scale" ? null : "scale") + } + > + + + +
+ + {/* Stitch Panel */} + {activePanel === "stitch" && + hasFile && + isPdf && + state.pageCount > 1 && ( +
+ + handleAction("setStitchPageRange", e.target.value) + } + label={tStitch("pageRange")} + name="page-range" + value={state.stitchSettings?.pageRange || ""} + onStep={(increment: number) => + handleAction( + "setStitchPageRange", + rotateRange( + state.stitchSettings?.pageRange || "", + state.pageCount, + increment, + ), + ) + } + /> +
+ + handleAction("setStitchLineDirection", e.target.value) + } + id="line-direction" + name="line-direction" + value={ + state.stitchSettings?.lineDirection || + LineDirection.Column + } + options={[ + { + value: LineDirection.Column, + label: tStitch("columnCount"), + }, + { + value: LineDirection.Row, + label: tStitch("rowCount"), + }, + ]} + /> + + handleAction("setStitchLineCount", e.target.value) + } + value={ + state.stitchSettings?.lineCount === 0 + ? "" + : String(state.stitchSettings?.lineCount || "") + } + onStep={(increment: number) => + handleAction("stepStitchLineCount", increment) + } + /> +
+ + handleAction( + "setStitchEdgeInsetHorizontal", + e.target.value, + ) + } + label={tStitch("horizontal")} + name="horizontal" + value={ + state.stitchSettings?.edgeInsets?.horizontal === 0 + ? "" + : String( + state.stitchSettings?.edgeInsets?.horizontal || + "", + ) + } + onStep={(increment: number) => + handleAction("stepStitchHorizontal", increment) + } + /> + + handleAction( + "setStitchEdgeInsetVertical", + e.target.value, + ) + } + label={tStitch("vertical")} + name="vertical" + value={ + state.stitchSettings?.edgeInsets?.vertical === 0 + ? "" + : String( + state.stitchSettings?.edgeInsets?.vertical || "", + ) + } + onStep={(increment: number) => + handleAction("stepStitchVertical", increment) + } + /> +
+ )} + + {/* Layers Panel */} + {activePanel === "layers" && + Object.keys(state.layers || {}).length > 0 && ( +
+
+ +
+
+ {Object.entries(state.layers).map(([key, layer]) => ( + + ))} +
+
+ )} + + {/* Scale Panel */} + {activePanel === "scale" && ( +
+ + handleAction("setScale", e.target.value) + } + label={tScale("scale")} + value={state.patternScale} + onStep={(delta) => handleAction("adjustScale", delta * 0.1)} + step={0.1} + /> +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/app/_components/buttons/icon-button.tsx b/app/_components/buttons/icon-button.tsx index 4795e7d6..94a36006 100644 --- a/app/_components/buttons/icon-button.tsx +++ b/app/_components/buttons/icon-button.tsx @@ -11,6 +11,7 @@ export function IconButton({ active, onPointerDown, onPointerUp, + onPointerLeave, style, border, }: { @@ -23,6 +24,7 @@ export function IconButton({ active?: boolean; onPointerDown?: PointerEventHandler; onPointerUp?: PointerEventHandler; + onPointerLeave?: PointerEventHandler; style?: React.CSSProperties; border?: boolean; }) { @@ -61,6 +63,7 @@ export function IconButton({ onPointerDown={onPointerDown} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} + onPointerLeave={onPointerLeave} style={style} > {children} diff --git a/app/_components/control-panel-bridge.tsx b/app/_components/control-panel-bridge.tsx new file mode 100644 index 00000000..3f03a98c --- /dev/null +++ b/app/_components/control-panel-bridge.tsx @@ -0,0 +1,532 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { + useBroadcastChannel, + BroadcastMessage, + ActionPayload, + FileTransferPayload, +} from "@/_hooks/use-broadcast-channel"; +import { useTransformerContext } from "@/_hooks/use-transform-context"; +import { DisplaySettings, themes } from "@/_lib/display-settings"; +import { + MenuStates, + SideMenuType, + toggleSideMenuStates, +} from "@/_lib/menu-states"; +import { Dispatch, SetStateAction, ChangeEvent, RefObject } from "react"; +import { PatternScaleAction } from "@/_reducers/patternScaleReducer"; +import { Layers } from "@/_lib/layers"; +import { + StitchSettings, + LineDirection, +} from "@/_lib/interfaces/stitch-settings"; +import { StitchSettingsAction } from "@/_reducers/stitchSettingsReducer"; +import { LayerAction } from "@/_reducers/layersReducer"; +import { + getCalibrationContext, +} from "@/_lib/calibration-context"; +import { PointAction } from "@/_reducers/pointsReducer"; +import { Direction } from "@/_lib/direction"; +import { Point } from "@/_lib/point"; + +interface ControlPanelBridgeProps { + // State to sync + isCalibrating: boolean; + setIsCalibrating: (value: boolean) => void; + displaySettings: DisplaySettings; + setDisplaySettings: (settings: DisplaySettings) => void; + zoomedOut: boolean; + setZoomedOut: (value: boolean) => void; + magnifying: boolean; + setMagnifying: (value: boolean) => void; + measuring: boolean; + setMeasuring: (value: boolean) => void; + file: File | null; + setFile: (file: File | null) => void; + lineThickness: number; + setLineThickness: (value: number) => void; + pageCount: number; + patternScale: string; + dispatchPatternScaleAction: Dispatch; + menuStates: MenuStates; + setMenuStates: Dispatch>; + // Calibration settings + widthInput: string; + heightInput: string; + handleWidthChange: (e: ChangeEvent) => void; + handleHeightChange: (e: ChangeEvent) => void; + unitOfMeasure: string; + setUnitOfMeasure: (unit: string) => void; + handleResetCalibration: () => void; + // For file input (no longer needed but kept for compatibility) + fileInputRef: RefObject; + // For actions + width: number; + height: number; + layoutWidth: number; + layoutHeight: number; + getCalibrationCenterPoint: ( + width: number, + height: number, + unitOfMeasure: string, + ) => { x: number; y: number }; + // Layers + layers: Layers; + dispatchLayerAction: Dispatch; + // Stitch settings + stitchSettings: StitchSettings; + dispatchStitchSettings: Dispatch; + // Move pad + showingMovePad: boolean; + setShowingMovePad: (value: boolean) => void; + // Calibration corners and dispatch for movement + corners: Set; + setCorners: (corners: Set) => void; + dispatchPoints: (action: PointAction) => void; + // Calibration validation + setCalibrationValidated: (value: boolean) => void; + fullScreenActive: boolean; +} + +/** + * Bridge component that handles communication between the main calibrate page + * and the control panel window via BroadcastChannel. + * This component must be rendered inside a Transformable context. + */ +export function ControlPanelBridge({ + isCalibrating, + setIsCalibrating, + displaySettings, + setDisplaySettings, + zoomedOut, + setZoomedOut, + magnifying, + setMagnifying, + measuring, + setMeasuring, + file, + setFile, + lineThickness, + setLineThickness, + pageCount, + patternScale, + dispatchPatternScaleAction, + menuStates, + setMenuStates, + widthInput, + heightInput, + handleWidthChange, + handleHeightChange, + unitOfMeasure, + setUnitOfMeasure, + handleResetCalibration, + fileInputRef, + width, + height, + layoutWidth, + layoutHeight, + getCalibrationCenterPoint, + layers, + dispatchLayerAction, + stitchSettings, + dispatchStitchSettings, + showingMovePad, + setShowingMovePad, + corners, + setCorners, + dispatchPoints, + setCalibrationValidated, + fullScreenActive, +}: ControlPanelBridgeProps) { + const transformer = useTransformerContext(); + const syncRequestedRef = useRef(false); + + // Helper function to get offset from direction + function getOffset(direction: Direction, px: number): Point { + switch (direction) { + case Direction.Up: + return { y: -px, x: 0 }; + case Direction.Down: + return { y: px, x: 0 }; + case Direction.Left: + return { y: 0, x: -px }; + case Direction.Right: + return { y: 0, x: px }; + default: + return { x: 0, y: 0 }; + } + } + + // Build current state object + const buildState = useCallback( + () => ({ + isCalibrating, + displaySettings, + zoomedOut, + magnifying, + measuring, + file: file ? { name: file.name, type: file.type } : null, + lineThickness, + pageCount, + patternScale, + menuStates: { + layers: menuStates.layers, + stitch: menuStates.stitch, + scale: menuStates.scale, + }, + widthInput, + heightInput, + unitOfMeasure, + layers, + stitchSettings, + showingMovePad, + corners: Array.from(corners), + }), + [ + isCalibrating, + displaySettings, + zoomedOut, + magnifying, + measuring, + file, + lineThickness, + pageCount, + patternScale, + menuStates, + widthInput, + heightInput, + unitOfMeasure, + layers, + stitchSettings, + showingMovePad, + corners, + ], + ); + + // Handle incoming messages from control panel + const handleMessage = useCallback( + (message: BroadcastMessage) => { + if (message.type === "request-sync") { + // Control panel is requesting current state - flag it for immediate sync + syncRequestedRef.current = true; + } else if (message.type === "file-transfer") { + // Control panel is sending a file + const { name, type, data } = message.payload as FileTransferPayload; + const newFile = new File([data], name, { type }); + setFile(newFile); + // Also switch to project mode when a file is opened + setIsCalibrating(false); + } else if (message.type === "action") { + const { action, params } = message.payload as ActionPayload; + const center = getCalibrationCenterPoint(width, height, unitOfMeasure); + + switch (action) { + case "toggleMode": + setIsCalibrating(!isCalibrating); + break; + case "saveAndProject": + // Save calibration context and switch to project mode (like main window's button) + const current = getCalibrationContext(fullScreenActive); + localStorage.setItem("calibrationContext", JSON.stringify(current)); + setCalibrationValidated(true); + setIsCalibrating(false); + // If no file is loaded, trigger file open + if (file === null && fileInputRef.current !== null) { + fileInputRef.current.click(); + } + break; + case "flipHorizontal": + transformer.flipHorizontal(center); + break; + case "flipVertical": + transformer.flipVertical(center); + break; + case "rotate": + transformer.rotate(center, 90); + break; + case "recenter": + transformer.recenter(center, layoutWidth, layoutHeight); + break; + case "toggleTheme": + const currentIdx = themes().indexOf(displaySettings.theme); + const theme = themes()[(currentIdx + 1) % themes().length]; + setDisplaySettings({ + ...displaySettings, + theme, + }); + break; + case "toggleOverlay": + const overlayKey = params as keyof DisplaySettings["overlay"]; + setDisplaySettings({ + ...displaySettings, + overlay: { + ...displaySettings.overlay, + [overlayKey]: !displaySettings.overlay[overlayKey], + }, + }); + break; + case "toggleZoom": + setZoomedOut(!zoomedOut); + break; + case "toggleMagnify": + setMagnifying(!magnifying); + break; + case "toggleMeasure": + setMeasuring(!measuring); + break; + case "setLineThickness": + setLineThickness(params as number); + break; + case "adjustScale": + const delta = params as number; + const currentScale = Number(patternScale); + const newScale = Math.max(0.5, Math.min(2, currentScale + delta)); + dispatchPatternScaleAction({ + type: "set", + scale: newScale.toFixed(2), + }); + break; + case "setScale": + dispatchPatternScaleAction({ + type: "set", + scale: params as string, + }); + break; + case "toggleMenu": + const menuType = params as string; + if (menuType === "stitch") { + setMenuStates( + toggleSideMenuStates(menuStates, SideMenuType.stitch), + ); + } else if (menuType === "layers") { + setMenuStates( + toggleSideMenuStates(menuStates, SideMenuType.layers), + ); + } else if (menuType === "scale") { + setMenuStates( + toggleSideMenuStates(menuStates, SideMenuType.scale), + ); + } + break; + case "setWidth": + // Create a synthetic event for the handler + handleWidthChange({ + target: { value: params as string }, + } as ChangeEvent); + break; + case "setHeight": + handleHeightChange({ + target: { value: params as string }, + } as ChangeEvent); + break; + case "setUnit": + setUnitOfMeasure(params as string); + break; + case "resetCalibration": + handleResetCalibration(); + break; + case "toggleMovePad": + setShowingMovePad(!showingMovePad); + break; + // Calibration movement actions (move corners) + case "moveCorner": { + const { direction, pixels } = params as { + direction: Direction; + pixels: number; + }; + const offset = getOffset(direction, pixels); + if (corners.size > 0) { + dispatchPoints({ type: "offset", offset, corners }); + } + break; + } + case "cycleCorner": { + const newCorners = new Set(); + corners.forEach((c) => { + newCorners.add((c + 1) % 4); + }); + setCorners(newCorners); + break; + } + case "saveCalibrationContext": { + // Save calibration context after move operations + localStorage.setItem( + "calibrationContext", + JSON.stringify(getCalibrationContext(fullScreenActive)), + ); + break; + } + // View panning actions (project mode) + case "panView": { + const { direction: panDir, pixels: panPixels } = params as { + direction: Direction; + pixels: number; + }; + const panOffset = getOffset(panDir, panPixels); + transformer.translate(panOffset); + break; + } + case "rotateView": { + const degrees = (params as number) ?? 15; + const center = getCalibrationCenterPoint( + width, + height, + unitOfMeasure, + ); + transformer.rotate(center, degrees); + break; + } + // Layer actions + case "toggleLayer": + dispatchLayerAction({ + type: "toggle-layer", + key: params as string, + }); + break; + case "toggleAllLayers": + const someVisible = Object.values(layers).some((l) => l.visible); + dispatchLayerAction({ + type: someVisible ? "hide-all" : "show-all", + }); + break; + // Stitch actions + case "setStitchPageRange": + dispatchStitchSettings({ + type: "set-page-range", + pageRange: params as string, + }); + break; + case "setStitchLineDirection": + dispatchStitchSettings({ + type: "set", + stitchSettings: { + ...stitchSettings, + lineDirection: + LineDirection[params as keyof typeof LineDirection], + }, + }); + break; + case "setStitchLineCount": + dispatchStitchSettings({ + type: "set-line-count", + lineCount: params ? Number(params) : 0, + pageCount, + }); + break; + case "stepStitchLineCount": + dispatchStitchSettings({ + type: "step-line-count", + pageCount, + step: params as number, + }); + break; + case "setStitchEdgeInsetHorizontal": + dispatchStitchSettings({ + type: "set-edge-insets", + edgeInsets: { + ...stitchSettings.edgeInsets, + horizontal: params ? Number(params) : 0, + }, + }); + break; + case "stepStitchHorizontal": + dispatchStitchSettings({ + type: "step-horizontal", + step: params as number, + }); + break; + case "setStitchEdgeInsetVertical": + dispatchStitchSettings({ + type: "set-edge-insets", + edgeInsets: { + ...stitchSettings.edgeInsets, + vertical: params ? Number(params) : 0, + }, + }); + break; + case "stepStitchVertical": + dispatchStitchSettings({ + type: "step-vertical", + step: params as number, + }); + break; + } + } + }, + [ + transformer, + width, + height, + unitOfMeasure, + layoutWidth, + layoutHeight, + displaySettings, + setDisplaySettings, + isCalibrating, + setIsCalibrating, + zoomedOut, + setZoomedOut, + magnifying, + setMagnifying, + measuring, + setMeasuring, + setLineThickness, + patternScale, + dispatchPatternScaleAction, + menuStates, + setMenuStates, + handleWidthChange, + handleHeightChange, + setUnitOfMeasure, + handleResetCalibration, + fileInputRef, + getCalibrationCenterPoint, + setFile, + showingMovePad, + setShowingMovePad, + corners, + setCorners, + dispatchPoints, + layers, + dispatchLayerAction, + stitchSettings, + dispatchStitchSettings, + pageCount, + setCalibrationValidated, + fullScreenActive, + file, + ], + ); + + const { sendStateSync } = useBroadcastChannel(handleMessage); + + // Sync state to control panel whenever it changes + useEffect(() => { + sendStateSync(buildState()); + }, [buildState, sendStateSync]); + + // Handle sync requests - check periodically if a sync was requested + useEffect(() => { + const interval = setInterval(() => { + if (syncRequestedRef.current) { + syncRequestedRef.current = false; + sendStateSync(buildState()); + } + }, 100); + return () => clearInterval(interval); + }, [buildState, sendStateSync]); + + // Send initial sync on mount + useEffect(() => { + // Small delay to ensure channel is ready + const timeout = setTimeout(() => { + sendStateSync(buildState()); + }, 100); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // This component doesn't render anything - it just handles communication + return null; +} diff --git a/app/_hooks/use-broadcast-channel.ts b/app/_hooks/use-broadcast-channel.ts new file mode 100644 index 00000000..b46b92dc --- /dev/null +++ b/app/_hooks/use-broadcast-channel.ts @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useRef } from "react"; + +// Channel name for Pattern Projector inter-window communication +export const PP_BROADCAST_CHANNEL = "pattern-projector-sync"; + +// Message types for communication between windows +export type BroadcastMessageType = + | "state-update" // Control panel -> Main: Update state + | "state-sync" // Main -> Control panel: Full state sync + | "request-sync" // Control panel -> Main: Request current state + | "action" // Control panel -> Main: Trigger an action + | "file-transfer"; // Control panel -> Main: Transfer a file + +export interface BroadcastMessage { + type: BroadcastMessageType; + payload: unknown; + timestamp: number; +} + +export interface StateUpdatePayload { + key: string; + value: unknown; +} + +export interface ActionPayload { + action: string; + params?: unknown; +} + +export interface FileTransferPayload { + name: string; + type: string; + data: ArrayBuffer; +} + +/** + * Hook for managing BroadcastChannel communication between windows. + * Used to sync state between the main projector view and control panel. + */ +export function useBroadcastChannel( + onMessage?: (message: BroadcastMessage) => void, +) { + const channelRef = useRef(null); + const onMessageRef = useRef(onMessage); + + // Keep the ref up to date with the latest callback + useEffect(() => { + onMessageRef.current = onMessage; + }, [onMessage]); + + useEffect(() => { + // Create channel on mount + channelRef.current = new BroadcastChannel(PP_BROADCAST_CHANNEL); + + // Set up message handler that uses the ref + channelRef.current.onmessage = (event: MessageEvent) => { + onMessageRef.current?.(event.data); + }; + + // Cleanup on unmount + return () => { + channelRef.current?.close(); + }; + }, []); // Empty deps - channel is created once + + /** + * Send a message to other windows on the same channel + */ + const postMessage = useCallback( + (message: Omit) => { + channelRef.current?.postMessage({ + ...message, + timestamp: Date.now(), + }); + }, + [], + ); + + /** + * Send a state update to the main window + */ + const sendStateUpdate = useCallback( + (key: string, value: unknown) => { + postMessage({ + type: "state-update", + payload: { key, value } as StateUpdatePayload, + }); + }, + [postMessage], + ); + + /** + * Send a full state sync (from main window to control panel) + */ + const sendStateSync = useCallback( + (state: Record) => { + postMessage({ + type: "state-sync", + payload: state, + }); + }, + [postMessage], + ); + + /** + * Request the current state from the main window + */ + const requestSync = useCallback(() => { + postMessage({ + type: "request-sync", + payload: null, + }); + }, [postMessage]); + + /** + * Send an action to the main window + */ + const sendAction = useCallback( + (action: string, params?: unknown) => { + postMessage({ + type: "action", + payload: { action, params } as ActionPayload, + }); + }, + [postMessage], + ); + + /** + * Send a file to the main window + */ + const sendFile = useCallback( + (name: string, type: string, data: ArrayBuffer) => { + postMessage({ + type: "file-transfer", + payload: { name, type, data } as FileTransferPayload, + }); + }, + [postMessage], + ); + + return { + postMessage, + sendStateUpdate, + sendStateSync, + requestSync, + sendAction, + sendFile, + }; +} diff --git a/app/_icons/open-in-new-icon.tsx b/app/_icons/open-in-new-icon.tsx new file mode 100644 index 00000000..5b3c3953 --- /dev/null +++ b/app/_icons/open-in-new-icon.tsx @@ -0,0 +1,14 @@ +export default function OpenInNewIcon({ ariaLabel }: { ariaLabel: string }) { + return ( + + + + ); +} diff --git a/messages/en.json b/messages/en.json index 59947318..51048edb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -315,12 +315,43 @@ "layersOff": "Hide layer menu", "noLayers": "No layers in pattern" }, + "ControlPanel": { + "title": "Control Panel", + "connected": "Connected", + "disconnected": "Not connected", + "currentFile": "File", + "currentMode": "Current", + "mode": "Mode", + "patternControls": "Pattern", + "viewControls": "View", + "displaySettings": "Display", + "overlaysAndLines": "Overlays & Lines", + "overlayOptions": "Overlays", + "patternScale": "Scale", + "calibrationSize": "Calibration Size", + "resetCalibration": "Reset Grid", + "stitchSettings": "Stitch Pages", + "stitchSettingsHint": "Toggle stitch menu on the projector view", + "layers": "Layers", + "showOnProjector": "Show menu on projector", + "hideOnProjector": "Hide menu on projector", + "showMenu": "Show", + "hideMenu": "Hide", + "lineTool": "Line tool", + "advancedOptions": "Stitch / Layers / Scale", + "instructions": "How to use", + "instructionsText": "Keep this window on your laptop screen while the main Pattern Projector window is on the projector. All controls here will update the projected view.", + "zoomIn": "Zoom in" + }, "MovementPad": { "up": "Up", "down": "Down", "left": "Left", "right": "Right", - "next": "Next corner" + "next": "Next corner", + "rotate": "Rotate view", + "selectedCorner": "Selected corner", + "allCorners": "All corners" }, "MeasureCanvas": { "rotateToHorizontal": "Align to center", From aae6dce54d184d7cf70e7445f66c1a38e6014ef6 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Wed, 21 Jan 2026 18:19:07 +0000 Subject: [PATCH 2/2] Add preview with pattern image and viewport indicator --- app/[locale]/calibrate/page.tsx | 20 + app/[locale]/control/page.tsx | 702 ++++++++++++++++++++++- app/_components/control-panel-bridge.tsx | 387 ++++++++++++- app/_components/tooltip/tooltip.tsx | 2 +- app/_hooks/use-pdf-thumbnail.ts | 309 ++++++++++ app/_icons/visibility-icon.tsx | 14 + app/_icons/visibility-off-icon.tsx | 18 + messages/en.json | 9 +- tailwind.config.ts | 4 + 9 files changed, 1431 insertions(+), 34 deletions(-) create mode 100644 app/_hooks/use-pdf-thumbnail.ts create mode 100644 app/_icons/visibility-icon.tsx create mode 100644 app/_icons/visibility-off-icon.tsx diff --git a/app/[locale]/calibrate/page.tsx b/app/[locale]/calibrate/page.tsx index 7fe5c54e..0efe1b29 100644 --- a/app/[locale]/calibrate/page.tsx +++ b/app/[locale]/calibrate/page.tsx @@ -76,6 +76,7 @@ import { Button } from "@/_components/buttons/button"; import { erosionFilter } from "@/_lib/erode"; import SvgViewer from "@/_components/svg-viewer"; import { toggleFullScreen } from "@/_lib/full-screen"; +import { usePdfThumbnail } from "@/_hooks/use-pdf-thumbnail"; const defaultStitchSettings = { lineCount: 1, @@ -160,6 +161,17 @@ export default function Page() { const patternScaleFactor = Number(patternScale) === 0 ? 1 : Number(patternScale); + // State for preview thumbnail + const [showPreviewImage, setShowPreviewImage] = useState(true); + const { thumbnail: pdfThumbnail, isLoading: isPreviewLoading } = + usePdfThumbnail( + file?.type === "application/pdf" ? file : null, + pageCount, + stitchSettings, + lineThickness, + showPreviewImage, + ); + const timeoutRef = useRef(null); const t = useTranslations("Header"); @@ -672,6 +684,14 @@ export default function Page() { dispatchPoints={dispatch} setCalibrationValidated={setCalibrationValidated} fullScreenActive={fullScreenHandle.active} + perspective={perspective} + calibrationTransform={calibrationTransform} + restoreTransforms={restoreTransforms} + setRestoreTransforms={setRestoreTransforms} + pdfThumbnail={pdfThumbnail} + isPreviewLoading={isPreviewLoading} + showPreviewImage={showPreviewImage} + setShowPreviewImage={setShowPreviewImage} /> void; + onPanDelta: (dx: number, dy: number) => void; + onMagnify: (x: number, y: number) => void; + onTogglePreview: () => void; + onToggleSize: () => void; + t: ReturnType>; +}) { + const containerRef = useRef(null); + const wrapperRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [containerWidth, setContainerWidth] = useState(400); + const lastNavigateTime = useRef(0); + const handledMagnifyClick = useRef(false); // Track if we handled a magnify click + const dragStartCoords = useRef<{ x: number; y: number } | null>(null); // Track drag start for delta calculation + const lastDragCoords = useRef<{ x: number; y: number } | null>(null); // Track last drag position + const throttleMs = 16; // Throttle navigation updates (~60fps) + + // Track absolute viewport position during drag for perfect 1:1 cursor tracking + // Instead of accumulating deltas, we compute position from drag start + mouse movement + const dragStartScreenPos = useRef<{ x: number; y: number } | null>(null); + const dragStartViewportCenter = useRef<{ x: number; y: number } | null>(null); + const [localViewportCenter, setLocalViewportCenter] = useState<{ x: number; y: number } | null>(null); + + // Measure available width from parent container + useEffect(() => { + const updateWidth = () => { + if (wrapperRef.current) { + // Get the width of the wrapper (minus some padding for aesthetics) + const availableWidth = wrapperRef.current.offsetWidth; + setContainerWidth(availableWidth); + } + }; + + updateWidth(); + window.addEventListener("resize", updateWidth); + return () => window.removeEventListener("resize", updateWidth); + }, []); + + // Calculate scale to fit the PDF in the preview container + // Both sizes are capped to prevent size jumps when rotating + // Normal: 400px max, Enlarged: 800px max + const maxWidth = Math.min(containerWidth, enlarged ? 800 : 400); + const maxHeight = enlarged ? 675 : 450; + + if (layoutWidth === 0 || layoutHeight === 0) { + return ( +
+
+ {t("previewNoFile")} +
+
+ ); + } + + // Get rotation and normalize to 0, 90, 180, 270 + const rotation = viewportBounds?.rotation ?? 0; + const normalizedRotation = ((rotation % 360) + 360) % 360; + const isRotated90or270 = + (normalizedRotation > 45 && normalizedRotation < 135) || + (normalizedRotation > 225 && normalizedRotation < 315); + const isRotated180 = normalizedRotation > 135 && normalizedRotation < 225; + + // Get the normalized transform matrix from viewport bounds + // This represents the exact rotation + flip transformation + const transformA = viewportBounds?.transformA ?? 1; + const transformB = viewportBounds?.transformB ?? 0; + const transformC = viewportBounds?.transformC ?? 0; + const transformD = viewportBounds?.transformD ?? 1; + const hasFlip = viewportBounds?.hasFlip ?? false; + + // When rotated 90/270 and "rotate with view" is on, swap effective dimensions + const effectiveLayoutWidth = isRotated90or270 ? layoutHeight : layoutWidth; + const effectiveLayoutHeight = isRotated90or270 ? layoutWidth : layoutHeight; + + // Add buffer around the PDF to show when view goes off-edge + // Use uniform buffer based on the smaller dimension for consistent appearance + const smallerDimension = Math.min( + effectiveLayoutWidth, + effectiveLayoutHeight, + ); + const buffer = smallerDimension * 0.15; + const bufferX = buffer; + const bufferY = buffer; + + // Total area including buffer + const totalWidth = effectiveLayoutWidth + bufferX * 2; + const totalHeight = effectiveLayoutHeight + bufferY * 2; + + const scale = Math.min(maxWidth / totalWidth, maxHeight / totalHeight); + const scaledWidth = totalWidth * scale; + const scaledHeight = totalHeight * scale; + const scaledBufferX = bufferX * scale; + const scaledBufferY = bufferY * scale; + + // Convert screen coordinates to PDF coordinates + // Uses the inverse of the transform matrix to correctly handle any rotation + flip combination + const screenToPdfCoords = ( + screenX: number, + screenY: number, + ): { x: number; y: number } => { + // Get position relative to the PDF area center + const centerX = scaledBufferX + (effectiveLayoutWidth * scale) / 2; + const centerY = scaledBufferY + (effectiveLayoutHeight * scale) / 2; + + // Position relative to center, in PDF units + const relX = (screenX - centerX) / scale; + const relY = (screenY - centerY) / scale; + + // Apply inverse of the transform matrix to get back to original PDF coordinates + // The transform matrix is [a, b; c, d], so inverse is (1/det) * [d, -b; -c, a] + const det = transformA * transformD - transformB * transformC; + if (Math.abs(det) < 0.0001) { + // Fallback for degenerate matrix + return { + x: layoutWidth / 2 + relX, + y: layoutHeight / 2 + relY, + }; + } + + // Apply inverse transform + const invA = transformD / det; + const invB = -transformB / det; + const invC = -transformC / det; + const invD = transformA / det; + + const pdfRelX = invA * relX + invB * relY; + const pdfRelY = invC * relX + invD * relY; + + // Convert back to PDF coordinates (from center-relative) + const pdfX = layoutWidth / 2 + pdfRelX; + const pdfY = layoutHeight / 2 + pdfRelY; + + return { x: pdfX, y: pdfY }; + }; + + // Handle pointer events for click and drag + const handlePointerDown = (e: React.PointerEvent) => { + if (!containerRef.current) return; + + e.preventDefault(); + const rect = containerRef.current.getBoundingClientRect(); + const coords = screenToPdfCoords( + e.clientX - rect.left, + e.clientY - rect.top, + ); + + // If magnifying mode is active, trigger magnify at this point instead of navigating + if (magnifying) { + handledMagnifyClick.current = true; + onMagnify(coords.x, coords.y); + return; + } + + setIsDragging(true); + containerRef.current.setPointerCapture(e.pointerId); + lastNavigateTime.current = Date.now(); + // Store the starting position for drag delta calculation + dragStartCoords.current = coords; + lastDragCoords.current = { x: e.clientX, y: e.clientY }; // Track screen position + + // Store absolute positions for perfect 1:1 cursor tracking + // The clicked point becomes the new viewport center + dragStartScreenPos.current = { x: e.clientX, y: e.clientY }; + dragStartViewportCenter.current = coords; // Click point = new center + setLocalViewportCenter(coords); + + // Initial click: center on this point + onNavigate(coords.x, coords.y); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isDragging || !containerRef.current || !dragStartScreenPos.current || !dragStartViewportCenter.current) return; + + // Calculate how far mouse has moved from drag start (in screen pixels) + const screenDeltaX = e.clientX - dragStartScreenPos.current.x; + const screenDeltaY = e.clientY - dragStartScreenPos.current.y; + + // Convert screen delta to PDF coordinates + // Moving mouse right in preview = viewport moves right in PDF space + const pdfDeltaX = screenDeltaX / scale; + const pdfDeltaY = screenDeltaY / scale; + + // Compute absolute viewport center: start position + mouse movement + // This gives perfect 1:1 tracking - the viewport follows the cursor exactly + const newCenter = { + x: dragStartViewportCenter.current.x + pdfDeltaX, + y: dragStartViewportCenter.current.y + pdfDeltaY, + }; + setLocalViewportCenter(newCenter); + + // Throttle the actual navigation commands to the main window + const now = Date.now(); + if (now - lastNavigateTime.current < throttleMs) return; + lastNavigateTime.current = now; + + // Calculate delta from last sent position for the main window + const lastScreenDeltaX = e.clientX - lastDragCoords.current!.x; + const lastScreenDeltaY = e.clientY - lastDragCoords.current!.y; + lastDragCoords.current = { x: e.clientX, y: e.clientY }; + + // Convert screen delta to main window pixels (negated for pan direction) + const mainWindowDeltaX = -lastScreenDeltaX / scale; + const mainWindowDeltaY = -lastScreenDeltaY / scale; + + if (Math.abs(mainWindowDeltaX) > 0.5 || Math.abs(mainWindowDeltaY) > 0.5) { + onPanDelta(mainWindowDeltaX, mainWindowDeltaY); + } + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!containerRef.current) return; + + // If we handled a magnify click, don't navigate on pointer up + if (handledMagnifyClick.current) { + handledMagnifyClick.current = false; + return; + } + + setIsDragging(false); + containerRef.current.releasePointerCapture(e.pointerId); + dragStartCoords.current = null; + lastDragCoords.current = null; + dragStartScreenPos.current = null; + dragStartViewportCenter.current = null; + // Clear local viewport - synced state is now accurate + setLocalViewportCenter(null); + }; + + // Transform a point from PDF coordinates to mini map display coordinates + // Uses the transform matrix to correctly handle any rotation + flip combination + const pdfToDisplayCoords = ( + pdfX: number, + pdfY: number, + ): { x: number; y: number } => { + // Convert to center-relative coordinates + const relX = pdfX - layoutWidth / 2; + const relY = pdfY - layoutHeight / 2; + + // Apply transform matrix + const transformedX = transformA * relX + transformB * relY; + const transformedY = transformC * relX + transformD * relY; + + // Convert to display coordinates (accounting for buffer and scale) + // The effective layout is centered in the display area + const displayX = + scaledBufferX + (effectiveLayoutWidth * scale) / 2 + transformedX * scale; + const displayY = + scaledBufferY + + (effectiveLayoutHeight * scale) / 2 + + transformedY * scale; + + return { x: displayX, y: displayY }; + }; + + // Calculate viewport indicator position and size + const getViewportIndicator = () => { + if (!viewportBounds) return null; + + // When dragging, use local viewport center for perfect 1:1 cursor tracking + // Otherwise use the synced viewport bounds + let effectiveBounds = viewportBounds; + if (localViewportCenter && isDragging) { + // Compute bounds centered on local viewport center + // The width/height stay the same, just the position changes + effectiveBounds = { + ...viewportBounds, + x: localViewportCenter.x - viewportBounds.width / 2, + y: localViewportCenter.y - viewportBounds.height / 2, + }; + } + + // Transform the four corners of the viewport bounds + const corners = [ + pdfToDisplayCoords(effectiveBounds.x, effectiveBounds.y), + pdfToDisplayCoords( + effectiveBounds.x + effectiveBounds.width, + effectiveBounds.y, + ), + pdfToDisplayCoords( + effectiveBounds.x + effectiveBounds.width, + effectiveBounds.y + effectiveBounds.height, + ), + pdfToDisplayCoords( + effectiveBounds.x, + effectiveBounds.y + effectiveBounds.height, + ), + ]; + + // Get bounding box of transformed corners + const minX = Math.min(...corners.map((c) => c.x)); + const maxX = Math.max(...corners.map((c) => c.x)); + const minY = Math.min(...corners.map((c) => c.y)); + const maxY = Math.max(...corners.map((c) => c.y)); + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + rotation: viewportBounds.rotation, + }; + }; + + // Calculate calibration border position (similar to viewport but uses calibrationBounds) + const getCalibrationBorderIndicator = () => { + if (!calibrationBounds) return null; + + // Transform the four corners of the calibration bounds + const corners = [ + pdfToDisplayCoords(calibrationBounds.x, calibrationBounds.y), + pdfToDisplayCoords( + calibrationBounds.x + calibrationBounds.width, + calibrationBounds.y, + ), + pdfToDisplayCoords( + calibrationBounds.x + calibrationBounds.width, + calibrationBounds.y + calibrationBounds.height, + ), + pdfToDisplayCoords( + calibrationBounds.x, + calibrationBounds.y + calibrationBounds.height, + ), + ]; + + // Get bounding box of transformed corners + const minX = Math.min(...corners.map((c) => c.x)); + const maxX = Math.max(...corners.map((c) => c.x)); + const minY = Math.min(...corners.map((c) => c.y)); + const maxY = Math.max(...corners.map((c) => c.y)); + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + }; + + // Calculate paper sheet position (similar to calibration border but uses paperBounds) + const getPaperIndicator = () => { + if (!paperBounds) return null; + + // Transform the four corners of the paper bounds + const corners = [ + pdfToDisplayCoords(paperBounds.x, paperBounds.y), + pdfToDisplayCoords(paperBounds.x + paperBounds.width, paperBounds.y), + pdfToDisplayCoords( + paperBounds.x + paperBounds.width, + paperBounds.y + paperBounds.height, + ), + pdfToDisplayCoords(paperBounds.x, paperBounds.y + paperBounds.height), + ]; + + // Get bounding box of transformed corners + const minX = Math.min(...corners.map((c) => c.x)); + const maxX = Math.max(...corners.map((c) => c.x)); + const minY = Math.min(...corners.map((c) => c.y)); + const maxY = Math.max(...corners.map((c) => c.y)); + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + }; + + const viewport = getViewportIndicator(); + const calibrationBorder = getCalibrationBorderIndicator(); + const paperSheet = getPaperIndicator(); + + return ( +
+
+ + + {showPreviewImage ? ( + + ) : ( + + )} + + + + + {enlarged ? ( + + ) : ( + + )} + + +
+
+ {/* PDF area representation */} +
+ {/* Loading indicator */} + {isPreviewLoading && showPreviewImage && ( +
+
+
+ )} + {/* PDF thumbnail image */} + {showPreviewImage && previewImage && ( + + )} +
+ + {/* Calibration border - shows the original calibration rectangle */} + {showBorder && calibrationBorder && ( +
+ )} + + {/* Paper sheet indicator - shows A4/Letter paper size rectangle */} + {showPaper && paperSheet && ( +
+ )} + + {/* Viewport indicator */} + {viewport && ( +
+ )} + + {/* Center crosshair when no viewport */} + {!viewport && ( +
+
+
+
+ )} +
+
+ ); +} + // Movement pad constants const PIXEL_LIST = [1, 4, 8, 16]; const REPEAT_MS = 100; @@ -257,7 +870,9 @@ function MovementPadControl({ if (i < PIXEL_LIST.length * REPEAT_PX_COUNT - 1) { ++i; } - const pixels = getEffectivePixels(PIXEL_LIST[Math.floor(i / REPEAT_PX_COUNT)]); + const pixels = getEffectivePixels( + PIXEL_LIST[Math.floor(i / REPEAT_PX_COUNT)], + ); if (mode === "calibrate") { handleAction("moveCorner", { direction, pixels }); } else { @@ -376,6 +991,8 @@ export default function ControlPanelPage() { // Local state for control panel move pads (independent from main window) const [showCalibrateMovepad, setShowCalibrateMovepad] = useState(false); const [showProjectMovepad, setShowProjectMovepad] = useState(false); + const [previewExpanded, setPreviewExpanded] = useState(true); + const [previewEnlarged, setPreviewEnlarged] = useState(false); // Toggle between compact and large view // Handle incoming messages from main window const handleMessage = useCallback((message: BroadcastMessage) => { @@ -435,9 +1052,9 @@ export default function ControlPanelPage() { return (
-
+
{/* Header */}
@@ -500,7 +1117,7 @@ export default function ControlPanelPage() { handleChange={(e) => handleAction("setWidth", e.target.value)} id="width" label={tHeader("width")} - labelRight={state.unitOfMeasure.toLocaleLowerCase()} + labelRight={(state.unitOfMeasure ?? Unit.IN).toLowerCase()} name="width" value={state.widthInput} type="number" @@ -514,7 +1131,7 @@ export default function ControlPanelPage() { } id="height" label={tHeader("height")} - labelRight={state.unitOfMeasure.toLocaleLowerCase()} + labelRight={(state.unitOfMeasure ?? Unit.IN).toLowerCase()} name="height" value={state.heightInput} type="number" @@ -524,16 +1141,14 @@ export default function ControlPanelPage() { handleChange={(e) => handleAction("setUnit", e.target.value)} id="unit_of_measure" name="unit_of_measure" - value={state.unitOfMeasure} + value={state.unitOfMeasure ?? Unit.IN} options={[ - { value: IN, label: "in" }, - { value: CM, label: "cm" }, + { value: Unit.IN, label: "in" }, + { value: Unit.CM, label: "cm" }, ]} /> - handleAction("resetCalibration")} - > + handleAction("resetCalibration")}> @@ -545,7 +1160,9 @@ export default function ControlPanelPage() { } > setShowCalibrateMovepad(!showCalibrateMovepad)} + onClick={() => + setShowCalibrateMovepad(!showCalibrateMovepad) + } active={showCalibrateMovepad} > @@ -794,6 +1411,63 @@ export default function ControlPanelPage() { )} + {/* Mini Map for navigation */} +
+ + {previewExpanded && ( +
+ + handleAction("navigateToPoint", { x, y }) + } + onPanDelta={(dx, dy) => + handleAction("panViewDelta", { dx, dy }) + } + onMagnify={(x, y) => + handleAction("magnifyAtPoint", { x, y }) + } + onTogglePreview={() => handleAction("togglePreviewImage")} + onToggleSize={() => setPreviewEnlarged((e) => !e)} + t={t} + /> +
+ )} +
+ {/* Stitch / Layers / Scale - grouped icon bar like main window */}
{t("advancedOptions")} diff --git a/app/_components/control-panel-bridge.tsx b/app/_components/control-panel-bridge.tsx index 3f03a98c..fef2bc4c 100644 --- a/app/_components/control-panel-bridge.tsx +++ b/app/_components/control-panel-bridge.tsx @@ -7,7 +7,10 @@ import { ActionPayload, FileTransferPayload, } from "@/_hooks/use-broadcast-channel"; -import { useTransformerContext } from "@/_hooks/use-transform-context"; +import { + useTransformerContext, + useTransformContext, +} from "@/_hooks/use-transform-context"; import { DisplaySettings, themes } from "@/_lib/display-settings"; import { MenuStates, @@ -17,18 +20,27 @@ import { import { Dispatch, SetStateAction, ChangeEvent, RefObject } from "react"; import { PatternScaleAction } from "@/_reducers/patternScaleReducer"; import { Layers } from "@/_lib/layers"; +import Matrix from "ml-matrix"; import { StitchSettings, LineDirection, } from "@/_lib/interfaces/stitch-settings"; import { StitchSettingsAction } from "@/_reducers/stitchSettingsReducer"; import { LayerAction } from "@/_reducers/layersReducer"; -import { - getCalibrationContext, -} from "@/_lib/calibration-context"; +import { getCalibrationContext } from "@/_lib/calibration-context"; import { PointAction } from "@/_reducers/pointsReducer"; import { Direction } from "@/_lib/direction"; import { Point } from "@/_lib/point"; +import { + transformPoint, + rectCorners, + getBounds, + RestoreTransforms, + translate, + scaleAboutPoint, +} from "@/_lib/geometry"; +import { inverse } from "ml-matrix"; +import { getPtDensity, Unit } from "@/_lib/unit"; interface ControlPanelBridgeProps { // State to sync @@ -56,8 +68,8 @@ interface ControlPanelBridgeProps { heightInput: string; handleWidthChange: (e: ChangeEvent) => void; handleHeightChange: (e: ChangeEvent) => void; - unitOfMeasure: string; - setUnitOfMeasure: (unit: string) => void; + unitOfMeasure: Unit; + setUnitOfMeasure: (unit: Unit) => void; handleResetCalibration: () => void; // For file input (no longer needed but kept for compatibility) fileInputRef: RefObject; @@ -69,7 +81,7 @@ interface ControlPanelBridgeProps { getCalibrationCenterPoint: ( width: number, height: number, - unitOfMeasure: string, + unitOfMeasure: Unit, ) => { x: number; y: number }; // Layers layers: Layers; @@ -87,6 +99,18 @@ interface ControlPanelBridgeProps { // Calibration validation setCalibrationValidated: (value: boolean) => void; fullScreenActive: boolean; + // For preview viewport calculation + perspective: Matrix; + // Calibration transform for saving restore state + calibrationTransform: Matrix; + // Saved transforms when zoomed out or magnifying (to preserve rotation/flip state) + restoreTransforms: RestoreTransforms | null; + setRestoreTransforms: (value: RestoreTransforms | null) => void; + // PDF thumbnail for preview + pdfThumbnail: string | null; + isPreviewLoading: boolean; + showPreviewImage: boolean; + setShowPreviewImage: (value: boolean) => void; } /** @@ -138,10 +162,27 @@ export function ControlPanelBridge({ dispatchPoints, setCalibrationValidated, fullScreenActive, + perspective, + calibrationTransform, + restoreTransforms, + setRestoreTransforms, + pdfThumbnail, + isPreviewLoading, + showPreviewImage, + setShowPreviewImage, }: ControlPanelBridgeProps) { const transformer = useTransformerContext(); + const localTransform = useTransformContext(); const syncRequestedRef = useRef(false); + // When zoomed out or magnifying, use the saved transform for preview display + // This preserves the rotation/flip state in the preview even though the actual + // localTransform is reset to identity during zoom out + const effectiveTransform = + (zoomedOut || magnifying) && restoreTransforms + ? restoreTransforms.localTransform + : localTransform; + // Helper function to get offset from direction function getOffset(direction: Direction, px: number): Point { switch (direction) { @@ -158,6 +199,188 @@ export function ControlPanelBridge({ } } + // Calculate viewport bounds in PDF coordinates for mini map + const calculateViewportBounds = useCallback(() => { + if (layoutWidth === 0 || layoutHeight === 0) { + return null; + } + + // Get screen corners (browser window dimensions) + const screenWidth = typeof window !== "undefined" ? window.innerWidth : 0; + const screenHeight = typeof window !== "undefined" ? window.innerHeight : 0; + const screenCorners = rectCorners(screenWidth, screenHeight); + + // Transform screen corners to PDF coordinates using inverse of combined transform + // Combined transform: perspective (calibration inverse) + localTransform + // Use the ACTUAL current localTransform for position calculation (not effectiveTransform) + // This ensures the viewport shows the real current view position, even during zoom out + try { + const inverseLocal = inverse(localTransform); + const pdfCorners = screenCorners.map((p) => { + // First apply perspective (to get to calibrated space) + const calibrated = transformPoint(p, perspective); + // Then apply inverse local transform (to get to PDF space) + return transformPoint(calibrated, inverseLocal); + }); + + // Get bounding box + const [min, max] = getBounds(pdfCorners); + + // For rotation/flip display, use effectiveTransform which preserves the saved state during zoom out + // This keeps the mini map image orientation correct even when localTransform is identity + const m = effectiveTransform.to1DArray(); + + // Detect flip state from the transform matrix + // The determinant of the 2x2 scale/rotation part indicates if there's a flip + // det = m[0]*m[4] - m[1]*m[3] = scaleX * scaleY + // Negative determinant means one axis is flipped (odd number of flips) + const det = m[0] * m[4] - m[1] * m[3]; + const hasFlip = det < 0; + + // Extract the 2x2 rotation/scale part of the matrix for the mini map + // This allows us to apply the exact same transform to the mini map image + // Normalize by the scale to get just rotation + flip + const scaleXMag = Math.sqrt(m[0] * m[0] + m[1] * m[1]); + const scaleYMag = Math.sqrt(m[3] * m[3] + m[4] * m[4]); + + // Normalized matrix components (just rotation + flip, no scale) + const a = scaleXMag > 0 ? m[0] / scaleXMag : 1; + const b = scaleYMag > 0 ? m[1] / scaleYMag : 0; + const c = scaleXMag > 0 ? m[3] / scaleXMag : 0; + const d = scaleYMag > 0 ? m[4] / scaleYMag : 1; + + // Standard rotation calculation for reference + const rotation = Math.atan2(m[3], m[0]) * (180 / Math.PI); + + return { + x: min.x, + y: min.y, + width: max.x - min.x, + height: max.y - min.y, + rotation, + // Pass the normalized transform matrix components for accurate mini map rendering + transformA: a, + transformB: b, + transformC: c, + transformD: d, + hasFlip, + }; + } catch { + return null; + } + }, [ + layoutWidth, + layoutHeight, + perspective, + localTransform, + effectiveTransform, + ]); + + // Calculate calibration bounds in PDF coordinates for mini map border + // This represents the fixed calibration rectangle (what the projector can display) in PDF space + // The size is fixed (width x height in calibration units), but position changes with pan/rotate + const calculateCalibrationBounds = useCallback(() => { + if (width === 0 || height === 0) { + return null; + } + + // Calculate calibration size in PDF units (points) + const ptDensity = getPtDensity(unitOfMeasure); + const calWidth = width * ptDensity; + const calHeight = height * ptDensity; + + // The calibration area in "calibration space" is (0,0) to (calWidth, calHeight) + // Transform corners to PDF space using inverse of localTransform + // This properly handles rotation and flipping + try { + const inverseLocal = inverse(localTransform); + + // Transform the 4 corners of the calibration rectangle + const corners = [ + transformPoint({ x: 0, y: 0 }, inverseLocal), + transformPoint({ x: calWidth, y: 0 }, inverseLocal), + transformPoint({ x: calWidth, y: calHeight }, inverseLocal), + transformPoint({ x: 0, y: calHeight }, inverseLocal), + ]; + + // Get bounding box in PDF space + const xs = corners.map((c) => c.x); + const ys = corners.map((c) => c.y); + + return { + x: Math.min(...xs), + y: Math.min(...ys), + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + }; + } catch { + return null; + } + }, [width, height, unitOfMeasure, localTransform]); + + // Calculate paper sheet bounds in PDF coordinates for mini map + // Paper sheet is centered in the calibration area, sized for A4 (CM) or Letter (IN) + const calculatePaperBounds = useCallback(() => { + if (width === 0 || height === 0) { + return null; + } + + // Paper dimensions based on unit of measure (matching drawing.ts drawPaperSheet) + const [paperWidth, paperHeight] = + unitOfMeasure === Unit.CM ? [29.7, 21] : [11, 8.5]; + + // Calculate calibration size in the current unit + const calWidth = width; + const calHeight = height; + + // Paper is centered in calibration area (in calibration units) + const paperX = (calWidth - paperWidth) * 0.5; + const paperY = (calHeight - paperHeight) * 0.5; + + // Convert to PDF units (points) + const ptDensity = getPtDensity(unitOfMeasure); + const paperWidthPts = paperWidth * ptDensity; + const paperHeightPts = paperHeight * ptDensity; + const paperXPts = paperX * ptDensity; + const paperYPts = paperY * ptDensity; + + // Transform corners to PDF space using inverse of localTransform + // This properly handles rotation and flipping + try { + const inverseLocal = inverse(localTransform); + + // Transform the 4 corners of the paper rectangle + const corners = [ + transformPoint({ x: paperXPts, y: paperYPts }, inverseLocal), + transformPoint( + { x: paperXPts + paperWidthPts, y: paperYPts }, + inverseLocal, + ), + transformPoint( + { x: paperXPts + paperWidthPts, y: paperYPts + paperHeightPts }, + inverseLocal, + ), + transformPoint( + { x: paperXPts, y: paperYPts + paperHeightPts }, + inverseLocal, + ), + ]; + + // Get bounding box in PDF space + const xs = corners.map((c) => c.x); + const ys = corners.map((c) => c.y); + + return { + x: Math.min(...xs), + y: Math.min(...ys), + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + }; + } catch { + return null; + } + }, [width, height, unitOfMeasure, localTransform]); + // Build current state object const buildState = useCallback( () => ({ @@ -165,6 +388,8 @@ export function ControlPanelBridge({ displaySettings, zoomedOut, magnifying, + // Whether we're actively zoomed in (magnify mode + already magnified) + isMagnified: magnifying && restoreTransforms !== null, measuring, file: file ? { name: file.name, type: file.type } : null, lineThickness, @@ -182,12 +407,22 @@ export function ControlPanelBridge({ stitchSettings, showingMovePad, corners: Array.from(corners), + // Preview data + previewImage: pdfThumbnail, + isPreviewLoading, + showPreviewImage, + viewportBounds: calculateViewportBounds(), + calibrationBounds: calculateCalibrationBounds(), + paperBounds: calculatePaperBounds(), + layoutWidth, + layoutHeight, }), [ isCalibrating, displaySettings, zoomedOut, magnifying, + restoreTransforms, measuring, file, lineThickness, @@ -201,6 +436,14 @@ export function ControlPanelBridge({ stitchSettings, showingMovePad, corners, + pdfThumbnail, + isPreviewLoading, + showPreviewImage, + calculateViewportBounds, + calculateCalibrationBounds, + calculatePaperBounds, + layoutWidth, + layoutHeight, ], ); @@ -321,7 +564,7 @@ export function ControlPanelBridge({ } as ChangeEvent); break; case "setUnit": - setUnitOfMeasure(params as string); + setUnitOfMeasure(params as Unit); break; case "resetCalibration": handleResetCalibration(); @@ -364,7 +607,15 @@ export function ControlPanelBridge({ pixels: number; }; const panOffset = getOffset(panDir, panPixels); - transformer.translate(panOffset); + // Negate the offset: pressing "right" should move viewport right, + // which means moving the pattern left (negative x) + transformer.translate({ x: -panOffset.x, y: -panOffset.y }); + break; + } + case "panViewDelta": { + // Direct delta panning from preview drag (in calibrated space units) + const { dx, dy } = params as { dx: number; dy: number }; + transformer.translate({ x: dx, y: dy }); break; } case "rotateView": { @@ -377,6 +628,82 @@ export function ControlPanelBridge({ transformer.rotate(center, degrees); break; } + // Mini map navigation - navigate to a point in PDF coordinates + case "navigateToPoint": { + const { x, y } = params as { x: number; y: number }; + const center = getCalibrationCenterPoint( + width, + height, + unitOfMeasure, + ); + + // If zoomed out, exit zoom out mode and center on the clicked point + if (zoomedOut && restoreTransforms) { + // Use the saved localTransform to calculate the new centered position + const oldLocal = restoreTransforms.localTransform; + // Transform the clicked point through the saved transform + const current = transformPoint({ x, y }, oldLocal); + + // Create new transform that centers on that point + const newLocal = translate({ + x: center.x - current.x, + y: center.y - current.y, + }).mmul(oldLocal); + + // Set the new transform and exit zoom out mode + transformer.setLocalTransform(newLocal); + setZoomedOut(false); + break; + } + + // Normal navigation (not zoomed out) + // Transform the clicked point through localTransform (same as recenter does) + const current = transformPoint({ x, y }, localTransform); + + // Move from current position to calibration center + const deltaX = center.x - current.x; + const deltaY = center.y - current.y; + + transformer.translate({ x: deltaX, y: deltaY }); + break; + } + // Magnify at a specific point in PDF coordinates (from mini map) + case "magnifyAtPoint": { + const { x, y } = params as { x: number; y: number }; + const center = getCalibrationCenterPoint( + width, + height, + unitOfMeasure, + ); + + if (magnifying && !restoreTransforms) { + // Not yet magnified - save transforms and magnify at the point + setRestoreTransforms({ + localTransform: localTransform.clone(), + calibrationTransform: calibrationTransform.clone(), + }); + + // Transform the clicked point through localTransform + const current = transformPoint({ x, y }, localTransform); + + // Create a new transform that centers on that point then scales + const translateToCenter = translate({ + x: center.x - current.x, + y: center.y - current.y, + }); + const scaleAtCenter = scaleAboutPoint(5, center); + + // Apply: first translate to center, then scale around center + const newTransform = scaleAtCenter + .mmul(translateToCenter) + .mmul(localTransform); + transformer.setLocalTransform(newTransform); + } else if (magnifying && restoreTransforms) { + // Already magnified - exit magnify mode + setMagnifying(false); + } + break; + } // Layer actions case "toggleLayer": dispatchLayerAction({ @@ -451,6 +778,10 @@ export function ControlPanelBridge({ step: params as number, }); break; + // Preview image toggle + case "togglePreviewImage": + setShowPreviewImage(!showPreviewImage); + break; } } }, @@ -469,6 +800,9 @@ export function ControlPanelBridge({ setZoomedOut, magnifying, setMagnifying, + restoreTransforms, + setRestoreTransforms, + calibrationTransform, measuring, setMeasuring, setLineThickness, @@ -496,32 +830,49 @@ export function ControlPanelBridge({ setCalibrationValidated, fullScreenActive, file, + showPreviewImage, + setShowPreviewImage, + localTransform, ], ); const { sendStateSync } = useBroadcastChannel(handleMessage); - // Sync state to control panel whenever it changes + // Use a ref to hold the latest buildState function so we can call it from intervals + // without causing the intervals to be recreated when buildState changes + const buildStateRef = useRef(buildState); + useEffect(() => { + buildStateRef.current = buildState; + }, [buildState]); + + // Track if state has changed since last sync + const stateVersionRef = useRef(0); + const lastSyncedVersionRef = useRef(0); + + // Increment version whenever buildState changes (indicates state changed) useEffect(() => { - sendStateSync(buildState()); - }, [buildState, sendStateSync]); + stateVersionRef.current += 1; + }, [buildState]); - // Handle sync requests - check periodically if a sync was requested + // Throttled sync - send updates at most every 150ms, only when state has changed + // This provides responsive updates without overwhelming the channel useEffect(() => { const interval = setInterval(() => { - if (syncRequestedRef.current) { + // Sync if: explicitly requested OR state has changed since last sync + if (syncRequestedRef.current || stateVersionRef.current !== lastSyncedVersionRef.current) { syncRequestedRef.current = false; - sendStateSync(buildState()); + lastSyncedVersionRef.current = stateVersionRef.current; + sendStateSync(buildStateRef.current()); } - }, 100); + }, 150); return () => clearInterval(interval); - }, [buildState, sendStateSync]); + }, [sendStateSync]); // Send initial sync on mount useEffect(() => { // Small delay to ensure channel is ready const timeout = setTimeout(() => { - sendStateSync(buildState()); + sendStateSync(buildStateRef.current()); }, 100); return () => clearTimeout(timeout); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/app/_components/tooltip/tooltip.tsx b/app/_components/tooltip/tooltip.tsx index 78f0ad40..d4868003 100644 --- a/app/_components/tooltip/tooltip.tsx +++ b/app/_components/tooltip/tooltip.tsx @@ -22,7 +22,7 @@ export default function Tooltip({ {children} {!disabled && (
{top ? ( <> diff --git a/app/_hooks/use-pdf-thumbnail.ts b/app/_hooks/use-pdf-thumbnail.ts new file mode 100644 index 00000000..87c2a33f --- /dev/null +++ b/app/_hooks/use-pdf-thumbnail.ts @@ -0,0 +1,309 @@ +"use client"; + +import { useEffect, useState, useRef, useMemo } from "react"; +import { pdfjs } from "react-pdf"; +import { getPageNumbers, getRowsColumns } from "@/_lib/get-page-numbers"; +import { + StitchSettings, + LineDirection, +} from "@/_lib/interfaces/stitch-settings"; +import { erodeImageData } from "@/_lib/erode"; +import { Layers } from "@/_lib/layers"; + +// Configure PDF.js worker +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.min.js", + import.meta.url, +).toString(); + +// Maximum thumbnail dimension (width or height) +// Using larger size for better quality in enlarged mini map view +const MAX_THUMBNAIL_SIZE = 1200; + +// Internal render multiplier - render at higher resolution then scale down +// This helps preserve thin lines that would otherwise be sub-pixel +const RENDER_SCALE_MULTIPLIER = 3; + +// Base erosion to apply to make lines visible at thumbnail scale +// Set to 1 for minimal thickening to ensure lines are visible +const THUMBNAIL_BASE_EROSION = 1; + +// Empty layers object - stable reference to avoid triggering re-renders +const EMPTY_LAYERS: Layers = {}; + +/** + * Apply threshold to make lines more visible. + * Anything not near-white becomes black - this makes even faint gray lines visible. + */ +function applyThreshold( + ctx: CanvasRenderingContext2D, + width: number, + height: number, +) { + const imageData = ctx.getImageData(0, 0, width, height); + const data = imageData.data; + + // Threshold: anything below this brightness becomes black + // 240 is quite aggressive - even light grays will be captured + const threshold = 240; + + for (let i = 0; i < data.length; i += 4) { + // Get the average brightness of the pixel + const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3; + + if (brightness < threshold) { + // Make it black (will become white when inverted) + data[i] = 0; + data[i + 1] = 0; + data[i + 2] = 0; + } else { + // Make it white (will become black when inverted) + data[i] = 255; + data[i + 1] = 255; + data[i + 2] = 255; + } + } + + ctx.putImageData(imageData, 0, 0); +} + +/** + * Hook to generate a low-resolution thumbnail of a PDF file. + * The thumbnail shows all pages stitched together according to stitch settings. + * Applies line thickness (erosion) to make faint lines more visible. + * + * The thumbnail is cached - toggling 'enabled' doesn't regenerate it. + * Only regenerates when file or relevant settings change. + * + * Returns an object with: + * - thumbnail: The data URL of the thumbnail image, or null if not ready + * - isLoading: Whether the thumbnail is currently being generated + */ +export function usePdfThumbnail( + file: File | null, + pageCount: number, + stitchSettings: StitchSettings, + lineThickness: number, + enabled: boolean = true, + layers: Layers = EMPTY_LAYERS, +): { thumbnail: string | null; isLoading: boolean } { + // Store the cached thumbnail separately from what we return + const [cachedThumbnail, setCachedThumbnail] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const abortControllerRef = useRef(null); + + // Create a stable key for layers to avoid re-running effect when layers object + // is recreated with the same content + const layersKey = useMemo( + () => JSON.stringify(Object.keys(layers).sort().map(k => [k, layers[k].visible])), + [layers] + ); + + useEffect(() => { + // Only regenerate if file or settings change, not when enabled toggles + if (!file || pageCount === 0) { + setCachedThumbnail(null); + setIsLoading(false); + return; + } + + // Cancel any previous render + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + + setIsLoading(true); + + async function generateThumbnail() { + try { + // Load the PDF document + const arrayBuffer = await file!.arrayBuffer(); + if (signal.aborted) return; + + const loadingTask = pdfjs.getDocument({ data: arrayBuffer }); + const pdf = await loadingTask.promise; + if (signal.aborted) return; + + // Get page layout info + const pages = getPageNumbers(stitchSettings.pageRange, pageCount); + const [rows, columns] = getRowsColumns( + pages, + stitchSettings.lineCount, + stitchSettings.lineDirection, + ); + + // Get page dimensions (use first page as reference) + const firstPageNum = pages.find((p) => p > 0) || 1; + const firstPage = await pdf.getPage(firstPageNum); + if (signal.aborted) return; + + const userUnit = firstPage.userUnit || 1; + const pageViewport = firstPage.getViewport({ scale: 1 }); + const pageWidth = pageViewport.width * userUnit; + const pageHeight = pageViewport.height * userUnit; + + // Calculate total layout size + const totalWidth = pageWidth * columns; + const totalHeight = pageHeight * rows; + + // Calculate scale to fit in thumbnail size + const outputScale = Math.min( + MAX_THUMBNAIL_SIZE / totalWidth, + MAX_THUMBNAIL_SIZE / totalHeight, + 1, // Don't upscale + ); + + // Render at higher resolution to preserve thin lines + const renderScale = outputScale * RENDER_SCALE_MULTIPLIER; + + const thumbWidth = Math.ceil(totalWidth * outputScale); + const thumbHeight = Math.ceil(totalHeight * outputScale); + const renderWidth = Math.ceil(totalWidth * renderScale); + const renderHeight = Math.ceil(totalHeight * renderScale); + const tileWidth = Math.ceil(pageWidth * renderScale); + const tileHeight = Math.ceil(pageHeight * renderScale); + + // Create high-res canvas for rendering + const renderCanvas = document.createElement("canvas"); + renderCanvas.width = renderWidth; + renderCanvas.height = renderHeight; + const renderCtx = renderCanvas.getContext("2d"); + if (!renderCtx) return; + + // Fill with white background + renderCtx.fillStyle = "#ffffff"; + renderCtx.fillRect(0, 0, renderWidth, renderHeight); + + // Render each page at high resolution + const pdfRenderScale = renderScale * userUnit; + + for (let i = 0; i < pages.length; i++) { + if (signal.aborted) return; + + const pageNum = pages[i]; + if (pageNum === 0) continue; // Skip blank pages + + // Calculate position based on grid layout + let col: number, row: number; + if (stitchSettings.lineDirection === LineDirection.Row) { + col = Math.floor(i / rows); + row = i % rows; + } else { + col = i % columns; + row = Math.floor(i / columns); + } + + const x = col * tileWidth; + const y = row * tileHeight; + + const page = await pdf.getPage(pageNum); + if (signal.aborted) return; + + const viewport = page.getViewport({ scale: pdfRenderScale }); + + // Create a temporary canvas for this page + const pageCanvas = document.createElement("canvas"); + pageCanvas.width = Math.ceil(viewport.width); + pageCanvas.height = Math.ceil(viewport.height); + const pageCtx = pageCanvas.getContext("2d"); + if (!pageCtx) continue; + + // Get optional content config and apply layer visibility + const optionalContentConfig = await pdf.getOptionalContentConfig(); + for (const layer of Object.values(layers)) { + for (const id of layer.ids) { + optionalContentConfig.setVisibility(id, layer.visible); + } + } + + // Render the page with layer visibility settings + await page.render({ + canvasContext: pageCtx, + viewport: viewport, + optionalContentConfigPromise: Promise.resolve( + optionalContentConfig, + ), + }).promise; + + if (signal.aborted) return; + + // Draw onto high-res canvas + renderCtx.drawImage(pageCanvas, x, y, tileWidth, tileHeight); + } + + // Apply erosion (line thickening) on high-res canvas + // Use base erosion plus user's lineThickness, scaled for the render multiplier + const totalErosion = + (THUMBNAIL_BASE_EROSION + lineThickness) * RENDER_SCALE_MULTIPLIER; + if (totalErosion > 0) { + let imageData = renderCtx.getImageData( + 0, + 0, + renderWidth, + renderHeight, + ); + let buffer = new ImageData(renderWidth, renderHeight); + for (let i = 0; i < totalErosion; i++) { + erodeImageData(imageData, buffer); + [imageData, buffer] = [buffer, imageData]; + } + renderCtx.putImageData(imageData, 0, 0); + } + + // Create final thumbnail canvas and scale down + const canvas = document.createElement("canvas"); + canvas.width = thumbWidth; + canvas.height = thumbHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Draw scaled-down version with high quality smoothing + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(renderCanvas, 0, 0, thumbWidth, thumbHeight); + + // Apply threshold to make lines crisp (anything not white becomes black) + applyThreshold(ctx, thumbWidth, thumbHeight); + + // Convert to data URL + const dataUrl = canvas.toDataURL("image/jpeg", 0.85); + if (!signal.aborted) { + setCachedThumbnail(dataUrl); + setIsLoading(false); + } + } catch (error) { + if (!signal.aborted) { + console.error("Error generating PDF thumbnail:", error); + setCachedThumbnail(null); + setIsLoading(false); + } + } + } + + generateThumbnail(); + + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, [ + file, + pageCount, + stitchSettings.pageRange, + stitchSettings.lineCount, + stitchSettings.lineDirection, + lineThickness, + layersKey, + ]); + // Note: 'enabled' is NOT in the dependency array - we cache regardless of enabled state + // Note: layersKey provides stable comparison for layers changes + + // Return the cached thumbnail only if enabled, and loading state + return { + thumbnail: enabled ? cachedThumbnail : null, + isLoading: isLoading && enabled, + }; +} diff --git a/app/_icons/visibility-icon.tsx b/app/_icons/visibility-icon.tsx new file mode 100644 index 00000000..a8bf211b --- /dev/null +++ b/app/_icons/visibility-icon.tsx @@ -0,0 +1,14 @@ +export default function VisibilityIcon({ ariaLabel }: { ariaLabel: string }) { + return ( + + + + ); +} diff --git a/app/_icons/visibility-off-icon.tsx b/app/_icons/visibility-off-icon.tsx new file mode 100644 index 00000000..acb5efef --- /dev/null +++ b/app/_icons/visibility-off-icon.tsx @@ -0,0 +1,18 @@ +export default function VisibilityOffIcon({ + ariaLabel, +}: { + ariaLabel: string; +}) { + return ( + + + + ); +} diff --git a/messages/en.json b/messages/en.json index 51048edb..abc85d56 100644 --- a/messages/en.json +++ b/messages/en.json @@ -341,7 +341,14 @@ "advancedOptions": "Stitch / Layers / Scale", "instructions": "How to use", "instructionsText": "Keep this window on your laptop screen while the main Pattern Projector window is on the projector. All controls here will update the projected view.", - "zoomIn": "Zoom in" + "zoomIn": "Zoom in", + "preview": "Preview", + "previewNoFile": "Open a file to see the preview", + "previewClickToNavigate": "Click or drag to navigate", + "previewShowImage": "Show pattern image", + "previewHideImage": "Hide pattern image", + "previewEnlarge": "Enlarge preview", + "previewShrink": "Shrink preview" }, "MovementPad": { "up": "Up", diff --git a/tailwind.config.ts b/tailwind.config.ts index 4e0fa799..058ea4b4 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -7,6 +7,10 @@ const config: Config = { "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", ], + safelist: [ + "cursor-zoom-in", + "cursor-zoom-out", + ], corePlugins: { aspectRatio: false, },