import { router } from '@inertiajs/react';
import Konva from 'konva';
import { Expand, Focus, Lock, Shrink, Unlock } from 'lucide-react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react';
import {
    Ellipse,
    Group,
    Layer as KonvaLayer,
    Line,
    Rect,
    Shape,
    Stage,
    Text,
    Transformer,
} from 'react-konva';
import { Tooltip } from '@/Components/ui';
import { cn } from '@/lib/cn';
import { useAccentColor } from '@/lib/theme';
import { rectToAnchors, resolveAnchors, type Rect as LayerRect } from './anchors';
import { anchorsForDropPoint, clientToCanvasPoint, validateDroppedImages } from './dnd';
import { createCalloutLayer, createImageLayer } from './factories';
import { ensureFontsForLayout, fontsReadyForLayouts, subscribeFonts } from './fonts';
import { ContextMenu } from './ContextMenu';
import { InlineTextEditor } from './InlineTextEditor';
import { isEffectivelyLocked, isEffectivelyVisible } from './hierarchy';
import { createFrameScreen } from './factories';
import { buildPathFromNodes, LayerNode, type ClipShapeBinding } from './LayerNode';
import type { Anchors, ImageCrop, ImageLayer, Layer, Layout, PathNode, ShapeLayer } from '@/types/layout';
import { uploadMedia } from './media';
import { computeSnap, computeSnapTargets } from './snap';
import { useEditor } from './store';

const GRID_STEP = 32;
const DOT_RADIUS = 1;
const MIN_SCALE = 0.05;
const MAX_SCALE = 4;
const ZOOM_STEP = 1.08;
const SNAP_PX = 8;
// Inactive screens are flattened to a bitmap (see the screen-cache effect).
// Its width is bounded to ~this many px REGARDLESS of the factor's canvas
// size, so a tablet (iPad 2064×2752 / Android tablet 1848×2960 ≈ 2.5× a
// phone's pixels) doesn't rasterize + blit a 2.5× bigger bitmap than a phone.
// Bounding it fixes both the tablet "setup" hitch (8 big rasterizes on load)
// and the per-frame blit cost during scroll. Inactive screens render dimmed
// as context, so the soft snapshot is invisible in practice.
const INACTIVE_CACHE_TARGET_WIDTH = 820;
const INACTIVE_CACHE_MIN_PIXEL_RATIO = 0.3;
const INACTIVE_CACHE_MAX_PIXEL_RATIO = 0.6;

// Cap Konva's global render pixel ratio for the on-screen editor. Konva
// defaults to `window.devicePixelRatio`, so on a 2× retina display EVERY
// stage/layer/cached-node backing canvas — and every scaled-image texture
// Chrome caches per zoom level while the user zooms a multi-screen deck —
// is allocated at 4× the pixels. On big projects that dominated GPU-process
// memory (10 GB+ while zooming a 9-screen factor) and stalled the canvas.
// The editor is a PREVIEW surface: export renders through
// `stage.toCanvas({ pixelRatio: 1 })` at native resolution regardless (see
// export/renderScreen.ts), so capping here never touches output quality.
// Pixel ratio is the dominant GPU-memory + byte-push lever (it SQUARES every
// backing canvas + scaled-image texture). 1.5 is the balance: crisp enough on
// a 2× retina display for editing (1 was reported blurry), while cutting
// pixel-ratio-dependent GPU memory ~44% vs 2 — meaningful on multi-factor
// projects where Chrome retains each visited factor's textures. Export is
// unaffected (`renderScreen.ts` forces `toCanvas({ pixelRatio: 1 })` at native
// resolution). Knob: raise toward 2 for sharper preview, lower for less
// memory. Inactive screens use their own low cache ratio, independent of this.
if (typeof window !== 'undefined') {
    Konva.pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5);
}

export type WorkspaceScreen = {
    id: number;
    name: string;
    viewport_x: number;
    viewport_y: number;
    layout: Layout;
    clipOverflow: boolean;
};

type Props = {
    screens: WorkspaceScreen[];
    activeScreenId: number;
    projectId: number;
    /** Editor.tsx owns the fullscreen-mode state (it controls whether the
     *  top toolbar renders). CanvasStage hosts the toggle button + drives
     *  a tighter canvas fit when the mode flips on, since the viewport
     *  area grows after the toolbar hides. */
    fullscreen?: boolean;
    /** Concurrency lock (Team Workspaces P4 / Δ21). When read-only the
     *  canvas suppresses every mutating interaction — layers aren't
     *  draggable/transformable/selectable; pan/zoom + screen-switch stay
     *  live so a read-only viewer can still navigate. */
    readOnly?: boolean;
    onToggleFullscreen?: () => void;
};

export default function CanvasStage({
    screens,
    activeScreenId,
    projectId,
    fullscreen = false,
    readOnly = false,
    onToggleFullscreen,
}: Props) {
    const activeLayout = useEditor((s) => s.layout);
    const activeLocale = useEditor((s) => s.activeLocale);
    // Resolved hex from the runtime `--color-accent` CSS var so the
    // active-screen label tracks the user's Appearance accent (Electric
    // Blue in light, the dark variant after the theme flip, or whatever
    // the admin set as the brand accent).
    const accentColor = useAccentColor();
    const selection = useEditor((s) => s.selection);
    const groupSelected = useEditor((s) => s.groupSelected);
    const ungroupSelected = useEditor((s) => s.ungroupSelected);
    const clearSelection = useEditor((s) => s.clearSelection);
    const updateLayer = useEditor((s) => s.updateLayer);
    const commitGroupTransform = useEditor((s) => s.commitGroupTransform);
    const addLayer = useEditor((s) => s.addLayer);
    const setSelection = useEditor((s) => s.setSelection);
    const setFrameScreen = useEditor((s) => s.setFrameScreen);
    const devicePresets = useEditor((s) => s.devicePresets);
    const cropModeLayerId = useEditor((s) => s.cropModeLayerId);
    const exitCropMode = useEditor((s) => s.exitCropMode);
    const penMode = useEditor((s) => s.penMode);
    const penDraft = useEditor((s) => s.penDraft);
    const addPenNode = useEditor((s) => s.addPenNode);
    const updatePenNode = useEditor((s) => s.updatePenNode);
    const setPenClosed = useEditor((s) => s.setPenClosed);
    const commitPen = useEditor((s) => s.commitPen);
    const exitPenMode = useEditor((s) => s.exitPenMode);
    const calloutMode = useEditor((s) => s.calloutMode);
    const calloutShape = useEditor((s) => s.calloutShape);
    const calloutEditingLayerId = useEditor((s) => s.calloutEditingLayerId);
    const exitCalloutMode = useEditor((s) => s.exitCalloutMode);
    const pathEditLayerId = useEditor((s) => s.pathEditLayerId);
    const enterPathEdit = useEditor((s) => s.enterPathEdit);
    const exitPathEdit = useEditor((s) => s.exitPathEdit);
    const textEditLayerId = useEditor((s) => s.textEditLayerId);
    const enterTextEdit = useEditor((s) => s.enterTextEdit);
    const previewLayer = useEditor((s) => s.previewLayer);
    const deleteLayer = useEditor((s) => s.deleteLayer);
    // Aspect lock is per-layer; it only meaningfully constrains the
    // Transformer when exactly one layer is selected.
    const aspectLocked = useEditor((s) =>
        s.selection.length === 1 ? s.aspectLocks[s.selection[0]] != null : false,
    );
    // Ellipse callout selection — Konva Transformer normally wraps the
    // bounding rect (8 anchors). For an ellipse the bbox corners sit in
    // empty space outside the visible shape; we trim to the 4 cardinal
    // anchors which land ON the ellipse perimeter so handles feel attached
    // to the visible content.
    const ellipseCalloutSelected = useEditor((s) => {
        if (s.selection.length !== 1) return false;
        const layer = s.layout.layers.find((l) => l.id === s.selection[0]);
        return layer?.type === 'callout' && layer.shape === 'ellipse';
    });
    // A single GROUP selected → the Transformer wraps the group's MEMBER nodes
    // (the group itself renders nothing), giving one box that moves + resizes
    // the element as a unit. Resize is proportional (corners + keepRatio).
    const selectedGroupId =
        selection.length === 1 && activeLayout.layers.find((l) => l.id === selection[0])?.type === 'group'
            ? selection[0]
            : null;
    const isGroupSelected = selectedGroupId !== null;
    const isGroupMember = (layer: Layer): boolean =>
        layer.parentId != null && selectedGroupId === layer.parentId;

    const containerRef = useRef<HTMLDivElement>(null);
    const stageRef = useRef<Konva.Stage>(null);
    const transformerRef = useRef<Konva.Transformer>(null);
    // Group move/resize fires one Konva event PER member; buffer them and flush
    // once (microtask, after the synchronous event burst) so the whole gesture
    // is a single undo entry — and the laurel/star link passes run once on the
    // final positions.
    const groupCommitRef = useRef(
        new Map<string, { id: string; anchors: Anchors; rotation?: number; scale?: number }>(),
    );
    const groupFlushScheduledRef = useRef(false);
    const [size, setSize] = useState({ w: 0, h: 0 });
    const [scale, setScale] = useState(1);
    const [guides, setGuides] = useState<{ x: number[]; y: number[] }>({ x: [], y: [] });
    // Viewport culling — every screen renders LIVE on one Konva layer (bg +
    // large frame PNG + screenshot + text), so a big multi-screen project
    // redraws all of them on every pan/zoom/drag. Above a threshold we mount
    // only the screens intersecting the visible viewport (+ a margin so
    // neighbours are ready before they scroll in) plus the active screen.
    // `null` = render all (small projects are unchanged). Pan/zoom mutate the
    // stage transform imperatively, so `scheduleCullRecompute` re-derives the
    // set from the live transform after each change (coalesced per frame).
    const CULL_THRESHOLD = 6;
    const CULL_MARGIN = 0.6; // fraction of a viewport of pre-mount headroom
    const [visibleScreenIds, setVisibleScreenIds] = useState<Set<number> | null>(null);
    const cullRafRef = useRef<number | null>(null);
    /**
     * Brief opacity gate to hide the "system font → Google font" reflow
     * flash. Konva measures + paints text at the canvas's CURRENT computed
     * font (system-ui fallback) on first commit, then `subscribeFonts`
     * triggers a redraw with the real face — which makes text visibly
     * shrink/grow. Holding the stage at opacity 0 for up to 250ms lets the
     * fonts resolve before the user sees anything; if they take longer
     * (slow network), we reveal anyway so the user isn't stuck looking at
     * a blank canvas.
     */
    const [fontsSettled, setFontsSettled] = useState(false);
    useEffect(() => {
        let cancelled = false;
        const reveal = () => {
            if (!cancelled) setFontsSettled(true);
        };
        if (typeof document !== 'undefined' && document.fonts?.ready) {
            document.fonts.ready.then(reveal).catch(reveal);
        } else {
            reveal();
        }
        // Hard cap — never block the canvas behind the gate for more than
        // 250ms even if document.fonts.ready stalls.
        const t = window.setTimeout(reveal, 250);
        return () => {
            cancelled = true;
            window.clearTimeout(t);
        };
    }, []);
    // Canvas zoom lock — when on, the wheel + keyboard zoom shortcuts pan
    // horizontally instead of changing scale. Useful when the user has set
    // a deliberate zoom level for review/screenshot work and doesn't want
    // accidental pinch-zoom to disrupt it. Defaults to LOCKED for first-time
    // visitors so plain mouse-wheel scrolls the workspace sideways (the
    // expected gesture for a multi-screen project) instead of zooming. The
    // localStorage key flips on first toggle and sticks across sessions.
    const [zoomLocked, setZoomLocked] = useState<boolean>(() => {
        if (typeof window === 'undefined') return true;
        try {
            const v = window.localStorage.getItem('appshot:canvas-zoom-locked');
            // Missing key = locked; '0' = the user explicitly unlocked once.
            return v !== '0';
        } catch {
            return true;
        }
    });
    const zoomLockedRef = useRef(zoomLocked);
    zoomLockedRef.current = zoomLocked;
    const toggleZoomLocked = () => {
        setZoomLocked((prev) => {
            const next = !prev;
            try {
                window.localStorage.setItem('appshot:canvas-zoom-locked', next ? '1' : '0');
            } catch {
                /* storage blocked — DOM state still wins for this session */
            }
            return next;
        });
    };
    const didFit = useRef(false);
    const scaleRef = useRef(scale);
    scaleRef.current = scale;
    // Debounced commit of the zoom level to React state. Wheel zoom applies
    // the new scale to the stage IMPERATIVELY (smooth, no React work), but a
    // synchronous `setScale` per wheel tick re-rendered all of CanvasStage —
    // and LayerNode is a plain forwardRef (not memoized), so all ~160 nodes
    // re-ran their bodies (resolveAnchors/useImage/…) dozens of times a second
    // during a scroll-zoom gesture. That was the residual sluggishness. The
    // screens don't read `scale` state (they use a local scale=1 + the live
    // stage transform); only overlay stroke widths + the zoom% readout do, and
    // those tolerate a ~120ms lag. `scaleRef` is updated immediately so drag/
    // snap thresholds (read at drag START, never mid-zoom) stay exact.
    const scaleCommitTimerRef = useRef<number | null>(null);
    const commitScale = useCallback((v: number) => {
        scaleRef.current = v;
        if (scaleCommitTimerRef.current !== null) {
            window.clearTimeout(scaleCommitTimerRef.current);
        }
        scaleCommitTimerRef.current = window.setTimeout(() => {
            scaleCommitTimerRef.current = null;
            setScale(v);
        }, 120);
    }, []);
    // Live cursor pos in active-canvas coords — drives the pen rubberband and
    // drag-to-smooth detection without round-tripping state through Zustand.
    const [penCursor, setPenCursor] = useState<{ x: number; y: number } | null>(null);
    const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
    /** Which inactive screen the pointer is currently over. Drives the
     *  "lift" affordance on the workspace canvas — inactive screens fade
     *  back to full opacity + pointer cursor so users get a click hint
     *  before they actually click to switch. Cleared on mouseleave. */
    const [hoveredScreenId, setHoveredScreenId] = useState<number | null>(null);
    // Layer-level hover on the active screen — drives a soft accent outline
    // overlay so users see what they'd select before clicking. Inactive
    // screens swallow mouse events at their screen-hit rect, so layer hover
    // only fires on the active canvas.
    const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
    const penDragRef = useRef<{ start: { x: number; y: number }; index: number } | null>(null);
    // Callout authoring drag — tracks the in-progress source or display rect
    // marquee. `sourceLayerId` is set during phase-1 drag (so the source layer
    // can be resolved at mouseup); null during phase-2 display drag.
    // `originLayer` snapshots the source layer's resolved rect at mousedown
    // so the marquee preview stays anchored even if the user drags off the
    // source.
    const calloutDragRef = useRef<{
        startCanvas: { x: number; y: number };
        currentCanvas: { x: number; y: number };
        sourceLayerId: string | null;
        sourceRect: { x: number; y: number; width: number; height: number } | null;
    } | null>(null);
    const [, calloutRedrawTick] = useState(0);
    const bumpCalloutRedraw = () => calloutRedrawTick((v) => v + 1);
    // OS → canvas drag-and-drop for images. `dropActive` controls the overlay
    // ring; counter tracks enter/leave events (child elements fire both, so a
    // plain boolean flickers). `dropError` is a short-lived toast message.
    const [dropActive, setDropActive] = useState(false);
    const dropEnterCountRef = useRef(0);
    const [dropError, setDropError] = useState<string | null>(null);
    const [dropBusy, setDropBusy] = useState(false);

    const workspace = useMemo(() => {
        if (screens.length === 0) {
            return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0 };
        }
        let minX = Infinity,
            minY = Infinity,
            maxX = -Infinity,
            maxY = -Infinity;
        for (const s of screens) {
            const { width, height } = s.layout.canvas;
            minX = Math.min(minX, s.viewport_x);
            minY = Math.min(minY, s.viewport_y);
            maxX = Math.max(maxX, s.viewport_x + width);
            maxY = Math.max(maxY, s.viewport_y + height);
        }
        return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
    }, [screens]);

    useEffect(() => {
        const el = containerRef.current;
        if (!el) return;
        const observer = new ResizeObserver((entries) => {
            for (const entry of entries) {
                const { width, height } = entry.contentRect;
                setSize({ w: Math.floor(width), h: Math.floor(height) });
            }
        });
        observer.observe(el);
        return () => observer.disconnect();
    }, []);

    // Two distinct font-load signals — both must trigger a React re-render
    // so LayerNode's `measureTextNaturalSize` re-runs against the now-loaded
    // face. Without these, initial paint measures with system fallback and
    // never updates: `subscribeFonts → batchDraw` only re-paints Konva, not
    // React; meanwhile the `fontsSettled` boolean already flipped true via
    // the 250ms hardcap and won't trigger again.
    //
    // (1) document.fonts.ready — fires once the BROWSER's font-load queue
    //     drains. Catches the "PRELOADED" lie: fonts.ts assumes Inter /
    //     Calistoga / JetBrains Mono are loaded at boot, but on a cold
    //     page refresh the <link rel=stylesheet> in app.blade.php is
    //     still in flight at React mount, so ensureFontLoaded short-
    //     circuits and never notifies. This bump fires once at startup
    //     after the head <link> resolves.
    //
    // (2) subscribeFonts — fires when a runtime ensureFontLoaded call
    //     (custom fonts, kit fonts not in PRELOADED) finishes its task
    //     chain. Debounced so a burst of N font loads collapses into
    //     one re-render burst.
    // fontTick is read into the screen Group's React key below. Konva's
    // Text node caches its internal wrap calculation once `text`/`width`/
    // `fontFamily` are set — react-konva's reconciler doesn't push a re-
    // measure when those props are unchanged across renders. So a React
    // re-render alone doesn't invalidate Konva's stale wrap. Including the
    // tick in the Group key forces a full unmount + remount when a font
    // arrives, identical to what a tab-switch does (proven to work).
    const [fontTick, bumpFontTick] = useReducer((n: number) => n + 1, 0);
    useEffect(() => {
        if (typeof document === 'undefined' || !document.fonts?.ready) return;
        // Skip the remount-on-font-load when every needed face is ALREADY
        // loaded (warm load / device change). The first render already
        // measured correctly, so bumping fontTick would tear down + rebuild
        // the whole scene (re-decoding all images) for nothing — the visible
        // "settle" on load / factor switch. Only arm the bump on a genuine
        // cold load where a face is still in flight.
        if (fontsReadyForLayouts(screens.map((s) => s.layout), activeLocale)) {
            return;
        }
        let cancelled = false;
        document.fonts.ready.then(() => {
            if (!cancelled) bumpFontTick();
        });
        return () => {
            cancelled = true;
        };
    }, [screens, activeLocale]);
    useEffect(() => {
        let timer: number | null = null;
        const unsub = subscribeFonts(() => {
            stageRef.current?.batchDraw();
            if (timer !== null) window.clearTimeout(timer);
            timer = window.setTimeout(() => {
                bumpFontTick();
                timer = null;
            }, 50);
        });
        return () => {
            unsub();
            if (timer !== null) window.clearTimeout(timer);
        };
    }, []);

    // Apply the font-settled opacity gate to Konva's container div. Stage
    // doesn't expose a `style` prop directly — we reach in via
    // `stage.container()`. Cheap to re-run since it's just 2 inline-style
    // assignments.
    useEffect(() => {
        const c = stageRef.current?.container();
        if (!c) return;
        c.style.opacity = fontsSettled ? '1' : '0';
        c.style.transition = 'opacity 120ms ease';
    }, [fontsSettled, size.w, size.h]);

    useEffect(() => {
        // Kick off font downloads for every family used in every screen so
        // custom fonts saved to localStorage actually render on page refresh
        // (not just immediately after the user picks them). ensureFontLoaded
        // is idempotent — cached promises make repeated calls cheap.
        for (const screen of screens) {
            void ensureFontsForLayout(screen.layout, activeLocale);
        }
        void ensureFontsForLayout(activeLayout, activeLocale);
    }, [screens, activeLayout, activeLocale]);

    // useLayoutEffect (not useEffect): the fit must apply BEFORE the browser
    // paints. In a plain effect the fit runs AFTER paint, so the Stage paints
    // once at scale 1 (100%, unfitted) first. On a heavy tablet load the main
    // thread is then busy for 2-3s (mounting ~160 nodes, decoding large
    // images, caching), so that unfitted frame stays on screen the whole time
    // before snapping to the fitted view — the visible "settle". Running the
    // fit + a SYNCHRONOUS draw pre-paint makes the first painted frame already
    // fitted, no matter how long the load blocks afterwards.
    useLayoutEffect(() => {
        if (didFit.current) return;
        if (!size.w || !size.h) return;
        if (workspace.width <= 0 || workspace.height <= 0) return;
        const stage = stageRef.current;
        if (!stage) return;

        // Default fit = max-zoom that fits the ACTIVE screen, not the whole
        // workspace. Authors land in editing position immediately instead of
        // staring at a thumbnail-strip view of every screen. The "fit all"
        // affordance still exists (the focus button next to zoom %); users
        // who want the bird's-eye view click it once. Falls back to the
        // workspace fit when the active screen can't be resolved (defensive
        // — shouldn't happen in practice).
        const active = screens.find((s) => s.id === activeScreenId);
        const padding = 40;
        let nextScale: number;
        let posX: number;
        let posY: number;
        if (active && active.layout.canvas.width > 0 && active.layout.canvas.height > 0) {
            const { width: screenW, height: screenH } = active.layout.canvas;
            const fit = Math.min(
                (size.w - padding * 2) / screenW,
                (size.h - padding * 2) / screenH,
            );
            nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fit));
            posX = (size.w - screenW * nextScale) / 2 - active.viewport_x * nextScale;
            posY = (size.h - screenH * nextScale) / 2 - active.viewport_y * nextScale;
        } else {
            const fit = Math.min(
                (size.w - 120 * 2) / workspace.width,
                (size.h - 120 * 2) / workspace.height,
            );
            nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fit));
            posX = (size.w - workspace.width * nextScale) / 2 - workspace.minX * nextScale;
            posY = (size.h - workspace.height * nextScale) / 2 - workspace.minY * nextScale;
        }
        stage.scale({ x: nextScale, y: nextScale });
        stage.position({ x: posX, y: posY });
        // Synchronous draw (not batchDraw, which defers to the next rAF) so the
        // fitted transform is on the canvas before this frame paints.
        stage.draw();
        setScale(nextScale);
        didFit.current = true;
    }, [size.w, size.h, workspace, screens, activeScreenId]);

    // Re-center on the active screen whenever the user switches screens.
    // Preserves current zoom (translates the stage only, no scale change),
    // so a flurry of switches doesn't keep snapping the user back to a
    // "fit-to-screen" view they've zoomed past. The initial-fit effect
    // above handles first-mount centering at the right scale; this effect
    // handles subsequent re-centers.
    useEffect(() => {
        if (!didFit.current) return;
        if (!size.w || !size.h) return;
        const stage = stageRef.current;
        if (!stage) return;
        const active = screens.find((s) => s.id === activeScreenId);
        if (!active) return;
        const { width: screenW, height: screenH } = active.layout.canvas;
        if (screenW <= 0 || screenH <= 0) return;
        const currentScale = stage.scaleX();
        const targetX =
            (size.w - screenW * currentScale) / 2 - active.viewport_x * currentScale;
        const targetY =
            (size.h - screenH * currentScale) / 2 - active.viewport_y * currentScale;
        // Smooth pan to the new active screen instead of a position
        // snap. Konva's Tween animates the stage's x/y on each rAF
        // frame; the re-center effect re-runs on the next switch and
        // implicitly replaces the in-flight tween via setAttrs at the
        // new starting position (Konva.Tween cancels prior tweens on
        // the same node + attrs). 180ms / EaseOut matches the
        // fullscreen-toggle fit cadence elsewhere in the editor.
        stage.to({
            x: targetX,
            y: targetY,
            duration: 0.18,
            easing: Konva.Easings.EaseOut,
        });
        // Intentionally narrow deps — screens array reference changes from
        // Inertia partial reloads shouldn't snap the user back to center
        // mid-edit; only an active-screen switch triggers re-centering.
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [activeScreenId, size.w, size.h]);

    useEffect(() => {
        const stage = stageRef.current;
        const tr = transformerRef.current;
        if (!stage || !tr) return;
        if (penMode || pathEditLayerId || textEditLayerId) {
            tr.nodes([]);
            tr.getLayer()?.batchDraw();
            return;
        }
        // Scope node lookup to the active screen's Group — `findOne('#id')` on
        // the stage returns the FIRST match, which collides if a screen
        // duplicate ever leaves IDs paired across screens. Searching inside
        // the active screen group keeps the transformer pinned to the right
        // copy regardless of any past collisions.
        const screenGroup = activeScreenId
            ? (stage.findOne(`.screen-group-${activeScreenId}`) as
                  | Konva.Group
                  | undefined) ?? null
            : null;
        const scope: Konva.Container = screenGroup ?? stage;
        const nodes = selection
            .flatMap((id): (Konva.Node | null)[] => {
                const layer = activeLayout.layers.find((l) => l.id === id);
                // Background layers are full-bleed and not resize/rotate-able;
                // hide transformer handles but keep the selection so the
                // properties panel still shows its controls.
                if (!layer || isEffectivelyLocked(layer, activeLayout.layers) || layer.type === 'background') return [];
                // While in crop mode the crop overlay owns its own transformer —
                // hide the main one for that layer so handles don't stack.
                if (id === cropModeLayerId) return [];
                // A group itself has no Konva node — wrap its visible, unlocked
                // members so the Transformer bounds + moves + resizes the whole
                // element as one unit.
                if (layer.type === 'group') {
                    return activeLayout.layers
                        .filter(
                            (l) =>
                                l.parentId === id &&
                                l.type !== 'group' &&
                                l.type !== 'background' &&
                                !isEffectivelyLocked(l, activeLayout.layers),
                        )
                        .map((l) => scope.findOne(`#${l.id}`) ?? null);
                }
                return [scope.findOne(`#${id}`) ?? null];
            })
            .filter((n): n is Konva.Node => !!n);
        tr.nodes(nodes);
        // Group move: make the whole transformer area draggable so a press
        // anywhere inside the box moves all members together (a group has no
        // single node to grab). Set imperatively — react-konva doesn't map this
        // prop reliably, so the back-drag rect was never created and the press
        // fell through to the Stage's pan.
        tr.shouldOverdrawWholeArea(isGroupSelected);
        // Refresh the Transformer's cached bounds after every layer mutation
        // — without this, prop changes that resize a Konva Text node
        // internally (fontFamily / fontWeight / fontSize / content) leave
        // the handles drawn around the OLD bbox while the text itself
        // re-renders correctly. nodes() alone doesn't invalidate the cache
        // when the node references are unchanged; forceUpdate always does.
        //
        // The fontTick dep makes this re-run when a freshly-requested font
        // FINISHES loading (Google Fonts / kit fonts arrive async). Without
        // it, the layer mutates instantly when the user picks a new family,
        // but the Konva Text measures with the FALLBACK font until the real
        // one arrives — so the bounds we cache here are the bigger fallback
        // bounds. When fontTick bumps on font-load, this re-fires and the
        // cache catches the now-correct narrower glyph metrics.
        tr.forceUpdate();
        tr.getLayer()?.batchDraw();
    }, [selection, isGroupSelected, activeLayout.layers, cropModeLayerId, penMode, pathEditLayerId, textEditLayerId, activeScreenId, fontTick]);

    useEffect(() => {
        // Crop mode is a modal overlay for one specific layer. Any selection
        // change or screen switch means the user has moved on — drop the mode.
        if (!cropModeLayerId) return;
        if (!selection.includes(cropModeLayerId)) {
            exitCropMode();
        }
    }, [selection, cropModeLayerId, exitCropMode]);

    useEffect(() => {
        if (!cropModeLayerId) return;
        exitCropMode();
    }, [activeScreenId]); // eslint-disable-line react-hooks/exhaustive-deps

    useEffect(() => {
        if (!cropModeLayerId) return;
        const onKey = (e: KeyboardEvent) => {
            if (e.key === 'Escape' || e.key === 'Enter') {
                e.preventDefault();
                exitCropMode();
            }
        };
        window.addEventListener('keydown', onKey);
        return () => window.removeEventListener('keydown', onKey);
    }, [cropModeLayerId, exitCropMode]);

    useEffect(() => {
        if (calloutMode === 'idle') return;
        const onKey = (e: KeyboardEvent) => {
            if (e.key !== 'Escape') return;
            e.preventDefault();
            e.stopPropagation();
            // Single-drag model: Esc always cancels + exits callout mode.
            // In-progress drags discard without committing.
            calloutDragRef.current = null;
            bumpCalloutRedraw();
            exitCalloutMode();
        };
        const onUp = () => {
            calloutDragRef.current = null;
            bumpCalloutRedraw();
        };
        window.addEventListener('keydown', onKey, true);
        window.addEventListener('mouseup', onUp);
        return () => {
            window.removeEventListener('keydown', onKey, true);
            window.removeEventListener('mouseup', onUp);
        };
    }, [calloutMode, exitCalloutMode]);

    useEffect(() => {
        if (!penMode) return;
        const onKey = (e: KeyboardEvent) => {
            if (e.key === 'Escape') {
                e.preventDefault();
                e.stopPropagation();
                const draft = useEditor.getState().penDraft ?? [];
                if (draft.length >= 2) commitPen();
                else exitPenMode();
                return;
            }
            if (e.key === 'Enter') {
                e.preventDefault();
                e.stopPropagation();
                const draft = useEditor.getState().penDraft ?? [];
                if (draft.length >= 2) commitPen();
                else exitPenMode();
            }
        };
        const onUp = () => {
            penDragRef.current = null;
        };
        window.addEventListener('keydown', onKey, true);
        window.addEventListener('mouseup', onUp);
        return () => {
            window.removeEventListener('keydown', onKey, true);
            window.removeEventListener('mouseup', onUp);
        };
    }, [penMode, commitPen, exitPenMode]);

    useEffect(() => {
        if (!pathEditLayerId) return;
        const onKey = (e: KeyboardEvent) => {
            const target = e.target as HTMLElement | null;
            const tag = target?.tagName;
            if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return;
            if (e.key === 'Escape') {
                e.preventDefault();
                exitPathEdit();
            }
        };
        window.addEventListener('keydown', onKey);
        return () => window.removeEventListener('keydown', onKey);
    }, [pathEditLayerId, exitPathEdit]);

    useEffect(() => {
        if (!pathEditLayerId) return;
        exitPathEdit();
    }, [activeScreenId]); // eslint-disable-line react-hooks/exhaustive-deps

    useEffect(() => {
        if (!pathEditLayerId) return;
        if (!activeLayout.layers.some((l) => l.id === pathEditLayerId)) {
            exitPathEdit();
        }
    }, [activeLayout.layers, pathEditLayerId, exitPathEdit]);

    useEffect(() => {
        if (penMode) return;
        setPenCursor(null);
        penDragRef.current = null;
    }, [penMode]);

    const zoomAtCenter = useCallback((nextScale: number) => {
        const stage = stageRef.current;
        if (!stage) return;
        const clamped = Math.max(MIN_SCALE, Math.min(MAX_SCALE, nextScale));
        const oldScale = stage.scaleX();
        if (clamped === oldScale) return;
        const center = { x: stage.width() / 2, y: stage.height() / 2 };
        const worldPoint = {
            x: (center.x - stage.x()) / oldScale,
            y: (center.y - stage.y()) / oldScale,
        };
        stage.scale({ x: clamped, y: clamped });
        stage.position({
            x: center.x - worldPoint.x * clamped,
            y: center.y - worldPoint.y * clamped,
        });
        stage.batchDraw();
        setScale(clamped);
    }, []);

    // Default padding tuned for the Focus button — 40px gives a clearly
    // closer fit than the initial workspace-fit (120px) without crashing
    // the active screen into the panels. The fullscreen toggle passes a
    // tighter 20px since the top bar's gone by then.
    const centerOnActiveScreen = useCallback((paddingPx = 40) => {
        const stage = stageRef.current;
        if (!stage || !size.w || !size.h) return;
        const screen = screens.find((s) => s.id === activeScreenId);
        if (!screen) return;
        const { width: screenW, height: screenH } = screen.layout.canvas;
        if (screenW <= 0 || screenH <= 0) return;
        const fit = Math.min(
            (size.w - paddingPx * 2) / screenW,
            (size.h - paddingPx * 2) / screenH,
        );
        const nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fit));
        stage.scale({ x: nextScale, y: nextScale });
        stage.position({
            x: (size.w - screenW * nextScale) / 2 - screen.viewport_x * nextScale,
            y: (size.h - screenH * nextScale) / 2 - screen.viewport_y * nextScale,
        });
        stage.batchDraw();
        setScale(nextScale);
    }, [size.w, size.h, screens, activeScreenId]);

    // Recompute which screens intersect the visible viewport (reads the live,
    // imperatively-mutated stage transform). No-ops below the threshold (small
    // projects render every screen, exactly as before). Only re-renders when
    // the set MEMBERSHIP changes, so a pan within the same visible band is free.
    const recomputeVisibleScreens = useCallback(() => {
        const stage = stageRef.current;
        if (!stage) return;
        if (screens.length <= CULL_THRESHOLD || !size.w || !size.h) {
            setVisibleScreenIds((prev) => (prev === null ? prev : null));
            return;
        }
        const s = stage.scaleX() || 1;
        const vx = -stage.x() / s;
        const vy = -stage.y() / s;
        const vw = size.w / s;
        const vh = size.h / s;
        const left = vx - vw * CULL_MARGIN;
        const right = vx + vw * (1 + CULL_MARGIN);
        const top = vy - vh * CULL_MARGIN;
        const bottom = vy + vh * (1 + CULL_MARGIN);
        const next = new Set<number>();
        for (const sc of screens) {
            const cw = sc.layout.canvas.width;
            const ch = sc.layout.canvas.height;
            if (
                sc.viewport_x + cw >= left &&
                sc.viewport_x <= right &&
                sc.viewport_y + ch >= top &&
                sc.viewport_y <= bottom
            ) {
                next.add(sc.id);
            }
        }
        next.add(activeScreenId); // the editable screen is always mounted
        setVisibleScreenIds((prev) => {
            if (prev && prev.size === next.size) {
                let same = true;
                for (const id of next) {
                    if (!prev.has(id)) {
                        same = false;
                        break;
                    }
                }
                if (same) return prev; // membership unchanged — skip the re-render
            }
            return next;
        });
    }, [screens, size.w, size.h, activeScreenId]);

    // Pan/zoom mutate the stage transform synchronously; recompute on the next
    // frame (coalesced) so it reads the final transform. Wired to native drag
    // pan, wheel, and keyboard pan; zoom is covered via the `scale` effect below.
    const scheduleCullRecompute = useCallback(() => {
        if (cullRafRef.current !== null) return;
        cullRafRef.current = requestAnimationFrame(() => {
            cullRafRef.current = null;
            recomputeVisibleScreens();
        });
    }, [recomputeVisibleScreens]);

    // Recompute on mount + whenever the inputs change (resize, screen set,
    // active screen, zoom level). Cancels any pending frame on unmount.
    useEffect(() => {
        recomputeVisibleScreens();
        return () => {
            if (cullRafRef.current !== null) {
                cancelAnimationFrame(cullRafRef.current);
                cullRafRef.current = null;
            }
        };
    }, [recomputeVisibleScreens, scale]);

    // ── Inactive-screen bitmap caching ──────────────────────────────────
    // Pan/zoom redraws the whole Konva layer every frame. On a big deck that
    // means re-rasterizing ~18 live nodes (shadows + images + text) PER
    // on-screen screen each frame — the canvas felt sluggish on scroll even
    // when memory was fine. Fix: flatten every INACTIVE screen into a single
    // cached bitmap (`group.cache()`), so pan/zoom blits one image per screen
    // instead of walking its subtree. The active screen stays uncached (live +
    // crisp for editing); switching screens re-caches the one you left and
    // clears the one you entered.
    //
    // Correctness rules:
    //  1. Never cache a screen whose images are still decoding — that would
    //     bake a blank/partial frame into the bitmap. We check every
    //     Konva.Image's element `.complete` and leave the screen LIVE until
    //     ready (a retry re-attempts once images finish).
    //  2. Caching is debounced so it runs when scrolling SETTLES, not every
    //     frame — re-caching mid-gesture would reintroduce the jank.
    const cacheTimersRef = useRef<number[]>([]);
    const cacheRafsRef = useRef<number[]>([]);
    // While a pan/zoom gesture is in flight the ACTIVE screen is also cached
    // (so every frame is a pure blit); the gesture-end handler restores it
    // live + crisp. `activeGestureCachedRef` tells `applyScreenCaches` not to
    // clear that cache mid-gesture.
    const gestureEndTimerRef = useRef<number | null>(null);
    const activeGestureCachedRef = useRef(false);
    const clearCacheTimers = useCallback(() => {
        cacheTimersRef.current.forEach((t) => window.clearTimeout(t));
        cacheTimersRef.current = [];
        cacheRafsRef.current.forEach((r) => cancelAnimationFrame(r));
        cacheRafsRef.current = [];
    }, []);
    const applyScreenCaches = useCallback(() => {
        const stage = stageRef.current;
        if (!stage) return;
        // Only worth flattening once the deck is large enough to be a draw
        // cost; small decks render every screen live (identical to before).
        const enabled = screens.length > CULL_THRESHOLD;
        let deferred = false;
        // Collect the screens that need (re)flattening; the actual
        // `group.cache()` calls run STAGGERED below (one per animation frame).
        // A tablet deck rasterizing 8 screens in one synchronous loop was a
        // single long main-thread task — the 2-3s "setup" freeze on factor
        // load. Spreading it keeps each frame short so the canvas stays
        // responsive while screens flatten in over ~8 frames.
        const pending: Array<{ group: Konva.Group; pr: number }> = [];
        for (const screen of screens) {
            const group = stage.findOne(
                `.screen-group-${screen.id}`,
            ) as Konva.Group | null;
            if (!group) continue;
            const isActive = screen.id === activeScreenId;
            const culled =
                !!visibleScreenIds && !visibleScreenIds.has(screen.id);
            // Small deck → everything live.
            if (!enabled) {
                if (group.isCached()) group.clearCache();
                continue;
            }
            // Active screen → live, UNLESS a pan/zoom gesture owns its cache
            // right now (leave it; the gesture-end handler restores it live).
            if (isActive) {
                if (!activeGestureCachedRef.current && group.isCached()) {
                    group.clearCache();
                }
                continue;
            }
            // Culled screens aren't drawn — leave their cache state untouched
            // so scrolling one back into view shows the bitmap instantly
            // instead of forcing a fresh rasterize.
            if (culled) continue;
            const imgs = group.find('Image') as unknown as Konva.Image[];
            const ready = imgs.every((n) => {
                const el = n.image() as
                    | HTMLImageElement
                    | HTMLCanvasElement
                    | undefined;
                // Canvas sources (pattern fills) are always ready; <img>
                // sources expose `.complete` (false while still decoding).
                return !el || (el as HTMLImageElement).complete !== false;
            });
            if (!ready) {
                if (group.isCached()) group.clearCache();
                deferred = true;
                continue;
            }
            // Pixel ratio is scaled down for large (tablet) canvases so the
            // cached bitmap width stays ~INACTIVE_CACHE_TARGET_WIDTH across
            // factors (bounds rasterize + blit cost).
            const cw = screen.layout.canvas.width || 1320;
            const pr = Math.max(
                INACTIVE_CACHE_MIN_PIXEL_RATIO,
                Math.min(
                    INACTIVE_CACHE_MAX_PIXEL_RATIO,
                    INACTIVE_CACHE_TARGET_WIDTH / cw,
                ),
            );
            pending.push({ group, pr });
        }
        // Flatten one screen per frame. `offset` pads the box so the card
        // drop-shadow (blur 32) and the screen-name label above the card
        // aren't clipped. Re-cache from scratch so edits since the last cache
        // show.
        let i = 0;
        const step = () => {
            if (i >= pending.length) {
                stage.batchDraw();
                if (deferred) {
                    const t = window.setTimeout(applyScreenCaches, 500);
                    cacheTimersRef.current.push(t);
                }
                return;
            }
            const { group, pr } = pending[i++];
            if (group.isCached()) group.clearCache();
            group.cache({ pixelRatio: pr, offset: 48 });
            stage.batchDraw();
            const raf = requestAnimationFrame(step);
            cacheRafsRef.current.push(raf);
        };
        step();
    }, [screens, activeScreenId, visibleScreenIds]);
    const scheduleScreenCaches = useCallback(() => {
        clearCacheTimers();
        const t = window.setTimeout(applyScreenCaches, 140);
        cacheTimersRef.current.push(t);
    }, [applyScreenCaches, clearCacheTimers]);
    // Called at the top of every pan/zoom interaction. On the FIRST event of a
    // gesture it flattens the ACTIVE screen into a bitmap too (bounded ratio),
    // so subsequent pan frames blit ALL screens instead of re-rasterizing the
    // active screen's live nodes/images each frame — this is what makes scroll
    // smooth on large (tablet) canvases even at full pixelRatio. A trailing
    // debounce restores the active screen live + crisp ~160ms after the last
    // event. (Editing a LAYER doesn't route through here, so it never caches
    // the screen you're actually editing.)
    const beginCanvasGesture = useCallback(() => {
        const stage = stageRef.current;
        if (stage && screens.length > CULL_THRESHOLD && !activeGestureCachedRef.current) {
            const group = stage.findOne(
                `.screen-group-${activeScreenId}`,
            ) as Konva.Group | null;
            if (group && !group.isCached()) {
                const active = screens.find((s) => s.id === activeScreenId);
                const cw = active?.layout.canvas.width || 1320;
                const pr = Math.max(
                    INACTIVE_CACHE_MIN_PIXEL_RATIO,
                    Math.min(
                        INACTIVE_CACHE_MAX_PIXEL_RATIO,
                        INACTIVE_CACHE_TARGET_WIDTH / cw,
                    ),
                );
                group.cache({ pixelRatio: pr, offset: 48 });
                activeGestureCachedRef.current = true;
                stage.batchDraw();
            }
        }
        if (gestureEndTimerRef.current !== null) {
            window.clearTimeout(gestureEndTimerRef.current);
        }
        gestureEndTimerRef.current = window.setTimeout(() => {
            gestureEndTimerRef.current = null;
            if (!activeGestureCachedRef.current) return;
            activeGestureCachedRef.current = false;
            const st = stageRef.current;
            const g = st?.findOne(
                `.screen-group-${activeScreenId}`,
            ) as Konva.Group | null;
            if (g?.isCached()) {
                g.clearCache();
                g.getLayer()?.batchDraw();
            }
        }, 160);
    }, [screens, activeScreenId]);
    // Re-schedule whenever the screen set / active screen / cull membership /
    // fonts / locale change. During a scroll, cull membership changes rapidly,
    // so the debounce keeps resetting and the (re)cache lands ~140ms after the
    // gesture stops. Newly-revealed screens render live for that brief window.
    useEffect(() => {
        scheduleScreenCaches();
        return clearCacheTimers;
    }, [scheduleScreenCaches, fontTick, activeLocale, clearCacheTimers]);
    // Clear the just-activated screen's cache SYNCHRONOUSLY so it's live and
    // crisp immediately on switch, without waiting for the debounced pass.
    // Also reset any in-flight gesture cache state so the new active screen
    // starts clean.
    useEffect(() => {
        if (gestureEndTimerRef.current !== null) {
            window.clearTimeout(gestureEndTimerRef.current);
            gestureEndTimerRef.current = null;
        }
        activeGestureCachedRef.current = false;
        const stage = stageRef.current;
        const group = stage?.findOne(
            `.screen-group-${activeScreenId}`,
        ) as Konva.Group | null;
        if (group?.isCached()) {
            group.clearCache();
            group.getLayer()?.batchDraw();
        }
    }, [activeScreenId]);

    useEffect(() => {
        const onKey = (e: KeyboardEvent) => {
            const target = e.target as HTMLElement | null;
            const tag = target?.tagName;
            if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) {
                return;
            }

            // Group / ungroup: Cmd+G / Cmd+Shift+G (Ctrl variant on Win/Linux).
            // Handled before the mod-key bail below because these are the only
            // mod-key shortcuts CanvasStage owns.
            const mod = e.metaKey || e.ctrlKey;
            if (mod && !e.altKey && e.key.toLowerCase() === 'g') {
                e.preventDefault();
                if (e.shiftKey) ungroupSelected();
                else groupSelected();
                return;
            }

            if (e.metaKey || e.ctrlKey || e.altKey) return;

            // Keyboard zoom shortcuts respect the lock — silently swallowed
            // when the lock is on so the user's deliberate zoom level stays
            // put. Reset (`0`) still works because it's the documented escape
            // hatch.
            if (e.key === '+' || e.key === '=') {
                if (zoomLockedRef.current) return;
                e.preventDefault();
                const stage = stageRef.current;
                if (stage) zoomAtCenter(stage.scaleX() * ZOOM_STEP);
                return;
            }
            if (e.key === '-' || e.key === '_') {
                if (zoomLockedRef.current) return;
                e.preventDefault();
                const stage = stageRef.current;
                if (stage) zoomAtCenter(stage.scaleX() / ZOOM_STEP);
                return;
            }
            if (e.key === '0') {
                e.preventDefault();
                zoomAtCenter(1);
                return;
            }

            if (
                e.key !== 'ArrowLeft' &&
                e.key !== 'ArrowRight' &&
                e.key !== 'ArrowUp' &&
                e.key !== 'ArrowDown'
            ) {
                return;
            }
            // Layer-nudge handler in Editor.tsx owns arrows when selection exists.
            if (useEditor.getState().selection.length > 0) return;

            const stage = stageRef.current;
            if (!stage) return;
            e.preventDefault();
            const step = e.shiftKey ? 200 : 50;
            const dx = e.key === 'ArrowLeft' ? step : e.key === 'ArrowRight' ? -step : 0;
            // Vertical arrow pan disabled when zoom is locked — request is
            // "horizontal scroll only" so arrow-up/down also bows out.
            const dy = zoomLockedRef.current
                ? 0
                : e.key === 'ArrowUp' ? step : e.key === 'ArrowDown' ? -step : 0;
            stage.position({ x: stage.x() + dx, y: stage.y() + dy });
            stage.batchDraw();
            scheduleCullRecompute();
            beginCanvasGesture();
        };
        window.addEventListener('keydown', onKey);
        return () => window.removeEventListener('keydown', onKey);
    }, [zoomAtCenter, groupSelected, ungroupSelected, scheduleCullRecompute, beginCanvasGesture]);

    const handleWheel = (e: Konva.KonvaEventObject<WheelEvent>) => {
        e.evt.preventDefault();
        const stage = stageRef.current;
        if (!stage) return;
        scheduleCullRecompute(); // reads the post-mutation transform next frame
        beginCanvasGesture(); // flatten active screen for smooth pan/zoom blits

        // Horizontal pan paths (run before the zoom path so a mouse user
        // doesn't need to flip the zoom-lock toggle just to scroll sideways):
        //   - Shift + wheel: industry-standard "scroll horizontally" — the
        //     vertical wheel delta becomes the horizontal pan amount.
        //   - Native horizontal delta (trackpad 2-finger sideways swipe,
        //     tilt-wheel mouse): pan by deltaX directly. Dominance check
        //     keeps a near-vertical trackpad gesture from accidentally
        //     panning sideways.
        const dx = e.evt.deltaX;
        const dy = e.evt.deltaY;
        if (e.evt.shiftKey) {
            stage.x(stage.x() - (dy || dx));
            stage.batchDraw();
            return;
        }
        if (Math.abs(dx) > Math.abs(dy)) {
            stage.x(stage.x() - dx);
            stage.batchDraw();
            return;
        }

        // Zoom-locked mode: wheel pans horizontally instead of zooming.
        // Combines both wheel axes so trackpads (which often send deltaX)
        // and mouse wheels (which only send deltaY) both produce a sensible
        // horizontal pan. Vertical pan disabled per request — workspace
        // already lays screens out left-to-right.
        if (zoomLockedRef.current) {
            const lockedDx = e.evt.deltaX || e.evt.deltaY;
            stage.x(stage.x() - lockedDx);
            stage.batchDraw();
            return;
        }

        const oldScale = stage.scaleX();
        const pointer = stage.getPointerPosition();
        if (!pointer) return;

        const worldPoint = {
            x: (pointer.x - stage.x()) / oldScale,
            y: (pointer.y - stage.y()) / oldScale,
        };

        const direction = e.evt.deltaY > 0 ? -1 : 1;
        const raw = direction > 0 ? oldScale * ZOOM_STEP : oldScale / ZOOM_STEP;
        const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, raw));

        stage.scale({ x: newScale, y: newScale });
        stage.position({
            x: pointer.x - worldPoint.x * newScale,
            y: pointer.y - worldPoint.y * newScale,
        });
        stage.batchDraw();
        // Debounced: the visual zoom is already applied above; committing to
        // React state per wheel tick re-rendered every LayerNode (see
        // commitScale). Coalesce to the gesture's end.
        commitScale(newScale);
    };

    const handleStageMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
        // Read-only (lock held by another editor): swallow selection / marquee
        // / callout starts. Pan still works (it's driven by stage drag, gated
        // separately below).
        if (readOnly) {
            return;
        }
        if (calloutMode === 'armed') {
            const canvasPt = pointerInActiveCanvas();
            if (!canvasPt) {
                e.cancelBubble = true;
                return;
            }
            // Reject clicks outside the active canvas rect — keep the user
            // armed so the next valid click works.
            if (
                canvasPt.x < 0 ||
                canvasPt.y < 0 ||
                canvasPt.x > activeCanvas.width ||
                canvasPt.y > activeCanvas.height
            ) {
                e.evt.preventDefault?.();
                e.cancelBubble = true;
                return;
            }
            // Must hit a frame or image layer. Walk Konva targets upward to
            // find a layer id; reject non-screenshot sources silently (the
            // chrome banner tells the user where to click).
            const layers = useEditor.getState().layout.layers;
            let target: Konva.Node | null = e.target;
            let hitLayer: Layer | null = null;
            while (target) {
                const id = target.attrs?.id as string | undefined;
                if (id) {
                    const layer = layers.find((l) => l.id === id);
                    if (layer) {
                        hitLayer = layer;
                        break;
                    }
                }
                target = target.getParent?.() ?? null;
            }
            if (
                !hitLayer ||
                (hitLayer.type !== 'frame' && hitLayer.type !== 'image')
            ) {
                e.cancelBubble = true;
                return;
            }
            // Empty frame (no bound screenshot) has nothing to call out —
            // reject silently. User needs to upload a screenshot to the
            // frame first via the empty-frame CTA or Properties panel.
            if (
                hitLayer.type === 'frame' &&
                (!hitLayer.screen?.storagePath || hitLayer.screen.storagePath === '')
            ) {
                e.cancelBubble = true;
                return;
            }
            // Same for image layers with no source path (rare; safety net).
            if (
                hitLayer.type === 'image' &&
                (!hitLayer.storagePath || hitLayer.storagePath === '')
            ) {
                e.cancelBubble = true;
                return;
            }
            const sourceRect = resolveAnchors(hitLayer.anchors, activeCanvas);
            calloutDragRef.current = {
                startCanvas: canvasPt,
                currentCanvas: canvasPt,
                sourceLayerId: hitLayer.id,
                sourceRect,
            };
            bumpCalloutRedraw();
            e.cancelBubble = true;
            return;
        }
        if (penMode) {
            const canvasPt = pointerInActiveCanvas();
            if (!canvasPt) return;
            // Outside the active canvas rect — swallow, keep pen mode.
            if (
                canvasPt.x < 0 ||
                canvasPt.y < 0 ||
                canvasPt.x > activeCanvas.width ||
                canvasPt.y > activeCanvas.height
            ) {
                e.evt.preventDefault?.();
                e.cancelBubble = true;
                return;
            }
            // Hit-test the first anchor to close the path when ≥ 2 nodes placed.
            const draft = penDraft ?? [];
            const threshold = 8 / scaleRef.current;
            if (draft.length >= 2) {
                const first = draft[0];
                const dx = canvasPt.x - first.x;
                const dy = canvasPt.y - first.y;
                if (Math.hypot(dx, dy) <= threshold) {
                    setPenClosed(true);
                    commitPen();
                    e.cancelBubble = true;
                    return;
                }
            }
            // Clicking on the last placed anchor is a no-op — this lets
            // dblclick-to-close fire without dropping a duplicate node first.
            if (draft.length >= 1) {
                const last = draft[draft.length - 1];
                const dx = canvasPt.x - last.x;
                const dy = canvasPt.y - last.y;
                if (Math.hypot(dx, dy) <= threshold) {
                    e.cancelBubble = true;
                    return;
                }
            }
            addPenNode({ x: canvasPt.x, y: canvasPt.y });
            penDragRef.current = { start: canvasPt, index: draft.length };
            e.cancelBubble = true;
            return;
        }
        if (pathEditLayerId) {
            // Clicks that reach the stage (not an anchor/handle or the path
            // itself) mean the user moved on — drop edit mode.
            if (e.target === e.target.getStage()) {
                exitPathEdit();
                clearSelection();
            }
            return;
        }
        if (e.target === e.target.getStage()) {
            clearSelection();
        }
    };

    const handleStageMouseMove = () => {
        if (calloutMode !== 'idle' && calloutDragRef.current) {
            const canvasPt = pointerInActiveCanvas();
            if (!canvasPt) return;
            calloutDragRef.current.currentCanvas = canvasPt;
            bumpCalloutRedraw();
            return;
        }
        if (!penMode) return;
        const canvasPt = pointerInActiveCanvas();
        if (!canvasPt) return;
        setPenCursor(canvasPt);
        const drag = penDragRef.current;
        if (!drag) return;
        const dx = canvasPt.x - drag.start.x;
        const dy = canvasPt.y - drag.start.y;
        const DRAG_THRESHOLD = 3 / scaleRef.current;
        if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
        updatePenNode(drag.index, {
            handleOut: { x: dx, y: dy },
            handleIn: { x: -dx, y: -dy },
        });
    };

    const handleStageMouseUp = () => {
        if (calloutMode === 'armed' && calloutDragRef.current) {
            const drag = calloutDragRef.current;
            const startX = Math.min(drag.startCanvas.x, drag.currentCanvas.x);
            const startY = Math.min(drag.startCanvas.y, drag.currentCanvas.y);
            const dragW = Math.abs(drag.currentCanvas.x - drag.startCanvas.x);
            const dragH = Math.abs(drag.currentCanvas.y - drag.startCanvas.y);
            calloutDragRef.current = null;
            bumpCalloutRedraw();

            if (!drag.sourceLayerId || !drag.sourceRect) {
                exitCalloutMode();
                return;
            }
            // Sub-pixel drag — fall back to a default ~15% × 15% rect centered
            // on the click point so the user gets something to adjust.
            const MIN_PX = 8 / scaleRef.current;
            let rectX: number;
            let rectY: number;
            let rectW: number;
            let rectH: number;
            if (dragW < MIN_PX || dragH < MIN_PX) {
                rectW = drag.sourceRect.width * 0.15;
                rectH = drag.sourceRect.height * 0.15;
                rectX = drag.startCanvas.x - rectW / 2;
                rectY = drag.startCanvas.y - rectH / 2;
            } else {
                rectX = startX;
                rectY = startY;
                rectW = dragW;
                rectH = dragH;
            }
            // Circle callouts must drop a SQUARE drawn rect — otherwise the
            // visible circle floats inside a wider/taller bbox and the
            // transform handles appear detached from the visible shape.
            // Snap to the larger drag dim + recenter on the drag midpoint so
            // the user's intent (the area they swept across) stays visually
            // contained in the resulting circle.
            if (calloutShape === 'ellipse') {
                const side = Math.max(rectW, rectH);
                const midX = rectX + rectW / 2;
                const midY = rectY + rectH / 2;
                rectX = midX - side / 2;
                rectY = midY - side / 2;
                rectW = side;
                rectH = side;
            }
            // Clamp drawn rect into the source's resolved rect.
            rectX = Math.max(drag.sourceRect.x, Math.min(drag.sourceRect.x + drag.sourceRect.width, rectX));
            rectY = Math.max(drag.sourceRect.y, Math.min(drag.sourceRect.y + drag.sourceRect.height, rectY));
            rectW = Math.max(1, Math.min(drag.sourceRect.x + drag.sourceRect.width - rectX, rectW));
            rectH = Math.max(1, Math.min(drag.sourceRect.y + drag.sourceRect.height - rectY, rectH));
            // The source-rect clamp above may have rectangle-ified our square
            // (e.g. the square overshot the source rect's bottom edge → height
            // got clipped while width survived). Re-square after the clamp by
            // tightening to the smaller resulting side, anchored on the
            // already-clamped top-left — keeps the result inside the source.
            if (calloutShape === 'ellipse') {
                const side = Math.min(rectW, rectH);
                rectW = side;
                rectH = side;
            }

            // Single-drag model: sourceRect = drawn rect (% of source).
            // Display rect = drawn rect scaled 1.2× and centered on the
            // drawn region — gives the new callout a subtle magnification
            // (just enough that the callout is visually distinct from the
            // source) while keeping it close to "what you drew". Zoom is
            // capped to the canvas bounds + nudged inside on edge drags so
            // the callout never extends off-canvas. User can adjust in
            // Properties (0.5×–4×).
            const srx = ((rectX - drag.sourceRect.x) / drag.sourceRect.width) * 100;
            const sry = ((rectY - drag.sourceRect.y) / drag.sourceRect.height) * 100;
            const srw = (rectW / drag.sourceRect.width) * 100;
            const srh = (rectH / drag.sourceRect.height) * 100;
            const REQUESTED_ZOOM = 1.5;
            const maxZoom = Math.min(
                activeCanvas.width / rectW,
                activeCanvas.height / rectH,
            );
            const zoomFactor = Math.min(REQUESTED_ZOOM, Math.max(1, maxZoom));
            const dispW = rectW * zoomFactor;
            const dispH = rectH * zoomFactor;
            const cx = rectX + rectW / 2;
            const cy = rectY + rectH / 2;
            let dispX = cx - dispW / 2;
            let dispY = cy - dispH / 2;
            dispX = Math.max(0, Math.min(activeCanvas.width - dispW, dispX));
            dispY = Math.max(0, Math.min(activeCanvas.height - dispH, dispY));

            // Default callout chrome: a solid 10px white border + a soft,
            // centered drop shadow (0/0 offset, 30px blur, 30% black). The
            // white border lifts the magnified region off the screenshot
            // regardless of the underlying content; the user can recolor or
            // remove either in Properties.
            const layer = createCalloutLayer(
                {
                    sourceLayerId: drag.sourceLayerId,
                    sourceRect: { x: srx, y: sry, width: srw, height: srh },
                    displayRect: { x: dispX, y: dispY, width: dispW, height: dispH },
                },
                activeCanvas,
                {
                    shape: calloutShape,
                    cornerRadius: 30,
                    style: {
                        stroke: {
                            color: { type: 'solid', value: '#FFFFFF' },
                            width: 10,
                            dash: 'solid',
                        },
                        shadows: [
                            { offsetX: 0, offsetY: 0, blur: 30, color: '#000000', opacity: 0.3 },
                        ],
                    },
                },
            );
            if (calloutEditingLayerId !== null) {
                const editingId = calloutEditingLayerId;
                updateLayer(editingId, (draft) => {
                    if (draft.type !== 'callout') return;
                    draft.sourceLayerId = layer.sourceLayerId;
                    draft.sourceRect = layer.sourceRect;
                    draft.shape = layer.shape;
                    draft.anchors = layer.anchors;
                });
                setSelection([editingId]);
            } else {
                addLayer(layer);
                setSelection([layer.id]);
            }
            exitCalloutMode();
            return;
        }
        if (!penMode) return;
        penDragRef.current = null;
    };

    const handleStageContextMenu = (e: Konva.KonvaEventObject<PointerEvent>) => {
        e.evt.preventDefault();
        if (penMode || cropModeLayerId) return;
        let target: Konva.Node | null = e.target;
        let hitId: string | null = null;
        while (target) {
            const id = target.attrs?.id as string | undefined;
            if (id && useEditor.getState().layout.layers.some((l) => l.id === id)) {
                hitId = id;
                break;
            }
            target = target.getParent?.() ?? null;
        }
        if (hitId) {
            // Right-click selects the exact clicked layer too — matches the
            // left-click semantics (no auto-promotion to group).
            const current = useEditor.getState().selection;
            if (!current.includes(hitId)) setSelection([hitId]);
        } else {
            clearSelection();
        }
        setContextMenu({ x: e.evt.clientX, y: e.evt.clientY });
    };

    const handleStageDblClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
        if (readOnly) return;
        if (cropModeLayerId) return;
        if (penMode) {
            const draft = penDraft ?? [];
            if (draft.length < 2) return;
            const canvasPt = pointerInActiveCanvas();
            if (!canvasPt) return;
            const last = draft[draft.length - 1];
            const threshold = 12 / scaleRef.current;
            if (Math.hypot(canvasPt.x - last.x, canvasPt.y - last.y) <= threshold) {
                setPenClosed(true);
                commitPen();
                e.cancelBubble = true;
            }
            return;
        }
        // Walk the Konva target chain in case the hit is on a nested child
        // (e.g. a text node inside a group). Mirrors handleStageContextMenu.
        let target: Konva.Node | null = e.target;
        let layerId: string | null = null;
        while (target) {
            const id = target.attrs?.id as string | undefined;
            if (id && activeLayout.layers.some((l) => l.id === id)) {
                layerId = id;
                break;
            }
            target = target.getParent?.() ?? null;
        }
        if (!layerId) return;
        const layer = activeLayout.layers.find((l) => l.id === layerId);
        if (!layer) return;
        if (layer.type === 'text') {
            enterTextEdit(layerId);
            return;
        }
        if (layer.type === 'shape' && layer.shape === 'path') {
            enterPathEdit(layerId);
        }
    };

    const switchTo = (screenId: number) => {
        if (screenId === activeScreenId) return;
        // The inactive screen's hit Rect unmounts the moment it becomes
        // active, so its mouseleave never fires. Clear the hover state
        // manually here so a subsequent hover on a different inactive
        // screen lands on a clean slate.
        setHoveredScreenId(null);
        // Preserve `factor` + `locale` query params so the workspace
        // canvas's screen-click doesn't fling the user back to the
        // primary factor (iPhone) when they're editing on iPad / Android
        // / Feature Graphic.
        const params = new URLSearchParams(window.location.search);
        params.set('screen', String(screenId));
        router.visit(`/editor/${projectId}?${params.toString()}`, {
            preserveState: true,
            preserveScroll: true,
            only: ['screen', 'screens', 'activeScreenId'],
        });
    };

    const activeCanvas = activeLayout.canvas;
    const activeScreen = useMemo(
        () => screens.find((s) => s.id === activeScreenId) ?? null,
        [screens, activeScreenId],
    );

    const pointerInActiveCanvas = useCallback((): { x: number; y: number } | null => {
        const stage = stageRef.current;
        if (!stage || !activeScreen) return null;
        const pos = stage.getPointerPosition();
        if (!pos) return null;
        const s = stage.scaleX();
        const worldX = (pos.x - stage.x()) / s;
        const worldY = (pos.y - stage.y()) / s;
        return { x: worldX - activeScreen.viewport_x, y: worldY - activeScreen.viewport_y };
    }, [activeScreen]);


    const isCenteredShape = (layer: Layer): boolean =>
        layer.type === 'shape' &&
        (layer.shape === 'ellipse' || layer.shape === 'polygon' || layer.shape === 'star');

    const rectFromNode = (layer: Layer, node: Konva.Node): LayerRect => {
        const w = node.width() * node.scaleX();
        const h = node.height() * node.scaleY();
        if (isCenteredShape(layer)) {
            return { x: node.x() - w / 2, y: node.y() - h / 2, width: w, height: h };
        }
        return { x: node.x(), y: node.y(), width: w, height: h };
    };

    const onDragMove = (id: string, node: Konva.Node) => {
        const layer = activeLayout.layers.find((l) => l.id === id);
        if (!layer) return;
        // Group members move as a unit via the Transformer; skip per-node snap.
        if (isGroupMember(layer)) return;
        const rect = rectFromNode(layer, node);
        const excluded = new Set(selection.length > 0 ? selection : [id]);
        const targets = computeSnapTargets(activeCanvas, activeLayout.layers, excluded);
        const threshold = SNAP_PX / scaleRef.current;
        const { dx, dy, guides: nextGuides } = computeSnap(rect, targets, threshold);
        if (dx !== 0 || dy !== 0) {
            node.x(node.x() + dx);
            node.y(node.y() + dy);
        }
        setGuides((prev) =>
            prev.x.length === nextGuides.x.length &&
            prev.y.length === nextGuides.y.length &&
            prev.x.every((v, i) => v === nextGuides.x[i]) &&
            prev.y.every((v, i) => v === nextGuides.y[i])
                ? prev
                : nextGuides,
        );
    };

    // Buffer a member's committed transform; flush all of them in one history
    // entry on the next microtask (after the per-member event burst).
    const scheduleGroupCommit = (update: {
        id: string;
        anchors: Anchors;
        rotation?: number;
        scale?: number;
    }) => {
        groupCommitRef.current.set(update.id, update);
        if (groupFlushScheduledRef.current) return;
        groupFlushScheduledRef.current = true;
        queueMicrotask(() => {
            groupFlushScheduledRef.current = false;
            const updates = Array.from(groupCommitRef.current.values());
            groupCommitRef.current.clear();
            commitGroupTransform(updates);
        });
    };

    const commitDrag = (id: string, node: Konva.Node) => {
        setGuides({ x: [], y: [] });
        const layer = activeLayout.layers.find((l) => l.id === id);
        if (!layer) return;
        const rect = rectFromNode(layer, node);
        const nextAnchors = rectToAnchors(rect, activeCanvas, layer.anchors);
        // Group members: Konva's transformer proxy-drag moves every member node
        // together (grab any member, or the overdraw area), each firing its own
        // dragend. Buffer them so the whole move is ONE undo entry.
        if (isGroupMember(layer)) {
            scheduleGroupCommit({ id, anchors: nextAnchors });
            return;
        }
        updateLayer(id, (draft) => {
            draft.anchors = nextAnchors;
        });
    };

    // Live transform: collapse the Transformer's scaleX/Y into the node's
    // actual width/height on every tick so text/shape strokes don't visually
    // stretch during the drag. Calling `transformer.forceUpdate()` after the
    // mutation re-reads the node's bounds, keeping the handles tracking the
    // cursor exactly (without this, the Transformer's cached pre-drag bounds
    // desync from the live node → handles jitter).
    //
    // Callouts opt out — they're a Group whose visible bbox comes from React-
    // sized child Konva nodes, not the outer Group's width attr, so baking
    // scale into the Group's width without rebuilding children would desync
    // the handles from the visible shape.
    const liveTransform = (id: string, node: Konva.Node) => {
        const layer = activeLayout.layers.find((l) => l.id === id);
        // Callouts AND frames AND images are Groups whose visible content
        // comes from React-sized child Konva nodes (bezel PNG, screen
        // cutout, screen image; fitBox-positioned image rect for contain /
        // cover) — not the outer Group's width attr. Baking scale into the
        // Group's width here resets the visible transform but leaves the
        // children at their pre-drag size, so the layer looks "frozen"
        // during drag and snaps to the new size on release. Skipping the
        // bake lets Konva's native scale ride through, scaling the children
        // proportionally with the cursor; commitTransform on release writes
        // the final anchors + React re-renders cleanly.
        if (
            layer?.type === 'callout'
            || layer?.type === 'frame'
            || layer?.type === 'image'
            // Group members: let Konva's native scale ride so the whole element
            // previews proportionally; onTransformEnd bakes per-member.
            || (layer != null && isGroupMember(layer))
        ) return;
        const sx = node.scaleX();
        const sy = node.scaleY();
        if (sx === 1 && sy === 1) return;
        const w = Math.max(4, node.width() * sx);
        const h = Math.max(4, node.height() * sy);
        node.scaleX(1);
        node.scaleY(1);
        node.width(w);
        node.height(h);
        // Konva caches the node's initial bounds on dragstart; calling
        // forceUpdate() after the mutation refreshes that cache so the next
        // tick's scale calculation is anchored to the current size.
        transformerRef.current?.forceUpdate();
    };

    const commitTransform = (id: string, node: Konva.Node) => {
        const layer = activeLayout.layers.find((l) => l.id === id);
        if (!layer) return;
        const scaleX = node.scaleX();
        const scaleY = node.scaleY();
        const newWidth = Math.max(4, node.width() * scaleX);
        const newHeight = Math.max(4, node.height() * scaleY);
        const rect = { x: node.x(), y: node.y(), width: newWidth, height: newHeight };
        if (isCenteredShape(layer)) {
            rect.x = node.x() - newWidth / 2;
            rect.y = node.y() - newHeight / 2;
        }
        node.scaleX(1);
        node.scaleY(1);
        node.width(newWidth);
        node.height(newHeight);
        const nextAnchors = rectToAnchors(rect, activeCanvas, layer.anchors);
        const nextRotation = node.rotation();
        // Whole-group resize (proportional, keepRatio): every member fires its
        // own transformend — buffer them into ONE undo entry. The uniform
        // `scale` also scales type-specific props (text fontSize/spacing, shape
        // stroke/radius/shadow) so the element grows as a unit.
        if (isGroupMember(layer)) {
            scheduleGroupCommit({ id, anchors: nextAnchors, rotation: nextRotation, scale: scaleX });
            return;
        }
        updateLayer(id, (draft) => {
            draft.anchors = nextAnchors;
            draft.rotation = nextRotation;
            // Manual handle drag = explicit size intent; flip auto-grow text
            // off so the new bounds stick instead of being overridden by the
            // natural-bbox measurement on the next render.
            if (draft.type === 'text' && draft.fitMode === 'auto') {
                draft.fitMode = 'fixed';
            }
        });
    };

    const onLayerSelect = (id: string, additive: boolean) => {
        // Canvas click selects the exact clicked layer — no auto-promotion
        // to the group parent. Group selection is via the Layers panel
        // (click the group row directly). This matches Sketch/Photoshop's
        // "what you click is what you select" model; the user reported that
        // the prior Figma-style promotion made non-text layers inside groups
        // feel un-clickable because the group was getting selected instead.
        if (additive) {
            const sel = useEditor.getState().selection;
            const next = new Set(sel);
            if (next.has(id)) next.delete(id);
            else next.add(id);
            setSelection(Array.from(next));
            return;
        }
        setSelection([id]);
    };

    // Empty-frame "Add screenshot" CTA upload handler. Opens a hidden file
    // input, uploads the picked file through the same `uploadMedia` +
    // `setFrameScreen` flow the properties panel uses. Selects the frame so
    // the panel's Frame Screen controls are visible after upload.
    const frameScreenUploadInputRef = useRef<HTMLInputElement | null>(null);
    const frameScreenUploadLayerRef = useRef<string | null>(null);
    const onRequestFrameScreenUpload = (layerId: string) => {
        frameScreenUploadLayerRef.current = layerId;
        frameScreenUploadInputRef.current?.click();
    };
    const onFrameScreenFilePicked = async (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        const layerId = frameScreenUploadLayerRef.current;
        e.target.value = '';
        frameScreenUploadLayerRef.current = null;
        if (!file || !layerId) return;
        try {
            const media = await uploadMedia(projectId, file);
            setFrameScreen(
                layerId,
                createFrameScreen({
                    storagePath: media.storagePath,
                    width: media.width,
                    height: media.height,
                    fileName: file.name,
                }),
            );
            setSelection([layerId]);
        } catch {
            // Per-error surface lives in the properties panel; canvas CTA
            // failing silently is acceptable — user can retry from the panel.
        }
    };

    const zoomPct = useMemo(() => Math.round(scale * 100), [scale]);
    const labelScale = 1 / scale;

    const handleDroppedFiles = async (e: React.DragEvent) => {
        if (readOnly) return;
        const files = e.dataTransfer.files;
        const { accepted, rejected } = validateDroppedImages(files);
        if (accepted.length === 0) {
            if (rejected.length > 0) {
                const msg =
                    rejected.length === 1
                        ? rejected[0].reason
                        : `${rejected.length} files couldn't be added`;
                setDropError(msg);
                setTimeout(() => setDropError(null), 4000);
            }
            return;
        }
        if (!activeScreen) return;
        const container = containerRef.current;
        if (!container) return;
        const dropPoint = clientToCanvasPoint(
            e.clientX,
            e.clientY,
            container,
            stageRef.current,
            { x: activeScreen.viewport_x, y: activeScreen.viewport_y },
        );
        if (!dropPoint) return;

        setDropBusy(true);
        const newIds: string[] = [];
        const failures: string[] = [];
        try {
            for (let i = 0; i < accepted.length; i++) {
                const file = accepted[i];
                try {
                    const media = await uploadMedia(projectId, file);
                    const aspect =
                        media.width && media.height ? media.width / media.height : 1;
                    const anchors = anchorsForDropPoint(
                        dropPoint,
                        aspect,
                        activeCanvas,
                        i,
                    );
                    const layer = createImageLayer(
                        {
                            storagePath: media.storagePath,
                            width: media.width,
                            height: media.height,
                            fileName: file.name,
                        },
                        activeCanvas,
                        { anchors },
                    ) as ImageLayer;
                    addLayer(layer, { select: i === 0 });
                    newIds.push(layer.id);
                } catch (err) {
                    failures.push(file.name);
                    // eslint-disable-next-line no-console
                    console.error('dropped image upload failed', err);
                }
            }
        } finally {
            setDropBusy(false);
        }

        if (newIds.length > 0) {
            setSelection([...newIds]);
        }
        if (failures.length > 0) {
            const msg =
                failures.length === 1
                    ? `Upload failed for ${failures[0]}`
                    : `${failures.length} uploads failed`;
            setDropError(msg);
            setTimeout(() => setDropError(null), 4000);
        }
    };

    const onContainerDragEnter = (e: React.DragEvent) => {
        if (penMode || !activeScreen) return;
        if (dropBusy) return;
        const hasFiles = Array.from(e.dataTransfer.types ?? []).includes('Files');
        if (!hasFiles) return;
        e.preventDefault();
        dropEnterCountRef.current += 1;
        if (dropEnterCountRef.current === 1) {
            setDropActive(true);
        }
    };

    const onContainerDragOver = (e: React.DragEvent) => {
        if (penMode || !activeScreen) return;
        e.preventDefault();
        e.dataTransfer.dropEffect = 'copy';
    };

    const onContainerDragLeave = (e: React.DragEvent) => {
        if (penMode || !activeScreen) return;
        e.preventDefault();
        dropEnterCountRef.current = Math.max(0, dropEnterCountRef.current - 1);
        if (dropEnterCountRef.current === 0) {
            setDropActive(false);
        }
    };

    const onContainerDrop = (e: React.DragEvent) => {
        if (penMode || !activeScreen) return;
        e.preventDefault();
        dropEnterCountRef.current = 0;
        setDropActive(false);
        void handleDroppedFiles(e);
    };

    return (
        <div
            ref={containerRef}
            data-testid="stage-container"
            className="relative min-h-0 flex-1 overflow-hidden bg-muted"
            onDragEnter={onContainerDragEnter}
            onDragOver={onContainerDragOver}
            onDragLeave={onContainerDragLeave}
            onDrop={onContainerDrop}
            style={
                penMode || calloutMode !== 'idle'
                    ? { cursor: 'crosshair' }
                    : undefined
            }

        >
            {size.w > 0 && size.h > 0 && (
                <Stage
                    ref={stageRef}
                    width={size.w}
                    height={size.h}
                    // Pan disabled while a group is selected so a press inside
                    // the element's box never falls through to a canvas pan —
                    // the Transformer's overdraw area owns that drag (group move).
                    draggable={!penMode && calloutMode === 'idle' && !isGroupSelected}
                    onDragMove={() => {
                        scheduleCullRecompute();
                        beginCanvasGesture();
                    }}
                    onWheel={handleWheel}
                    onMouseDown={handleStageMouseDown}
                    onMouseMove={handleStageMouseMove}
                    onMouseUp={handleStageMouseUp}
                    onDblClick={handleStageDblClick}
                    onDblTap={handleStageDblClick as never}
                    onContextMenu={handleStageContextMenu}
                >
                    <KonvaLayer listening={false}>
                        <Shape
                            listening={false}
                            sceneFunc={(ctx, shape) => {
                                const stage = shape.getStage();
                                if (!stage) return;
                                const s = stage.scaleX();
                                if (s <= 0) return;
                                // Adaptive step: the world-space dot spacing grows
                                // as you zoom OUT so the ON-SCREEN density stays
                                // constant. Without this the dot count scaled with
                                // the world viewport — ~15k dots/frame at 0.3× zoom
                                // (exactly when scrolling the deck), and this
                                // sceneFunc reruns EVERY pan/zoom frame. Snapping
                                // the multiplier to power-of-2 octaves keeps the
                                // spacing between ~GRID_STEP and 2×GRID_STEP on
                                // screen and stops dots jittering during a zoom.
                                // Net: dot count is bounded to ~viewport/step at
                                // any zoom (~1.3k), not the 15k+ it hit zoomed out.
                                const mult = Math.max(
                                    1,
                                    Math.pow(2, Math.ceil(Math.log2(1 / s))),
                                );
                                const step = GRID_STEP * mult;
                                const x1 = -stage.x() / s;
                                const y1 = -stage.y() / s;
                                const x2 = x1 + stage.width() / s;
                                const y2 = y1 + stage.height() / s;
                                const sx = Math.floor(x1 / step) * step;
                                const sy = Math.floor(y1 / step) * step;
                                const r = DOT_RADIUS / s;
                                const d = r * 2;
                                // Square dots, not arcs: arc() tessellation across
                                // the dots dominates the frame; a 2px rect is
                                // visually identical at this size and far cheaper.
                                ctx.beginPath();
                                for (let x = sx; x < x2; x += step) {
                                    for (let y = sy; y < y2; y += step) {
                                        ctx.rect(x - r, y - r, d, d);
                                    }
                                }
                                ctx.fillStyle = '#D4D4D8';
                                ctx.fill();
                            }}
                        />
                    </KonvaLayer>
                    <KonvaLayer>
                        {screens.map((screen) => {
                            // Viewport culling: HIDE (don't unmount) screens
                            // outside the visible band (null = cull disabled →
                            // all visible). The active screen is always in the
                            // set, so it never culls.
                            //
                            // Critical: we toggle Konva `visible` rather than
                            // returning null. Unmounting tore down every
                            // LayerNode → useImage re-ran `new Image()` on the
                            // next remount → re-decoded 18 images/screen into
                            // fresh GPU textures. Scrolling crosses screen
                            // boundaries constantly, so that churn re-decoded
                            // the whole deck every few frames — memory climbed
                            // unbounded (Chrome frees destroyed-node textures
                            // lazily) and the main thread froze mid-scroll.
                            // Keeping the tree mounted decodes each image once;
                            // `visible={false}` still skips draw + hit-test, so
                            // off-screen screens cost nothing to render.
                            const culled =
                                !!visibleScreenIds && !visibleScreenIds.has(screen.id);
                            const isActive = screen.id === activeScreenId;
                            const isHovered =
                                !isActive && hoveredScreenId === screen.id;
                            const layout = isActive ? activeLayout : screen.layout;
                            const canvas = layout.canvas;
                            const layers = layout.layers;
                            // All screens render at 1.0 — no zoom differentiation
                            // between active and inactive. The opacity dim
                            // below is what distinguishes them. (Previously
                            // inactive scaled to 0.94; pulled per user
                            // feedback — it made the workspace feel jumpy on
                            // screen switch.)
                            const scale = 1;
                            const groupOpacity = isActive
                                ? 1
                                : isHovered
                                    ? 0.85
                                    : 0.5;
                            return (
                                <Group
                                    // fontTick in key forces remount when fonts arrive — see
                                    // the bumpFontTick declaration. Without this, Konva's Text
                                    // wrap cache stays at the pre-font-load measurement and
                                    // text overflows on first paint until something else
                                    // triggers a layer-tree rebuild (e.g., tab switch).
                                    key={`${screen.id}-${fontTick}`}
                                    x={screen.viewport_x + canvas.width / 2}
                                    y={screen.viewport_y + canvas.height / 2}
                                    offsetX={canvas.width / 2}
                                    offsetY={canvas.height / 2}
                                    scaleX={scale}
                                    scaleY={scale}
                                    opacity={groupOpacity}
                                    visible={!culled}
                                    listening={!culled}
                                    name={`screen-group-${screen.id}`}
                                >
                                    <Text
                                        text={screen.name}
                                        x={0}
                                        width={canvas.width}
                                        y={-22 * labelScale}
                                        fontFamily="Inter"
                                        fontSize={14 * labelScale}
                                        fontStyle={isActive ? 'bold' : 'normal'}
                                        fill={isActive ? accentColor : '#71717A'}
                                        ellipsis={true}
                                        wrap="none"
                                        listening={false}
                                        perfectDrawEnabled={false}
                                    />
                                    <Rect
                                        x={0}
                                        y={0}
                                        width={canvas.width}
                                        height={canvas.height}
                                        fill="#FFFFFF"
                                        shadowColor="#0F172A"
                                        shadowBlur={32}
                                        shadowOpacity={0.12}
                                        shadowOffsetY={8}
                                        listening={false}
                                        // fill + shadow → Konva otherwise does an
                                        // extra offscreen buffer pass; this card
                                        // redraws every pan frame on the ACTIVE
                                        // (uncached) screen, so skip it.
                                        perfectDrawEnabled={false}
                                    />
                                    <Group
                                        listening={
                                            isActive && !cropModeLayerId && !penMode
                                        }
                                        // Clip is per-screen toggleable via the
                                        // chip button → /screens/{id} PATCH. When
                                        // off, layers can render outside the
                                        // canvas (intentional bleed for tilted
                                        // frames or decorative shapes).
                                        {...(screen.clipOverflow
                                            ? {
                                                  clipX: 0,
                                                  clipY: 0,
                                                  clipWidth: canvas.width,
                                                  clipHeight: canvas.height,
                                              }
                                            : {})}
                                    >
                                        {layers.map((layer) => {
                                            if (!isEffectivelyVisible(layer, layers)) return null;
                                            const devicePreset =
                                                layer.type === 'frame'
                                                    ? devicePresets.find(
                                                          (p) =>
                                                              p.id === layer.devicePresetId,
                                                      )
                                                    : undefined;
                                            let clipBinding: ClipShapeBinding | undefined;
                                            if (
                                                layer.type === 'image' &&
                                                layer.clipShapeId
                                            ) {
                                                const host = layers.find(
                                                    (l) =>
                                                        l.id === layer.clipShapeId &&
                                                        l.type === 'shape',
                                                );
                                                if (host && host.type === 'shape') {
                                                    const kind = host.shape;
                                                    const isOpenPath =
                                                        kind === 'path' && host.closed === false;
                                                    if (
                                                        kind !== 'line' &&
                                                        kind !== 'arrow' &&
                                                        !isOpenPath
                                                    ) {
                                                        clipBinding = {
                                                            shape: host,
                                                            rect: resolveAnchors(
                                                                host.anchors,
                                                                canvas,
                                                            ),
                                                            rotation: host.rotation ?? 0,
                                                        };
                                                    }
                                                }
                                            }
                                            return (
                                                <LayerNode
                                                    key={layer.id}
                                                    layer={layer}
                                                    canvas={canvas}
                                                    siblings={layers}
                                                    draggable={isActive && !readOnly}
                                                    effectivelyLocked={
                                                        readOnly ||
                                                        isEffectivelyLocked(layer, layers)
                                                    }
                                                    hiddenForEdit={
                                                        isActive && textEditLayerId === layer.id
                                                    }
                                                    devicePreset={devicePreset}
                                                    clipShapeBinding={clipBinding}
                                                    onSelect={
                                                        isActive && !readOnly ? onLayerSelect : undefined
                                                    }
                                                    onDragMove={
                                                        isActive && !readOnly ? onDragMove : undefined
                                                    }
                                                    onDragEnd={
                                                        isActive && !readOnly ? commitDrag : undefined
                                                    }
                                                    onTransform={
                                                        isActive && !readOnly ? liveTransform : undefined
                                                    }
                                                    onTransformEnd={
                                                        isActive && !readOnly ? commitTransform : undefined
                                                    }
                                                    onRequestFrameScreenUpload={
                                                        isActive && !readOnly ? onRequestFrameScreenUpload : undefined
                                                    }
                                                    onHoverChange={
                                                        isActive && !readOnly ? setHoveredLayerId : undefined
                                                    }
                                                />
                                            );
                                        })}
                                        {/* Soft accent outline over the
                                            hovered layer's bounds so users
                                            can preview a click target before
                                            committing. Listening off so the
                                            outline doesn't intercept events.
                                            Skipped when the layer is already
                                            selected (the transformer's
                                            handles already mark it). */}
                                        {isActive && hoveredLayerId && (() => {
                                            const hovered = layers.find(
                                                (l) => l.id === hoveredLayerId,
                                            );
                                            if (!hovered) return null;
                                            if (selection.includes(hoveredLayerId)) return null;
                                            const hr = resolveAnchors(
                                                hovered.anchors,
                                                canvas,
                                            );
                                            return (
                                                <Rect
                                                    x={hr.x}
                                                    y={hr.y}
                                                    width={hr.width}
                                                    height={hr.height}
                                                    rotation={hovered.rotation ?? 0}
                                                    stroke={accentColor}
                                                    strokeWidth={1.5 / scale}
                                                    dash={[6 / scale, 4 / scale]}
                                                    fill="transparent"
                                                    listening={false}
                                                    perfectDrawEnabled={false}
                                                />
                                            );
                                        })()}
                                    </Group>
                                    {!isActive && (
                                        <Rect
                                            x={0}
                                            y={0}
                                            width={canvas.width}
                                            height={canvas.height}
                                            fill="transparent"
                                            // In callout mode the inactive
                                            // screens become non-interactive
                                            // so a stray mousedown doesn't
                                            // switch screen mid-gesture. The
                                            // user must finish or cancel the
                                            // callout before changing screen.
                                            listening={calloutMode === 'idle'}
                                            data-testid={`screen-hit-${screen.id}`}
                                            onMouseEnter={(e) => {
                                                setHoveredScreenId(screen.id);
                                                const c = e.target.getStage()?.container();
                                                if (c) c.style.cursor = 'pointer';
                                            }}
                                            onMouseLeave={(e) => {
                                                setHoveredScreenId((prev) =>
                                                    prev === screen.id ? null : prev,
                                                );
                                                const c = e.target.getStage()?.container();
                                                if (c) c.style.cursor = '';
                                            }}
                                            onMouseDown={(e) => {
                                                e.cancelBubble = true;
                                                switchTo(screen.id);
                                            }}
                                            onTap={(e) => {
                                                e.cancelBubble = true;
                                                switchTo(screen.id);
                                            }}
                                        />
                                    )}
                                    {isActive && (
                                        <>
                                            {guides.x.map((x, i) => (
                                                <Line
                                                    key={`gx-${i}`}
                                                    points={[x, 0, x, canvas.height]}
                                                    stroke="#0052FF"
                                                    strokeWidth={1 / scale}
                                                    dash={[6 / scale, 4 / scale]}
                                                    listening={false}
                                                />
                                            ))}
                                            {guides.y.map((y, i) => (
                                                <Line
                                                    key={`gy-${i}`}
                                                    points={[0, y, canvas.width, y]}
                                                    stroke="#0052FF"
                                                    strokeWidth={1 / scale}
                                                    dash={[6 / scale, 4 / scale]}
                                                    listening={false}
                                                />
                                            ))}
                                        </>
                                    )}
                                    {isActive &&
                                        cropModeLayerId &&
                                        (() => {
                                            const target = layers.find(
                                                (l) => l.id === cropModeLayerId,
                                            );
                                            if (!target || target.type !== 'image') {
                                                return null;
                                            }
                                            const targetRect = resolveAnchors(
                                                target.anchors,
                                                canvas,
                                            );
                                            return (
                                                <CropOverlay
                                                    layer={target}
                                                    layerRect={targetRect}
                                                    canvasWidth={canvas.width}
                                                    canvasHeight={canvas.height}
                                                    scale={scale}
                                                    onCommit={(crop) => {
                                                        updateLayer(target.id, (draft) => {
                                                            if (draft.type !== 'image') return;
                                                            if (!crop) {
                                                                delete draft.crop;
                                                            } else {
                                                                draft.crop = crop;
                                                                // Flip to `contain` so the entire
                                                                // cropped sub-region stays visible
                                                                // inside the layer bbox (letterbox
                                                                // where aspects differ) instead of
                                                                // `cover`-clipping the user's
                                                                // selection again.
                                                                draft.fit = 'contain';
                                                            }
                                                        });
                                                    }}
                                                />
                                            );
                                        })()}
                                    {isActive && penMode && (
                                        <PenPreview
                                            draft={penDraft ?? []}
                                            cursor={penCursor}
                                            scale={scale}
                                        />
                                    )}
                                    {isActive && calloutMode !== 'idle' && calloutDragRef.current && (
                                        <CalloutMarquee
                                            drag={calloutDragRef.current}
                                            shape={calloutShape}
                                            scale={scale}
                                        />
                                    )}
                                    {isActive &&
                                        pathEditLayerId &&
                                        (() => {
                                            const target = layers.find(
                                                (l) => l.id === pathEditLayerId,
                                            );
                                            if (
                                                !target ||
                                                target.type !== 'shape' ||
                                                target.shape !== 'path'
                                            ) {
                                                return null;
                                            }
                                            const targetRect = resolveAnchors(
                                                target.anchors,
                                                canvas,
                                            );
                                            return (
                                                <PathEditOverlay
                                                    layer={target}
                                                    layerRect={targetRect}
                                                    scale={scale}
                                                    onPreview={(nodes, closed) => {
                                                        previewLayer(target.id, (draft) => {
                                                            if (draft.type !== 'shape') return;
                                                            draft.nodes = nodes;
                                                            draft.closed = closed;
                                                        });
                                                    }}
                                                    onCommit={(nodes, closed) => {
                                                        if (nodes.length < 2) {
                                                            deleteLayer(target.id);
                                                            exitPathEdit();
                                                            return;
                                                        }
                                                        updateLayer(target.id, (draft) => {
                                                            if (draft.type !== 'shape') return;
                                                            draft.nodes = nodes;
                                                            draft.closed = closed;
                                                        });
                                                    }}
                                                />
                                            );
                                        })()}
                                </Group>
                            );
                        })}
                        {!readOnly && (
                        <Transformer
                            ref={transformerRef}
                            // Groups don't rotate (members carry independent
                            // anchors); everything else can.
                            rotateEnabled={!isGroupSelected}
                            flipEnabled={false}
                            // `shouldOverdrawWholeArea` is set imperatively in the
                            // transformer-nodes effect (react-konva doesn't map it
                            // reliably as a prop) so dragging the box area moves the
                            // whole GROUP; Konva's proxy-drag carries every member
                            // and each commits via its own onDragEnd (commitDrag).
                            // Circle callouts need 1:1 to render as a true
                            // circle (renderer + drawn-rect snap both assume
                            // square bbox). Groups resize proportionally so the
                            // element scales as a unit. Falls back to the user's
                            // aspect-lock pref for everything else.
                            keepRatio={ellipseCalloutSelected || aspectLocked || isGroupSelected}
                            // Konva's keepRatio only constrains corner anchors.
                            // Hide the mid-side anchors when locked so the user
                            // can't drag one axis free.
                            enabledAnchors={
                                ellipseCalloutSelected || aspectLocked || isGroupSelected
                                    ? [
                                          'top-left',
                                          'top-right',
                                          'bottom-left',
                                          'bottom-right',
                                      ]
                                    : undefined
                            }
                            boundBoxFunc={(_oldBox, newBox) => {
                                if (newBox.width < 8 || newBox.height < 8) return _oldBox;
                                // Belt-and-braces for circle callouts:
                                // keepRatio handles the corner drag but
                                // rotation / snap can still produce a
                                // floating-point drift across resizes.
                                // Force the bbox square so the renderer
                                // never gets a non-square box.
                                if (ellipseCalloutSelected) {
                                    const side = Math.min(newBox.width, newBox.height);
                                    return {
                                        ...newBox,
                                        width: side,
                                        height: side,
                                    };
                                }
                                return newBox;
                            }}
                        />
                        )}
                    </KonvaLayer>
                </Stage>
            )}
            {calloutMode !== 'idle' && (
                <div
                    data-testid="callout-mode-banner"
                    className="pointer-events-none absolute left-1/2 top-3 z-10 -translate-x-1/2 rounded-md bg-accent px-3 py-1.5 text-[11px] font-medium text-accent-foreground shadow-md"
                >
                    Drag a {calloutShape === 'rounded-rect' ? 'rectangle' : calloutShape === 'ellipse' ? 'circle' : 'pill'} inside a screenshot to add a callout · Esc to cancel
                </div>
            )}
            <div className="absolute bottom-3 right-3 flex items-center gap-1.5">
                {onToggleFullscreen && (
                    <Tooltip
                        label={
                            fullscreen
                                ? 'Exit fullscreen — restore top bar'
                                : 'Fullscreen — hide top bar + zoom in'
                        }
                        side="top"
                    >
                        <button
                            type="button"
                            onClick={() => {
                                const entering = !fullscreen;
                                onToggleFullscreen();
                                if (entering) {
                                    // Defer the fit so the toolbar's
                                    // unmount + ResizeObserver remeasure
                                    // land before we read `size`. ~60ms
                                    // covers React commit + RAF + observer
                                    // microtask on every browser we
                                    // support.
                                    window.setTimeout(
                                        () => centerOnActiveScreen(20),
                                        60,
                                    );
                                }
                            }}
                            data-testid="fullscreen-toggle"
                            aria-pressed={fullscreen}
                            aria-label={
                                fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'
                            }
                            className={cn(
                                'flex h-7 w-7 items-center justify-center rounded-md border shadow-sm backdrop-blur transition-colors',
                                fullscreen
                                    ? 'border-accent/50 bg-accent/10 text-accent hover:bg-accent/15'
                                    : 'border-border bg-card/90 text-muted-foreground hover:text-foreground',
                            )}
                        >
                            {fullscreen ? (
                                <Shrink className="h-3.5 w-3.5" strokeWidth={2} />
                            ) : (
                                <Expand className="h-3.5 w-3.5" strokeWidth={2} />
                            )}
                        </button>
                    </Tooltip>
                )}
                <Tooltip label="Fit active screen" side="top">
                    <button
                        type="button"
                        onClick={() => centerOnActiveScreen()}
                        data-testid="center-canvas"
                        aria-label="Fit active screen to viewport"
                        className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card/90 text-muted-foreground shadow-sm backdrop-blur transition-colors hover:text-foreground"
                    >
                        <Focus className="h-3.5 w-3.5" strokeWidth={2} />
                    </button>
                </Tooltip>
                <Tooltip label="Canvas zoom · type or use ±/0" side="top">
                    <div>
                        <ZoomInput
                            zoomPct={zoomPct}
                            minPct={Math.round(MIN_SCALE * 100)}
                            maxPct={Math.round(MAX_SCALE * 100)}
                            onCommit={(pct) => zoomAtCenter(pct / 100)}
                        />
                    </div>
                </Tooltip>
                <Tooltip
                    label={
                        zoomLocked
                            ? 'Zoom locked — wheel pans horizontally'
                            : 'Lock zoom — wheel will pan instead of zooming'
                    }
                    side="top-end"
                >
                    <button
                        type="button"
                        onClick={toggleZoomLocked}
                        data-testid="zoom-lock-toggle"
                        aria-pressed={zoomLocked}
                        aria-label={zoomLocked ? 'Unlock canvas zoom' : 'Lock canvas zoom'}
                        className={cn(
                            'flex h-7 w-7 items-center justify-center rounded-md border shadow-sm backdrop-blur transition-colors',
                            zoomLocked
                                ? 'border-accent/50 bg-accent/10 text-accent hover:bg-accent/15'
                                : 'border-border bg-card/90 text-muted-foreground hover:text-foreground',
                        )}
                    >
                        {zoomLocked ? (
                            <Lock className="h-3.5 w-3.5" strokeWidth={2} />
                        ) : (
                            <Unlock className="h-3.5 w-3.5" strokeWidth={2} />
                        )}
                    </button>
                </Tooltip>
            </div>
            {cropModeLayerId && (
                <div
                    data-testid="crop-mode-banner"
                    className="pointer-events-none absolute left-1/2 top-3 -translate-x-1/2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-[11px] font-medium text-accent shadow-sm backdrop-blur"
                >
                    Cropping — drag handles to adjust · Enter or Esc to finish
                </div>
            )}
            {contextMenu && (
                <ContextMenu
                    x={contextMenu.x}
                    y={contextMenu.y}
                    onClose={() => setContextMenu(null)}
                />
            )}
            {textEditLayerId && activeScreen && (
                <InlineTextEditor
                    stageRef={stageRef}
                    scale={scale}
                    viewportOffset={{
                        x: activeScreen.viewport_x,
                        y: activeScreen.viewport_y,
                    }}
                    layout={activeLayout}
                />
            )}
            {dropActive && (
                <div
                    data-testid="canvas-drop-overlay"
                    className="pointer-events-none absolute inset-2 rounded-lg border-2 border-dashed border-[#0052FF]/70 bg-[#0052FF]/5 flex items-center justify-center"
                >
                    <span className="text-xs font-medium text-[#0052FF]">Drop images to add</span>
                </div>
            )}
            {dropError && (
                <div
                    data-testid="canvas-drop-error"
                    className="pointer-events-none absolute left-1/2 top-4 -translate-x-1/2 rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground shadow"
                >
                    {dropError}
                </div>
            )}
            {/* Hidden file input — picks the screen image when the user
                clicks the empty-frame "Add screenshot" CTA inside a frame
                layer's bezel. Mirrors the PropertiesPanel Frame Screen
                upload control so both entry points share the same flow. */}
            <input
                ref={frameScreenUploadInputRef}
                type="file"
                accept="image/*"
                className="hidden"
                onChange={onFrameScreenFilePicked}
                data-testid="canvas-frame-screen-upload-input"
            />
        </div>
    );
}

type CropOverlayProps = {
    layer: Extract<Layer, { type: 'image' }>;
    layerRect: LayerRect;
    canvasWidth: number;
    canvasHeight: number;
    scale: number;
    onCommit: (crop: ImageCrop | undefined) => void;
};

function CropOverlay({
    layer,
    layerRect,
    canvasWidth,
    canvasHeight,
    scale,
    onCommit,
}: CropOverlayProps) {
    const rectRef = useRef<Konva.Rect>(null);
    const trRef = useRef<Konva.Transformer>(null);
    // Live mask refs — updated directly during drag/transform so the dark
    // overlay tracks the rect smoothly without going through React. The
    // initial layout is computed from layer.crop on first render.
    const maskTopRef = useRef<Konva.Rect>(null);
    const maskBottomRef = useRef<Konva.Rect>(null);
    const maskLeftRef = useRef<Konva.Rect>(null);
    const maskRightRef = useRef<Konva.Rect>(null);

    const cropTop = Math.max(0, Math.min(0.99, layer.crop?.top ?? 0));
    const cropRight = Math.max(0, Math.min(0.99, layer.crop?.right ?? 0));
    const cropBottom = Math.max(0, Math.min(0.99, layer.crop?.bottom ?? 0));
    const cropLeft = Math.max(0, Math.min(0.99, layer.crop?.left ?? 0));

    const initCropX = layerRect.x + cropLeft * layerRect.width;
    const initCropY = layerRect.y + cropTop * layerRect.height;
    const initCropW = Math.max(1, layerRect.width * (1 - cropLeft - cropRight));
    const initCropH = Math.max(1, layerRect.height * (1 - cropTop - cropBottom));

    useEffect(() => {
        const rect = rectRef.current;
        const tr = trRef.current;
        if (!rect || !tr) return;
        tr.nodes([rect]);
        tr.getLayer()?.batchDraw();
    }, []);

    // Read the rect's CURRENT geometry (including any active scale from the
    // in-progress transform) and rewrite the four mask quads to wrap around
    // it. Called per tick during drag/transform so the dark mask tracks the
    // inside rect with zero lag. Scale is NOT baked here — that happens once
    // in commit() on release, otherwise the per-tick mutation conflicts with
    // Konva's ongoing drag gesture and the handles get stuck.
    const updateMaskFromRect = () => {
        const node = rectRef.current;
        if (!node) return;
        const x = node.x();
        const y = node.y();
        const w = node.width() * node.scaleX();
        const h = node.height() * node.scaleY();

        if (maskTopRef.current) {
            maskTopRef.current.setAttrs({ x: 0, y: 0, width: canvasWidth, height: y });
        }
        if (maskBottomRef.current) {
            maskBottomRef.current.setAttrs({
                x: 0,
                y: y + h,
                width: canvasWidth,
                height: Math.max(0, canvasHeight - y - h),
            });
        }
        if (maskLeftRef.current) {
            maskLeftRef.current.setAttrs({ x: 0, y, width: x, height: h });
        }
        if (maskRightRef.current) {
            maskRightRef.current.setAttrs({
                x: x + w,
                y,
                width: Math.max(0, canvasWidth - x - w),
                height: h,
            });
        }
        node.getLayer()?.batchDraw();
    };

    const commit = () => {
        const node = rectRef.current;
        if (!node) return;
        // Collapse Transformer's scaleX/scaleY into the rect's actual w/h so
        // subsequent transforms start clean. Without this, the anchors and
        // hit-area drift relative to the visible rect on the next gesture.
        const w = node.width() * node.scaleX();
        const h = node.height() * node.scaleY();
        node.scaleX(1);
        node.scaleY(1);
        node.width(w);
        node.height(h);
        const x = node.x();
        const y = node.y();

        const left = Math.max(0, (x - layerRect.x) / layerRect.width);
        const top = Math.max(0, (y - layerRect.y) / layerRect.height);
        const right = Math.max(
            0,
            (layerRect.x + layerRect.width - x - w) / layerRect.width,
        );
        const bottom = Math.max(
            0,
            (layerRect.y + layerRect.height - y - h) / layerRect.height,
        );

        const next: ImageCrop = {};
        if (top > 0.001) next.top = Math.min(0.99, top);
        if (right > 0.001) next.right = Math.min(0.99, right);
        if (bottom > 0.001) next.bottom = Math.min(0.99, bottom);
        if (left > 0.001) next.left = Math.min(0.99, left);
        onCommit(Object.keys(next).length === 0 ? undefined : next);
    };

    const maskFill = 'rgba(15, 23, 42, 0.55)';

    return (
        <>
            <Rect
                ref={maskTopRef}
                x={0}
                y={0}
                width={canvasWidth}
                height={initCropY}
                fill={maskFill}
                listening={false}
            />
            <Rect
                ref={maskBottomRef}
                x={0}
                y={initCropY + initCropH}
                width={canvasWidth}
                height={Math.max(0, canvasHeight - initCropY - initCropH)}
                fill={maskFill}
                listening={false}
            />
            <Rect
                ref={maskLeftRef}
                x={0}
                y={initCropY}
                width={initCropX}
                height={initCropH}
                fill={maskFill}
                listening={false}
            />
            <Rect
                ref={maskRightRef}
                x={initCropX + initCropW}
                y={initCropY}
                width={Math.max(0, canvasWidth - initCropX - initCropW)}
                height={initCropH}
                fill={maskFill}
                listening={false}
            />
            <Rect
                ref={rectRef}
                x={initCropX}
                y={initCropY}
                width={initCropW}
                height={initCropH}
                name="crop-overlay-rect"
                fill="transparent"
                stroke="#0052FF"
                strokeWidth={2 / scale}
                dash={[8 / scale, 4 / scale]}
                draggable
                dragBoundFunc={(pos) => {
                    // Konva's dragBoundFunc receives absolute stage coords; we
                    // translate to the screen-group's local space via the
                    // node's parent transform, clamp, then translate back.
                    const node = rectRef.current;
                    if (!node) return pos;
                    const parent = node.getParent();
                    const invert = parent?.getAbsoluteTransform().copy().invert();
                    const localPos = invert ? invert.point(pos) : pos;
                    const w = node.width() * node.scaleX();
                    const h = node.height() * node.scaleY();
                    const minX = layerRect.x;
                    const minY = layerRect.y;
                    const maxX = layerRect.x + layerRect.width - w;
                    const maxY = layerRect.y + layerRect.height - h;
                    const lx = Math.max(minX, Math.min(maxX, localPos.x));
                    const ly = Math.max(minY, Math.min(maxY, localPos.y));
                    const abs = parent?.getAbsoluteTransform().point({ x: lx, y: ly });
                    return abs ?? pos;
                }}
                onDragMove={updateMaskFromRect}
                onTransform={updateMaskFromRect}
                onDragEnd={() => {
                    updateMaskFromRect();
                    commit();
                }}
                onTransformEnd={() => {
                    updateMaskFromRect();
                    commit();
                }}
            />
            <Transformer
                ref={trRef}
                rotateEnabled={false}
                keepRatio={false}
                centeredScaling={false}
                // 8 handles, free aspect — Photoshop-style: top/right/bottom/
                // left edge handles + 4 corners; each anchor moves ONE edge
                // (or one corner) independently.
                enabledAnchors={[
                    'top-left',
                    'top-center',
                    'top-right',
                    'middle-left',
                    'middle-right',
                    'bottom-left',
                    'bottom-center',
                    'bottom-right',
                ]}
                anchorSize={12}
                anchorStroke="#0052FF"
                anchorFill="#FFFFFF"
                anchorCornerRadius={2}
                borderStroke="#0052FF"
                borderStrokeWidth={1}
                boundBoxFunc={(oldBox, newBox) => {
                    // Only enforce min size during the gesture. Bounds
                    // clamping happens in commit() on release — clamping
                    // here makes Konva's anchor math see surprise coordinate
                    // shifts which can freeze entire edges.
                    const minSize = 8 / scale;
                    if (newBox.width < minSize || newBox.height < minSize) return oldBox;
                    return newBox;
                }}
            />
        </>
    );
}

type PenPreviewProps = {
    draft: PathNode[];
    cursor: { x: number; y: number } | null;
    scale: number;
};

function PenPreview({ draft, cursor, scale }: PenPreviewProps) {
    const inv = 1 / scale;
    const anchorR = 5 * inv;
    const firstR = 8 * inv;
    const handleR = 3 * inv;
    const strokeW = 1.5 * inv;

    return (
        <Group listening={false}>
            {draft.length > 0 && (
                <Shape
                    listening={false}
                    sceneFunc={(ctx, shape) => {
                        buildPathFromNodes(
                            ctx as unknown as CanvasRenderingContext2D,
                            draft.map((n) => ({
                                x: n.x,
                                y: n.y,
                                handleIn: n.handleIn,
                                handleOut: n.handleOut,
                            })),
                            1,
                            1,
                            false,
                        );
                        ctx.strokeShape(shape);
                    }}
                    stroke="#0F172A"
                    strokeWidth={2 * inv}
                    dash={[6 * inv, 6 * inv]}
                />
            )}
            {cursor && draft.length > 0 && (
                <Line
                    points={[
                        draft[draft.length - 1].x,
                        draft[draft.length - 1].y,
                        cursor.x,
                        cursor.y,
                    ]}
                    stroke="#71717A"
                    strokeWidth={1.5 * inv}
                    dash={[4 * inv, 4 * inv]}
                    listening={false}
                />
            )}
            {draft.map((node, i) => {
                const isFirst = i === 0;
                const r = isFirst && draft.length >= 2 ? firstR : anchorR;
                return (
                    <Group key={`pen-anchor-${i}`} listening={false}>
                        {node.handleIn && (
                            <>
                                <Line
                                    points={[
                                        node.x,
                                        node.y,
                                        node.x + node.handleIn.x,
                                        node.y + node.handleIn.y,
                                    ]}
                                    stroke="#94A3B8"
                                    strokeWidth={strokeW}
                                    listening={false}
                                />
                                <Rect
                                    x={node.x + node.handleIn.x - handleR}
                                    y={node.y + node.handleIn.y - handleR}
                                    width={handleR * 2}
                                    height={handleR * 2}
                                    fill="#FFFFFF"
                                    stroke="#0F172A"
                                    strokeWidth={strokeW}
                                    listening={false}
                                />
                            </>
                        )}
                        {node.handleOut && (
                            <>
                                <Line
                                    points={[
                                        node.x,
                                        node.y,
                                        node.x + node.handleOut.x,
                                        node.y + node.handleOut.y,
                                    ]}
                                    stroke="#94A3B8"
                                    strokeWidth={strokeW}
                                    listening={false}
                                />
                                <Rect
                                    x={node.x + node.handleOut.x - handleR}
                                    y={node.y + node.handleOut.y - handleR}
                                    width={handleR * 2}
                                    height={handleR * 2}
                                    fill="#FFFFFF"
                                    stroke="#0F172A"
                                    strokeWidth={strokeW}
                                    listening={false}
                                />
                            </>
                        )}
                        <Shape
                            listening={false}
                            sceneFunc={(ctx, shape) => {
                                ctx.beginPath();
                                ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
                                ctx.fillStrokeShape(shape);
                            }}
                            fill="#FFFFFF"
                            stroke={isFirst && draft.length >= 2 ? '#0052FF' : '#0F172A'}
                            strokeWidth={strokeW}
                        />
                    </Group>
                );
            })}
        </Group>
    );
}

type PathEditOverlayProps = {
    layer: ShapeLayer;
    layerRect: LayerRect;
    scale: number;
    onPreview: (nodes: PathNode[], closed: boolean) => void;
    onCommit: (nodes: PathNode[], closed: boolean) => void;
};

/**
 * Dashed marquee for the in-progress callout drag — phase 1 (source rect)
 * and phase 2 (display rect) both render with the same affordance. Drawn
 * in the active screen's local coords (the parent Group already positions
 * us at the screen origin via viewport_x/y).
 */
function CalloutMarquee({
    drag,
    shape,
    scale,
}: {
    drag: {
        startCanvas: { x: number; y: number };
        currentCanvas: { x: number; y: number };
        sourceLayerId: string | null;
    };
    shape: 'rounded-rect' | 'ellipse' | 'pill';
    scale: number;
}) {
    const inv = 1 / scale;
    const x = Math.min(drag.startCanvas.x, drag.currentCanvas.x);
    const y = Math.min(drag.startCanvas.y, drag.currentCanvas.y);
    const w = Math.abs(drag.currentCanvas.x - drag.startCanvas.x);
    const h = Math.abs(drag.currentCanvas.y - drag.startCanvas.y);
    if (w < 0.5 && h < 0.5) return null;
    const stroke = '#0052FF';
    const fill = 'rgba(0, 82, 255, 0.06)';
    const strokeWidth = 1.5 * inv;
    const dash = [6 * inv, 4 * inv];
    if (shape === 'ellipse') {
        return (
            <Group listening={false}>
                <Ellipse
                    x={x + w / 2}
                    y={y + h / 2}
                    radiusX={w / 2}
                    radiusY={h / 2}
                    stroke={stroke}
                    strokeWidth={strokeWidth}
                    dash={dash}
                    fill={fill}
                    listening={false}
                />
            </Group>
        );
    }
    const cornerRadius =
        shape === 'pill' ? Math.min(w, h) / 2 : Math.min(8, Math.min(w, h) / 2);
    return (
        <Group listening={false}>
            <Rect
                x={x}
                y={y}
                width={w}
                height={h}
                cornerRadius={cornerRadius}
                stroke={stroke}
                strokeWidth={strokeWidth}
                dash={dash}
                fill={fill}
                listening={false}
            />
        </Group>
    );
}

function PathEditOverlay({
    layer,
    layerRect,
    scale,
    onPreview,
    onCommit,
}: PathEditOverlayProps) {
    const nodes = layer.nodes ?? [];
    const closed = layer.closed ?? false;
    const inv = 1 / scale;
    const anchorR = 7 * inv;
    const handleR = 5 * inv;
    const strokeW = 1.5 * inv;

    const nodeAbs = (n: PathNode) => ({
        x: layerRect.x + n.x * layerRect.width,
        y: layerRect.y + n.y * layerRect.height,
    });
    const handleAbs = (n: PathNode, h: { x: number; y: number }) => ({
        x: layerRect.x + (n.x + h.x) * layerRect.width,
        y: layerRect.y + (n.y + h.y) * layerRect.height,
    });
    const toFracX = (ax: number) => (ax - layerRect.x) / Math.max(1, layerRect.width);
    const toFracY = (ay: number) => (ay - layerRect.y) / Math.max(1, layerRect.height);

    const updateAnchorDrag = (i: number, ax: number, ay: number, commit: boolean) => {
        const next = nodes.map((n, idx) => (idx === i ? { ...n } : n));
        next[i] = { ...next[i], x: toFracX(ax), y: toFracY(ay) };
        if (commit) onCommit(next, closed);
        else onPreview(next, closed);
    };

    const updateHandleDrag = (
        i: number,
        which: 'handleIn' | 'handleOut',
        ax: number,
        ay: number,
        commit: boolean,
    ) => {
        const anchor = nodes[i];
        const hx = toFracX(ax) - anchor.x;
        const hy = toFracY(ay) - anchor.y;
        const next = nodes.map((n, idx) => (idx === i ? { ...n } : n));
        next[i] = { ...next[i], [which]: { x: hx, y: hy } } as PathNode;
        if (commit) onCommit(next, closed);
        else onPreview(next, closed);
    };

    const toggleSmooth = (i: number) => {
        const n = nodes[i];
        const hasHandles = !!(n.handleIn || n.handleOut);
        const next = nodes.map((m, idx) => (idx === i ? { ...m } : m));
        if (hasHandles) {
            const copy = { ...next[i] };
            delete copy.handleIn;
            delete copy.handleOut;
            next[i] = copy;
        } else {
            const prev = nodes[(i - 1 + nodes.length) % nodes.length];
            const nxt = nodes[(i + 1) % nodes.length];
            const hasPrev = closed || i > 0;
            const hasNext = closed || i < nodes.length - 1;
            let tx = 0;
            let ty = 0;
            if (hasPrev && hasNext) {
                tx = 0.2 * (nxt.x - prev.x);
                ty = 0.2 * (nxt.y - prev.y);
            } else if (hasNext) {
                tx = 0.2 * (nxt.x - n.x);
                ty = 0.2 * (nxt.y - n.y);
            } else if (hasPrev) {
                tx = 0.2 * (n.x - prev.x);
                ty = 0.2 * (n.y - prev.y);
            } else {
                tx = 0.2;
                ty = 0;
            }
            next[i] = {
                ...next[i],
                handleOut: { x: tx, y: ty },
                handleIn: { x: -tx, y: -ty },
            };
        }
        onCommit(next, closed);
    };

    const deleteAnchor = (i: number) => {
        const next = nodes.filter((_, idx) => idx !== i);
        onCommit(next, next.length < 2 ? false : closed);
    };

    const insertAt = (i: number) => {
        const a = nodes[i];
        const b = nodes[(i + 1) % nodes.length];
        const mid: PathNode = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
        const next = [...nodes.slice(0, i + 1), mid, ...nodes.slice(i + 1)];
        onCommit(next, closed);
    };

    const segmentCount = closed ? nodes.length : Math.max(0, nodes.length - 1);

    return (
        <Group>
            {Array.from({ length: segmentCount }).map((_, i) => {
                const a = nodeAbs(nodes[i]);
                const b = nodeAbs(nodes[(i + 1) % nodes.length]);
                const mx = (a.x + b.x) / 2;
                const my = (a.y + b.y) / 2;
                return (
                    <Shape
                        key={`seg-mid-${i}`}
                        sceneFunc={(ctx, shape) => {
                            ctx.beginPath();
                            ctx.arc(mx, my, 5 * inv, 0, Math.PI * 2);
                            ctx.fillStrokeShape(shape);
                        }}
                        fill="#F4F4F5"
                        stroke="#0052FF"
                        strokeWidth={strokeW}
                        opacity={0.7}
                        onMouseDown={(e) => {
                            e.cancelBubble = true;
                            insertAt(i);
                        }}
                        onTap={(e) => {
                            e.cancelBubble = true;
                            insertAt(i);
                        }}
                    />
                );
            })}
            {nodes.map((node, i) => {
                const abs = nodeAbs(node);
                return (
                    <Group key={`path-anchor-${i}`}>
                        {node.handleIn && (
                            <>
                                <Line
                                    points={[
                                        abs.x,
                                        abs.y,
                                        handleAbs(node, node.handleIn).x,
                                        handleAbs(node, node.handleIn).y,
                                    ]}
                                    stroke="#94A3B8"
                                    strokeWidth={strokeW}
                                    listening={false}
                                />
                                <Rect
                                    x={handleAbs(node, node.handleIn).x - handleR}
                                    y={handleAbs(node, node.handleIn).y - handleR}
                                    width={handleR * 2}
                                    height={handleR * 2}
                                    fill="#FFFFFF"
                                    stroke="#0F172A"
                                    strokeWidth={strokeW}
                                    draggable
                                    onDragMove={(e) => {
                                        const n = e.target;
                                        updateHandleDrag(
                                            i,
                                            'handleIn',
                                            n.x() + handleR,
                                            n.y() + handleR,
                                            false,
                                        );
                                    }}
                                    onDragEnd={(e) => {
                                        const n = e.target;
                                        updateHandleDrag(
                                            i,
                                            'handleIn',
                                            n.x() + handleR,
                                            n.y() + handleR,
                                            true,
                                        );
                                    }}
                                />
                            </>
                        )}
                        {node.handleOut && (
                            <>
                                <Line
                                    points={[
                                        abs.x,
                                        abs.y,
                                        handleAbs(node, node.handleOut).x,
                                        handleAbs(node, node.handleOut).y,
                                    ]}
                                    stroke="#94A3B8"
                                    strokeWidth={strokeW}
                                    listening={false}
                                />
                                <Rect
                                    x={handleAbs(node, node.handleOut).x - handleR}
                                    y={handleAbs(node, node.handleOut).y - handleR}
                                    width={handleR * 2}
                                    height={handleR * 2}
                                    fill="#FFFFFF"
                                    stroke="#0F172A"
                                    strokeWidth={strokeW}
                                    draggable
                                    onDragMove={(e) => {
                                        const n = e.target;
                                        updateHandleDrag(
                                            i,
                                            'handleOut',
                                            n.x() + handleR,
                                            n.y() + handleR,
                                            false,
                                        );
                                    }}
                                    onDragEnd={(e) => {
                                        const n = e.target;
                                        updateHandleDrag(
                                            i,
                                            'handleOut',
                                            n.x() + handleR,
                                            n.y() + handleR,
                                            true,
                                        );
                                    }}
                                />
                            </>
                        )}
                        <Rect
                            x={abs.x - anchorR}
                            y={abs.y - anchorR}
                            width={anchorR * 2}
                            height={anchorR * 2}
                            cornerRadius={anchorR}
                            fill="#FFFFFF"
                            stroke="#0052FF"
                            strokeWidth={strokeW * 1.5}
                            draggable
                            onMouseDown={(e) => {
                                e.cancelBubble = true;
                                if (e.evt.altKey) {
                                    e.evt.preventDefault();
                                    toggleSmooth(i);
                                }
                            }}
                            onDragMove={(e) => {
                                const n = e.target;
                                updateAnchorDrag(i, n.x() + anchorR, n.y() + anchorR, false);
                            }}
                            onDragEnd={(e) => {
                                const n = e.target;
                                updateAnchorDrag(i, n.x() + anchorR, n.y() + anchorR, true);
                            }}
                            onDblClick={(e) => {
                                e.cancelBubble = true;
                                deleteAnchor(i);
                            }}
                        />
                    </Group>
                );
            })}
        </Group>
    );
}

function ZoomInput({
    zoomPct,
    minPct,
    maxPct,
    onCommit,
}: {
    zoomPct: number;
    minPct: number;
    maxPct: number;
    onCommit: (pct: number) => void;
}) {
    const [editing, setEditing] = useState(false);
    const [draft, setDraft] = useState(String(zoomPct));
    const inputRef = useRef<HTMLInputElement | null>(null);

    useEffect(() => {
        if (!editing) setDraft(String(zoomPct));
    }, [zoomPct, editing]);

    useEffect(() => {
        if (!editing) return;
        const t = window.setTimeout(() => {
            inputRef.current?.focus();
            inputRef.current?.select();
        }, 0);
        return () => window.clearTimeout(t);
    }, [editing]);

    const commit = () => {
        const parsed = parseInt(draft.replace(/[^\d-]/g, ''), 10);
        if (!Number.isFinite(parsed)) {
            setDraft(String(zoomPct));
            setEditing(false);
            return;
        }
        const clamped = Math.max(minPct, Math.min(maxPct, parsed));
        onCommit(clamped);
        setEditing(false);
    };

    // Fixed h-7 matches the sibling Focus + Lock buttons in the controls
    // cluster so all three pills line up vertically.
    const base =
        'h-7 rounded-md border border-border bg-card/90 text-[11px] font-medium shadow-sm backdrop-blur';

    if (editing) {
        return (
            <div className={`${base} flex items-center px-1.5 text-muted-foreground`}>
                <input
                    ref={inputRef}
                    type="number"
                    min={minPct}
                    max={maxPct}
                    step={1}
                    value={draft}
                    onChange={(e) => setDraft(e.target.value)}
                    onBlur={commit}
                    onKeyDown={(e) => {
                        if (e.key === 'Enter') {
                            e.preventDefault();
                            commit();
                        } else if (e.key === 'Escape') {
                            e.preventDefault();
                            setDraft(String(zoomPct));
                            setEditing(false);
                        }
                        e.stopPropagation();
                    }}
                    data-testid="zoom-input"
                    className="w-8 bg-transparent text-right text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
                    inputMode="numeric"
                />
                <span className="ml-0.5">%</span>
            </div>
        );
    }

    return (
        <button
            type="button"
            onClick={() => setEditing(true)}
            data-testid="zoom-indicator"
            aria-label="Zoom level"
            title="Click to set zoom"
            className={`${base} inline-flex cursor-pointer items-center px-2 text-muted-foreground transition-colors hover:text-foreground`}
        >
            {zoomPct}%
        </button>
    );
}
