Files
TREK/client/src/components/Studio/StudioInspector.tsx
T
Maurice e6930e00ac refactor(studio): share the typeface buttons and the weight line between the inspectors
The text inspector and the travel inspector each drew the same row of
typeface buttons and the same weight choice. Both now render FontButtons
and WeightLine from StudioTypeControls.tsx. The travel inspector keeps
casting its patches to Partial<BookElement>, so each side writes exactly
the fields it wrote before.
2026-10-09 16:15:49 +02:00

787 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared'
import {
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp,
ChevronsDown, ChevronsUp, ClipboardCopy, Copy, Italic, Lock, Trash2, Unlock,
} from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { useStudioStore } from '../../store/studioStore'
import { photoSrc } from './bookRender'
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
import { ShapeGlyph } from './StudioElementsPanel'
import { TravelInspector } from './StudioTravelInspector'
import { Swatches } from './StudioSwatches'
import { formatBookCoords } from './entryText'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import { FontButtons, WeightLine } from './StudioTypeControls'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
/** How many icons the swap grid offers at once. A search narrows it below this. */
const ICON_CHOICES = 60
/**
* Properties of whatever is selected.
*
* ── What it shows, and in what order ─────────────────────────────────────
*
* Deliberately narrow: what the selected thing actually has, and nothing else.
* A panel that lists every property of every element with most of them greyed
* out reads as a settings dialog, not as a tool.
*
* The order is the same for every kind, because the panel is used by muscle
* memory more than by reading: **what it says** first, then **how it looks**,
* then **where it sits**, then **what is in front of what**. A photo's crop and
* a mark's words are the same step in that sequence, so they sit in the same
* place on the panel.
*
* The frame's numbers are folded away by default. They are the least-used group
* in the panel — dragging is how anyone actually positions an element — and
* four number fields left open at the top pushed everything worth looking at
* below the fold.
*
* ── What is not here any more ────────────────────────────────────────────
*
* Duplicate, lock and delete are in the panel head rather than in a section of
* their own: they act on the element rather than describing it, and a group
* called "Arrange" that held two of those next to the stacking buttons was the
* one group in the panel whose name did not say what was inside it.
*/
export function StudioInspector({
spreadIndex,
page,
stats,
source,
setPageNumbers,
t,
locale,
}: {
spreadIndex: number
page: BookPageSetup
setPageNumbers: (patch: Partial<BookPageNumbers>) => void
/** Live journey figures, so a travel element can be brought up to date. */
stats: JourneyStats | null
/** The journal itself, so a bound mark can be re-set from it. */
source: JourneySource
t: (k: string) => string
locale: string
}) {
const [iconQuery, setIconQuery] = useState('')
/*
* A page of results, memoised because the inspector re-renders on every
* pointer move of a drag and the library is fourteen hundred names long.
*/
const iconChoices = useMemo(() => searchIcons(iconQuery).slice(0, ICON_CHOICES), [iconQuery])
const doc = useStudioStore(s => s.doc)
const selection = useStudioStore(s => s.selection)
const update = useStudioStore(s => s.updateElement)
const commit = useStudioStore(s => s.commit)
const raise = useStudioStore(s => s.raise)
const duplicate = useStudioStore(s => s.duplicate)
const copy = useStudioStore(s => s.copy)
const removeElements = useStudioStore(s => s.removeElements)
const spread = doc?.spreads[spreadIndex]
const sel = spread?.elements.filter(e => selection.includes(e.id)) ?? []
if (!sel.length) {
/*
* Nothing selected, so this is where the *book's* own settings belong.
* The panel was showing one line of hint text and nothing else, and page
* numbers have nowhere else to go: they are not a property of any element,
* because the number a page carries depends on where its spread sits.
*/
const folios = page.pageNumbers
return (
<aside className="st-panel st-inspector">
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div>
<div className="st-panel-scroll">
<Section label={t('journey.studio.pageNumbers')}>
<Switch
label={t('journey.studio.pageNumbers')}
on={folios.show}
onToggle={() => setPageNumbers({ show: !folios.show })}
/>
{folios.show && (
<>
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.position')}>
<Choice
value={folios.position}
options={(['outer', 'inner', 'centre'] as const).map(pos => ({
value: pos, label: t(`journey.studio.folio.${pos}`),
}))}
onPick={position => setPageNumbers({ position })}
/>
</Line>
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.folioStart')}
value={folios.startAt}
min={0}
max={9999}
step={1}
onChange={v => setPageNumbers({ startAt: Math.round(v) })}
/>
<NumField
label={t('journey.studio.size')}
value={folios.size}
min={4}
max={48}
step={0.5}
unit="pt"
onChange={v => setPageNumbers({ size: v })}
/>
</div>
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.folioMargin')}
value={folios.margin}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={v => setPageNumbers({ margin: v })}
/>
</div>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.folioAuto')}
on={folios.autoColor}
onToggle={() => setPageNumbers({ autoColor: !folios.autoColor })}
/>
</div>
{/* Picking a colour is the other way to turn automatic off, so
the row stays reachable and simply reads as inactive. */}
<div style={{ marginTop: 10, opacity: folios.autoColor ? 0.45 : 1 }}>
<Line label={t('journey.studio.colour')} wide>
<Swatches
value={folios.color}
onPick={c => setPageNumbers({ color: c, autoColor: false })}
/>
</Line>
</div>
</>
)}
</Section>
<p className="st-hint" style={{ paddingTop: 10 }}>
{t('journey.studio.inspectorEmpty')}
</p>
<p className="st-hint" style={{ paddingTop: 6 }}>
{t('journey.studio.pageHint')} {page.pageWidth} × {page.pageHeight} mm
</p>
</div>
</aside>
)
}
const el = sel[0]
const one = sel.length === 1
// A property edit is one undo step on its own; only drags open a gesture.
const set = (patch: Partial<BookElement>) => commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(e => (selection.includes(e.id) ? ({ ...e, ...patch } as BookElement) : e)),
})),
}))
const live = (patch: Partial<BookElement>) => update(spreadIndex, el.id, patch)
/**
* Stretch the picture over the page it is on, or over both pages, bleed
* included.
*
* One commit rather than `set` followed by `raise`: the frame, the rotation
* put back to zero (a turned picture leaves bare corners), the cover crop and
* the move to the back are one press, and one press comes back with one
* undo. Which page is the one under the frame's centre; a cover has only
* the one.
*/
const fill = (scope: 'page' | 'spread') => commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => {
if (i !== spreadIndex) return sp
const bleed = page.bleed
const onRight = sp.role === 'inner' && scope === 'page'
&& el.frame.x + el.frame.w / 2 >= page.pageWidth
const frame = {
x: onRight ? page.pageWidth - bleed : -bleed,
y: -bleed,
w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed,
h: page.pageHeight + 2 * bleed,
}
const filled = { ...el, frame, rotation: 0, fit: 'cover' } as BookElement
return { ...sp, elements: [filled, ...sp.elements.filter(e => e.id !== el.id)] }
}),
}))
return (
<aside className="st-panel st-inspector">
{/*
The head says what is selected and carries the three things you do TO
it. It used to say only the kind, in the same small capitals as every
section label below it, which left the panel with no top.
*/}
<div className="st-panel-head st-inspector-head">
<span className="st-what">
{one ? t(`journey.studio.kind.${el.kind}`) : t('journey.studio.multiple')}
{sel.length > 1 && <em>{sel.length}</em>}
</span>
<span className="st-head-acts">
<Tooltip label={t('journey.studio.copyToPage')}>
<button type="button"
className="st-act"
onClick={() => copy(spreadIndex, selection)}
aria-label={t('journey.studio.copyToPage')}
>
<ClipboardCopy size={14} />
</button>
</Tooltip>
<button type="button"
className="st-act"
onClick={() => duplicate(spreadIndex, selection)}
title={t('journey.studio.duplicate')}
aria-label={t('journey.studio.duplicate')}
>
<Copy size={14} />
</button>
<button type="button"
className={`st-act ${el.locked ? 'is-on' : ''}`}
onClick={() => set({ locked: !el.locked })}
title={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
aria-label={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
aria-pressed={el.locked}
>
{el.locked ? <Lock size={14} /> : <Unlock size={14} />}
</button>
<button type="button"
className="st-act is-danger"
onClick={() => removeElements(spreadIndex, selection)}
title={t('journey.studio.delete')}
aria-label={t('journey.studio.delete')}
>
<Trash2 size={14} />
</button>
</span>
</div>
<div className="st-panel-scroll">
{el.kind === 'text' && (
<>
<Section label={t('journey.studio.text')}>
<textarea
className="st-input st-textarea"
value={el.text}
rows={5}
onChange={e => live({ text: e.target.value, overridden: true })}
onBlur={e => set({ text: e.target.value, overridden: true })}
/>
{el.binding && !el.overridden && (
<p className="st-hint" style={{ paddingTop: 6 }}>{t('journey.studio.boundHint')}</p>
)}
{/*
How a point is set. Only a coordinate binding has the question,
and answering it re-sets the words on the spot rather than at
the next open — switching a notation and seeing nothing happen
reads as a broken control. Deliberately not `overridden`:
choosing a format is not writing the text.
*/}
{el.binding?.source === 'entry.location' && el.binding.format && (
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.coordsMark')}>
<Choice
value={el.binding.format}
options={[
{ value: 'dms' as const, label: t('journey.studio.coordsDms') },
{ value: 'decimal' as const, label: t('journey.studio.coordsDecimal') },
]}
onPick={f => {
const entry = source.entries.find(e => e.id === el.binding!.entryId)
const text = entry?.lat != null && entry.lng != null
? formatBookCoords(entry.lat, entry.lng, f)
: el.text
set({ binding: { ...el.binding!, format: f }, text })
}}
/>
</Line>
</div>
)}
</Section>
<Section label={t('journey.studio.typography')}>
{/*
Each family set in itself, because a list of names tells you
nothing about a typeface — and grouped, because "which serif"
is a different question from "serif or sans".
*/}
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch)} />
<div style={{ marginTop: 10 }}>
<WeightLine font={el.font} weight={el.weight} onPick={weight => set({ weight })} t={t} />
<Line label={t('journey.studio.align')}>
<div className="st-row">
{([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
<button type="button"
key={a}
className={`st-chip is-icon ${el.align === a ? 'is-on' : ''}`}
onClick={() => set({ align: a })}
aria-label={a}
>
<Icon size={14} />
</button>
))}
{/* Italic sits with the alignment because both are things
you do to a line rather than to a typeface. It had no
control at all before, though the renderer drew it. */}
<button type="button"
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
onClick={() => set({ italic: !el.italic })}
title={t('journey.studio.italic')}
aria-label={t('journey.studio.italic')}
aria-pressed={el.italic}
>
<Italic size={14} />
</button>
</div>
</Line>
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField label={t('journey.studio.size')} value={el.size} min={4} max={200} step={0.5} unit="pt" onChange={v => set({ size: v })} />
<NumField label={t('journey.studio.leading')} value={el.leading} min={0.7} max={3} step={0.05} onChange={v => set({ leading: v })} />
</div>
<div style={{ marginTop: 10 }}>
{/*
Letter spacing was rendered but had no control, so type the
layout had tracked out could be edited in every way except the
one that made it look like that.
*/}
<NumField
label={t('journey.studio.tracking')}
value={el.tracking}
min={-0.2}
max={1}
step={0.01}
unit="em"
onChange={v => set({ tracking: v })}
/>
</div>
</Section>
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.text')} wide>
<Swatches value={el.color} onPick={c => set({ color: c })} />
</Line>
</Section>
</>
)}
{el.kind === 'photo' && (
<>
<Section label={t('journey.studio.crop')}>
<Line label={t('journey.studio.crop')} wide>
<Choice
value={el.fit}
options={(['cover', 'contain'] as const).map(f => ({
value: f, label: t(`journey.studio.fit.${f}`),
}))}
onPick={fit => set({ fit })}
/>
</Line>
{/*
An empty frame has no picture to aim at, and asking for one by
id produced a request for /api/photos/null/thumbnail and a
broken image under a caption telling you to drag it.
*/}
{el.photoId != null ? (
<FocalPad
photoId={el.photoId}
x={el.focalX}
y={el.focalY}
onDrag={(x, y) => live({ focalX: x, focalY: y })}
onCommit={(x, y) => set({ focalX: x, focalY: y })}
hint={t('journey.studio.focalHint')}
/>
) : (
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.emptyFrame')}</p>
)}
</Section>
<Section label={t('journey.studio.look')}>
<Line label={t('journey.studio.look')} wide>
<Choice
value={el.filter}
options={(['none', 'bw', 'warm', 'cool', 'fade', 'contrast'] as const).map(f => ({
value: f, label: t(`journey.studio.filter.${f}`),
}))}
onPick={filter => set({ filter })}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.frameStyle')} wide>
<Choice
value={el.frameStyle}
options={([
['none', 'plainFrame'],
['polaroid', 'polaroidFrame'],
['white', 'whiteFrame'],
['shadow', 'shadowFrame'],
['film', 'filmFrame'],
['tape', 'tapeFrame'],
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
onPick={frameStyle => set({ frameStyle })}
/>
</Line>
</div>
{/* A corner radius and a mask are the same idea at different
resolutions, so the radius steps aside once a shape is cut.
It did not, and edited a property the renderer ignored. */}
{(!el.mask || el.mask === 'rect') && (
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.radius')}
value={el.radius}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={v => set({ radius: v })}
/>
</div>
)}
</Section>
<Section label={t('journey.studio.mask')} defaultOpen={false}>
<div className="st-mini-shapes">
<button type="button"
className={`st-mini-shape ${el.mask ? '' : 'is-on'}`}
onClick={() => set({ mask: null })}
title={t('journey.studio.maskNone')}
>
<span className="st-mini-none" />
</button>
{FRAME_SHAPES.filter(sh => sh !== 'rect').map(sh => (
<button type="button"
key={sh}
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
onClick={() => set({ mask: sh })}
aria-label={sh}
>
<ShapeGlyph shape={sh} />
</button>
))}
</div>
</Section>
</>
)}
{el.kind === 'shape' && (
<>
<Section label={t('journey.studio.shape')}>
{/* Every shape, not just the two the element started as: swapping
a placed shape keeps its position, size and colour, which
deleting and re-adding would not. */}
<div className="st-mini-shapes">
{SHAPE_GROUPS.flatMap(g => g.shapes).map((sh: BookShapeId) => (
<button type="button"
key={sh}
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
onClick={() => set({ shape: sh })}
aria-label={sh}
>
<ShapeGlyph shape={sh} />
</button>
))}
</div>
<div style={{ marginTop: 10 }}>
{/* A photo had a corner radius and a shape did not, although
both store the same field and the renderer draws both. */}
<NumField
label={t('journey.studio.radius')}
value={el.radius}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={v => set({ radius: v })}
/>
</div>
</Section>
<Section label={t('journey.studio.colour')}>
{/*
Fill and stroke, both named. The panel used to show one
unlabelled swatch row that wrote `fill`, so on an outline-only
shape it lit up a colour that was not being drawn and there was
no way to reach the line you could actually see.
*/}
<Line label={t('journey.studio.fill')} wide>
<Swatches value={el.fill ?? '#111111'} onPick={c => set({ fill: c })} />
</Line>
<Line label={t('journey.studio.stroke')} wide>
<Swatches
value={el.stroke ?? '#141414'}
onPick={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.fillOn')}
on={el.fill !== null}
onToggle={() => set({
// Turning the fill off on a shape with no stroke would make
// it invisible, so the outline comes on with it.
fill: el.fill === null ? '#111111' : null,
...(el.fill !== null && !el.stroke ? { stroke: '#141414', strokeWidth: el.strokeWidth || 0.5 } : {}),
})}
/>
<Switch
label={t('journey.studio.gradient')}
on={el.gradient !== 'none'}
onToggle={() => set({ gradient: el.gradient === 'none' ? 'down' : 'none' })}
/>
</div>
{el.gradient !== 'none' && (
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.gradient')}>
<Choice
value={el.gradient}
options={[
{ value: 'down' as const, label: t('journey.studio.gradientDown') },
{ value: 'up' as const, label: t('journey.studio.gradientUp') },
]}
onPick={gradient => set({ gradient })}
/>
</Line>
</div>
)}
</Section>
<Section label={t('journey.studio.strokeStyle')} defaultOpen={false}>
<Line label={t('journey.studio.strokeStyle')} wide>
<Choice
value={el.strokeStyle}
options={([
['solid', 'strokeSolid'],
['dashed', 'strokeDashed'],
['dotted', 'strokeDotted'],
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
onPick={strokeStyle => set({
strokeStyle,
// A stroke style with no stroke is invisible, so asking for
// dashes turns the outline on rather than doing nothing.
...(el.stroke ? {} : { stroke: el.fill ?? '#141414', strokeWidth: el.strokeWidth || 0.5 }),
})}
/>
</Line>
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.strokeWidth')}
value={el.strokeWidth}
min={0}
max={20}
step={0.1}
unit="mm"
onChange={v => set({ strokeWidth: v, ...(v > 0 && !el.stroke ? { stroke: '#141414' } : {}) })}
/>
</div>
</Section>
</>
)}
{el.kind === 'icon' && (
<>
<Section label={t('journey.studio.icon')}>
{/*
A search rather than the whole library: the picker in the panel
can afford to grow as it is scrolled, but the inspector is a
column beside a page and its job here is swapping one drawing
for a near neighbour. Swapping keeps the position, size and
colour that deleting and re-placing would lose.
*/}
<input
className="st-input"
value={iconQuery}
onChange={e => setIconQuery(e.target.value)}
placeholder={t('journey.studio.searchIcons')}
aria-label={t('journey.studio.searchIcons')}
spellCheck={false}
/>
<div className="st-mini-shapes" style={{ marginTop: 8 }}>
{iconChoices.map(name => {
const Glyph = iconComponent(name)
return (
<button type="button"
key={name}
className={`st-mini-shape ${el.name === name ? 'is-on' : ''}`}
onClick={() => set({ name })}
aria-label={name}
title={iconLabel(name)}
>
<Glyph strokeWidth={1.7} />
</button>
)
})}
</div>
</Section>
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.icon')} wide>
<Swatches value={el.color} onPick={c => set({ color: c })} />
</Line>
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.lineWidth')}
value={el.lineWidth}
min={0.25}
max={4}
step={0.25}
onChange={v => set({ lineWidth: v })}
/>
</div>
</Section>
</>
)}
<TravelInspector el={el} stats={stats} set={set} t={t} locale={locale} />
{/*
Where it sits, folded away.
Everything in here can be done by dragging, which is how it is
actually done; the numbers are for the one time in ten you need the
same margin on two pages. Opacity and rotation live here too — both
were painted by the renderer and neither had a control anywhere.
*/}
{one && (
<Section label={t('journey.studio.position')} defaultOpen={false}>
<div className="st-grid2">
<NumField label="X" value={el.frame.x} unit="mm" onChange={v => set({ frame: { ...el.frame, x: v } })} />
<NumField label="Y" value={el.frame.y} unit="mm" onChange={v => set({ frame: { ...el.frame, y: v } })} />
<NumField label={t('journey.studio.width')} value={el.frame.w} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, w: v } })} />
<NumField label={t('journey.studio.height')} value={el.frame.h} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, h: v } })} />
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.rotation')}
value={el.rotation}
min={-180}
max={180}
step={1}
unit="°"
onChange={v => set({ rotation: v })}
/>
<NumField
label={t('journey.studio.opacity')}
value={Math.round(el.opacity * 100)}
min={0}
max={100}
step={5}
unit="%"
onChange={v => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
/>
</div>
</Section>
)}
<Section label={t('journey.studio.arrange')} defaultOpen={false}>
<div className="st-row">
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'up')} title={t('journey.studio.forward')}>
<ArrowUp size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'down')} title={t('journey.studio.backward')}>
<ArrowDown size={14} />
</button>
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</div>
{/*
The picture as the page.
A full-bleed photograph is the commonest layout in any photo book
and the hardest to make by hand: the frame has to reach exactly to
the bleed on every side, and a millimetre short leaves a white
hairline on the trimmed page. So it is one press. It goes to the
back as well, since a page-sized picture in front of everything is
a page with nothing else on it.
*/}
{one && el.kind === 'photo' && (
<div className="st-row" style={{ marginTop: 6 }}>
<button type="button" className="st-chip" onClick={() => fill('page')} title={t('journey.studio.fillHint')}>
{t('journey.studio.fillPage')}
</button>
{spread.role === 'inner' && (
<button type="button" className="st-chip" onClick={() => fill('spread')} title={t('journey.studio.fillHint')}>
{t('journey.studio.fillSpread')}
</button>
)}
</div>
)}
</Section>
</div>
</aside>
)
}
/**
* Where the picture is anchored inside its frame.
*
* Two sliders were the first draft and they were the wrong instrument: a focal
* point is one position in a plane, not two unrelated numbers, and nobody can
* predict what "X 0.62" does to a photograph. Dragging the point over the actual
* image answers the question by showing it.
*/
function FocalPad({
photoId, x, y, onDrag, onCommit, hint,
}: {
photoId: number
x: number
y: number
onDrag: (x: number, y: number) => void
onCommit: (x: number, y: number) => void
hint: string
}) {
const box = useRef<HTMLDivElement>(null)
const last = useRef({ x, y })
const from = (e: React.PointerEvent) => {
const r = box.current!.getBoundingClientRect()
const nx = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
const ny = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))
last.current = { x: nx, y: ny }
return last.current
}
return (
<div className="st-focal-wrap">
<div
className="st-focal"
ref={box}
onPointerDown={e => {
(e.target as Element).setPointerCapture(e.pointerId)
const p = from(e)
onDrag(p.x, p.y)
}}
onPointerMove={e => {
if (!e.currentTarget.hasPointerCapture?.(e.pointerId)) return
const p = from(e)
onDrag(p.x, p.y)
}}
onPointerUp={() => onCommit(last.current.x, last.current.y)}
>
<img src={photoSrc(photoId, false)} alt="" draggable={false} />
<span className="st-focal-dot" style={{ left: `${x * 100}%`, top: `${y * 100}%` }} />
</div>
<p className="st-hint" style={{ padding: '6px 0 0' }}>{hint}</p>
</div>
)
}