Files
TREK/client/src/components/Studio/StudioBackgroundSection.tsx
T
Maurice 84e18e4954 fix(studio): paste style, the page background, layouts and fill page keep the engine's rules
Paste style wrote the copied fields straight onto the element, although a
pasted look is meant to be the style operation the assistant sends. It now
runs the engine's own restyle (bookElementStyled): a colour or face pasted by
hand takes the element off the theme, so the next theme applied no longer
brings the old colour back; a plain fill replaces the paint of the shape it
lands on instead of staying hidden under it; and a line takes another line's
look without its points, which are its course, not its look.

A background picked by hand in the inspector kept the page's link to the
theme, so the next theme applied set the page back to the theme's colour. The
picker and the background operation now paint through one helper that takes
the link away (withSpreadPaintByHand). Giving one page's background to every
page carries that page's link along, or takes the others' away when it was
painted by hand.

A built-in layout, a book's own layout and laying a page out again from its
entry ignored locks in the Studio: a locked shape was dropped and a locked
photo poured into a slot unlocked, while the assistant's layout operation left
both where they were. All four now lay a page out around its locked elements
through one function (layoutAroundLocks), which the engine's layout operation
uses too.

Fill page and fill spread kept the photo in its group, so the background
picture was selected and dragged with its caption and listed under the group
at the front. It leaves its group as the engine's fill does, and a group left
with one member goes.
2026-10-24 04:06:27 +00:00

132 lines
4.8 KiB
TypeScript

import { bookElementId, spreadPaint, withSpreadPaintByHand, type BookPageSetup } from '@trek/shared';
import { Check, CopyCheck, ImageOff } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useStudioStore } from '../../store/studioStore';
import { photoSrc } from './bookRender';
import {
backgroundCandidates,
backgroundPhotoOf,
backgroundToAll,
withBackgroundPhoto,
withoutBackgroundPhoto,
} from './paint/backgroundPhoto';
import { spreadPhotoIds } from './paint/paletteModel';
import { usePaintChannel } from './paint/usePaintChannel';
import { Section } from './StudioControls';
import { StudioPaintPicker } from './StudioPaintPicker';
import type { JourneySource, StudioT } from './StudioSidebar';
/**
* The page's background, in the inspector when nothing is selected.
*
* Until now only the assistant could set it; a person had to lay a shape
* across the page and remember to send it to the back. Three things, in the
* order people reach for them: the paint itself, the same background for
* every page, and a photograph under the whole page.
*/
export function StudioBackgroundSection({
spreadIndex,
page,
source,
t,
}: {
spreadIndex: number;
page: BookPageSetup;
source: JourneySource;
t: StudioT;
}) {
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
const commit = useStudioStore((s) => s.commit);
// A colour picked here is the person's, so the page stops following the theme's paper (as the assistant's does).
const channel = usePaintChannel((paint) => (doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaintByHand(sp, paint) : sp)),
}));
// "Applied" stands in the button for a moment, so a press that changed pages out of sight is seen to have worked.
const [applied, setApplied] = useState(false);
useEffect(() => {
if (!applied) return;
const timer = window.setTimeout(() => setApplied(false), 1600);
return () => window.clearTimeout(timer);
}, [applied]);
const current = backgroundPhotoOf(spread, page);
const candidates = useMemo(() => {
if (!spread) return [];
const entry = source.entries.find((e) => e.id === spread.entryId);
return backgroundCandidates(
spreadPhotoIds(spread),
entry?.photoIds ?? [],
source.photos.map((p) => p.photoId)
);
}, [spread, source]);
if (!spread) return null;
const setPhoto = (photoId: number) =>
commit((doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => {
if (i !== spreadIndex) return sp;
return withBackgroundPhoto(sp, page, photoId, () => bookElementId('p')) ?? sp;
}),
}));
const clearPhoto = () =>
commit((doc) => ({
...doc,
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withoutBackgroundPhoto(sp, page) : sp)),
}));
const applyToAll = () => {
commit((doc) => backgroundToAll(doc, spreadIndex));
setApplied(true);
};
return (
<Section label={t('journey.studio.paint.background')}>
<StudioPaintPicker
value={spreadPaint(spread)}
channel={channel}
t={t}
noneLabel={t('journey.studio.paint.paper')}
/>
<button type="button" className={`st-chip st-paint-apply ${applied ? 'is-done' : ''}`} onClick={applyToAll}>
{applied ? <Check size={13} strokeWidth={2.4} /> : <CopyCheck size={13} />}
{applied ? t('journey.studio.paint.appliedAll') : t('journey.studio.paint.applyAll')}
</button>
<div className="st-paint-field">
<span className="st-paint-caption">{t('journey.studio.paint.photoBackground')}</span>
{candidates.length ? (
<>
<div className="st-bgphoto-grid">
{candidates.map((id) => (
<button
type="button"
key={id}
className={`st-bgphoto ${current?.photoId === id ? 'is-on' : ''}`}
aria-pressed={current?.photoId === id}
aria-label={t('journey.studio.paint.photoBackground')}
onClick={() => (current?.photoId === id ? clearPhoto() : setPhoto(id))}
>
<img src={photoSrc(id, false)} alt="" loading="lazy" draggable={false} />
</button>
))}
</div>
<p className="st-hint st-paint-hint">{t('journey.studio.paint.photoBackgroundHint')}</p>
{current && (
<button type="button" className="st-chip is-danger st-paint-apply" onClick={clearPhoto}>
<ImageOff size={13} />
{t('journey.studio.paint.removePhotoBackground')}
</button>
)}
</>
) : (
<p className="st-hint st-paint-hint">{t('journey.studio.paint.noPhotos')}</p>
)}
</div>
</Section>
);
}