diff --git a/client/src/components/Studio/SpreadView.tsx b/client/src/components/Studio/SpreadView.tsx
index da6d8c032..d5440e853 100644
--- a/client/src/components/Studio/SpreadView.tsx
+++ b/client/src/components/Studio/SpreadView.tsx
@@ -1,8 +1,9 @@
import type { CSSProperties } from 'react'
import type {
- BookElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
+ BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
} from '@trek/shared'
import { FONT_STACKS, photoSrc } from './bookRender'
+import { iconComponent } from './iconLibrary'
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
import { TravelElementView } from './TravelElements'
import { PageNumbers } from './PageNumbers'
@@ -197,8 +198,18 @@ function PhotoView({ el, big, print, dropLabel }: {
style={{
position: 'absolute',
inset: 0,
+ /*
+ * Opaque, and that is the point.
+ *
+ * The hatch used to be laid over nothing, so an empty frame showed
+ * whatever was behind it and every attempt to arrange a shape *under*
+ * one looked like it had done nothing: the frame appeared to sit at the
+ * bottom of the stack no matter where it was, right up until a
+ * photograph landed in it and it suddenly covered things. A frame is a
+ * promise that a picture goes here, and a picture is not see-through.
+ */
background:
- 'repeating-linear-gradient(45deg, rgba(0,0,0,.045) 0 6px, rgba(0,0,0,.02) 6px 12px)',
+ 'repeating-linear-gradient(45deg, rgba(0,0,0,.055) 0 6px, rgba(0,0,0,.025) 6px 12px), #ffffff', // theme-lint-disable — paper, not app chrome
// A dashed border would be cut in half lengthwise by the clip, so a
// masked placeholder shows its shape through the hatch alone.
border: clipped ? undefined : '1px dashed rgba(0,0,0,.16)',
@@ -338,6 +349,31 @@ function PhotoView({ el, big, print, dropLabel }: {
)
}
+/**
+ * A lucide drawing, at the size the frame gives it.
+ *
+ * Kept proportional rather than stretched to the box. A shape is scaled by
+ * moving its points and looks deliberate at any ratio; a stroked icon does not
+ * — a stretched compass reads as a mistake — so it sits centred in whatever
+ * rectangle it was given, which is also what the handles then appear to do.
+ *
+ * The stroke follows the drawing rather than staying a fixed width, because an
+ * icon set 60mm across with a hairline is a diagram, not a mark. `lineWidth` is
+ * the weight against lucide's own 24-unit grid, exactly as the icons are drawn.
+ */
+function IconView({ el }: { el: BookIconElement }) {
+ const Icon = iconComponent(el.name)
+ return (
+
+
+
+ )
+}
+
export function ElementView({
el, big, print = false, dropLabel = '',
}: { el: BookElement; big: boolean; print?: boolean; dropLabel?: string }) {
@@ -347,12 +383,16 @@ export function ElementView({
if (el.kind === 'shape') return
+ // Above the catch-all below, which routes everything that is not text into
+ // the travel elements and draws null for anything they do not know.
+ if (el.kind === 'icon') return
+
if (el.kind !== 'text') {
// map, stats, countries, badge — the elements drawn from the journey's own
// figures. They live in their own file because they are a different kind of
// drawing: type and vector graphics laid out from data, rather than one box
// with one property set.
- return
+ return
}
return (
diff --git a/client/src/components/Studio/StudioControls.tsx b/client/src/components/Studio/StudioControls.tsx
new file mode 100644
index 000000000..95ca427f4
--- /dev/null
+++ b/client/src/components/Studio/StudioControls.tsx
@@ -0,0 +1,200 @@
+import { useState, type ReactNode } from 'react'
+import { Check, ChevronDown } from 'lucide-react'
+import ToggleSwitch from '../Settings/ToggleSwitch'
+
+/**
+ * The inspector's vocabulary.
+ *
+ * ── Why this file exists ─────────────────────────────────────────────────
+ *
+ * The panel had grown four ways of saying "this one is on" — a filled pill, a
+ * ring, a tinted border, ink on a tint — and used the *same* pill for a choice
+ * between four things and for a switch that turns one thing off. A map showed
+ * seventy controls in ten sections that were all open at once, all labelled in
+ * the same small capitals, so nothing on the panel looked more important than
+ * anything else. None of that was a decision; it was what happens when a panel
+ * grows one control at a time.
+ *
+ * So there are now exactly three ways to answer a question here, and each looks
+ * like what it is:
+ *
+ * **Choose one** is a row of chips, and the chosen one is filled.
+ * **Choose several** is a row of chips that carry a tick when they are on,
+ * because a set with three of seven picked has to be readable at a glance.
+ * **On or off** is the app's switch, on its own line beside the words —
+ * the one control in TREK that already means exactly that.
+ *
+ * A section can be folded away, which is the other half of the answer: a
+ * property panel is a reference, and a reference nobody can collapse is a wall.
+ */
+
+/**
+ * One group of properties.
+ *
+ * `defaultOpen={false}` is for the groups you set once and rarely return to —
+ * the frame's numbers, mostly. The state is per mounted section rather than
+ * remembered: coming back to an element and finding its panel folded up
+ * differently from last time is worse than opening one group again.
+ */
+export function Section({ label, children, defaultOpen = true, hint }: {
+ label: string
+ children: ReactNode
+ defaultOpen?: boolean
+ /** A line under the label, for a group whose name cannot carry the whole idea. */
+ hint?: string
+}) {
+ const [open, setOpen] = useState(defaultOpen)
+ return (
+
+
+ {open && (
+
+ {hint &&
{hint}
}
+ {children}
+
+ )}
+
+ )
+}
+
+/** A labelled line. The label sits left, the control fills what is left. */
+export function Line({ label, children, wide = false }: {
+ label: string
+ children: ReactNode
+ /** Put the control under the label rather than beside it, when it needs the width. */
+ wide?: boolean
+}) {
+ return (
+
+ {label}
+
{children}
+
+ )
+}
+
+/** Choose one of a few. The chosen one is filled in. */
+export function Choice({ value, options, onPick, ariaLabel }: {
+ value: T
+ options: { value: T; label: ReactNode; title?: string; disabled?: boolean }[]
+ onPick: (v: T) => void
+ ariaLabel?: string
+}) {
+ return (
+
+ {options.map(o => (
+
+ ))}
+
+ )
+}
+
+/**
+ * Choose several. A tick marks what is in the set.
+ *
+ * Not switches: seven metrics as seven switched lines is a column of switches
+ * taller than the element it configures, and what matters about a set is which
+ * of it is in — a question a row answers and a list does not.
+ */
+export function Picks({ value, options, onToggle }: {
+ value: readonly T[]
+ options: { value: T; label: ReactNode; disabled?: boolean }[]
+ onToggle: (v: T) => void
+}) {
+ return (
+
+ )
+}
+
+/** On or off, said the way the rest of TREK says it. */
+export function Switch({ label, on, onToggle }: { label: string; on: boolean; onToggle: () => void }) {
+ return (
+
+ {label}
+
+
+ )
+}
+
+/**
+ * A number, with its unit.
+ *
+ * Held as text while it is being typed and only read back on blur or Enter,
+ * which fixes three things at once: "0." no longer snaps to "0" the moment the
+ * point is typed, an emptied field no longer collapses the element to zero,
+ * and a value is one undo step rather than one per keystroke.
+ */
+export function NumField({
+ label, value, onChange, min, max, step = 0.5, unit,
+}: {
+ label: string
+ value: number
+ onChange: (v: number) => void
+ min?: number
+ max?: number
+ step?: number
+ unit?: string
+}) {
+ const shown = String(Math.round(value * 100) / 100)
+ const [draft, setDraft] = useState(null)
+
+ const commit = (raw: string) => {
+ setDraft(null)
+ const v = Number(raw)
+ if (raw.trim() === '' || !Number.isFinite(v)) return
+ const clamped = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, v))
+ if (clamped !== value) onChange(clamped)
+ }
+
+ return (
+
+ )
+}
diff --git a/client/src/components/Studio/StudioElementsPanel.tsx b/client/src/components/Studio/StudioElementsPanel.tsx
index 9f1154364..c74ba99f2 100644
--- a/client/src/components/Studio/StudioElementsPanel.tsx
+++ b/client/src/components/Studio/StudioElementsPanel.tsx
@@ -1,22 +1,26 @@
-import { useState } from 'react'
-import { Minus, Quote, Square } from 'lucide-react'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Minus, Quote, Search, Square, X } from 'lucide-react'
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, SHAPE_PATHS } from './shapes'
import { GRIDS, defaultGridBox, gridElements } from './grids'
import { PanelHead } from './StudioPanelHead'
+import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
/**
* Everything you can add that does not come from the journey: type, shapes,
- * picture frames and photo grids.
+ * picture frames, photo grids and the icon set.
*
- * Three tabs rather than three more entries on the rail. They are all the same
+ * Four tabs rather than four more entries on the rail. They are all the same
* kind of answer to "what do I put on this page", and a rail long enough to
* hold them separately stops being faster to hit than a word would be.
*/
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
+/** How many icons the grid draws at a time. Two screens' worth at four across. */
+const ICON_PAGE = 120
+
type FrameStyle = 'none' | 'polaroid' | 'white' | 'shadow' | 'film' | 'tape'
/**
@@ -74,8 +78,35 @@ function FrameStyleGlyph({ style }: { style: FrameStyle }) {
)
}
+function IconCell({ name, onPick }: { name: string; onPick: (name: string) => void }) {
+ const Icon = iconComponent(name)
+ return (
+
+ )
+}
+
export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: string) => string }) {
- const [tab, setTab] = useState<'shapes' | 'frames' | 'grids'>('shapes')
+ const [tab, setTab] = useState<'shapes' | 'frames' | 'grids' | 'icons'>('shapes')
+ const [iconQuery, setIconQuery] = useState('')
+ /*
+ * How much of the library is on screen.
+ *
+ * Fourteen hundred icons is fourteen hundred React components, each a
+ * forwardRef around several SVG nodes, and rendering them all costs about a
+ * second of blocked main thread on the first click of the tab — for a grid
+ * whose first two rows are all anybody sees. So it grows as it is scrolled,
+ * and a search resets it: the results of a search are short, and starting
+ * them part-drawn would be wrong.
+ */
+ const [iconLimit, setIconLimit] = useState(ICON_PAGE)
+ const iconTail = useRef(null)
const addElement = useStudioStore(s => s.addElement)
const commit = useStudioStore(s => s.commit)
const select = useStudioStore(s => s.select)
@@ -130,6 +161,22 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
} as BookElement)
}
+ /**
+ * An icon, placed as a square.
+ *
+ * Square because that is what it is: unlike a shape, an icon keeps its
+ * proportions inside whatever frame it is given, so a rectangle would only
+ * pad it with air on two sides.
+ */
+ const addIcon = (name: string) => {
+ const side = Math.min(page.pageWidth, page.pageHeight) * 0.18
+ addElement(active, {
+ id: uid('i'), kind: 'icon', frame: centre(side, side),
+ rotation: 0, opacity: 1, locked: false,
+ name, color: '#111827', lineWidth: 2,
+ } as BookElement)
+ }
+
/** An empty frame, cut to a shape and waiting for a picture. */
const addFrame = (mask: BookShapeId | null, frameStyle: FrameStyle = 'none') => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.45
@@ -144,6 +191,26 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
} as BookElement)
}
+ const matchedIcons = useMemo(() => searchIcons(iconQuery), [iconQuery])
+ const shownIcons = useMemo(() => matchedIcons.slice(0, iconLimit), [matchedIcons, iconLimit])
+
+ /*
+ * Grow the grid when its foot comes into view.
+ *
+ * An observer rather than a scroll handler: the panel is the scroller, the
+ * foot is the thing that matters, and asking the browser to tell us when they
+ * meet costs nothing per frame.
+ */
+ useEffect(() => {
+ const tail = iconTail.current
+ if (!tail) return
+ const io = new IntersectionObserver(entries => {
+ if (entries.some(e => e.isIntersecting)) setIconLimit(n => n + ICON_PAGE)
+ })
+ io.observe(tail)
+ return () => io.disconnect()
+ }, [shownIcons.length, matchedIcons.length])
+
return (
<>
@@ -158,6 +225,9 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
+
@@ -319,6 +389,54 @@ export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: s
{t('journey.studio.gridHint')}
)}
+
+ {tab === 'icons' && (
+
+ {/*
+ The search sits inside the scroller rather than above the tab
+ strip, unlike the Content panel's: that one filters both of its
+ tabs, this one belongs to a single tab, and hanging it above the
+ strip would make the strip move when you switch away from it.
+ */}
+
+ {/* The shelf, until somebody searches. */}
+ {!iconQuery && (
+ <>
+
{t('journey.studio.iconsForTravel')}
+
+ {FEATURED_ICONS.map(name => (
+
+ ))}
+
+
{t('journey.studio.iconsAll')}
+ >
+ )}
+
+ {shownIcons.map(name => (
+
+ ))}
+
+ {/* What the observer below watches for. */}
+ {shownIcons.length < matchedIcons.length && }
+ {!matchedIcons.length && (
+
{t('journey.studio.noMatches')}
+ )}
+
+ )}
>
)
diff --git a/client/src/components/Studio/StudioInspector.tsx b/client/src/components/Studio/StudioInspector.tsx
index baf96bd60..82d1f27d3 100644
--- a/client/src/components/Studio/StudioInspector.tsx
+++ b/client/src/components/Studio/StudioInspector.tsx
@@ -1,9 +1,9 @@
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared'
import {
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp,
- ChevronsDown, ChevronsUp, Lock, Trash2, Unlock,
+ ChevronsDown, ChevronsUp, Copy, Italic, Lock, Trash2, Unlock,
} from 'lucide-react'
-import { useRef } from 'react'
+import { useMemo, useRef, useState } from 'react'
import { useStudioStore } from '../../store/studioStore'
import { photoSrc } from './bookRender'
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
@@ -11,19 +11,47 @@ 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 type { JourneySource } from './StudioSidebar'
+
+/** How many icons the swap grid offers at once. A search narrows it below this. */
+const ICON_CHOICES = 60
/**
* Properties of whatever is selected.
*
- * Deliberately narrow: it shows what the selected thing actually has, and
- * nothing else. A panel that lists every property of every element type with
- * most of them greyed out reads as a settings dialog, not as a tool.
+ * ── 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,
@@ -33,14 +61,23 @@ export function StudioInspector({
setPageNumbers: (patch: Partial) => 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 removeElements = useStudioStore(s => s.removeElements)
const spread = doc?.spreads[spreadIndex]
@@ -59,30 +96,27 @@ export function StudioInspector({
- {/*
- Automatic first, and picking a colour is what turns it off —
- no separate switch to find and no state where a chosen colour
- is quietly ignored.
- */}
-