import { useId, useState } from 'react' import { Frame, Minus, Quote, Shapes, Spline, Sparkles, Square, Sticker, Type, type LucideIcon } from 'lucide-react' import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared' import { useStudioStore } from '../../store/studioStore' import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS } from './shapes' import { BOOK_GRIDS as GRIDS, bookElementId, bookShapeSize, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared' import { shapeTile } from './shapeOutline' import { StudioLineShelf } from './StudioLineShelf' import { PanelHead, Shelf } from './StudioPanelHead' import { StudioTabPanel, StudioTabs } from './StudioTabs' import { StudioStickersPanel } from './StudioStickersPanel' import { StudioIconShelf } from './sidebar/StudioIconShelf' import { FRAME_STYLE_OPTIONS, type FrameStyle } from './studioOptions' import { StudioTextStyles } from './StudioTextStyles' import type { ShelfSource } from './textStylesModel' /** * Everything you can add that does not come from the journey: type, shapes, * lines, picture frames and grids, the icon set and the stickers. * * One panel with six tabs rather than six 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. The stickers were a rail section of their own until the rebuild; they * are one more kind of thing to add, so they sit here beside the icons. */ // The book's own ids, the ones the layouts and the assistant mint (shared/src/book/book-ids.ts). const uid = bookElementId type Tab = 'text' | 'shapes' | 'lines' | 'frames' | 'icons' | 'stickers' /** The tabs, three to a row: a word under each icon, so the longest of them keeps its cell. */ const TABS: readonly { id: Tab; icon: LucideIcon; labelKey: string }[] = [ { id: 'text', icon: Type, labelKey: 'journey.studio.text' }, { id: 'shapes', icon: Shapes, labelKey: 'journey.studio.shapes' }, { id: 'lines', icon: Spline, labelKey: 'journey.studio.lines' }, { id: 'frames', icon: Frame, labelKey: 'journey.studio.frames' }, { id: 'icons', icon: Sparkles, labelKey: 'journey.studio.icons' }, { id: 'stickers', icon: Sticker, labelKey: 'journey.studio.stickers' }, ] /** * 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}` const tile = shapeTile(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' && <>} ) } /** A plain text style: size, weight, its sample words and anything else it sets. */ type AddText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra?: Partial) => void /** * What every tab places, and where: centred on the page that is open, across * both pages of a spread or on the one page of a cover. */ function useElementAdders(page: BookPageSetup) { 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 role = useStudioStore(s => s.doc?.spreads[s.activeSpread]?.role) const single = role !== undefined && 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: AddText = (size, weight, sample, extra = {}) => 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' const own = bookShapeSize(shape, side) addElement(active, { id: uid('s'), kind: 'shape', frame: isRule ? centre(page.pageWidth * 0.5, 0.5) : own ? centre(own.w, own.h) : 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. 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) } /** A block of empty frames, selected as a group so it moves and resizes as one thing straight away. */ const addGrid = (grid: (typeof GRIDS)[number]) => { const els = gridElements(grid, defaultGridBox(page, single)) commit(d => ({ ...d, spreads: d.spreads.map((sp, i) => (i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })), })) select(els.map(e => e.id)) } return { active, single, addElement, addText, addShape, addIcon, addFrame, addGrid } } type Adders = ReturnType /** The type tab: the plain styles and the big quotation mark, then the lockups of the Text styles shelf. */ function TextTab({ page, source, t, addText }: { page: BookPageSetup source: ShelfSource t: (k: string) => string addText: AddText }) { return ( <>
{/* A mark of type rather than a shape: it sets in the face the rest of the page is set in. */}
) } /** Every shape by its group, the outline-only rectangle first among the basic ones. */ function ShapesTab({ addShape, t }: { addShape: Adders['addShape']; t: (k: string) => string }) { return ( <> {SHAPE_GROUPS.map(group => (
{group.id === 'basic' && ( )} {group.shapes.map(shape => ( ))}
))} ) } /** Empty frames in their styles and shapes, and the grids that lay several of them out at once. */ function FramesTab({ adders, t }: { adders: Adders; t: (k: string) => string }) { return ( <>
{FRAME_STYLE_OPTIONS.map(({ value, labelKey }) => ( ))}
{FRAME_SHAPES.map(shape => ( ))}
{GRIDS.map(grid => ( ))}
) } export function StudioElementsPanel({ page, t, source }: { page: BookPageSetup t: (k: string) => string /** The journal, for the year and the point a lockup is filled with. */ source: ShelfSource }) { const [tab, setTab] = useState('text') const tabsId = useId() const adders = useElementAdders(page) return ( <> idBase={tabsId} label={t('journey.studio.elements')} value={tab} onChange={setTab} columns={3} tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))} /> {/* The icons and the stickers carry a search of their own, which sits above their scrolling grid like the Content panel's above its own; the other tabs are short shelves and scroll as they are. */} {tab === 'icons' ? ( ) : tab === 'stickers' ? ( ) : (
{tab === 'text' && } {tab === 'shapes' && } {tab === 'lines' && ( adders.addElement(adders.active, el)} t={t} lead={( )} /> )} {tab === 'frames' && }
)}
) }