import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
    IconPhotoPlus, IconClipboard, IconFrame, IconMaximize, IconSelectAll,
} from '@tabler/icons-react';
import { usePrintEditorStore } from '../state/usePrintEditorStore';
import {
    getSortedElements, ELEMENT_TYPES, isAspectLocked, isLockedFor,
    constrainCanvasSize, CANVAS_MIN, CANVAS_MAX,
    getGroupAncestry, getGroupElements, isGroupInside,
    getFrames, frameById, frameForBox, sceneBounds,
} from '../schema/documentSchema';
import { isSectionLocked } from '../schema/sectionLocks';
import { combinedBounds } from '../utils/geometry';
import {
    getCrop, innerRect, pictureRect, cropFromPicture, moveCrop, scaleCrop, elementInsets,
} from '../utils/imageCrop';
import { peekRecoloredImage } from '../utils/paletteImage';
import { readImageTransfer, imageSourceToPayloads, readArtworkTransfer } from '../utils/imageFiles';
import { notifications } from '@/lib/notifications';
import { importArtworkFile } from '../utils/importArtwork';
import ElementRenderer from '../elements/ElementRenderer';
import ElementEffectsOverlay, { ElementBackdrop } from '../elements/ElementEffects';
import CanvasContextMenu from './CanvasContextMenu';
import MaskDefs from './MaskDefs';
import DrawingOverlay from './DrawingOverlay';
import { PEN_CURSOR, PENCIL_CURSOR } from '../ui/drawCursors';
import {
    simplifyPoints, smoothNodes, nodesToElement,
} from '../utils/vectorPath';
import { buildElementMenu, pasteHere } from '../menus/elementMenuItems';
import { elementIcon } from '../ui/elementIcons';
import { isMaskElement, maskCssFor, maskFor } from '../utils/elementMask';
import { buildCssFilter, resolveCanvasBackground } from '../panels/propertyControls';
import {
    isGradientFill, toLocalPoint, angleFromLocalPoint, centerPercentFromLocal,
} from '../utils/gradientHandles';
import GradientGizmo from './GradientGizmo';
import PathEditOverlay from './PathEditOverlay';
import { T } from '../ui/figma';

const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
const CORNER_HANDLES = ['nw', 'ne', 'se', 'sw'];
const ROTATE_CORNERS = ['nw', 'ne', 'se', 'sw'];
const FIGMA_BLUE = '#0d99ff';

function cloneDoc(doc) {
    return JSON.parse(JSON.stringify(doc));
}

function handleCursor(h) {
    const map = { nw: 'nwse', se: 'nwse', ne: 'nesw', sw: 'nesw', n: 'ns', s: 'ns', e: 'ew', w: 'ew' };
    return `${map[h]}-resize`;
}

/** Invisible rotate hotspots just outside each corner (Figma behavior).
 *  `size` is the grab square (screen px); `off` is how far it sits outside the
 *  corner. Kept large and biased outward so rotation is easy to grab while the
 *  smaller resize handle (drawn on top) still wins the precise corner. */
function rotateHotspotStyle(corner, width, height, zoom) {
    const size = 48 / zoom; // enlarged hit area (was 18) — easier to grab for rotation
    const off = size * 0.85; // ~85% of the square sits outside the box (extra size goes outward, not into the element)
    const pos = {
        nw: { left: -off, top: -off },
        ne: { left: width + off - size, top: -off },
        se: { left: width + off - size, top: height + off - size },
        sw: { left: -off, top: height + off - size },
    }[corner] || {};
    return { ...pos, width: size, height: size };
}

/**
 * Figma-like canvas viewport:
 * - dotted workspace background
 * - white artboard (preset) floating above it
 * - scroll = pan, ctrl/cmd+scroll = zoom toward cursor
 * - space / middle-mouse / pan tool = grab-pan
 * - auto fit-to-view on load
 */
export default function CanvasViewport({ readOnly = false }) {
    const viewportRef = useRef(null);
    const fittedRef = useRef(false);
    const spaceHeld = useRef(false);
    const {
        document: doc,
        documentRevision,
        selectedIds,
        selectedFrameId,
        selectFrame,
        enteredGroupId,
        croppingId,
        cropSeed,
        editingPathId,
        stopCrop,
        zoom,
        panX,
        panY,
        tool,
        snapEnabled,
        showGuides,
        mode,
        isExporting,
        select,
        clearSelection,
        updateElement,
        updateElements,
        setPan,
        zoomAt,
        fitToView,
        addElement,
        setTool,
        addDrawnElement,
        setTextSelection,
    } = usePrintEditorStore();

    const [editingId, setEditingId] = useState(null);

    /**
     * Leave the text editor — but KEEP whatever characters were selected.
     *
     * Clicking a colour in the properties panel blurs the textarea first, so
     * clearing the selection here meant the range was gone by the time the colour
     * arrived and nothing could ever be coloured. The selection is dropped where
     * it genuinely stops meaning anything instead: when another element (or
     * nothing) is selected, and when a fresh edit starts.
     */
    const stopEditing = () => setEditingId(null);
    const [guides, setGuides] = useState({ v: [], h: [] });
    const [spacePan, setSpacePan] = useState(false);
    const [isPanning, setIsPanning] = useState(false);
    const [dropActive, setDropActive] = useState(false);
    // Rubber-band rect in canvas coordinates; null when no band is being dragged.
    const [marquee, setMarquee] = useState(null);
    const dragRef = useRef(null);
    const dragDepth = useRef(0);

    const elements = useMemo(() => getSortedElements(doc.elements), [doc.elements]);
    const selectedEls = useMemo(
        () => elements.filter((e) => selectedIds.includes(e.id)),
        [elements, selectedIds]
    );

    /**
     * Selection is exactly one whole (non-entered) group → draw a single group box.
     * Walks the ancestry outermost-first so selecting a nested sub-layer boxes that
     * sub-layer, not just the elements that share its innermost group.
     */
    const fullGroup = useMemo(() => {
        if (selectedEls.length < 2) return null;
        const chain = getGroupAncestry(doc.groups || [], selectedEls[0].groupId);
        if (!chain.length) return null;
        const selIds = new Set(selectedIds);
        for (const g of chain) {
            if (g.id === enteredGroupId) break; // entered → its children select individually
            const members = getGroupElements(elements, doc.groups || [], g.id);
            if (members.length === selectedEls.length && members.every((m) => selIds.has(m.id))) {
                const bounds = combinedBounds(members);
                return bounds ? { gid: g.id, members, ...bounds } : null;
            }
        }
        return null;
    }, [selectedEls, selectedIds, elements, doc.groups, enteredGroupId]);

    /**
     * Crop mode: the image being cropped and the rect the overlay works on — the
     * element's own crop, or the seed the store computed from its fit mode for an
     * image that has never been cropped (see startCrop).
     */
    const cropEl = useMemo(
        () => (croppingId ? elements.find((e) => e.id === croppingId) || null : null),
        [croppingId, elements],
    );
    const cropRect = cropEl ? (getCrop(cropEl) || cropSeed) : null;
    const cropping = !!(cropEl && cropRect);

    const panActive = tool === 'pan' || spacePan;
    const frames = useMemo(() => getFrames(doc), [doc]);
    const scene = useMemo(() => sceneBounds(doc), [doc]);
    /** Every frame's edges and centre lines — what elements snap to. */
    const canvasGuides = useMemo(() => {
        const v = [];
        const h = [];
        frames.forEach((f) => {
            v.push(f.x, f.x + f.width / 2, f.x + f.width);
            h.push(f.y, f.y + f.height / 2, f.y + f.height);
        });
        return { v, h };
    }, [frames]);

    const otherEdges = useCallback((exceptIds) => {
        const v = [...canvasGuides.v];
        const h = [...canvasGuides.h];
        elements.forEach((el) => {
            if (exceptIds.includes(el.id) || !el.visible) return;
            v.push(el.x, el.x + el.width / 2, el.x + el.width);
            h.push(el.y, el.y + el.height / 2, el.y + el.height);
        });
        return { v, h };
    }, [elements, canvasGuides]);

    const snap = useCallback((value, list) => {
        if (!snapEnabled) return { value, snapped: null };
        let best = null;
        let bestDist = 6;
        for (const g of list) {
            const d = Math.abs(value - g);
            if (d <= bestDist) { bestDist = d; best = g; }
        }
        return { value: best ?? value, snapped: best };
    }, [snapEnabled]);

    // Fit artboard into viewport on mount and whenever a NEW document arrives
    // (load / import / reset — each bumps documentRevision). Deliberately not on
    // canvas width/height: resizing the page from the panel or by dragging its
    // edge would otherwise snap the view back to fit and throw away the zoom the
    // user was working at.
    useEffect(() => {
        fittedRef.current = false;
    }, [documentRevision]);

    useEffect(() => {
        const el = viewportRef.current;
        if (!el || fittedRef.current) return;
        const run = () => {
            const { width, height } = el.getBoundingClientRect();
            if (width < 40 || height < 40) return;
            fitToView(width, height, { padding: 72 });
            fittedRef.current = true;
        };
        run();
        const ro = new ResizeObserver(() => {
            if (!fittedRef.current) run();
        });
        ro.observe(el);
        return () => ro.disconnect();
    }, [fitToView, documentRevision]);

    // Spacebar temporary pan (Figma)
    useEffect(() => {
        const down = (e) => {
            if (e.code !== 'Space') return;
            const tag = document.activeElement?.tagName;
            if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
            if (editingId) return;
            e.preventDefault();
            spaceHeld.current = true;
            setSpacePan(true);
        };
        const up = (e) => {
            if (e.code !== 'Space') return;
            spaceHeld.current = false;
            setSpacePan(false);
        };
        window.addEventListener('keydown', down, { passive: false });
        window.addEventListener('keyup', up);
        return () => {
            window.removeEventListener('keydown', down);
            window.removeEventListener('keyup', up);
        };
    }, [editingId]);

    /* ------------------------------------------------------------------ *
     *  Paste
     * ------------------------------------------------------------------ */

    const pasteFallbackRef = useRef(null);
    const fallbackPastedAtRef = useRef(0);
    const nativePasteWorksRef = useRef(false);

    /**
     * Safety net for a ⌘V that produces no `paste` event at all: pasting in-app
     * elements is the one thing that used to work from the keystroke alone, and
     * it must not depend on a clipboard event turning up.
     *
     * Disarmed for good the moment one real paste event arrives — from then on
     * there is no timer left that could race it into pasting twice.
     */
    const schedulePasteFallback = useCallback(() => {
        if (nativePasteWorksRef.current) return;
        clearTimeout(pasteFallbackRef.current);
        pasteFallbackRef.current = setTimeout(() => {
            pasteFallbackRef.current = null;
            fallbackPastedAtRef.current = Date.now();
            usePrintEditorStore.getState().pasteClipboard();
        }, 150);
    }, []);

    /**
     * Turn a pasted or dropped image into canvas content.
     * `at` = a canvas point when the image was dropped, so it lands under the
     * cursor; null for a paste (centered placement).
     */
    const pasteImages = useCallback(async (source, at = null) => {
        let payloads;
        try {
            payloads = await imageSourceToPayloads(source, { maxSide: 280 });
        } catch (err) {
            // A picture over the size limit is the one failure worth interrupting
            // for: the drop looked fine and nothing appeared, and the fix is the
            // person's to make. The rest is usually a cross-origin link the
            // browser won't let us read, which no one can act on.
            if (err?.code === 'image-too-large') {
                notifications.show({
                    color: 'yellow', title: 'Photo is too large', message: err.message, autoClose: 6000,
                });
            } else console.warn('[print-editor] Could not paste that image', err);
            return;
        }
        if (!payloads.length) return;

        const store = usePrintEditorStore.getState();
        if (store.mode === 'user') {
            // Users have no insert tools (see BottomToolbar), so an image fills an
            // existing box: the one dropped onto, else the selected one.
            const dropTarget = at
                ? getSortedElements(store.document.elements)
                    .filter((el) => el.type === ELEMENT_TYPES.IMAGE && !el.locked && el.editableByUser !== false)
                    .reverse()
                    .find((el) => at.x >= el.x && at.x <= el.x + el.width && at.y >= el.y && at.y <= el.y + el.height)
                : null;
            const target = dropTarget || (store.selectedIds.length === 1
                ? store.document.elements.find((el) => el.id === store.selectedIds[0])
                : null);
            if (target?.type !== ELEMENT_TYPES.IMAGE) return;
            const [first] = payloads;
            store.updateElement(target.id, {
                src: first.src,
                name: first.name || target.name,
                width: first.width,
                height: first.height,
                // A crop belongs to the picture it was cut from — a new picture with a
                // different shape would be framed by a rect that means nothing for it.
                crop: null,
            });
            return;
        }
        store.addElements(
            payloads.map((p) => ({
                type: ELEMENT_TYPES.IMAGE, src: p.src, name: p.name, width: p.width, height: p.height,
            })),
            at ? { at } : undefined,
        );
    }, []);

    // Going read-only (presenting) mid-edit must close the text field with it —
    // otherwise a caret and a live textarea stay on the canvas nothing can commit.
    useEffect(() => {
        if (readOnly) setEditingId(null);
    }, [readOnly]);

    /** A focused text field owns its own clipboard events — property panel, canvas text editing. */
    const clipboardIsOurs = useCallback(() => {
        if (readOnly || editingId) return false;
        const tag = document.activeElement?.tagName;
        if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return false;
        return !document.activeElement?.isContentEditable;
    }, [readOnly, editingId]);

    /**
     * Copy / cut — put the copied elements on the *system* clipboard.
     *
     * This is what keeps the two clipboards from fighting. Without it, copying an
     * image in another app and then copying elements in here leaves that image
     * sitting on the system clipboard, and since it is the newer of the two the
     * next ⌘V pastes the image instead of the elements just copied. Claiming the
     * clipboard on every copy means the most recent copy always wins, whichever
     * side of the app it came from.
     *
     * Done through the copy event rather than navigator.clipboard.writeText
     * because that one is unavailable outside a secure context (plain http on a
     * LAN address, say), which is exactly where the conflict showed up.
     */
    useEffect(() => {
        const onCopy = (e) => {
            if (!clipboardIsOurs()) return;
            const text = usePrintEditorStore.getState().serializeClipboard();
            if (!text) return; // nothing copied in the editor — leave the clipboard alone
            e.clipboardData?.setData('text/plain', text);
            e.preventDefault();
        };
        // Same payload for cut: the keydown handler has already copied-then-deleted
        window.addEventListener('copy', onCopy);
        window.addEventListener('cut', onCopy);
        return () => {
            window.removeEventListener('copy', onCopy);
            window.removeEventListener('cut', onCopy);
        };
    }, [clipboardIsOurs]);

    /**
     * Paste — ⌘V, or the browser's own Paste menu item.
     *
     * An image copied from anywhere outside the app (a screenshot, "Copy image"
     * in a browser, a file copied in Finder/Explorer, raw <svg> markup) becomes
     * canvas content; png, jpg, webp, gif, svg, avif and the rest all arrive here
     * as a file and are read the same way an upload is. Anything else falls
     * through to the editor's own clipboard, so copying and pasting elements
     * behaves exactly as it did before.
     */
    useEffect(() => {
        const onPaste = (e) => {
            if (!clipboardIsOurs()) return;

            nativePasteWorksRef.current = true;
            clearTimeout(pasteFallbackRef.current);
            pasteFallbackRef.current = null;

            // Both reads have to happen now — clipboardData is dead once this returns
            const imageSource = readImageTransfer(e.clipboardData);
            const systemText = e.clipboardData?.getData('text/plain') || '';

            if (imageSource) {
                e.preventDefault();
                pasteImages(imageSource);
                return;
            }
            // A late `paste` event, after the fallback above already pasted
            if (Date.now() - fallbackPastedAtRef.current < 400) return;
            e.preventDefault();
            usePrintEditorStore.getState().pasteClipboard({ systemText });
        };
        window.addEventListener('paste', onPaste);
        return () => window.removeEventListener('paste', onPaste);
    }, [clipboardIsOurs, pasteImages]);

    useEffect(() => () => clearTimeout(pasteFallbackRef.current), []);

    // Keyboard shortcuts
    useEffect(() => {
        const onKey = (e) => {
            if (readOnly || editingId) return;
            const tag = document.activeElement?.tagName;
            if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
            if (document.activeElement?.isContentEditable) return;

            const store = usePrintEditorStore.getState();
            // AltGr on European Windows layouts reports itself as Ctrl+Alt, so typing
            // a character that needs it (@ on a German keyboard, ł on Polish…) would
            // otherwise fire the Ctrl shortcut of the same letter. Nothing here binds
            // Ctrl+Alt — except the USER lock, Ctrl+Alt+L. That one is let through by
            // its physical key, unless the browser says the press really was AltGr:
            // dropping every Ctrl+Alt made the user lock unreachable on Windows, and
            // on a Mac too, where Cmd+Option+L is the browser's Downloads page.
            if (e.ctrlKey && e.altKey && !e.metaKey
                && (e.code !== 'KeyL' || e.getModifierState?.('AltGraph'))) return;
            const mod = e.metaKey || e.ctrlKey;
            const key = e.key.toLowerCase();

            if (mod && e.key === '0') {
                e.preventDefault();
                const rect = viewportRef.current?.getBoundingClientRect();
                if (rect) store.fitToView(rect.width, rect.height, { padding: 72 });
                return;
            }
            if (mod && e.key === '1') {
                e.preventDefault();
                const rect = viewportRef.current?.getBoundingClientRect();
                if (rect) {
                    // 1:1 centred on the active frame — with several frames, "100%"
                    // has to mean "100% looking at the one I'm working on".
                    const f = store.activeFrame();
                    store.setZoom(1);
                    store.setPan(
                        (rect.width - (f?.width || 1050)) / 2 - (f?.x || 0),
                        (rect.height - (f?.height || 600)) / 2 - (f?.y || 0),
                    );
                }
                return;
            }
            if (mod && (e.key === '=' || e.key === '+')) {
                e.preventDefault();
                const rect = viewportRef.current?.getBoundingClientRect();
                if (rect) store.zoomAt(store.zoom * 1.15, rect.width / 2, rect.height / 2);
                return;
            }
            if (mod && e.key === '-') {
                e.preventDefault();
                const rect = viewportRef.current?.getBoundingClientRect();
                if (rect) store.zoomAt(store.zoom / 1.15, rect.width / 2, rect.height / 2);
                return;
            }
            if (mod && key === 'd') {
                e.preventDefault();
                // ⌘D copies the selection; with a FRAME selected — or with ⇧ held, so
                // it works whatever is selected — it copies the whole artboard and its
                // contents, the same thing the panel's "Duplicate frame" button does.
                // (⌘⌥D is deliberately not used: macOS eats it to hide the Dock.)
                const frameId = store.selectedFrameId || (e.shiftKey ? store.activeFrame()?.id : null);
                if (frameId && store.mode === 'admin') store.duplicateFrame(frameId);
                else store.duplicateSelected();
                return;
            }
            // ⌘C / ⌘X are not prevented either — onCopy below needs the browser's
            // copy to run so it can put these elements on the system clipboard.
            if (mod && key === 'c') { store.copySelected(); return; }
            if (mod && key === 'x') { store.cutSelected(); return; }
            if (mod && key === 'v') {
                // Deliberately NOT prevented: canceling ⌘V here also cancels the
                // browser's paste, and the `paste` event is the only way to see
                // an image copied from another app. onPaste below does the work.
                schedulePasteFallback();
                return;
            }
            if (mod && key === 'g') {
                e.preventDefault();
                if (store.mode !== 'user') {
                    if (e.shiftKey) store.ungroupSelected();
                    else store.groupSelected();
                }
                return;
            }
            // Select all / all locked / all unlocked. In the admin editor a lock only
            // pins a layer against stray drags — every mutating action (delete,
            // nudge, drag, resize) already skips locked layers on its own — so
            // "all" means ALL, and the locked ones can be picked out to unlock or
            // restyle together: ⌘L the locked (the rows showing a lock in Layers),
            // ⌘U the rest. Matched by `code` too, so a non-Latin layout still works.
            // In the user editor a locked layer is fixed template artwork the user
            // cannot even click, so ⌘A keeps leaving it out and ⌘L / ⌘U do nothing.
            // Shift and Alt excluded: ⌘⇧L is the admin lock and ⌘⌥L the user lock,
            // both handled further down.
            const letter = (ch) => key === ch || e.code === `Key${ch.toUpperCase()}`;
            const plainMod = mod && !e.shiftKey && !e.altKey;
            if (plainMod && letter('a')) {
                e.preventDefault();
                const all = store.document.elements;
                store.select((store.mode === 'admin' ? all : all.filter((el) => !isLockedFor(el, store.mode)))
                    .map((el) => el.id));
                return;
            }
            if (plainMod && store.mode === 'admin' && (letter('l') || letter('u'))) {
                // Prevented even when nothing matches: ⌘L would otherwise jump to the
                // address bar and ⌘U open the page source.
                e.preventDefault();
                const wantLocked = letter('l');
                const ids = store.document.elements
                    .filter((el) => isLockedFor(el, store.mode) === wantLocked)
                    .map((el) => el.id);
                // Nothing locked (or nothing unlocked) → leave the selection alone
                // rather than silently wiping it.
                if (ids.length) store.select(ids);
                return;
            }
            // Crop mode owns Escape / Enter first — both mean "done cropping", and the
            // picture stays selected so the next gesture continues where the eye is.
            if (store.croppingId && (e.key === 'Escape' || e.key === 'Enter')) {
                e.preventDefault();
                store.stopCrop();
                return;
            }
            if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); store.deleteSelected(); return; }
            if (e.key === 'Escape') {
                // Step out of an entered group first (Figma) — ONE nesting level per
                // Escape, so deep sub-layers walk back out gradually — then clear.
                if (store.enteredGroupId) {
                    const groups = store.document.groups || [];
                    const cur = groups.find((g) => g.id === store.enteredGroupId);
                    const members = getGroupElements(store.document.elements, groups, store.enteredGroupId)
                        .map((x) => x.id);
                    store.enterGroup(cur?.parentId || null);
                    if (members.length) { store.select(members); stopEditing(); return; }
                }
                clearSelection();
                stopEditing();
                setTool('select');
                return;
            }

            // Selection actions — the same ones the right-click menu offers, so the
            // menu's shortcut column is a promise the editor keeps. Checked BEFORE
            // the tool shortcuts, which own bare V / H.
            if (store.selectedIds.length && store.mode === 'admin') {
                if (e.key === ']') { e.preventDefault(); store.bringToFront(); return; }
                if (e.key === '[') { e.preventDefault(); store.sendToBack(); return; }
                if (mod && e.shiftKey && key === 'h') { e.preventDefault(); store.toggleVisibility(); return; }
                // Not with Alt: on Windows Ctrl+Shift+Alt+L still reports key "L", and
                // Alt makes it the USER lock below.
                if (mod && e.shiftKey && !e.altKey && key === 'l') { e.preventDefault(); store.toggleLock(); return; }
                // The USER lock. Holding Option on a Mac changes what `e.key` reports
                // (⌥L arrives as "¬"), so the physical key is checked first; `key` is
                // the fallback for everywhere else, and for events carrying no `code`.
                if (mod && e.altKey && (e.code === 'KeyL' || key === 'l')) {
                    e.preventDefault();
                    store.toggleUserLock();
                    return;
                }
                if (e.ctrlKey && key === 'm') { e.preventDefault(); store.maskSelected(); return; }
                if (!mod && e.shiftKey && key === 'h') { e.preventDefault(); store.flipSelected('x'); return; }
                if (!mod && e.shiftKey && key === 'v') { e.preventDefault(); store.flipSelected('y'); return; }
            }

            // Tool shortcuts — ignore when modifier held (so ⌘V pastes instead of Select)
            if (!mod) {
                if (key === 'v') { setTool('select'); return; }
                if (key === 'h') { setTool('pan'); return; }
                // Insert shortcuts, as in Figma: R / O / L / ⇧L for shapes, T for text.
                // Admin only — the user-facing editor has no insert tools at all.
                if (store.mode === 'admin') {
                    // P / ⇧P arm the drawing tools — they draw rather than insert,
                    // so they set the tool instead of adding an element.
                    if (key === 'p') {
                        e.preventDefault();
                        setTool(e.shiftKey ? 'pencil' : 'pen');
                        return;
                    }
                    const shape = key === 'r' ? 'rect'
                        : key === 'o' ? 'ellipse'
                            : key === 'l' ? (e.shiftKey ? 'arrow' : 'line')
                                : null;
                    if (shape) {
                        e.preventDefault();
                        store.addElement(ELEMENT_TYPES.SHAPE, { shape });
                        return;
                    }
                    if (key === 't') {
                        e.preventDefault();
                        store.addElement(ELEMENT_TYPES.TEXT);
                        return;
                    }
                    if (key === 'f') {
                        e.preventDefault();
                        store.addFrame();
                        return;
                    }
                }
            }

            const step = e.shiftKey ? 10 : 1;
            if (e.key === 'ArrowLeft') { e.preventDefault(); store.nudge(-step, 0); }
            if (e.key === 'ArrowRight') { e.preventDefault(); store.nudge(step, 0); }
            if (e.key === 'ArrowUp') { e.preventDefault(); store.nudge(0, -step); }
            if (e.key === 'ArrowDown') { e.preventDefault(); store.nudge(0, step); }
        };
        window.addEventListener('keydown', onKey);
        return () => window.removeEventListener('keydown', onKey);
    }, [readOnly, editingId, clearSelection, setTool, schedulePasteFallback]);

    /**
     * Leave canvas text editing, committing what's in the textarea.
     *
     * Every canvas pointerdown handler below calls preventDefault() (to stop the
     * browser's own drag/selection), and a canceled pointerdown also cancels the
     * focus change that would normally blur the textarea. Without this the edited
     * box stayed a focused textarea forever: it swallowed its own clicks
     * (onPointerDown stopPropagation), so the block could never be re-selected
     * until something outside the artboard cleared the state by hand.
     */
    const exitTextEditing = () => {
        if (!editingId) return;
        const active = document.activeElement;
        if (active?.tagName === 'TEXTAREA') active.blur(); // fires onBlur → commits content
        stopEditing();
    };

    const clientToCanvas = useCallback((clientX, clientY) => {
        const rect = viewportRef.current.getBoundingClientRect();
        return {
            x: (clientX - rect.left - panX) / zoom,
            y: (clientY - rect.top - panY) / zoom,
        };
    }, [panX, panY, zoom]);

    /**
     * A dropped design file (PSD / AI / PDF / TIFF) goes through the artwork
     * importer, not the image path — those formats can't decode into an <img>,
     * and importing them keeps their layer structure.
     *
     * Dropped onto an EMPTY page it behaves like the Import button (the page
     * takes the artwork's size). Dropped onto a page that already has content it
     * leaves the page alone and drops the layers centred on the cursor, so an
     * import can't silently resize work already on the board.
     */
    const importArtworkDrop = useCallback(async (file, at) => {
        try {
            const result = await importArtworkFile(file);
            if (!result.elements?.length) throw new Error('Nothing importable found in this file.');
            const store = usePrintEditorStore.getState();
            const isEmpty = store.document.elements.length === 0;

            if (isEmpty) {
                store.importElements(result.elements, result.canvas, result.groups);
                requestAnimationFrame(() => {
                    const vp = document.querySelector('[data-workspace="1"]');
                    if (!vp) return;
                    const r = vp.getBoundingClientRect();
                    usePrintEditorStore.getState().fitToView(r.width, r.height, { padding: 72 });
                });
            } else {
                // Land on the frame under the cursor, centred on the drop point.
                // importElements shifts everything onto that frame's origin, so these
                // deltas are frame-local.
                const target = at
                    ? frameById(store.document, frameForBox(getFrames(store.document), { x: at.x, y: at.y }))
                    : null;
                const frame = target || store.activeFrame();
                const localX = at ? at.x - (frame?.x || 0) : (frame?.width || 1050) / 2;
                const localY = at ? at.y - (frame?.y || 0) : (frame?.height || 600) / 2;
                const dx = Math.round(localX - result.canvas.width / 2);
                const dy = Math.round(localY - result.canvas.height / 2);
                store.importElements(
                    result.elements.map((el) => ({ ...el, x: (el.x || 0) + dx, y: (el.y || 0) + dy })),
                    null,
                    result.groups,
                    { frameId: frame?.id || null },
                );
            }
        } catch (err) {
            console.error('[print-editor] Artwork drop failed', err);
            // eslint-disable-next-line no-alert
            window.alert(`Import failed: ${err?.message || err}`);
        }
    }, []);

    /* ---- Drag-and-drop images from the desktop / another app onto the canvas ---- */

    // During dragover the payload is unreadable — only its `types` are exposed.
    // A file drag lists 'Files'; a dragged web image lists these URL/HTML types.
    const dragHasImage = (dt) => {
        if (!dt) return false;
        const types = Array.from(dt.types || []);
        return types.includes('Files') || types.includes('text/uri-list') || types.includes('text/html');
    };

    // Enter/leave fire for every child crossed, so depth-count to avoid flicker.
    const onDragEnter = (e) => {
        if (readOnly || !dragHasImage(e.dataTransfer)) return;
        e.preventDefault();
        dragDepth.current += 1;
        setDropActive(true);
    };
    const onDragOver = (e) => {
        if (readOnly || !dragHasImage(e.dataTransfer)) return;
        e.preventDefault(); // required — without it the browser rejects the drop
        e.dataTransfer.dropEffect = 'copy';
    };
    const onDragLeave = (e) => {
        if (readOnly) return;
        dragDepth.current = Math.max(0, dragDepth.current - 1);
        if (dragDepth.current === 0) setDropActive(false);
    };
    const onDrop = (e) => {
        if (readOnly) return;
        dragDepth.current = 0;
        setDropActive(false);
        // Read now — dataTransfer is dead once this handler returns
        const artwork = mode === 'admin' ? readArtworkTransfer(e.dataTransfer) : [];
        if (artwork.length) {
            e.preventDefault();
            const at = clientToCanvas(e.clientX, e.clientY);
            // One design file per drop — importing several would stack whole
            // artworks on top of each other.
            importArtworkDrop(artwork[0], at);
            return;
        }
        const source = readImageTransfer(e.dataTransfer);
        if (!source) return; // let the browser handle non-image drops
        e.preventDefault();
        pasteImages(source, clientToCanvas(e.clientX, e.clientY));
    };

    /**
     * Axis-aligned bounds of an element, rotation included — what the marquee
     * tests against, so a rotated box is caught by the area it actually covers.
     */
    const elementBounds = (el) => {
        const rot = ((Number(el.rotation) || 0) * Math.PI) / 180;
        const cos = Math.abs(Math.cos(rot));
        const sin = Math.abs(Math.sin(rot));
        const hw = (el.width * cos + el.height * sin) / 2;
        const hh = (el.width * sin + el.height * cos) / 2;
        const cx = el.x + el.width / 2;
        const cy = el.y + el.height / 2;
        return { x1: cx - hw, y1: cy - hh, x2: cx + hw, y2: cy + hh };
    };

    /**
     * Ids the marquee covers. Figma selects anything the band TOUCHES rather than
     * only what it fully contains, and a hit on a grouped element takes the whole
     * group (unless you have drilled into it) — resolveSelectionTarget decides that,
     * the same call a plain click uses, so both routes agree.
     */
    const marqueeTargets = (rect) => {
        // Read live state rather than the render's closure — the pointermove
        // listener outlives the render that installed it.
        const store = usePrintEditorStore.getState();
        const hits = new Set();
        store.document.elements.forEach((el) => {
            if (!el.visible) return;
            if (isLockedFor(el, store.mode)) return;
            if (store.mode === 'user' && el.editableByUser === false) return;
            const b = elementBounds(el);
            const touches = b.x1 < rect.x + rect.w && b.x2 > rect.x
                && b.y1 < rect.y + rect.h && b.y2 > rect.y;
            if (!touches) return;
            store.resolveSelectionTarget(el.id).forEach((id) => hits.add(id));
        });
        return Array.from(hits);
    };

    /* ------------------------------------------------------------------ *
     *  Pen and pencil
     *
     *  The draft lives HERE, never in the document: a half-drawn path is not a
     *  layer, and must not reach undo, autosave or an export. Only the finished
     *  shape is committed, in one step, by `addDrawnElement`.
     *
     *  Both tools are handled on the viewport's CAPTURE phase, so a stroke can
     *  start over existing artwork — the elements and the frame background have
     *  their own pointer handlers, and in draw mode none of them should win.
     * ------------------------------------------------------------------ */

    const [draft, setDraftState] = useState(null);
    const draftRef = useRef(null);
    /**
     * Ref and state move TOGETHER. Pointer samples can arrive several to a tick,
     * and each one is computed from the last: reading the draft back from React
     * state would hand the second sample in a tick the value the first one
     * started from, and the stroke would lose everything but its last point.
     */
    const setDraft = useCallback((next) => {
        draftRef.current = typeof next === 'function' ? next(draftRef.current) : next;
        setDraftState(draftRef.current);
    }, []);
    const drawTool = (tool === 'pen' || tool === 'pencil') && mode === 'admin' && !readOnly;

    /** How close (in scene units) counts as "on" a node — a screen-constant 9px. */
    const grabRadius = () => 9 / Math.max(zoom, 0.01);

    const commitDraft = useCallback((finished) => {
        setDraft(null);
        const nodes = finished?.nodes || [];
        if (nodes.length < 2) return;
        const spec = nodesToElement(nodes, {
            closed: !!finished.closed,
            name: finished.tool === 'pencil' ? 'Pencil path' : 'Pen path',
        });
        if (spec) addDrawnElement(spec);
    }, [addDrawnElement]);

    /** Pencil: the sampled trail becomes a simplified, smoothed open path. */
    const finishPencil = useCallback((trail) => {
        setDraft(null);
        if (!trail || trail.length < 2) return;
        // Tolerance in SCENE units: the same 2 screen pixels of slack whether the
        // stroke was drawn zoomed in on a detail or zoomed out on the whole page.
        const simplified = simplifyPoints(trail, 2 / Math.max(zoom, 0.01));
        const nodes = smoothNodes(simplified, { closed: false });
        const spec = nodesToElement(nodes, { closed: false, name: 'Pencil path' });
        if (spec) addDrawnElement(spec);
    }, [addDrawnElement, zoom]);

    const onDrawPointerDown = (e) => {
        if (!drawTool || panActive || e.button !== 0) return;
        e.preventDefault();
        e.stopPropagation();
        exitTextEditing();
        const p = clientToCanvas(e.clientX, e.clientY);

        if (tool === 'pencil') {
            setDraft({ tool: 'pencil', trail: [p] });
            return;
        }

        const cur = draftRef.current;
        const nodes = cur?.tool === 'pen' ? cur.nodes : [];
        // Clicking the first node closes the path and finishes it — the standard
        // way to end a shape rather than a line.
        if (nodes.length > 2 && Math.hypot(p.x - nodes[0].x, p.y - nodes[0].y) <= grabRadius()) {
            commitDraft({ ...cur, closed: true });
            return;
        }
        const next = [...nodes, { x: p.x, y: p.y }];
        setDraft({
            tool: 'pen',
            nodes: next,
            cursor: p,
            closed: false,
            // Dragging away from the node just placed bends the segment into it,
            // exactly as a pen tool is expected to behave.
            dragging: next.length - 1,
            dragFrom: p,
        });
    };

    /**
     * Draft gestures live on their own listeners rather than in the shared drag
     * machinery: they are not moving anything in the document, so none of that
     * file's snapping, history or selection rules apply to them.
     */
    useEffect(() => {
        if (!drawTool) { setDraft(null); return undefined; }
        const move = (e) => {
            const cur = draftRef.current;
            if (!cur) return;
            const p = clientToCanvas(e.clientX, e.clientY);
            if (cur.tool === 'pencil') {
                const trail = cur.trail;
                const last = trail[trail.length - 1];
                // One sample per screen pixel is plenty; anything finer is noise
                // that the simplifier would only have to throw away again.
                if (Math.hypot(p.x - last.x, p.y - last.y) < 1 / Math.max(zoom, 0.01)) return;
                setDraft({ ...cur, trail: [...trail, p] });
                return;
            }
            if (cur.dragging != null) {
                const nodes = [...cur.nodes];
                const n = { ...nodes[cur.dragging] };
                // Symmetric handles: the incoming side mirrors the outgoing one,
                // which is what makes a dragged pen node a smooth curve.
                n.hOut = [p.x, p.y];
                n.hIn = [2 * n.x - p.x, 2 * n.y - p.y];
                nodes[cur.dragging] = n;
                setDraft({ ...cur, nodes, cursor: p });
                return;
            }
            const first = cur.nodes[0];
            setDraft({
                ...cur,
                cursor: p,
                overFirst: !!first && Math.hypot(p.x - first.x, p.y - first.y) <= grabRadius(),
            });
        };
        const up = () => {
            const cur = draftRef.current;
            if (!cur) return;
            if (cur.tool === 'pencil') { finishPencil(cur.trail); return; }
            if (cur.dragging != null) {
                const n = cur.nodes[cur.dragging];
                // A click that never moved is a CORNER, not a curve — drop the
                // handles a drag would have left behind.
                const moved = cur.dragFrom
                    && Math.hypot(n.x - (n.hOut?.[0] ?? n.x), n.y - (n.hOut?.[1] ?? n.y)) > grabRadius() / 3;
                const nodes = [...cur.nodes];
                if (!moved) nodes[cur.dragging] = { x: n.x, y: n.y };
                setDraft({ ...cur, nodes, dragging: null, dragFrom: null });
            }
        };
        window.addEventListener('pointermove', move);
        window.addEventListener('pointerup', up);
        return () => {
            window.removeEventListener('pointermove', move);
            window.removeEventListener('pointerup', up);
        };
    }, [drawTool, tool, zoom, clientToCanvas, finishPencil]);

    /** Enter / Escape / Backspace while a pen path is open. */
    useEffect(() => {
        if (!drawTool) return undefined;
        const onKey = (e) => {
            const cur = draftRef.current;
            if (e.key === 'Escape') {
                e.preventDefault();
                e.stopPropagation();
                // First Escape abandons the draft, a second leaves the tool — so a
                // mis-drawn path never costs you the tool you are working in.
                if (cur) setDraft(null); else setTool('select');
                return;
            }
            if (!cur || cur.tool !== 'pen') return;
            if (e.key === 'Enter') {
                e.preventDefault();
                e.stopPropagation();
                commitDraft({ ...cur, closed: false });
                return;
            }
            if (e.key === 'Backspace' || e.key === 'Delete') {
                e.preventDefault();
                e.stopPropagation();
                const nodes = cur.nodes.slice(0, -1);
                setDraft(nodes.length ? { ...cur, nodes, dragging: null } : null);
            }
        };
        window.addEventListener('keydown', onKey, true);
        return () => window.removeEventListener('keydown', onKey, true);
    }, [drawTool, commitDraft, setTool]);

    const onViewportPointerDown = (e) => {
        const wantsPan =
            e.button === 1 ||
            panActive ||
            (e.button === 0 && e.altKey);

        if (wantsPan) {
            e.preventDefault();
            exitTextEditing();
            setIsPanning(true);
            dragRef.current = { type: 'pan', startX: e.clientX, startY: e.clientY, panX, panY };
            return;
        }

        // Press on empty space — the dotted workspace or the artboard itself.
        const onBackground = e.target === e.currentTarget
            || e.target.dataset.workspace === '1'
            || !!e.target.dataset.canvasBg;
        if (!onBackground || e.button !== 0) return;

        exitTextEditing();
        // Shift keeps what is already selected and adds to it, like a shift-click.
        if (!e.shiftKey) clearSelection();
        if (readOnly) return;

        // Rubber-band select. The rect stays null until the pointer actually moves,
        // so a plain click still reads as "deselect" and never flashes a band.
        //
        // Pressed on a frame's blank artboard, it is still a marquee — that is how
        // several layers INSIDE the frame get picked — but a click that never
        // becomes a drag selects the frame, so an admin still reaches its
        // properties in one click. Moving the frame is its label's job
        // (onFrameMovePointerDown), exactly as in Figma.
        const start = clientToCanvas(e.clientX, e.clientY);
        const frameEl = e.target.dataset.canvasBg ? e.target.closest?.('[data-frame-id]') : null;
        dragRef.current = {
            type: 'marquee',
            start,
            additive: e.shiftKey,
            base: e.shiftKey ? [...selectedIds] : [],
            clickSelectsFrame: mode === 'admin' && !e.shiftKey ? frameEl?.dataset.frameId || null : null,
            moved: false,
        };
    };

    const onElementPointerDown = (e, el) => {
        if (panActive) {
            e.preventDefault();
            exitTextEditing();
            setIsPanning(true);
            dragRef.current = { type: 'pan', startX: e.clientX, startY: e.clientY, panX, panY };
            return;
        }
        if (readOnly) return;
        /**
         * Only the LEFT button selects and drags. A right-press used to run this
         * whole handler first and pick whatever was topmost under the pointer, so
         * the context menu that followed was already about the wrong layer — the
         * one thing a right-click must never do (see `onContextMenu`, which keeps
         * the selection). The viewport's own handler already ignores other buttons.
         */
        if (e.button !== 0) return;
        // Pressing on any element ends a text edit in progress — the textarea only
        // covers the box being edited, so this is always a press outside it.
        exitTextEditing();

        const store = usePrintEditorStore.getState();
        // Clicking a grouped element targets its whole group unless the group is entered
        const targets = store.resolveSelectionTarget(el.id);

        // Each side is stopped by its OWN lock: the user lock blocks the user, the
        // admin lock blocks the admin here (see isLockedFor).
        // When the target is a whole group, judge it by the group rather than the single
        // part clicked, so a group can be grabbed anywhere (decorative parts included)
        // as long as something in it is still movable.
        const movable = (m) => !!m
            && !isLockedFor(m, mode)
            && (mode !== 'user' || m.editableByUser !== false);
        const canGrab = targets.length > 1
            ? targets.some((id) => movable(elements.find((x) => x.id === id)))
            : movable(el);
        if (!canGrab) return;

        /**
         * A locked POSITION section is not a locked layer: the user still picks it
         * up to type into it, they simply cannot carry it anywhere. So this one is
         * filtered out of what a drag moves rather than blocking the press — the
         * click still selects, and a selection of mixed layers still drags the rest.
         */
        const draggable = (m) => !!m && !isLockedFor(m, mode) && !isSectionLocked(m, 'position', mode);

        e.stopPropagation();
        e.preventDefault();

        // Read fresh selection from the store — closures can lag a render behind
        const selIds = store.selectedIds;
        const allTargeted = targets.every((id) => selIds.includes(id));

        const multi = e.shiftKey || e.metaKey || e.ctrlKey;

        /**
         * Dragging INSIDE the current selection moves that selection, even when a
         * bigger layer is painted on top of it.
         *
         * Without this, a layer picked out of the stack ("Select layer" in the
         * right-click menu, or the Layers list) could not be dragged at all: the
         * press landed on whatever covered it and grabbed that instead. Figma's
         * rule, and the reason the selection is decided on pointer UP here — a
         * press that never moves still selects the layer on top, which is what a
         * plain click means.
         */
        if (!multi && !allTargeted && mode === 'admin' && (tool === 'select' || tool === 'pan')) {
            const point = clientToCanvas(e.clientX, e.clientY);
            const selected = elements.filter((x) => selIds.includes(x.id));
            const inSelection = selected.length > 0 && selected.some((x) => {
                const b = elementBounds(x);
                return point.x >= b.x1 && point.x <= b.x2 && point.y >= b.y1 && point.y <= b.y2;
            });
            if (inSelection) {
                dragRef.current = {
                    type: 'move',
                    start: point,
                    // A locked layer caught in the selection stays put while the
                    // rest of it moves — each side judged by its own lock.
                    moving: selected
                        .filter(draggable)
                        .map((x) => ({ id: x.id, x: x.x, y: x.y })),
                    pointerId: e.pointerId,
                    pendingDuplicate: e.altKey,
                    // Applied on pointerup if the press turned out to be a click.
                    pendingSelect: targets,
                    historyBefore: cloneDoc(store.document),
                };
                e.currentTarget.setPointerCapture?.(e.pointerId);
                return;
            }
        }
        let nextIds = selIds;
        if (multi) {
            nextIds = allTargeted
                ? selIds.filter((id) => !targets.includes(id))
                : [...new Set([...selIds, ...targets])];
            select(nextIds);
        } else if (!allTargeted) {
            nextIds = targets;
            select(nextIds);
        }
        // Clicking outside the entered group steps back out of it. With nesting,
        // an element deeper inside the entered group still counts as "inside".
        if (store.enteredGroupId
            && !isGroupInside(store.document.groups || [], el.groupId, store.enteredGroupId)) {
            store.enterGroup(null);
        }

        if (tool !== 'select' && tool !== 'pan') return;

        const start = clientToCanvas(e.clientX, e.clientY);
        const moving = nextIds
            .map((id) => elements.find((x) => x.id === id))
            .filter(draggable)
            .map((x) => ({ id: x.id, x: x.x, y: x.y }));

        dragRef.current = {
            type: 'move',
            start,
            moving,
            pointerId: e.pointerId,
            // ⌥-drag copies (Illustrator / Photoshop / Figma). The copy is made on the
            // FIRST real movement, not here — otherwise an ⌥-click that never moves
            // would silently leave a duplicate stacked on the original.
            pendingDuplicate: e.altKey && mode === 'admin',
            historyBefore: cloneDoc(usePrintEditorStore.getState().document),
        };
        e.currentTarget.setPointerCapture?.(e.pointerId);
    };

    const onHandlePointerDown = (e, handle) => {
        if (e.button !== 0) return;
        e.stopPropagation();
        e.preventDefault();
        const el = selectedEls[0];
        if (!el || isLockedFor(el, mode) || readOnly) return;
        // Resizing IS the Layout section, done with the pointer.
        if (isSectionLocked(el, 'layout', mode)) return;
        exitTextEditing();
        const start = clientToCanvas(e.clientX, e.clientY);
        const historyBefore = cloneDoc(usePrintEditorStore.getState().document);
        dragRef.current = {
            type: 'resize',
            handle,
            start,
            origin: { x: el.x, y: el.y, width: el.width, height: el.height, rotation: el.rotation || 0 },
            id: el.id,
            lockAspect: isAspectLocked(el),
            historyBefore,
        };
    };

    /**
     * Drag one end of the gradient line (or a radial's centre dot) — Figma's
     * gradient gizmo. The paint is stored as a CSS angle / centre, so the drag
     * only has to hand back the bearing of the pointer in the element's own
     * unrotated frame; ⇧ snaps the angle to 15°, as it does when rotating.
     */
    const onGradientPointerDown = (e, which, target) => {
        if (e.button !== 0) return;
        e.stopPropagation();
        e.preventDefault();
        if (readOnly || !target?.node) return;
        // Aiming a gradient is an authoring act: the admin designs the paint, the
        // user only fills in what the admin opened up. No gizmo in user mode.
        if (mode !== 'admin') return;
        const { kind, node } = target;
        if (kind === 'element' && isLockedFor(node, mode)) return;
        exitTextEditing();
        dragRef.current = {
            type: which === 'center' ? 'gradientCenter' : 'gradientAngle',
            which,
            kind,
            id: node.id,
            box: { x: node.x, y: node.y, width: node.width, height: node.height },
            // Frames never rotate; an element's gizmo turns with it.
            rotation: kind === 'element' ? (node.rotation || 0) : 0,
            historyBefore: cloneDoc(usePrintEditorStore.getState().document),
        };
        e.currentTarget.setPointerCapture?.(e.pointerId);
    };

    /**
     * Start one of the three crop gestures, all against the picture parked where it
     * is right now:
     *  - `cropResize` — a frame handle: the box slides over a fixed picture, so the
     *    crop is simply "which part of it the new box covers" (Figma's crop handles).
     *  - `cropPan`    — drag the picture itself to choose what shows.
     *  - `cropScale`  — a picture corner: zoom the picture inside the same frame.
     */
    const onCropPointerDown = (e, kind, handle) => {
        if (e.button !== 0) return;
        e.stopPropagation();
        e.preventDefault();
        if (readOnly || !cropping) return;
        const el = cropEl;
        const start = clientToCanvas(e.clientX, e.clientY);
        const center = { x: el.x + el.width / 2, y: el.y + el.height / 2 };
        dragRef.current = {
            type: kind,
            handle,
            id: el.id,
            start,
            crop: cropRect,
            rotation: el.rotation || 0,
            origin: { x: el.x, y: el.y, width: el.width, height: el.height },
            insets: elementInsets(el.styles),
            picture: pictureRect(el, cropRect), // element-local, held still by the gesture
            center,
            startDist: Math.max(1, Math.hypot(start.x - center.x, start.y - center.y)),
            historyBefore: cloneDoc(usePrintEditorStore.getState().document),
        };
        e.currentTarget.setPointerCapture?.(e.pointerId);
    };

    const onRotatePointerDown = (e) => {
        if (e.button !== 0) return;
        e.stopPropagation();
        e.preventDefault();
        const el = selectedEls[0];
        if (!el || isLockedFor(el, mode) || readOnly) return;
        // Rotation lives in the Position section.
        if (isSectionLocked(el, 'position', mode)) return;
        exitTextEditing();
        const cx = el.x + el.width / 2;
        const cy = el.y + el.height / 2;
        const start = clientToCanvas(e.clientX, e.clientY);
        const startAngle = Math.atan2(start.y - cy, start.x - cx) * (180 / Math.PI);
        dragRef.current = {
            type: 'rotate',
            id: el.id,
            cx, cy,
            startAngle,
            originRot: el.rotation || 0,
            historyBefore: cloneDoc(usePrintEditorStore.getState().document),
        };
    };

    /**
     * Drag a frame by its label to move the whole artboard, contents included —
     * the Figma gesture. The label is the handle rather than the frame background,
     * which keeps dragging on blank artboard doing what it always did (marquee).
     */
    const onFrameMovePointerDown = (e, frame) => {
        if (e.button !== 0) return;
        if (readOnly || panActive || mode !== 'admin') return;
        e.stopPropagation();
        e.preventDefault();
        exitTextEditing();
        usePrintEditorStore.getState().selectFrame(frame.id);
        dragRef.current = {
            type: 'frameMove',
            frameId: frame.id,
            start: clientToCanvas(e.clientX, e.clientY),
            origin: { x: frame.x, y: frame.y },
            applied: { dx: 0, dy: 0 },
            // ⌥-drag duplicates the artboard — made on the first real movement, so an
            // ⌥-click on the label just selects the frame.
            pendingDuplicate: e.altKey,
            historyBefore: cloneDoc(usePrintEditorStore.getState().document),
        };
        e.currentTarget.setPointerCapture?.(e.pointerId);
    };

    /** Drag a frame's right / bottom edge (or corner) to resize that frame. */
    const onFrameHandlePointerDown = (e, handle, frame) => {
        if (e.button !== 0) return;
        if (readOnly || panActive || mode !== 'admin') return;
        e.stopPropagation();
        e.preventDefault();
        exitTextEditing();
        dragRef.current = {
            type: 'frameResize',
            handle,
            frameId: frame.id,
            start: clientToCanvas(e.clientX, e.clientY),
            origin: { width: frame.width, height: frame.height },
            lockAspect: !!frame.lockAspect,
            historyBefore: cloneDoc(usePrintEditorStore.getState().document),
        };
        e.currentTarget.setPointerCapture?.(e.pointerId);
    };

    const onGroupHandlePointerDown = (e, corner) => {
        if (e.button !== 0) return;
        e.stopPropagation();
        e.preventDefault();
        if (!fullGroup || readOnly) return;
        exitTextEditing();
        const start = clientToCanvas(e.clientX, e.clientY);
        const anchor = {
            x: corner.includes('w') ? fullGroup.x + fullGroup.width : fullGroup.x,
            y: corner.includes('n') ? fullGroup.y + fullGroup.height : fullGroup.y,
        };
        const startDist = Math.hypot(start.x - anchor.x, start.y - anchor.y) || 1;
        dragRef.current = {
            type: 'groupScale',
            anchor,
            startDist,
            origins: fullGroup.members.map((m) => ({
                id: m.id,
                x: m.x,
                y: m.y,
                width: m.width,
                height: m.height,
                fontSize: m.styles?.fontSize,
            })),
            minDim: Math.min(...fullGroup.members.flatMap((m) => [m.width || 1, m.height || 1])),
            historyBefore: cloneDoc(usePrintEditorStore.getState().document),
        };
    };

    useEffect(() => {
        const onMove = (e) => {
            const drag = dragRef.current;
            if (!drag) return;

            if (drag.type === 'pan') {
                setPan(drag.panX + (e.clientX - drag.startX), drag.panY + (e.clientY - drag.startY));
                return;
            }

            const pt = clientToCanvas(e.clientX, e.clientY);

            if (drag.type === 'marquee') {
                const rect = {
                    x: Math.min(drag.start.x, pt.x),
                    y: Math.min(drag.start.y, pt.y),
                    w: Math.abs(pt.x - drag.start.x),
                    h: Math.abs(pt.y - drag.start.y),
                };
                // A couple of stray pixels while clicking is not a drag — wait until
                // the band has real size before it starts claiming elements.
                if (rect.w < 2 && rect.h < 2) return;
                drag.moved = true;
                setMarquee(rect);
                const hits = marqueeTargets(rect);
                usePrintEditorStore.getState().select(drag.additive
                    ? Array.from(new Set([...drag.base, ...hits]))
                    : hits);
                return;
            }

            // Frame move — the frame and everything on it travel together, snapping
            // to the OTHER frames' edges so artboards line up without fiddling.
            if (drag.type === 'frameMove') {
                // ⌥-drag a frame label: the copy is dropped exactly on the original
                // (contents included) and the rest of the drag carries it away.
                if (drag.pendingDuplicate) {
                    if (Math.abs(pt.x - drag.start.x) <= 1 && Math.abs(pt.y - drag.start.y) <= 1) return;
                    drag.pendingDuplicate = false;
                    const copy = usePrintEditorStore.getState().duplicateFrame(drag.frameId, {
                        at: { x: drag.origin.x, y: drag.origin.y },
                        skipHistory: true,
                    });
                    if (copy) drag.frameId = copy.id;
                    return;
                }
                // Frames come from the store, not the render closure: a frame born
                // mid-drag (the ⌥ copy above) is not in that closure's list.
                const liveFrames = getFrames(usePrintEditorStore.getState().document);
                const others = liveFrames.filter((f) => f.id !== drag.frameId);
                const self = liveFrames.find((f) => f.id === drag.frameId);
                if (!self) return;
                const vEdges = others.flatMap((f) => [f.x, f.x + f.width]);
                const hEdges = others.flatMap((f) => [f.y, f.y + f.height]);
                // Same ⇧ axis lock (and same snapping rule) as an element drag, so
                // ⇧⌥-dragging a frame label lays the copy out in a straight row.
                let fdx = pt.x - drag.start.x;
                let fdy = pt.y - drag.start.y;
                const axis = e.shiftKey ? (Math.abs(fdx) >= Math.abs(fdy) ? 'x' : 'y') : null;
                if (axis === 'x') fdy = 0;
                else if (axis === 'y') fdx = 0;
                let wantX = drag.origin.x + fdx;
                let wantY = drag.origin.y + fdy;
                if (axis !== 'y') {
                    const sLeft = snap(wantX, vEdges);
                    const sRight = snap(wantX + self.width, vEdges);
                    if (sLeft.snapped != null) wantX = sLeft.value;
                    else if (sRight.snapped != null) wantX = sRight.value - self.width;
                }
                if (axis !== 'x') {
                    const sTop = snap(wantY, hEdges);
                    const sBottom = snap(wantY + self.height, hEdges);
                    if (sTop.snapped != null) wantY = sTop.value;
                    else if (sBottom.snapped != null) wantY = sBottom.value - self.height;
                }
                // Same locked-axis rule as an element drag: show the line the artboard
                // is travelling along, so a row of frames can be laid out by eye.
                setGuides(axis === 'x'
                    ? { v: [], h: [Math.round(drag.origin.y + self.height / 2)] }
                    : axis === 'y'
                        ? { v: [Math.round(drag.origin.x + self.width / 2)], h: [] }
                        : { v: [], h: [] });
                // moveFrame takes a delta, so ask for the difference from where the
                // frame actually is right now.
                usePrintEditorStore.getState().moveFrame(
                    drag.frameId,
                    Math.round(wantX) - self.x,
                    Math.round(wantY) - self.y,
                    { skipHistory: true },
                );
                return;
            }

            // Frame resize — anchored at the frame's top-left so nothing on it
            // shifts; only the right/bottom edges move.
            if (drag.type === 'frameResize') {
                const o = drag.origin;
                const dx = pt.x - drag.start.x;
                const dy = pt.y - drag.start.y;
                const clamp = (v) => Math.min(CANVAS_MAX, Math.max(CANVAS_MIN, v));
                let next = {};
                if (e.shiftKey || drag.lockAspect) {
                    // Constrain proportions. The corner grip follows the larger delta;
                    // an edge grip is driven by the axis it owns.
                    const leadsWidth = drag.handle === 'se'
                        ? Math.abs(dx) > Math.abs(dy)
                        : drag.handle.includes('e');
                    next = constrainCanvasSize(o, leadsWidth
                        ? { width: o.width + dx }
                        : { height: o.height + dy });
                } else {
                    if (drag.handle.includes('e')) next.width = Math.round(clamp(o.width + dx));
                    if (drag.handle.includes('s')) next.height = Math.round(clamp(o.height + dy));
                }
                // No presetId — setFrame auto-matches, so dragging onto an exact preset
                // size snaps the Size-preset dropdown back to that preset.
                // skipHistory: the whole drag becomes one undo step, committed on pointerup
                if (Object.keys(next).length) {
                    usePrintEditorStore.getState().setFrame(drag.frameId, next, { skipHistory: true });
                }
                return;
            }

            if (drag.type === 'move') {
                let dx = pt.x - drag.start.x;
                let dy = pt.y - drag.start.y;
                // ⇧ locks the drag to whichever axis it has travelled furthest along,
                // so ⇧⌥-drag drops a copy perfectly in line with its original — the
                // Illustrator / Photoshop / Figma rule. `axis` = the one still free.
                const axis = e.shiftKey ? (Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y') : null;
                if (axis === 'x') dy = 0;
                else if (axis === 'y') dx = 0;
                // Once the pointer has really travelled, the press was a MOVE of the
                // current selection, not a click on the layer above it.
                if (drag.pendingSelect && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) {
                    drag.pendingSelect = null;
                }
                // ⌥-drag: the copies are born the moment the drag becomes real, and
                // the gesture carries THEM while the originals stay where they were.
                if (drag.pendingDuplicate && (Math.abs(dx) > 1 || Math.abs(dy) > 1)) {
                    drag.pendingDuplicate = false;
                    const map = usePrintEditorStore.getState().duplicateInPlace(
                        drag.moving.map((m) => m.id),
                        { skipHistory: true },
                    );
                    if (Object.keys(map).length) {
                        drag.moving = drag.moving.map((m) => ({ ...m, id: map[m.id] || m.id }));
                    }
                }
                // Boxes come from the store, not this listener's closure: a copy born
                // mid-drag isn't in that closure's array until React re-renders, and
                // the drag must not stall for a frame waiting for it.
                const liveEls = usePrintEditorStore.getState().document.elements;
                const edges = otherEdges(drag.moving.map((m) => m.id));
                const activeV = [];
                const activeH = [];
                const patches = {};
                drag.moving.forEach((m, i) => {
                    let nx = m.x + dx;
                    let ny = m.y + dy;
                    const el = liveEls.find((x) => x.id === m.id);
                    if (!el) return;
                    if (i === 0) {
                        // A locked axis skips its own snapping: nudging the box onto a
                        // guide there is exactly the drift ⇧ was pressed to prevent.
                        if (axis !== 'y') {
                            const left = snap(nx, edges.v);
                            const right = snap(nx + el.width, edges.v);
                            const midX = snap(nx + el.width / 2, edges.v);
                            if (left.snapped != null) { nx = left.value; activeV.push(left.snapped); }
                            else if (right.snapped != null) { nx = right.value - el.width; activeV.push(right.snapped); }
                            else if (midX.snapped != null) { nx = midX.value - el.width / 2; activeV.push(midX.snapped); }
                        }

                        if (axis !== 'x') {
                            const top = snap(ny, edges.h);
                            const bottom = snap(ny + el.height, edges.h);
                            const midY = snap(ny + el.height / 2, edges.h);
                            if (top.snapped != null) { ny = top.value; activeH.push(top.snapped); }
                            else if (bottom.snapped != null) { ny = bottom.value - el.height; activeH.push(bottom.snapped); }
                            else if (midY.snapped != null) { ny = midY.value - el.height / 2; activeH.push(midY.snapped); }
                        }

                        drag._corr = { dx: nx - m.x, dy: ny - m.y };
                    } else if (drag._corr) {
                        nx = m.x + drag._corr.dx;
                        ny = m.y + drag._corr.dy;
                    }
                    patches[m.id] = { x: Math.round(nx), y: Math.round(ny) };
                });
                // Draw the line the locked drag is riding — the same red rule snapping
                // uses, so ⇧ shows what it is holding instead of being invisible until
                // the pointer comes up. It runs through the box's own centre.
                if (axis) {
                    const lead = liveEls.find((x) => x.id === drag.moving[0]?.id);
                    if (lead) {
                        if (axis === 'x') activeH.push(Math.round(drag.moving[0].y + lead.height / 2));
                        else activeV.push(Math.round(drag.moving[0].x + lead.width / 2));
                    }
                }
                setGuides({ v: activeV, h: activeH });
                updateElements(patches, { skipHistory: true });
                return;
            }

            if (drag.type === 'resize') {
                const o = drag.origin;
                let { x, y, width, height } = o;
                const dx = pt.x - drag.start.x;
                const dy = pt.y - drag.start.y;
                const h = drag.handle;
                if (h.includes('e')) width = Math.max(20, o.width + dx);
                if (h.includes('s')) height = Math.max(20, o.height + dy);
                if (h.includes('w')) width = Math.max(20, o.width - dx);
                if (h.includes('n')) height = Math.max(20, o.height - dy);
                if ((e.shiftKey || drag.lockAspect) && o.width > 0 && o.height > 0) {
                    const ratio = o.width / o.height;
                    // Which dimension the drag is steering. A corner follows whichever
                    // way the pointer travelled further; an EDGE follows the axis it
                    // owns, always — deciding that one by the larger delta made a side
                    // grip snap back to its starting size whenever the hand drifted
                    // across the edge it was pulling.
                    const horizontal = h === 'e' || h === 'w' ? true
                        : h === 'n' || h === 's' ? false
                            : Math.abs(dx) > Math.abs(dy);
                    if (horizontal) height = width / ratio;
                    else width = height * ratio;
                }
                // Anchor the west/north edges only once the FINAL size is known —
                // doing it before the ratio adjustment let the box drift on shift-resize.
                if (h.includes('w')) x = o.x + (o.width - width);
                if (h.includes('n')) y = o.y + (o.height - height);
                updateElement(drag.id, {
                    x: Math.round(x), y: Math.round(y),
                    width: Math.round(width), height: Math.round(height),
                }, { skipHistory: true });
                return;
            }

            // ---- Crop gestures ------------------------------------------------
            // The pointer travels in scene space; a rotated picture has to be moved
            // along ITS axes, so the delta is turned back by the element's rotation.
            if (drag.type === 'cropPan') {
                const rad = (-(drag.rotation || 0) * Math.PI) / 180;
                const rx = pt.x - drag.start.x;
                const ry = pt.y - drag.start.y;
                let dx = rx * Math.cos(rad) - ry * Math.sin(rad);
                let dy = rx * Math.sin(rad) + ry * Math.cos(rad);
                // ⇧ slides the picture along one axis only — same lock as a move drag.
                if (e.shiftKey) {
                    if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
                    else dx = 0;
                }
                updateElement(drag.id, {
                    crop: moveCrop(drag.crop, -dx / drag.picture.w, -dy / drag.picture.h),
                }, { skipHistory: true });
                return;
            }

            if (drag.type === 'cropScale') {
                const dist = Math.hypot(pt.x - drag.center.x, pt.y - drag.center.y);
                updateElement(drag.id, {
                    crop: scaleCrop(drag.crop, dist / drag.startDist),
                }, { skipHistory: true });
                return;
            }

            if (drag.type === 'cropResize') {
                const o = drag.origin;
                let { x, y, width, height } = o;
                const dx = pt.x - drag.start.x;
                const dy = pt.y - drag.start.y;
                const h = drag.handle;
                if (h.includes('e')) width = Math.max(20, o.width + dx);
                if (h.includes('s')) height = Math.max(20, o.height + dy);
                if (h.includes('w')) width = Math.max(20, o.width - dx);
                if (h.includes('n')) height = Math.max(20, o.height - dy);
                if (h.includes('w')) x = o.x + (o.width - width);
                if (h.includes('n')) y = o.y + (o.height - height);
                const rx = Math.round(x);
                const ry = Math.round(y);
                const rw = Math.round(width);
                const rh = Math.round(height);
                // The new picture box, still measured from the ORIGINAL element origin
                // — the frame the picture is seen through after this drag.
                const i = drag.insets;
                const box = {
                    x: rx - o.x + i.left,
                    y: ry - o.y + i.top,
                    w: Math.max(1, rw - i.left - i.right),
                    h: Math.max(1, rh - i.top - i.bottom),
                };
                updateElement(drag.id, {
                    x: rx, y: ry, width: rw, height: rh,
                    crop: cropFromPicture(box, drag.picture),
                }, { skipHistory: true });
                return;
            }

            if (drag.type === 'rotate') {
                const angle = Math.atan2(pt.y - drag.cy, pt.x - drag.cx) * (180 / Math.PI);
                let rot = drag.originRot + (angle - drag.startAngle);
                if (e.shiftKey) rot = Math.round(rot / 15) * 15;
                updateElement(drag.id, { rotation: Math.round(rot) }, { skipHistory: true });
                return;
            }

            if (drag.type === 'gradientAngle' || drag.type === 'gradientCenter') {
                const local = toLocalPoint(pt, drag.box, drag.rotation);
                let patch = null;
                if (drag.type === 'gradientAngle') {
                    let deg = angleFromLocalPoint(local, drag.box.width, drag.box.height, drag.which);
                    if (deg == null) return;
                    if (e.shiftKey) deg = (Math.round(deg / 15) * 15) % 360;
                    patch = { gradientAngle: Math.round(deg) };
                } else {
                    const c = centerPercentFromLocal(local, drag.box.width, drag.box.height);
                    patch = { gradientCenterX: c.x, gradientCenterY: c.y };
                }
                // A frame keeps its gradient at its top level; an element's lives in styles.
                if (drag.kind === 'frame') {
                    usePrintEditorStore.getState().setFrame(drag.id, patch, { skipHistory: true });
                } else {
                    updateElement(drag.id, { styles: patch }, { skipHistory: true });
                }
                return;
            }

            if (drag.type === 'groupScale') {
                const dist = Math.hypot(pt.x - drag.anchor.x, pt.y - drag.anchor.y);
                const minS = Math.max(0.02, 4 / Math.max(4, drag.minDim));
                const s = Math.min(40, Math.max(minS, dist / drag.startDist));
                const patches = {};
                drag.origins.forEach((o) => {
                    const patch = {
                        x: Math.round((drag.anchor.x + (o.x - drag.anchor.x) * s) * 100) / 100,
                        y: Math.round((drag.anchor.y + (o.y - drag.anchor.y) * s) * 100) / 100,
                        width: Math.max(1, Math.round(o.width * s * 100) / 100),
                        height: Math.max(1, Math.round(o.height * s * 100) / 100),
                    };
                    if (o.fontSize != null) {
                        patch.styles = { fontSize: Math.max(2, Math.round(o.fontSize * s * 10) / 10) };
                    }
                    patches[o.id] = patch;
                });
                updateElements(patches, { skipHistory: true });
            }
        };

        const onUp = () => {
            const drag = dragRef.current;
            setMarquee(null);
            if (!drag) return;
            // A press inside the selection that never moved was a plain click on
            // whatever sits on top — select it now (see onElementPointerDown).
            if (drag.pendingSelect) {
                usePrintEditorStore.getState().select(drag.pendingSelect);
            }
            // A press on blank artboard that never became a band was a click on the
            // frame — select it (see onViewportPointerDown).
            if (drag.type === 'marquee' && !drag.moved && drag.clickSelectsFrame) {
                usePrintEditorStore.getState().selectFrame(drag.clickSelectsFrame);
            }
            if ([
                'move', 'resize', 'rotate', 'groupScale', 'frameResize', 'frameMove',
                'cropResize', 'cropPan', 'cropScale', 'gradientAngle', 'gradientCenter',
            ].includes(drag.type)) {
                usePrintEditorStore.getState().commitHistorySnapshot(drag.historyBefore);
            }
            dragRef.current = null;
            setGuides({ v: [], h: [] });
            setIsPanning(false);
        };

        window.addEventListener('pointermove', onMove);
        window.addEventListener('pointerup', onUp);
        return () => {
            window.removeEventListener('pointermove', onMove);
            window.removeEventListener('pointerup', onUp);
        };
    }, [clientToCanvas, elements, otherEdges, snap, setPan, updateElement, updateElements]);

    // Figma-style wheel: pan by default, zoom toward cursor with ctrl/cmd (trackpad pinch)
    useEffect(() => {
        const el = viewportRef.current;
        if (!el) return;
        const onWheel = (e) => {
            e.preventDefault();
            const rect = el.getBoundingClientRect();
            const ox = e.clientX - rect.left;
            const oy = e.clientY - rect.top;
            const state = usePrintEditorStore.getState();

            // Trackpad pinch / ctrl+wheel → zoom toward cursor
            if (e.ctrlKey || e.metaKey) {
                const factor = Math.exp(-e.deltaY * 0.01);
                state.zoomAt(state.zoom * factor, ox, oy);
                return;
            }

            // Shift+wheel → horizontal pan
            if (e.shiftKey) {
                state.setPan(state.panX - e.deltaY, state.panY);
                return;
            }

            // Normal scroll → pan (also handles trackpad two-finger scroll)
            state.setPan(state.panX - e.deltaX, state.panY - e.deltaY);
        };
        el.addEventListener('wheel', onWheel, { passive: false });
        return () => el.removeEventListener('wheel', onWheel);
    }, []);

    const onDoubleClick = (el) => {
        if (readOnly || isLockedFor(el, mode) || panActive) return;
        // Figma: double-click drills into the group and selects the child.
        // Once inside (or for ungrouped elements), double-click edits text.
        const store = usePrintEditorStore.getState();
        const isTextLike = el.type === ELEMENT_TYPES.TEXT || el.type === ELEMENT_TYPES.PLACEHOLDER;
        const canEditText = isTextLike
            && !(mode === 'user' && el.editableByUser === false)
            // Words the admin closed off are not typed over on the canvas either.
            && !isSectionLocked(el, 'content', mode);

        if (el.groupId && el.groupId !== store.enteredGroupId) {
            // Drill in ONE nesting level and select whatever lives at that level —
            // a sub-layer stays selected as a unit until you drill into it too.
            // A second double-click (now inside) continues deeper / starts editing.
            const next = store.resolveEnterTarget(el.id);
            store.enterGroup(next);
            select(store.resolveSelectionTarget(el.id));
            return;
        }
        // Double-click on a path opens its ANCHORS — the vector equivalent of
        // double-clicking text to type in it. A second one leaves again.
        if (mode === 'admin' && el.type === ELEMENT_TYPES.SHAPE) {
            if (store.editingPathId === el.id) store.stopPathEdit();
            else store.startPathEdit(el.id);
            return;
        }
        // Double-click on a picture crops it, the way double-click on text edits it.
        // A second double-click (now inside crop mode) finishes.
        if (el.type === ELEMENT_TYPES.IMAGE && el.src) {
            if (store.croppingId === el.id) store.stopCrop();
            else store.startCrop(el.id);
            return;
        }
        if (canEditText) {
            // A new editing session starts with no character range of its own.
            setTextSelection(null);
            setEditingId(el.id);
        }
    };

    /* ------------------------------------------------------------------ *
     *  Right-click menu
     *
     *  Right-clicking an element SELECTS it first (unless it is already part of
     *  the selection) — the same rule every design tool follows, and the reason
     *  the menu can be built from `selectedIds` alone.
     * ------------------------------------------------------------------ */

    /** Document as it stood before the running anchor gesture — one undo per drag. */
    const pathHistoryRef = useRef(null);

    const [menuAt, setMenuAt] = useState(null);
    const menuPointRef = useRef(null);
    const closeMenu = useCallback(() => setMenuAt(null), []);

    const onContextMenu = (e) => {
        if (readOnly) return;
        e.preventDefault();
        e.stopPropagation();
        const store = usePrintEditorStore.getState();
        const node = e.target.closest?.('[data-element-id]');
        const id = node?.dataset?.elementId || null;
        // EVERY layer under the pointer, topmost first — the menu lists them so a
        // layer buried under a bigger one can be picked without moving anything.
        // Taken from the DOM rather than from box maths, so it honours what is
        // actually painted there: masks, clips and rotation all come out right.
        const stack = (document.elementsFromPoint(e.clientX, e.clientY) || [])
            .map((n) => n.closest?.('[data-element-id]')?.dataset?.elementId)
            .filter(Boolean);
        const under = [...new Set(stack)];
        /**
         * A right-click NEVER re-picks while something is selected: the menu is
         * about the SELECTED layer, not about whatever happens to be painted over
         * it. Overlapping layers made the old rule (keep only when the selection is
         * itself in the stack under the pointer) unusable — a layer chosen by name
         * in the Layers list lost the menu to whatever covered it as soon as the
         * pointer strayed outside its own box, which is most of the area whenever
         * the layer on top is the bigger one.
         *
         * Switching is still one click away: every layer under the pointer is
         * listed under "Select layer", ticked on the one the menu is about.
         */
        const keepSelection = store.selectedIds.length > 0;
        if (id && !keepSelection) {
            exitTextEditing();
            select(store.resolveSelectionTarget(id));
        } else if (!id && !keepSelection) {
            exitTextEditing();
        }
        menuPointRef.current = clientToCanvas(e.clientX, e.clientY);
        // Empty canvas gets the CANVAS menu even when something is still selected:
        // the menu describes what was clicked, which is how a right-click reads.
        setMenuAt({ x: e.clientX, y: e.clientY, onElement: !!id, under });
    };

    /**
     * "Select layer" — every layer under the pointer, topmost first, plus the frame
     * under them all.
     *
     * The answer to "a photo covers the shape and I have to move it to get at what
     * is underneath": pick the layer from the list instead. The frame is last for
     * the same reason — it is what sits under everything.
     */
    const layerPickItems = (under = []) => {
        const store = usePrintEditorStore.getState();
        const rows = under
            .map((id) => elements.find((el) => el.id === id))
            .filter((el) => el && el.visible !== false)
            .slice(0, 8)
            .map((el) => ({
                label: el.name || el.type,
                icon: elementIcon(el),
                // A tick marks what is selected now, so the list reads as "you are
                // here, and these are the layers under your pointer".
                shortcut: store.selectedIds.includes(el.id) ? '✓' : undefined,
                onSelect: () => {
                    exitTextEditing();
                    select(store.resolveSelectionTarget(el.id));
                },
            }));
        const frame = frames.find((f) => (
            menuPointRef.current
            && menuPointRef.current.x >= f.x && menuPointRef.current.x <= f.x + f.width
            && menuPointRef.current.y >= f.y && menuPointRef.current.y <= f.y + f.height
        ));
        if (frame && mode === 'admin') {
            rows.push({
                label: frame.name || 'Frame',
                icon: IconFrame,
                shortcut: selectedFrameId === frame.id ? '✓' : undefined,
                onSelect: () => { exitTextEditing(); selectFrame(frame.id); },
            });
        }
        return rows;
    };

    const menuItems = (() => {
        const store = usePrintEditorStore.getState();
        const ids = store.selectedIds;
        const chosen = elements.filter((el) => ids.includes(el.id));
        const some = chosen.length > 0 && menuAt?.onElement;
        const at = menuPointRef.current;
        // Layers under the pointer — offered in BOTH menus: on empty canvas it is
        // how the frame gets picked, over a layer it is how the one underneath does.
        // The menu's own subject leads the list even when the pointer is not over
        // it, so the tick always says what these rows will act on.
        const picks = layerPickItems([...new Set([...ids, ...(menuAt?.under || [])])]);
        const pickSection = picks.length > 1
            ? [{ separator: true }, { label: 'Select layer', header: true }, ...picks]
            : [];

        if (!some) {
            return [
                {
                    label: 'Paste here',
                    shortcut: '$mod V',
                    icon: IconClipboard,
                    onSelect: () => pasteHere(store, at),
                },
                ...pickSection,
                { separator: true },
                {
                    label: 'Select all',
                    shortcut: '$mod A',
                    icon: IconSelectAll,
                    onSelect: () => select(elements.map((el) => el.id)),
                },
                {
                    label: 'Add frame',
                    shortcut: 'F',
                    icon: IconFrame,
                    disabled: mode !== 'admin',
                    onSelect: () => store.addFrame(),
                },
                {
                    label: 'Fit to screen',
                    shortcut: '$mod 0',
                    icon: IconMaximize,
                    onSelect: () => {
                        const rect = viewportRef.current?.getBoundingClientRect();
                        if (rect) fitToView(rect.width, rect.height, { padding: 72 });
                    },
                },
            ];
        }

        return buildElementMenu({ store, mode, ids, at, extra: pickSection });
    })();

    const cursor = isPanning
        ? 'grabbing'
        : panActive
            ? 'grab'
            // The drawing tools set their own cursor in CSS (ui/drawCursors); this
            // one covers the empty workspace outside the scene.
            : drawTool ? (tool === 'pencil' ? PENCIL_CURSOR : PEN_CURSOR)
                : 'default';

    // Screen-constant chrome sizes (selection outline, handles) regardless of zoom
    const px = (n) => n / Math.max(zoom, 0.01);

    return (
        <div
            ref={viewportRef}
            className={`relative flex-1 select-none bg-[#f5f5f5] dark:bg-[#1e1e1e] ${isExporting ? 'overflow-visible' : 'overflow-hidden'}`}
            onPointerDownCapture={onDrawPointerDown}
            onPointerDown={onViewportPointerDown}
            onContextMenu={onContextMenu}
            onDragEnter={onDragEnter}
            onDragOver={onDragOver}
            onDragLeave={onDragLeave}
            onDrop={onDrop}
            style={{ cursor }}
            data-workspace="1"
        >
            {/* Transform layer */}
            <div
                /* While the pen or pencil is armed the whole scene is a drawing
                   surface, and the cursor is the tool itself — see ui/drawCursors,
                   which owns the rule that beats every layer's own cursor. */
                data-draw-tool={drawTool ? tool : undefined}
                className="absolute origin-top-left will-change-transform"
                style={{
                    transform: isExporting
                        ? 'none'
                        : `translate(${panX}px, ${panY}px) scale(${zoom})`,
                }}
            >
                {/* Rubber-band selection. Lives in the transform layer so the rect is
                    plain canvas coordinates, with its border scaled back down so the
                    outline stays 1px on screen at any zoom. */}
                <DrawingOverlay draft={draft} zoom={zoom} />
                {marquee && (
                    <div
                        aria-hidden
                        data-export-ignore="1"
                        className="absolute pointer-events-none z-30"
                        style={{
                            left: marquee.x,
                            top: marquee.y,
                            width: marquee.w,
                            height: marquee.h,
                            border: `${px(1)}px solid ${FIGMA_BLUE}`,
                            background: 'rgba(13,153,255,0.08)',
                        }}
                    />
                )}

                {/* Frames — every artboard in the document, side by side on one scene.
                    Each draws its own plate, label, background and clip; the selection
                    chrome further down is a SIBLING of all of them, in scene
                    coordinates, so it is never clipped by a frame. */}
                {frames.map((frame) => {
                    const frameSelected = selectedFrameId === frame.id;
                    const members = elements.filter((el) => el.frameId === frame.id);
                    return (
                        <div
                            key={frame.id}
                            className="absolute pointer-events-none"
                            style={{ left: frame.x, top: frame.y, width: frame.width, height: frame.height }}
                        >
                            {/* Soft drop shadow plate under the artboard */}
                            <div
                                aria-hidden
                                className="absolute inset-0 pointer-events-none"
                                style={{
                                    boxShadow: `0 0 0 ${px(1)}px rgba(0,0,0,0.05), 0 ${px(2)}px ${px(10)}px rgba(0,0,0,0.10)`,
                                }}
                            />

                            {/* Frame label above the artboard (Figma-style) — click to
                                select the frame and edit its size preset. Hidden in a
                                read-only view: presenting shows the artwork, not the
                                editor's furniture. */}
                            {!isExporting && !readOnly && (
                                <button
                                    type="button"
                                    data-export-ignore="1"
                                    title={mode === 'admin'
                                        ? `${frame.name} — click to edit, drag to move the frame, ⌥/Alt-drag to duplicate it`
                                        : frame.name}
                                    onPointerDown={(e) => {
                                        if (e.button !== 0) return;
                                        e.stopPropagation();
                                        if (mode === 'admin') onFrameMovePointerDown(e, frame);
                                    }}
                                    onClick={() => {
                                        stopEditing();
                                        if (mode === 'admin') selectFrame(frame.id);
                                        else clearSelection();
                                    }}
                                    className={`absolute whitespace-nowrap font-medium pointer-events-auto ${
                                        mode === 'admin' ? 'cursor-move' : ''
                                    } ${
                                        frameSelected
                                            ? 'text-[#0d99ff]'
                                            : 'text-black/40 dark:text-white/40 hover:text-[#0d99ff]'
                                    }`}
                                    style={{
                                        left: 0,
                                        top: -px(22),
                                        fontSize: px(11),
                                        lineHeight: 1.4,
                                    }}
                                >
                                    {frame.name}
                                    <span className="opacity-60">{'  '}{frame.width} × {frame.height}</span>
                                </button>
                            )}

                            {/* The artboard itself (the printable preset) */}
                            <div
                                data-frame-id={frame.id}
                                data-canvas-bg="1"
                                data-print-canvas="1"
                                className="relative w-full h-full"
                                style={{
                                    background: resolveCanvasBackground(frame),
                                    pointerEvents: readOnly ? 'none' : 'auto',
                                    outline: frameSelected ? `${px(1.5)}px solid ${FIGMA_BLUE}` : undefined,
                                    outlineOffset: 0,
                                }}
                                onPointerDown={(e) => {
                                    if (panActive) return;
                                    // Left button only. A right-press here used to select
                                    // the FRAME — wiping the element selection a moment
                                    // before the context menu read it, which is how a menu
                                    // opened on a layer picked in the Layers list ended up
                                    // about whatever was painted on top instead.
                                    if (e.button !== 0) return;
                                    stopEditing();
                                    // Everything else is the viewport's (onViewportPointerDown,
                                    // which this bubbles up to): a drag on blank artboard is a
                                    // marquee over the layers inside the frame, a click selects
                                    // the frame (admin) or clears the selection (user), and ⌥
                                    // pans. This used to grab the press for the admin — a first
                                    // press selected the frame, a second one DRAGGED it — so a
                                    // marquee could only ever start outside the artboard. The
                                    // frame moves by its label now, and only by its label.
                                }}
                            >
                                {/* Content layer. "Clip content" hides whatever hangs past the
                                    frame edge, matching what export produces (its canvas is
                                    exactly the frame size). Click-through, so a press on blank
                                    artboard reaches the frame below. */}
                                <div
                                    className={`absolute inset-0 pointer-events-none
                                        ${frame.clipContent ? 'overflow-hidden' : ''}`}
                                >
                                    {/* Silhouettes CSS cannot describe — glyphs,
                                        QR modules, barcode bars. Zero-sized, so it
                                        takes no room in the layer stack. */}
                                    <MaskDefs elements={members} />
                                    {members.map((el) => {
                                        if (!el.visible) return null;
                                        // A mask paints nothing of its own — only its silhouette,
                                        // through the layers above it (see utils/elementMask). It
                                        // stays in the Layers list, which is where it is selected,
                                        // renamed, moved and released.
                                        if (isMaskElement(el)) return null;
                                        // Whole-group selections draw one box — hide per-member outlines
                                        const isSelected = selectedIds.includes(el.id)
                                            && !(fullGroup && (el.groupId || null) === fullGroup.gid);
                                        const maskCss = maskCssFor(el, maskFor(el, elements));
                                        const flip = (el.flipX ? ' scaleX(-1)' : '') + (el.flipY ? ' scaleY(-1)' : '');
                                        return (
                                            <div
                                                key={el.id}
                                                data-element-id={el.id}
                                                className={`absolute ${isLockedFor(el, mode)
                                                    ? 'cursor-not-allowed'
                                                    : panActive ? ''
                                                        // Pinned by a Position lock: still clickable, just not draggable.
                                                        : isSectionLocked(el, 'position', mode) ? 'cursor-default' : 'cursor-move'}`}
                                                style={{
                                                    // Elements carry SCENE coordinates; the frame is
                                                    // the positioned box they live in.
                                                    left: el.x - frame.x,
                                                    top: el.y - frame.y,
                                                    width: el.width,
                                                    height: el.height,
                                                    opacity: el.opacity ?? 1,
                                                    // Flip mirrors the PAINT, never the box — the
                                                    // geometry every handle and snap reads is
                                                    // untouched (see store flipSelected).
                                                    transform: `${el.rotation ? `rotate(${el.rotation}deg)` : ''}${flip}`.trim() || undefined,
                                                    ...maskCss,
                                                    mixBlendMode: el.styles?.mixBlendMode || 'normal',
                                                    zIndex: el.zIndex ?? 0,
                                                    pointerEvents: readOnly ? 'none' : 'auto',
                                                    outline: isSelected ? `${px(1.5)}px solid ${FIGMA_BLUE}` : undefined,
                                                    outlineOffset: 0,
                                                }}
                                                onPointerDown={(e) => onElementPointerDown(e, el)}
                                                onDoubleClick={() => onDoubleClick(el)}
                                            >
                                                {/* Background blur / Glass frost — the backdrop
                                                    seen THROUGH the element, so it is painted
                                                    under its content and clipped to its shape
                                                    (a frosted ellipse is not a frosted square).
                                                    Outside the filtered layer below, because a
                                                    filter is a backdrop root and a backdrop-filter
                                                    inside one samples nothing. */}
                                                <ElementBackdrop element={el} />
                                                {/* Layer blur + colour adjustments apply to the
                                                    element's own paint, overlays included — one
                                                    layer, exactly as the export composites it. */}
                                                <div
                                                    style={{
                                                        position: 'absolute',
                                                        inset: 0,
                                                        filter: buildCssFilter(el.styles),
                                                    }}
                                                >
                                                <ElementRenderer
                                                    element={el}
                                                    isEditing={editingId === el.id}
                                                    // Show a text element's curve while it is selected,
                                                    // the way Figma does. Never while exporting.
                                                    showPathGuide={isSelected && !isExporting && !readOnly}
                                                    // Live-sync canvas typing into the store (skipHistory so a
                                                    // sentence isn't one undo step per character); blur commits.
                                                    onTextInput={(value) => updateElement(el.id, { content: value }, { skipHistory: true })}
                                                    onCommitText={(value) => {
                                                        updateElement(el.id, { content: value });
                                                        stopEditing();
                                                    }}
                                                    // Which characters are selected right now — the
                                                    // properties panel colours exactly those.
                                                    onTextSelect={(range) => setTextSelection({ id: el.id, ...range })}
                                                    // ElementRenderer decides when: text blocks hug their content
                                                    // (grow and shrink); hand-resized ones opt out entirely.
                                                    onAutoSize={(size) => updateElement(el.id, size, { skipHistory: true })}
                                                    // A hugging box stops at the artboard's width and wraps
                                                    // instead — on a page, a box that grows past the paper is
                                                    // never what the author meant.
                                                    maxAutoWidth={frame.width}
                                                />
                                                <ElementEffectsOverlay element={el} />
                                                </div>
                                            </div>
                                        );
                                    })}
                                </div>
                            </div>

                            {/* Frame resize grips — drag this frame's right / bottom edge or
                                its corner. Anchored top-left, so resizing a frame never
                                moves anything already on it. Shown on the selected frame
                                only, so a scene of frames isn't covered in grips. */}
                            {!readOnly && !panActive && !isExporting && mode === 'admin'
                                && frameSelected && (() => {
                                const W = frame.width;
                                const H = frame.height;
                                const t = px(6);   // grip thickness, screen-constant
                                const len = px(28);
                                // Sit entirely OUTSIDE the artboard. These render above every
                                // element (z 9998), so any overlap would swallow clicks meant
                                // for elements near the right/bottom edge.
                                const gap = px(7);
                                // Aspect-locked frame: corner only, for the same reason as a
                                // locked element — an edge grip can only stretch one axis.
                                const lockedRatio = !!frame.lockAspect;
                                const grip = (key, cursor, style) => (
                                    <div
                                        key={key}
                                        data-export-ignore="1"
                                        title={lockedRatio
                                            ? 'Drag to resize the frame — proportions locked'
                                            : 'Drag to resize the frame'}
                                        onPointerDown={(ev) => onFrameHandlePointerDown(ev, key, frame)}
                                        className="absolute pointer-events-auto rounded-full bg-[#0d99ff] opacity-50 hover:opacity-100 transition-opacity"
                                        style={{ cursor, zIndex: 9998, ...style }}
                                    />
                                );
                                return (
                                    <>
                                        {!lockedRatio && grip('e', 'ew-resize', { left: W + gap, top: H / 2 - len / 2, width: t, height: len })}
                                        {!lockedRatio && grip('s', 'ns-resize', { left: W / 2 - len / 2, top: H + gap, width: len, height: t })}
                                        {grip('se', 'nwse-resize', { left: W + gap, top: H + gap, width: t * 2, height: t * 2 })}
                                    </>
                                );
                            })()}

                            {/* The artboard's own background can be a gradient too — same
                                gizmo, aimed at the frame's keys instead of an element's. */}
                            {!readOnly && !panActive && !isExporting && mode === 'admin'
                                && frameSelected && !frame.gradientLineHidden && isGradientFill(frame) && (
                                <GradientGizmo
                                    paint={frame}
                                    width={frame.width}
                                    height={frame.height}
                                    px={px}
                                    onHandleDown={(ev, which) => onGradientPointerDown(ev, which, {
                                        kind: 'frame',
                                        node: frame,
                                    })}
                                />
                            )}
                        </div>
                    );
                })}

                {/* Anchor editing — a path's own nodes and handles, in place of the
                    selection box it would otherwise wear. */}
                {mode === 'admin' && !readOnly && !panActive && editingPathId && (() => {
                    const el = elements.find((e) => e.id === editingPathId);
                    if (!el || !el.path?.nodes?.length || isLockedFor(el, mode)) return null;
                    return (
                        <PathEditOverlay
                            key={el.id}
                            element={el}
                            px={px}
                            clientToCanvas={clientToCanvas}
                            onExit={() => usePrintEditorStore.getState().stopPathEdit()}
                            onChange={(nodes, closed, { done }) => {
                                const store = usePrintEditorStore.getState();
                                // One undo per gesture, not per pointer sample: the
                                // document before the FIRST move is the snapshot, and
                                // it is committed when the gesture lets go.
                                if (!pathHistoryRef.current) pathHistoryRef.current = cloneDoc(store.document);
                                store.setPathNodes(el.id, nodes, closed, { skipHistory: true });
                                if (done) {
                                    store.commitHistorySnapshot(pathHistoryRef.current);
                                    pathHistoryRef.current = null;
                                }
                            }}
                        />
                    );
                })()}

                {/* Selection handles + size chip */}
                    {!readOnly && !panActive && selectedEls.length === 1 && (() => {
                        const el = selectedEls[0];
                        if (isLockedFor(el, mode)) return null;
                        if (mode === 'user' && el.editableByUser === false) return null;
                        // Crop mode draws its own handles over this picture, and
                        // node editing its own anchors over this path.
                        if (el.id === croppingId || el.id === editingPathId) return null;
                        const hs = px(8); // handle size, screen-constant
                        // All eight grips, on everything. An aspect-locked layer used
                        // to show corners only, on the grounds that an edge can only
                        // stretch one axis — but that is a statement about the maths,
                        // not about the gesture: an edge drag on a locked layer scales
                        // it PROPORTIONALLY, exactly as Figma does, so the grip has a
                        // perfectly good job to do. Hiding it left images (locked by
                        // default) resizable from four points out of eight.
                        // A user whose Layout section is locked gets no size grips at
                        // all, and no rotate hotspots when Position is locked: a grip
                        // that refuses the drag is worse than no grip.
                        const noResize = isSectionLocked(el, 'layout', mode);
                        const noRotate = isSectionLocked(el, 'position', mode);
                        const sizeHandles = noResize ? [] : HANDLES;
                        return (
                            <div
                                className="absolute pointer-events-none"
                                data-export-ignore="1"
                                style={{
                                    left: el.x,
                                    top: el.y,
                                    width: el.width,
                                    height: el.height,
                                    transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
                                    zIndex: 9999,
                                }}
                            >
                                {/* Rotate hotspots — invisible zones outside each corner */}
                                {(noRotate ? [] : ROTATE_CORNERS).map((corner) => (
                                    <div
                                        key={`rot-${corner}`}
                                        title="Rotate"
                                        aria-label={`Rotate from ${corner}`}
                                        className="absolute pointer-events-auto cursor-grab active:cursor-grabbing"
                                        style={rotateHotspotStyle(corner, el.width, el.height, zoom)}
                                        onPointerDown={onRotatePointerDown}
                                    />
                                ))}
                                {sizeHandles.map((h) => (
                                    <div
                                        key={h}
                                        className="absolute bg-white pointer-events-auto"
                                        style={{
                                            cursor: handleCursor(h),
                                            width: hs,
                                            height: hs,
                                            borderRadius: px(1.5),
                                            border: `${px(1.25)}px solid ${FIGMA_BLUE}`,
                                            boxShadow: `0 0 ${px(2)}px rgba(0,0,0,0.18)`,
                                            left: h.includes('w') ? -hs / 2 : h.includes('e') ? el.width - hs / 2 : el.width / 2 - hs / 2,
                                            top: h.includes('n') ? -hs / 2 : h.includes('s') ? el.height - hs / 2 : el.height / 2 - hs / 2,
                                        }}
                                        onPointerDown={(ev) => onHandlePointerDown(ev, h)}
                                    />
                                ))}
                                {/* Gradient gizmo — the line (or centre dot) that aims the
                                    paint, drawn only while a gradient fill is on and the
                                    Fill section is the user's to change. */}
                                {mode === 'admin' && !el.styles?.gradientLineHidden && isGradientFill(el.styles) && (
                                    <GradientGizmo
                                        paint={el.styles || {}}
                                        width={el.width}
                                        height={el.height}
                                        px={px}
                                        onHandleDown={(ev, which) => onGradientPointerDown(ev, which, {
                                            kind: 'element',
                                            node: el,
                                        })}
                                    />
                                )}
                                {/* Dimensions chip below selection (Figma) */}
                                <div
                                    className="absolute flex justify-center pointer-events-none"
                                    style={{ left: 0, right: 0, top: el.height + px(8) }}
                                >
                                    <span
                                        className="text-white font-medium tabular-nums"
                                        style={{
                                            backgroundColor: FIGMA_BLUE,
                                            fontSize: px(10),
                                            lineHeight: 1.5,
                                            padding: `${px(1)}px ${px(4)}px`,
                                            borderRadius: px(2),
                                            // keep the label horizontal even when the element is rotated
                                            transform: el.rotation ? `rotate(${-el.rotation}deg)` : undefined,
                                        }}
                                    >
                                        {Math.round(el.width)} × {Math.round(el.height)}
                                    </span>
                                </div>
                            </div>
                        );
                    })()}

                    {/* Crop overlay (Figma): the whole picture dimmed, the kept part crisp,
                        white grips on the frame and blue grips on the picture. Drawn in
                        scene coordinates like the rest of the chrome, so a frame's clip
                        never cuts it off. */}
                    {!readOnly && !panActive && !isExporting && cropping && (() => {
                        const el = cropEl;
                        const hs = px(8);
                        const box = innerRect(el);
                        const pic = pictureRect(el, cropRect);
                        // The palette-recoloured bitmap when there is one (already decoded
                        // for the element itself), so cropping never flashes a different
                        // colourway than the canvas underneath.
                        const src = peekRecoloredImage(el.src, el.paletteId) || el.src;
                        return (
                            <div
                                className="absolute pointer-events-none"
                                data-export-ignore="1"
                                style={{
                                    left: el.x,
                                    top: el.y,
                                    width: el.width,
                                    height: el.height,
                                    transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
                                    zIndex: 9999,
                                }}
                            >
                                {/* What cropping is hiding */}
                                <img
                                    src={src}
                                    alt=""
                                    draggable={false}
                                    className="absolute select-none"
                                    style={{
                                        left: pic.x,
                                        top: pic.y,
                                        width: pic.w,
                                        height: pic.h,
                                        maxWidth: 'none',
                                        opacity: 0.35,
                                    }}
                                />
                                {/* What it keeps — the same pixels the element draws, redrawn
                                    on top of the dim copy so the frame reads crisp. */}
                                <div
                                    className="absolute overflow-hidden"
                                    style={{ left: box.x, top: box.y, width: box.w, height: box.h }}
                                >
                                    <img
                                        src={src}
                                        alt=""
                                        draggable={false}
                                        className="select-none"
                                        style={{
                                            position: 'absolute',
                                            left: pic.x - box.x,
                                            top: pic.y - box.y,
                                            width: pic.w,
                                            height: pic.h,
                                            maxWidth: 'none',
                                        }}
                                    />
                                </div>
                                {/* Drag anywhere inside the frame to reposition the picture */}
                                <div
                                    title="Drag to reposition the picture"
                                    className="absolute pointer-events-auto cursor-move"
                                    style={{
                                        left: box.x,
                                        top: box.y,
                                        width: box.w,
                                        height: box.h,
                                        outline: `${px(1.5)}px solid ${FIGMA_BLUE}`,
                                    }}
                                    onPointerDown={(ev) => onCropPointerDown(ev, 'cropPan')}
                                />
                                {/* Picture grips — scale the picture inside the frame */}
                                {CORNER_HANDLES.map((c) => {
                                    const r = px(5);
                                    return (
                                        <div
                                            key={`pic-${c}`}
                                            title="Drag to resize the picture inside the frame"
                                            className="absolute pointer-events-auto rounded-full"
                                            style={{
                                                cursor: handleCursor(c),
                                                width: r * 2,
                                                height: r * 2,
                                                background: FIGMA_BLUE,
                                                border: `${px(1.25)}px solid #ffffff`,
                                                boxShadow: `0 0 ${px(2)}px rgba(0,0,0,0.25)`,
                                                left: (c.includes('w') ? pic.x : pic.x + pic.w) - r,
                                                top: (c.includes('n') ? pic.y : pic.y + pic.h) - r,
                                            }}
                                            onPointerDown={(ev) => onCropPointerDown(ev, 'cropScale', c)}
                                        />
                                    );
                                })}
                                {/* Frame grips — every side, since cropping is what breaks the
                                    picture's proportions loose from the box on purpose */}
                                {HANDLES.map((h) => (
                                    <div
                                        key={`crop-${h}`}
                                        title="Drag to crop"
                                        className="absolute bg-white pointer-events-auto"
                                        style={{
                                            cursor: handleCursor(h),
                                            width: hs,
                                            height: hs,
                                            borderRadius: px(1.5),
                                            border: `${px(1.25)}px solid ${FIGMA_BLUE}`,
                                            boxShadow: `0 0 ${px(2)}px rgba(0,0,0,0.18)`,
                                            left: h.includes('w') ? -hs / 2 : h.includes('e') ? el.width - hs / 2 : el.width / 2 - hs / 2,
                                            top: h.includes('n') ? -hs / 2 : h.includes('s') ? el.height - hs / 2 : el.height / 2 - hs / 2,
                                        }}
                                        onPointerDown={(ev) => onCropPointerDown(ev, 'cropResize', h)}
                                    />
                                ))}
                                <div
                                    className="absolute flex justify-center pointer-events-none"
                                    style={{ left: 0, right: 0, top: el.height + px(8) }}
                                >
                                    <span
                                        className="text-white font-medium tabular-nums"
                                        style={{
                                            backgroundColor: FIGMA_BLUE,
                                            fontSize: px(10),
                                            lineHeight: 1.5,
                                            padding: `${px(1)}px ${px(4)}px`,
                                            borderRadius: px(2),
                                            transform: el.rotation ? `rotate(${-el.rotation}deg)` : undefined,
                                        }}
                                    >
                                        {Math.round(el.width)} × {Math.round(el.height)}
                                    </span>
                                </div>
                            </div>
                        );
                    })()}

                    {/* Group selection box — one bounding box, corner handles scale uniformly */}
                    {!readOnly && !panActive && fullGroup && (() => {
                        const hs = px(8);
                        const corners = ['nw', 'ne', 'se', 'sw'];
                        return (
                            <div
                                className="absolute pointer-events-none"
                                data-export-ignore="1"
                                style={{
                                    left: fullGroup.x,
                                    top: fullGroup.y,
                                    width: fullGroup.width,
                                    height: fullGroup.height,
                                    zIndex: 9999,
                                    outline: `${px(1.5)}px solid ${FIGMA_BLUE}`,
                                    outlineOffset: 0,
                                }}
                            >
                                {/* Group scaling stays admin-only — end users move the group, not resize it */}
                                {mode === 'admin' && corners.map((c) => (
                                    <div
                                        key={c}
                                        className="absolute bg-white pointer-events-auto"
                                        style={{
                                            cursor: handleCursor(c),
                                            width: hs,
                                            height: hs,
                                            borderRadius: px(1.5),
                                            border: `${px(1.25)}px solid ${FIGMA_BLUE}`,
                                            boxShadow: `0 0 ${px(2)}px rgba(0,0,0,0.18)`,
                                            left: c.includes('w') ? -hs / 2 : fullGroup.width - hs / 2,
                                            top: c.includes('n') ? -hs / 2 : fullGroup.height - hs / 2,
                                        }}
                                        onPointerDown={(ev) => onGroupHandlePointerDown(ev, c)}
                                    />
                                ))}
                                <div
                                    className="absolute flex justify-center pointer-events-none"
                                    style={{ left: 0, right: 0, top: fullGroup.height + px(8) }}
                                >
                                    <span
                                        className="text-white font-medium tabular-nums"
                                        style={{
                                            backgroundColor: FIGMA_BLUE,
                                            fontSize: px(10),
                                            lineHeight: 1.5,
                                            padding: `${px(1)}px ${px(4)}px`,
                                            borderRadius: px(2),
                                        }}
                                    >
                                        {Math.round(fullGroup.width)} × {Math.round(fullGroup.height)}
                                    </span>
                                </div>
                            </div>
                        );
                    })()}

                {/* Snap guides. At scene level they need explicit extents — they used
                    to inherit the single artboard's height/width. */}
                {showGuides && guides.v.map((x) => (
                    <div
                        key={`v-${x}`}
                        data-export-ignore="1"
                        className="absolute bg-[#f24822] pointer-events-none z-[10000]"
                        style={{ left: x, width: px(1), top: scene.minY - px(40), height: (scene.maxY - scene.minY) + px(80) }}
                    />
                ))}
                {showGuides && guides.h.map((y) => (
                    <div
                        key={`h-${y}`}
                        data-export-ignore="1"
                        className="absolute bg-[#f24822] pointer-events-none z-[10000]"
                        style={{ top: y, height: px(1), left: scene.minX - px(40), width: (scene.maxX - scene.minX) + px(80) }}
                    />
                ))}
            </div>

            {/* Floating zoom HUD — mobile fallback (panels hidden below sm), and the
                ONLY control in a read-only view, where zoom and pan are all there is.
                No toolbar underneath it there, so it sits lower. */}
            <div
                className={`absolute left-1/2 -translate-x-1/2 flex items-center gap-0.5 rounded-[10px] bg-[#1e1e1e] shadow-lg px-1 py-1 z-20
                    ${readOnly ? 'bottom-6' : 'sm:hidden bottom-20'}`}
            >
                <button
                    type="button"
                    title="Zoom out (⌘-)"
                    className="px-2 py-1 text-xs font-medium text-white/80 hover:bg-white/10 rounded-[5px]"
                    onClick={() => {
                        const rect = viewportRef.current?.getBoundingClientRect();
                        if (rect) zoomAt(zoom / 1.2, rect.width / 2, rect.height / 2);
                    }}
                >
                    −
                </button>
                <button
                    type="button"
                    title="Reset to 100% (⌘1)"
                    className="px-2 py-1 text-[11px] tabular-nums font-medium text-white/90 hover:bg-white/10 rounded-[5px] min-w-[3rem]"
                    onClick={() => {
                        const rect = viewportRef.current?.getBoundingClientRect();
                        if (!rect) return;
                        const store = usePrintEditorStore.getState();
                        const f = store.activeFrame();
                        store.setZoom(1);
                        store.setPan(
                            (rect.width - (f?.width || 1050)) / 2 - (f?.x || 0),
                            (rect.height - (f?.height || 600)) / 2 - (f?.y || 0),
                        );
                    }}
                >
                    {Math.round(zoom * 100)}%
                </button>
                <button
                    type="button"
                    title="Zoom in (⌘+)"
                    className="px-2 py-1 text-xs font-medium text-white/80 hover:bg-white/10 rounded-[5px]"
                    onClick={() => {
                        const rect = viewportRef.current?.getBoundingClientRect();
                        if (rect) zoomAt(zoom * 1.2, rect.width / 2, rect.height / 2);
                    }}
                >
                    +
                </button>
                <div className="w-px h-4 bg-white/15 mx-0.5" />
                <button
                    type="button"
                    title="Fit to screen (⌘0)"
                    className="px-2 py-1 text-[11px] font-medium text-white/80 hover:bg-white/10 rounded-[5px]"
                    onClick={() => {
                        const rect = viewportRef.current?.getBoundingClientRect();
                        if (rect) fitToView(rect.width, rect.height, { padding: 72 });
                    }}
                >
                    Fit
                </button>
            </div>

            {/* Every frame deleted — say what to do next instead of showing a void */}
            {!frames.length && !isExporting && (
                <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
                    <div className={`text-center text-[12px] leading-relaxed ${T.textSoft}`}>
                        <p className="font-medium">No frames</p>
                        <p className="mt-1">
                            {mode === 'admin'
                                ? 'Press F or use the frame tool below to add one.'
                                : 'This template has no artboards.'}
                        </p>
                    </div>
                </div>
            )}

            {/* Crop mode banner — says what the two kinds of grip do, and how to leave */}
            {cropping && !isExporting && (
                <div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-2 px-2.5 py-1 rounded-full bg-[#1e1e1e]/90 text-white text-[11px] z-20 shadow-md">
                    <span>Cropping — drag the picture to move it, the handles to crop</span>
                    <button
                        type="button"
                        onClick={stopCrop}
                        className="px-2 py-0.5 rounded-full bg-[#0d99ff] font-medium hover:bg-[#0b87e0]"
                    >
                        Done
                    </button>
                </div>
            )}

            {/* Hint while holding space / pan tool. A read-only view pans by default,
                so the banner would just sit there for good — no hint there. */}
            {panActive && !readOnly && (
                <div className="absolute top-3 left-1/2 -translate-x-1/2 px-2.5 py-1 rounded-full bg-[#1e1e1e]/90 text-white text-[11px] z-20 pointer-events-none shadow-md">
                    {spacePan ? 'Space — drag to pan' : 'Hand tool — drag to move canvas'}
                </div>
            )}

            {tool !== 'select' && tool !== 'pan' && !drawTool && !readOnly && (
                <button
                    type="button"
                    className="absolute bottom-20 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full bg-[#1e1e1e] text-white text-xs shadow-lg z-20"
                    onClick={() => addElement(tool)}
                >
                    Click to add {tool}
                </button>
            )}

            <CanvasContextMenu at={menuAt} items={menuItems} onClose={closeMenu} />

            {/* Drop-to-place overlay — while an image file is dragged over the canvas */}
            {dropActive && !readOnly && (
                <div className="absolute inset-0 z-40 pointer-events-none flex items-center justify-center bg-[#0d99ff]/10 ring-2 ring-inset ring-[#0d99ff]">
                    <div className="px-4 py-2 rounded-full bg-[#1e1e1e]/90 text-white text-xs font-medium shadow-lg flex items-center gap-2">
                        <IconPhotoPlus size={15} stroke={1.75} />
                        {mode === 'user' ? 'Drop image to replace' : 'Drop image to place it here'}
                    </div>
                </div>
            )}
        </div>
    );
}
