import { useEffect, useState } from 'react' import { ChevronsDown, ChevronsUp, Copy, Lock, RotateCcw, RotateCw, Trash2, Unlock } from 'lucide-react' import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared' import { SpreadFold, SpreadView } from './SpreadView' import { PeerCursors } from './PeerCursors' import type { PeerCursor } from './useBookPresence' import { bookElementId as elementId } from '@trek/shared' import { fontStack } from './bookFonts' import { useSpreadInteraction, type HandleId } from './useSpreadInteraction' import { useStudioStore } from '../../store/studioStore' import { useTranslation } from '../../i18n' const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] /** * What a delete may actually take out of the selection. * * A locked element stays selectable on purpose, so the lock has to be honoured * here the way the drag, resize and rotate gestures honour it. The store keeps * removing whatever it is handed: the import and repair paths remove locked * elements deliberately. */ const deletable = (spread: BookSpread | undefined, selection: string[]): string[] => (spread?.elements ?? []).filter(e => selection.includes(e.id) && !e.locked).map(e => e.id) /** * Where the four rotation handles sit, as percentages of the selection box. * * The push diagonally outwards lives in the stylesheet rather than here: it is * a fixed number of screen pixels clear of the resize handle on the same * corner, so it must not scale with the element the way a percentage would. */ const ROTATE_CORNERS = [ { id: 'nw', left: '0%', top: '0%' }, { id: 'ne', left: '100%', top: '0%' }, { id: 'se', left: '100%', top: '100%' }, { id: 'sw', left: '0%', top: '100%' }, ] as const const HANDLE_POS: Record = { nw: { left: '0%', top: '0%', cursor: 'nwse-resize' }, n: { left: '50%', top: '0%', cursor: 'ns-resize' }, ne: { left: '100%', top: '0%', cursor: 'nesw-resize' }, e: { left: '100%', top: '50%', cursor: 'ew-resize' }, se: { left: '100%', top: '100%', cursor: 'nwse-resize' }, s: { left: '50%', top: '100%', cursor: 'ns-resize' }, sw: { left: '0%', top: '100%', cursor: 'nesw-resize' }, w: { left: '0%', top: '50%', cursor: 'ew-resize' }, } /** * The sheet plus everything you do to it. * * The page itself is `SpreadView`, byte for byte what the print renderer will * draw. Selection outlines, handles and snap guides live in a layer *above* it * and never touch the document, so nothing you see while editing can end up in * the book. */ export function StudioCanvas({ spread, spreadIndex, folios = [], page, zoom, pxPerMm, bookView, dropLabel, cursors, onCursor, onDropFiles, fileDropLabel, }: { spread: BookSpread | null spreadIndex: number /** The page numbers this spread carries, from foliosOf. */ folios?: readonly number[] page: BookPageSetup zoom: number pxPerMm: number bookView: boolean dropLabel: string /** * Files from outside the browser, let go over the sheet. Absent for a * viewer, and then a file drag is left to the browser, which opens it. * `targetFrameId` is the photo frame under the pointer, when there is one. */ onDropFiles?: (files: File[], at: { x: number; y: number }, targetFrameId: string | null) => void /** What the veil says while a file drag is over the sheet. */ fileDropLabel?: string /** The other editors' pointers, on this spread and elsewhere. */ cursors?: PeerCursor[] /** Where this one is, in the spread's millimetres. Null once it leaves. */ onCursor?: (x: number | null, y: number | null) => void }) { const { t } = useTranslation() const selection = useStudioStore(s => s.selection) const select = useStudioStore(s => s.select) const removeElements = useStudioStore(s => s.removeElements) const duplicate = useStudioStore(s => s.duplicate) const copy = useStudioStore(s => s.copy) const paste = useStudioStore(s => s.paste) const hasClipboard = useStudioStore(s => !!s.clipboard) const raise = useStudioStore(s => s.raise) const commit = useStudioStore(s => s.commit) const addElement = useStudioStore(s => s.addElement) const [dropTarget, setDropTarget] = useState(null) /** A file drag from outside the browser is over the sheet. */ const [fileOver, setFileOver] = useState(false) const [editing, setEditing] = useState(null) /** Where on the spread, in millimetres, a drop landed. */ /** * Turn the selection by a number of degrees. * * Kept in the range a person reads as an angle rather than as a winding * number: -180 to 180, so an element turned all the way round reads as * straight rather than as 360. */ const rotateBy = (deg: number) => { commit(d => ({ ...d, spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : { ...sp, elements: sp.elements.map(e => { if (!selection.includes(e.id) || e.locked) return e let next = (e.rotation + deg) % 360 if (next > 180) next -= 360 if (next < -180) next += 360 return { ...e, rotation: Math.round(next * 10) / 10 } }), })), })) } const pointInMm = (e: React.DragEvent) => { const r = e.currentTarget.getBoundingClientRect() return { x: (e.clientX - r.left) / scaled, y: (e.clientY - r.top) / scaled } } const frameUnder = (x: number, y: number) => spread?.elements.find(el => el.kind === 'photo' && !el.locked && x >= el.frame.x && x <= el.frame.x + el.frame.w && y >= el.frame.y && y <= el.frame.y + el.frame.h, ) ?? null const scaled = pxPerMm * zoom const { guides, dragging, startMove, startResize, startRotate, onPointerMove, finish } = useSpreadInteraction({ spread, spreadIndex, page, pxPerMm: scaled, }) useEffect(() => { const onKey = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) { e.preventDefault() const ids = deletable(spread, selection) if (ids.length) removeElements(spreadIndex, ids) } // Copy and paste across pages (#2316). Text selected on the page is the // browser's to copy, not the book's. if (!(e.metaKey || e.ctrlKey) || e.altKey) return const key = e.key.toLowerCase() if (key === 'c' && selection.length && !window.getSelection()?.toString()) { e.preventDefault() copy(spreadIndex, selection) } else if (key === 'v' && hasClipboard) { e.preventDefault() paste(spreadIndex) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [selection, spread, spreadIndex, removeElements, copy, paste, hasClipboard]) if (!spread) return null const single = spread.role !== 'inner' const sheetW = single ? page.pageWidth : page.pageWidth * 2 const sel = spread.elements.filter(e => selection.includes(e.id)) return (
{ onPointerMove(e) if (!onCursor) return const r = e.currentTarget.getBoundingClientRect() onCursor((e.clientX - r.left) / scaled, (e.clientY - r.top) / scaled) }} // Leaving the stage sends null, so the arrow goes rather than sticking // where the pointer happened to cross the edge. onPointerLeave={() => onCursor?.(null, null)} onPointerUp={finish} onPointerCancel={finish} onDragOver={e => { const fromPanel = e.dataTransfer.types.includes('application/x-trek-photo') // A file drag is only claimed when somebody is there to take it; for a // viewer the browser keeps its default and opens the picture itself. const fromDisk = !!onDropFiles && e.dataTransfer.types.includes('Files') if (!fromPanel && !fromDisk) return e.preventDefault() e.dataTransfer.dropEffect = 'copy' const p = pointInMm(e) setDropTarget(frameUnder(p.x, p.y)?.id ?? null) setFileOver(fromDisk && !fromPanel) }} onDragLeave={() => { setDropTarget(null); setFileOver(false) }} onDrop={e => { const raw = e.dataTransfer.getData('application/x-trek-photo') const files = Array.from(e.dataTransfer.files ?? []) if (!raw && !(files.length && onDropFiles)) return e.preventDefault() setDropTarget(null) setFileOver(false) const p = pointInMm(e) if (!raw) { onDropFiles!(files, p, frameUnder(p.x, p.y)?.id ?? null) return } const photoId = Number(raw) if (!Number.isFinite(photoId)) return const target = frameUnder(p.x, p.y) if (target) { // Dropped onto a frame: fill it. That is the whole point of an empty // frame, and replacing a picture is the natural way to swap one. commit(d => ({ ...d, spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : { ...sp, elements: sp.elements.map(el => (el.id === target.id ? { ...el, photoId } : el)), })), })) select([target.id]) return } // Dropped on bare paper: a new frame, centred on the cursor. const w = Math.min(page.pageWidth, page.pageHeight) * 0.5 const h = w * 0.72 const id = elementId('p') addElement(spreadIndex, { id, kind: 'photo', frame: { x: p.x - w / 2, y: p.y - h / 2, w, h }, rotation: 0, opacity: 1, locked: false, photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', mask: null, frameStyle: 'none', } as BookElement) select([id]) }} > {/* Over the stage rather than inside the sheet, which is scaled: at 40% a label drawn on the page would be unreadable. It says what letting go will do, because a page is not an obvious drop target for a file the way an upload box is. */} {fileOver && fileDropLabel && (
{fileDropLabel}
)}
select([])} > 0.34} showGuides dropLabel={dropLabel} /> {cursors && cursors.length > 0 && ( )} {/* Hit targets sit above the page so a photo's own never eats the gesture, and so a locked element simply is not grabbable. */} {spread.elements.map(el => (
{ if (editing !== el.id) startMove(e, el) }} onDoubleClick={() => { if (el.kind === 'text' && !el.locked) setEditing(el.id) }} style={{ position: 'absolute', left: `${el.frame.x}mm`, top: `${el.frame.y}mm`, width: `${el.frame.w}mm`, height: `${el.frame.h}mm`, transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined, cursor: el.locked ? 'default' : 'move', // The target stays live even when locked, or the element could // not be selected — and an element that cannot be selected cannot // be unlocked. pointerEvents: 'auto', }} /> ))}
{/* Editing happens *on the page*, in the element's own type at the element's own size. A dialog with a text box would be easier to build and would make you guess how the words break — the whole reason to type here is to watch the line endings while you do it. */} {editing && (() => { const el = spread.elements.find(e => e.id === editing) if (!el || el.kind !== 'text') return null return (