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, photo grids and the icon set. * * 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' /** * A shape as a tile. * * The same path the page will draw, so what you pick is what you get. In frame * mode it is filled with a sky-and-hill gradient, which is Canva's trick and a * good one: an outline tells you the silhouette, but only something * photograph-shaped inside it says "a picture goes here". */ export function ShapeGlyph({ shape, photo = false }: { shape: BookShapeId; photo?: boolean }) { const gid = `sg-${photo ? 'p' : 's'}-${shape}` return ( {photo && ( {/* theme-lint-disable — a frame preview stands in for a photograph, not for UI chrome */} )} ) } /** A miniature of what the decoration around a picture does. */ function FrameStyleGlyph({ style }: { style: FrameStyle }) { const pad = style === 'polaroid' ? 3 : style === 'white' ? 2 : style === 'film' ? 4 : 0 const bottom = style === 'polaroid' ? 9 : pad return ( {/* Both strips, because both is what gets placed — a preview showing one made the second one look like a bug the first time it appeared. */} {style === 'tape' && <>} ) } 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' | '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) const active = useStudioStore(s => s.activeSpread) const doc = useStudioStore(s => s.doc) const spread = doc?.spreads[active] const single = !!spread && spread.role !== 'inner' const centre = (w: number, h: number) => { const W = single ? page.pageWidth : page.pageWidth * 2 return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h } } const addText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra: Partial = {}) => addElement(active, { id: uid('t'), kind: 'text', frame: centre(page.pageWidth * 0.7, size * 0.5 + 8), rotation: 0, opacity: 1, locked: false, text: sample, font: 'sans', size, weight, italic: false, align: 'left', leading: size > 16 ? 1.1 : 1.5, tracking: size > 16 ? -0.02 : 0, color: '#1a1a1a', binding: null, overridden: false, ...extra, } as BookElement) /** * Placed at a size that suits the shape rather than always as a square. * * A banner wants to be wide and a pin wants to be tall; dropping every shape * as one 60mm square means resizing each one before it is any use, which is * a tax on the most common action in the panel. */ const addShape = (shape: BookShapeId, opts: { outline?: boolean } = {}) => { const side = Math.min(page.pageWidth, page.pageHeight) * 0.3 const wide = shape.startsWith('banner') || shape === 'ticket' || shape === 'wave' || shape === 'arrow-right' || shape === 'arrow-left' || shape === 'arrow-both' || shape === 'capsule' const tall = shape === 'pin' || shape === 'drop' || shape === 'arrow-up' || shape === 'arrow-down' const isRule = shape === 'line' addElement(active, { id: uid('s'), kind: 'shape', frame: isRule ? centre(page.pageWidth * 0.5, 0.5) : centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side), rotation: 0, opacity: 1, locked: false, // A rule is a shape too — a hairline box rather than its own element type, // so it moves, colours and snaps like everything else on the page. shape: isRule ? 'rect' : shape, fill: opts.outline ? null : '#141414', gradient: 'none', stroke: opts.outline ? '#141414' : null, strokeWidth: opts.outline ? 0.5 : 0, strokeStyle: 'solid', radius: 0, } 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 addElement(active, { id: uid('p'), kind: 'photo', // A Polaroid is taller than it is wide, because of the chin. Placing it // square would put the picture in a box the decoration then eats. frame: centre(side, frameStyle === 'polaroid' ? side * 1.16 : side), rotation: 0, opacity: 1, locked: false, photoId: null, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', mask, frameStyle, } 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 ( <>
{tab === 'shapes' && ( <>
{t('journey.studio.text')}
{t('journey.studio.lines')}
{SHAPE_GROUPS.map(group => (
{t(`journey.studio.shapeGroup.${group.id}`)}
{group.shapes.map(shape => ( ))}
))} )} {tab === 'frames' && ( <>
{t('journey.studio.frameStyles')}
{([ ['none', 'plainFrame'], ['polaroid', 'polaroidFrame'], ['white', 'whiteFrame'], ['shadow', 'shadowFrame'], ['film', 'filmFrame'], ['tape', 'tapeFrame'], ] as [FrameStyle, string][]).map(([style, key]) => ( ))}
{t('journey.studio.frameShapes')}
{FRAME_SHAPES.map(shape => ( ))}

{t('journey.studio.frameHint')}

)} {tab === 'grids' && (
{t('journey.studio.grids')}
{GRIDS.map(grid => ( ))}

{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. */}
{ setIconQuery(e.target.value); setIconLimit(ICON_PAGE) }} placeholder={t('journey.studio.searchIcons')} aria-label={t('journey.studio.searchIcons')} spellCheck={false} /> {iconQuery && ( )}
{/* 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')}

)}
)}
) }