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 (
)
}
/** 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 (
<>
{/*
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.
*/}