import { AlignVerticalJustifyCenter, AlignVerticalJustifyEnd, AlignVerticalJustifyStart, CaseLower, CaseSensitive, CaseUpper, } from 'lucide-react'; import type { BookElement, BookSpread, BookTextElement } from '@trek/shared'; import { bookTextIsDrawn } from '@trek/shared'; import '../../styles/studio-text.css'; import { photoSrc } from './bookRender'; import { FocalPad } from './inspector/FocalPad'; import { textEffectsInUse, textPlacementInUse } from './inspector/inspectorModel'; import { Choice, Line, NumField, Section, Switch } from './StudioControls'; import { Swatches } from './StudioSwatches'; import { defaultBand, defaultOutline, defaultShadow, firstFillPhoto, isHollow } from './textEffects'; /** * The text inspector's effects: how the letters are cased and where the lines * sit, whether the text shrinks to its frame, and the outline, hard shadow, * marker band, curve and fill (book-text-effects.schema.ts in shared). Both * groups start folded and open on a text that already uses them. * * Every effect is a switch that arrives already looking right: an outline in * the colour that stands out from the letters at a twentieth of their height, * a shadow a twelfth of it down and across, a band with a quarter em of room. * A first press that shows something good is the difference between a feature * people use and a row of switches nobody turns on twice. */ type Patch = Partial; type SetPatch = (patch: Partial) => void; interface Props { el: BookTextElement; /** One undo step. */ set: SetPatch; /** A change inside a drag, as the focal pad makes. */ live: SetPatch; /** The journey's photos, for filling the letters with one. */ photos: readonly { photoId: number }[]; spread: BookSpread | undefined; t: (k: string) => string; } /** How many photos the fill offers at once: two rows of the panel's thumbnails. */ const FILL_PHOTOS = 12; function CaseAndPlace({ el, set, t }: Pick) { return (
, title: t('journey.studio.fx.caseNone') }, { value: 'upper' as const, label: , title: t('journey.studio.fx.caseUpper') }, { value: 'lower' as const, label: , title: t('journey.studio.fx.caseLower') }, { value: 'smallcaps' as const, label: t('journey.studio.fx.caseSmall') }, ]} onPick={(transform) => set({ transform } as Patch)} /> , title: t('journey.studio.fx.top'), }, { value: 'middle' as const, label: , title: t('journey.studio.fx.middle'), }, { value: 'bottom' as const, label: , title: t('journey.studio.fx.bottom'), }, ]} onPick={(vAlign) => set({ vAlign } as Patch)} /> set((el.fit === 'shrink' ? { fit: 'none', size: el.fitMax ?? el.size } : { fit: 'shrink' }) as Patch) } /> {el.fit === 'shrink' &&

{t('journey.studio.fx.fitHint')}

}
); } function OutlineControls({ el, set, t }: Pick) { const outline = el.outline; return (
set({ outline: outline ? null : defaultOutline(el), ...(outline && isHollow(el) ? { fillPaint: null } : {}), } as Patch) } /> {outline && (
set({ outline: { ...outline, width } } as Patch)} /> set({ outline: { ...outline, color } } as Patch)} />
)}
); } function ShadowControls({ el, set, t }: Pick) { const shadow = el.shadow; return (
set({ shadow: shadow ? null : defaultShadow(el) } as Patch)} /> {shadow && (
set({ shadow: { ...shadow, dx } } as Patch)} /> set({ shadow: { ...shadow, dy } } as Patch)} />
set({ shadow: { ...shadow, alpha: v / 100 } } as Patch)} /> set({ shadow: { ...shadow, color } } as Patch)} />
)}
); } function BandControls({ el, set, t }: Pick) { const band = el.highlight; if (bookTextIsDrawn(el)) { return

{t('journey.studio.fx.bandDrawn')}

; } return (
set({ highlight: band ? null : defaultBand(el) } as Patch)} /> {band && (
set({ highlight: { ...band, padX } } as Patch)} /> set({ highlight: { ...band, padY } } as Patch)} />
set({ highlight: { ...band, radius } } as Patch)} /> set({ highlight: { ...band, color } } as Patch)} />
)}
); } function CurveControls({ el, set, t }: Pick) { const curve = el.curve; return (
set({ curve: type === 'none' ? null : { type, bend: curve?.bend ?? 40, start: curve?.start ?? 0 }, } as Patch) } /> {curve?.type === 'arc' && ( set({ curve: { ...curve, bend } } as Patch)} /> )} {curve?.type === 'circle' && ( set({ curve: { ...curve, start } } as Patch)} /> )}
); } type FillKind = 'colour' | 'hollow' | 'photo'; function fillKindOf(el: BookTextElement): FillKind { if (el.fillPaint?.type === 'photo') return 'photo'; return isHollow(el) ? 'hollow' : 'colour'; } function FillControls({ el, set, live, photos, spread, t }: Props) { const kind = fillKindOf(el); const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null; const pick = (next: FillKind) => { if (next === 'colour') return set({ fillPaint: null } as Patch); if (next === 'hollow') { return set({ fillPaint: { type: 'solid', color: el.color, alpha: 0 }, // Hollow letters are their outline: one appears with them if there was none. outline: el.outline ?? { ...defaultOutline(el), color: el.color }, } as Patch); } const photoId = firstFillPhoto(spread, photos); if (photoId != null) set({ fillPaint: { type: 'photo', photoId, focalX: 0.5, focalY: 0.5, zoom: 1 } } as Patch); }; return (
{!photos.length &&

{t('journey.studio.fx.noPhotos')}

} {photo && (
{photos.slice(0, FILL_PHOTOS).map((p) => ( ))}
live({ fillPaint: { ...photo, focalX, focalY } } as Patch)} onCommit={(focalX, focalY) => set({ fillPaint: { ...photo, focalX, focalY } } as Patch)} hint={t('journey.studio.fx.focalHint')} /> set({ fillPaint: { ...photo, zoom: v / 100 } } as Patch)} />
)}
); } export function StudioTextEffects(props: Props) { const { el, set, t } = props; return ( <>
{bookTextIsDrawn(el) &&

{t('journey.studio.fx.drawnHint')}

}
); }