Files
TREK/client/src/components/Studio/StudioCanvas.tsx
T
Maurice 6cbefd23e8 feat(studio): filter photos by entry, upload from Studio, and switch stops off
The content browser gets a filter over the journey's pictures: all of them,
the ones no entry holds, or one entry at a time, with the day and the place
under each name. An entry card in the Entries tab reaches its own pictures
the same way. Pictures can now come in without leaving Studio, into the
entry the filter names or into the gallery, from the panel or dropped
straight onto the page, and a photo element can fill its page or the whole
spread in one press.

Stops can be taken out of the arithmetic. A journal entry carries a
stats_excluded flag; an entry that has it is off the route, out of the
distance, out of the countries and not a step, while staying in the
journal. Studio lists every stop in the travel panel with a switch, the
entry editor carries the same switch on desktop and phone, and the entry
card says when a day is off the route.
2026-09-03 10:47:08 +02:00

508 lines
20 KiB
TypeScript

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 { 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<HandleId, { left: string; top: string; cursor: string }> = {
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,
page,
zoom,
pxPerMm,
bookView,
dropLabel,
cursors,
onCursor,
onDropFiles,
fileDropLabel,
}: {
spread: BookSpread | null
spreadIndex: 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 raise = useStudioStore(s => s.raise)
const commit = useStudioStore(s => s.commit)
const addElement = useStudioStore(s => s.addElement)
const [dropTarget, setDropTarget] = useState<string | null>(null)
/** A file drag from outside the browser is over the sheet. */
const [fileOver, setFileOver] = useState(false)
const [editing, setEditing] = useState<string | null>(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)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [selection, spread, spreadIndex, removeElements])
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 (
<div
className="st-stage"
style={{ width: sheetW * scaled, height: page.pageHeight * scaled }}
onPointerMove={e => {
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 = `p-${Math.random().toString(36).slice(2, 9)}`
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 && (
<div className="st-file-veil" aria-hidden>
<span>{fileDropLabel}</span>
</div>
)}
<div
className="st-sheet"
style={{
width: `${sheetW}mm`,
height: `${page.pageHeight}mm`,
transform: `scale(${zoom})`,
}}
onPointerDown={() => select([])}
>
<SpreadView spread={spread} page={page} spreadIndex={spreadIndex} big={zoom > 0.34} showGuides dropLabel={dropLabel} />
{cursors && cursors.length > 0 && (
<PeerCursors cursors={cursors} spreadIndex={spreadIndex} zoom={zoom} />
)}
{/* Hit targets sit above the page so a photo's own <img> never eats the
gesture, and so a locked element simply is not grabbable. */}
{spread.elements.map(el => (
<div
key={el.id}
onPointerDown={e => { 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',
}}
/>
))}
</div>
{/*
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 (
<textarea
className="st-inline-edit"
autoFocus
defaultValue={el.text}
style={{
left: el.frame.x * scaled,
top: el.frame.y * scaled,
width: el.frame.w * scaled,
height: el.frame.h * scaled,
// The document is in pt; the overlay is on screen at the same zoom.
fontSize: `${el.size * (96 / 72) * zoom}px`,
fontFamily: fontStack(el.font),
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
textAlign: el.align,
color: el.color,
}}
onBlur={e => {
const text = e.target.value
setEditing(null)
if (text !== el.text) {
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(x => (x.id === el.id ? { ...x, text, overridden: true } : x)),
})),
}))
}
}}
onKeyDown={e => {
// Escape here means "stop editing", not "close Studio" — so it must
// not reach the shell's handler.
if (e.key === 'Escape') { e.stopPropagation(); (e.target as HTMLTextAreaElement).blur() }
}}
/>
)
})()}
{/* Chrome layer: drawn in screen pixels so outlines stay hairline at any
zoom instead of growing with the page. */}
<div className="st-chrome">
{bookView && !single && <SpreadFold page={page} scaled={scaled} />}
{/* The frame a drop would land in. Without it you are aiming blind. */}
{dropTarget && (() => {
const el = spread.elements.find(e => e.id === dropTarget)
if (!el) return null
return (
<div
className="st-drop"
style={{
left: el.frame.x * scaled,
top: el.frame.y * scaled,
width: el.frame.w * scaled,
height: el.frame.h * scaled,
}}
/>
)
})()}
{sel.map(el => (
<div
key={el.id}
className="st-select"
style={{
left: el.frame.x * scaled,
top: el.frame.y * scaled,
width: el.frame.w * scaled,
height: el.frame.h * scaled,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
}}
>
{/* No resize handles on a locked element: they would offer a drag
that is refused, which reads as the editor being broken rather
than as the element being locked. */}
{/*
A rotation handle diagonally outside each corner.
Four rather than one, because which corner is reachable depends on
where the element sits: a handle off the top right alone is under
the inspector for anything in the right page's upper half.
Positioned in percentages of the selection box, so they follow it
at any zoom and turn with it once it is turned — the box itself
carries the rotation, and everything inside it comes along.
Outside the box rather than on it, because a handle on the corner
is a handle you cannot tell from the one that resizes — and this
is the gesture where grabbing the wrong one is most annoying to
undo. Held to 15° steps unless shift is down.
*/}
{sel.length === 1 && !dragging && !el.locked && ROTATE_CORNERS.map(c => (
<div
key={c.id}
className={`st-rotate is-${c.id}`}
style={{ left: c.left, top: c.top }}
onPointerDown={startRotate}
title={t('journey.studio.rotate')}
>
<RotateCw size={11} />
</div>
))}
{sel.length === 1 && !dragging && !el.locked && HANDLES.map(h => (
<span
key={h}
className={`st-handle ${h.length === 2 ? 'is-corner' : h === 'n' || h === 's' ? 'is-h' : 'is-v'}`}
style={{ left: HANDLE_POS[h].left, top: HANDLE_POS[h].top, cursor: HANDLE_POS[h].cursor }}
onPointerDown={e => startResize(e, h)}
/>
))}
</div>
))}
{/*
The quick bar, the way Canva and Figma do it: the three or four things
you reach for constantly, right where you are already looking, instead
of a trip to the panel on the far side of the screen. It hides while
you drag — chrome that follows your hand around is noise.
*/}
{sel.length >= 1 && !dragging && (() => {
const x0 = Math.min(...sel.map(e => e.frame.x)) * scaled
const x1 = Math.max(...sel.map(e => e.frame.x + e.frame.w)) * scaled
const y0 = Math.min(...sel.map(e => e.frame.y)) * scaled
const locked = sel.every(e => e.locked)
return (
<div
className="st-quickbar"
style={{ left: (x0 + x1) / 2, top: Math.max(6, y0 - 12) }}
onPointerDown={e => e.stopPropagation()}
>
<button type="button" onClick={() => duplicate(spreadIndex, selection)} title={t('journey.studio.duplicate')}>
<Copy size={14} />
</button>
{sel.length === 1 && (
<>
<button type="button" onClick={() => raise(spreadIndex, sel[0].id, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button type="button" onClick={() => raise(spreadIndex, sel[0].id, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</>
)}
{/*
Turn it, a step at a time.
Fifteen degrees per press, and a press with shift is one degree
for the times it has to line up with something. Steps rather
than a handle you drag: a photograph on a page is almost always
either square to it or tilted a little on purpose, and dragging
a rotation past the angle you wanted and back is a worse way to
arrive at either.
*/}
<button type="button"
onClick={e => rotateBy(e.shiftKey ? -1 : -15)}
title={t('journey.studio.rotateLeft')}
>
<RotateCcw size={14} />
</button>
<button type="button"
onClick={e => rotateBy(e.shiftKey ? 1 : 15)}
title={t('journey.studio.rotateRight')}
>
<RotateCw size={14} />
</button>
<span className="st-quickbar-sep" />
<button
type="button"
onClick={() => commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(e => (selection.includes(e.id) ? { ...e, locked: !locked } : e)),
})),
}))}
title={t(locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
>
{locked ? <Unlock size={14} /> : <Lock size={14} />}
</button>
<span className="st-quickbar-sep" />
<button type="button"
className="is-danger"
onClick={() => { const ids = deletable(spread, selection); if (ids.length) removeElements(spreadIndex, ids) }}
title={t('journey.studio.delete')}
>
<Trash2 size={14} />
</button>
</div>
)
})()}
{guides.map((g, i) => (
<div
key={i}
className="st-guide"
style={g.axis === 'x'
? { left: g.at * scaled, top: 0, width: 1, height: '100%' }
: { top: g.at * scaled, left: 0, height: 1, width: '100%' }}
/>
))}
</div>
</div>
)
}
export type { BookElement }