refactor(studio): share the typeface buttons and the weight line between the inspectors

The text inspector and the travel inspector each drew the same row of
typeface buttons and the same weight choice. Both now render FontButtons
and WeightLine from StudioTypeControls.tsx. The travel inspector keeps
casting its patches to Partial<BookElement>, so each side writes exactly
the fields it wrote before.
This commit is contained in:
Maurice
2026-10-09 15:01:53 +02:00
parent d08c949b14
commit e6930e00ac
4 changed files with 142 additions and 70 deletions
@@ -6,7 +6,6 @@ import {
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { useStudioStore } from '../../store/studioStore' import { useStudioStore } from '../../store/studioStore'
import { photoSrc } from './bookRender' import { photoSrc } from './bookRender'
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes' import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
import { ShapeGlyph } from './StudioElementsPanel' import { ShapeGlyph } from './StudioElementsPanel'
import { TravelInspector } from './StudioTravelInspector' import { TravelInspector } from './StudioTravelInspector'
@@ -14,6 +13,7 @@ import { Swatches } from './StudioSwatches'
import { formatBookCoords } from './entryText' import { formatBookCoords } from './entryText'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary' import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls' import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import { FontButtons, WeightLine } from './StudioTypeControls'
import type { JourneySource } from './StudioSidebar' import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip' import { Tooltip } from '../shared/Tooltip'
@@ -320,41 +320,9 @@ export function StudioInspector({
nothing about a typeface — and grouped, because "which serif" nothing about a typeface — and grouped, because "which serif"
is a different question from "serif or sans". is a different question from "serif or sans".
*/} */}
<div className="st-fonts"> <FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch)} />
{BOOK_FONT_ORDER.map(id => {
const font = BOOK_FONTS[id]
return (
<button type="button"
key={id}
className={`st-font ${el.font === id ? 'is-on' : ''}`}
style={{ fontFamily: font.stack }}
onClick={() => set({
font: id,
// A family that does not ship this weight would render a
// synthesised bold — a smeared regular in print — so the
// weight moves to the nearest one it really has.
weight: nearestWeight(id, el.weight) as typeof el.weight,
})}
title={font.name}
>
{font.name}
</button>
)
})}
</div>
<div style={{ marginTop: 10 }}> <div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.weight')}> <WeightLine font={el.font} weight={el.weight} onPick={weight => set({ weight })} t={t} />
<Choice
value={el.weight}
options={([400, 500, 600, 700] as const).map(w => ({
value: w,
label: String(w),
disabled: !hasWeight(el.font, w),
title: hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing'),
}))}
onPick={weight => set({ weight })}
/>
</Line>
<Line label={t('journey.studio.align')}> <Line label={t('journey.studio.align')}>
<div className="st-row"> <div className="st-row">
{([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => ( {([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
@@ -6,7 +6,7 @@ import type {
import { BOOK_METRICS } from '@trek/shared' import { BOOK_METRICS } from '@trek/shared'
import { isStale, refreshPatch } from './travelRefresh' import { isStale, refreshPatch } from './travelRefresh'
import { Swatches } from './StudioSwatches' import { Swatches } from './StudioSwatches'
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts' import { FontButtons, WeightLine } from './StudioTypeControls'
import { sourceIdOf, useMapSources } from './mapSources' import { sourceIdOf, useMapSources } from './mapSources'
import { printDpi, tileView } from './mapTiles' import { printDpi, tileView } from './mapTiles'
import { useCartoApiKey } from '../../hooks/useTileUrl' import { useCartoApiKey } from '../../hooks/useTileUrl'
@@ -82,41 +82,14 @@ export function TravelInspector({ el, stats, set, t }: TravelInspectorProps) {
* wrong typeface and no way to fix it. * wrong typeface and no way to fix it.
*/} */}
<Section label={t('journey.studio.typography')} defaultOpen={false}> <Section label={t('journey.studio.typography')} defaultOpen={false}>
<div className="st-fonts"> <FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} />
{BOOK_FONT_ORDER.map(id => {
const font = BOOK_FONTS[id]
return (
<button type="button"
key={id}
className={`st-font ${el.font === id ? 'is-on' : ''}`}
style={{ fontFamily: font.stack }}
onClick={() => set({
font: id,
// A family that does not ship this weight renders a
// synthesised bold — a smeared regular in print — so the
// weight moves to the nearest one it really has.
weight: nearestWeight(id, el.weight) as typeof el.weight,
} as Partial<BookElement>)}
title={font.name}
>
{font.name}
</button>
)
})}
</div>
<div style={{ marginTop: 10 }}> <div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.weight')}> <WeightLine
<Choice font={el.font}
value={el.weight} weight={el.weight}
options={([400, 500, 600, 700] as const).map(w => ({ onPick={weight => set({ weight } as Partial<BookElement>)}
value: w, t={t}
label: String(w), />
disabled: !hasWeight(el.font, w),
title: hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing'),
}))}
onPick={weight => set({ weight } as Partial<BookElement>)}
/>
</Line>
<Line label={t('journey.studio.textScale')}> <Line label={t('journey.studio.textScale')}>
<Choice <Choice
value={el.textScale} value={el.textScale}
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { BOOK_FONT_ORDER } from './bookFonts';
import { FontButtons, WeightLine } from './StudioTypeControls';
const t = (k: string) => k;
describe('FontButtons', () => {
it('offers every bundled family in the picker order and marks the current one', () => {
render(<FontButtons font="serif" weight={400} onPick={vi.fn()} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(BOOK_FONT_ORDER.length);
expect(screen.getByRole('button', { name: 'Lora' }).className).toContain('is-on');
expect(screen.getByRole('button', { name: 'Poppins' }).className).not.toContain('is-on');
});
it('keeps the weight when the new family ships it', () => {
const onPick = vi.fn();
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
fireEvent.click(screen.getByRole('button', { name: 'Inter' }));
expect(onPick).toHaveBeenCalledWith({ font: 'inter', weight: 700 });
});
it('moves the weight to one the new family really has', () => {
const onPick = vi.fn();
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
fireEvent.click(screen.getByRole('button', { name: 'Bebas Neue' }));
expect(onPick).toHaveBeenCalledWith({ font: 'bebas', weight: 400 });
});
});
describe('WeightLine', () => {
it('greys the weights the family does not ship and says why', () => {
render(<WeightLine font="bebas" weight={400} onPick={vi.fn()} t={t} />);
expect(screen.getByText('journey.studio.weight')).toBeTruthy();
const bold = screen.getByRole('radio', { name: '700' }) as HTMLButtonElement;
expect(bold.disabled).toBe(true);
expect(bold.title).toBe('journey.studio.weightMissing');
const regular = screen.getByRole('radio', { name: '400' }) as HTMLButtonElement;
expect(regular.disabled).toBe(false);
expect(regular.getAttribute('aria-checked')).toBe('true');
});
it('hands the picked weight back', () => {
const onPick = vi.fn();
render(<WeightLine font="sans" weight={400} onPick={onPick} t={t} />);
fireEvent.click(screen.getByRole('radio', { name: '600' }));
expect(onPick).toHaveBeenCalledWith(600);
});
});
@@ -0,0 +1,81 @@
import type { BookTextElement } from '@trek/shared';
import type { BookFontId } from './bookFonts';
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts';
import { Choice, Line } from './StudioControls';
/**
* The typeface buttons and the weight line, shared by the text inspector and
* the travel inspector so both set type the same way.
*/
/** The weight set the book schema allows, taken from it so there is one list. */
type BookWeight = BookTextElement['weight'];
const WEIGHTS = [400, 500, 600, 700] as const;
/** One button per bundled family, each set in its own face. */
export function FontButtons({
font,
weight,
onPick,
}: {
font: string;
weight: BookWeight;
onPick: (patch: { font: BookFontId; weight: BookWeight }) => void;
}) {
return (
<div className="st-fonts">
{BOOK_FONT_ORDER.map((id) => {
const face = BOOK_FONTS[id];
return (
<button
type="button"
key={id}
className={`st-font ${font === id ? 'is-on' : ''}`}
style={{ fontFamily: face.stack }}
onClick={() =>
onPick({
font: id,
// A family that does not ship this weight would render a
// synthesised bold, a smeared regular in print, so the
// weight moves to the nearest one it really has.
weight: nearestWeight(id, weight) as BookWeight,
})
}
title={face.name}
>
{face.name}
</button>
);
})}
</div>
);
}
/** The four weights, with the ones the family does not ship greyed out. */
export function WeightLine({
font,
weight,
onPick,
t,
}: {
font: string;
weight: BookWeight;
onPick: (weight: BookWeight) => void;
t: (k: string) => string;
}) {
return (
<Line label={t('journey.studio.weight')}>
<Choice
value={weight}
options={WEIGHTS.map((w) => ({
value: w,
label: String(w),
disabled: !hasWeight(font, w),
title: hasWeight(font, w) ? undefined : t('journey.studio.weightMissing'),
}))}
onPick={onPick}
/>
</Line>
);
}