mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02a12ce918 | |||
| 2f4644d434 | |||
| dd2431db97 | |||
| 746f53ff7e | |||
| 7f9a4da52a | |||
| 8043b7952d | |||
| e198392060 | |||
| deea669e28 | |||
| d6b624e400 | |||
| 263d650460 | |||
| a3fc696510 | |||
| 9f6f62c6ae | |||
| a70ff750a6 | |||
| 815a523d1c | |||
| 30382dc366 |
@@ -53,7 +53,7 @@
|
||||
"components/Studio/PageNumbers.tsx": 1,
|
||||
"components/Studio/PeerCursors.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 8,
|
||||
"components/Studio/StudioCanvas.tsx": 22,
|
||||
"components/Studio/StudioCanvas.tsx": 12,
|
||||
"components/Studio/StudioElementsPanel.tsx": 1,
|
||||
"components/Studio/StudioExport.tsx": 1,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1,
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
"src/components/Settings/NotificationsTab.test.tsx": 1,
|
||||
"src/components/Settings/PasskeysSection.tsx": 1,
|
||||
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
|
||||
"src/components/Studio/StudioCanvas.tsx": 1,
|
||||
"src/components/Studio/StudioInspector.tsx": 1,
|
||||
"src/components/Todo/todoListModel.ts": 1,
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Check, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
|
||||
import { Check, CheckCheck, ListChecks, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { blockKeyOf } from '../../store/assistantMapModel';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { DialogButton, NEUTRAL_TINT } from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||
@@ -13,6 +14,8 @@ import AssistantWeekOverview from './AssistantWeekOverview';
|
||||
import { type ChangeItem, undoLeftMs } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { ChangeRows } from './cards/ChangeCardSlots';
|
||||
import CardHeader from './cards/parts/CardHeader';
|
||||
import { CardFoot } from './cards/parts/CardSection';
|
||||
import { ICON_BUTTON } from './cards/parts/assistantTones';
|
||||
import { useChangeExclusions } from './cards/useChangeExclusions';
|
||||
import { changePoints, type DayChange } from './dayAfterModel';
|
||||
@@ -60,6 +63,9 @@ function undoableNow(item: ChangeItem, undone: Readonly<Record<string, true>>, n
|
||||
);
|
||||
}
|
||||
|
||||
/** The band of a card whose changes are all decided: the green of a done booking. */
|
||||
const DONE_TINT = 'color-mix(in srgb, var(--success) 9%, transparent)';
|
||||
|
||||
/** The waiting changes the user left on that touch the day plan. */
|
||||
function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): DayChange[] {
|
||||
return items.flatMap((i) =>
|
||||
@@ -69,17 +75,18 @@ function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's head: what the card is, what can be done with it as a whole, and how many changes it holds. */
|
||||
/** The change card's band: tinted green once everything in it is decided, what it holds and its actions. */
|
||||
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-1 flex min-h-7 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-content-secondary">
|
||||
{t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||
</span>
|
||||
{actions}
|
||||
<span className="text-caption tabular-nums text-content-faint">{count}</span>
|
||||
</div>
|
||||
<CardHeader
|
||||
icon={open ? ListChecks : CheckCheck}
|
||||
tone={open ? 'info' : 'success'}
|
||||
tint={open ? NEUTRAL_TINT : DONE_TINT}
|
||||
title={t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||
count={count}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,7 +231,7 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
|
||||
const open = pending.length > 0;
|
||||
return (
|
||||
<section className="trek-popover-enter rounded-xl border border-edge-faint bg-surface-card px-3 pb-2 pt-2.5 shadow-card">
|
||||
<section className="trek-popover-enter overflow-hidden rounded-[16px] border border-edge-faint bg-surface-card px-3.5 pb-3 pt-3 shadow-card">
|
||||
<CardHead
|
||||
open={open}
|
||||
count={items.length}
|
||||
@@ -257,25 +264,19 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
<p className="sr-only" role="status">
|
||||
{t('assistant.change.pending', { count: pending.length })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 border-t border-edge-faint pt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRunning}
|
||||
onClick={() => void submit(false)}
|
||||
className="rounded-lg px-3 py-1.5 text-body text-content-secondary hover:bg-surface-hover hover:text-content disabled:opacity-50"
|
||||
>
|
||||
<CardFoot>
|
||||
<DialogButton disabled={isRunning} onClick={() => void submit(false)}>
|
||||
{t('assistant.change.discard')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
disabled={isRunning || chosen === 0}
|
||||
onClick={() => void submit(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-body font-medium text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
icon={<Check size={14} aria-hidden="true" />}
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
|
||||
</button>
|
||||
</div>
|
||||
</DialogButton>
|
||||
</CardFoot>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useLocation, useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import { rememberListsSubTab } from '../../hooks/useListsSubTab';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantWindow from './AssistantWindow';
|
||||
import { plannerSearchOf } from './assistantShowOnMap';
|
||||
@@ -22,7 +23,7 @@ function useRouteTripId(): number | null {
|
||||
* notification opens a place, a tab of the trip opens as `?tab=` (a list's
|
||||
* own tab remembered first), and another page is simply navigated to. A day
|
||||
* picked in the chat (its weather) selects that day the same way, and "On
|
||||
* map" on a card brings the planner's map back first.
|
||||
* map" on a card brings the planner's map back first from a tab without one.
|
||||
*/
|
||||
export default function AssistantHost() {
|
||||
const navigate = useNavigate();
|
||||
@@ -54,8 +55,12 @@ export default function AssistantHost() {
|
||||
[navigate, openPlanner, tripId]
|
||||
);
|
||||
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
|
||||
// "On map" from another tab of the planner (Tours, Costs, ...) first brings the map back.
|
||||
const revealMap = useCallback(() => openPlanner('?tab=plan'), [openPlanner]);
|
||||
// "On map" from a tab without a map (Costs, Lists, ...) first brings the plan's map back; the Tours tab
|
||||
// shows the route on its own map. A map on screen tells the assistant its view, which is how it is known.
|
||||
const revealMap = useCallback(() => {
|
||||
if (useAssistantStore.getState().mapView?.tripId === tripId) return;
|
||||
openPlanner('?tab=plan');
|
||||
}, [openPlanner, tripId]);
|
||||
|
||||
return <AssistantWindow trip={trip} onOpen={open} onFocusDay={focusDay} onRevealMap={revealMap} />;
|
||||
}
|
||||
|
||||
@@ -111,6 +111,14 @@ describe('the questions an answer offers', () => {
|
||||
|
||||
it('FE-AI-FOLLOW-007: an answer that asks the user something waits for their answer, not for "tell me more"', () => {
|
||||
expect(followUpsFor(of([{ type: 'text', text: 'Should I plan it anyway?' }]), true)).toEqual([]);
|
||||
// Not the found places' chips either, but the model's own answers to its question.
|
||||
const asked = of([
|
||||
{ type: 'data-trek-places', data: { toolCallId: 's', places: [], more: 0 } },
|
||||
{ type: 'text', text: 'Shall I plan it there?' },
|
||||
]);
|
||||
expect(followUpsFor(asked, true)).toEqual([]);
|
||||
const offered = of([...asked.parts, { type: 'data-trek-follow-ups', data: { questions: ['Yes, plan it'] } }]);
|
||||
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
|
||||
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,11 +126,12 @@ export function followUpsFor(message: AssistantMessage, canEdit: boolean): reado
|
||||
if (fromModel.length > 0) return fromModel;
|
||||
const fromCards = cardFollowUps(message, canEdit);
|
||||
if (fromCards.length > 0) return fromCards;
|
||||
// An answer that asks the user something waits for their answer: only the model's own offer fits it.
|
||||
if (endsWithQuestion(message)) return [];
|
||||
const fromChanges = changeFollowUps(message, canEdit);
|
||||
if (fromChanges !== null) return fromChanges.length > 0 ? fromChanges : GENERAL;
|
||||
const types = new Set(message.parts.map((part) => part.type));
|
||||
if (types.has('data-trek-places')) return PLACES;
|
||||
if (types.has('data-trek-weather')) return WEATHER;
|
||||
// An answer that asks the user something waits for their answer, not for "tell me more".
|
||||
return types.has('text') && !endsWithQuestion(message) ? GENERAL : [];
|
||||
return types.has('text') ? GENERAL : [];
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ function BodySlot({ item, settled }: { item: ChangeItem; settled: boolean }) {
|
||||
const view = item.card ? viewOf(item.card) : null;
|
||||
if (!view?.Body || !item.card) return null;
|
||||
return (
|
||||
<div className={`pb-1.5 ps-[38px] ${settled ? 'opacity-60' : ''}`}>
|
||||
<div className={`mt-1.5 pb-1.5 ${view.wideBody ? '' : 'ps-[38px]'} ${settled ? 'opacity-60' : ''}`}>
|
||||
<Slot item={item} view={view} name="Body" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AssistantMessage, AtlasCountryCard as CountryCard } from '@trek/shared';
|
||||
import { Briefcase, MapPin, Radio } from 'lucide-react';
|
||||
import { Briefcase, Globe2, MapPin, Radio } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
@@ -8,8 +8,10 @@ import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import { CardFacts } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardMore } from '../parts/CardRows';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { CONTINENT_LABEL, continentOf, shortDay, STATUS_LOOK } from './atlasCardModel';
|
||||
import { ATLAS_PAGE } from './atlasPage';
|
||||
import { ChangeFlag, FlagTile, StatusFact } from './AtlasParts';
|
||||
@@ -112,14 +114,15 @@ export default function AtlasCountryBlock({ cards }: { cards: readonly CardEntry
|
||||
if (countries.length === 0) return null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.domain.atlas')}>
|
||||
<CardHeader icon={Globe2} tone="info" title={t('assistant.domain.atlas')} />
|
||||
{countries.map((entry, index) => (
|
||||
<div key={entry.toolCallId} className={index > 0 ? 'mt-2 border-t border-edge-faint pt-2' : ''}>
|
||||
{entry.card.kind === 'atlasCountry' && <CountrySection card={entry.card} />}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1 flex justify-end">
|
||||
<CardFoot>
|
||||
<CardOpenButton target={ATLAS_PAGE} labelKey="assistant.card.atlas.open" />
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,6 @@ export function ExpenseFacts({ card }: SlotProps) {
|
||||
<>
|
||||
<CategoryFact row={row} />
|
||||
<DateFact row={row} />
|
||||
{row.place && <PlaceFact place={row.place} />}
|
||||
{split && (
|
||||
<FactChange
|
||||
icon={Tag}
|
||||
@@ -92,13 +91,16 @@ export function ExpenseFacts({ card }: SlotProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The split under the row, and the expense's note under it: too long for the faint line beside the icons. */
|
||||
/** Under the row: the place it belongs to beside the split, and the expense's note under them. */
|
||||
export function ExpenseBody({ card }: SlotProps) {
|
||||
const row = card.rows[0];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<span className="flex flex-col items-start gap-1">
|
||||
<SplitLine row={row} />
|
||||
<span className="flex flex-col items-start gap-1.5">
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
{row.place && <PlaceFact place={row.place} />}
|
||||
<SplitLine row={row} />
|
||||
</span>
|
||||
{row.note && <NotePill note={row.note} />}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -121,8 +121,7 @@ describe('an expense in a change row', () => {
|
||||
expect(screen.getByText(money(84))).toHaveClass('line-through');
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-012: the facts name the category, day and place, and what an update changes', () => {
|
||||
const onOpen = vi.fn();
|
||||
it('FE-AI-CARD-BUD-012: the facts name the category and day, and what an update changes', () => {
|
||||
const update = expense({
|
||||
category: 'transport',
|
||||
date: '2026-06-05',
|
||||
@@ -132,7 +131,7 @@ describe('an expense in a change row', () => {
|
||||
split: 'equal',
|
||||
before: { name: null, amount: null, category: 'food', date: '2026-06-03', split: 'custom' },
|
||||
});
|
||||
inView(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />, { onOpen });
|
||||
inView(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />);
|
||||
expect(screen.getByText('Food & drink')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
||||
expect(screen.getByText('Split unevenly')).toHaveClass('line-through');
|
||||
@@ -140,6 +139,14 @@ describe('an expense in a change row', () => {
|
||||
expect(screen.getByText('1 receipt')).toBeInTheDocument();
|
||||
// The note is too long for the faint line beside the row's buttons: it sits under the row.
|
||||
expect(screen.queryByText('Window table')).toBeNull();
|
||||
// The place sits under the row, beside the split.
|
||||
expect(screen.queryByText('Lokanta')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-012c: the place sits under the row beside the split, and opens on the map', () => {
|
||||
const onOpen = vi.fn();
|
||||
const row = expense({ place: { id: 12, name: 'Lokanta' } });
|
||||
inView(<ExpenseBody card={expenses([row])} item={changeItem('a', null)} />, { onOpen });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Lokanta/ }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 12, dayId: null });
|
||||
});
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface CardView<K extends TrekCardKind> {
|
||||
Facts?: ComponentType<CardSlotProps<K>>;
|
||||
Body?: ComponentType<CardSlotProps<K>>;
|
||||
ownsSubject?: boolean;
|
||||
/** The body runs the whole width of the change card instead of under the row's text (a tour's drawing). */
|
||||
wideBody?: boolean;
|
||||
Group?: ComponentType<CardRunProps & { place: 'head' | 'foot' }>;
|
||||
Run?: ComponentType<CardRunProps>;
|
||||
runnable?(card: TrekCardOf<K>): boolean;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { AssistantMessage, JourneyEntryLook } from '@trek/shared';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { ArrowUpRight, BookOpen } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { DAY_COLORS } from '../../../Journey/dayColors';
|
||||
import { cardTitle } from '../../../Journey/journeyCard';
|
||||
import JourneyEntryCover from '../../../Journey/JourneyEntryCover';
|
||||
import { LinkedJournalIcon } from '../../../Trips/LinkedJournalIcon';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import CardStrip from '../parts/CardStrip';
|
||||
import { type JournalEntries, coverEntryOf, mergeJournalCards, titleSourceOf } from './journeyCardModel';
|
||||
import { EntryMarks, EntryStory, EntryThumb, EntryWhen } from './JourneyEntryParts';
|
||||
@@ -33,9 +34,6 @@ const TITLE_BUTTON =
|
||||
'block w-full rounded-md text-start outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent';
|
||||
/** The phone's taller target, the title still at the top of the row. */
|
||||
const TITLE_BUTTON_TALL = `${TITLE_BUTTON} min-h-10`;
|
||||
const FOOTER = 'mt-1 flex items-center gap-2 border-t border-edge-faint pt-2 text-caption';
|
||||
const FOOTER_TITLE = 'min-w-0 flex-1 truncate text-content-muted';
|
||||
const FAINT = 'text-content-faint';
|
||||
const OPEN = 'inline-flex flex-none items-center gap-1 font-medium text-accent hover:underline';
|
||||
const OPEN_TALL = 'inline-flex h-10 flex-none items-center gap-1 font-medium text-accent hover:underline';
|
||||
/** The size and corners of the journal's own tile, so the count stands in the row like one more entry. */
|
||||
@@ -47,29 +45,40 @@ const TILE = 'flex-none snap-start';
|
||||
|
||||
type Open = ((entryId: number | null) => void) | null;
|
||||
|
||||
/** The journal's name and the way into it, under the card. */
|
||||
function JournalFooter({ journal, open }: { journal: JournalEntries; open: Open }) {
|
||||
/**
|
||||
* The way into the journal under its entries: in the foot bar of the card, or
|
||||
* under the strip of tiles with how many entries it holds, which the strip
|
||||
* has no band to say.
|
||||
*/
|
||||
function JournalFooter({ open, count }: { open: Open; count?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const title = journal.journalTitle ?? t('assistant.card.journey.label');
|
||||
const count = journal.entries.length + journal.more;
|
||||
const button = open && (
|
||||
<button type="button" onClick={() => open(null)} className={variant === 'fullscreen' ? OPEN_TALL : OPEN}>
|
||||
{t('assistant.card.journey.open')}
|
||||
<ArrowUpRight size={12} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
);
|
||||
if (count === undefined) return button ? <CardFoot>{button}</CardFoot> : null;
|
||||
return (
|
||||
<div className={FOOTER}>
|
||||
<LinkedJournalIcon size={12} className={FAINT} />
|
||||
<span title={title} className={FOOTER_TITLE}>
|
||||
<bdi>{title}</bdi>
|
||||
{count > 2 && <span className="ms-1.5 tabular-nums">{t('assistant.card.journey.entries', { count })}</span>}
|
||||
<div className="mt-1 flex items-center gap-2 text-caption">
|
||||
<span className="min-w-0 flex-1 truncate tabular-nums text-content-muted">
|
||||
{t('assistant.card.journey.entries', { count })}
|
||||
</span>
|
||||
{open && (
|
||||
<button type="button" onClick={() => open(null)} className={variant === 'fullscreen' ? OPEN_TALL : OPEN}>
|
||||
{t('assistant.card.journey.open')}
|
||||
<ArrowUpRight size={12} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The band of the entries: the journal's name, and how many entries it holds once there are more than two. */
|
||||
function JournalHead({ journal }: { journal: JournalEntries }) {
|
||||
const { t } = useTranslation();
|
||||
const title = journal.journalTitle ?? t('assistant.card.journey.label');
|
||||
const count = journal.entries.length + journal.more;
|
||||
const meta = count > 2 ? t('assistant.card.journey.entries', { count }) : undefined;
|
||||
return <CardHeader icon={BookOpen} tone="info" title={title} meta={meta} />;
|
||||
}
|
||||
|
||||
/** One entry as a row: its photo, its title (the way to it), its facts and the start of its text. */
|
||||
function EntryRow({
|
||||
look,
|
||||
@@ -116,10 +125,11 @@ function JournalRows({ journal, fallbackRef }: { journal: JournalEntries; fallba
|
||||
const hover = useEntryHover(journal.journeyId, fallbackRef);
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.journey.label')}>
|
||||
<JournalHead journal={journal} />
|
||||
{journal.entries.map((look, index) => (
|
||||
<EntryRow key={look.entryId ?? `new-${index}`} look={look} divided={index > 0} open={open} hover={hover} />
|
||||
))}
|
||||
<JournalFooter journal={journal} open={open} />
|
||||
<JournalFooter open={open} />
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -152,7 +162,7 @@ function JournalStrip({ journal, fallbackRef }: { journal: JournalEntries; fallb
|
||||
</li>
|
||||
)}
|
||||
</CardStrip>
|
||||
<JournalFooter journal={journal} open={open} />
|
||||
<JournalFooter open={open} count={journal.entries.length + journal.more} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { PackingListCard as PackingCard, PackingCardGroup } from '@trek/shared';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Backpack, Check } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { PackedBar, PackTile } from '../../../Tours/packing/packingLinkParts';
|
||||
import { PackedBar } from '../../../Tours/packing/packingLinkParts';
|
||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import { isRunnable, mergePackingCards, packingColorOf, rowCount } from './checklistModel';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { isRunnable, mergePackingCards, rowCount } from './checklistModel';
|
||||
import { ChecklistFoot, ChecklistRun, type RunEntry, type RunProps } from './checklistParts';
|
||||
import { PackingGroup, PackingRowAside, PackingRowFacts } from './packingParts';
|
||||
|
||||
@@ -62,7 +64,7 @@ function useTitle(card: PackingCard): string {
|
||||
: t('assistant.card.packing.title');
|
||||
}
|
||||
|
||||
/** The head of an answer: the tile with the count, the title, how much is packed, and "All packed" once it is. */
|
||||
/** The head band of an answer: the backpack, the title, how much is packed with its bar, and "All packed" once it is. */
|
||||
function PackingHead({ card }: { card: PackingCard }) {
|
||||
const { t } = useTranslation();
|
||||
const title = useTitle(card);
|
||||
@@ -73,50 +75,46 @@ function PackingHead({ card }: { card: PackingCard }) {
|
||||
if (source.type === 'tour') caption = t('assistant.card.packing.forTour');
|
||||
else if (source.type === 'template') caption = t('assistant.card.packing.templateAdds', { count: source.itemCount });
|
||||
else if (card.progress && total > 0) caption = t('tours.packing.packed', { packed, count: total });
|
||||
const first = card.groups[0]?.key;
|
||||
const color =
|
||||
source.type === 'tour' && source.category !== null && first?.by === 'category'
|
||||
? packingColorOf(first.slot, source.category)
|
||||
: null;
|
||||
const done = card.progress !== null && total > 0 && packed === total;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<PackTile count={source.type === 'template' ? source.itemCount : total} color={color} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p title={title} className="m-0 line-clamp-2 text-body font-semibold leading-tight text-content">
|
||||
{title}
|
||||
</p>
|
||||
{caption && (
|
||||
<p title={caption} className="m-0 truncate text-caption text-content-faint">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{card.progress && total > 0 && packed === total && (
|
||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||
{t('tours.packing.allPacked')}
|
||||
</SoftPill>
|
||||
<CardHeader
|
||||
icon={Backpack}
|
||||
tone={done ? 'success' : 'info'}
|
||||
title={title}
|
||||
count={source.type === 'template' ? source.itemCount : total}
|
||||
meta={caption ?? undefined}
|
||||
actions={
|
||||
done ? (
|
||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||
{t('tours.packing.allPacked')}
|
||||
</SoftPill>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{card.progress && card.progress.total > 0 && (
|
||||
<div className="mt-2.5">
|
||||
<PackedBar packed={card.progress.packed} total={card.progress.total} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
/** One answer card. */
|
||||
function PackingAnswer({ card }: { card: PackingCard }) {
|
||||
const title = useTitle(card);
|
||||
const progress = card.progress;
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<PackingHead card={card} />
|
||||
{progress && progress.total > 0 && <PackedBar packed={progress.packed} total={progress.total} />}
|
||||
<PackingGroups card={card} inChange={false} />
|
||||
<PackingHead card={card} />
|
||||
<PackingGroups card={card} inChange={false} />
|
||||
<CardFoot>
|
||||
<ChecklistFoot
|
||||
more={card.moreGroups}
|
||||
moreKey={MORE_GROUPS[card.groupBy].labelKey}
|
||||
target={PACKING_TARGET}
|
||||
openKey="tours.packing.open"
|
||||
/>
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts'
|
||||
import { localToday } from '../../../Planner/today';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { PRIO_CONFIG } from '../../../Todo/todoListModel';
|
||||
import { PackTile } from '../../../Tours/packing/packingLinkParts';
|
||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { PersonFace } from '../parts/CardPeople';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { isRunnable, mergeTodoCards, todoColorOf } from './checklistModel';
|
||||
import {
|
||||
@@ -204,27 +205,30 @@ function TodoAnswer({ card }: { card: TodoCard }) {
|
||||
const title = t('assistant.card.todo.title');
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<PackTile count={progress?.open ?? 0} color={null} icon={ListTodo} />
|
||||
<p className="m-0 text-body font-semibold leading-tight text-content">{title}</p>
|
||||
{progress && <SoftPill>{t('assistant.card.todo.openCount', { count: progress.open })}</SoftPill>}
|
||||
{progress && progress.overdue > 0 && (
|
||||
<CardHeader
|
||||
icon={ListTodo}
|
||||
tone={progress && progress.overdue > 0 ? 'danger' : 'info'}
|
||||
title={title}
|
||||
meta={progress ? t('assistant.card.todo.openCount', { count: progress.open }) : undefined}
|
||||
actions={
|
||||
progress && progress.overdue > 0 ? (
|
||||
<SoftPill tone="danger">{t('assistant.card.todo.overdueCount', { count: progress.overdue })}</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{card.groups.map((group) => (
|
||||
<TodoGroup key={group.category ?? ''} group={group} inChange={false} />
|
||||
))}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{card.groups.map((group) => (
|
||||
<TodoGroup key={group.category ?? ''} group={group} inChange={false} />
|
||||
))}
|
||||
</div>
|
||||
<CardFoot>
|
||||
<ChecklistFoot
|
||||
more={card.moreGroups}
|
||||
moreKey="assistant.card.todo.moreLists"
|
||||
target={TODO_TARGET}
|
||||
openKey="assistant.card.todo.open"
|
||||
/>
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ChecklistRow({
|
||||
>
|
||||
{name}
|
||||
</bdi>
|
||||
{aside && <span className="flex flex-none flex-wrap items-center justify-end gap-1">{aside}</span>}
|
||||
{aside && <span className="flex flex-none flex-wrap items-center justify-end gap-x-2 gap-y-1">{aside}</span>}
|
||||
</div>
|
||||
{under}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { formatWeight } from '../../../Packing/packingListModel';
|
||||
import { bagFillPct } from '../../../Packing/packingListPanel.helpers';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { CountBadge } from '../../../Tours/packing/packingLinkParts';
|
||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||
import { PeopleStack, PersonFace } from '../parts/CardPeople';
|
||||
import { packingColorOf } from './checklistModel';
|
||||
@@ -56,15 +55,31 @@ function BagSwatch({ bag, size = 'row' }: { bag: PackingCardBag | undefined; siz
|
||||
* What a row says at its end: how many, how many packed, its weight, its tier
|
||||
* and its bag. A bag the card does not carry draws nothing rather than "Unassigned".
|
||||
*/
|
||||
/** A row's quantity and weight: quiet figures at its end, no frame each. */
|
||||
const QUIET = 'text-caption tabular-nums text-content-muted';
|
||||
|
||||
/** How many rows a group holds, quiet beside its name. */
|
||||
function GroupCount({ count }: { count: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label={t('tours.packing.items', { count })}
|
||||
className="flex-none text-caption tabular-nums text-content-faint"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PackingRowAside({ row, bag }: { row: PackingCardItem; bag: PackingCardBag | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const personal = t('assistant.card.packing.tierPersonal');
|
||||
const partial = !row.checked && row.packed > 0 && row.packed < row.quantity;
|
||||
return (
|
||||
<>
|
||||
{row.quantity > 1 && <WhiteBadge>×{row.quantity}</WhiteBadge>}
|
||||
{partial && <WhiteBadge>{`${row.packed}/${row.quantity}`}</WhiteBadge>}
|
||||
{row.weightGrams !== null && <WhiteBadge>{formatWeight(row.weightGrams)}</WhiteBadge>}
|
||||
{row.quantity > 1 && <span className={QUIET}>×{row.quantity}</span>}
|
||||
{partial && <span className={QUIET}>{`${row.packed}/${row.quantity}`}</span>}
|
||||
{row.weightGrams !== null && <span className={QUIET}>{formatWeight(row.weightGrams)}</span>}
|
||||
{row.tier === 'personal' && (
|
||||
<Tooltip label={personal}>
|
||||
<span role="img" aria-label={personal} className="flex text-content-faint">
|
||||
@@ -216,7 +231,7 @@ function BagHead({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!bag) {
|
||||
const badge = inChange ? undefined : <CountBadge count={group.count} />;
|
||||
const badge = inChange ? undefined : <GroupCount count={group.count} />;
|
||||
const lead = <BagSwatch bag={undefined} size="head" />;
|
||||
return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />;
|
||||
}
|
||||
@@ -283,7 +298,7 @@ export function PackingGroup({
|
||||
<ChecklistHead
|
||||
color={packingColorOf(key.slot, name)}
|
||||
name={name}
|
||||
badge={inChange ? undefined : <CountBadge count={group.count} />}
|
||||
badge={inChange ? undefined : <GroupCount count={group.count} />}
|
||||
people={group.assignees}
|
||||
removed={group.assigneesRemoved}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* The frame every answer card sits in, the same as the change card's: a
|
||||
* rounded card on the chat. A waiting change shows its frame dashed, a
|
||||
* rounded card on the chat whose head band (CardHeader) runs to its edges. A waiting change shows its frame dashed, a
|
||||
* removed one faded, a settled one (discarded, expired, undone) fainter
|
||||
* still. The chat card (a message in the trip chat) sits on the quieter
|
||||
* secondary surface.
|
||||
@@ -32,7 +32,7 @@ export default function CardFrame({
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className={`trek-assistant-enter rounded-xl border px-3 pb-2 pt-2.5 shadow-card ${STATE[state]} ${
|
||||
className={`trek-assistant-enter overflow-hidden rounded-[16px] border px-3.5 pb-3 pt-3 shadow-card ${STATE[state]} ${
|
||||
surface === 'secondary' ? 'bg-surface-secondary' : 'bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,43 +1,94 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { WhiteBadge } from '../../../Planner/planParts';
|
||||
import { fs, NEUTRAL_TINT } from '../../../shared/DialogShell';
|
||||
import type { Tone } from './assistantTones';
|
||||
import KindTile from './KindTile';
|
||||
|
||||
/**
|
||||
* The head of an answer card: its tile, what it is about (wrapping to two
|
||||
* lines at most, the whole title in its tooltip), how many it holds, one
|
||||
* quiet line of context under it, and its actions at the end.
|
||||
* The head band of an answer card, as the trip's dialogs and booking cards
|
||||
* wear it: a tinted band across the card's top, a raised white tile with the
|
||||
* icon of what the card is about in its tone, an eyebrow naming the kind,
|
||||
* the title, one quiet line under it, a count, and the card's actions at the
|
||||
* end. It reaches the card's edges, so a card that opens with it needs no
|
||||
* other head.
|
||||
*/
|
||||
|
||||
/** The icon's colour on the white tile, per tone. */
|
||||
const GLYPH: Record<Tone, string> = {
|
||||
success: 'text-success',
|
||||
info: 'text-info',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-danger',
|
||||
};
|
||||
|
||||
export default function CardHeader({
|
||||
icon,
|
||||
icon: Icon,
|
||||
tone,
|
||||
title,
|
||||
eyebrow,
|
||||
count,
|
||||
meta,
|
||||
actions,
|
||||
tile,
|
||||
tint = NEUTRAL_TINT,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
tone: Tone;
|
||||
title: string;
|
||||
/** The kind above the title ("PACKING LIST"); none when the title says it. */
|
||||
eyebrow?: string;
|
||||
count?: number;
|
||||
meta?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
/** In place of the icon tile: a drawing of what the card shows (a route's sketch). */
|
||||
tile?: ReactNode;
|
||||
tint?: string;
|
||||
/** Under the title row, on the band: a progress bar, a row of pills. */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<KindTile icon={icon} tone={tone} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<bdi title={title} className="line-clamp-2 text-body font-semibold leading-tight text-content">
|
||||
{title}
|
||||
</bdi>
|
||||
{count !== undefined && <WhiteBadge>{count}</WhiteBadge>}
|
||||
<div className="-mx-3.5 -mt-3 mb-3 px-3.5 pb-3 pt-3" style={{ background: tint }}>
|
||||
<div className={`flex gap-3 ${meta ? 'items-start' : 'items-center'}`}>
|
||||
{tile ?? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`grid h-10 w-10 flex-none place-items-center rounded-[12px] bg-surface-card shadow-sm ${GLYPH[tone]}`}
|
||||
>
|
||||
<Icon size={18} strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{eyebrow && (
|
||||
<div
|
||||
className="truncate font-geist font-bold uppercase tracking-[.08em] text-content-muted"
|
||||
style={fs(9.5)}
|
||||
>
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<bdi
|
||||
title={title}
|
||||
className="line-clamp-2 min-w-0 font-bold leading-tight tracking-[-0.01em] text-content"
|
||||
style={fs(15, 'body')}
|
||||
>
|
||||
{title}
|
||||
</bdi>
|
||||
{count !== undefined && (
|
||||
<span
|
||||
className="inline-flex h-5 flex-none items-center rounded-full bg-surface-card px-2 font-semibold tabular-nums text-content-secondary shadow-sm"
|
||||
style={fs(11)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{meta && <div className="mt-0.5 font-geist text-caption text-content-muted">{meta}</div>}
|
||||
</div>
|
||||
{meta && <div className="mt-0.5 text-caption text-content-muted">{meta}</div>}
|
||||
{actions && <div className="flex flex-none items-center gap-1">{actions}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-none items-center gap-1">{actions}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,11 @@ export function CardRow({
|
||||
<span className="block text-body font-semibold leading-tight text-content">{title}</span>
|
||||
{facts}
|
||||
</span>
|
||||
{aside && <span className="flex-none text-caption tabular-nums text-content-secondary">{aside}</span>}
|
||||
{aside && (
|
||||
<span className="flex flex-none items-center gap-2 text-caption tabular-nums text-content-secondary">
|
||||
{aside}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const hover = onHover
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Eyebrow, Field } from '../../../Planner/bookings/bookingParts';
|
||||
|
||||
/**
|
||||
* The body of an answer card in the trip dialogs' look: sections under an
|
||||
* eyebrow of their own, figures as framed fields with their label over them
|
||||
* (the field every booking card is made of), and a foot bar for the card's
|
||||
* ways onward, set off by a line across the card.
|
||||
*/
|
||||
|
||||
/** One part of a card under its eyebrow, with room for something on the right of the label. */
|
||||
export function CardSection({ label, aside, children }: { label: string; aside?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="mt-3 first:mt-0">
|
||||
<div className="mb-1.5 flex min-h-4 items-center gap-2">
|
||||
<Eyebrow>{label}</Eyebrow>
|
||||
{aside && <span className="ms-auto flex items-center gap-1">{aside}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A card's figures, side by side in framed fields: as many columns as figures, at most four. */
|
||||
export function CardFields({ fields }: { fields: readonly { key: string; label: string; value: ReactNode }[] }) {
|
||||
if (fields.length === 0) return null;
|
||||
const columns = ['grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4'][Math.min(fields.length, 4) - 1];
|
||||
return (
|
||||
<div className={`grid gap-1.5 ${columns}`}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="min-w-0" data-field={field.key}>
|
||||
<Field label={field.label} tabular>
|
||||
{field.value}
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The foot of a card: its actions at the end, under a line that runs to the card's edges. */
|
||||
export function CardFoot({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="-mx-3.5 -mb-3 mt-3 flex flex-wrap items-center justify-end gap-1.5 border-t border-edge-faint bg-surface-secondary px-3.5 py-2.5">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Clock, MoveHorizontal, TrendingUp } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { DistanceUnit } from '../../../../types';
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
import type { LegVariant } from '../../../Tours/planner/legVariants';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
|
||||
/**
|
||||
* A way's length, climb and time as one quiet line of glyphs and values,
|
||||
* without a frame around each: what a choice of ways and a change row show
|
||||
* where framed fields would crowd the row.
|
||||
*/
|
||||
|
||||
/** One figure of a way: its glyph and its value, quiet, in a line of them. */
|
||||
function Figure({ name, icon, children }: { name: string; icon: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap" data-figure={name}>
|
||||
{icon}
|
||||
<span className="tabular-nums">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const GLYPH = { size: 11, strokeWidth: 2.2, 'aria-hidden': true, className: 'flex-none text-content-faint' } as const;
|
||||
|
||||
/** A way's length, climb and time in one quiet line. */
|
||||
export default function WayFigures({ way, unit }: { way: LegVariant; unit: DistanceUnit }) {
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-caption text-content-secondary">
|
||||
<Figure name="distance" icon={<MoveHorizontal {...GLYPH} />}>
|
||||
{formatDistance(way.distanceM / 1000, unit)}
|
||||
</Figure>
|
||||
{way.gainM !== null && (
|
||||
<Figure name="ascent" icon={<TrendingUp {...GLYPH} />}>
|
||||
{formatElevation(way.gainM, unit)}
|
||||
</Figure>
|
||||
)}
|
||||
{way.minutes !== null && (
|
||||
<Figure name="time" icon={<Clock {...GLYPH} />}>
|
||||
{formatTourDuration(way.minutes)}
|
||||
</Figure>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { tourTarget } from './tourLook';
|
||||
import { useTakeProposal } from './useTourRouteCard';
|
||||
|
||||
@@ -58,7 +59,7 @@ export default function TourRouteActions({
|
||||
if (take.state === 'failed') caption = t('assistant.card.tour.askAgain');
|
||||
else if (take.state !== 'taken' && take.reasonKey) caption = t(take.reasonKey);
|
||||
return (
|
||||
<div className="mt-2.5 flex flex-col items-end gap-1">
|
||||
<CardFoot>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
@@ -92,10 +93,10 @@ export default function TourRouteActions({
|
||||
)}
|
||||
</div>
|
||||
{take.visible && caption && (
|
||||
<p className="m-0 text-end text-caption text-content-muted" role="status">
|
||||
<p className="m-0 basis-full text-end text-caption text-content-muted" role="status">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardFoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,11 +115,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-021: states the name, the figures as badges, the day and the hardest grade, uncertain when little is graded', () => {
|
||||
it('FE-AI-TOUR-021: states the name, the figures as framed fields, the day and the hardest grade, uncertain when little is graded', () => {
|
||||
const { container } = renderCard(HIKE);
|
||||
expect(screen.getByText('Hochfelln from Bergen')).toBeInTheDocument();
|
||||
for (const figure of ['time', 'distance', 'ascent', 'descent']) {
|
||||
expect(container.querySelector(`[data-figure="${figure}"]`)).not.toBeNull();
|
||||
expect(container.querySelector(`[data-field="${figure}"]`)).not.toBeNull();
|
||||
}
|
||||
expect(screen.getByText('8.2 km')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('640 m')).toHaveLength(2);
|
||||
@@ -159,7 +159,7 @@ describe('TourRouteCard', () => {
|
||||
|
||||
it('FE-AI-TOUR-024: a stop chip moves the spot in focus to the stop while pointed at', () => {
|
||||
renderCard(HIKE);
|
||||
const hut = screen.getByRole('button', { name: 'Strausshütte, 09:40' });
|
||||
const hut = screen.getByRole('button', { name: /^Strausshütte, 09:40/ });
|
||||
fireEvent.mouseEnter(hut);
|
||||
expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBeCloseTo(2400, 0);
|
||||
fireEvent.mouseLeave(hut);
|
||||
@@ -171,11 +171,11 @@ describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-024b: the stops run as a timeline, each walk on the line between two stops and read with the one it leads to', () => {
|
||||
renderCard(HIKE);
|
||||
const list = screen.getByRole('list', { name: 'Stops along the way' });
|
||||
// One row per stop; the lines between them are not rows.
|
||||
expect(list.querySelectorAll('[role="listitem"]')).toHaveLength(5);
|
||||
// One row per stop; the walks between them are hidden, read with the stop they lead to.
|
||||
expect(list.querySelectorAll('li:not([aria-hidden="true"])')).toHaveLength(5);
|
||||
// The place beside the way has no time: the hut measures from the start, then each stop from the last.
|
||||
expect(screen.getAllByText(/from the last stop$/)).toHaveLength(3);
|
||||
const legs = [...list.querySelectorAll('[aria-hidden="true"]')].map((leg) => leg.textContent).filter(Boolean);
|
||||
expect(screen.getAllByRole('button', { name: /from the last stop$/ })).toHaveLength(3);
|
||||
const legs = [...list.querySelectorAll('li[aria-hidden="true"]')].map((leg) => leg.textContent).filter(Boolean);
|
||||
expect(legs).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -234,18 +234,21 @@ describe('TourRouteCard', () => {
|
||||
const decide = vi.fn(async (_key: number, decisions: { toolCallId: string; approved: boolean }[]) => {
|
||||
// The server applies the take; the thread shows it applied with the new tour's place.
|
||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||
const applied = heldTake(true);
|
||||
applied.parts.push({
|
||||
// The server sends the answer back with the take joined to it, now applied with the new tour's place.
|
||||
const applied = proposed();
|
||||
applied.parts.push(...heldTake(true).parts, {
|
||||
type: 'data-trek-applied',
|
||||
data: { toolCallId: decisions[0]?.toolCallId ?? '', entity: 'tour', ids: [77], dayId: 22, undoUntil: null },
|
||||
});
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [proposed(), applied] } } });
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [applied] } } });
|
||||
});
|
||||
seed([proposed()]);
|
||||
useAssistantStore.setState({ decide });
|
||||
takeProposal.mockImplementationOnce(async () => {
|
||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [proposed(), heldTake(undefined)] } } });
|
||||
const joined = proposed();
|
||||
joined.parts.push(...heldTake(undefined).parts);
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [joined] } } });
|
||||
return 'held';
|
||||
});
|
||||
renderCard(HIKE, { onOpen });
|
||||
@@ -374,7 +377,7 @@ describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-042: the day shows the latest start when none is set, and the walk in the dark in red', () => {
|
||||
const schedule = HIKE.schedule as NonNullable<Route['schedule']>;
|
||||
renderCard({ ...HIKE, schedule: { ...schedule, start: null, arrive: null, latestStart: '10:30' } });
|
||||
expect(screen.getByText('Start by 10:30 to finish before sunset')).toBeInTheDocument();
|
||||
expect(screen.getByText('Start by 10:30')).toBeInTheDocument();
|
||||
|
||||
const { container } = renderCard({
|
||||
...HIKE,
|
||||
@@ -382,6 +385,6 @@ describe('TourRouteCard', () => {
|
||||
schedule: { ...schedule, start: '06:00', arrive: '19:00', latestStart: '10:30' },
|
||||
});
|
||||
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Start by 10:30 to finish before sunset')).toHaveLength(1);
|
||||
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import type { DistanceIndexedProfileSample } from '../../../../utils/routeGeometry';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import { CardSection } from '../parts/CardSection';
|
||||
import TourRouteActions from './TourRouteActions';
|
||||
import TourRouteDay from './TourRouteDay';
|
||||
import TourRouteHead from './TourRouteHead';
|
||||
import TourRouteHead, { TourRouteFields } from './TourRouteHead';
|
||||
import { profileSamples } from './tourRouteModel';
|
||||
import { TourRouteProblem, TourRouteWarnings } from './TourRouteNotes';
|
||||
import TourRouteProfile from './TourRouteProfile';
|
||||
@@ -40,19 +40,30 @@ export function TourRouteBody({
|
||||
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>;
|
||||
letter?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const samples = useMemo(() => profileSamples(card), [card]);
|
||||
return (
|
||||
<>
|
||||
<TourRouteProfile
|
||||
toolCallId={toolCallId}
|
||||
card={card}
|
||||
samples={samples}
|
||||
color={color}
|
||||
focus={route.focus}
|
||||
onFocus={route.setFocus}
|
||||
compare={compare}
|
||||
/>
|
||||
<TourRouteStops card={card} samples={samples} onFocus={route.setFocus} />
|
||||
<TourRouteFields card={card} />
|
||||
{/* A way drawn straight has neither heights nor the line to say so under: no section at all. */}
|
||||
{(samples.length >= 2 || card.routed) && (
|
||||
<CardSection label={t('tours.planner.inspector.elevation')}>
|
||||
<TourRouteProfile
|
||||
toolCallId={toolCallId}
|
||||
card={card}
|
||||
samples={samples}
|
||||
color={color}
|
||||
focus={route.focus}
|
||||
onFocus={route.setFocus}
|
||||
compare={compare}
|
||||
/>
|
||||
</CardSection>
|
||||
)}
|
||||
{card.stops.length > 0 && (
|
||||
<CardSection label={t('assistant.card.tour.stops')}>
|
||||
<TourRouteStops card={card} samples={samples} onFocus={route.setFocus} />
|
||||
</CardSection>
|
||||
)}
|
||||
<TourRouteDay card={card} />
|
||||
<TourRouteWarnings card={card} />
|
||||
<TourRouteProblem card={card} />
|
||||
@@ -63,12 +74,11 @@ export function TourRouteBody({
|
||||
|
||||
export default function TourRouteCard({ toolCallId, card }: { toolCallId: string; card: Route }) {
|
||||
const { t } = useTranslation();
|
||||
const { density } = useAssistantView();
|
||||
const route = useTourRouteCard(toolCallId, card);
|
||||
return (
|
||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<CardFrame label={routeName(card, t)}>
|
||||
<TourRouteHead card={card} sketchHeight={density === 'roomy' ? 64 : 56} onShow={route.showOnMap} />
|
||||
<TourRouteHead card={card} onShow={route.showOnMap} />
|
||||
<TourRouteBody toolCallId={toolCallId} card={card} route={route} color={variantPaintCss('current')} />
|
||||
</CardFrame>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createElement } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { weatherIconFor } from '../../../Weather/weatherIcons';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { type BarSpan, dayBarOf, forecastHours, RAIN_SHOWN_PCT } from './tourRouteDayModel';
|
||||
@@ -93,9 +94,13 @@ export default function TourRouteDay({ card }: { card: TourRouteCard }) {
|
||||
)}
|
||||
{!schedule.start && schedule.latestStart && (
|
||||
<div className="mt-1 flex">
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.tour.latestStart', { time: format.clock(schedule.latestStart) })}
|
||||
</SoftPill>
|
||||
<Tooltip label={t('assistant.card.tour.latestStart', { time: format.clock(schedule.latestStart) })}>
|
||||
<span className="inline-flex">
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.tour.latestStartShort', { time: format.clock(schedule.latestStart) })}
|
||||
</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{schedule.weather === 'climate' && (
|
||||
|
||||
@@ -4,12 +4,14 @@ import { createElement } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { fs, PILL } from '../../../shared/DialogShell';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
||||
import { CardFacts } from '../parts/CardFacts';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFields } from '../parts/CardSection';
|
||||
import RouteSketch from '../parts/RouteSketch';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
|
||||
@@ -89,7 +91,7 @@ export function TourGrade({ card }: { card: TourRouteCard }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The day a route is planned on, as words: "Day 2, Tue 14 Oct", or the date alone. */
|
||||
/** The day a route is planned on, as a white pill on the band: "Day 2, Tue 14 Oct", or the date alone. */
|
||||
function DayPill({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
@@ -98,7 +100,12 @@ function DayPill({ card }: { card: TourRouteCard }) {
|
||||
const date = format.date(schedule.date);
|
||||
const text =
|
||||
schedule.dayNumber !== null ? `${t('assistant.card.tour.day', { day: schedule.dayNumber })}, ${date}` : date;
|
||||
return <SoftPill icon={<CalendarDays size={10} strokeWidth={2.2} aria-hidden="true" />}>{text}</SoftPill>;
|
||||
return (
|
||||
<span className={PILL} style={fs(11)}>
|
||||
<CalendarDays size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The route drawn small: a button that shows it on the map. */
|
||||
@@ -127,35 +134,51 @@ export function TourRouteSketch({
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourRouteHead({
|
||||
card,
|
||||
sketchHeight,
|
||||
onShow,
|
||||
}: {
|
||||
card: TourRouteCard;
|
||||
sketchHeight: number;
|
||||
onShow: () => void;
|
||||
}) {
|
||||
/** The route's figures as framed fields, each under its label: distance, climb, descent and time, as known. */
|
||||
export function TourRouteFields({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const fields = factKeysOf(card).flatMap((key) => {
|
||||
const value = factValue(card, key);
|
||||
if (value === null) return [];
|
||||
// A field's label is short; how the time was reckoned is the figure badges' tooltip.
|
||||
const label = t(FACT_LOOK[key].labelKey ?? 'tours.planner.inspector.duration');
|
||||
let text: string;
|
||||
if (key === 'time') text = formatTourDuration(value);
|
||||
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
|
||||
return [{ key, label, value: text }];
|
||||
});
|
||||
return <CardFields fields={fields} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The head band of a route card: the route drawn small on a raised tile, what
|
||||
* kind of tour it is above its name, the label under it, and its grade and
|
||||
* the day it is planned on as pills on the band.
|
||||
*/
|
||||
export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; onShow: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const name = routeName(card, t);
|
||||
const tile = (
|
||||
<span className="w-14 flex-none overflow-hidden rounded-[12px] bg-surface-card p-1 shadow-sm">
|
||||
<TourRouteSketch card={card} height={48} onShow={onShow} />
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-[72px] flex-none">
|
||||
<TourRouteSketch card={card} height={sketchHeight} onShow={onShow} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<bdi title={name} className="line-clamp-2 min-w-0 flex-1 text-body font-semibold leading-tight text-content">
|
||||
{name}
|
||||
</bdi>
|
||||
<TourGrade card={card} />
|
||||
</div>
|
||||
{card.label && <p className="m-0 mt-0.5 truncate text-caption text-content-muted">{card.label}</p>}
|
||||
<CardFacts>
|
||||
<TourRouteFigures card={card} />
|
||||
<CardHeader
|
||||
icon={tourDayRowIcon(card.activity)}
|
||||
tone="info"
|
||||
tile={tile}
|
||||
eyebrow={t(TOUR_TYPE_WORDS[card.activity].labelKey)}
|
||||
title={name}
|
||||
meta={card.label ?? undefined}
|
||||
>
|
||||
{((card.activity === 'hike' && card.grades) || card.schedule) && (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
{card.activity === 'hike' && card.grades && <TourGrade card={card} />}
|
||||
<DayPill card={card} />
|
||||
</CardFacts>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { verbLookOf } from '../cardLooks';
|
||||
import KindTile from '../parts/KindTile';
|
||||
import { TourGrade, TourRouteFigures, TourRouteSketch } from './TourRouteHead';
|
||||
import WayFigures from './TourFigureLine';
|
||||
import { TourGrade, TourRouteSketch } from './TourRouteHead';
|
||||
import { legVariantOf, previousVariantOf, profileBands, profileSamples } from './tourRouteModel';
|
||||
import { useTourRouteCard } from './useTourRouteCard';
|
||||
|
||||
@@ -37,29 +38,33 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
|
||||
const samples = useMemo(() => profileSamples(card), [card]);
|
||||
const previous = previousVariantOf(card);
|
||||
const notPlanned = card.problem?.why === 'not_planned';
|
||||
const grades = card.activity === 'hike' ? card.grades : null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5" data-testid="tour-route-row">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-16 flex-none">
|
||||
<TourRouteSketch card={card} height={40} onShow={showOnMap} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<TourRouteFigures card={card} />
|
||||
<TourGrade card={card} />
|
||||
{previous && (
|
||||
<VariantDelta
|
||||
variant={legVariantOf(card, 'current')}
|
||||
current={previous}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaBefore')}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-secondary p-2.5"
|
||||
data-testid="tour-route-row"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-14 flex-none overflow-hidden rounded-[10px] bg-surface-card p-1 shadow-sm">
|
||||
<TourRouteSketch card={card} height={36} onShow={showOnMap} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<WayFigures way={legVariantOf(card, 'current')} unit={unit} />
|
||||
</span>
|
||||
{grades && grades.highest !== null && <TourGrade card={card} />}
|
||||
</span>
|
||||
{notPlanned && (
|
||||
<p className="m-0 mt-1 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>
|
||||
{previous && (
|
||||
<VariantDelta
|
||||
variant={legVariantOf(card, 'current')}
|
||||
current={previous}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaBefore')}
|
||||
/>
|
||||
)}
|
||||
{notPlanned && <p className="m-0 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{!notPlanned && samples.length >= 2 && (
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function TourRouteProfile({ toolCallId, card, samples, color, foc
|
||||
const bands = useMemo(() => profileBands(card, samples), [card, samples]);
|
||||
if (samples.length < 2) {
|
||||
if (!card.routed) return null;
|
||||
return <p className="m-0 mt-2 text-caption text-content-faint">{t('assistant.card.tour.noHeights')}</p>;
|
||||
return <p className="m-0 text-caption text-content-faint">{t('assistant.card.tour.noHeights')}</p>;
|
||||
}
|
||||
const phone = variant === 'fullscreen';
|
||||
const focusGrade = focus ? gradeAt(grades, focus.distanceMeters) : null;
|
||||
@@ -112,7 +112,7 @@ export default function TourRouteProfile({ toolCallId, card, samples, color, foc
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="rounded-[12px] border border-edge-faint bg-surface-secondary px-2 pb-1.5 pt-2">
|
||||
<ElevationProfile
|
||||
samples={samples}
|
||||
color={color}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
|
||||
import { AlertTriangle, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
|
||||
import { AlertTriangle, Clock, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type RouteProfileFocus,
|
||||
} from '../../../../utils/routeGeometry';
|
||||
import { TOUR_POI_LOOKS } from '../../../Map/poiCategories';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { StageEndCorner } from '../../../Tours/stages/StageParts';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
@@ -18,12 +18,13 @@ import { FLAG_LOOK, stopName } from './tourRouteWords';
|
||||
|
||||
/**
|
||||
* The stops of a route as a timeline in the order they lie along it: one row
|
||||
* per stop (the start and the end, the huts, summits and stations on the way
|
||||
* with their clock, what lies a little off the way quieter), joined by a line
|
||||
* that carries the walking time of the stretch between two stops, so a time
|
||||
* is never read as belonging to the stop beside it. Pointing at a chip or
|
||||
* tapping it moves the spot in focus there, on the profile and on the map
|
||||
* alike. Past eight the middle ones fold into a count that names them.
|
||||
* per stop on a line down the card (the start and the end filled, the huts,
|
||||
* summits and stations on the way with their glyph, what lies a little off
|
||||
* the way quieter), its name, how far off the way or how long the stay under
|
||||
* it, and its clock at the end; the walk between two stops sits on the line
|
||||
* between their rows, so a time is never read as a stop's own. Pointing at a
|
||||
* row or tapping it moves the spot in focus there, on the profile and on the
|
||||
* map alike. Past eight the middle ones fold into a row that names them.
|
||||
*/
|
||||
|
||||
/** A stop's glyph: the flag at either end, the mountain at the highest point, the place's own for a kind. */
|
||||
@@ -46,12 +47,31 @@ function focusOf(stop: TourRouteStop, samples: readonly DistanceIndexedProfileSa
|
||||
);
|
||||
}
|
||||
|
||||
function StopChip({
|
||||
/** A stop's mark on the line: filled at the start and the end, a ring with its glyph on the way, smaller off it. */
|
||||
function Marker({ stop }: { stop: TourRouteStop }) {
|
||||
const end = stop.role === 'start' || stop.role === 'end';
|
||||
const along = stop.role === 'along';
|
||||
const glyph = createElement(iconOf(stop), { size: along ? 9 : 10, strokeWidth: 2.2, 'aria-hidden': true });
|
||||
let look = 'h-5 w-5 bg-surface-card text-content-muted ring-1 ring-inset ring-edge';
|
||||
if (end) look = 'h-5 w-5 bg-accent text-accent-text';
|
||||
else if (along) look = 'h-4 w-4 bg-surface-secondary text-content-faint ring-1 ring-inset ring-edge-faint';
|
||||
return (
|
||||
<span className="relative z-[1] flex w-5 flex-none justify-center">
|
||||
<span className={`grid place-items-center rounded-full ${look}`}>{glyph}</span>
|
||||
{stop.stageEnd && <StageEndCorner />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StopRow({
|
||||
stop,
|
||||
leg,
|
||||
samples,
|
||||
onFocus,
|
||||
}: {
|
||||
stop: TourRouteStop;
|
||||
/** The walk from the last stop with a time to this one, read with it. */
|
||||
leg: number | null;
|
||||
samples: readonly DistanceIndexedProfileSample[];
|
||||
onFocus: (focus: RouteProfileFocus | null) => void;
|
||||
}) {
|
||||
@@ -65,44 +85,45 @@ function StopChip({
|
||||
: null;
|
||||
const stay = stop.stayMinutes ? t('assistant.card.tour.stay', { time: format.minutes(stop.stayMinutes) }) : null;
|
||||
const clock = stop.clock ? format.clock(stop.clock) : null;
|
||||
const label = [name, clock, off, stay].filter(Boolean).join(', ');
|
||||
const glyph = createElement(iconOf(stop), {
|
||||
size: 10,
|
||||
strokeWidth: 2.2,
|
||||
'aria-hidden': true,
|
||||
className: 'flex-none text-content-faint',
|
||||
});
|
||||
const body = (
|
||||
<>
|
||||
<bdi className="max-w-[10rem] truncate">{name}</bdi>
|
||||
{clock && <span className="tabular-nums text-content-muted">{clock}</span>}
|
||||
{off && <span className="text-content-faint">{off}</span>}
|
||||
</>
|
||||
);
|
||||
const walked = leg !== null ? t('assistant.card.tour.legTime', { time: format.minutes(leg) }) : null;
|
||||
const label = [name, clock, off, stay, walked].filter(Boolean).join(', ');
|
||||
const point = () => onFocus(focusOf(stop, samples));
|
||||
const under = [off, stay].filter((part): part is string => part !== null);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onMouseEnter={() => onFocus(focusOf(stop, samples))}
|
||||
onMouseLeave={() => onFocus(null)}
|
||||
onFocus={() => onFocus(focusOf(stop, samples))}
|
||||
onBlur={() => onFocus(null)}
|
||||
onClick={() => onFocus(focusOf(stop, samples))}
|
||||
className="relative inline-flex rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{along ? <WhiteBadge icon={glyph}>{body}</WhiteBadge> : <SoftPill icon={glyph}>{body}</SoftPill>}
|
||||
{stop.stageEnd && <StageEndCorner />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{stay && <span className="text-caption text-content-faint">{stay}</span>}
|
||||
{stop.flag && (
|
||||
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t(FLAG_LOOK[stop.flag].labelKey)}
|
||||
</SoftPill>
|
||||
)}
|
||||
</span>
|
||||
<li className="flex">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onMouseEnter={point}
|
||||
onMouseLeave={() => onFocus(null)}
|
||||
onFocus={point}
|
||||
onBlur={() => onFocus(null)}
|
||||
onClick={point}
|
||||
className="-mx-1.5 flex min-w-0 flex-1 items-center gap-2.5 rounded-[10px] px-1.5 py-1 text-start transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Marker stop={stop} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<bdi
|
||||
className={`block truncate leading-tight ${along ? 'text-caption text-content-secondary' : 'text-body font-medium text-content'}`}
|
||||
>
|
||||
{name}
|
||||
</bdi>
|
||||
{(under.length > 0 || stop.flag) && (
|
||||
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-caption text-content-faint">
|
||||
{under.map((part) => (
|
||||
<span key={part}>{part}</span>
|
||||
))}
|
||||
{stop.flag && (
|
||||
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t(FLAG_LOOK[stop.flag].labelKey)}
|
||||
</SoftPill>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{clock && <span className="flex-none text-caption tabular-nums text-content-muted">{clock}</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,38 +133,36 @@ function legMinutes(from: TourRouteStop | undefined, to: TourRouteStop): number
|
||||
return to.minutes - from.minutes;
|
||||
}
|
||||
|
||||
/** The line between two rows, with the stretch's walking time on it. */
|
||||
/** The walk between two rows, beside the line: read with the row it leads to, so hidden here. */
|
||||
function Leg({ minutes }: { minutes: number | null }) {
|
||||
const format = useCardFormat();
|
||||
if (minutes === null) return <li aria-hidden="true" className="h-1.5" />;
|
||||
return (
|
||||
// Read with the stop it leads to; a list holds only its rows.
|
||||
<span aria-hidden="true" className="flex min-h-[14px] items-stretch gap-2">
|
||||
<span className="flex w-2.5 flex-none justify-center">
|
||||
<span className="w-px bg-edge" />
|
||||
<li aria-hidden="true" className="flex items-center py-0.5 ps-[30px]">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-caption tabular-nums text-content-muted">
|
||||
<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />
|
||||
{format.minutes(minutes)}
|
||||
</span>
|
||||
{minutes !== null && (
|
||||
<span className="text-caption tabular-nums leading-tight text-content-faint">{format.minutes(minutes)}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row of the timeline: its dot on the line, then the stop, read after the walk that leads to it. */
|
||||
function Row({ end, leg, children }: { end: boolean; leg?: number | null; children: ReactNode }) {
|
||||
/** The middle stops folded into one row past eight, naming them in its tooltip. */
|
||||
function FoldedRow({ stops }: { stops: readonly TourRouteStop[] }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const names = stops.map((stop) => stopName(stop, t)).join(', ');
|
||||
return (
|
||||
<span role="listitem" className="flex items-center gap-2">
|
||||
{leg != null && (
|
||||
<span className="sr-only">{t('assistant.card.tour.legTime', { time: format.minutes(leg) })}</span>
|
||||
)}
|
||||
<span className="flex w-2.5 flex-none justify-center">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${end ? 'bg-content-muted' : 'border border-content-faint bg-surface-card'}`}
|
||||
/>
|
||||
<li className="flex items-center gap-2.5 py-1">
|
||||
<span className="relative z-[1] flex w-5 flex-none justify-center">
|
||||
<span className="h-2 w-2 rounded-full border border-dashed border-content-faint bg-surface-card" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">{children}</span>
|
||||
</span>
|
||||
<Tooltip label={names}>
|
||||
<span className="text-caption text-content-muted underline decoration-dotted underline-offset-2">
|
||||
{t('assistant.card.tour.stopsMore', { count: stops.length })}
|
||||
<span className="sr-only">{`: ${names}`}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,42 +178,30 @@ export default function TourRouteStops({
|
||||
const { t } = useTranslation();
|
||||
const { head, folded, tail } = chipStops(card);
|
||||
if (head.length === 0) return null;
|
||||
const names = folded.map((stop) => stopName(stop, t)).join(', ');
|
||||
// The time is walked from the last stop that has one: a place beside the way has none.
|
||||
let walked: TourRouteStop | undefined;
|
||||
const rows: ReactNode[] = [];
|
||||
const pushStop = (stop: TourRouteStop, key: string) => {
|
||||
const leg = legMinutes(walked, stop);
|
||||
if (rows.length > 0) rows.push(<Leg key={`${key}-leg`} minutes={leg} />);
|
||||
rows.push(
|
||||
<Row key={key} end={stop.role === 'start' || stop.role === 'end'} leg={leg}>
|
||||
<StopChip stop={stop} samples={samples} onFocus={onFocus} />
|
||||
</Row>
|
||||
);
|
||||
rows.push(<StopRow key={key} stop={stop} leg={leg} samples={samples} onFocus={onFocus} />);
|
||||
if (stop.minutes !== null) walked = stop;
|
||||
};
|
||||
head.forEach((stop, index) => pushStop(stop, `h-${index}`));
|
||||
if (folded.length > 0) {
|
||||
rows.push(<Leg key="f-leg" minutes={null} />);
|
||||
rows.push(
|
||||
<Row key="f" end={false}>
|
||||
<Tooltip label={names}>
|
||||
<span className="inline-flex">
|
||||
<WhiteBadge>
|
||||
{t('assistant.card.tour.stopsMore', { count: folded.length })}
|
||||
<span className="sr-only">{`: ${names}`}</span>
|
||||
</WhiteBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Row>
|
||||
);
|
||||
rows.push(<FoldedRow key="f" stops={folded} />);
|
||||
// The folded stretch's time is not one leg; the next stop measures from the last folded one.
|
||||
walked = [...folded].reverse().find((stop) => stop.minutes !== null) ?? walked;
|
||||
}
|
||||
tail.forEach((stop, index) => pushStop(stop, `t-${index}`));
|
||||
return (
|
||||
<div className="mt-2 flex flex-col" role="list" aria-label={t('assistant.card.tour.stops')}>
|
||||
{rows}
|
||||
<div className="relative">
|
||||
{/* The line down the timeline, from the first mark to the last, under the marks. */}
|
||||
<span aria-hidden="true" className="absolute bottom-3 start-[9.5px] top-3 w-px bg-edge" />
|
||||
<ul className="relative m-0 flex list-none flex-col p-0" aria-label={t('assistant.card.tour.stops')}>
|
||||
{rows}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,128 @@
|
||||
import type { TourRouteCard as Route } from '@trek/shared';
|
||||
import { Route as RouteIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import type { DistanceUnit } from '../../../../types';
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { VariantDelta } from '../../../Tours/planner/LegVariantsCard';
|
||||
import { VariantFigures, WayChoice } from '../../../Tours/planner/mapCardParts';
|
||||
import { type LegVariant, variantDelta } from '../../../Tours/planner/legVariants';
|
||||
import { VariantMark } from '../../../Tours/planner/mapCardParts';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardSection } from '../parts/CardSection';
|
||||
import WayFigures from './TourFigureLine';
|
||||
import { TourRouteBody } from './TourRouteCard';
|
||||
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
|
||||
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
|
||||
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
|
||||
|
||||
/** The ways of one answer with the chosen one known: its choices, then the chosen way's body. */
|
||||
/** A signed difference: a plus for more, a minus sign for less, nothing for none worth printing. */
|
||||
function signed(value: number, format: (absolute: number) => string): string | null {
|
||||
const text = format(Math.abs(value));
|
||||
if (Math.abs(value) < 1e-9 || /^0\s/.test(text)) return null;
|
||||
return `${value > 0 ? '+' : '−'}${text}`;
|
||||
}
|
||||
|
||||
/** How a way differs from the chosen one, as one faint line: "Compared with way A: −4.3 km −178 m −1 h 33 min". */
|
||||
function WayDelta({
|
||||
way,
|
||||
chosen,
|
||||
letter,
|
||||
unit,
|
||||
}: {
|
||||
way: LegVariant;
|
||||
chosen: LegVariant;
|
||||
letter: string;
|
||||
unit: DistanceUnit;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const delta = variantDelta(way, chosen);
|
||||
const parts = [
|
||||
signed(delta.distanceM / 1000, (km) => formatDistance(km, unit)),
|
||||
delta.gainM === null ? null : signed(delta.gainM, (m) => formatElevation(m, unit)),
|
||||
delta.minutes === null ? null : signed(delta.minutes, formatTourDuration),
|
||||
].filter((part): part is string => part !== null);
|
||||
if (parts.length === 0) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex flex-wrap items-center gap-x-2 text-caption text-content-faint"
|
||||
data-testid="leg-variant-delta"
|
||||
>
|
||||
<span>{t('assistant.card.tour.deltaTo', { letter })}</span>
|
||||
{parts.map((part) => (
|
||||
<span key={part} className="tabular-nums text-content-muted">
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One way to choose: its letter in the colour it is drawn in, its name and grade, its figures, how it differs. */
|
||||
function WayOption({
|
||||
entry,
|
||||
index,
|
||||
chosen,
|
||||
chosenWay,
|
||||
chosenLetter,
|
||||
unit,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: {
|
||||
entry: RouteEntry;
|
||||
index: number;
|
||||
chosen: boolean;
|
||||
chosenWay: LegVariant;
|
||||
chosenLetter: string;
|
||||
unit: DistanceUnit;
|
||||
onSelect: () => void;
|
||||
onHover: (on: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const key = WAY_KEYS[index] ?? 'a';
|
||||
const letter = key.toUpperCase();
|
||||
const way = legVariantOf(entry.card, key);
|
||||
const grades = entry.card.grades;
|
||||
const name = entry.card.label ?? t('tours.variants.option', { letter });
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chosen}
|
||||
aria-label={name}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => onHover(true)}
|
||||
onMouseLeave={() => onHover(false)}
|
||||
onFocus={() => onHover(true)}
|
||||
onBlur={() => onHover(false)}
|
||||
className={`flex w-full items-start gap-2.5 rounded-[12px] border px-2.5 py-2 text-start transition-colors ${
|
||||
chosen
|
||||
? 'border-accent bg-surface-card shadow-sm'
|
||||
: 'border-edge-faint bg-surface-secondary hover:border-edge hover:bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
<VariantMark variant={way} chosen={chosen} />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<bdi className="min-w-0 flex-1 truncate text-body font-semibold leading-tight text-content">{name}</bdi>
|
||||
{grades && grades.highest !== null && (
|
||||
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
|
||||
)}
|
||||
</span>
|
||||
<WayFigures way={way} unit={unit} />
|
||||
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The ways of one answer with the chosen one known: the band, the choices, then the chosen way's body. */
|
||||
function WaysCard({
|
||||
entries,
|
||||
chosen,
|
||||
@@ -44,57 +153,49 @@ function WaysCard({
|
||||
[entries, chosenIndex]
|
||||
);
|
||||
|
||||
const current = legVariantOf(chosen.card, chosenKey);
|
||||
const chosenWay = legVariantOf(chosen.card, chosenKey);
|
||||
// Side by side where the window is roomy; on the phone the ways stay rows, so a finger scrubs the profile freely.
|
||||
let columns = '';
|
||||
if (density === 'roomy' && variant !== 'fullscreen') columns = entries.length >= 3 ? 'grid-cols-3' : 'grid-cols-2';
|
||||
const title = t('assistant.card.tour.ways', { count: entries.length });
|
||||
const card: Route = chosen.card;
|
||||
return (
|
||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<div {...route.handlers} className={`rounded-[16px] ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<CardFrame label={title}>
|
||||
<p className="m-0 text-caption text-content-muted">{title}</p>
|
||||
<div role="radiogroup" aria-label={title} className={`mt-1.5 grid gap-1.5 ${columns}`}>
|
||||
{entries.map((entry, index) => {
|
||||
const key = WAY_KEYS[index] ?? 'a';
|
||||
const letter = key.toUpperCase();
|
||||
const way = legVariantOf(entry.card, key);
|
||||
const grades = entry.card.grades;
|
||||
return (
|
||||
<WayChoice
|
||||
<CardHeader
|
||||
icon={RouteIcon}
|
||||
tone="info"
|
||||
eyebrow={t(TOUR_TYPE_WORDS[card.activity].labelKey)}
|
||||
title={routeName(card, t)}
|
||||
meta={title}
|
||||
/>
|
||||
<CardSection label={t('assistant.card.tour.waysPick')}>
|
||||
<div role="radiogroup" aria-label={title} className={`grid gap-1.5 ${columns}`}>
|
||||
{entries.map((entry, index) => (
|
||||
<WayOption
|
||||
key={entry.toolCallId}
|
||||
way={way}
|
||||
name={entry.card.label ?? t('tours.variants.option', { letter })}
|
||||
entry={entry}
|
||||
index={index}
|
||||
chosen={index === chosenIndex}
|
||||
aside={
|
||||
<>
|
||||
{index !== chosenIndex && (
|
||||
<VariantDelta
|
||||
variant={way}
|
||||
current={current}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaTo', { letter: chosenKey.toUpperCase() })}
|
||||
/>
|
||||
)}
|
||||
{grades && <SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />}
|
||||
</>
|
||||
}
|
||||
chosenWay={chosenWay}
|
||||
chosenLetter={chosenKey.toUpperCase()}
|
||||
unit={unit}
|
||||
onSelect={() => pick(tripId, blockKey, entry.toolCallId)}
|
||||
onHover={(on) => hover(entry.toolCallId, on)}
|
||||
>
|
||||
<VariantFigures variant={way} unit={unit} t={t} />
|
||||
</WayChoice>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardSection>
|
||||
<div className="mt-3">
|
||||
<TourRouteBody
|
||||
toolCallId={chosen.toolCallId}
|
||||
card={chosen.card}
|
||||
route={route}
|
||||
color={variantPaintCss(chosenKey)}
|
||||
compare={compare}
|
||||
letter={chosenKey.toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<TourRouteBody
|
||||
toolCallId={chosen.toolCallId}
|
||||
card={chosen.card}
|
||||
route={route}
|
||||
color={variantPaintCss(chosenKey)}
|
||||
compare={compare}
|
||||
letter={chosenKey.toUpperCase()}
|
||||
/>
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
@@ -102,12 +203,12 @@ function WaysCard({
|
||||
|
||||
/**
|
||||
* Two or three ways of one route an answer proposed, as the planner offers a
|
||||
* section's ways: each a choice with its letter in the colour it is drawn in,
|
||||
* its own words, how it differs from the chosen one and its hardest grade,
|
||||
* its figures below. One profile holds them all, the chosen way filled and
|
||||
* the others thin on the same axes; under it the chosen way's stops, day and
|
||||
* warnings, and "Plan way B". A choice is kept per answer, and pointing at a
|
||||
* way lights its line on the map.
|
||||
* section's ways: a band naming the route, each way a choice with its letter
|
||||
* in the colour it is drawn in, its name and hardest grade, its figures and
|
||||
* how it differs from the chosen one; one profile holds them all, the chosen
|
||||
* way filled and the others thin on the same axes; under it the chosen way's
|
||||
* figures, stops, day and warnings, and "Plan way B". A choice is kept per
|
||||
* answer, and pointing at a way lights its line on the map.
|
||||
*/
|
||||
export default function TourWays({ entries }: { entries: readonly RouteEntry[] }) {
|
||||
const { tripId } = useAssistantView();
|
||||
|
||||
@@ -67,6 +67,9 @@ describe('takeInThread', () => {
|
||||
it('FE-AI-TOUR-048: a take that names its proposal is matched by it, not by its way', () => {
|
||||
const other = { ...GENTLE, line: [[47.9, 12.8, 600], ...GENTLE.line.slice(1)] } satisfies TourRouteCard;
|
||||
expect(takeInThread([proposal, take(other, {}, 'take_proposal', 'call_1')], 'call_1', HIKE)).toBe('waiting');
|
||||
// The server joins a take to the answer that drew the proposal: found there too.
|
||||
const joined = { ...proposal, parts: [...proposal.parts, ...take(other, {}, 'take_proposal', 'call_1').parts] };
|
||||
expect(takeInThread([joined], 'call_1', HIKE)).toBe('waiting');
|
||||
// The same way taken from another proposal leaves this one free.
|
||||
expect(
|
||||
takeInThread([proposal, take(HIKE, { approved: true }, 'take_proposal', 'call_2')], 'call_1', HIKE)
|
||||
|
||||
@@ -45,7 +45,8 @@ export function takeOfProposal(
|
||||
): { id: string; state: 'waiting' | 'applied'; placeId: number | null } | null {
|
||||
const from = proposalIndex(messages, toolCallId);
|
||||
if (from < 0) return null;
|
||||
for (const message of messages.slice(from + 1)) {
|
||||
// The server joins a take to the answer that drew its proposal, so that answer is searched as well as the later ones.
|
||||
for (const message of messages.slice(from)) {
|
||||
const open = new Map<string, 'waiting' | 'applied'>();
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool-call' || part.toolName !== TAKE_TOOL || part.approval?.resolution) continue;
|
||||
|
||||
@@ -14,6 +14,6 @@ import TourRouteInRow, { TourRouteLead } from './TourRouteInRow';
|
||||
* file.
|
||||
*/
|
||||
export const TOUR_VIEWS = {
|
||||
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow },
|
||||
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow, wideBody: true },
|
||||
tourList: { Block: TourListCard },
|
||||
} satisfies { [K in 'tourRoute' | 'tourList']: CardView<K> };
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { Palmtree } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { safeHexColor } from '../../../../utils/safeColor';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { useViewerId } from '../parts/useViewerId';
|
||||
import VacayBalance from './VacayBalance';
|
||||
import { balanceLines, mergeVacayCards, type VacayView } from './vacayCardModel';
|
||||
@@ -26,7 +30,6 @@ const SQUARE = 'size-2 flex-none rounded-sm';
|
||||
const COMPANY = 'size-2 flex-none rounded-sm bg-warning-soft ring-1 ring-inset ring-warning';
|
||||
const TRIP = 'size-1.5 flex-none rounded-full bg-info';
|
||||
const OTHER = 'mt-1.5 text-caption text-content-muted';
|
||||
const FOOT = 'mt-1 flex justify-end';
|
||||
|
||||
interface LegendItem {
|
||||
key: string;
|
||||
@@ -79,13 +82,14 @@ export default function VacayAnswerBlock({ cards }: { cards: readonly CardEntry[
|
||||
const first = own[0] ?? null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.vacay.label')}>
|
||||
<CardHeader icon={Palmtree} tone="info" title={t('assistant.card.vacay.label')} />
|
||||
{view.months.length > 0 && <VacayMonthStrip view={view} placed={[]} labelOf={() => ''} />}
|
||||
<Legend view={view} />
|
||||
{view.otherDays > 0 && <p className={OTHER}>{t('assistant.card.vacay.otherDays', { count: view.otherDays })}</p>}
|
||||
<VacayBalance lines={lines} view={view} mode="read" />
|
||||
<div className={FOOT}>
|
||||
<CardFoot>
|
||||
<CardOpenButton target={{ kind: 'page', path: vacayPathOf(first) }} labelKey="assistant.card.vacay.open" />
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,13 +15,15 @@ import type { SaveState } from './useBookStore'
|
||||
* which is exactly the wrong habit for the one time it says something else.
|
||||
*/
|
||||
export function SaveIndicator({
|
||||
state, t, onAcceptTheirs, onKeepMine, onRetry,
|
||||
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
|
||||
}: {
|
||||
state: SaveState
|
||||
t: (k: string) => string
|
||||
onAcceptTheirs: (current: BookRecord) => void
|
||||
onKeepMine: (current: BookRecord) => void
|
||||
onRetry: () => void
|
||||
/** Load this Studio again, for a book a newer one saved. A full reload by default. */
|
||||
onReload?: () => void
|
||||
}) {
|
||||
if (state.status === 'conflict') {
|
||||
return (
|
||||
@@ -43,6 +45,24 @@ export function SaveIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status === 'newer') {
|
||||
/*
|
||||
* The one state with a way out the user has to take themselves: a newer
|
||||
* Studio saved this book, this tab cannot write it without losing what
|
||||
* that one added, and only a reload brings the newer Studio here. Loud
|
||||
* like a conflict, because until then nothing done here is kept.
|
||||
*/
|
||||
return (
|
||||
<div className="st-save is-conflict" role="alert">
|
||||
<AlertTriangle size={13} />
|
||||
<span>{t('journey.studio.saveNewer')}</span>
|
||||
<button type="button" onClick={onReload}>
|
||||
{t('journey.studio.saveReload')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status === 'readonly') {
|
||||
/*
|
||||
* Not an error and not retryable: this person was invited to read the
|
||||
@@ -86,3 +106,8 @@ export function SaveIndicator({
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** The default way back to a current Studio: the page again, which loads the newest build. */
|
||||
function reloadStudio() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
@@ -260,7 +260,10 @@ function PhotoView({ el, big, print, dropLabel }: {
|
||||
src={photoSrc(el.photoId!, big)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
// Lazy on the canvas, where a long book scrolls past; eager in print,
|
||||
// where every sheet is laid out at once and a picture the browser put
|
||||
// off is a picture missing from the PDF.
|
||||
loading={print ? 'eager' : 'lazy'}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@@ -482,7 +485,8 @@ export function SpreadView({
|
||||
overflow: print ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{spread.elements.map(el => (
|
||||
{/* A hidden layer stays in the layers panel and off the page, here and in print. */}
|
||||
{spread.elements.filter(el => !el.hidden).map(el => (
|
||||
<ElementView
|
||||
key={el.id}
|
||||
el={el}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { BookAlignEdge, BookAlignTo, BookSpread } from '@trek/shared';
|
||||
import { distributableCount, isWholeGroup } from '@trek/shared';
|
||||
import {
|
||||
AlignCenterHorizontal,
|
||||
AlignCenterVertical,
|
||||
AlignEndHorizontal,
|
||||
AlignEndVertical,
|
||||
AlignHorizontalDistributeCenter,
|
||||
AlignStartHorizontal,
|
||||
AlignStartVertical,
|
||||
AlignVerticalDistributeCenter,
|
||||
Group,
|
||||
Paintbrush,
|
||||
Pipette,
|
||||
Ungroup,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { Choice, Section } from './StudioControls';
|
||||
import { useStudioShortcut } from './studioShortcut';
|
||||
|
||||
/**
|
||||
* Align, distribute, group and the style clipboard, for whatever is selected.
|
||||
*
|
||||
* The align bar lines a multi-selection up within its own box by default, and
|
||||
* a single element (or one whole group) against its page, since there is
|
||||
* nothing else for it to line up with. Groups move as one box throughout
|
||||
* (shared/src/book/book-arrange.ts). Every press is one undo step.
|
||||
*/
|
||||
|
||||
const ALIGN: { edge: BookAlignEdge; icon: LucideIcon; labelKey: string }[] = [
|
||||
{ edge: 'left', icon: AlignStartVertical, labelKey: 'journey.studio.layers.alignLeft' },
|
||||
{ edge: 'center', icon: AlignCenterVertical, labelKey: 'journey.studio.layers.alignCenter' },
|
||||
{ edge: 'right', icon: AlignEndVertical, labelKey: 'journey.studio.layers.alignRight' },
|
||||
{ edge: 'top', icon: AlignStartHorizontal, labelKey: 'journey.studio.layers.alignTop' },
|
||||
{ edge: 'middle', icon: AlignCenterHorizontal, labelKey: 'journey.studio.layers.alignMiddle' },
|
||||
{ edge: 'bottom', icon: AlignEndHorizontal, labelKey: 'journey.studio.layers.alignBottom' },
|
||||
];
|
||||
|
||||
/** How many things move as one in a selection: a group counts once. */
|
||||
function unitCount(spread: BookSpread, ids: string[]): number {
|
||||
return distributableCount(
|
||||
spread,
|
||||
spread.elements.filter((e) => ids.includes(e.id))
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
disabled,
|
||||
shortcut,
|
||||
}: {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
shortcut?: readonly string[];
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label} shortcut={shortcut}>
|
||||
<button type="button" className="st-chip is-icon" onClick={onClick} disabled={disabled} aria-label={label}>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function StudioArrangeSection({ spreadIndex, t }: { spreadIndex: number; t: (k: string) => string }) {
|
||||
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
|
||||
const selection = useStudioStore((s) => s.selection);
|
||||
const hasStyle = useStudioStore((s) => s.styleClip !== null);
|
||||
const align = useStudioStore((s) => s.align);
|
||||
const distribute = useStudioStore((s) => s.distribute);
|
||||
const group = useStudioStore((s) => s.group);
|
||||
const ungroup = useStudioStore((s) => s.ungroup);
|
||||
const copyStyle = useStudioStore((s) => s.copyStyle);
|
||||
const pasteStyle = useStudioStore((s) => s.pasteStyle);
|
||||
const keys = useStudioShortcut();
|
||||
const [chosenTo, setChosenTo] = useState<BookAlignTo | null>(null);
|
||||
|
||||
if (!spread || !selection.length) return null;
|
||||
const sel = spread.elements.filter((e) => selection.includes(e.id));
|
||||
const movable = sel.filter((e) => !e.locked).map((e) => e.id);
|
||||
const units = unitCount(spread, movable);
|
||||
// One thing has nothing to line up with but its page.
|
||||
const to: BookAlignTo = units < 2 ? (chosenTo === 'safe' ? 'safe' : 'page') : (chosenTo ?? 'selection');
|
||||
const grouped = sel.some((e) => e.group);
|
||||
const canGroup = movable.length >= 2 && !isWholeGroup(spread, movable);
|
||||
const asGroup = selection.length > 1 && isWholeGroup(spread, selection);
|
||||
|
||||
return (
|
||||
// Open for a multi-selection, where lining up is what comes next; folded for one element.
|
||||
<Section key={units > 1 ? 'many' : 'one'} label={t('journey.studio.layers.align')} defaultOpen={units > 1}>
|
||||
<div className="st-row">
|
||||
{ALIGN.map((a) => (
|
||||
<IconButton
|
||||
key={a.edge}
|
||||
label={t(a.labelKey)}
|
||||
icon={a.icon}
|
||||
disabled={!movable.length}
|
||||
onClick={() => align(spreadIndex, a.edge, to)}
|
||||
/>
|
||||
))}
|
||||
<span className="st-row-sep" aria-hidden />
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.distributeH')}
|
||||
icon={AlignHorizontalDistributeCenter}
|
||||
disabled={units < 3}
|
||||
onClick={() => distribute(spreadIndex, 'horizontal')}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.distributeV')}
|
||||
icon={AlignVerticalDistributeCenter}
|
||||
disabled={units < 3}
|
||||
onClick={() => distribute(spreadIndex, 'vertical')}
|
||||
/>
|
||||
</div>
|
||||
<div className="st-arrange-to">
|
||||
<span className="st-line-label">{t('journey.studio.layers.alignTo')}</span>
|
||||
<Choice<BookAlignTo>
|
||||
value={to}
|
||||
ariaLabel={t('journey.studio.layers.alignTo')}
|
||||
options={[
|
||||
{ value: 'selection' as const, label: t('journey.studio.layers.toSelection'), disabled: units < 2 },
|
||||
{ value: 'page' as const, label: t('journey.studio.layers.toPage') },
|
||||
{ value: 'safe' as const, label: t('journey.studio.layers.toSafe') },
|
||||
]}
|
||||
onPick={setChosenTo}
|
||||
/>
|
||||
</div>
|
||||
<div className="st-row st-arrange-acts">
|
||||
{(canGroup || !grouped) && (
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.groupAction')}
|
||||
icon={Group}
|
||||
disabled={!canGroup}
|
||||
shortcut={keys('G')}
|
||||
onClick={() => group(spreadIndex)}
|
||||
/>
|
||||
)}
|
||||
{grouped && (
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.ungroup')}
|
||||
icon={Ungroup}
|
||||
shortcut={keys('⇧', 'G')}
|
||||
onClick={() => ungroup(spreadIndex)}
|
||||
/>
|
||||
)}
|
||||
<span className="st-row-sep" aria-hidden />
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.copyStyle')}
|
||||
icon={Pipette}
|
||||
shortcut={keys('alt', 'C')}
|
||||
onClick={() => copyStyle(spreadIndex)}
|
||||
/>
|
||||
<IconButton
|
||||
label={t('journey.studio.layers.pasteStyle')}
|
||||
icon={Paintbrush}
|
||||
disabled={!hasStyle || !movable.length}
|
||||
shortcut={keys('alt', 'V')}
|
||||
onClick={() => pasteStyle(spreadIndex)}
|
||||
/>
|
||||
</div>
|
||||
{asGroup && <p className="st-hint st-arrange-hint">{t('journey.studio.layers.groupHint')}</p>}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import type { BookDocument, BookElement } from '@trek/shared';
|
||||
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { fireEvent, render } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { StudioCanvas } from './StudioCanvas';
|
||||
|
||||
/**
|
||||
* The sheet with groups and multi-selections (journey-studio WP6): a click
|
||||
* picks a group, a double-click steps into it, a marquee picks what it
|
||||
* touches, one box resizes a multi-selection, and the keyboard nudges,
|
||||
* groups, duplicates and steps back out.
|
||||
*/
|
||||
|
||||
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
|
||||
const PX = 96 / 25.4;
|
||||
|
||||
/** The value, or a failed test when it is missing. */
|
||||
function must<T>(value: T | null | undefined): T {
|
||||
if (value === null || value === undefined) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
|
||||
const shape = (id: string, x: number, y: number, over: Partial<BookElement> = {}): BookElement =>
|
||||
bookShapeElementSchema.parse({
|
||||
id,
|
||||
kind: 'shape',
|
||||
frame: { x, y, w: 20, h: 20 },
|
||||
shape: 'rect',
|
||||
...over,
|
||||
}) as BookElement;
|
||||
|
||||
function load(elements: BookElement[]) {
|
||||
useStudioStore.getState().load({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page,
|
||||
spreads: [{ id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null }],
|
||||
} as BookDocument);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const spread = must(useStudioStore.getState().doc).spreads[0] ?? null;
|
||||
const view = render(
|
||||
<StudioCanvas spread={spread} spreadIndex={0} page={page} zoom={1} pxPerMm={PX} bookView={false} dropLabel="" />
|
||||
);
|
||||
const stage = view.container.querySelector('.st-stage') as HTMLElement;
|
||||
const sheet = view.container.querySelector('.st-sheet') as HTMLElement;
|
||||
// The hit targets are the last children of the sheet, one per element shown, in paint order.
|
||||
const targets = () => Array.from(sheet.children).slice(-elements().filter((e) => !e.hidden).length) as HTMLElement[];
|
||||
return { ...view, stage, sheet, targets };
|
||||
}
|
||||
|
||||
const store = () => useStudioStore.getState();
|
||||
const elements = () => must(must(store().doc).spreads[0]).elements;
|
||||
const el = (id: string) => must(elements().find((e) => e.id === id));
|
||||
|
||||
const grouped = () => [shape('a', 10, 10), shape('g1', 60, 10, { group: 'g' }), shape('g2', 90, 40, { group: 'g' })];
|
||||
|
||||
beforeEach(() => {
|
||||
load([]);
|
||||
useStudioStore.setState({ enteredGroup: null, styleClip: null });
|
||||
});
|
||||
|
||||
describe('groups on the sheet', () => {
|
||||
it('a click on a member picks the whole group, drawn as one dashed box with handles', () => {
|
||||
load(grouped());
|
||||
const { container, stage, targets } = draw();
|
||||
fireEvent.pointerDown(must(targets()[1]));
|
||||
fireEvent.pointerUp(stage);
|
||||
expect(store().selection).toEqual(['g1', 'g2']);
|
||||
const box = container.querySelector('.st-select-box') as HTMLElement;
|
||||
expect(box.className).toContain('is-group');
|
||||
expect(box.querySelectorAll('.st-handle')).toHaveLength(8);
|
||||
expect(container.querySelectorAll('.st-select.is-member')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('a double-click steps into the group and picks the member alone', () => {
|
||||
load(grouped());
|
||||
const { container, stage, targets } = draw();
|
||||
fireEvent.pointerDown(must(targets()[2]));
|
||||
fireEvent.pointerUp(stage);
|
||||
fireEvent.doubleClick(must(targets()[2]));
|
||||
expect(store().selection).toEqual(['g2']);
|
||||
expect(store().enteredGroup).toBe('g');
|
||||
expect(container.querySelector('.st-select.is-inside')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('a second double-click on a text inside its group edits it', () => {
|
||||
const words = bookTextElementSchema.parse({
|
||||
id: 't',
|
||||
kind: 'text',
|
||||
frame: { x: 10, y: 10, w: 60, h: 20 },
|
||||
text: 'Hi',
|
||||
group: 'g',
|
||||
}) as BookElement;
|
||||
load([words, shape('s', 100, 100, { group: 'g' })]);
|
||||
const { container, targets } = draw();
|
||||
fireEvent.doubleClick(must(targets()[0]));
|
||||
expect(container.querySelector('.st-inline-edit')).toBeNull();
|
||||
fireEvent.doubleClick(must(targets()[0]));
|
||||
expect(container.querySelector('.st-inline-edit')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('draws no target for a hidden element', () => {
|
||||
load([shape('a', 10, 10), shape('h', 50, 50, { hidden: true })]);
|
||||
const { sheet } = draw();
|
||||
const hits = Array.from(sheet.children).filter((c) => (c as HTMLElement).style.cursor === 'move');
|
||||
expect(hits).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pressing on a selection', () => {
|
||||
it('drags all of a multi-selection, and a click without a drag narrows it to the one pressed', () => {
|
||||
load([shape('a', 10, 10), shape('b', 60, 10)]);
|
||||
store().select(['a', 'b']);
|
||||
const { stage, targets } = draw();
|
||||
fireEvent.pointerDown(must(targets()[0]), { clientX: 0, clientY: 0 });
|
||||
fireEvent.pointerMove(stage, { clientX: 10 * PX, clientY: 0 });
|
||||
fireEvent.pointerUp(stage);
|
||||
expect(store().selection).toEqual(['a', 'b']);
|
||||
expect(el('a').frame.x).toBeCloseTo(20);
|
||||
expect(el('b').frame.x).toBeCloseTo(70);
|
||||
|
||||
fireEvent.pointerDown(must(targets()[1]), { clientX: 0, clientY: 0 });
|
||||
fireEvent.pointerUp(stage);
|
||||
expect(store().selection).toEqual(['b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the marquee', () => {
|
||||
it('picks what it touches, with its group, and is drawn while it is dragged', () => {
|
||||
load(grouped());
|
||||
const { container, stage, sheet } = draw();
|
||||
fireEvent.pointerDown(sheet, { clientX: 0, clientY: 0 });
|
||||
fireEvent.pointerMove(stage, { clientX: 70 * PX, clientY: 25 * PX });
|
||||
expect(container.querySelector('.st-marquee')).not.toBeNull();
|
||||
expect(store().selection).toEqual(['a', 'g1', 'g2']);
|
||||
fireEvent.pointerUp(stage);
|
||||
expect(container.querySelector('.st-marquee')).toBeNull();
|
||||
expect(store().selection).toEqual(['a', 'g1', 'g2']);
|
||||
});
|
||||
|
||||
it('adds to the selection with shift, and a click on bare paper clears it', () => {
|
||||
load([shape('a', 10, 10), shape('b', 100, 100)]);
|
||||
const { stage, sheet } = draw();
|
||||
store().select(['b']);
|
||||
fireEvent.pointerDown(sheet, { clientX: 0, clientY: 0, shiftKey: true });
|
||||
fireEvent.pointerMove(stage, { clientX: 40 * PX, clientY: 40 * PX, shiftKey: true });
|
||||
fireEvent.pointerUp(stage);
|
||||
expect(store().selection).toEqual(['b', 'a']);
|
||||
fireEvent.pointerDown(sheet, { clientX: 5, clientY: 5 });
|
||||
fireEvent.pointerUp(stage);
|
||||
expect(store().selection).toEqual([]);
|
||||
});
|
||||
|
||||
it('starts from a locked element dragged, and leaves the locked one out', () => {
|
||||
load([shape('bg', 0, 0, { locked: true, frame: { x: 0, y: 0, w: 200, h: 200 } }), shape('a', 10, 10)]);
|
||||
const { stage, targets } = draw();
|
||||
fireEvent.pointerDown(must(targets()[0]), { clientX: 1, clientY: 1 });
|
||||
fireEvent.pointerMove(stage, { clientX: 40 * PX, clientY: 40 * PX });
|
||||
fireEvent.pointerUp(stage);
|
||||
expect(store().selection).toEqual(['a']);
|
||||
expect(el('bg').frame).toMatchObject({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resizing a multi-selection', () => {
|
||||
const grab = () => {
|
||||
load([shape('a', 10, 10), shape('b', 50, 30)]);
|
||||
store().select(['a', 'b']);
|
||||
const view = draw();
|
||||
const handles = Array.from(view.container.querySelectorAll('.st-select-box .st-handle')) as HTMLElement[];
|
||||
// The handles run nw, n, ne, e, se, s, sw, w.
|
||||
return { ...view, se: must(handles[4]) };
|
||||
};
|
||||
|
||||
it('scales every frame inside one box', () => {
|
||||
const { stage, se } = grab();
|
||||
fireEvent.pointerDown(se, { clientX: 0, clientY: 0 });
|
||||
fireEvent.pointerMove(stage, { clientX: 60 * PX, clientY: 0 });
|
||||
fireEvent.pointerUp(stage);
|
||||
// The box 10..70 grows to 10..130 across, so everything doubles that way.
|
||||
expect(el('a').frame.w).toBeCloseTo(40);
|
||||
expect(el('b').frame.x).toBeCloseTo(90);
|
||||
expect(el('b').frame.h).toBeCloseTo(20);
|
||||
store().undo();
|
||||
expect(el('a').frame).toMatchObject({ w: 20, h: 20 });
|
||||
});
|
||||
|
||||
it('keeps the proportions with shift, so every element keeps its own', () => {
|
||||
const { stage, se } = grab();
|
||||
fireEvent.pointerDown(se, { clientX: 0, clientY: 0 });
|
||||
fireEvent.pointerMove(stage, { clientX: 120 * PX, clientY: 0, shiftKey: true });
|
||||
fireEvent.pointerUp(stage);
|
||||
// 60 by 40 to 180 by 120: three times either way.
|
||||
expect(el('a').frame.w).toBeCloseTo(60);
|
||||
expect(el('a').frame.h).toBeCloseTo(60);
|
||||
expect(el('b').frame.y).toBeCloseTo(70);
|
||||
});
|
||||
|
||||
it('leaves a locked member where it is', () => {
|
||||
load([shape('a', 10, 10), shape('l', 50, 30, { locked: true })]);
|
||||
store().select(['a', 'l']);
|
||||
const { container, stage } = draw();
|
||||
const se = Array.from(container.querySelectorAll('.st-select-box .st-handle'))[4] as HTMLElement;
|
||||
fireEvent.pointerDown(se, { clientX: 0, clientY: 0 });
|
||||
fireEvent.pointerMove(stage, { clientX: 20 * PX, clientY: 20 * PX });
|
||||
fireEvent.pointerUp(stage);
|
||||
expect(el('l').frame).toMatchObject({ x: 50, y: 30, w: 20, h: 20 });
|
||||
expect(el('a').frame.w).toBeCloseTo(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the keyboard', () => {
|
||||
it('nudges by a millimetre and ten with shift, one undo step while held', () => {
|
||||
load([shape('a', 10, 10)]);
|
||||
store().select(['a']);
|
||||
draw();
|
||||
fireEvent.keyDown(document, { key: 'ArrowRight' });
|
||||
fireEvent.keyDown(document, { key: 'ArrowRight', repeat: true });
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown', shiftKey: true });
|
||||
fireEvent.keyUp(document, { key: 'ArrowDown' });
|
||||
expect(el('a').frame).toMatchObject({ x: 12, y: 20 });
|
||||
store().undo();
|
||||
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
|
||||
});
|
||||
|
||||
it('groups, ungroups, duplicates and selects everything', () => {
|
||||
load([shape('a', 10, 10), shape('b', 40, 10)]);
|
||||
draw();
|
||||
fireEvent.keyDown(document, { key: 'a', ctrlKey: true });
|
||||
expect(store().selection).toEqual(['a', 'b']);
|
||||
fireEvent.keyDown(document, { key: 'g', ctrlKey: true });
|
||||
expect(el('a').group).toBeDefined();
|
||||
fireEvent.keyDown(document, { key: 'G', ctrlKey: true, shiftKey: true });
|
||||
expect(el('a').group).toBeUndefined();
|
||||
fireEvent.keyDown(document, { key: 'd', ctrlKey: true });
|
||||
expect(elements()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('copies and pastes style, and arranges with the brackets', () => {
|
||||
load([shape('a', 10, 10, { fill: '#ff0000' }), shape('b', 40, 10)]);
|
||||
draw();
|
||||
store().select(['a']);
|
||||
fireEvent.keyDown(document, { key: 'c', ctrlKey: true, altKey: true });
|
||||
store().select(['b']);
|
||||
fireEvent.keyDown(document, { key: 'v', ctrlKey: true, altKey: true });
|
||||
expect(el('b')).toMatchObject({ fill: '#ff0000' });
|
||||
fireEvent.keyDown(document, { key: '[', ctrlKey: true });
|
||||
expect(elements().map((e) => e.id)).toEqual(['b', 'a']);
|
||||
fireEvent.keyDown(document, { key: '}', ctrlKey: true, shiftKey: true });
|
||||
expect(elements().map((e) => e.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('steps out of a group with Escape, telling the page not to clear the selection', () => {
|
||||
load(grouped());
|
||||
draw();
|
||||
store().enterGroup(0, 'g1');
|
||||
const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
|
||||
document.dispatchEvent(escape);
|
||||
expect(escape.defaultPrevented).toBe(true);
|
||||
expect(store().selection).toEqual(['g1', 'g2']);
|
||||
const again = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
|
||||
document.dispatchEvent(again);
|
||||
expect(again.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the keys to a field being typed in', () => {
|
||||
load([shape('a', 10, 10)]);
|
||||
store().select(['a']);
|
||||
draw();
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
fireEvent.keyDown(input, { key: 'ArrowRight' });
|
||||
fireEvent.keyDown(input, { key: 'a', ctrlKey: true });
|
||||
expect(el('a').frame.x).toBe(10);
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronsDown, ChevronsUp, Copy, Lock, RotateCcw, RotateCw, Trash2, Unlock } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
|
||||
import { SpreadFold, SpreadView } from './SpreadView'
|
||||
@@ -6,11 +6,14 @@ import { PeerCursors } from './PeerCursors'
|
||||
import type { PeerCursor } from './useBookPresence'
|
||||
import { bookElementId as elementId } from '@trek/shared'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
|
||||
import { useSpreadInteraction } from './useSpreadInteraction'
|
||||
import { useStudioKeys } from './useStudioKeys'
|
||||
import { selectClass, selectionShape } from './studioGestures'
|
||||
import { MarqueeRect, ResizeHandles, SelectionBox } from './StudioSelectionChrome'
|
||||
import '../../styles/studio-layers.css'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
|
||||
|
||||
/**
|
||||
* What a delete may actually take out of the selection.
|
||||
@@ -37,17 +40,6 @@ const ROTATE_CORNERS = [
|
||||
{ id: 'sw', left: '0%', top: '100%' },
|
||||
] as const
|
||||
|
||||
const HANDLE_POS: Record<HandleId, { left: string; top: string; cursor: string }> = {
|
||||
nw: { left: '0%', top: '0%', cursor: 'nwse-resize' },
|
||||
n: { left: '50%', top: '0%', cursor: 'ns-resize' },
|
||||
ne: { left: '100%', top: '0%', cursor: 'nesw-resize' },
|
||||
e: { left: '100%', top: '50%', cursor: 'ew-resize' },
|
||||
se: { left: '100%', top: '100%', cursor: 'nwse-resize' },
|
||||
s: { left: '50%', top: '100%', cursor: 'ns-resize' },
|
||||
sw: { left: '0%', top: '100%', cursor: 'nesw-resize' },
|
||||
w: { left: '0%', top: '50%', cursor: 'ew-resize' },
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet plus everything you do to it.
|
||||
*
|
||||
@@ -97,10 +89,9 @@ export function StudioCanvas({
|
||||
const select = useStudioStore(s => s.select)
|
||||
const removeElements = useStudioStore(s => s.removeElements)
|
||||
const duplicate = useStudioStore(s => s.duplicate)
|
||||
const copy = useStudioStore(s => s.copy)
|
||||
const paste = useStudioStore(s => s.paste)
|
||||
const hasClipboard = useStudioStore(s => !!s.clipboard)
|
||||
const raise = useStudioStore(s => s.raise)
|
||||
const enteredGroup = useStudioStore(s => s.enteredGroup)
|
||||
const enterGroup = useStudioStore(s => s.enterGroup)
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||
@@ -140,45 +131,25 @@ export function StudioCanvas({
|
||||
const frameUnder = (x: number, y: number) => spread?.elements.find(el =>
|
||||
el.kind === 'photo'
|
||||
&& !el.locked
|
||||
&& !el.hidden
|
||||
&& x >= el.frame.x && x <= el.frame.x + el.frame.w
|
||||
&& y >= el.frame.y && y <= el.frame.y + el.frame.h,
|
||||
) ?? null
|
||||
const scaled = pxPerMm * zoom
|
||||
|
||||
const { guides, dragging, startMove, startResize, startRotate, onPointerMove, finish } = useSpreadInteraction({
|
||||
const { guides, dragging, marquee, startMove, startPaper, startResize, startRotate, onPointerMove, finish } = useSpreadInteraction({
|
||||
spread, spreadIndex, page, pxPerMm: scaled,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) {
|
||||
e.preventDefault()
|
||||
const ids = deletable(spread, selection)
|
||||
if (ids.length) removeElements(spreadIndex, ids)
|
||||
}
|
||||
// Copy and paste across pages (#2316). Text selected on the page is the
|
||||
// browser's to copy, not the book's.
|
||||
if (!(e.metaKey || e.ctrlKey) || e.altKey) return
|
||||
const key = e.key.toLowerCase()
|
||||
if (key === 'c' && selection.length && !window.getSelection()?.toString()) {
|
||||
e.preventDefault()
|
||||
copy(spreadIndex, selection)
|
||||
} else if (key === 'v' && hasClipboard) {
|
||||
e.preventDefault()
|
||||
paste(spreadIndex)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [selection, spread, spreadIndex, removeElements, copy, paste, hasClipboard])
|
||||
useStudioKeys({ spreadIndex, editing: editing !== null })
|
||||
|
||||
if (!spread) return null
|
||||
|
||||
const single = spread.role !== 'inner'
|
||||
const sheetW = single ? page.pageWidth : page.pageWidth * 2
|
||||
const sel = spread.elements.filter(e => selection.includes(e.id))
|
||||
const sel = spread.elements.filter(e => selection.includes(e.id) && !e.hidden)
|
||||
/** One box with handles around a multi-selection; a whole group reads as one thing. */
|
||||
const { many, box, asGroup, anyMovable } = selectionShape(spread, sel)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -271,7 +242,8 @@ export function StudioCanvas({
|
||||
height: `${page.pageHeight}mm`,
|
||||
transform: `scale(${zoom})`,
|
||||
}}
|
||||
onPointerDown={() => select([])}
|
||||
// A press on bare paper draws a marquee; without a drag it clears the selection.
|
||||
onPointerDown={startPaper}
|
||||
>
|
||||
<SpreadView spread={spread} page={page} folios={folios} big={zoom > 0.34} showGuides dropLabel={dropLabel} />
|
||||
|
||||
@@ -281,11 +253,15 @@ export function StudioCanvas({
|
||||
|
||||
{/* Hit targets sit above the page so a photo's own <img> never eats the
|
||||
gesture, and so a locked element simply is not grabbable. */}
|
||||
{spread.elements.map(el => (
|
||||
{spread.elements.filter(el => !el.hidden).map(el => (
|
||||
<div
|
||||
key={el.id}
|
||||
onPointerDown={e => { if (editing !== el.id) startMove(e, el) }}
|
||||
onDoubleClick={() => { if (el.kind === 'text' && !el.locked) setEditing(el.id) }}
|
||||
onDoubleClick={() => {
|
||||
// Into the group first, the way design tools do it; a second one edits a text.
|
||||
if (el.group && enteredGroup !== el.group) { enterGroup(spreadIndex, el.id); return }
|
||||
if (el.kind === 'text' && !el.locked) setEditing(el.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${el.frame.x}mm`,
|
||||
@@ -379,7 +355,7 @@ export function StudioCanvas({
|
||||
{sel.map(el => (
|
||||
<div
|
||||
key={el.id}
|
||||
className="st-select"
|
||||
className={selectClass(many, el, enteredGroup)}
|
||||
style={{
|
||||
left: el.frame.x * scaled,
|
||||
top: el.frame.y * scaled,
|
||||
@@ -419,17 +395,22 @@ export function StudioCanvas({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sel.length === 1 && !dragging && !el.locked && HANDLES.map(h => (
|
||||
<span
|
||||
key={h}
|
||||
className={`st-handle ${h.length === 2 ? 'is-corner' : h === 'n' || h === 's' ? 'is-h' : 'is-v'}`}
|
||||
style={{ left: HANDLE_POS[h].left, top: HANDLE_POS[h].top, cursor: HANDLE_POS[h].cursor }}
|
||||
onPointerDown={e => startResize(e, h)}
|
||||
/>
|
||||
))}
|
||||
{sel.length === 1 && !dragging && !el.locked && <ResizeHandles onResize={startResize} />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/*
|
||||
A multi-selection as one box: one set of handles that resizes them
|
||||
together (Shift keeps the proportions), the members outlined lightly
|
||||
inside it. A whole group's box is dashed, so a group reads as a group
|
||||
before anything is done to it.
|
||||
*/}
|
||||
{box && (
|
||||
<SelectionBox box={box} scaled={scaled} asGroup={asGroup} handles={!dragging && anyMovable} onResize={startResize} />
|
||||
)}
|
||||
|
||||
{marquee && <MarqueeRect rect={marquee} scaled={scaled} />}
|
||||
|
||||
{/*
|
||||
The quick bar, the way Canva and Figma do it: the three or four things
|
||||
you reach for constantly, right where you are already looking, instead
|
||||
@@ -450,12 +431,12 @@ export function StudioCanvas({
|
||||
<button type="button" onClick={() => duplicate(spreadIndex, selection)} title={t('journey.studio.duplicate')}>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
{sel.length === 1 && (
|
||||
{(sel.length === 1 || asGroup) && (
|
||||
<>
|
||||
<button type="button" onClick={() => raise(spreadIndex, sel[0].id, 'front')} title={t('journey.studio.toFront')}>
|
||||
<button type="button" onClick={() => raise(spreadIndex, selection, 'front')} title={t('journey.studio.toFront')}>
|
||||
<ChevronsUp size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => raise(spreadIndex, sel[0].id, 'back')} title={t('journey.studio.toBack')}>
|
||||
<button type="button" onClick={() => raise(spreadIndex, selection, 'back')} title={t('journey.studio.toBack')}>
|
||||
<ChevronsDown size={14} />
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { BOOK_GRIDS as GRIDS, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared'
|
||||
import { BOOK_GRIDS as GRIDS, bookElementId, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared'
|
||||
import { PanelHead } from './StudioPanelHead'
|
||||
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
|
||||
|
||||
@@ -16,7 +16,8 @@ import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLib
|
||||
* 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)}`
|
||||
// The book's own ids, the ones the layouts and the assistant mint (shared/src/book/book-ids.ts).
|
||||
const uid = bookElementId
|
||||
|
||||
/** How many icons the grid draws at a time. Two screens' worth at four across. */
|
||||
const ICON_PAGE = 120
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Choice, Line, NumField, Section, Switch } from './StudioControls'
|
||||
import { FontButtons, WeightLine } from './StudioTypeControls'
|
||||
import type { JourneySource } from './StudioSidebar'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { StudioArrangeSection } from './StudioArrangeSection'
|
||||
import { selectionTitle } from './studioLayerRows'
|
||||
|
||||
/** How many icons the swap grid offers at once. A search narrows it below this. */
|
||||
const ICON_CHOICES = 60
|
||||
@@ -233,7 +235,7 @@ export function StudioInspector({
|
||||
*/}
|
||||
<div className="st-panel-head st-inspector-head">
|
||||
<span className="st-what">
|
||||
{one ? t(`journey.studio.kind.${el.kind}`) : t('journey.studio.multiple')}
|
||||
{one ? t(`journey.studio.kind.${el.kind}`) : selectionTitle(spread, selection, t)}
|
||||
{sel.length > 1 && <em>{sel.length}</em>}
|
||||
</span>
|
||||
<span className="st-head-acts">
|
||||
@@ -691,18 +693,20 @@ export function StudioInspector({
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<StudioArrangeSection spreadIndex={spreadIndex} t={t} />
|
||||
|
||||
<Section label={t('journey.studio.arrange')} defaultOpen={false}>
|
||||
<div className="st-row">
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'front')} title={t('journey.studio.toFront')}>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'front')} title={t('journey.studio.toFront')}>
|
||||
<ChevronsUp size={14} />
|
||||
</button>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'up')} title={t('journey.studio.forward')}>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'up')} title={t('journey.studio.forward')}>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'down')} title={t('journey.studio.backward')}>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'down')} title={t('journey.studio.backward')}>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'back')} title={t('journey.studio.toBack')}>
|
||||
<button type="button" className="st-chip is-icon" onClick={() => raise(spreadIndex, selection, 'back')} title={t('journey.studio.toBack')}>
|
||||
<ChevronsDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { BookDocument, BookElement } from '@trek/shared';
|
||||
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { StudioArrangeSection } from './StudioArrangeSection';
|
||||
import { selectionTitle } from './studioLayerRows';
|
||||
import { StudioLayersPanel } from './StudioLayersPanel';
|
||||
|
||||
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
|
||||
const t = (k: string) => k;
|
||||
|
||||
/** The value, or a failed test when it is missing. */
|
||||
function must<T>(value: T | null | undefined): T {
|
||||
if (value === null || value === undefined) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
|
||||
const shape = (id: string, x: number, over: Partial<BookElement> = {}): BookElement =>
|
||||
bookShapeElementSchema.parse({
|
||||
id,
|
||||
kind: 'shape',
|
||||
frame: { x, y: 10, w: 20, h: 20 },
|
||||
shape: 'rect',
|
||||
...over,
|
||||
}) as BookElement;
|
||||
|
||||
function load(elements: BookElement[], groups?: { id: string; name?: string }[]) {
|
||||
useStudioStore.getState().load({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page,
|
||||
spreads: [{ id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null, groups }],
|
||||
} as BookDocument);
|
||||
useStudioStore.setState({ enteredGroup: null, styleClip: null, readOnly: false });
|
||||
}
|
||||
|
||||
const store = () => useStudioStore.getState();
|
||||
const el = (id: string) => must(must(must(store().doc).spreads[0]).elements.find((e) => e.id === id));
|
||||
const order = () => must(must(store().doc).spreads[0]).elements.map((e) => e.id);
|
||||
|
||||
const book = () => {
|
||||
const title = bookTextElementSchema.parse({
|
||||
id: 't',
|
||||
kind: 'text',
|
||||
frame: { x: 0, y: 0, w: 50, h: 10 },
|
||||
text: 'Reykjavik',
|
||||
}) as BookElement;
|
||||
load(
|
||||
[shape('a', 0), shape('g1', 40, { group: 'g' }), shape('g2', 80, { group: 'g' }), title],
|
||||
[{ id: 'g', name: 'Pair' }]
|
||||
);
|
||||
};
|
||||
|
||||
const rows = (container: HTMLElement) => Array.from(container.querySelectorAll<HTMLElement>('[data-layer-key]'));
|
||||
|
||||
beforeEach(book);
|
||||
|
||||
describe('the layers panel', () => {
|
||||
it('lists the page front to back, a group with its members, a text by its words', () => {
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
expect(rows(container).map((r) => r.dataset.layerKey)).toEqual(['t', 'g:g', 'g2', 'g1', 'a']);
|
||||
expect(screen.getByText('Reykjavik')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pair')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects a group from its row, a member alone inside it, and an element with shift added', () => {
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
fireEvent.click(must(rows(container)[1]));
|
||||
expect(store().selection).toEqual(['g1', 'g2']);
|
||||
fireEvent.click(must(rows(container)[2]));
|
||||
expect([store().selection, store().enteredGroup]).toEqual([['g2'], 'g']);
|
||||
fireEvent.click(must(rows(container)[4]), { shiftKey: true });
|
||||
expect(store().selection).toEqual(['g2', 'a']);
|
||||
});
|
||||
|
||||
it('hides, shows and locks a layer from its row, and a group from its own', () => {
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
const aRow = must(rows(container)[4]);
|
||||
fireEvent.click(aRow.querySelector('[aria-label="journey.studio.layers.hide"]') as Element);
|
||||
expect(el('a').hidden).toBe(true);
|
||||
expect(must(rows(container)[4]).className).toContain('is-hidden');
|
||||
fireEvent.click(must(rows(container)[4]).querySelector('[aria-label="journey.studio.layers.show"]') as Element);
|
||||
expect(el('a').hidden).toBeUndefined();
|
||||
fireEvent.click(must(rows(container)[1]).querySelector('[aria-label="journey.studio.lock"]') as Element);
|
||||
expect([el('g1').locked, el('g2').locked]).toEqual([true, true]);
|
||||
// Toggling a row does not select it.
|
||||
expect(store().selection).toEqual([]);
|
||||
});
|
||||
|
||||
it('renames on a double-click, Enter keeps it and Escape leaves the name as it was', () => {
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
fireEvent.doubleClick(screen.getByText('Pair'));
|
||||
const input = container.querySelector('.st-layer-input') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'Sun and sea' } });
|
||||
fireEvent.blur(input);
|
||||
expect(must(must(store().doc).spreads[0]).groups).toEqual([{ id: 'g', name: 'Sun and sea' }]);
|
||||
|
||||
fireEvent.doubleClick(screen.getByText('Reykjavik'));
|
||||
const again = container.querySelector('.st-layer-input') as HTMLInputElement;
|
||||
fireEvent.keyDown(again, { key: 'Escape' });
|
||||
expect(container.querySelector('.st-layer-input')).toBeNull();
|
||||
expect(el('t').name).toBeUndefined();
|
||||
});
|
||||
|
||||
it('folds a group away and opens it again', () => {
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
fireEvent.click(container.querySelector('[aria-label="journey.studio.layers.collapse"]') as Element);
|
||||
expect(rows(container).map((r) => r.dataset.layerKey)).toEqual(['t', 'g:g', 'a']);
|
||||
fireEvent.click(container.querySelector('[aria-label="journey.studio.layers.expand"]') as Element);
|
||||
expect(rows(container)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('restacks a row dragged over another, as one undo step', () => {
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
const list = container.querySelector('.st-layers') as HTMLElement;
|
||||
// Rows 20 pixels tall from the top, front to back.
|
||||
rows(container).forEach((row, i) => {
|
||||
row.getBoundingClientRect = () =>
|
||||
({
|
||||
top: i * 20,
|
||||
bottom: i * 20 + 20,
|
||||
height: 20,
|
||||
left: 0,
|
||||
right: 100,
|
||||
width: 100,
|
||||
x: 0,
|
||||
y: i * 20,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
});
|
||||
fireEvent.pointerDown(must(rows(container)[4]), { clientY: 90, button: 0 });
|
||||
fireEvent.pointerMove(list, { clientY: 60 });
|
||||
fireEvent.pointerMove(list, { clientY: 3 });
|
||||
expect(must(rows(container)[0]).className).toContain('is-drop-above');
|
||||
fireEvent.pointerUp(list, { clientY: 3 });
|
||||
expect(order()).toEqual(['g1', 'g2', 't', 'a']);
|
||||
store().undo();
|
||||
expect(order()).toEqual(['a', 'g1', 'g2', 't']);
|
||||
});
|
||||
|
||||
it('groups and ungroups the selection from its foot, and says when the page is empty', () => {
|
||||
store().select(['a', 't']);
|
||||
render(<StudioLayersPanel t={t} />);
|
||||
fireEvent.click(screen.getByText('journey.studio.layers.groupAction'));
|
||||
expect(el('a').group).toBeDefined();
|
||||
fireEvent.click(screen.getByText('journey.studio.layers.ungroup'));
|
||||
expect(el('a').group).toBeUndefined();
|
||||
load([]);
|
||||
render(<StudioLayersPanel t={t} />);
|
||||
expect(screen.getAllByText('journey.studio.layers.empty').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('offers no drag, lock or visibility to a book held read-only', () => {
|
||||
useStudioStore.setState({ readOnly: true });
|
||||
const { container } = render(<StudioLayersPanel t={t} />);
|
||||
expect(container.querySelector('.st-layer-acts')).toBeNull();
|
||||
expect(container.querySelector('.st-layers-foot')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the align section', () => {
|
||||
it('aligns a multi-selection within itself and distributes three', () => {
|
||||
store().select(['a', 'g1', 'g2', 't']);
|
||||
render(<StudioArrangeSection spreadIndex={0} t={t} />);
|
||||
fireEvent.click(screen.getByLabelText('journey.studio.layers.alignTop'));
|
||||
expect([el('a').frame.y, el('t').frame.y]).toEqual([0, 0]);
|
||||
const steps = store().past.length;
|
||||
fireEvent.click(screen.getByLabelText('journey.studio.layers.distributeH'));
|
||||
expect(store().past.length).toBe(steps + 1);
|
||||
});
|
||||
|
||||
it('aligns a single element against its page, and copies and pastes style', () => {
|
||||
store().select(['a']);
|
||||
render(<StudioArrangeSection spreadIndex={0} t={t} />);
|
||||
// Folded for one element: lining up is not what one element usually needs next.
|
||||
fireEvent.click(screen.getByText('journey.studio.layers.align'));
|
||||
expect(screen.getByRole('radio', { name: 'journey.studio.layers.toSelection' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByLabelText('journey.studio.layers.alignRight'));
|
||||
expect(el('a').frame.x).toBe(190);
|
||||
expect(screen.getByLabelText('journey.studio.layers.pasteStyle')).toBeDisabled();
|
||||
fireEvent.click(screen.getByLabelText('journey.studio.layers.copyStyle'));
|
||||
expect(store().styleClip?.kind).toBe('shape');
|
||||
});
|
||||
|
||||
it('offers group for a loose selection and ungroup for a group, named in the inspector head', () => {
|
||||
store().select(['g1', 'g2']);
|
||||
const spread = must(store().doc).spreads[0];
|
||||
expect(selectionTitle(spread, ['g1', 'g2'], t)).toBe('Pair');
|
||||
expect(selectionTitle(spread, ['a', 'g1'], t)).toBe('journey.studio.multiple');
|
||||
render(<StudioArrangeSection spreadIndex={0} t={t} />);
|
||||
expect(screen.getByText('journey.studio.layers.groupHint')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText('journey.studio.layers.ungroup'));
|
||||
expect(el('g1').group).toBeUndefined();
|
||||
});
|
||||
|
||||
it('draws nothing without a selection', () => {
|
||||
const { container } = render(<StudioArrangeSection spreadIndex={0} t={t} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,432 @@
|
||||
import type { BookElement } from '@trek/shared';
|
||||
import { isWholeGroup } from '@trek/shared';
|
||||
import {
|
||||
Award,
|
||||
ChartColumn,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
GripVertical,
|
||||
Group,
|
||||
ImageIcon,
|
||||
List,
|
||||
Lock,
|
||||
MapIcon,
|
||||
Shapes,
|
||||
Sparkles,
|
||||
Square,
|
||||
Type,
|
||||
Ungroup,
|
||||
Unlock,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-layers.css';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { PanelHead } from './StudioPanelHead';
|
||||
import { dropOn, layerLabel, layerRows, rowIds, type LayerDrop, type LayerRow } from './studioLayerRows';
|
||||
import { useStudioShortcut } from './studioShortcut';
|
||||
|
||||
/**
|
||||
* The layers of the open page, front to back (journey-studio WP6).
|
||||
*
|
||||
* One row per element and one per group, its members indented under it. A row
|
||||
* selects what it stands for; a member's row selects the member alone, inside
|
||||
* its group, the way a double-click on the sheet does. Rows drag to restack:
|
||||
* the upper half of a row puts what is dragged in front of it, the lower half
|
||||
* behind it, and a member's row takes it into that group. Lock and visibility
|
||||
* sit at the end of every row, always shown when they are on so a locked or
|
||||
* hidden layer can be told at a glance; a hidden layer is dimmed, not removed,
|
||||
* since this panel is the way back to it. Every change is one undo step.
|
||||
*/
|
||||
|
||||
const KIND_ICONS: Record<string, LucideIcon> = {
|
||||
photo: ImageIcon,
|
||||
text: Type,
|
||||
shape: Square,
|
||||
icon: Sparkles,
|
||||
map: MapIcon,
|
||||
stats: ChartColumn,
|
||||
countries: Globe,
|
||||
badge: Award,
|
||||
list: List,
|
||||
};
|
||||
|
||||
/** How far, in pixels, a press on a row travels before it is a drag rather than a click. */
|
||||
const DRAG_PX = 4;
|
||||
|
||||
interface Drag {
|
||||
ids: string[];
|
||||
startY: number;
|
||||
active: boolean;
|
||||
drop: LayerDrop | null;
|
||||
/** The row the pointer is over and which half, for the insertion line. */
|
||||
overKey: string | null;
|
||||
upper: boolean;
|
||||
}
|
||||
|
||||
type T = (k: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export function StudioLayersPanel({ t }: { t: T }) {
|
||||
const spreadIndex = useStudioStore((s) => s.activeSpread);
|
||||
const spread = useStudioStore((s) => s.doc?.spreads[s.activeSpread]);
|
||||
const selection = useStudioStore((s) => s.selection);
|
||||
const enteredGroup = useStudioStore((s) => s.enteredGroup);
|
||||
const readOnly = useStudioStore((s) => s.readOnly);
|
||||
const pick = useStudioStore((s) => s.pick);
|
||||
const selectGroup = useStudioStore((s) => s.selectGroup);
|
||||
const enterGroup = useStudioStore((s) => s.enterGroup);
|
||||
const restack = useStudioStore((s) => s.restack);
|
||||
const setHidden = useStudioStore((s) => s.setHidden);
|
||||
const setLocked = useStudioStore((s) => s.setLocked);
|
||||
const renameLayer = useStudioStore((s) => s.renameLayer);
|
||||
const group = useStudioStore((s) => s.group);
|
||||
const ungroup = useStudioStore((s) => s.ungroup);
|
||||
const keys = useStudioShortcut();
|
||||
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
const list = useRef<HTMLDivElement>(null);
|
||||
/** A drag ends in a click on the row it started from; that click is not a choice. */
|
||||
const justDragged = useRef(false);
|
||||
|
||||
if (!spread) return null;
|
||||
const rows = layerRows(spread, collapsed);
|
||||
const selected = new Set(selection);
|
||||
const movable = spread.elements.filter((e) => selected.has(e.id) && !e.locked);
|
||||
const canGroup =
|
||||
movable.length >= 2 &&
|
||||
!isWholeGroup(
|
||||
spread,
|
||||
movable.map((e) => e.id)
|
||||
);
|
||||
const canUngroup = spread.elements.some((e) => selected.has(e.id) && e.group);
|
||||
|
||||
const toggleCollapsed = (groupId: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupId)) next.delete(groupId);
|
||||
else next.add(groupId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const choose = (row: LayerRow, additive: boolean) => {
|
||||
if (justDragged.current) {
|
||||
justDragged.current = false;
|
||||
return;
|
||||
}
|
||||
if (row.type === 'group') selectGroup(spreadIndex, row.groupId, additive);
|
||||
else if (row.inGroup && !additive) enterGroup(spreadIndex, row.el.id);
|
||||
else pick(spreadIndex, row.el.id, additive);
|
||||
};
|
||||
|
||||
/** What a drag from this row carries: the selection when the row is in it, else what the row stands for. */
|
||||
const dragIds = (row: LayerRow) => {
|
||||
const own = rowIds(row);
|
||||
return own.every((id) => selected.has(id))
|
||||
? spread.elements.filter((e) => selected.has(e.id)).map((e) => e.id)
|
||||
: own;
|
||||
};
|
||||
|
||||
const rowAt = (clientY: number): { row: LayerRow; upper: boolean } | null => {
|
||||
const nodes = list.current?.querySelectorAll<HTMLElement>('[data-layer-key]') ?? [];
|
||||
for (const node of nodes) {
|
||||
const r = node.getBoundingClientRect();
|
||||
if (clientY < r.top || clientY > r.bottom) continue;
|
||||
const row = rows.find((x) => x.key === node.dataset.layerKey);
|
||||
if (row) return { row, upper: clientY < r.top + r.height / 2 };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent, row: LayerRow) => {
|
||||
if (e.button !== 0 || readOnly) return;
|
||||
if ((e.target as HTMLElement).closest('button, input')) return;
|
||||
setDrag({ ids: dragIds(row), startY: e.clientY, active: false, drop: null, overKey: null, upper: true });
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!drag) return;
|
||||
if (!drag.active && Math.abs(e.clientY - drag.startY) < DRAG_PX) return;
|
||||
if (!drag.active) {
|
||||
try {
|
||||
list.current?.setPointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
// A pointer that is gone already cannot be captured; the drag still follows the list.
|
||||
}
|
||||
}
|
||||
const at = rowAt(e.clientY);
|
||||
const over = at && !rowIds(at.row).some((id) => drag.ids.includes(id)) ? at : null;
|
||||
setDrag({
|
||||
...drag,
|
||||
active: true,
|
||||
drop: over ? dropOn(over.row, over.upper) : null,
|
||||
overKey: over?.row.key ?? null,
|
||||
upper: over?.upper ?? true,
|
||||
});
|
||||
};
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent) => {
|
||||
if (!drag) return;
|
||||
try {
|
||||
list.current?.releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
// Released already, or never taken: a click that did not move.
|
||||
}
|
||||
justDragged.current = drag.active;
|
||||
if (drag.active && drag.drop) restack(spreadIndex, drag.ids, drag.drop.anchorId, drag.drop.where, drag.drop.join);
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
const commitName = (row: LayerRow, value: string) => {
|
||||
setRenaming(null);
|
||||
const current = row.type === 'group' ? (row.name ?? '') : (row.el.name ?? '');
|
||||
if (value.trim() === current) return;
|
||||
renameLayer(spreadIndex, row.type === 'group' ? { groupId: row.groupId } : { elementId: row.el.id }, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.layers')} count={spread.elements.length} />
|
||||
<div className="st-panel-scroll st-layers-scroll">
|
||||
{rows.length === 0 ? (
|
||||
<p className="st-hint">{t('journey.studio.layers.empty')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="st-hint st-layers-hint">{t('journey.studio.layers.hint')}</p>
|
||||
<div
|
||||
ref={list}
|
||||
className={`st-layers ${drag?.active ? 'is-dragging' : ''}`}
|
||||
role="tree"
|
||||
aria-label={t('journey.studio.layers')}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={() => setDrag(null)}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<LayerRowView
|
||||
key={row.key}
|
||||
row={row}
|
||||
t={t}
|
||||
selected={rowIds(row).every((id) => selected.has(id))}
|
||||
inside={row.type === 'element' && row.inGroup && row.el.group === enteredGroup}
|
||||
collapsed={row.type === 'group' && collapsed.has(row.groupId)}
|
||||
renaming={renaming === row.key}
|
||||
dragged={!!drag?.active && rowIds(row).every((id) => drag.ids.includes(id))}
|
||||
dropAt={drag?.active && drag.overKey === row.key ? (drag.upper ? 'above' : 'below') : null}
|
||||
readOnly={readOnly}
|
||||
onPointerDown={(e) => onPointerDown(e, row)}
|
||||
onChoose={(additive) => choose(row, additive)}
|
||||
onToggleCollapsed={() => row.type === 'group' && toggleCollapsed(row.groupId)}
|
||||
onRename={() => !readOnly && setRenaming(row.key)}
|
||||
onCommitName={(v) => commitName(row, v)}
|
||||
onCancelName={() => setRenaming(null)}
|
||||
onLock={(locked) => setLocked(spreadIndex, rowIds(row), locked)}
|
||||
onHide={(hidden) => setHidden(spreadIndex, rowIds(row), hidden)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && rows.length > 0 && (
|
||||
<div className="st-layers-foot">
|
||||
<Tooltip label={t('journey.studio.layers.groupAction')} shortcut={keys('G')} placement="top">
|
||||
<button type="button" className="st-chip" disabled={!canGroup} onClick={() => group(spreadIndex)}>
|
||||
<Group size={14} />
|
||||
{t('journey.studio.layers.groupAction')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('journey.studio.layers.ungroup')} shortcut={keys('⇧', 'G')} placement="top">
|
||||
<button type="button" className="st-chip" disabled={!canUngroup} onClick={() => ungroup(spreadIndex)}>
|
||||
<Ungroup size={14} />
|
||||
{t('journey.studio.layers.ungroup')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
row: LayerRow;
|
||||
t: T;
|
||||
selected: boolean;
|
||||
inside: boolean;
|
||||
collapsed: boolean;
|
||||
renaming: boolean;
|
||||
dragged: boolean;
|
||||
dropAt: 'above' | 'below' | null;
|
||||
readOnly: boolean;
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onChoose: (additive: boolean) => void;
|
||||
onToggleCollapsed: () => void;
|
||||
onRename: () => void;
|
||||
onCommitName: (value: string) => void;
|
||||
onCancelName: () => void;
|
||||
onLock: (locked: boolean) => void;
|
||||
onHide: (hidden: boolean) => void;
|
||||
}
|
||||
|
||||
function rowState(row: LayerRow): { locked: boolean; hidden: boolean } {
|
||||
const els: BookElement[] = row.type === 'group' ? row.members : [row.el];
|
||||
return { locked: els.every((e) => e.locked), hidden: els.every((e) => e.hidden) };
|
||||
}
|
||||
|
||||
/** The row's classes: what it is, whether it is picked, hidden, dragged, or where a drop would land. */
|
||||
function rowClasses(p: RowProps, hidden: boolean): string {
|
||||
const { row } = p;
|
||||
return [
|
||||
'st-layer',
|
||||
row.type === 'group' ? 'is-group' : '',
|
||||
row.type === 'element' && row.inGroup ? 'is-member' : '',
|
||||
p.selected ? 'is-on' : '',
|
||||
p.inside ? 'is-inside' : '',
|
||||
hidden ? 'is-hidden' : '',
|
||||
p.dragged ? 'is-dragged' : '',
|
||||
p.dropAt ? `is-drop-${p.dropAt}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** What the row says it is, and whether that is a name somebody gave it. */
|
||||
function rowLabel(row: LayerRow, t: T): { label: string; named: boolean } {
|
||||
if (row.type === 'group') return { label: row.name || t('journey.studio.layers.group'), named: !!row.name };
|
||||
return { label: layerLabel(row.el, t(`journey.studio.kind.${row.el.kind}`)), named: !!row.el.name };
|
||||
}
|
||||
|
||||
function LayerRowView(p: RowProps) {
|
||||
const { row, t } = p;
|
||||
const { locked, hidden } = rowState(p.row);
|
||||
const Icon = row.type === 'group' ? Group : (KIND_ICONS[row.el.kind] ?? Shapes);
|
||||
const { label, named } = rowLabel(row, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={rowClasses(p, hidden)}
|
||||
data-layer-key={row.key}
|
||||
role="treeitem"
|
||||
aria-selected={p.selected}
|
||||
aria-expanded={row.type === 'group' ? !p.collapsed : undefined}
|
||||
onPointerDown={p.onPointerDown}
|
||||
onClick={(e) => p.onChoose(e.shiftKey || e.metaKey || e.ctrlKey)}
|
||||
>
|
||||
{!p.readOnly && <GripVertical size={12} className="st-layer-grip" aria-hidden />}
|
||||
{row.type === 'group' && <RowCaret collapsed={p.collapsed} onToggle={p.onToggleCollapsed} t={t} />}
|
||||
<Icon size={13} className="st-layer-icon" aria-hidden />
|
||||
<RowName
|
||||
label={label}
|
||||
named={named}
|
||||
renaming={p.renaming}
|
||||
t={t}
|
||||
onRename={p.onRename}
|
||||
onCommit={p.onCommitName}
|
||||
onCancel={p.onCancelName}
|
||||
/>
|
||||
{row.type === 'group' && <span className="st-layer-count">{row.members.length}</span>}
|
||||
{!p.readOnly && <RowToggles locked={locked} hidden={hidden} t={t} onLock={p.onLock} onHide={p.onHide} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A click that does something on its own and must not also select the row. */
|
||||
const own = (fn: () => void) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fn();
|
||||
};
|
||||
|
||||
function RowCaret({ collapsed, onToggle, t }: { collapsed: boolean; onToggle: () => void; t: T }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`st-layer-caret ${collapsed ? '' : 'is-open'}`}
|
||||
onClick={own(onToggle)}
|
||||
aria-label={t(collapsed ? 'journey.studio.layers.expand' : 'journey.studio.layers.collapse')}
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RowName(p: {
|
||||
label: string;
|
||||
named: boolean;
|
||||
renaming: boolean;
|
||||
t: T;
|
||||
onRename: () => void;
|
||||
onCommit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
if (!p.renaming) {
|
||||
return (
|
||||
<span
|
||||
className={`st-layer-name ${p.named ? '' : 'is-auto'}`}
|
||||
title={p.t('journey.studio.layers.rename')}
|
||||
onDoubleClick={own(p.onRename)}
|
||||
>
|
||||
{p.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
className="st-input st-layer-input"
|
||||
autoFocus
|
||||
defaultValue={p.named ? p.label : ''}
|
||||
placeholder={p.label}
|
||||
maxLength={60}
|
||||
aria-label={p.t('journey.studio.layers.rename')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => p.onCommit(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
if (e.key !== 'Escape') return;
|
||||
// Escape here leaves the name as it was, and must not close Studio.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
p.onCancel();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RowToggles(p: {
|
||||
locked: boolean;
|
||||
hidden: boolean;
|
||||
t: T;
|
||||
onLock: (locked: boolean) => void;
|
||||
onHide: (hidden: boolean) => void;
|
||||
}) {
|
||||
const lockLabel = p.t(p.locked ? 'journey.studio.unlock' : 'journey.studio.lock');
|
||||
const hideLabel = p.t(p.hidden ? 'journey.studio.layers.show' : 'journey.studio.layers.hide');
|
||||
return (
|
||||
<span className="st-layer-acts">
|
||||
<button
|
||||
type="button"
|
||||
className={`st-act ${p.locked ? 'is-on is-kept' : ''}`}
|
||||
onClick={own(() => p.onLock(!p.locked))}
|
||||
title={lockLabel}
|
||||
aria-label={lockLabel}
|
||||
aria-pressed={p.locked}
|
||||
>
|
||||
{p.locked ? <Lock size={12} /> : <Unlock size={12} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`st-act ${p.hidden ? 'is-on is-kept' : ''}`}
|
||||
onClick={own(() => p.onHide(!p.hidden))}
|
||||
title={hideLabel}
|
||||
aria-label={hideLabel}
|
||||
aria-pressed={p.hidden}
|
||||
>
|
||||
{p.hidden ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { BookFrame } from '@trek/shared';
|
||||
|
||||
import { HANDLES, HANDLE_POS, type HandleId } from './studioGestures';
|
||||
|
||||
/**
|
||||
* The selection chrome the sheet draws above the page for more than one
|
||||
* element: one box with the eight resize handles around all of them, dashed
|
||||
* when it is exactly one group, and the marquee while one is being drawn.
|
||||
* Screen pixels, like every outline on the sheet, so a line stays a hairline
|
||||
* at any zoom. The sheet is laid out in millimetres from its left edge in
|
||||
* every reading direction, which is why these sit at physical offsets.
|
||||
*/
|
||||
|
||||
/** The rectangle of a frame on screen. */
|
||||
const onScreen = (f: BookFrame, scaled: number) => ({
|
||||
left: f.x * scaled, // rtl-lint-disable: the sheet is measured from its left edge
|
||||
top: f.y * scaled,
|
||||
width: f.w * scaled,
|
||||
height: f.h * scaled,
|
||||
});
|
||||
|
||||
/** The eight resize handles of a box, corners round and edges as short bars. */
|
||||
export function ResizeHandles({ onResize }: { onResize: (e: React.PointerEvent, handle: HandleId) => void }) {
|
||||
return (
|
||||
<>
|
||||
{HANDLES.map((h) => {
|
||||
const kind = h.length === 2 ? 'is-corner' : h === 'n' || h === 's' ? 'is-h' : 'is-v';
|
||||
const at = HANDLE_POS[h];
|
||||
const place = { left: at.x, top: at.y, cursor: at.cursor }; // rtl-lint-disable: a corner of the box, not a side of the text
|
||||
return <span key={h} className={`st-handle ${kind}`} style={place} onPointerDown={(e) => onResize(e, h)} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One box around a multi-selection, with handles that resize all of it (Shift keeps the proportions). */
|
||||
export function SelectionBox({
|
||||
box,
|
||||
scaled,
|
||||
asGroup,
|
||||
handles,
|
||||
onResize,
|
||||
}: {
|
||||
box: BookFrame;
|
||||
scaled: number;
|
||||
asGroup: boolean;
|
||||
handles: boolean;
|
||||
onResize: (e: React.PointerEvent, handle: HandleId) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`st-select-box ${asGroup ? 'is-group' : ''}`} style={onScreen(box, scaled)}>
|
||||
{handles && <ResizeHandles onResize={onResize} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The marquee being drawn. */
|
||||
export function MarqueeRect({ rect, scaled }: { rect: BookFrame; scaled: number }) {
|
||||
return <div className="st-marquee" style={onScreen(rect, scaled)} />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Camera, Check, ChevronDown, ImagePlus, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate,
|
||||
Camera, Check, ChevronDown, ImagePlus, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, Layers, LayoutTemplate,
|
||||
Plus, Search, Shapes, Trash2, Upload, X,
|
||||
} from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { PanelHead as Head } from './StudioPanelHead'
|
||||
import { StudioElementsPanel } from './StudioElementsPanel'
|
||||
import { StudioLayersPanel } from './StudioLayersPanel'
|
||||
import { StudioTravelPanel } from './StudioTravelPanel'
|
||||
import { StudioSavedLayouts } from './StudioSavedLayouts'
|
||||
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
|
||||
@@ -39,7 +40,7 @@ import type { StudioUploader } from './studioUpload'
|
||||
* costs 52px and gives the panel the room a photo browser actually needs.
|
||||
*/
|
||||
|
||||
type Section = 'pages' | 'content' | 'elements' | 'travel' | 'templates'
|
||||
type Section = 'pages' | 'content' | 'elements' | 'travel' | 'templates' | 'layers'
|
||||
|
||||
export interface JourneySource {
|
||||
entries: {
|
||||
@@ -120,6 +121,7 @@ export function StudioSidebar({
|
||||
{ id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' },
|
||||
{ id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' },
|
||||
{ id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' },
|
||||
{ id: 'layers', icon: Layers, labelKey: 'journey.studio.layers' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -157,6 +159,7 @@ export function StudioSidebar({
|
||||
onToggleStop={onToggleStop}
|
||||
/>
|
||||
)}
|
||||
{section === 'layers' && <StudioLayersPanel t={t} />}
|
||||
{section === 'templates' && <TemplatesPanel page={page} pxPerMm={pxPerMm} t={t} onOpenContent={() => setSection('content')} />}
|
||||
</aside>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* FE-STUDIO-ICON-001 to FE-STUDIO-ICON-002
|
||||
*
|
||||
* The icons the book engine takes (BOOK_ICON_NAMES in @trek/shared) are the
|
||||
* drawings the Studio's icon tab offers, no more and no fewer: one the engine
|
||||
* took that this lucide does not have would print as the fallback glyph, and
|
||||
* one the tab offers that the engine refused would be a drawing a person can
|
||||
* place and the assistant cannot. When lucide-react moves, run
|
||||
* `node scripts/gen-book-icon-names.mjs` in shared/ and this passes again.
|
||||
*/
|
||||
import { BOOK_DESIGN_ICONS, BOOK_ICON_NAMES } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { FEATURED_ICONS, ICON_NAMES } from './iconLibrary';
|
||||
|
||||
describe('the icons the book engine takes', () => {
|
||||
it('FE-STUDIO-ICON-001: are every drawing the icon tab offers, and nothing it does not', () => {
|
||||
// `Icon` is the component every drawing is built on, not a drawing.
|
||||
const offered = ICON_NAMES.filter((name) => name !== 'Icon');
|
||||
const missing = offered.filter((name) => !BOOK_ICON_NAMES.has(name));
|
||||
const extra = [...BOOK_ICON_NAMES].filter((name) => !offered.includes(name));
|
||||
expect({ missing, extra }).toEqual({ missing: [], extra: [] });
|
||||
});
|
||||
|
||||
it('FE-STUDIO-ICON-002: hold the whole travel shelf the tab opens on', () => {
|
||||
expect(BOOK_DESIGN_ICONS.filter((name) => !BOOK_ICON_NAMES.has(name))).toEqual([]);
|
||||
expect(FEATURED_ICONS).toEqual([...BOOK_DESIGN_ICONS]);
|
||||
});
|
||||
});
|
||||
@@ -60,9 +60,10 @@ export const ICON_NAMES: string[] = buildNames()
|
||||
* of them quietly drops it instead of drawing a fallback in the shelf.
|
||||
*/
|
||||
/*
|
||||
* The shelf itself lives in @trek/shared (book-icons.ts), because the book
|
||||
* engine places icons from the same list: the assistant may only name a
|
||||
* drawing a person could pick here.
|
||||
* The shelf itself lives in @trek/shared (book-icons.ts), beside the whole
|
||||
* list the book engine takes (BOOK_ICON_NAMES): the assistant may name any
|
||||
* drawing a person could pick here, and iconLibrary.test.ts holds that list
|
||||
* to ICON_NAMES.
|
||||
*/
|
||||
const FEATURED: readonly string[] = BOOK_DESIGN_ICONS
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* FE-STUDIO-PRINT-001 to FE-STUDIO-PRINT-004
|
||||
*
|
||||
* When the print view of a book may be printed: every picture in, the SVG
|
||||
* ones too wherever they sit (a map marker, a photo behind a clip path), and
|
||||
* a ceiling that gives a book of many photographs longer before the Save
|
||||
* button gives up waiting.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { printReadyCeilingMs, svgImagesToWait, whenReady } from './printReady';
|
||||
|
||||
const SVG = 'http://www.w3.org/2000/svg';
|
||||
const XLINK = 'http://www.w3.org/1999/xlink';
|
||||
|
||||
/** A print document as the frame holds it, built from markup. */
|
||||
function printDoc(markup: string): Document {
|
||||
const doc = document.implementation.createHTMLDocument('print');
|
||||
doc.body.innerHTML = markup;
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** An iframe whose document and window are the given ones, as the print view's frame is once it loaded. */
|
||||
function frameOf(doc: Document, win: Partial<Window> = {}): HTMLIFrameElement {
|
||||
const iframe = document.createElement('iframe');
|
||||
Object.defineProperty(iframe, 'contentDocument', { value: doc, configurable: true });
|
||||
Object.defineProperty(iframe, 'contentWindow', { value: win, configurable: true });
|
||||
return iframe;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('the pictures to wait for', () => {
|
||||
it('FE-STUDIO-PRINT-001: finds an SVG image inside a clip path and in the defs, not only a marker photo', () => {
|
||||
const doc = printDoc(
|
||||
`<svg xmlns="${SVG}"><defs><clipPath id="c"><image href="/a.jpg"/></clipPath>` +
|
||||
`<image id="d" href="/b.jpg"/></defs><image href="/c.jpg"/></svg>`
|
||||
);
|
||||
expect(svgImagesToWait(doc).map((el) => el.getAttribute('href'))).toEqual(['/a.jpg', '/b.jpg', '/c.jpg']);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-PRINT-002: takes an xlink source, and leaves out an image that names none and would never load', () => {
|
||||
const doc = printDoc(`<svg xmlns="${SVG}"><image/></svg>`);
|
||||
const linked = doc.createElementNS(SVG, 'image');
|
||||
linked.setAttributeNS(XLINK, 'xlink:href', '/x.jpg');
|
||||
doc.querySelector('svg')?.appendChild(linked);
|
||||
expect(svgImagesToWait(doc)).toEqual([linked]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the ceiling', () => {
|
||||
it('FE-STUDIO-PRINT-003: waits eight seconds for an ordinary book and fifteen for one of more than 40 photos', () => {
|
||||
expect(printReadyCeilingMs(0)).toBe(8000);
|
||||
expect(printReadyCeilingMs(40)).toBe(8000);
|
||||
expect(printReadyCeilingMs(41)).toBe(15000);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-PRINT-004: holds a large book past eight seconds while an image is still loading', async () => {
|
||||
vi.useFakeTimers();
|
||||
const many = Array.from({ length: 41 }, (_, i) => `<image href="/p${i}.jpg"/>`).join('');
|
||||
const doc = printDoc(`<svg xmlns="${SVG}">${many}</svg>`);
|
||||
let ready = false;
|
||||
// A window without its own timers: the wait falls back to the global one, which the fake timers drive.
|
||||
void whenReady(frameOf(doc)).then(() => {
|
||||
ready = true;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(8100);
|
||||
expect(ready).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(7000);
|
||||
expect(ready).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* When the print view of a book is actually printable.
|
||||
*
|
||||
* Both halves matter and neither is covered by the load event: a photograph
|
||||
* still decoding prints as an empty box, and a face still loading prints in the
|
||||
* fallback, the exact substitution the bundled typefaces exist to prevent.
|
||||
*/
|
||||
|
||||
/** How long the print view waits at most, and the photo count past which a book gets the longer wait. */
|
||||
const PRINT_READY = {
|
||||
ceilingMs: 8000,
|
||||
largeCeilingMs: 15000,
|
||||
largeBookPhotos: 40,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The longest the Save button stays off for a book of `photos` pictures. A
|
||||
* book of a few dozen photographs from a slow provider can take longer than
|
||||
* eight seconds to decode; one that has not finished must not go to the press.
|
||||
*/
|
||||
export function printReadyCeilingMs(photos: number): number {
|
||||
return photos > PRINT_READY.largeBookPhotos ? PRINT_READY.largeCeilingMs : PRINT_READY.ceilingMs;
|
||||
}
|
||||
|
||||
/** What `whenReady` reads off the print document: its `<img>` elements, its SVG and its fonts. */
|
||||
interface PrintDocument {
|
||||
images: ArrayLike<Element & { complete?: boolean }>;
|
||||
querySelectorAll?: (selector: string) => ArrayLike<Element>;
|
||||
fonts?: { ready: Promise<unknown> };
|
||||
}
|
||||
|
||||
const XLINK = 'http://www.w3.org/1999/xlink';
|
||||
|
||||
/**
|
||||
* The SVG pictures to wait for: every `<image>` that names a source, wherever
|
||||
* it sits. `doc.images` does not collect them, and they are not only the
|
||||
* photographs in a map's markers: a photo cut to a mask or set behind clipped
|
||||
* type is an `<image>` inside a `<clipPath>` or `<defs>`, which the selector
|
||||
* reaches just the same. One that names no source would never fire an event,
|
||||
* so it is left out rather than left to hold the button until the ceiling.
|
||||
*/
|
||||
export function svgImagesToWait(doc: PrintDocument): Element[] {
|
||||
const found = Array.from(doc.querySelectorAll?.('image') ?? []);
|
||||
return found.filter((el) => !!(el.getAttribute('href') || el.getAttributeNS(XLINK, 'href')));
|
||||
}
|
||||
|
||||
/** Resolves when the element has loaded or failed: a picture that will not load must not hold the button hostage. */
|
||||
function settle(el: Element): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
el.addEventListener('load', () => resolve(), { once: true });
|
||||
el.addEventListener('error', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the print view's pictures and faces are in, or until the
|
||||
* ceiling, which rises for a book with many photographs. Without `complete`
|
||||
* on an SVG image there is no way to tell one that is still coming from one
|
||||
* whose events fired before this ran, and a Save button that never enables is
|
||||
* worse than a marker that prints blank.
|
||||
*/
|
||||
export async function whenReady(iframe: HTMLIFrameElement): Promise<void> {
|
||||
const win = iframe.contentWindow;
|
||||
const doc = iframe.contentDocument as PrintDocument | null;
|
||||
if (!win || !doc) return;
|
||||
|
||||
const imgs = Array.from(doc.images);
|
||||
const svg = svgImagesToWait(doc);
|
||||
const images = imgs.map((img) => (img.complete ? Promise.resolve() : settle(img)));
|
||||
|
||||
const wait = win.setTimeout?.bind(win) ?? setTimeout;
|
||||
const ceiling = new Promise<void>((resolve) => {
|
||||
wait(resolve, printReadyCeilingMs(imgs.length + svg.length));
|
||||
});
|
||||
|
||||
await Promise.race([Promise.all([...images, ...svg.map(settle), doc.fonts?.ready ?? Promise.resolve()]), ceiling]);
|
||||
}
|
||||
@@ -23,6 +23,8 @@
|
||||
* standalone PWA. An overlay with a `srcdoc` iframe works everywhere.
|
||||
*/
|
||||
|
||||
import { whenReady } from './printReady'
|
||||
|
||||
/*
|
||||
* The only literal colours in Studio, and deliberately so.
|
||||
*
|
||||
@@ -254,47 +256,3 @@ ${singleRule(input)}
|
||||
|
||||
return close
|
||||
}
|
||||
|
||||
/**
|
||||
* When the document is actually printable.
|
||||
*
|
||||
* Both halves matter and neither is covered by the load event: a photograph
|
||||
* still decoding prints as an empty box, and a face still loading prints in the
|
||||
* fallback — the exact substitution the bundled typefaces exist to prevent.
|
||||
*/
|
||||
async function whenReady(iframe: HTMLIFrameElement): Promise<void> {
|
||||
const win = iframe.contentWindow
|
||||
const doc = iframe.contentDocument
|
||||
if (!win || !doc) return
|
||||
|
||||
const settle = (el: Element) => new Promise<void>(resolve => {
|
||||
// A picture that will not load must not hold the button hostage — better a
|
||||
// book with one gap than a dialog that never finishes.
|
||||
el.addEventListener('load', () => resolve(), { once: true })
|
||||
el.addEventListener('error', () => resolve(), { once: true })
|
||||
})
|
||||
|
||||
const images = Array.from(doc.images).map(img => (img.complete ? Promise.resolve() : settle(img)))
|
||||
|
||||
/*
|
||||
* The photographs inside the map's markers are SVG `<image>`, which
|
||||
* `doc.images` does not collect and which has no `complete` to ask, so there
|
||||
* is nothing to do but wait on the events. Missing them meant a book could go
|
||||
* to print with its route beads still blank.
|
||||
*/
|
||||
const svgImages = Array.from(doc.querySelectorAll?.('image') ?? []).map(settle)
|
||||
|
||||
/*
|
||||
* And a ceiling over the lot. Without `complete` to fall back on there is no
|
||||
* way to tell a picture that is still coming from one whose events already
|
||||
* fired before this ran, and a Save button that never enables is worse than a
|
||||
* marker that prints blank.
|
||||
*/
|
||||
const wait = win.setTimeout?.bind(win) ?? setTimeout
|
||||
const ceiling = new Promise<void>(resolve => { wait(resolve, 8000) })
|
||||
|
||||
await Promise.race([
|
||||
Promise.all([...images, ...svgImages, doc.fonts?.ready ?? Promise.resolve()]),
|
||||
ceiling,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { BookElement, BookSpread } from '@trek/shared';
|
||||
import { bookShapeElementSchema } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MIN_FRAME_MM, boxOf, mapFrame, marqueeHits, rectBetween, resizeBox } from './studioGestures';
|
||||
|
||||
const base = { x: 10, y: 10, w: 40, h: 20 };
|
||||
|
||||
describe('resizeBox', () => {
|
||||
it('moves the dragged edges and keeps the opposite ones', () => {
|
||||
expect(resizeBox(base, 'se', { dx: 10, dy: 5 }, false)).toEqual({ x: 10, y: 10, w: 50, h: 25 });
|
||||
expect(resizeBox(base, 'nw', { dx: 10, dy: 5 }, false)).toEqual({ x: 20, y: 15, w: 30, h: 15 });
|
||||
expect(resizeBox(base, 'e', { dx: 10, dy: 99 }, false)).toEqual({ x: 10, y: 10, w: 50, h: 20 });
|
||||
expect(resizeBox(base, 'n', { dx: 99, dy: -10 }, false)).toEqual({ x: 10, y: 0, w: 40, h: 30 });
|
||||
});
|
||||
|
||||
it('never turns a box inside out, stopping at the minimum against the fixed edge', () => {
|
||||
expect(resizeBox(base, 'w', { dx: 100, dy: 0 }, false)).toEqual({
|
||||
x: 50 - MIN_FRAME_MM,
|
||||
y: 10,
|
||||
w: MIN_FRAME_MM,
|
||||
h: 20,
|
||||
});
|
||||
expect(resizeBox(base, 's', { dx: 0, dy: -100 }, false).h).toBe(MIN_FRAME_MM);
|
||||
});
|
||||
|
||||
it('keeps the proportions with Shift, led by the larger move', () => {
|
||||
expect(resizeBox(base, 'se', { dx: 40, dy: 0 }, true)).toEqual({ x: 10, y: 10, w: 80, h: 40 });
|
||||
expect(resizeBox(base, 'se', { dx: 0, dy: 20 }, true)).toEqual({ x: 10, y: 10, w: 80, h: 40 });
|
||||
const nw = resizeBox(base, 'nw', { dx: -40, dy: 0 }, true);
|
||||
expect(nw).toEqual({ x: -30, y: -10, w: 80, h: 40 });
|
||||
});
|
||||
|
||||
it('grows the other side about the middle on an edge handle with Shift', () => {
|
||||
expect(resizeBox(base, 'e', { dx: 40, dy: 0 }, true)).toEqual({ x: 10, y: 0, w: 80, h: 40 });
|
||||
expect(resizeBox(base, 's', { dx: 0, dy: 20 }, true)).toEqual({ x: -10, y: 10, w: 80, h: 40 });
|
||||
});
|
||||
|
||||
it('holds the minimum on both sides when the proportions are kept', () => {
|
||||
const tiny = resizeBox(base, 'se', { dx: -100, dy: -100 }, true);
|
||||
expect(tiny.h).toBeGreaterThanOrEqual(MIN_FRAME_MM);
|
||||
expect(tiny.w / tiny.h).toBeCloseTo(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('carrying frames along', () => {
|
||||
it('scales a frame inside a box with it', () => {
|
||||
const from = { x: 0, y: 0, w: 100, h: 50 };
|
||||
const to = { x: 10, y: 10, w: 200, h: 100 };
|
||||
expect(mapFrame({ x: 50, y: 25, w: 20, h: 10 }, from, to)).toEqual({ x: 110, y: 60, w: 40, h: 20 });
|
||||
expect(mapFrame(from, from, to)).toEqual(to);
|
||||
});
|
||||
|
||||
it('measures the box around frames and the rectangle between two points', () => {
|
||||
expect(boxOf([base, { x: 0, y: 40, w: 5, h: 5 }])).toEqual({ x: 0, y: 10, w: 50, h: 35 });
|
||||
expect(rectBetween(30, 40, 10, 5)).toEqual({ x: 10, y: 5, w: 20, h: 35 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('marqueeHits', () => {
|
||||
const el = (id: string, x: number, y: number, w: number, h: number, over: Partial<BookElement> = {}) =>
|
||||
bookShapeElementSchema.parse({ id, kind: 'shape', frame: { x, y, w, h }, shape: 'rect', ...over }) as BookElement;
|
||||
const spread = {
|
||||
id: 'sp',
|
||||
role: 'inner',
|
||||
background: null,
|
||||
parked: [],
|
||||
entryId: null,
|
||||
elements: [
|
||||
el('bg', -3, -3, 426, 216),
|
||||
el('a', 10, 10, 20, 20),
|
||||
el('g1', 60, 10, 20, 20, { group: 'g' }),
|
||||
el('g2', 150, 150, 20, 20, { group: 'g' }),
|
||||
el('locked', 12, 12, 5, 5, { locked: true }),
|
||||
el('hidden', 12, 12, 5, 5, { hidden: true }),
|
||||
],
|
||||
} as BookSpread;
|
||||
|
||||
it('catches what it touches with its whole group, not a page-sized element around it', () => {
|
||||
expect(marqueeHits(spread, { x: 0, y: 0, w: 70, h: 40 })).toEqual(['a', 'g1', 'g2']);
|
||||
});
|
||||
|
||||
it('leaves locked and hidden elements out, and a zero rectangle catches nothing', () => {
|
||||
expect(marqueeHits(spread, { x: 5, y: 5, w: 10, h: 10 })).toEqual(['a']);
|
||||
// Drawn wholly inside an element, it is the element's background case: nothing.
|
||||
expect(marqueeHits(spread, { x: 11, y: 11, w: 2, h: 2 })).toEqual([]);
|
||||
expect(marqueeHits(spread, { x: 11, y: 11, w: 0, h: 0 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { BookElement, BookFrame, BookSpread } from '@trek/shared';
|
||||
import { expandToGroups, isWholeGroup } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The geometry behind the sheet's gestures that is worth testing on its own:
|
||||
* resizing a box from a handle (with the aspect held on Shift), carrying the
|
||||
* frames inside it along, and what a marquee picks.
|
||||
*/
|
||||
|
||||
export type HandleId = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
|
||||
|
||||
/** About the smallest thing worth having on a page, in millimetres. */
|
||||
export const MIN_FRAME_MM = 4;
|
||||
|
||||
/** The box around frames. */
|
||||
export function boxOf(frames: readonly BookFrame[]): BookFrame {
|
||||
const x = Math.min(...frames.map((f) => f.x));
|
||||
const y = Math.min(...frames.map((f) => f.y));
|
||||
const right = Math.max(...frames.map((f) => f.x + f.w));
|
||||
const bottom = Math.max(...frames.map((f) => f.y + f.h));
|
||||
return { x, y, w: right - x, h: bottom - y };
|
||||
}
|
||||
|
||||
/**
|
||||
* A box dragged by one of its handles by `dx`/`dy` millimetres.
|
||||
*
|
||||
* The opposite edge stays where it was, and a box never turns inside out: it
|
||||
* stops at the minimum size against that edge. With `keepAspect` a corner
|
||||
* keeps the box's proportions, following whichever of the two directions the
|
||||
* pointer went further in, and an edge handle grows the other side about the
|
||||
* middle, the way design tools do it.
|
||||
*/
|
||||
export function resizeBox(
|
||||
base: BookFrame,
|
||||
handle: HandleId,
|
||||
by: { dx: number; dy: number },
|
||||
keepAspect: boolean
|
||||
): BookFrame {
|
||||
const side = sidesOf(handle);
|
||||
const w = base.w + (side.east ? by.dx : side.west ? -by.dx : 0);
|
||||
const h = base.h + (side.south ? by.dy : side.north ? -by.dy : 0);
|
||||
const size =
|
||||
keepAspect && base.w > 0 && base.h > 0
|
||||
? heldSize(base, side, w, h)
|
||||
: { w: Math.max(w, MIN_FRAME_MM), h: Math.max(h, MIN_FRAME_MM) };
|
||||
return placed(base, side, size);
|
||||
}
|
||||
|
||||
interface Sides {
|
||||
west: boolean;
|
||||
east: boolean;
|
||||
north: boolean;
|
||||
south: boolean;
|
||||
}
|
||||
|
||||
const sidesOf = (handle: HandleId): Sides => ({
|
||||
west: handle.includes('w'),
|
||||
east: handle.includes('e'),
|
||||
north: handle.includes('n'),
|
||||
south: handle.includes('s'),
|
||||
});
|
||||
|
||||
/** The size with the base's proportions kept, never under the minimum on either side. */
|
||||
function heldSize(base: BookFrame, side: Sides, w: number, h: number): { w: number; h: number } {
|
||||
const ratio = base.w / base.h;
|
||||
const across = side.west || side.east;
|
||||
const down = side.north || side.south;
|
||||
// On a corner, whichever way the pointer went further leads and the other follows it.
|
||||
const widthLeads = across && (!down || Math.abs(w / base.w - 1) >= Math.abs(h / base.h - 1));
|
||||
const width = Math.max(widthLeads ? w : h * ratio, MIN_FRAME_MM, MIN_FRAME_MM * ratio);
|
||||
return { w: width, h: width / ratio };
|
||||
}
|
||||
|
||||
/** Where the resized box sits: against the edges that did not move, centred on an axis no handle moved. */
|
||||
function placed(base: BookFrame, side: Sides, size: { w: number; h: number }): BookFrame {
|
||||
const x = side.west ? base.x + base.w - size.w : side.east ? base.x : base.x + (base.w - size.w) / 2;
|
||||
const y = side.north ? base.y + base.h - size.h : side.south ? base.y : base.y + (base.h - size.h) / 2;
|
||||
return { x, y, w: size.w, h: size.h };
|
||||
}
|
||||
|
||||
/** A frame inside `from` carried into `to`, scaled with it. */
|
||||
export function mapFrame(frame: BookFrame, from: BookFrame, to: BookFrame): BookFrame {
|
||||
const sx = from.w > 0 ? to.w / from.w : 1;
|
||||
const sy = from.h > 0 ? to.h / from.h : 1;
|
||||
return {
|
||||
x: to.x + (frame.x - from.x) * sx,
|
||||
y: to.y + (frame.y - from.y) * sy,
|
||||
w: Math.max(frame.w * sx, 0.5),
|
||||
h: Math.max(frame.h * sy, 0.5),
|
||||
};
|
||||
}
|
||||
|
||||
/** The rectangle between two points, whichever way it was dragged. */
|
||||
export function rectBetween(ax: number, ay: number, bx: number, by: number): BookFrame {
|
||||
return { x: Math.min(ax, bx), y: Math.min(ay, by), w: Math.abs(bx - ax), h: Math.abs(by - ay) };
|
||||
}
|
||||
|
||||
const intersects = (a: BookFrame, b: BookFrame) =>
|
||||
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
|
||||
const contains = (outer: BookFrame, inner: BookFrame) =>
|
||||
outer.x <= inner.x &&
|
||||
outer.y <= inner.y &&
|
||||
outer.x + outer.w >= inner.x + inner.w &&
|
||||
outer.y + outer.h >= inner.y + inner.h;
|
||||
|
||||
/**
|
||||
* What a marquee picks: every element it touches, with its whole group.
|
||||
*
|
||||
* Locked and hidden elements are left out, as in every design tool. So is an
|
||||
* element that holds the whole marquee inside it: a page-sized photo behind
|
||||
* everything would otherwise be caught by every marquee drawn over it, which
|
||||
* is the one thing nobody drawing one ever wants.
|
||||
*/
|
||||
export function marqueeHits(spread: BookSpread, rect: BookFrame): string[] {
|
||||
if (rect.w <= 0 && rect.h <= 0) return [];
|
||||
const hits = spread.elements
|
||||
.filter((e) => !e.locked && !e.hidden)
|
||||
.filter((e) => intersects(rect, e.frame) && !contains(e.frame, rect))
|
||||
.map((e) => e.id);
|
||||
return expandToGroups(spread, hits);
|
||||
}
|
||||
|
||||
export const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
||||
|
||||
/** Where each handle sits on its box, as percentages from its top left corner, and the cursor it shows. */
|
||||
type HandlePlace = Record<'x' | 'y' | 'cursor', string>;
|
||||
|
||||
export const HANDLE_POS: Record<HandleId, HandlePlace> = {
|
||||
nw: { x: '0%', y: '0%', cursor: 'nwse-resize' },
|
||||
n: { x: '50%', y: '0%', cursor: 'ns-resize' },
|
||||
ne: { x: '100%', y: '0%', cursor: 'nesw-resize' },
|
||||
e: { x: '100%', y: '50%', cursor: 'ew-resize' },
|
||||
se: { x: '100%', y: '100%', cursor: 'nwse-resize' },
|
||||
s: { x: '50%', y: '100%', cursor: 'ns-resize' },
|
||||
sw: { x: '0%', y: '100%', cursor: 'nesw-resize' },
|
||||
w: { x: '0%', y: '50%', cursor: 'ew-resize' },
|
||||
};
|
||||
|
||||
/** What the selection is, for the chrome: several, their box, one whole group, anything movable. */
|
||||
export function selectionShape(spread: BookSpread, sel: readonly BookElement[]) {
|
||||
const many = sel.length > 1;
|
||||
return {
|
||||
many,
|
||||
box: many ? boxOf(sel.map((e) => e.frame)) : null,
|
||||
asGroup:
|
||||
many &&
|
||||
isWholeGroup(
|
||||
spread,
|
||||
sel.map((e) => e.id)
|
||||
),
|
||||
anyMovable: sel.some((e) => !e.locked),
|
||||
};
|
||||
}
|
||||
|
||||
/** The outline class of one selected element: quieter as a member of several, marked when picked inside its group. */
|
||||
export function selectClass(many: boolean, el: BookElement, enteredGroup: string | null): string {
|
||||
const inside = el.group !== undefined && el.group === enteredGroup;
|
||||
return ['st-select', many ? 'is-member' : '', inside ? 'is-inside' : ''].filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { BookElement, BookSpread } from '@trek/shared';
|
||||
import { bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { dropOn, layerLabel, layerRows, rowIds } from './studioLayerRows';
|
||||
import { shortcutKeys } from './studioShortcut';
|
||||
|
||||
/** The value, or a failed test when it is missing. */
|
||||
function must<T>(value: T | null | undefined): T {
|
||||
if (value === null || value === undefined) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
|
||||
const shape = (id: string, over: Partial<BookElement> = {}) =>
|
||||
bookShapeElementSchema.parse({
|
||||
id,
|
||||
kind: 'shape',
|
||||
frame: { x: 0, y: 0, w: 10, h: 10 },
|
||||
shape: 'rect',
|
||||
...over,
|
||||
}) as BookElement;
|
||||
|
||||
const spread = {
|
||||
id: 'sp',
|
||||
role: 'inner',
|
||||
background: null,
|
||||
parked: [],
|
||||
entryId: null,
|
||||
elements: [shape('back'), shape('m1', { group: 'g' }), shape('m2', { group: 'g' }), shape('front')],
|
||||
groups: [{ id: 'g', name: 'Pair' }],
|
||||
} as BookSpread;
|
||||
|
||||
describe('layerRows', () => {
|
||||
it('lists the page front to back, a group with its members under it', () => {
|
||||
const rows = layerRows(spread, new Set());
|
||||
expect(rows.map((r) => r.key)).toEqual(['front', 'g:g', 'm2', 'm1', 'back']);
|
||||
const group = must(rows[1]);
|
||||
expect(group.type === 'group' && group.name).toBe('Pair');
|
||||
expect(rowIds(group)).toEqual(['m2', 'm1']);
|
||||
expect(rowIds(must(rows[0]))).toEqual(['front']);
|
||||
});
|
||||
|
||||
it('shows a folded group as its row alone', () => {
|
||||
expect(layerRows(spread, new Set(['g'])).map((r) => r.key)).toEqual(['front', 'g:g', 'back']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropOn', () => {
|
||||
const rows = layerRows(spread, new Set());
|
||||
it('puts what is dragged in front of or behind an element row', () => {
|
||||
expect(dropOn(must(rows[0]), true)).toEqual({ anchorId: 'front', where: 'above', join: false });
|
||||
expect(dropOn(must(rows[4]), false)).toEqual({ anchorId: 'back', where: 'below', join: false });
|
||||
});
|
||||
|
||||
it('takes it into a group on a member row, and beside the whole group on the group row', () => {
|
||||
expect(dropOn(must(rows[2]), false)).toEqual({ anchorId: 'm2', where: 'below', join: true });
|
||||
expect(dropOn(must(rows[1]), true)).toEqual({ anchorId: 'm2', where: 'above', join: false });
|
||||
expect(dropOn(must(rows[1]), false)).toEqual({ anchorId: 'm1', where: 'below', join: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('layerLabel', () => {
|
||||
it('uses the name, then a text’s first words, then the kind', () => {
|
||||
expect(layerLabel(shape('a', { name: 'Sun' }), 'Shape')).toBe('Sun');
|
||||
expect(layerLabel(shape('a'), 'Shape')).toBe('Shape');
|
||||
const words = bookTextElementSchema.parse({
|
||||
id: 't',
|
||||
kind: 'text',
|
||||
frame: { x: 0, y: 0, w: 1, h: 1 },
|
||||
text: '\n Reykjavik \nmore',
|
||||
});
|
||||
expect(layerLabel(words as BookElement, 'Text')).toBe('Reykjavik');
|
||||
const long = bookTextElementSchema.parse({
|
||||
id: 't',
|
||||
kind: 'text',
|
||||
frame: { x: 0, y: 0, w: 1, h: 1 },
|
||||
text: 'x'.repeat(60),
|
||||
});
|
||||
expect(layerLabel(long as BookElement, 'Text')).toHaveLength(40);
|
||||
const empty = bookTextElementSchema.parse({ id: 't', kind: 'text', frame: { x: 0, y: 0, w: 1, h: 1 }, text: ' ' });
|
||||
expect(layerLabel(empty as BookElement, 'Text')).toBe('Text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcutKeys', () => {
|
||||
it('spells the command and option keys for the platform', () => {
|
||||
expect(shortcutKeys(true, 'Ctrl', ['⇧', 'G'])).toEqual(['⌘', '⇧', 'G']);
|
||||
expect(shortcutKeys(false, 'Strg', ['alt', 'C'])).toEqual(['Strg', 'Alt', 'C']);
|
||||
expect(shortcutKeys(true, 'Ctrl', ['alt', 'V'])).toEqual(['⌘', '⌥', 'V']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { BookElement, BookSpread } from '@trek/shared';
|
||||
import { bookGroupName, commonGroup, groupMembers, isWholeGroup } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The layers panel's rows, front to back: what the panel draws and where a
|
||||
* drag lands. Kept apart from the panel so the order and the drop rules can
|
||||
* be tested without drawing it.
|
||||
*/
|
||||
|
||||
export type LayerRow =
|
||||
| { type: 'group'; key: string; groupId: string; name: string | undefined; members: BookElement[] }
|
||||
| { type: 'element'; key: string; el: BookElement; inGroup: boolean };
|
||||
|
||||
/**
|
||||
* Every element front to back, a group's members under its row. A collapsed
|
||||
* group shows its row alone. Groups are kept together in the paint order
|
||||
* (book-groups.ts), so the members found first, from the front, are the whole
|
||||
* group.
|
||||
*/
|
||||
export function layerRows(spread: BookSpread, collapsed: ReadonlySet<string>): LayerRow[] {
|
||||
const rows: LayerRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const el of spread.elements.slice().reverse()) {
|
||||
if (!el.group) {
|
||||
rows.push({ type: 'element', key: el.id, el, inGroup: false });
|
||||
continue;
|
||||
}
|
||||
if (seen.has(el.group)) continue;
|
||||
seen.add(el.group);
|
||||
const members = groupMembers(spread, el.group).reverse();
|
||||
rows.push({
|
||||
type: 'group',
|
||||
key: `g:${el.group}`,
|
||||
groupId: el.group,
|
||||
name: bookGroupName(spread, el.group),
|
||||
members,
|
||||
});
|
||||
if (collapsed.has(el.group)) continue;
|
||||
for (const m of members) rows.push({ type: 'element', key: m.id, el: m, inGroup: true });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The elements a row stands for: a group's members, or the one element. */
|
||||
export function rowIds(row: LayerRow): string[] {
|
||||
return row.type === 'group' ? row.members.map((m) => m.id) : [row.el.id];
|
||||
}
|
||||
|
||||
/** Where a drop lands in the paint order, for the store's `restack`. */
|
||||
export interface LayerDrop {
|
||||
anchorId: string;
|
||||
where: 'above' | 'below';
|
||||
join: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What letting go over a row does. Over the upper half of a row the dragged
|
||||
* layers go in front of it, over the lower half behind it. A member of a
|
||||
* group takes them into its group; a group's own row puts them in front of or
|
||||
* behind the whole group.
|
||||
*/
|
||||
export function dropOn(row: LayerRow, upper: boolean): LayerDrop {
|
||||
if (row.type === 'group') {
|
||||
const front = row.members[0] as BookElement;
|
||||
const back = row.members[row.members.length - 1] as BookElement;
|
||||
return upper
|
||||
? { anchorId: front.id, where: 'above', join: false }
|
||||
: { anchorId: back.id, where: 'below', join: false };
|
||||
}
|
||||
return { anchorId: row.el.id, where: upper ? 'above' : 'below', join: row.inGroup };
|
||||
}
|
||||
|
||||
/** What the panel calls an element: its own name, a text's first words, or its kind. */
|
||||
export function layerLabel(el: BookElement, kindLabel: string): string {
|
||||
if (el.name) return el.name;
|
||||
if (el.kind === 'text') {
|
||||
const first = el.text
|
||||
.split('\n')
|
||||
.find((l) => l.trim())
|
||||
?.trim();
|
||||
if (first) return first.length > 40 ? `${first.slice(0, 39)}…` : first;
|
||||
}
|
||||
return kindLabel;
|
||||
}
|
||||
|
||||
/** What the inspector's head calls the selection: a whole group by its name, otherwise "Several". */
|
||||
export function selectionTitle(spread: BookSpread | undefined, selection: string[], t: (k: string) => string): string {
|
||||
if (spread && isWholeGroup(spread, selection)) {
|
||||
const group = commonGroup(spread, selection);
|
||||
return (group && bookGroupName(spread, group)) || t('journey.studio.layers.group');
|
||||
}
|
||||
return t('journey.studio.multiple');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { isApplePlatform } from '../../utils/applePlatform';
|
||||
|
||||
/**
|
||||
* The keys of a Studio shortcut as the tooltip shows them: ⌘ and ⌥ on a Mac,
|
||||
* Ctrl and Alt elsewhere, always with the command key first. `alt` stands for
|
||||
* the option key so callers do not each decide how to spell it.
|
||||
*/
|
||||
export function shortcutKeys(apple: boolean, ctrl: string, keys: readonly string[]): string[] {
|
||||
const mod = apple ? '⌘' : ctrl;
|
||||
return [mod, ...keys.map((k) => (k === 'alt' ? (apple ? '⌥' : 'Alt') : k))];
|
||||
}
|
||||
|
||||
/** `keys('⇧', 'G')` reads as ⌘ ⇧ G or Ctrl ⇧ G, for the platform the page runs on. */
|
||||
export function useStudioShortcut(): (...keys: string[]) => string[] {
|
||||
const { t } = useTranslation();
|
||||
const apple = isApplePlatform();
|
||||
return (...keys) => shortcutKeys(apple, t('assistant.launcher.ctrlKey'), keys);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { BookDocument, BookRecord } from '@trek/shared'
|
||||
import { normalizeBookDocument } from '@trek/shared'
|
||||
import { BOOK_SCHEMA_NEWER_CODE, readBookDocument, stampBookSchemaRev } from '@trek/shared'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
|
||||
@@ -54,6 +54,16 @@ import { addListener, removeListener } from '../../api/websocket'
|
||||
* either: it is remembered and read once that work has reached the server,
|
||||
* because the save response and the broadcast travel on different channels and
|
||||
* either can arrive first.
|
||||
*
|
||||
* ── Why a book from a newer Studio is never written ───────────────────────
|
||||
*
|
||||
* A document says which revision of the format wrote it (book-schema-rev.ts).
|
||||
* This build reads a newer one as far as it can, and what it cannot read is
|
||||
* already gone from the copy it holds: saving that copy would delete the rest
|
||||
* for everyone. So such a book is shown and never written, the same way a
|
||||
* viewer's is, until a reload brings the Studio that wrote it. The server
|
||||
* refuses the save as well, which covers a tab that loaded the book before the
|
||||
* newer Studio saved it.
|
||||
*/
|
||||
|
||||
/** How long the document has to stay still before it is written. */
|
||||
@@ -69,6 +79,8 @@ export type SaveState =
|
||||
| { status: 'conflict'; current: BookRecord }
|
||||
/** The journey is open to this user, but they may not write it (role 'viewer'). */
|
||||
| { status: 'readonly' }
|
||||
/** The book was saved by a newer Studio than this one: shown, never written, until a reload. */
|
||||
| { status: 'newer' }
|
||||
| { status: 'error' }
|
||||
|
||||
/** What the editor needs to know about a version it is handed from elsewhere. */
|
||||
@@ -176,7 +188,12 @@ export function useBookStore(
|
||||
* agreed document by identity, so a consumer that normalised on its own
|
||||
* would hand back a different object and turn every open into a save.
|
||||
*/
|
||||
const book = res.book ? { ...res.book, document: normalizeBookDocument(res.book.document) } : null
|
||||
const read = res.book ? readBookDocument(res.book.document) : null
|
||||
const book = res.book && read ? { ...res.book, document: read.document } : null
|
||||
if (read?.newer) {
|
||||
blocked.current = true
|
||||
setState({ status: 'newer' })
|
||||
}
|
||||
setRecord(book)
|
||||
version.current = book?.version ?? null
|
||||
bookId.current = book?.id ?? null
|
||||
@@ -201,7 +218,8 @@ export function useBookStore(
|
||||
try {
|
||||
const saved = await journeyApi.saveBook(journeyId, {
|
||||
title: next.title,
|
||||
document: next.document,
|
||||
// Stamped on the way out only: `synced` below keeps the editor's own object.
|
||||
document: stampBookSchemaRev(next.document),
|
||||
baseVersion: version.current ?? undefined,
|
||||
})
|
||||
setRecord(saved)
|
||||
@@ -217,8 +235,12 @@ export function useBookStore(
|
||||
* not need a second request: the editor can offer to take their version
|
||||
* or keep going from here, and either way it can say whose work it is.
|
||||
*/
|
||||
const res = (err as { response?: { status?: number; data?: { current?: BookRecord } } }).response
|
||||
if (res?.status === 409 && res.data?.current) {
|
||||
const res = (err as { response?: { status?: number; data?: { current?: BookRecord; code?: string } } }).response
|
||||
if (res?.status === 409 && res.data?.code === BOOK_SCHEMA_NEWER_CODE) {
|
||||
// A newer Studio saved the book since this tab read it: nothing here may be written over it.
|
||||
blocked.current = true
|
||||
setState({ status: 'newer' })
|
||||
} else if (res?.status === 409 && res.data?.current) {
|
||||
blocked.current = true
|
||||
setState({ status: 'conflict', current: res.data.current })
|
||||
} else if (res?.status === 403) {
|
||||
@@ -305,15 +327,15 @@ export function useBookStore(
|
||||
|
||||
/** Take the other version, discarding the local one. */
|
||||
const acceptTheirs = useCallback((current: BookRecord) => {
|
||||
const document = normalizeBookDocument(current.document)
|
||||
blocked.current = false
|
||||
const { document, newer } = readBookDocument(current.document)
|
||||
blocked.current = newer
|
||||
setRecord({ ...current, document })
|
||||
version.current = current.version
|
||||
bookId.current = current.id
|
||||
synced.current = document
|
||||
latest.current = document
|
||||
pending.current = null
|
||||
setState({ status: 'idle' })
|
||||
setState(newer ? { status: 'newer' } : { status: 'idle' })
|
||||
// A save from elsewhere passed over while the conflict was open may be
|
||||
// newer than the record it carried.
|
||||
catchUp.current()
|
||||
@@ -388,8 +410,12 @@ export function useBookStore(
|
||||
if (sameBook && !isNewer(res.book.version)) return
|
||||
// Normalised for the same reason the initial load is: a document
|
||||
// written by an older build is missing whatever the contract has
|
||||
// gained since.
|
||||
const document = normalizeBookDocument(res.book.document)
|
||||
// gained since. One written by a newer build is shown and held.
|
||||
const { document, newer } = readBookDocument(res.book.document)
|
||||
if (newer) {
|
||||
blocked.current = true
|
||||
setState({ status: 'newer' })
|
||||
}
|
||||
const held = version.current
|
||||
const taken = res.book.version
|
||||
// Undoable only when every save it folds in, past the version held,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { BookElement, BookFrame, BookPageSetup, BookSpread } from '@trek/shared'
|
||||
import { expandToGroups } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { MIN_FRAME_MM, boxOf, mapFrame, marqueeHits, rectBetween, resizeBox, type HandleId } from './studioGestures'
|
||||
|
||||
/**
|
||||
* Moving and resizing on the sheet.
|
||||
@@ -27,11 +29,31 @@ export interface Guide {
|
||||
at: number
|
||||
}
|
||||
|
||||
export type HandleId = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
|
||||
export type { HandleId }
|
||||
|
||||
/** How far, in screen pixels, a press has to travel before it draws a marquee. */
|
||||
const MARQUEE_START_PX = 3
|
||||
|
||||
interface Gesture {
|
||||
kind: 'move' | 'resize' | 'rotate'
|
||||
kind: 'move' | 'resize' | 'rotate' | 'marquee'
|
||||
handle?: HandleId
|
||||
/** The box around the frames being resized, as it was. */
|
||||
baseBox?: BookFrame
|
||||
/**
|
||||
* A marquee: where it started on the spread, in millimetres, and what was
|
||||
* selected before it when shift was held (a marquee with shift adds).
|
||||
*/
|
||||
originX?: number
|
||||
originY?: number
|
||||
keep?: string[]
|
||||
/** Whether the pointer has travelled: far enough for a marquee to count, or at all for a move. */
|
||||
moved?: boolean
|
||||
/**
|
||||
* What a press on one of several selected elements narrows the selection
|
||||
* to when it lets go without moving: the element, or its group. A drag
|
||||
* moves all of them; a click picks the one, as in every design tool.
|
||||
*/
|
||||
narrowTo?: string[]
|
||||
startX: number
|
||||
startY: number
|
||||
/** Frames as they were when the gesture started, by element id. */
|
||||
@@ -120,6 +142,41 @@ function bestSnap(edges: number[], targets: number[], tolMm: number): { delta: n
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* A resized box with the edges being dragged snapped onto the nearest line,
|
||||
* and the lines it snapped to added to `guides`. A box never turns inside out,
|
||||
* snapped or not: it stops at the minimum against the edge that stays.
|
||||
*/
|
||||
function snapBox(
|
||||
box: BookFrame,
|
||||
h: HandleId,
|
||||
base: BookFrame,
|
||||
targets: { xs: number[]; ys: number[]; tolMm: number },
|
||||
guides: Guide[],
|
||||
): BookFrame {
|
||||
let { x, y, w } = box
|
||||
let hh = box.h
|
||||
if (h.includes('w')) {
|
||||
const s = bestSnap([x], targets.xs, targets.tolMm)
|
||||
if (s) { w += x - s.at; x = s.at; guides.push({ axis: 'x', at: s.at }) }
|
||||
}
|
||||
if (h.includes('e')) {
|
||||
const s = bestSnap([x + w], targets.xs, targets.tolMm)
|
||||
if (s) { w = s.at - x; guides.push({ axis: 'x', at: s.at }) }
|
||||
}
|
||||
if (h.includes('n')) {
|
||||
const s = bestSnap([y], targets.ys, targets.tolMm)
|
||||
if (s) { hh += y - s.at; y = s.at; guides.push({ axis: 'y', at: s.at }) }
|
||||
}
|
||||
if (h.includes('s')) {
|
||||
const s = bestSnap([y + hh], targets.ys, targets.tolMm)
|
||||
if (s) { hh = s.at - y; guides.push({ axis: 'y', at: s.at }) }
|
||||
}
|
||||
if (w < MIN_FRAME_MM) { if (h.includes('w')) x = base.x + base.w - MIN_FRAME_MM; w = MIN_FRAME_MM }
|
||||
if (hh < MIN_FRAME_MM) { if (h.includes('n')) y = base.y + base.h - MIN_FRAME_MM; hh = MIN_FRAME_MM }
|
||||
return { x, y, w, h: hh }
|
||||
}
|
||||
|
||||
export function useSpreadInteraction(opts: {
|
||||
spread: BookSpread | null
|
||||
spreadIndex: number
|
||||
@@ -129,7 +186,7 @@ export function useSpreadInteraction(opts: {
|
||||
}) {
|
||||
const { spread, spreadIndex, page, pxPerMm } = opts
|
||||
const selection = useStudioStore(s => s.selection)
|
||||
const toggleSelect = useStudioStore(s => s.toggleSelect)
|
||||
const pick = useStudioStore(s => s.pick)
|
||||
const select = useStudioStore(s => s.select)
|
||||
const setFrame = useStudioStore(s => s.setFrame)
|
||||
const setRotation = useStudioStore(s => s.setRotation)
|
||||
@@ -137,8 +194,12 @@ export function useSpreadInteraction(opts: {
|
||||
const endGesture = useStudioStore(s => s.endGesture)
|
||||
|
||||
const gesture = useRef<Gesture | null>(null)
|
||||
/** Whether letting go of a marquee that never moved clears the selection. */
|
||||
const clearOnClick = useRef(false)
|
||||
const [guides, setGuides] = useState<Guide[]>([])
|
||||
const [dragging, setDragging] = useState(false)
|
||||
/** The marquee being drawn, in millimetres on the spread. */
|
||||
const [marquee, setMarquee] = useState<BookFrame | null>(null)
|
||||
|
||||
const tolMm = SNAP_PX / Math.max(pxPerMm, 0.0001)
|
||||
|
||||
@@ -149,11 +210,21 @@ export function useSpreadInteraction(opts: {
|
||||
} catch {
|
||||
// The element can be gone already — releasing a capture is best-effort.
|
||||
}
|
||||
const g = gesture.current
|
||||
gesture.current = null
|
||||
setGuides([])
|
||||
setDragging(false)
|
||||
setMarquee(null)
|
||||
// A marquee changes the selection only. A press on bare paper that never
|
||||
// became one clears the selection, as a click there always did.
|
||||
if (g.kind === 'marquee') {
|
||||
if (!g.moved && clearOnClick.current) select([])
|
||||
clearOnClick.current = false
|
||||
return
|
||||
}
|
||||
if (g.kind === 'move' && !g.moved && g.narrowTo) select(g.narrowTo)
|
||||
endGesture()
|
||||
}, [endGesture])
|
||||
}, [endGesture, select])
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
const g = gesture.current
|
||||
@@ -162,6 +233,18 @@ export function useSpreadInteraction(opts: {
|
||||
|
||||
const dxMm = (e.clientX - g.startX) / pxPerMm
|
||||
const dyMm = (e.clientY - g.startY) / pxPerMm
|
||||
|
||||
if (g.kind === 'marquee') {
|
||||
if (!g.moved && Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < MARQUEE_START_PX) return
|
||||
g.moved = true
|
||||
const rect = rectBetween(g.originX ?? 0, g.originY ?? 0, (g.originX ?? 0) + dxMm, (g.originY ?? 0) + dyMm)
|
||||
setMarquee(rect)
|
||||
const hits = marqueeHits(spread, rect)
|
||||
const keep = g.keep ?? []
|
||||
select([...keep, ...hits.filter(id => !keep.includes(id))])
|
||||
return
|
||||
}
|
||||
|
||||
const moving = new Set(g.base.keys())
|
||||
const { xs, ys } = snapTargets(spread, page, moving)
|
||||
const nextGuides: Guide[] = []
|
||||
@@ -195,6 +278,7 @@ export function useSpreadInteraction(opts: {
|
||||
}
|
||||
|
||||
if (g.kind === 'move') {
|
||||
if (dxMm !== 0 || dyMm !== 0) g.moved = true
|
||||
// Snap on the union of all moving edges, so a multi-selection lines up as
|
||||
// a block rather than each element fighting for its own line.
|
||||
const movedX: number[] = []
|
||||
@@ -216,46 +300,28 @@ export function useSpreadInteraction(opts: {
|
||||
h: f.h,
|
||||
})
|
||||
}
|
||||
} else if (g.handle) {
|
||||
} else if (g.handle && g.baseBox) {
|
||||
/*
|
||||
* One box for the whole selection, however many are in it.
|
||||
*
|
||||
* The box is resized and every frame inside it is carried along, so a
|
||||
* multi-selection or a group grows as one picture rather than each
|
||||
* element stretching about its own corner. With one element the box is
|
||||
* its frame and this is the plain resize it always was. Shift keeps the
|
||||
* proportions; the snap then stays out of it, since an edge pulled onto
|
||||
* a line would bend the ratio Shift is holding.
|
||||
*/
|
||||
const h = g.handle
|
||||
for (const [id, f] of g.base) {
|
||||
let { x, y, w, hh } = { x: f.x, y: f.y, w: f.w, hh: f.h }
|
||||
const keep = e.shiftKey
|
||||
let box = resizeBox(g.baseBox, h, { dx: dxMm, dy: dyMm }, keep)
|
||||
|
||||
if (h.includes('w')) { x = f.x + dxMm; w = f.w - dxMm }
|
||||
if (h.includes('e')) { w = f.w + dxMm }
|
||||
if (h.includes('n')) { y = f.y + dyMm; hh = f.h - dyMm }
|
||||
if (h.includes('s')) { hh = f.h + dyMm }
|
||||
if (!keep) box = snapBox(box, h, g.baseBox, { xs, ys, tolMm }, nextGuides)
|
||||
|
||||
// Snap the edge actually being dragged, not the whole box.
|
||||
if (h.includes('w')) {
|
||||
const s = bestSnap([x], xs, tolMm)
|
||||
if (s) { w += x - s.at; x = s.at; nextGuides.push({ axis: 'x', at: s.at }) }
|
||||
}
|
||||
if (h.includes('e')) {
|
||||
const s = bestSnap([x + w], xs, tolMm)
|
||||
if (s) { w = s.at - x; nextGuides.push({ axis: 'x', at: s.at }) }
|
||||
}
|
||||
if (h.includes('n')) {
|
||||
const s = bestSnap([y], ys, tolMm)
|
||||
if (s) { hh += y - s.at; y = s.at; nextGuides.push({ axis: 'y', at: s.at }) }
|
||||
}
|
||||
if (h.includes('s')) {
|
||||
const s = bestSnap([y + hh], ys, tolMm)
|
||||
if (s) { hh = s.at - y; nextGuides.push({ axis: 'y', at: s.at }) }
|
||||
}
|
||||
|
||||
// A frame may not turn inside out. 4mm is about the smallest thing
|
||||
// worth having on a page.
|
||||
const MIN = 4
|
||||
if (w < MIN) { if (h.includes('w')) x = f.x + f.w - MIN; w = MIN }
|
||||
if (hh < MIN) { if (h.includes('n')) y = f.y + f.h - MIN; hh = MIN }
|
||||
|
||||
setFrame(spreadIndex, id, { x, y, w, h: hh })
|
||||
}
|
||||
for (const [id, f] of g.base) setFrame(spreadIndex, id, mapFrame(f, g.baseBox, box))
|
||||
}
|
||||
|
||||
setGuides(nextGuides)
|
||||
}, [spread, page, pxPerMm, tolMm, setFrame, setRotation, spreadIndex])
|
||||
}, [spread, page, pxPerMm, tolMm, setFrame, setRotation, spreadIndex, select])
|
||||
|
||||
const framesFor = useCallback((ids: string[]) => {
|
||||
const map = new Map<string, BookFrame>()
|
||||
@@ -264,14 +330,44 @@ export function useSpreadInteraction(opts: {
|
||||
return map
|
||||
}, [spread])
|
||||
|
||||
/**
|
||||
* Draw a marquee from where the press landed. `keep` is what stays selected
|
||||
* besides what it catches: the selection before it when shift is held.
|
||||
* `clear` says whether letting go without dragging clears the selection,
|
||||
* which is what a click on bare paper does.
|
||||
*/
|
||||
const startMarquee = useCallback((e: React.PointerEvent, keep: string[] | undefined, clear: boolean) => {
|
||||
const stage = (e.currentTarget as Element).closest('.st-stage')
|
||||
const rect = stage?.getBoundingClientRect()
|
||||
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
||||
clearOnClick.current = clear
|
||||
gesture.current = {
|
||||
kind: 'marquee',
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
originX: (e.clientX - (rect?.left ?? 0)) / pxPerMm,
|
||||
originY: (e.clientY - (rect?.top ?? 0)) / pxPerMm,
|
||||
keep,
|
||||
moved: false,
|
||||
base: new Map(),
|
||||
pointerId: e.pointerId,
|
||||
}
|
||||
}, [pxPerMm])
|
||||
|
||||
/** A press on bare paper: a marquee, or with no drag, nothing selected. */
|
||||
const startPaper = useCallback((e: React.PointerEvent) => {
|
||||
const additive = e.shiftKey || e.metaKey || e.ctrlKey
|
||||
startMarquee(e, additive ? useStudioStore.getState().selection : undefined, !additive)
|
||||
}, [startMarquee])
|
||||
|
||||
const startMove = useCallback((e: React.PointerEvent, el: BookElement) => {
|
||||
e.stopPropagation()
|
||||
const additive = e.shiftKey || e.metaKey || e.ctrlKey
|
||||
const ids = selection.includes(el.id)
|
||||
? (additive ? selection.filter(x => x !== el.id) : selection)
|
||||
: (additive ? [...selection, el.id] : [el.id])
|
||||
const before = useStudioStore.getState().selection
|
||||
|
||||
if (additive) { toggleSelect(el.id, true) } else if (!selection.includes(el.id)) { select([el.id]) }
|
||||
// Group-aware: a click picks the element's whole group, or the element
|
||||
// alone inside a group stepped into with a double-click (studioLayers.ts).
|
||||
pick(spreadIndex, el.id, additive)
|
||||
|
||||
/*
|
||||
* Locked means "cannot be moved", not "cannot be reached".
|
||||
@@ -280,10 +376,24 @@ export function useSpreadInteraction(opts: {
|
||||
* not be picked, so the inspector never showed it, so the unlock button was
|
||||
* unreachable and the lock could not be undone by any means short of undo.
|
||||
* Selection happens first and then the gesture stops here.
|
||||
*
|
||||
* A press on a locked element that goes on to drag draws a marquee
|
||||
* instead: a locked page-sized photo is where a marquee over a page
|
||||
* usually has to start.
|
||||
*/
|
||||
if (el.locked) return
|
||||
if (el.locked) {
|
||||
startMarquee(e, additive ? before : undefined, false)
|
||||
return
|
||||
}
|
||||
if (additive) return
|
||||
|
||||
const targets = ids.length ? ids : [el.id]
|
||||
// What moves is the selection as the press left it, less what is locked.
|
||||
const state = useStudioStore.getState()
|
||||
const onPage = state.doc?.spreads[spreadIndex]?.elements ?? spread?.elements ?? []
|
||||
const targets = onPage.filter(x => state.selection.includes(x.id) && !x.locked).map(x => x.id)
|
||||
const own = el.group && el.group === state.enteredGroup
|
||||
? [el.id]
|
||||
: expandToGroups({ elements: onPage } as BookSpread, [el.id])
|
||||
|
||||
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
||||
beginGesture()
|
||||
@@ -291,11 +401,12 @@ export function useSpreadInteraction(opts: {
|
||||
kind: 'move',
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
base: framesFor(targets),
|
||||
base: framesFor(targets.length ? targets : [el.id]),
|
||||
narrowTo: state.selection.length > own.length ? own : undefined,
|
||||
pointerId: e.pointerId,
|
||||
}
|
||||
setDragging(true)
|
||||
}, [selection, toggleSelect, select, beginGesture, framesFor])
|
||||
}, [pick, spreadIndex, spread, startMarquee, beginGesture, framesFor])
|
||||
|
||||
/**
|
||||
* Take hold of the rotation handle.
|
||||
@@ -340,6 +451,9 @@ export function useSpreadInteraction(opts: {
|
||||
|
||||
const startResize = useCallback((e: React.PointerEvent, handle: HandleId) => {
|
||||
e.stopPropagation()
|
||||
const unlocked = (spread?.elements ?? []).filter(el => selection.includes(el.id) && !el.locked).map(el => el.id)
|
||||
const base = framesFor(unlocked)
|
||||
if (!base.size) return
|
||||
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
||||
beginGesture()
|
||||
gesture.current = {
|
||||
@@ -347,11 +461,12 @@ export function useSpreadInteraction(opts: {
|
||||
handle,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
base: framesFor(selection),
|
||||
base,
|
||||
baseBox: boxOf([...base.values()]),
|
||||
pointerId: e.pointerId,
|
||||
}
|
||||
setDragging(true)
|
||||
}, [selection, beginGesture, framesFor])
|
||||
}, [spread, selection, beginGesture, framesFor])
|
||||
|
||||
return { guides, dragging, startMove, startResize, startRotate, onPointerMove, finish }
|
||||
return { guides, dragging, marquee, startMove, startPaper, startResize, startRotate, onPointerMove, finish }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { BookSpread } from '@trek/shared';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
|
||||
/**
|
||||
* The sheet's keyboard.
|
||||
*
|
||||
* Every shortcut here is one a design tool has taught people already, and none
|
||||
* of them takes a key away from the browser while somebody is typing: in a
|
||||
* field, a text area or a text being edited on the page, the keys are the
|
||||
* field's. Undo and redo are the Studio page's own (useJourneyStudio.ts), and
|
||||
* every action below is one undo step there.
|
||||
*
|
||||
* - Delete or Backspace: remove what is selected and not locked.
|
||||
* - Ctrl+C, Ctrl+V: copy and paste elements, onto any page (#2316).
|
||||
* - Ctrl+D: duplicate. Ctrl+A: select everything on the page.
|
||||
* - Ctrl+G, Ctrl+Shift+G: group and ungroup.
|
||||
* - Ctrl+Alt+C, Ctrl+Alt+V: copy and paste style.
|
||||
* - Ctrl+] and Ctrl+[: forward and backward, with Shift to the front and back.
|
||||
* - Arrows: nudge 1 mm, 10 mm with Shift. Holding one down is one undo step.
|
||||
* - Escape inside a group: back to the whole group selected.
|
||||
*
|
||||
* Ctrl is Cmd on a Mac throughout.
|
||||
*/
|
||||
|
||||
const NUDGE_MM = 1;
|
||||
const NUDGE_BIG_MM = 10;
|
||||
|
||||
const ARROWS: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-1, 0],
|
||||
ArrowRight: [1, 0],
|
||||
ArrowUp: [0, -1],
|
||||
ArrowDown: [0, 1],
|
||||
};
|
||||
|
||||
/** Whether a key press belongs to whatever has the focus rather than to the sheet. */
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable;
|
||||
}
|
||||
|
||||
/** Which of the deletable: on the page, selected, unlocked. */
|
||||
const deletable = (spread: BookSpread | null, selection: string[]): string[] =>
|
||||
(spread?.elements ?? []).filter((e) => selection.includes(e.id) && !e.locked).map((e) => e.id);
|
||||
|
||||
type Store = ReturnType<typeof useStudioStore.getState>;
|
||||
|
||||
/**
|
||||
* One shortcut with Ctrl (Cmd on a Mac): whether it is this press, and what it
|
||||
* does. The first that matches runs; a press none of them takes stays the
|
||||
* browser's.
|
||||
*/
|
||||
interface Shortcut {
|
||||
matches: (e: KeyboardEvent, key: string, store: Store, spread: BookSpread | null) => boolean;
|
||||
run: (e: KeyboardEvent, store: Store, spreadIndex: number) => void;
|
||||
}
|
||||
|
||||
const has = (store: Store) => store.selection.length > 0;
|
||||
const BRACKETS = new Set([']', '[', '}', '{']);
|
||||
|
||||
const SHORTCUTS: Shortcut[] = [
|
||||
// Ctrl+Alt is AltGr on many keyboards: only the two letters it does not type are taken.
|
||||
{ matches: (e, key, s) => e.altKey && key === 'c' && has(s), run: (_e, s, i) => s.copyStyle(i) },
|
||||
{
|
||||
matches: (e, key, s) => e.altKey && key === 'v' && has(s) && s.styleClip !== null,
|
||||
run: (_e, s, i) => s.pasteStyle(i),
|
||||
},
|
||||
{
|
||||
matches: (e, key, s) => !e.altKey && key === 'g' && has(s),
|
||||
run: (e, s, i) => (e.shiftKey ? s.ungroup(i) : s.group(i)),
|
||||
},
|
||||
{
|
||||
matches: (e, key, s) => !e.altKey && !e.shiftKey && key === 'd' && has(s),
|
||||
run: (_e, s, i) => s.duplicate(i, s.selection),
|
||||
},
|
||||
{
|
||||
matches: (e, key, _s, spread) => !e.altKey && !e.shiftKey && key === 'a' && !!spread?.elements.length,
|
||||
run: (_e, s, i) => s.selectAll(i),
|
||||
},
|
||||
{
|
||||
matches: (e, _key, s) => !e.altKey && BRACKETS.has(e.key) && has(s),
|
||||
run: (e, s, i) => {
|
||||
const up = e.key === ']' || e.key === '}';
|
||||
const far = up ? 'front' : 'back';
|
||||
s.raise(i, s.selection, e.shiftKey ? far : up ? 'forward' : 'backward');
|
||||
},
|
||||
},
|
||||
// Copy and paste across pages (#2316). Text selected on the page is the browser's to copy, not the book's.
|
||||
{
|
||||
matches: (e, key, s) => !e.altKey && key === 'c' && has(s) && !window.getSelection()?.toString(),
|
||||
run: (_e, s, i) => s.copy(i, s.selection),
|
||||
},
|
||||
{ matches: (e, key, s) => !e.altKey && key === 'v' && s.clipboard !== null, run: (_e, s, i) => s.paste(i) },
|
||||
];
|
||||
|
||||
/** A shortcut with Ctrl (Cmd on a Mac): answers whether it was one of ours. */
|
||||
function command(e: KeyboardEvent, spreadIndex: number, spread: BookSpread | null): boolean {
|
||||
const store = useStudioStore.getState();
|
||||
const key = e.key.toLowerCase();
|
||||
const hit = SHORTCUTS.find((sc) => sc.matches(e, key, store, spread));
|
||||
hit?.run(e, store, spreadIndex);
|
||||
return !!hit;
|
||||
}
|
||||
|
||||
/** The arrow a press is, when it moves the selection: no modifier but Shift, something selected. */
|
||||
function nudgeOf(e: KeyboardEvent, store: Store): [number, number] | null {
|
||||
const arrow = ARROWS[e.key];
|
||||
if (!arrow || !store.selection.length || e.ctrlKey || e.metaKey || e.altKey) return null;
|
||||
const step = e.shiftKey ? NUDGE_BIG_MM : NUDGE_MM;
|
||||
return [arrow[0] * step, arrow[1] * step];
|
||||
}
|
||||
|
||||
/** Delete or Backspace: what is selected and not locked goes. Answers whether the key was taken. */
|
||||
function deleteSelection(e: KeyboardEvent, store: Store, spreadIndex: number, spread: BookSpread | null): boolean {
|
||||
if ((e.key !== 'Delete' && e.key !== 'Backspace') || !store.selection.length) return false;
|
||||
e.preventDefault();
|
||||
const ids = deletable(spread, store.selection);
|
||||
if (ids.length) store.removeElements(spreadIndex, ids);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useStudioKeys(opts: { spreadIndex: number; editing: boolean }) {
|
||||
const { spreadIndex, editing } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
/** The arrow keys hold one gesture open while any of them is down, so holding one is one undo step. */
|
||||
let nudging = false;
|
||||
const stopNudge = () => {
|
||||
if (!nudging) return;
|
||||
nudging = false;
|
||||
useStudioStore.getState().endGesture();
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (editing || isTypingTarget(e.target)) return;
|
||||
const store = useStudioStore.getState();
|
||||
// Read when the key comes, not when the effect ran: a nudge changes the
|
||||
// page, and an effect that re-ran on it would close the gesture each step.
|
||||
const spread = store.doc?.spreads[spreadIndex] ?? null;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
// Out of the group first; the Studio page's own Escape sees it was taken.
|
||||
if (store.leaveGroup(spreadIndex)) e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (deleteSelection(e, store, spreadIndex, spread)) return;
|
||||
const nudge = nudgeOf(e, store);
|
||||
if (nudge) {
|
||||
e.preventDefault();
|
||||
if (store.readOnly) return;
|
||||
if (!nudging) {
|
||||
nudging = true;
|
||||
store.beginGesture();
|
||||
}
|
||||
store.nudge(spreadIndex, nudge[0], nudge[1]);
|
||||
return;
|
||||
}
|
||||
if (!(e.metaKey || e.ctrlKey)) return;
|
||||
if (command(e, spreadIndex, spread)) e.preventDefault();
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (ARROWS[e.key]) stopNudge();
|
||||
};
|
||||
|
||||
// Capture, so a step out of a group is decided before the page's Escape clears the selection.
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
window.addEventListener('keyup', onKeyUp, true);
|
||||
window.addEventListener('blur', stopNudge);
|
||||
return () => {
|
||||
stopNudge();
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
window.removeEventListener('keyup', onKeyUp, true);
|
||||
window.removeEventListener('blur', stopNudge);
|
||||
};
|
||||
}, [spreadIndex, editing]);
|
||||
}
|
||||
@@ -625,7 +625,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<TourPlannerMapStage
|
||||
tripId={tripId} mapTileUrl={mapTileUrl} leftHidden={leftHidden} rightHidden={rightHidden} t={t}
|
||||
tourPlanner={tourPlanner} tourMap={tourMap} locationSearch={locationSearch} canPlaceEdit={canPlaceEdit}
|
||||
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx}
|
||||
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx} assistantRoutes={assistantMap}
|
||||
onViewportChange={wandererBrowser.onViewportChange} paddleChecklist={pageState.paddleChecklist}
|
||||
/>
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
|
||||
@@ -68,7 +68,7 @@ function seedJourney() {
|
||||
beforeEach(() => {
|
||||
seedJourney()
|
||||
buildBook.mockClear()
|
||||
useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [] })
|
||||
useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [], readOnly: false })
|
||||
vi.spyOn(journeyApi, 'getBook').mockResolvedValue({ book: null } as never)
|
||||
vi.spyOn(journeyApi, 'saveBook').mockResolvedValue({} as never)
|
||||
vi.spyOn(journeyApi, 'stats').mockResolvedValue(stats() as never)
|
||||
@@ -295,6 +295,31 @@ describe('pictures and stops', () => {
|
||||
|
||||
expect(result.current.canEdit).toBe(false)
|
||||
})
|
||||
|
||||
/*
|
||||
* A book a newer Studio saved has already lost, in this copy, what this build
|
||||
* cannot read. It is shown and never changed, for every role, and let go once
|
||||
* Studio closes so the next book opens as it should.
|
||||
*/
|
||||
it('FE-STUDIO-REV-001: holds a book a newer Studio saved read-only until Studio closes', async () => {
|
||||
const document = { ...shared.blankBook({ title: 'Iceland' }), schemaRev: shared.BOOK_SCHEMA_REV + 1 }
|
||||
vi.mocked(journeyApi.getBook).mockResolvedValue({
|
||||
book: { id: 3, journeyId: 9, title: 'Iceland', version: 4, updatedAt: '', updatedBy: 1, document },
|
||||
} as never)
|
||||
const { result, unmount } = await mountStudio()
|
||||
|
||||
await waitFor(() => expect(result.current.saveState.status).toBe('newer'))
|
||||
expect(result.current.canEdit).toBe(false)
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(useStudioStore.getState().readOnly).toBe(true)
|
||||
const before = useStudioStore.getState().doc
|
||||
act(() => { useStudioStore.getState().commit(d => ({ ...d, title: 'edited' })) })
|
||||
expect(useStudioStore.getState().doc).toBe(before)
|
||||
expect(journeyApi.saveBook).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
expect(useStudioStore.getState().readOnly).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -355,7 +380,7 @@ describe('saves from elsewhere', () => {
|
||||
const { ok } = await openAndFlush()
|
||||
expect(ok).toBe(true)
|
||||
const calls = vi.mocked(journeyApi.saveBook).mock.calls
|
||||
expect(calls[calls.length - 1][1].document.title).toBe('edited')
|
||||
expect((calls[calls.length - 1][1].document as { title?: string }).title).toBe('edited')
|
||||
})
|
||||
|
||||
it('FE-STUDIO-WIRE-002: a save of this user keeps the page by its id and is one undo away', async () => {
|
||||
|
||||
@@ -78,6 +78,7 @@ export function useJourneyStudio() {
|
||||
const past = useStudioStore(s => s.past)
|
||||
const future = useStudioStore(s => s.future)
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const setReadOnly = useStudioStore(s => s.setReadOnly)
|
||||
|
||||
const journeyId = Number(id)
|
||||
const backTo = `/journey/${id}`
|
||||
@@ -117,6 +118,21 @@ export function useJourneyStudio() {
|
||||
// every render — depending on that made the save effect fire per render.
|
||||
const { queueSave, saveNow, loaded: bookLoaded } = book
|
||||
|
||||
/*
|
||||
* A book a newer Studio saved is shown and never changed here.
|
||||
*
|
||||
* This build read it as far as it could, and what it could not read is gone
|
||||
* from its copy, so an edit made now could only ever be saved by deleting
|
||||
* the rest. The store refuses edits while this holds and the controls that
|
||||
* add to the book go with `canEdit`; the save indicator asks for a reload.
|
||||
* Released when Studio closes, so the next book opens as it should.
|
||||
*/
|
||||
const newerBook = book.state.status === 'newer'
|
||||
useEffect(() => {
|
||||
setReadOnly(newerBook)
|
||||
return () => setReadOnly(false)
|
||||
}, [newerBook, setReadOnly])
|
||||
|
||||
/*
|
||||
* Who else is in here, and where their pointers are.
|
||||
*
|
||||
@@ -577,7 +593,8 @@ export function useJourneyStudio() {
|
||||
if (e.shiftKey) redo(); else undo()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Escape' || typing) return
|
||||
// Taken already: the sheet stepped out of a group with it (useStudioKeys).
|
||||
if (e.key !== 'Escape' || typing || e.defaultPrevented) return
|
||||
// An editor's Escape clears the selection first; only a second one leaves.
|
||||
if (selection.length) {
|
||||
e.preventDefault()
|
||||
@@ -595,9 +612,10 @@ export function useJourneyStudio() {
|
||||
/*
|
||||
* A viewer may open the book and change nothing: not the pages, not the
|
||||
* journey behind them. The server refuses either anyway; this keeps the
|
||||
* controls that would only ever answer with a 403 off the screen.
|
||||
* controls that would only ever answer with a 403 off the screen. A book a
|
||||
* newer Studio saved is held the same way, for everyone (see `newerBook`).
|
||||
*/
|
||||
const canEdit = (journey?.my_role ?? 'owner') !== 'viewer'
|
||||
const canEdit = (journey?.my_role ?? 'owner') !== 'viewer' && !newerBook
|
||||
const toast = useToast()
|
||||
|
||||
/**
|
||||
@@ -798,8 +816,8 @@ export function useJourneyStudio() {
|
||||
|
||||
undo,
|
||||
redo,
|
||||
canUndo: past.length > 0,
|
||||
canRedo: future.length > 0,
|
||||
canUndo: !newerBook && past.length > 0,
|
||||
canRedo: !newerBook && future.length > 0,
|
||||
|
||||
pxPerMm: PX_PER_MM,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressG
|
||||
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
|
||||
import { MapUndoHint } from '../../components/Map/MapUndoHint';
|
||||
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
|
||||
import type { AssistantMapProps } from '../../components/Map/mapViewContract';
|
||||
import LocationSelect from '../../components/Planner/LocationSelect';
|
||||
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
|
||||
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
|
||||
@@ -74,6 +75,11 @@ export interface TourPlannerMapStageProps
|
||||
onViewportChange?: (bounds: FerrataBbox) => void;
|
||||
/** The open paddle tour's checklist (PAD-06): its part for the picked object rides in the object's map card. */
|
||||
paddleChecklist?: PageState['paddleChecklist'];
|
||||
/** The routes the assistant's cards show and its camera, as on the plan's map, so a proposed hike shows here too. */
|
||||
assistantRoutes?: Pick<
|
||||
AssistantMapProps,
|
||||
'assistantCamera' | 'assistantRouteLines' | 'onAssistantRouteHover' | 'onAssistantRoutePoint'
|
||||
>;
|
||||
}
|
||||
|
||||
/** What the map reads of the tour on screen: a GPX file viewed as it came in has its analysis and no points. */
|
||||
@@ -104,6 +110,7 @@ export function TourPlannerMapStage({
|
||||
rightPanelPx,
|
||||
onViewportChange,
|
||||
paddleChecklist,
|
||||
assistantRoutes,
|
||||
}: Readonly<TourPlannerMapStageProps>): ReactElement {
|
||||
const [terrain3d, setTerrain3d] = useState(false);
|
||||
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
|
||||
@@ -285,6 +292,7 @@ export function TourPlannerMapStage({
|
||||
onViewportChange={viewportChanged}
|
||||
placeTool={placeTool}
|
||||
{...poiMarkers}
|
||||
{...assistantRoutes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-ROUTE-001 to FE-AI-ROUTE-005
|
||||
* FE-AI-ROUTE-001 to FE-AI-ROUTE-006
|
||||
*
|
||||
* Which routes of the chat's cards the plan's map draws, and how: changes
|
||||
* that still stand on the map with the way they replace, the cards of the
|
||||
@@ -127,6 +127,20 @@ describe('assistantRouteModel', () => {
|
||||
expect(refs[1]?.card).toBe(SAVED);
|
||||
});
|
||||
|
||||
it('FE-AI-ROUTE-006: once one of the ways is taken into the trip, the answer draws none of them; a discarded take brings them back', () => {
|
||||
const latest = message([...read('call_1', HIKE), ...read('call_2', GENTLE)], 'msg-1');
|
||||
const take = (approval: Approval) => {
|
||||
const parts = held('t1', PENDING, approval);
|
||||
const change = parts[1] as Extract<AssistantMessagePart, { type: 'data-trek-change' }>;
|
||||
change.data = { ...change.data, takenFrom: 'call_2' };
|
||||
return message(parts, 'msg-2');
|
||||
};
|
||||
const waiting = routeRefs([latest, take({})], {}, NOBODY, LOOK, AS_WAYS);
|
||||
expect(waiting.map((ref) => `${ref.toolCallId}:${ref.use}`)).toEqual(['t1:before', 't1:pending']);
|
||||
const discarded = routeRefs([latest, take({ approved: false })], {}, NOBODY, LOOK, AS_WAYS);
|
||||
expect(discarded.map((ref) => ref.toolCallId)).toEqual(['call_1', 'call_2']);
|
||||
});
|
||||
|
||||
it("FE-AI-ROUTE-003: lines lie in order (replaced way, others, the chosen way, the lit one) in their ways' colours", () => {
|
||||
const previous = { line: flat(HIKE), distanceM: 8000, ascentM: null, minutes: null };
|
||||
const messages = [
|
||||
|
||||
@@ -101,7 +101,8 @@ function standsOnMap(part: ToolCallPart, receipt: AssistantApplied | undefined,
|
||||
/**
|
||||
* The cards of the latest answer that draws any, laid out as the chat lays
|
||||
* them out: a group of several is a choice of ways, each with its letter, the
|
||||
* chosen one (by `picks`, else the first) picked. Older answers drop.
|
||||
* chosen one (by `picks`, else the first) picked. Older answers drop, and so
|
||||
* do the ways of a group once one of them was taken into the trip.
|
||||
*/
|
||||
function lookRefs(
|
||||
messages: readonly AssistantMessage[],
|
||||
@@ -111,6 +112,7 @@ function lookRefs(
|
||||
layout: AnswerLayout
|
||||
): RouteRef[] {
|
||||
const chosenIds = new Set(Object.values(picks));
|
||||
const taken = takenProposals(messages);
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const answers = (messages[index]?.parts ?? []).flatMap((part): RouteCardEntry[] =>
|
||||
part.type === 'data-trek-card' && part.data.card && !changeIds.has(part.data.toolCallId)
|
||||
@@ -121,6 +123,7 @@ function lookRefs(
|
||||
const drawn = new Set(lined.map((entry) => entry.toolCallId));
|
||||
if (drawn.size === 0) continue;
|
||||
return layout(answers).flatMap((group) => {
|
||||
if (group.some((entry) => taken.has(entry.toolCallId))) return [];
|
||||
const ways = group.length >= 2;
|
||||
const chosen = group.find((entry) => chosenIds.has(entry.toolCallId)) ?? group[0];
|
||||
return group.flatMap((entry, position): RouteRef[] => {
|
||||
@@ -133,6 +136,28 @@ function lookRefs(
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The proposals taken into the trip ("Plan this") and not discarded since:
|
||||
* each stands as its change from then on, so its answer's ways leave the map.
|
||||
*/
|
||||
function takenProposals(messages: readonly AssistantMessage[]): Set<string> {
|
||||
const dropped = new Set<string>();
|
||||
for (const message of messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool-call' || !part.approval) continue;
|
||||
if (part.approval.approved === false || part.approval.resolution) dropped.add(part.toolCallId);
|
||||
}
|
||||
}
|
||||
const taken = new Set<string>();
|
||||
for (const message of messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'data-trek-change' || !part.data.takenFrom) continue;
|
||||
if (!dropped.has(part.data.toolCallId)) taken.add(part.data.takenFrom);
|
||||
}
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every card of the chat the map draws now, in the part it plays: the
|
||||
* changes that still stand on the map (each with the way it replaces), then
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { BookDocument, BookElement, BookSpread } from '@trek/shared';
|
||||
import { bookPageSetupSchema, bookShapeElementSchema, bookTextElementSchema } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useStudioStore } from './studioStore';
|
||||
|
||||
/**
|
||||
* Groups, layers and the editor's productivity actions in the Studio store
|
||||
* (studioLayers.ts): what a click selects, every action as one undo step,
|
||||
* locked elements left where they are.
|
||||
*/
|
||||
|
||||
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
|
||||
|
||||
const shape = (id: string, x: number, y: number, over: Partial<BookElement> = {}): BookElement =>
|
||||
bookShapeElementSchema.parse({
|
||||
id,
|
||||
kind: 'shape',
|
||||
frame: { x, y, w: 20, h: 20 },
|
||||
shape: 'rect',
|
||||
...over,
|
||||
}) as BookElement;
|
||||
|
||||
const text = (id: string, over: Partial<BookElement> = {}): BookElement =>
|
||||
bookTextElementSchema.parse({
|
||||
id,
|
||||
kind: 'text',
|
||||
frame: { x: 10, y: 10, w: 60, h: 20 },
|
||||
text: 'Hi',
|
||||
...over,
|
||||
}) as BookElement;
|
||||
|
||||
function load(elements: BookElement[], over: Partial<BookSpread> = {}, more: BookSpread[] = []) {
|
||||
useStudioStore.getState().load({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page,
|
||||
spreads: [{ id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null, ...over }, ...more],
|
||||
} as BookDocument);
|
||||
}
|
||||
|
||||
const store = () => useStudioStore.getState();
|
||||
|
||||
/** The value, or a failed test when it is missing. */
|
||||
function must<T>(value: T | null | undefined): T {
|
||||
if (value === null || value === undefined) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
const els = (i = 0) => must(must(store().doc).spreads[i]).elements;
|
||||
const spread = (i = 0) => must(must(store().doc).spreads[i]);
|
||||
const el = (id: string) => must(els().find((e) => e.id === id));
|
||||
const order = () => els().map((e) => e.id);
|
||||
|
||||
const grouped = () => [
|
||||
shape('a', 10, 10),
|
||||
shape('g1', 50, 10, { group: 'g' }),
|
||||
shape('g2', 80, 30, { group: 'g' }),
|
||||
shape('b', 150, 10),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
load([]);
|
||||
// The style clipboard outlives a page on purpose; each test starts without one.
|
||||
useStudioStore.setState({ styleClip: null, enteredGroup: null });
|
||||
});
|
||||
|
||||
describe('what a click selects', () => {
|
||||
it('picks a whole group, and one element alone', () => {
|
||||
load(grouped());
|
||||
store().pick(0, 'g2', false);
|
||||
expect(store().selection).toEqual(['g1', 'g2']);
|
||||
store().pick(0, 'a', false);
|
||||
expect(store().selection).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('adds with shift, and takes out what is all in already', () => {
|
||||
load(grouped());
|
||||
store().pick(0, 'a', false);
|
||||
store().pick(0, 'g1', true);
|
||||
expect(store().selection).toEqual(['a', 'g1', 'g2']);
|
||||
store().pick(0, 'g2', true);
|
||||
expect(store().selection).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('keeps a multi-selection when one of it is pressed, so it can be dragged', () => {
|
||||
load(grouped());
|
||||
store().select(['a', 'b']);
|
||||
store().pick(0, 'b', false);
|
||||
expect(store().selection).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('steps into a group and out again', () => {
|
||||
load(grouped());
|
||||
store().enterGroup(0, 'g2');
|
||||
expect([store().selection, store().enteredGroup]).toEqual([['g2'], 'g']);
|
||||
store().pick(0, 'g1', false);
|
||||
expect(store().selection).toEqual(['g1']);
|
||||
expect(store().leaveGroup(0)).toBe(true);
|
||||
expect([store().selection, store().enteredGroup]).toEqual([['g1', 'g2'], null]);
|
||||
expect(store().leaveGroup(0)).toBe(false);
|
||||
// An ungrouped element cannot be stepped into.
|
||||
store().enterGroup(0, 'a');
|
||||
expect(store().enteredGroup).toBeNull();
|
||||
});
|
||||
|
||||
it('a click outside the group stepped into steps out, and clearing the selection does too', () => {
|
||||
load(grouped());
|
||||
store().enterGroup(0, 'g1');
|
||||
store().pick(0, 'a', false);
|
||||
expect(store().enteredGroup).toBeNull();
|
||||
store().enterGroup(0, 'g1');
|
||||
store().select([]);
|
||||
expect(store().enteredGroup).toBeNull();
|
||||
});
|
||||
|
||||
it('selects a group from its row, and everything not hidden', () => {
|
||||
load([...grouped(), shape('h', 0, 0, { hidden: true })]);
|
||||
store().selectGroup(0, 'g', false);
|
||||
expect(store().selection).toEqual(['g1', 'g2']);
|
||||
store().select(['a']);
|
||||
store().selectGroup(0, 'g', true);
|
||||
expect(store().selection).toEqual(['a', 'g1', 'g2']);
|
||||
store().selectAll(0);
|
||||
expect(store().selection).toEqual(['a', 'g1', 'g2', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group and ungroup', () => {
|
||||
it('groups the unlocked part of the selection as one undo step, and ungroups it again', () => {
|
||||
load([shape('a', 0, 0), shape('b', 30, 0), shape('c', 60, 0, { locked: true })]);
|
||||
store().select(['a', 'b', 'c']);
|
||||
store().group(0);
|
||||
expect(el('a').group).toBeDefined();
|
||||
expect(el('a').group).toBe(el('b').group);
|
||||
expect(el('c').group).toBeUndefined();
|
||||
expect(store().selection).toEqual(['a', 'b']);
|
||||
store().ungroup(0);
|
||||
expect(el('a').group).toBeUndefined();
|
||||
store().undo();
|
||||
expect(el('a').group).toBeDefined();
|
||||
store().undo();
|
||||
expect(el('a').group).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing for one element, a whole group, or a selection without groups', () => {
|
||||
load(grouped());
|
||||
const before = store().doc;
|
||||
store().select(['a']);
|
||||
store().group(0);
|
||||
store().select(['g1', 'g2']);
|
||||
store().group(0);
|
||||
store().select(['a', 'b']);
|
||||
store().ungroup(0);
|
||||
expect(store().doc).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moving', () => {
|
||||
it('nudges what is selected and unlocked, one undo step for a held key', () => {
|
||||
load([shape('a', 10, 10), shape('l', 10, 10, { locked: true })]);
|
||||
store().select(['a', 'l']);
|
||||
store().beginGesture();
|
||||
store().nudge(0, 1, 0);
|
||||
store().nudge(0, 1, 0);
|
||||
store().nudge(0, 0, 10);
|
||||
store().endGesture();
|
||||
expect(el('a').frame).toMatchObject({ x: 12, y: 20 });
|
||||
expect(el('l').frame).toMatchObject({ x: 10, y: 10 });
|
||||
store().undo();
|
||||
expect(el('a').frame).toMatchObject({ x: 10, y: 10 });
|
||||
});
|
||||
|
||||
it('aligns and distributes, moving a group as one box', () => {
|
||||
load(grouped());
|
||||
store().select(['a', 'g1', 'g2', 'b']);
|
||||
store().align(0, 'top', 'selection');
|
||||
expect([el('a').frame.y, el('g1').frame.y, el('g2').frame.y]).toEqual([10, 10, 30]);
|
||||
store().distribute(0, 'horizontal');
|
||||
// a 10..30, group 50..100, b 150..170: gaps of 35, so the group starts at 65.
|
||||
expect([el('g1').frame.x, el('g2').frame.x]).toEqual([65, 95]);
|
||||
store().undo();
|
||||
expect(el('g1').frame.x).toBe(50);
|
||||
});
|
||||
|
||||
it('leaves alignment alone with nothing movable, and distribute with fewer than three', () => {
|
||||
load([shape('a', 10, 10, { locked: true }), shape('b', 40, 40)]);
|
||||
const before = store().doc;
|
||||
store().select(['a']);
|
||||
store().align(0, 'left', 'page');
|
||||
store().select(['a', 'b']);
|
||||
store().distribute(0, 'horizontal');
|
||||
expect(store().doc).toBe(before);
|
||||
});
|
||||
|
||||
it('raises a group whole, and a member inside its group only', () => {
|
||||
load(grouped());
|
||||
store().raise(0, ['g1'], 'back');
|
||||
expect(order()).toEqual(['g1', 'g2', 'a', 'b']);
|
||||
store().enterGroup(0, 'g1');
|
||||
store().raise(0, ['g1'], 'up');
|
||||
expect(order()).toEqual(['g2', 'g1', 'a', 'b']);
|
||||
store().raise(0, 'b', 'down');
|
||||
expect(order()).toEqual(['g2', 'g1', 'b', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the layers panel', () => {
|
||||
it('restacks a layer in front of or behind another, joining a group when dropped on a member', () => {
|
||||
load(grouped());
|
||||
store().restack(0, ['a'], 'b', 'above', false);
|
||||
expect(order()).toEqual(['g1', 'g2', 'b', 'a']);
|
||||
store().restack(0, ['a'], 'g1', 'below', true);
|
||||
expect(order()).toEqual(['a', 'g1', 'g2', 'b']);
|
||||
expect(el('a').group).toBe('g');
|
||||
// Dragged out beside an ungrouped element, it leaves the group.
|
||||
store().restack(0, ['a'], 'b', 'above', false);
|
||||
expect(el('a').group).toBeUndefined();
|
||||
});
|
||||
|
||||
it('moves a whole group without breaking it, and refuses to drop a layer on itself', () => {
|
||||
load(grouped());
|
||||
store().restack(0, ['g1', 'g2'], 'b', 'above', false);
|
||||
expect(order()).toEqual(['a', 'b', 'g1', 'g2']);
|
||||
expect(el('g1').group).toBe('g');
|
||||
const before = store().doc;
|
||||
store().restack(0, ['a'], 'a', 'above', false);
|
||||
store().restack(0, ['a'], 'missing', 'above', false);
|
||||
expect(store().doc).toBe(before);
|
||||
});
|
||||
|
||||
it('hides, locks and names, each as one undo step, and a hidden layer leaves the selection', () => {
|
||||
load(grouped());
|
||||
store().select(['a', 'b']);
|
||||
store().setHidden(0, ['a'], true);
|
||||
expect(el('a').hidden).toBe(true);
|
||||
expect(store().selection).toEqual(['b']);
|
||||
store().setLocked(0, ['g1', 'g2'], true);
|
||||
expect([el('g1').locked, el('g2').locked]).toEqual([true, true]);
|
||||
store().renameLayer(0, { elementId: 'b' }, 'Sun');
|
||||
store().renameLayer(0, { groupId: 'g' }, 'Lockup');
|
||||
expect(el('b').name).toBe('Sun');
|
||||
expect(spread().groups).toEqual([{ id: 'g', name: 'Lockup' }]);
|
||||
store().undo();
|
||||
expect(spread().groups).toBeUndefined();
|
||||
store().setHidden(0, ['a'], false);
|
||||
expect('hidden' in el('a')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copies and the style clipboard', () => {
|
||||
it('duplicates a group as a new group beside it, selected', () => {
|
||||
load(grouped(), { groups: [{ id: 'g', name: 'Lockup' }] });
|
||||
store().duplicate(0, ['g1', 'g2']);
|
||||
const copies = els().slice(4);
|
||||
expect(copies.map((e) => e.frame.x)).toEqual([54, 84]);
|
||||
expect(must(copies[0]).group).toBe(must(copies[1]).group);
|
||||
expect(must(copies[0]).group).not.toBe('g');
|
||||
expect(store().selection).toEqual(copies.map((e) => e.id));
|
||||
expect(spread().groups?.map((g) => g.name)).toEqual(['Lockup', 'Lockup']);
|
||||
});
|
||||
|
||||
it('pastes a copied group onto another page as a group of its own', () => {
|
||||
const other: BookSpread = { id: 'sp2', role: 'inner', background: null, elements: [], parked: [], entryId: null };
|
||||
load(grouped(), {}, [other]);
|
||||
store().copy(0, ['g1', 'g2']);
|
||||
store().paste(1);
|
||||
const pasted = els(1);
|
||||
expect(pasted).toHaveLength(2);
|
||||
expect(must(pasted[0]).group).toBe(must(pasted[1]).group);
|
||||
expect(must(pasted[0]).group).not.toBe('g');
|
||||
});
|
||||
|
||||
it('a delete that leaves a group of one dissolves it', () => {
|
||||
load(grouped());
|
||||
store().removeElements(0, ['g1']);
|
||||
expect(el('g2').group).toBeUndefined();
|
||||
});
|
||||
|
||||
it('copies the look of one element and pastes it onto others, unlocked only, as one step', () => {
|
||||
load([
|
||||
text('t1', { size: 30, color: '#ff0000' }),
|
||||
text('t2'),
|
||||
text('t3', { locked: true }),
|
||||
shape('s', 0, 0, { opacity: 1 }),
|
||||
]);
|
||||
store().select(['t1']);
|
||||
store().copyStyle(0);
|
||||
expect(store().styleClip?.kind).toBe('text');
|
||||
store().select(['t2', 't3', 's']);
|
||||
store().pasteStyle(0);
|
||||
expect(el('t2')).toMatchObject({ size: 30, color: '#ff0000', text: 'Hi' });
|
||||
expect(el('t3')).not.toMatchObject({ size: 30 });
|
||||
store().undo();
|
||||
expect(el('t2')).not.toMatchObject({ size: 30 });
|
||||
});
|
||||
|
||||
it('pastes nothing without a copied style, or onto a kind that shares nothing with it', () => {
|
||||
load([shape('s', 0, 0), text('t')]);
|
||||
const before = store().doc;
|
||||
store().select(['s']);
|
||||
store().pasteStyle(0);
|
||||
useStudioStore.setState({ styleClip: { kind: 'icon', props: { lineWidth: 2 } } });
|
||||
store().pasteStyle(0);
|
||||
expect(store().doc).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
import type {
|
||||
BookAlignEdge,
|
||||
BookAlignTo,
|
||||
BookArrangeTo,
|
||||
BookCopiedStyle,
|
||||
BookDistributeAxis,
|
||||
BookDocument,
|
||||
BookElement,
|
||||
BookSpread,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
alignElements,
|
||||
arrangeElements,
|
||||
arrangeWithinGroup,
|
||||
bookElementId,
|
||||
bookElementStyle,
|
||||
commonGroup,
|
||||
distributableCount,
|
||||
distributeElements,
|
||||
expandToGroups,
|
||||
gatherGroups,
|
||||
groupElements,
|
||||
groupMembers,
|
||||
isWholeGroup,
|
||||
pastableStyle,
|
||||
regroupCopies,
|
||||
renameElement,
|
||||
renameGroup,
|
||||
setElementsHidden,
|
||||
tidyGroups,
|
||||
ungroupElements,
|
||||
withCopies,
|
||||
withoutGroup,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Groups, layers and the editor's productivity actions (journey-studio WP6).
|
||||
*
|
||||
* The geometry and the group rules are the book engine's own
|
||||
* (shared/src/book/book-groups.ts, book-arrange.ts, book-style-copy.ts): the
|
||||
* `group`, `align`, `distribute`, `arrange` and `style` operations the
|
||||
* assistant sends run the same functions, so a press here and an assistant's
|
||||
* change move a page the same way. What lives here is only how the Studio
|
||||
* holds them: which page, what is selected, and one undo step per action.
|
||||
*/
|
||||
|
||||
/** The part of the Studio store these actions read and write. */
|
||||
interface Host {
|
||||
doc: BookDocument | null;
|
||||
selection: string[];
|
||||
enteredGroup: string | null;
|
||||
styleClip: BookCopiedStyle | null;
|
||||
commit: (fn: (doc: BookDocument) => BookDocument) => void;
|
||||
apply: (fn: (doc: BookDocument) => BookDocument) => void;
|
||||
}
|
||||
|
||||
type Set = (partial: Partial<Host> | ((s: Host) => Partial<Host>)) => void;
|
||||
type Get = () => Host;
|
||||
|
||||
export interface StudioLayerActions {
|
||||
/**
|
||||
* The group the user has stepped into with a double-click, so a click picks
|
||||
* one member rather than the whole group. Null outside every group.
|
||||
*/
|
||||
enteredGroup: string | null;
|
||||
/** A copied look, for paste style (Ctrl+Alt+C, Ctrl+Alt+V). Kept across pages. */
|
||||
styleClip: BookCopiedStyle | null;
|
||||
|
||||
/**
|
||||
* What a click on an element selects: its whole group, or the element alone
|
||||
* inside the group stepped into. `additive` adds it to the selection, or takes
|
||||
* it out when it is all in already.
|
||||
*/
|
||||
pick: (spreadIndex: number, id: string, additive: boolean) => void;
|
||||
/** A whole group selected, from its row in the layers panel; `additive` adds it. */
|
||||
selectGroup: (spreadIndex: number, groupId: string, additive: boolean) => void;
|
||||
/** Step into an element's group and select the element alone (a double-click). */
|
||||
enterGroup: (spreadIndex: number, id: string) => void;
|
||||
/** Step out of the group again, with the whole group selected (Escape). Answers whether it did. */
|
||||
leaveGroup: (spreadIndex: number) => boolean;
|
||||
/** Every element on the page that is not hidden (Ctrl+A). */
|
||||
selectAll: (spreadIndex: number) => void;
|
||||
|
||||
/** Move the unlocked part of the selection by millimetres, inside an open gesture (arrow keys). */
|
||||
nudge: (spreadIndex: number, dx: number, dy: number) => void;
|
||||
/** The selection, one group (Ctrl+G). Locked elements stay out of it. */
|
||||
group: (spreadIndex: number) => void;
|
||||
/** Every group in the selection back to its members (Ctrl+Shift+G). */
|
||||
ungroup: (spreadIndex: number) => void;
|
||||
/** The selection lined up; locked elements stay where they are. */
|
||||
align: (spreadIndex: number, edge: BookAlignEdge, to: BookAlignTo) => void;
|
||||
/** The selection spaced evenly, three elements or groups at least. */
|
||||
distribute: (spreadIndex: number, axis: BookDistributeAxis) => void;
|
||||
/** Elements, or whole groups, moved in the paint order. */
|
||||
raise: (spreadIndex: number, ids: string | readonly string[], to: BookArrangeTo | 'up' | 'down') => void;
|
||||
/**
|
||||
* Elements dragged to another place in the layers list: in front of
|
||||
* (`above`) or behind (`below`) the anchor. Dropped onto a member of a group
|
||||
* they join that group; dropped beside a group or an ungrouped element they
|
||||
* are on their own.
|
||||
*/
|
||||
restack: (
|
||||
spreadIndex: number,
|
||||
ids: readonly string[],
|
||||
anchorId: string,
|
||||
where: 'above' | 'below',
|
||||
join: boolean
|
||||
) => void;
|
||||
setHidden: (spreadIndex: number, ids: readonly string[], hidden: boolean) => void;
|
||||
setLocked: (spreadIndex: number, ids: readonly string[], locked: boolean) => void;
|
||||
renameLayer: (spreadIndex: number, target: { elementId: string } | { groupId: string }, name: string) => void;
|
||||
/** Copy the look of the first selected element. */
|
||||
copyStyle: (spreadIndex: number) => void;
|
||||
/** Paste the copied look onto the selection, what each kind can take, as one undo step. */
|
||||
pasteStyle: (spreadIndex: number) => void;
|
||||
/** Copies of the selection beside it, groups copied as new groups, selected. */
|
||||
duplicate: (spreadIndex: number, ids: readonly string[]) => void;
|
||||
}
|
||||
|
||||
function onSpread(doc: BookDocument, index: number, fn: (s: BookSpread) => BookSpread): BookDocument {
|
||||
const spread = doc.spreads[index];
|
||||
if (!spread) return doc;
|
||||
const next = fn(spread);
|
||||
if (next === spread) return doc;
|
||||
const spreads = doc.spreads.slice();
|
||||
spreads[index] = next;
|
||||
return { ...doc, spreads };
|
||||
}
|
||||
|
||||
const spreadOf = (get: Get, index: number) => get().doc?.spreads[index];
|
||||
|
||||
/** The selected elements on a spread that may change: on the page and unlocked. */
|
||||
function movable(spread: BookSpread, ids: readonly string[]): BookElement[] {
|
||||
return spread.elements.filter((e) => ids.includes(e.id) && !e.locked);
|
||||
}
|
||||
|
||||
/** The element `id` selects with a click, given the group stepped into. */
|
||||
function clickTarget(spread: BookSpread, id: string, entered: string | null): string[] {
|
||||
const el = spread.elements.find((e) => e.id === id);
|
||||
if (!el) return [];
|
||||
return el.group && el.group === entered ? [id] : expandToGroups(spread, [id]);
|
||||
}
|
||||
|
||||
const OFFSET = 4;
|
||||
|
||||
/** What a click, a double-click, a row or Ctrl+A selects. */
|
||||
function selectionActions(
|
||||
set: Set,
|
||||
get: Get
|
||||
): Pick<StudioLayerActions, 'pick' | 'selectGroup' | 'enterGroup' | 'leaveGroup' | 'selectAll'> {
|
||||
return {
|
||||
pick: (spreadIndex, id, additive) => {
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
if (!spread) return;
|
||||
const { selection, enteredGroup } = get();
|
||||
const target = clickTarget(spread, id, enteredGroup);
|
||||
const el = spread.elements.find((e) => e.id === id);
|
||||
// A click outside the group stepped into steps out of it.
|
||||
const entered = el?.group && el.group === enteredGroup ? enteredGroup : null;
|
||||
if (additive) {
|
||||
const all = target.every((t) => selection.includes(t));
|
||||
const next = all
|
||||
? selection.filter((s) => !target.includes(s))
|
||||
: [...selection, ...target.filter((t) => !selection.includes(t))];
|
||||
set({ selection: next, enteredGroup: entered });
|
||||
return;
|
||||
}
|
||||
// Pressing on what is already selected keeps it, so a multi-selection can be dragged.
|
||||
if (target.every((t) => selection.includes(t))) {
|
||||
set({ enteredGroup: entered });
|
||||
return;
|
||||
}
|
||||
set({ selection: target, enteredGroup: entered });
|
||||
},
|
||||
|
||||
selectGroup: (spreadIndex, groupId, additive) => {
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
if (!spread) return;
|
||||
const ids = groupMembers(spread, groupId).map((e) => e.id);
|
||||
const selection = get().selection;
|
||||
set({ selection: additive ? [...selection.filter((s) => !ids.includes(s)), ...ids] : ids, enteredGroup: null });
|
||||
},
|
||||
|
||||
enterGroup: (spreadIndex, id) => {
|
||||
const el = spreadOf(get, spreadIndex)?.elements.find((e) => e.id === id);
|
||||
if (!el?.group) return;
|
||||
set({ selection: [id], enteredGroup: el.group });
|
||||
},
|
||||
|
||||
leaveGroup: (spreadIndex) => {
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
const { enteredGroup } = get();
|
||||
if (!spread || !enteredGroup) return false;
|
||||
set({ selection: expandToGroups(spread, get().selection), enteredGroup: null });
|
||||
return true;
|
||||
},
|
||||
|
||||
selectAll: (spreadIndex) => {
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
if (!spread) return;
|
||||
set({ selection: spread.elements.filter((e) => !e.hidden).map((e) => e.id), enteredGroup: null });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Moving, grouping, lining up and restacking. */
|
||||
function arrangeActions(
|
||||
set: Set,
|
||||
get: Get
|
||||
): Pick<StudioLayerActions, 'nudge' | 'group' | 'ungroup' | 'align' | 'distribute' | 'raise' | 'restack'> {
|
||||
const commitOn = (index: number, fn: (s: BookSpread) => BookSpread) =>
|
||||
get().commit((doc) => onSpread(doc, index, fn));
|
||||
return {
|
||||
nudge: (spreadIndex, dx, dy) => {
|
||||
const ids = get().selection;
|
||||
get().apply((doc) =>
|
||||
onSpread(doc, spreadIndex, (sp) => {
|
||||
const moving = new Set(movable(sp, ids).map((e) => e.id));
|
||||
if (!moving.size) return sp;
|
||||
const round = (v: number) => Math.round(v * 100) / 100;
|
||||
const elements = sp.elements.map((e) =>
|
||||
moving.has(e.id) ? { ...e, frame: { ...e.frame, x: round(e.frame.x + dx), y: round(e.frame.y + dy) } } : e
|
||||
);
|
||||
return { ...sp, elements };
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
group: (spreadIndex) => {
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
if (!spread) return;
|
||||
const ids = movable(spread, get().selection).map((e) => e.id);
|
||||
if (ids.length < 2 || isWholeGroup(spread, ids)) return;
|
||||
const groupId = bookElementId('g');
|
||||
commitOn(spreadIndex, (sp) => groupElements(sp, ids, groupId));
|
||||
set({ selection: ids, enteredGroup: null });
|
||||
},
|
||||
|
||||
ungroup: (spreadIndex) => {
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
if (!spread) return;
|
||||
const selection = get().selection;
|
||||
const groups = new Set(
|
||||
spread.elements.filter((e) => selection.includes(e.id) && e.group).map((e) => e.group as string)
|
||||
);
|
||||
if (!groups.size) return;
|
||||
commitOn(spreadIndex, (sp) => [...groups].reduce((acc, g) => ungroupElements(acc, g), sp));
|
||||
set({ enteredGroup: null });
|
||||
},
|
||||
|
||||
align: (spreadIndex, edge, to) => {
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
if (!spread) return;
|
||||
const els = movable(spread, get().selection);
|
||||
if (!els.length) return;
|
||||
const doc = get().doc;
|
||||
if (!doc) return;
|
||||
commitOn(spreadIndex, (sp) => alignElements(sp, doc.page, els, edge, to));
|
||||
},
|
||||
|
||||
distribute: (spreadIndex, axis) => {
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
if (!spread) return;
|
||||
const els = movable(spread, get().selection);
|
||||
if (distributableCount(spread, els) < 3) return;
|
||||
commitOn(spreadIndex, (sp) => distributeElements(sp, els, axis));
|
||||
},
|
||||
|
||||
raise: (spreadIndex, ids, to) => {
|
||||
const list = typeof ids === 'string' ? [ids] : ids;
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
if (!spread || !list.length) return;
|
||||
const step: BookArrangeTo = to === 'up' ? 'forward' : to === 'down' ? 'backward' : to;
|
||||
// A group member moved alone inside its group; anything else takes its whole group along.
|
||||
const entered = get().enteredGroup;
|
||||
if (entered !== null && commonGroup(spread, list) === entered) {
|
||||
commitOn(spreadIndex, (sp) => arrangeWithinGroup(sp, entered, list, step));
|
||||
return;
|
||||
}
|
||||
const chosen = expandToGroups(spread, list);
|
||||
commitOn(spreadIndex, (sp) => {
|
||||
const next = arrangeElements(sp, chosen, step);
|
||||
return next.elements.every((e, i) => e === sp.elements[i]) ? sp : next;
|
||||
});
|
||||
},
|
||||
|
||||
restack: (spreadIndex, ids, anchorId, where, join) => {
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
const anchor = spread?.elements.find((e) => e.id === anchorId);
|
||||
if (!spread || !anchor || ids.includes(anchorId)) return;
|
||||
commitOn(spreadIndex, (sp) => {
|
||||
const moving = sp.elements.filter((e) => ids.includes(e.id));
|
||||
const rest = sp.elements.filter((e) => !ids.includes(e.id));
|
||||
const at = rest.findIndex((e) => e.id === anchorId) + (where === 'above' ? 1 : 0);
|
||||
// A whole group dragged keeps itself together; a member dragged alone joins or leaves.
|
||||
const whole = new Set(
|
||||
moving
|
||||
.filter((e) => e.group && groupMembers(sp, e.group).every((m) => ids.includes(m.id)))
|
||||
.map((e) => e.group)
|
||||
);
|
||||
const placed = moving.map((e) => {
|
||||
if (join && anchor.group) return { ...e, group: anchor.group };
|
||||
if (!e.group || whole.has(e.group)) return e;
|
||||
return withoutGroup(e);
|
||||
});
|
||||
const elements = [...rest.slice(0, at), ...placed, ...rest.slice(at)];
|
||||
return tidyGroups(gatherGroups({ ...sp, elements }));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The layers panel's toggles and names, the style clipboard and duplicate. */
|
||||
function panelActions(
|
||||
set: Set,
|
||||
get: Get
|
||||
): Pick<StudioLayerActions, 'setHidden' | 'setLocked' | 'renameLayer' | 'copyStyle' | 'pasteStyle' | 'duplicate'> {
|
||||
const commitOn = (index: number, fn: (s: BookSpread) => BookSpread) =>
|
||||
get().commit((doc) => onSpread(doc, index, fn));
|
||||
return {
|
||||
setHidden: (spreadIndex, ids, hidden) => {
|
||||
commitOn(spreadIndex, (sp) => setElementsHidden(sp, ids, hidden));
|
||||
// A hidden element cannot be seen to be selected; it leaves the selection.
|
||||
if (hidden) set((s) => ({ selection: s.selection.filter((id) => !ids.includes(id)) }));
|
||||
},
|
||||
|
||||
setLocked: (spreadIndex, ids, locked) =>
|
||||
commitOn(spreadIndex, (sp) => ({
|
||||
...sp,
|
||||
elements: sp.elements.map((e) => (ids.includes(e.id) ? { ...e, locked } : e)),
|
||||
})),
|
||||
|
||||
renameLayer: (spreadIndex, target, name) =>
|
||||
commitOn(spreadIndex, (sp) =>
|
||||
'groupId' in target ? renameGroup(sp, target.groupId, name) : renameElement(sp, target.elementId, name)
|
||||
),
|
||||
|
||||
copyStyle: (spreadIndex) => {
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
const el = spread?.elements.find((e) => get().selection.includes(e.id));
|
||||
if (el) set({ styleClip: bookElementStyle(el) });
|
||||
},
|
||||
|
||||
pasteStyle: (spreadIndex) => {
|
||||
const { styleClip, selection } = get();
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
if (!styleClip || !spread) return;
|
||||
const targets = movable(spread, selection);
|
||||
if (!targets.length) return;
|
||||
commitOn(spreadIndex, (sp) => {
|
||||
let changed = false;
|
||||
const elements = sp.elements.map((e) => {
|
||||
if (!targets.some((t) => t.id === e.id)) return e;
|
||||
const look = pastableStyle(styleClip, e.kind);
|
||||
if (!look) return e;
|
||||
changed = true;
|
||||
const props = Object.fromEntries(Object.entries(look).filter(([k]) => k !== 'kind'));
|
||||
return { ...e, ...props } as BookElement;
|
||||
});
|
||||
return changed ? { ...sp, elements } : sp;
|
||||
});
|
||||
},
|
||||
|
||||
duplicate: (spreadIndex, ids) => {
|
||||
const spread = spreadOf(get, spreadIndex);
|
||||
if (!spread) return;
|
||||
const doc = get().doc;
|
||||
if (!doc) return;
|
||||
const chosen = spread.elements.filter((e) => ids.includes(e.id));
|
||||
// The page's own cap: copies that would make the book unsaveable are not made.
|
||||
if (!chosen.length || spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) return;
|
||||
const copies = chosen.map(
|
||||
(e) =>
|
||||
({
|
||||
...e,
|
||||
id: bookElementId(e.kind.charAt(0)),
|
||||
frame: { ...e.frame, x: e.frame.x + OFFSET, y: e.frame.y + OFFSET },
|
||||
}) as BookElement
|
||||
);
|
||||
const regrouped = regroupCopies(copies, spread, bookElementId);
|
||||
commitOn(spreadIndex, (sp) => withCopies(sp, regrouped));
|
||||
set({ selection: regrouped.elements.map((e) => e.id), enteredGroup: null });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function studioLayerActions(set: Set, get: Get): StudioLayerActions {
|
||||
return {
|
||||
enteredGroup: null,
|
||||
styleClip: null,
|
||||
...selectionActions(set, get),
|
||||
...arrangeActions(set, get),
|
||||
...panelActions(set, get),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* FE-STUDIO-READONLY-001 to FE-STUDIO-READONLY-002
|
||||
*
|
||||
* A book held as it is (one a newer Studio saved): every edit, undo and redo
|
||||
* is refused while it holds, and a version from elsewhere is still taken, so
|
||||
* the page on screen stays current.
|
||||
*/
|
||||
import { blankBook, type BookDocument } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useStudioStore } from './studioStore';
|
||||
|
||||
const STUDIO = useStudioStore.getState();
|
||||
|
||||
function book(title = 'Lisbon'): BookDocument {
|
||||
let seq = 0;
|
||||
return blankBook({ title, newId: (prefix) => `${prefix}-${++seq}` });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useStudioStore.setState({ ...STUDIO, doc: null, past: [], future: [], readOnly: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useStudioStore.getState().setReadOnly(false);
|
||||
});
|
||||
|
||||
describe('a book held as it is', () => {
|
||||
it('FE-STUDIO-READONLY-001: refuses edits, undo and redo while held, and takes them again once let go', () => {
|
||||
const store = useStudioStore.getState();
|
||||
store.load(book());
|
||||
store.commit((d) => ({ ...d, title: 'one' }));
|
||||
const edited = useStudioStore.getState().doc;
|
||||
|
||||
store.setReadOnly(true);
|
||||
store.commit((d) => ({ ...d, title: 'two' }));
|
||||
store.apply((d) => ({ ...d, title: 'three' }));
|
||||
store.undo();
|
||||
expect(useStudioStore.getState().doc).toBe(edited);
|
||||
expect(useStudioStore.getState().canUndo()).toBe(false);
|
||||
|
||||
store.setReadOnly(false);
|
||||
expect(useStudioStore.getState().canUndo()).toBe(true);
|
||||
store.undo();
|
||||
expect(useStudioStore.getState().doc?.title).toBe('Lisbon');
|
||||
expect(useStudioStore.getState().canRedo()).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-READONLY-002: still takes a version saved elsewhere, so the page shown stays current', () => {
|
||||
const store = useStudioStore.getState();
|
||||
store.load(book());
|
||||
store.setReadOnly(true);
|
||||
const theirs = book('Porto');
|
||||
store.adoptRemote(theirs, { undoable: false });
|
||||
expect(useStudioStore.getState().doc).toBe(theirs);
|
||||
store.load(book('Faro'));
|
||||
expect(useStudioStore.getState().doc?.title).toBe('Faro');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, innerSpreadBounds as innerBounds,
|
||||
} from '@trek/shared'
|
||||
import { pastedElements, type StudioClipboard } from '../components/Studio/studioClipboard'
|
||||
import { regroupCopies, tidyGroups, withCopies, bookElementId } from '@trek/shared'
|
||||
import { studioLayerActions, type StudioLayerActions } from './studioLayers'
|
||||
|
||||
/**
|
||||
* The book being edited.
|
||||
@@ -22,7 +24,7 @@ import { pastedElements, type StudioClipboard } from '../components/Studio/studi
|
||||
|
||||
const HISTORY_LIMIT = 60
|
||||
|
||||
interface StudioState {
|
||||
interface StudioState extends StudioLayerActions {
|
||||
doc: BookDocument | null
|
||||
selection: string[]
|
||||
activeSpread: number
|
||||
@@ -37,6 +39,15 @@ interface StudioState {
|
||||
* 4.2). Not part of the book or its history; opening a book clears it.
|
||||
*/
|
||||
hintSpread: string | null
|
||||
/**
|
||||
* Whether the open book is held as it is. True for a book a newer Studio
|
||||
* saved (useBookStore's `newer`): this build has already dropped what
|
||||
* it cannot read from its copy, so every edit, undo and redo is refused
|
||||
* rather than shown as work that can never be saved. Loading and taking a
|
||||
* remote version still work, so the page stays current while it is shown.
|
||||
*/
|
||||
readOnly: boolean
|
||||
setReadOnly: (readOnly: boolean) => void
|
||||
|
||||
load: (doc: BookDocument) => void
|
||||
/**
|
||||
@@ -77,8 +88,6 @@ interface StudioState {
|
||||
setRotation: (spreadIndex: number, id: string, rotation: number) => void
|
||||
addElement: (spreadIndex: number, el: BookElement) => void
|
||||
removeElements: (spreadIndex: number, ids: string[]) => void
|
||||
duplicate: (spreadIndex: number, ids: string[]) => void
|
||||
raise: (spreadIndex: number, id: string, to: 'front' | 'back' | 'up' | 'down') => void
|
||||
/** Copy elements of a spread to the clipboard (#2316). */
|
||||
copy: (spreadIndex: number, ids: string[]) => void
|
||||
/** Put the clipboard onto a spread, as one undo step, and select what arrived. */
|
||||
@@ -127,6 +136,8 @@ function replaceSpread(doc: BookDocument, index: number, fn: (s: BookSpread) =>
|
||||
}
|
||||
|
||||
export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
// Groups, layers, align, nudge, duplicate and paste style: studioLayers.ts.
|
||||
...studioLayerActions(set, get),
|
||||
doc: null,
|
||||
selection: [],
|
||||
activeSpread: 0,
|
||||
@@ -135,8 +146,10 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
gestureBase: null,
|
||||
clipboard: null,
|
||||
hintSpread: null,
|
||||
readOnly: false,
|
||||
|
||||
load: doc => set({ doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null, hintSpread: null }),
|
||||
setReadOnly: readOnly => set({ readOnly, gestureBase: null }),
|
||||
load: doc => set({ doc, selection: [], enteredGroup: null, activeSpread: 0, past: [], future: [], gestureBase: null, hintSpread: null }),
|
||||
adoptRemote: (doc, opts) => set(s => {
|
||||
if (!s.doc) return { doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null }
|
||||
if (doc === s.doc) return {}
|
||||
@@ -152,6 +165,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
doc,
|
||||
activeSpread,
|
||||
selection: found >= 0 ? s.selection.filter(id => onPage.has(id)) : [],
|
||||
enteredGroup: found >= 0 ? s.enteredGroup : null,
|
||||
past: opts?.undoable === false ? [] : [...s.past, replaced].slice(-HISTORY_LIMIT),
|
||||
// A new branch: redoing what was undone before this arrived would write over it.
|
||||
future: [],
|
||||
@@ -160,9 +174,10 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
gestureBase: s.gestureBase ? doc : null,
|
||||
}
|
||||
}),
|
||||
setActiveSpread: i => set({ activeSpread: i, selection: [] }),
|
||||
setActiveSpread: i => set({ activeSpread: i, selection: [], enteredGroup: null }),
|
||||
setHintSpread: spreadId => set({ hintSpread: spreadId }),
|
||||
select: ids => set({ selection: ids }),
|
||||
// Nothing selected is outside every group too.
|
||||
select: ids => set(ids.length ? { selection: ids } : { selection: ids, enteredGroup: null }),
|
||||
toggleSelect: (id, additive) => set(s => {
|
||||
if (!additive) return { selection: [id] }
|
||||
return { selection: s.selection.includes(id) ? s.selection.filter(x => x !== id) : [...s.selection, id] }
|
||||
@@ -180,13 +195,13 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
}),
|
||||
|
||||
commit: fn => set(s => {
|
||||
if (!s.doc) return {}
|
||||
if (!s.doc || s.readOnly) return {}
|
||||
const next = fn(s.doc)
|
||||
if (next === s.doc) return {}
|
||||
return { doc: next, past: [...s.past, s.doc].slice(-HISTORY_LIMIT), future: [] }
|
||||
}),
|
||||
|
||||
apply: fn => set(s => (s.doc ? { doc: fn(s.doc) } : {})),
|
||||
apply: fn => set(s => (s.doc && !s.readOnly ? { doc: fn(s.doc) } : {})),
|
||||
|
||||
updateElement: (spreadIndex, id, patch) => get().apply(doc =>
|
||||
replaceSpread(doc, spreadIndex, sp => ({
|
||||
@@ -221,45 +236,14 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
},
|
||||
|
||||
removeElements: (spreadIndex, ids) => {
|
||||
get().commit(doc => replaceSpread(doc, spreadIndex, sp => ({
|
||||
// A group left with one member is no group any more.
|
||||
get().commit(doc => replaceSpread(doc, spreadIndex, sp => tidyGroups({
|
||||
...sp,
|
||||
elements: sp.elements.filter(e => !ids.includes(e.id)),
|
||||
})))
|
||||
set({ selection: [] })
|
||||
set({ selection: [], enteredGroup: null })
|
||||
},
|
||||
|
||||
duplicate: (spreadIndex, ids) => {
|
||||
// Offset the copy so it is visibly a second thing rather than an exact
|
||||
// overlay you cannot tell apart from the original.
|
||||
const OFFSET = 4
|
||||
const made: string[] = []
|
||||
get().commit(doc => replaceSpread(doc, spreadIndex, sp => {
|
||||
const copies = sp.elements
|
||||
.filter(e => ids.includes(e.id))
|
||||
.map(e => {
|
||||
const id = `${e.kind[0]}-${Math.random().toString(36).slice(2, 9)}`
|
||||
made.push(id)
|
||||
return { ...e, id, frame: { ...e.frame, x: e.frame.x + OFFSET, y: e.frame.y + OFFSET } }
|
||||
})
|
||||
return { ...sp, elements: [...sp.elements, ...copies] }
|
||||
}))
|
||||
if (made.length) set({ selection: made })
|
||||
},
|
||||
|
||||
raise: (spreadIndex, id, to) => get().commit(doc =>
|
||||
replaceSpread(doc, spreadIndex, sp => {
|
||||
const i = sp.elements.findIndex(e => e.id === id)
|
||||
if (i < 0) return sp
|
||||
const els = sp.elements.slice()
|
||||
const [el] = els.splice(i, 1)
|
||||
const at = to === 'front' ? els.length
|
||||
: to === 'back' ? 0
|
||||
: to === 'up' ? Math.min(els.length, i + 1)
|
||||
: Math.max(0, i - 1)
|
||||
els.splice(at, 0, el)
|
||||
return { ...sp, elements: els }
|
||||
})),
|
||||
|
||||
copy: (spreadIndex, ids) => {
|
||||
const sp = get().doc?.spreads[spreadIndex]
|
||||
if (!sp) return
|
||||
@@ -274,10 +258,12 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
// The spread's own cap: a paste that would push it past what the contract
|
||||
// accepts takes what fits rather than making the book unsaveable.
|
||||
const room = Math.max(0, MAX_SPREAD_ELEMENTS - target.elements.length)
|
||||
const copies = pastedElements(clipboard, target, doc.page).slice(0, room)
|
||||
if (!copies.length) return
|
||||
get().commit(d => replaceSpread(d, spreadIndex, sp => ({ ...sp, elements: [...sp.elements, ...copies] })))
|
||||
set({ selection: copies.map(e => e.id) })
|
||||
const source = doc.spreads.find(sp => sp.id === clipboard.fromSpreadId)
|
||||
// A copied group arrives as a group of its own, never as part of one already on the page.
|
||||
const copies = regroupCopies(pastedElements(clipboard, target, doc.page).slice(0, room), source, bookElementId)
|
||||
if (!copies.elements.length) return
|
||||
get().commit(d => replaceSpread(d, spreadIndex, sp => withCopies(sp, copies)))
|
||||
set({ selection: copies.elements.map(e => e.id), enteredGroup: null })
|
||||
},
|
||||
|
||||
saveLayout: (spreadIndex, name) => {
|
||||
@@ -420,16 +406,16 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
|
||||
undo: () => set(s => {
|
||||
const prev = s.past[s.past.length - 1]
|
||||
if (!prev || !s.doc) return {}
|
||||
return { doc: prev, past: s.past.slice(0, -1), future: [s.doc, ...s.future].slice(0, HISTORY_LIMIT), selection: [] }
|
||||
if (!prev || !s.doc || s.readOnly) return {}
|
||||
return { doc: prev, past: s.past.slice(0, -1), future: [s.doc, ...s.future].slice(0, HISTORY_LIMIT), selection: [], enteredGroup: null }
|
||||
}),
|
||||
|
||||
redo: () => set(s => {
|
||||
const next = s.future[0]
|
||||
if (!next || !s.doc) return {}
|
||||
return { doc: next, future: s.future.slice(1), past: [...s.past, s.doc].slice(-HISTORY_LIMIT), selection: [] }
|
||||
if (!next || !s.doc || s.readOnly) return {}
|
||||
return { doc: next, future: s.future.slice(1), past: [...s.past, s.doc].slice(-HISTORY_LIMIT), selection: [], enteredGroup: null }
|
||||
}),
|
||||
|
||||
canUndo: () => get().past.length > 0,
|
||||
canRedo: () => get().future.length > 0,
|
||||
canUndo: () => !get().readOnly && get().past.length > 0,
|
||||
canRedo: () => !get().readOnly && get().future.length > 0,
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/* ============================================================================
|
||||
TREK Studio: groups, multi-selection and the layers panel.
|
||||
|
||||
Beside studio.css rather than in it, which has reached its size budget. The
|
||||
same rules apply: every selector is scoped under `.trek-dash`, colours come
|
||||
from the dashboard tokens, and selection chrome is drawn in screen pixels
|
||||
above the page, never on it.
|
||||
============================================================================ */
|
||||
|
||||
/* ----------------- multi-selection on the sheet ----------------- */
|
||||
|
||||
/*
|
||||
* A member of a multi-selection: a single quiet ring, so the box around all
|
||||
* of them is the one thing that reads as "this is what you are holding".
|
||||
*/
|
||||
.trek-dash .st-select.is-member {
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in oklch, var(--accent) 70%, transparent),
|
||||
0 0 0 2px rgba(255, 255, 255, 0.7); /* the light ring keeps the line findable on a dark photo */
|
||||
}
|
||||
|
||||
/* The one element picked inside a group stands out against its dashed box. */
|
||||
.trek-dash .st-select.is-inside:not(.is-member) {
|
||||
box-shadow:
|
||||
0 0 0 1.5px var(--accent),
|
||||
0 0 0 2.5px rgba(255, 255, 255, 0.92),
|
||||
0 0 0 6px color-mix(in oklch, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.trek-dash .st-select-box {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-radius: 1px;
|
||||
box-shadow:
|
||||
0 0 0 1.5px var(--accent),
|
||||
0 0 0 2.5px rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
/* A whole group: dashed, so it reads as a group before anything is done to it. */
|
||||
.trek-dash .st-select-box.is-group {
|
||||
box-shadow: 0 0 0 2.5px rgba(255, 255, 255, 0.85);
|
||||
outline: 1.5px dashed var(--accent);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
.trek-dash .st-marquee {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 1px solid var(--accent);
|
||||
background: color-mix(in oklch, var(--accent) 10%, transparent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ----------------- inspector: align and arrange ----------------- */
|
||||
|
||||
.trek-dash .st-row-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
align-self: center;
|
||||
margin: 0 3px;
|
||||
background: var(--line-2);
|
||||
}
|
||||
.trek-dash .st-arrange-to {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.trek-dash .st-arrange-to .st-line-label {
|
||||
width: auto;
|
||||
}
|
||||
.trek-dash .st-arrange-acts {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.trek-dash .st-section-body > .st-arrange-hint {
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
.trek-dash .st-chip:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ----------------- the layers panel ----------------- */
|
||||
|
||||
.trek-dash .st-layers-scroll {
|
||||
flex: 1;
|
||||
}
|
||||
.trek-dash .st-layers-hint {
|
||||
padding: 0 2px 8px;
|
||||
}
|
||||
|
||||
.trek-dash .st-layers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.trek-dash .st-layers.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.trek-dash .st-layer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding-inline: 4px 2px;
|
||||
border-radius: 8px;
|
||||
color: var(--ink-2);
|
||||
font-size: calc(12.5px * var(--fs-scale-caption, 1));
|
||||
cursor: default;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s,
|
||||
opacity 0.12s;
|
||||
}
|
||||
.trek-dash .st-layer:hover {
|
||||
background: color-mix(in oklch, var(--ink) 6%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-layer.is-on {
|
||||
background: color-mix(in oklch, var(--accent) 13%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-layer.is-inside {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--accent) 45%, transparent);
|
||||
}
|
||||
.trek-dash .st-layer.is-group {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-layer.is-member {
|
||||
padding-inline-start: 22px;
|
||||
}
|
||||
/* The line that joins a group's members to its row. */
|
||||
.trek-dash .st-layer.is-member::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 13px;
|
||||
width: 1px;
|
||||
background: var(--line-2);
|
||||
}
|
||||
/* Dimmed, not removed: this panel is the way back to a hidden layer. */
|
||||
.trek-dash .st-layer.is-hidden .st-layer-name,
|
||||
.trek-dash .st-layer.is-hidden .st-layer-icon {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.trek-dash .st-layer.is-dragged {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Where the drop lands: a line on the edge it goes to. */
|
||||
.trek-dash .st-layer.is-drop-above::after,
|
||||
.trek-dash .st-layer.is-drop-below::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline: 4px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.trek-dash .st-layer.is-drop-above::after {
|
||||
top: -1.5px;
|
||||
}
|
||||
.trek-dash .st-layer.is-drop-below::after {
|
||||
bottom: -1.5px;
|
||||
}
|
||||
|
||||
.trek-dash .st-layer-grip {
|
||||
flex: none;
|
||||
color: var(--ink-3);
|
||||
opacity: 0;
|
||||
cursor: grab;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.trek-dash .st-layer:hover .st-layer-grip {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.trek-dash .st-layer-caret {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 5px;
|
||||
color: var(--ink-3);
|
||||
transition:
|
||||
transform 0.16s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
background 0.12s;
|
||||
}
|
||||
.trek-dash .st-layer-caret:hover {
|
||||
background: color-mix(in oklch, var(--ink) 9%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-layer-caret.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
[dir='rtl'] .trek-dash .st-layer-caret:not(.is-open) {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.trek-dash .st-layer-icon {
|
||||
flex: none;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.trek-dash .st-layer.is-on .st-layer-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
.trek-dash .st-layer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trek-dash .st-layer-name.is-auto {
|
||||
font-weight: inherit;
|
||||
}
|
||||
.trek-dash .st-layer-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0 7px;
|
||||
font-size: inherit;
|
||||
}
|
||||
.trek-dash .st-layer-count {
|
||||
flex: none;
|
||||
font-size: calc(11px * var(--fs-scale-caption, 1));
|
||||
font-weight: 500;
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.trek-dash .st-layer-acts {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
.trek-dash .st-layer-acts .st-act {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.12s,
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
/* Shown on hover and while focused, and always when on, so a locked or hidden layer reads at a glance. */
|
||||
.trek-dash .st-layer:hover .st-layer-acts .st-act,
|
||||
.trek-dash .st-layer-acts .st-act:focus-visible,
|
||||
.trek-dash .st-layer-acts .st-act.is-kept {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.trek-dash .st-layers-foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.trek-dash .st-layers-foot .st-chip {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-dash .st-layer,
|
||||
.trek-dash .st-layer-caret,
|
||||
.trek-dash .st-layer-acts .st-act {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch has no hover: the row's tools stay in view. */
|
||||
@media (hover: none) {
|
||||
.trek-dash .st-layer-acts .st-act,
|
||||
.trek-dash .st-layer-grip {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -99,3 +99,19 @@ describe('a conflict', () => {
|
||||
expect(screen.getByRole('alert')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a book a newer Studio saved', () => {
|
||||
/*
|
||||
* Nothing done in this tab is kept until it reloads, so the state is loud
|
||||
* like a conflict and offers the one way out there is.
|
||||
*/
|
||||
it('is announced, says why, and reloads on request', () => {
|
||||
const onReload = vi.fn()
|
||||
draw({ status: 'newer' }, { onReload })
|
||||
|
||||
expect(screen.getByRole('alert')).toBeTruthy()
|
||||
expect(screen.getByText('journey.studio.saveNewer')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('journey.studio.saveReload'))
|
||||
expect(onReload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,6 +121,17 @@ describe('photo frames', () => {
|
||||
expect(container.querySelector('img')).not.toBeNull()
|
||||
})
|
||||
|
||||
/*
|
||||
* The print view lays every sheet out at once; a picture the browser put off
|
||||
* until it scrolled into view is a picture missing from the PDF.
|
||||
*/
|
||||
it('loads a photo lazily on the canvas and at once in print', () => {
|
||||
const { container: canvas } = draw([photo()])
|
||||
expect(canvas.querySelector('img')?.getAttribute('loading')).toBe('lazy')
|
||||
const { container: printing } = draw([photo()], { print: true })
|
||||
expect(printing.querySelector('img')?.getAttribute('loading')).toBe('eager')
|
||||
})
|
||||
|
||||
it('cuts a masked photo with an objectBoundingBox clip, so it follows a resize', () => {
|
||||
const { container } = draw([photo({ mask: 'heart' })])
|
||||
const clip = container.querySelector('clipPath')!
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { BookDocument, BookRecord } from '@trek/shared'
|
||||
import { BOOK_SCHEMA_REV } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Autosaving a Studio book (#1973).
|
||||
@@ -776,3 +777,90 @@ describe('unmounting', () => {
|
||||
expect(api.saveBook).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// -- A book a newer Studio saved ----------------------------------------------
|
||||
|
||||
/*
|
||||
* This build reads such a book as far as it can, and what it cannot read is
|
||||
* already gone from its copy. Showing it is fine; writing it would delete the
|
||||
* rest for everyone, so nothing is written until a reload.
|
||||
*/
|
||||
describe('a book a newer Studio saved', () => {
|
||||
const newer = (title = 'newer') => ({ ...doc(title), schemaRev: 99 }) as unknown as BookDocument
|
||||
|
||||
/** A 409 for a book a newer Studio saved since this tab read it. */
|
||||
function newerError() {
|
||||
return Object.assign(new Error('Request failed'), {
|
||||
response: { status: 409, data: { error: 'Book was saved by a newer version of TREK Studio', code: 'book_schema_newer', schemaRev: 2 } },
|
||||
})
|
||||
}
|
||||
|
||||
it('FE-STUDIO-REV-002: opens it, says so, and writes nothing, not even on flush', async () => {
|
||||
api.getBook.mockResolvedValue({ book: record({ version: 5, document: newer() }) })
|
||||
const { result } = await mount()
|
||||
|
||||
expect(result.current.state.status).toBe('newer')
|
||||
expect(result.current.record?.document.title).toBe('newer')
|
||||
act(() => { result.current.queueSave(doc('edited'), 'T') })
|
||||
await tick()
|
||||
let ok: boolean | undefined
|
||||
await act(async () => { ok = await result.current.flush() })
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(api.saveBook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-STUDIO-REV-003: stops on the server refusing a save over a newer book, and does not try again', async () => {
|
||||
api.saveBook.mockRejectedValue(newerError())
|
||||
const { result } = await mount()
|
||||
|
||||
act(() => { result.current.queueSave(doc('a'), 'T') })
|
||||
await tick()
|
||||
expect(result.current.state.status).toBe('newer')
|
||||
|
||||
act(() => { result.current.queueSave(doc('b'), 'T') })
|
||||
await tick()
|
||||
expect(api.saveBook).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-STUDIO-REV-004: shows a newer version another editor saved, and holds it the same way', async () => {
|
||||
api.getBook.mockResolvedValue({ book: record({ version: 4, document: doc('ours') }) })
|
||||
const onRemote = vi.fn()
|
||||
const { result } = renderHook(() => useBookStore(9, onRemote))
|
||||
await tick(0)
|
||||
api.getBook.mockResolvedValue({ book: record({ version: 6, document: newer('theirs') }) })
|
||||
|
||||
await remoteSave(6)
|
||||
|
||||
expect(onRemote.mock.calls[0][0].title).toBe('theirs')
|
||||
expect(result.current.state.status).toBe('newer')
|
||||
act(() => { result.current.queueSave(doc('mine'), 'T') })
|
||||
await tick()
|
||||
expect(api.saveBook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-STUDIO-REV-005: taking their side of a conflict holds the book when theirs is newer', async () => {
|
||||
const theirs = record({ version: 6, document: newer('theirs') })
|
||||
api.saveBook.mockRejectedValueOnce(conflictError(theirs))
|
||||
const { result } = await mount()
|
||||
|
||||
act(() => { result.current.queueSave(doc('mine'), 'T') })
|
||||
await tick()
|
||||
expect(result.current.state.status).toBe('conflict')
|
||||
|
||||
act(() => { result.current.acceptTheirs(theirs) })
|
||||
expect(result.current.state.status).toBe('newer')
|
||||
})
|
||||
|
||||
/* A book of today goes out exactly as it went out before the revision existed. */
|
||||
it('FE-STUDIO-REV-006: stamps a book with the revision this Studio writes, and none while that is 1', async () => {
|
||||
const { result } = await mount()
|
||||
|
||||
act(() => { result.current.queueSave(doc('a'), 'T') })
|
||||
await tick()
|
||||
|
||||
const sent = api.saveBook.mock.calls[0][1].document
|
||||
if (BOOK_SCHEMA_REV > 1) expect(sent.schemaRev).toBe(BOOK_SCHEMA_REV)
|
||||
else expect(sent).not.toHaveProperty('schemaRev')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,17 +2,37 @@
|
||||
* How the Studio assistant designs a book (journey-studio-wp13.md 7): the
|
||||
* designer's rules it works by, appended to the Studio's standing
|
||||
* instructions (studioPrompt). Static, so the prompt cache stays stable.
|
||||
*
|
||||
* Restraint is one way to design a book, not the only one. The print rules
|
||||
* (resolution, fold, trim, bleed, binding, legible words) hold for every
|
||||
* book; how full, layered and loud a page is follows the energy the person
|
||||
* asked for. The type scale is given in points on a 210 mm page, as
|
||||
* design-style.ts sets it, so it scales with the page the same way. The
|
||||
* questions under "Look, judge, revise" are the critique a designer runs on a
|
||||
* proof, answered silently before every proposal.
|
||||
*/
|
||||
export const BOOK_DESIGN_PROMPT = `How you design:
|
||||
- Act as a book designer, not a form filler: decide the story first (chapters, pacing), then the pages.
|
||||
- Map the person's words to design_book's directives: "calm, lots of white" is style minimal and density airy; "like a magazine" is editorial; "bold" is bold and rich; "scrapbook" is scrapbook; a page count is pages.min and pages.max; "only Portugal" is a scope.
|
||||
- Hierarchy: one dominant element per page; heading, then body, then captions; never two equal heroes on a spread.
|
||||
- Whitespace is a beat: after a loud spread a quiet one; margins at least the safe area plus 6 mm.
|
||||
- Rhythm: vary the beats, never three alike in a row; grids for bursts, a quiet page for a long story.
|
||||
- Alignment: everything on the margin grid and the gutter; align and distribute instead of eyeballing.
|
||||
- Colour from the photos: one accent, an ink and a paper; at most two typefaces in the whole book.
|
||||
- Never upscale: under 200 dpi make the frame smaller; keep the focal point visible; no words on a busy part of a photo without a scrim.
|
||||
- Act as a book designer, not a form filler: decide the story first (chapters, pacing), then the energy of the book, then the pages.
|
||||
- Map the person's words to design_book's directives: "calm, lots of white" is style minimal and density airy; "like a magazine" is editorial; "bold", "lively" or "loud" is bold and rich; "scrapbook" is scrapbook; a page count is pages.min and pages.max; "only Portugal" is a scope.
|
||||
- Before you call design_book, state the brief in one line: the energy (calm, lively or loud), the cover photo, the title words and the look.
|
||||
Always, whatever the energy:
|
||||
- Never upscale: under 200 dpi make the frame smaller; keep the focal point visible.
|
||||
- Nothing that matters in the fold, past the trim or outside the safe area; a picture meant to bleed reaches the bleed; mind the binding on a double spread.
|
||||
- Words on a photo sit on a quiet part of it, a scrim or a colour block; running text at 4.5:1 contrast, display type at 3:1.
|
||||
- Bound texts keep the journal in sync: prefer bindings over copied words; never invent captions or facts.
|
||||
- Check every changed page with describe_book_page and the designId, look at the ones that matter most with preview_book_page and the designId when you can see images, and fix their findings with revise_book_design before you propose.
|
||||
The energy decides the rest:
|
||||
- Calm: one dominant element per page; heading, then body, then captions; whitespace as a beat after a loud spread; margins at least the safe area plus 6 mm; everything on the margin grid and the gutter; one accent from the photos and two typefaces.
|
||||
- Lively: still one hero per page, now layered: a photo over a colour block or a shape, the title over a photo, photos turned 2 to 6 degrees in a white or polaroid frame, oversize display type, colour blocking in the accent.
|
||||
- Loud: lively pushed further: bigger type, stronger colour, more layers, a title that fills the cover; never two heroes on one page.
|
||||
- A deliberate overlap is a design; an accidental one (two straight photos or texts of a size half over each other) is a fault.
|
||||
- Vary the beats, never three alike in a row; a quiet page for a long story, grids for bursts.
|
||||
Type scale, in points for a 210 mm page and scaled with the page width:
|
||||
- Cover title in a display face, 72 to 180 pt, 12 to 30 % of the page height; title to subtitle at least 3:1. For a big cover raise type.display (up to 6.5, about 180 pt); keep type.scale between 0.9 and 1.1 so body stays 9 to 11 pt.
|
||||
- Date lines in spaced caps, 7 to 9 pt, tracking 0.25 to 0.4; an accent word over a title in the contrasting face (playfair italic or display), 1.2 to 1.6 times its cap height, with contrast, an outline or a shadow.
|
||||
- Body 9 to 11 pt; captions 6.5 to 8 pt with tracking 0.05 to 0.1; a display face and a body face, two typefaces at most.
|
||||
Look, judge, revise:
|
||||
- Check every changed page with describe_book_page and the designId, and look at the pages that matter most with preview_book_page and the designId when you can see images; preview the cover at least twice, the first draft and the final one.
|
||||
- Before every proposal answer silently: Is there one dominant element? Does the title read at thumbnail size? Does the energy match the brief? Is the palette the book's own? Is anything touching or overlapping by accident? Is text on a busy area without contrast? Does the decoration suit the energy? Does the page differ from the one before? Fix what fails with revise_book_design.
|
||||
- Fix every severe finding before you propose; an info finding is context, not a fault.
|
||||
- One design, one proposal: apply_book_design for a whole book, never dozens of edit_book_page calls; a single page with edit_book_page.
|
||||
- Keep what the person locked or made by hand unless they ask; say which photos you left out and why.`;
|
||||
|
||||
@@ -226,9 +226,10 @@ type ToolGroups = Readonly<Record<string, { about: string; controllers: readonly
|
||||
const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
tours: {
|
||||
about:
|
||||
'Tours: hikes, bike tours, city walks, paddling and ski routes; plan, create, change and export them ' +
|
||||
'(hikes with their heights and SAC grades), their packing, the tour library and imports from GPX files ' +
|
||||
'and Google Maps walks.',
|
||||
'Tours: hikes (also over several days in stages), city walks, bike tours, runs, rides, paddling and ski ' +
|
||||
'routes; plan, create, change and export them (hikes with their heights and SAC grades), search along a ' +
|
||||
'tour, check off a city walk under way, the recorded tracks and tour stats, their packing, tours in ' +
|
||||
'collections, the tour library and imports from GPX files and Google Maps walks.',
|
||||
controllers: [
|
||||
'ToursMcp',
|
||||
'CityWalkMcp',
|
||||
|
||||
@@ -34,7 +34,7 @@ How you work:
|
||||
- Expenses and their ids come from list_budget_items. Change an existing one with update_budget_item; never add a second entry to correct one. For what each person's share comes to, use get_budget_per_person; for who pays whom, get_settlement_summary.
|
||||
- For the wind on a day (a paddle, a sail, a ridge walk), use get_wind_forecast: it has the gusts and the direction hour by hour.
|
||||
- End every answer with one suggest_follow_ups call, alone in its step after your text: up to three short messages the user would plausibly send next, about what you just discussed (a hike you planned: an easier variant, what to pack; never the day plan when the answer was not about it). When you asked the user a question, offer its likely answers instead ("Yes, plan it anyway", "Rather in the Harz"). Skip it only while changes wait for the user's decision.
|
||||
- If the request is unclear, ask one short question instead of guessing.`;
|
||||
- If the request is unclear, ask one short question instead of guessing. A request that names what and roughly where is clear: propose it at once (a proposal saves nothing) and say in a sentence what you assumed, rather than asking whether you may.`;
|
||||
|
||||
/** The instructions with the packages' lines about their cards, built once so the provider's cache holds. */
|
||||
const STANDING = INSTRUCTIONS + CARD_PROMPT_LINES.map((line) => `\n- ${line}`).join('');
|
||||
@@ -58,7 +58,7 @@ How you work:
|
||||
- Read get_book and describe_book_page before you change a page. Page and element ids come from them and from the book card; never invent one. Photo ids come only from {photoSources}.
|
||||
- To change the book, call the tool for the change: the app shows each call to the user as a change card, and nothing happens until they apply it. Do not describe a change in text and ask whether to make it. Make one edit_book_page call per page in a step, with all of that page's operations.
|
||||
- Act, don't announce. Make the reads and changes you need in this reply; never end a reply with "I will now ..." and no calls. Never say a change was made unless its result says it was applied.
|
||||
- Keep every text inside its frame and nothing important in the fold or past the trim. When a result lists severe findings (overlapping photos, words past their frame, empty frames, content in the fold), fix them with the next change.
|
||||
- Keep every text inside its frame and nothing important in the fold or past the trim. When a result lists severe findings (photos or texts overlapping by accident, words past their frame, empty frames, content in the fold), fix them with the next change; a layered page (a title over a photo, a turned photo over a shape) is a design, not a finding to fix.
|
||||
- Before you propose an edit_book_page change, look at the page once with preview_book_page and the edit's ops, fix what its findings show, then propose. When a result brings the page as an image, check your work on it too: photos of other contributors are empty frames there on purpose, not missing photos.
|
||||
- Never delete pages unless the user asks for it. Never place a photo at a size its resolution cannot print.
|
||||
- Picking photos works on what TREK knows about them: the day, the place, bursts and near duplicates, sharpness, resolution and whether a photo is in the book already. Nobody can tell from that who or what is in a photo or whether it is beautiful; only a page image shows the photos themselves. Say so when asked.
|
||||
|
||||
@@ -85,7 +85,7 @@ export const TOUR_CARDS: DomainCards = {
|
||||
},
|
||||
},
|
||||
prompt: [
|
||||
'Hikes: call propose_hike with a name, and day_id and start_time when known; to compare ways, call it up to three times in one step, each with a variant_label and along false. Call create_hike only once the user chose a way.',
|
||||
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters.',
|
||||
'Hikes: call propose_hike with a name, and day_id and start_time when known; to compare ways, call it up to three times in one step, each with a variant_label and along false, the way you recommend first (the card shows the first one chosen). Call create_hike only once the user chose a way. A hike over several days is one hike: stage_end on each stop a day ends at (no day_id), then each stage on its day with assign_place_to_day and tourStageKey.',
|
||||
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters. Once a tour is planned, offer what goes with it: places along it (search_tour_pois), what to pack (get_tour_packing, link_tour_packing), the GPX file (export_tour_gpx).',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -139,11 +139,18 @@ function waypointStops(waypoints: readonly Row[], start: string | null): TourRou
|
||||
atM: metresOf(point.at_m) ?? 0,
|
||||
minutes,
|
||||
clock: clockOf(point.arrival) ?? clockAfter(start, minutes),
|
||||
stageEnd: point.stage_end === true && index > 0 && index < waypoints.length - 1,
|
||||
}),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** How many stages a proposed hike's stops make; null for a day hike, as a saved tour's card says it. */
|
||||
function stagesOf(stops: readonly TourRouteStop[]): number | null {
|
||||
const count = 1 + stops.filter((stop) => stop.stageEnd).length;
|
||||
return count >= 2 ? Math.min(count, 60) : null;
|
||||
}
|
||||
|
||||
/** The places along a proposed way and its highest point, timed between the points around them. */
|
||||
function alongStops(value: Row, own: readonly TourRouteStop[], start: string | null): TourRouteStop[] {
|
||||
const along = rowsIn(value.along)
|
||||
@@ -248,6 +255,7 @@ export function hikeCard(
|
||||
line,
|
||||
stretches: sacStretches(sac?.segments),
|
||||
stops: sortedStops([...own, ...alongStops(plan, own, start)]),
|
||||
stages: stagesOf(own),
|
||||
schedule: scheduleOf(plan.schedule, card),
|
||||
warnings: warningsOf(plan.warnings),
|
||||
});
|
||||
|
||||
@@ -177,7 +177,7 @@ export class JourneyBookMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'edit_book_page',
|
||||
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, fonts, strokes and the like), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures) and add_grid (a photo grid as empty frames in a box). A locked element refuses any operation that would change it but lock. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, fonts, strokes and the like), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures), add_grid (a photo grid as empty frames in a box), group and ungroup (two or more elements picked, moved, aligned and arranged as one, such as a title lockup; align and distribute move a group as one box), name (an element or a group, as the layers panel shows it) and hide (left off the page and out of print, or shown again). A locked element refuses any operation that would change it but lock, ungroup, name and hide. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...editBookPageArgs },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: journeyAddonOn,
|
||||
|
||||
@@ -8,7 +8,14 @@ import { JourneyDomainService } from './journey-domain.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { BookDocument, BookRecord, BookSummary } from '@trek/shared';
|
||||
import { BOOK_DESIGN_LIMITS, normalizeBookDocument } from '@trek/shared';
|
||||
import {
|
||||
BOOK_DESIGN_LIMITS,
|
||||
BOOK_SCHEMA_NEWER_CODE,
|
||||
BOOK_SCHEMA_REV,
|
||||
bookSchemaRev,
|
||||
normalizeBookDocument,
|
||||
stampBookSchemaRev,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* What an edit of the book hands back to `editBook`: the record's title and
|
||||
@@ -59,6 +66,15 @@ interface BookEditOptions extends BookWriteOrigin {
|
||||
*
|
||||
* The alternative — last write wins — is not a simpler version of this. It is
|
||||
* the same thing with the failure moved somewhere nobody sees it.
|
||||
*
|
||||
* ── Format revisions ─────────────────────────────────────────────────────
|
||||
*
|
||||
* A book says which revision of the format wrote it (book-schema-rev.ts in
|
||||
* @trek/shared). A save from a writer older than the stored book is refused,
|
||||
* because that writer has already dropped whatever it could not read; so is
|
||||
* one newer than this server, which would drop the same on the way in. The
|
||||
* book tools write only books this server reads whole, and stamp what they
|
||||
* write with its revision.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JourneyBookService {
|
||||
@@ -166,6 +182,7 @@ export class JourneyBookService {
|
||||
userId: number,
|
||||
input: { title: string; document: unknown; baseVersion?: number },
|
||||
): Promise<{ record: BookRecord } | { conflict: BookRecord }> {
|
||||
refuseNewerThanServer(bookSchemaRev(input.document));
|
||||
const document = JSON.stringify(normalizeBookDocument(input.document));
|
||||
|
||||
// M1 (rule 11/24) — JB3's existing-link read and JB4's insert are
|
||||
@@ -187,6 +204,10 @@ export class JourneyBookService {
|
||||
return { record: (await this.byId(id))! };
|
||||
}
|
||||
|
||||
// An older writer has already lost what it could not read; it does not get to save that loss.
|
||||
const stored = storedSchemaRev(existing.document);
|
||||
if (stored > bookSchemaRev(input.document)) throw newerBook(stored);
|
||||
|
||||
/*
|
||||
* The version goes in the WHERE clause rather than being checked first.
|
||||
* Read-then-write leaves a window between the two in which another save can
|
||||
@@ -249,11 +270,15 @@ export class JourneyBookService {
|
||||
if (options.expectedVersion !== undefined && current?.version !== options.expectedVersion) {
|
||||
throw bookMoved(current);
|
||||
}
|
||||
// `current` is the book as this server reads it, which for a newer book is the book minus what it dropped.
|
||||
if (row) refuseNewerThanServer(storedSchemaRev(row.document));
|
||||
const next = await edit(current);
|
||||
// What the edit hands back is checked too: a kept revision from a newer server, put back after a downgrade, would land stripped yet still stamped newer.
|
||||
refuseNewerThanServer(bookSchemaRev(next.document));
|
||||
if (current && next.unchanged) {
|
||||
return { record: current, result: next.result, wrote: false, revisionId: null };
|
||||
}
|
||||
const document = JSON.stringify(normalizeBookDocument(next.document));
|
||||
const document = JSON.stringify(normalizeBookDocument(stampBookSchemaRev(next.document)));
|
||||
if (!row) {
|
||||
const id = await this.booksRepo.insertBook(journeyId, next.title, document, userId);
|
||||
return { record: await this.written(id), result: next.result, wrote: true, revisionId: null };
|
||||
@@ -341,6 +366,31 @@ function bookMoved(current: BookRecord | null): DomainError {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The book was written by a newer revision of the format than the writer (or
|
||||
* this server) knows. 409 like a moved book, with the code the Studio reads to
|
||||
* open the book read-only and ask for a reload; no `current`, because there is
|
||||
* nothing here an older writer could safely take.
|
||||
*/
|
||||
function newerBook(schemaRev: number): DomainError {
|
||||
return new DomainError(409, 'Book was saved by a newer version of TREK Studio', {
|
||||
code: BOOK_SCHEMA_NEWER_CODE,
|
||||
details: { code: BOOK_SCHEMA_NEWER_CODE, schemaRev },
|
||||
mcpMessage:
|
||||
'This book was saved by a newer version of TREK than this server runs. Changing it here would drop what that version added, so it stays as it is.',
|
||||
});
|
||||
}
|
||||
|
||||
/** Refuse a book written by a revision this server cannot read whole. */
|
||||
function refuseNewerThanServer(schemaRev: number): void {
|
||||
if (schemaRev > BOOK_SCHEMA_REV) throw newerBook(schemaRev);
|
||||
}
|
||||
|
||||
/** The revision a stored row's document says it was written by; broken JSON reads as 1, like its document. */
|
||||
function storedSchemaRev(raw: string): number {
|
||||
return bookSchemaRev(safeParse(raw));
|
||||
}
|
||||
|
||||
/** JSON that will not parse is an empty document, never an exception. */
|
||||
function safeParse(raw: string): unknown {
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type HikeHighPoint,
|
||||
type HikePlan,
|
||||
type HikePlanPoint,
|
||||
type HikeStop,
|
||||
type HikeProposal,
|
||||
type HikeProposalSac,
|
||||
type HikeProposeRequest,
|
||||
@@ -39,9 +40,10 @@ import {
|
||||
*/
|
||||
|
||||
/** The points a hike is planned through: as asked, and for a round trip back to its start. */
|
||||
export function hikePointsOf(waypoints: readonly HikePlanPoint[], roundTrip: boolean | undefined): HikePlanPoint[] {
|
||||
export function hikePointsOf(waypoints: readonly HikeStop[], roundTrip: boolean | undefined): HikeStop[] {
|
||||
const [start] = waypoints;
|
||||
return roundTrip === true && start ? [...waypoints, { ...start }] : [...waypoints];
|
||||
// The way back ends where the hike began: the start ends no stage there.
|
||||
return roundTrip === true && start ? [...waypoints, { ...start, stage_end: undefined }] : [...waypoints];
|
||||
}
|
||||
|
||||
/** Whether the way was planned, and why not, as the answers name it. */
|
||||
@@ -49,19 +51,29 @@ export function routeOutcomeOf(plan: HikePlan): HikeRouteOutcome {
|
||||
return plan.kind === 'routed' ? { planned: true } : { planned: false, why: plan.why, position: plan.position };
|
||||
}
|
||||
|
||||
/** The points as a tour keeps them: in order, the first the start and the last the end, with their names. */
|
||||
export function hikeWaypointsOf(points: readonly HikePlanPoint[]): TourWaypoint[] {
|
||||
/**
|
||||
* The points as a tour keeps them: in order, the first the start and the
|
||||
* last the end, with their names, and on a hike over several days the stops
|
||||
* its stages end at.
|
||||
*/
|
||||
export function hikeWaypointsOf(points: readonly HikeStop[]): TourWaypoint[] {
|
||||
const last = points.length - 1;
|
||||
return points.map(({ lat, lng, name, poi_category }, sequence) => ({
|
||||
return points.map(({ lat, lng, name, poi_category, stage_end }, sequence) => ({
|
||||
lat,
|
||||
lng,
|
||||
...(name ? { name } : {}),
|
||||
...(poi_category ? { poi_category } : {}),
|
||||
role: sequence === 0 ? 'start' : sequence === last ? 'end' : 'via',
|
||||
sequence,
|
||||
...(stage_end === true && sequence > 0 && sequence < last ? { stage_end: true } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** How many stages a hike's points make: one for a day hike. */
|
||||
export function hikeStagesOf(points: readonly HikeStop[]): number {
|
||||
return 1 + points.filter((point) => point.stage_end === true).length;
|
||||
}
|
||||
|
||||
/** The way a saved hike keeps from a plan: its whole line and walking time, or none, and how the planning went. */
|
||||
export interface KeptWay {
|
||||
line: GeometryPoint[] | null;
|
||||
@@ -93,7 +105,7 @@ export function keptWayOf({ plan, line }: PlannedHike): KeptWay {
|
||||
*/
|
||||
export function hikeCreateCommandOf(
|
||||
request: HikeCreateRequest,
|
||||
points: readonly HikePlanPoint[],
|
||||
points: readonly HikeStop[],
|
||||
way: KeptWay,
|
||||
limit: number,
|
||||
): TourCreateCommand {
|
||||
@@ -135,13 +147,15 @@ function savedPointAt(point: { lat: number; lng: number }, saved: readonly TourW
|
||||
* keeps its link to the trip's place it stands for, and its name where the
|
||||
* new one gives none. A saved point no new one stands on goes, with its link.
|
||||
*/
|
||||
export function repointedWaypoints(points: readonly HikePlanPoint[], saved: readonly TourWaypoint[]): TourWaypoint[] {
|
||||
export function repointedWaypoints(points: readonly HikeStop[], saved: readonly TourWaypoint[]): TourWaypoint[] {
|
||||
return hikeWaypointsOf(points).map((point) => {
|
||||
const same = savedPointAt(point, saved);
|
||||
if (!same) return point;
|
||||
const name = point.name ?? same.name;
|
||||
// Its key too: a stage is put on its day by the key of the stop it starts at.
|
||||
return {
|
||||
...point,
|
||||
...(same.key ? { key: same.key } : {}),
|
||||
...(name ? { name } : {}),
|
||||
...(same.linked_place_id ? { linked_place_id: same.linked_place_id } : {}),
|
||||
};
|
||||
@@ -319,7 +333,7 @@ export interface HikeProposalContext {
|
||||
*/
|
||||
export function hikeProposalOf(
|
||||
request: HikeProposeRequest,
|
||||
points: readonly HikePlanPoint[],
|
||||
points: readonly HikeStop[],
|
||||
planned: PlannedHike,
|
||||
context: HikeProposalContext,
|
||||
): HikeProposal {
|
||||
@@ -329,8 +343,9 @@ export function hikeProposalOf(
|
||||
const heights = routed?.has_heights && line ? heightRangeOf(line) : null;
|
||||
const minutes = routed && line && positions ? waypointMinutesOf(line, positions, routed.duration_minutes) : null;
|
||||
const arrivals = arrivalsOf(request.start_time ?? null, minutes ?? points.map(() => null));
|
||||
// One day's sun and hours say nothing about a hike over several days.
|
||||
const day =
|
||||
routed && context.date
|
||||
routed && context.date && hikeStagesOf(points) === 1
|
||||
? hikeScheduleOf({
|
||||
date: context.date,
|
||||
dayId: context.dayId,
|
||||
@@ -357,11 +372,12 @@ export function hikeProposalOf(
|
||||
: {}),
|
||||
sac: proposalSacOf(routed?.grades ?? null),
|
||||
high_point: routed ? highPointOf(line) : null,
|
||||
waypoints: points.map(({ lat, lng, name, poi_category }, index) => ({
|
||||
waypoints: points.map(({ lat, lng, name, poi_category, stage_end }, index) => ({
|
||||
lat,
|
||||
lng,
|
||||
name: name || null,
|
||||
poi_category: poi_category ?? null,
|
||||
stage_end: stage_end === true,
|
||||
at_m: positions?.[index] ?? null,
|
||||
snapped_m: routed ? (planned.snapped?.[index] ?? null) : null,
|
||||
minutes: minutes?.[index] ?? null,
|
||||
|
||||
@@ -55,7 +55,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'propose_hike',
|
||||
description:
|
||||
"Plan a hike for a trip and show it to the traveller, without saving anything; give it a name. The way on foot runs through waypoints in order (the start, the points on the way such as a hut or a summit, each with its poi_category where it is one, the end; with round_trip it comes back to the start) and takes no path harder than max_hiking_difficulty on the SAC scale (1 for T1 hiking to 6 for T6 difficult alpine hiking, 2 when left out), avoiding steep climbs, steps or ferries where there is a way round when hiking_preferences asks so. TREK's router plans it as the tour planner does. The answer gives distance_m, ascent_m and descent_m, the walking time without breaks (duration_minutes: by DIN 33466, the rule hiking signposts use, where the heights are known, duration_basis din33466; else the router's, duration_basis router), min_ele_m, max_ele_m and high_point, the SAC grades OpenStreetMap records along the way (sac: highest, the graded stretches in metres along the way, and unknown_share, the part with no grade, which is unknown and never easy), every point with at_m (where it lies along the way), snapped_m (how far it was moved onto a way), minutes from the start and, with start_time, its arrival, and along: summits, huts, water and viewpoints near the way (null with along false or where they could not be searched). With day_id (a day of the trip, whose date it takes) or date, schedule gives the day: the arrival (next_day past midnight), sunrise and sunset, the weather by the hour at the highest point (weather forecast, or climate further out: last year's day) and, without start_time, latest_start to be back half an hour before sunset; warnings name a start before sunrise (dark_start), an arrival after sunset (dark_end) or within the hour before it (dusk), and the first walking hour with thunderstorms (thunder) or likely rain (rain). Where no way was planned, route says why: no_valhalla (this TREK has no router that knows hiking grades), router_unavailable (try again later), no_way (nothing within the limit joins the points: a higher limit or other points may), point_off_the_way (unreachable names the point and how far it is from any way) or too_long (longer than one hike is planned for: split it into shorter hikes). To compare ways, propose each one with its own variant_label, such as 'short and steep' or 'by the lake', and along false. The chat shows the route, its profile, times, weather and warnings as a card: never list coordinates, the hours or the figures, sum the hike up in a sentence and say what matters. Propose first and create the hike with create_hike only after the traveller said yes.",
|
||||
"Plan a hike for a trip and show it to the traveller, without saving anything; give it a name. A hike over several days (a trek from hut to hut) is one hike: mark each stop a day ends at with stage_end, and the card shows its stages. The way on foot runs through waypoints in order (the start, the points on the way such as a hut or a summit, each with its poi_category where it is one, the end; with round_trip it comes back to the start) and takes no path harder than max_hiking_difficulty on the SAC scale (1 for T1 hiking to 6 for T6 difficult alpine hiking, 2 when left out), avoiding steep climbs, steps or ferries where there is a way round when hiking_preferences asks so. TREK's router plans it as the tour planner does. The answer gives distance_m, ascent_m and descent_m, the walking time without breaks (duration_minutes: by DIN 33466, the rule hiking signposts use, where the heights are known, duration_basis din33466; else the router's, duration_basis router), min_ele_m, max_ele_m and high_point, the SAC grades OpenStreetMap records along the way (sac: highest, the graded stretches in metres along the way, and unknown_share, the part with no grade, which is unknown and never easy), every point with at_m (where it lies along the way), snapped_m (how far it was moved onto a way), minutes from the start and, with start_time, its arrival, and along: summits, huts, water and viewpoints near the way (null with along false or where they could not be searched). With day_id (a day of the trip, whose date it takes) or date, schedule gives the day: the arrival (next_day past midnight), sunrise and sunset, the weather by the hour at the highest point (weather forecast, or climate further out: last year's day) and, without start_time, latest_start to be back half an hour before sunset; warnings name a start before sunrise (dark_start), an arrival after sunset (dark_end) or within the hour before it (dusk), and the first walking hour with thunderstorms (thunder) or likely rain (rain). Where no way was planned, route says why: no_valhalla (this TREK has no router that knows hiking grades), router_unavailable (try again later), no_way (nothing within the limit joins the points: a higher limit or other points may), point_off_the_way (unreachable names the point and how far it is from any way) or too_long (longer than one hike is planned for: split it into shorter hikes). To compare ways, propose each one with its own variant_label, such as 'short and steep' or 'by the lake', and along false. The chat shows the route, its profile, times, weather and warnings as a card: never list coordinates, the hours or the figures, sum the hike up in a sentence and say what matters. Propose first and create the hike with create_hike only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema, ...hikeProposeRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -69,7 +69,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'create_hike',
|
||||
description:
|
||||
"Create a hike in a trip from points the traveller confirmed, usually the name and waypoints of a proposal of propose_hike with its max_hiking_difficulty, hiking_preferences and round_trip. It is saved the way the planner saves a new hike: a tour of type hike with its points in order (start, points on the way, end, with their names and poi_category), the way on foot planned by the same router as the proposal and kept whole (the very line a proposal of the last minutes showed), with its heights where every point has one, its distance, ascent, descent and walking time, its hiking limit and preferences, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the hike is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner; no straight line is stored. Any other reason the proposal names (no_valhalla, no_way, point_off_the_way, too_long) refuses the hike with why, and nothing is saved. With day_id the hike is put on that day of the trip in the same write, start_time kept as the start of that visit (which needs day editing permission too); assignment names the visit, null without a day. The answer is the tour as list_tours reads it (tour.place_id is the tour's id, its revision 1), route and assignment. Put it on a day later with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx, change it with update_tour. Needs place editing permission on the trip. Create it only after the traveller said yes.",
|
||||
"Create a hike in a trip from points the traveller confirmed, usually the name and waypoints of a proposal of propose_hike with its max_hiking_difficulty, hiking_preferences and round_trip, and its stage_end marks for a hike over several days; then put each stage on its day with assign_place_to_day and tourStageKey (the key of the stop the stage starts at, from the stage_stops list_tours gives); leave day_id out for such a hike, which would put the whole hike on one day. It is saved the way the planner saves a new hike: a tour of type hike with its points in order (start, points on the way, end, with their names and poi_category), the way on foot planned by the same router as the proposal and kept whole (the very line a proposal of the last minutes showed), with its heights where every point has one, its distance, ascent, descent and walking time, its hiking limit and preferences, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the hike is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner; no straight line is stored. Any other reason the proposal names (no_valhalla, no_way, point_off_the_way, too_long) refuses the hike with why, and nothing is saved. With day_id the hike is put on that day of the trip in the same write, start_time kept as the start of that visit (which needs day editing permission too); assignment names the visit, null without a day. The answer is the tour as list_tours reads it (tour.place_id is the tour's id, its revision 1), route and assignment. Put it on a day later with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx, change it with update_tour. Needs place editing permission on the trip. Create it only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema.describe('The trip to create the hike in'), ...hikeCreateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -88,7 +88,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'update_tour',
|
||||
description:
|
||||
"Change a saved hike (tour_type hike) against the revision it was read at: expected_revision is tour.revision from get_tour_plan or list_tours. A new name, description or planned_duration_minutes keeps the hike's way. New waypoints (start to end; a round trip ends on its start), a new max_hiking_difficulty or new hiking_preferences plan the way anew as propose_hike plans it, and so does a hike still waiting for its way (route_pending); propose the new way to the traveller first. A hike changed since that revision is refused as a conflict: read it again and show the traveller what changed. Where the new way cannot be planned, or not with its heights, nothing is saved and the hike keeps its way; the error says why. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) or split into stages takes new points only in the planner, and the hand-drawn one no new limit or preferences either: those changes are refused here. A recorded track without points of its own (an import) takes only new waypoints, which plan it over and keep the recording. Any other type is refused: change a city walk with prepare_tour_change and save_tour_change. The hike stays on its days. The answer is the tour as saved, with its new revision, route and replanned. Needs place editing permission on the trip. Change it only after the traveller said yes.",
|
||||
"Change a saved hike (tour_type hike) against the revision it was read at: expected_revision is tour.revision from get_tour_plan or list_tours. A new name, description or planned_duration_minutes keeps the hike's way. New waypoints (start to end; a round trip ends on its start), a new max_hiking_difficulty or new hiking_preferences plan the way anew as propose_hike plans it, and so does a hike still waiting for its way (route_pending); propose the new way to the traveller first. A hike changed since that revision is refused as a conflict: read it again and show the traveller what changed. Where the new way cannot be planned, or not with its heights, nothing is saved and the hike keeps its way; the error says why. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) takes new points, a new limit or new preferences only in the planner: those changes are refused here. A hike split into stages takes new points only with stage_end given again on each stop a day ends at, and its stages keep their days where a stop stays where it was. A recorded track without points of its own (an import) takes only new waypoints, which plan it over and keep the recording. Any other type is refused: change a city walk with prepare_tour_change and save_tour_change. The hike stays on its days. The answer is the tour as saved, with its new revision, route and replanned. Needs place editing permission on the trip. Change it only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema, ...tourUpdateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
HIKE_ALONG_CATEGORIES,
|
||||
HIKE_ALONG_CORRIDOR_M,
|
||||
HIKE_PLAN_MAX_WAYPOINTS,
|
||||
hikeStageIssues,
|
||||
hikingPreferencesOf,
|
||||
TOUR_POI_MAX_LINE_POINTS,
|
||||
tourGpxImportFieldsSchema,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
type HikeAlongPlace,
|
||||
type HikeCreateRequest,
|
||||
type HikePlanPoint,
|
||||
type HikeStop,
|
||||
type HikeProposal,
|
||||
type HikeProposeRequest,
|
||||
type HikingPreferences,
|
||||
@@ -109,14 +111,21 @@ const HAND_DRAWN =
|
||||
* ends of its stages would go with the points they belong to, and a stage
|
||||
* that goes would leave its day. Null where new points take nothing away.
|
||||
*/
|
||||
function repointingRefusal(saved: readonly TourWaypoint[]): string | null {
|
||||
function repointingRefusal(saved: readonly TourWaypoint[], points: readonly HikeStop[]): string | null {
|
||||
if (saved.some((point) => point.fixed_leg)) return HAND_DRAWN;
|
||||
if (saved.some((point) => point.stage_end)) {
|
||||
return 'This hike is split into stages over several days, which new points would end: change its points in the planner. A new limit or new preferences plan it anew here, its stages kept.';
|
||||
// New points carry the stages of the hike themselves; without any, its stages would silently end.
|
||||
if (saved.some((point) => point.stage_end) && !points.some((point) => point.stage_end)) {
|
||||
return 'This hike is split into stages over several days: give stage_end again on each stop a day ends at, or the stages would end. A new limit or new preferences alone plan it anew here, its stages kept.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Stage ends that do not hold (at the start or the end, too many stages): refused with their reason. */
|
||||
function refuseBadStages(points: readonly HikeStop[], roundTrip: boolean | undefined): void {
|
||||
const issues = hikeStageIssues(points, roundTrip);
|
||||
if (issues.length > 0) throw badRequest(issues.map((issue) => issue.message).join('; '));
|
||||
}
|
||||
|
||||
/**
|
||||
* A hike planned with the traveller over MCP, end to end on the server: the
|
||||
* proposal reads only and may be asked for as often as ways are compared;
|
||||
@@ -149,10 +158,17 @@ export class HikePlanService {
|
||||
preferences: HikingPreferences | null | undefined,
|
||||
grades: boolean,
|
||||
): Promise<PlannedHike> {
|
||||
// The router takes the points only: where a stage ends is the hike's, not the way's.
|
||||
const waypoints = points.map(({ lat, lng, name, poi_category }) => ({
|
||||
lat,
|
||||
lng,
|
||||
...(name === undefined ? {} : { name }),
|
||||
...(poi_category === undefined ? {} : { poi_category }),
|
||||
}));
|
||||
return await this.routing.plan(
|
||||
userId,
|
||||
tripId,
|
||||
{ waypoints: points, max_hiking_difficulty: limit, hiking_preferences: preferences },
|
||||
{ waypoints, max_hiking_difficulty: limit, hiking_preferences: preferences },
|
||||
{ grades },
|
||||
);
|
||||
}
|
||||
@@ -165,6 +181,7 @@ export class HikePlanService {
|
||||
* best effort: without them the proposal still answers.
|
||||
*/
|
||||
async propose(userId: number, tripId: number, request: HikeProposeRequest): Promise<HikeProposal> {
|
||||
refuseBadStages(request.waypoints, request.round_trip);
|
||||
const day = await this.dayOf(tripId, request.day_id, request.date);
|
||||
const points = hikePointsOf(request.waypoints, request.round_trip);
|
||||
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
|
||||
@@ -231,6 +248,7 @@ export class HikePlanService {
|
||||
* drop onto the day tell it.
|
||||
*/
|
||||
async create(userId: number, tripId: number, request: HikeCreateRequest): Promise<HikeCreated> {
|
||||
refuseBadStages(request.waypoints, request.round_trip);
|
||||
const day = request.day_id === undefined ? null : await this.dayOf(tripId, request.day_id, undefined);
|
||||
const points = hikePointsOf(request.waypoints, request.round_trip);
|
||||
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
|
||||
@@ -295,7 +313,8 @@ export class HikePlanService {
|
||||
'This hike is a recorded track without points of its own: give waypoints to plan it over (the recorded track is kept), or rename it with update_place.',
|
||||
);
|
||||
}
|
||||
const repointing = request.waypoints ? repointingRefusal(saved.waypoints) : null;
|
||||
if (request.waypoints) refuseBadStages(request.waypoints, false);
|
||||
const repointing = request.waypoints ? repointingRefusal(saved.waypoints, request.waypoints) : null;
|
||||
if (repointing) throw badRequest(repointing);
|
||||
const kept = hikingPreferencesOf(tour.hiking_preferences);
|
||||
// A recorded track keeps no limit of its own; planned over, it takes the planner's.
|
||||
|
||||
@@ -202,8 +202,8 @@ function proposalFor(asked: HikeProposeRequest): HikeProposal {
|
||||
sac: { segments: [{ from_m: 0, to_m: 1112, sac: 2 }], highest: 2, unknown_share: 0 },
|
||||
high_point: { lat: 47.61, lng: 11.3, ele_m: 880, at_m: 1112 },
|
||||
waypoints: [
|
||||
{ ...WAYPOINTS[0], poi_category: null, at_m: 0, snapped_m: 2, minutes: 0, arrival: null },
|
||||
{ ...WAYPOINTS[1], at_m: 1112, snapped_m: 3, minutes: 22, arrival: null },
|
||||
{ ...WAYPOINTS[0], poi_category: null, at_m: 0, snapped_m: 2, minutes: 0, arrival: null, stage_end: false },
|
||||
{ ...WAYPOINTS[1], at_m: 1112, snapped_m: 3, minutes: 22, arrival: null, stage_end: false },
|
||||
],
|
||||
along: null,
|
||||
max_hiking_difficulty: 2,
|
||||
|
||||
@@ -9,14 +9,18 @@
|
||||
* viewer's write is refused with the tool's own words, and an operation the
|
||||
* engine refuses with the operation it names; a REST save and an MCP write
|
||||
* send the same event to the same users, REST leaving out the tab that saved
|
||||
* and MCP none; and the tools are gone while the Journey addon is off.
|
||||
* and MCP none; the tools are gone while the Journey addon is off; and a book
|
||||
* a newer Studio wrote is refused with 409 and its code over both, even when
|
||||
* its document holds what this build cannot parse.
|
||||
*/
|
||||
import { ADDON_IDS } from '../../src/addons';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { JourneyBooks } from '../../src/db/entities/JourneyBooks.entity';
|
||||
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { addJourneyContributor, makeJourney } from '../helpers/factories/journeys';
|
||||
import { findRow, updateRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime';
|
||||
@@ -24,11 +28,14 @@ import { MikroORM } from '@mikro-orm/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { bookWriteResultSchema } from '@trek/shared';
|
||||
import { BOOK_SCHEMA_REV, bookWriteResultSchema } from '@trek/shared';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
/** A format revision newer than this build reads. */
|
||||
const NEWER = BOOK_SCHEMA_REV + 1;
|
||||
|
||||
const BOOK_TOOLS = [
|
||||
'get_book',
|
||||
'describe_book_page',
|
||||
@@ -198,4 +205,62 @@ describe('Studio book over MCP and REST', () => {
|
||||
expect(names.filter((name) => BOOK_TOOLS.includes(name))).toEqual([]);
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('JBE2E-005: a book a newer Studio saved refuses a REST save with 409 and its code, and an MCP write in its words', async () => {
|
||||
const { owner, journey, inner } = await journeyWithBook();
|
||||
const before = await request(app.getHttpServer())
|
||||
.get(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
const row = await findRow(orm, JourneyBooks, { journey: journey.id });
|
||||
const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: NEWER });
|
||||
await updateRows(orm, JourneyBooks, { journey: journey.id }, { document: newer });
|
||||
|
||||
const saved = await request(app.getHttpServer())
|
||||
.put(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ title: before.body.book.title, document: before.body.book.document, baseVersion: 1 });
|
||||
expect(saved.status).toBe(409);
|
||||
expect(saved.body).toEqual({
|
||||
error: 'Book was saved by a newer version of TREK Studio',
|
||||
code: 'book_schema_newer',
|
||||
schemaRev: NEWER,
|
||||
});
|
||||
|
||||
const tool = await callTool(owner.id, 'edit_book_page', {
|
||||
journeyId: journey.id,
|
||||
spreadId: inner,
|
||||
ops: [{ op: 'background', color: '#202020' }],
|
||||
});
|
||||
expect(tool).toEqual({
|
||||
error:
|
||||
'This book was saved by a newer version of TREK than this server runs. Changing it here would drop what that version added, so it stays as it is.',
|
||||
});
|
||||
expect(sent()).toEqual([]);
|
||||
const after = await findRow(orm, JourneyBooks, { journey: journey.id });
|
||||
expect([after?.version, after?.document]).toEqual([1, newer]);
|
||||
});
|
||||
|
||||
it('JBE2E-006: a save of a book a newer Studio wrote, with a kind this one cannot read, is a 409 with its code, not a 400', async () => {
|
||||
const { owner, journey } = await journeyWithBook();
|
||||
const before = await request(app.getHttpServer())
|
||||
.get(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
const document = before.body.book.document as { spreads: Array<{ elements: unknown[] }> };
|
||||
const frame = { x: 10, y: 10, w: 40, h: 40 };
|
||||
document.spreads[0]?.elements.push({ id: 'from-later', kind: 'sticker', frame, stickerId: 'lemon' });
|
||||
|
||||
const saved = await request(app.getHttpServer())
|
||||
.put(`/api/journeys/${journey.id}/book`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ title: before.body.book.title, document: { ...document, schemaRev: NEWER }, baseVersion: 1 });
|
||||
expect(saved.status).toBe(409);
|
||||
expect(saved.body).toEqual({
|
||||
error: 'Book was saved by a newer version of TREK Studio',
|
||||
code: 'book_schema_newer',
|
||||
schemaRev: NEWER,
|
||||
});
|
||||
expect(sent()).toEqual([]);
|
||||
const after = await findRow(orm, JourneyBooks, { journey: journey.id });
|
||||
expect(after?.version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,11 +224,42 @@ describe('the designer in the Studio', () => {
|
||||
it('ASTBD-008: the Studio instructions end with the design rules, cache-stable and without dashes', () => {
|
||||
const prompt = studioPrompt('German', true);
|
||||
expect(prompt.endsWith(BOOK_DESIGN_PROMPT)).toBe(true);
|
||||
expect(BOOK_DESIGN_PROMPT.split('\n').length).toBeLessThanOrEqual(14);
|
||||
// The print rules, the energy, the type scale and the critique, on a budget the cache carries every turn.
|
||||
expect(BOOK_DESIGN_PROMPT.split('\n').length).toBeLessThanOrEqual(30);
|
||||
expect(BOOK_DESIGN_PROMPT.length).toBeLessThan(6000);
|
||||
for (const tool of ['design_book', 'revise_book_design', 'apply_book_design', 'preview_book_page']) {
|
||||
expect(BOOK_DESIGN_PROMPT).toContain(tool);
|
||||
}
|
||||
expect(BOOK_DESIGN_PROMPT).not.toMatch(/[\u2013\u2014]/);
|
||||
expect(studioPrompt('German', true)).toBe(prompt);
|
||||
});
|
||||
|
||||
it('ASTBD-009: restraint is one energy among three, beside print rules that hold for every book', () => {
|
||||
for (const rule of ['200 dpi', 'fold', 'trim', 'bleed', 'binding', 'safe area']) {
|
||||
expect(BOOK_DESIGN_PROMPT, rule).toContain(rule);
|
||||
}
|
||||
for (const energy of ['- Calm:', '- Lively:', '- Loud:']) expect(BOOK_DESIGN_PROMPT).toContain(energy);
|
||||
expect(BOOK_DESIGN_PROMPT).toContain('A deliberate overlap is a design');
|
||||
});
|
||||
|
||||
it('ASTBD-010: gives the type scale in numbers against the page, and the critique to answer before a proposal', () => {
|
||||
for (const figure of ['72 to 180 pt', '12 to 30 % of the page height', 'at least 3:1', 'Body 9 to 11 pt']) {
|
||||
expect(BOOK_DESIGN_PROMPT, figure).toContain(figure);
|
||||
}
|
||||
expect(BOOK_DESIGN_PROMPT).toContain('captions 6.5 to 8 pt');
|
||||
// The cover tier carries a big title alone, and the faces named are faces the book has.
|
||||
expect(BOOK_DESIGN_PROMPT).toContain('type.display (up to 6.5');
|
||||
expect(BOOK_DESIGN_PROMPT).toContain('type.scale between 0.9 and 1.1');
|
||||
expect(BOOK_DESIGN_PROMPT).not.toMatch(/script/i);
|
||||
expect(BOOK_DESIGN_PROMPT).toContain('210 mm page and scaled with the page width');
|
||||
const rubric = BOOK_DESIGN_PROMPT.slice(BOOK_DESIGN_PROMPT.indexOf('answer silently'));
|
||||
expect(rubric.slice(0, rubric.indexOf('Fix what fails')).match(/\?/g)).toHaveLength(8);
|
||||
expect(BOOK_DESIGN_PROMPT).toContain('preview the cover at least twice');
|
||||
});
|
||||
|
||||
it('ASTBD-011: the Studio instructions take a layered page for a design and only an accidental overlap for a fault', () => {
|
||||
const prompt = studioPrompt('German', true);
|
||||
expect(prompt).toContain('photos or texts overlapping by accident');
|
||||
expect(prompt).toContain('a layered page (a title over a photo, a turned photo over a shape) is a design');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,6 +180,16 @@ describe('the card of a proposed hike', () => {
|
||||
expect(shown.warnings).toEqual([{ kind: 'thunder', at: '08:00', count: null }]);
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-001b: a hike over several days counts its stages and marks the stops they end at', () => {
|
||||
const base = proposal();
|
||||
const [start, summit] = base.waypoints as Array<Record<string, unknown>>;
|
||||
const hut = { ...start, lat: 47.605, name: 'Hut', at_m: 556, minutes: 11, arrival: '08:11', stage_end: true };
|
||||
const shown = route(answerCardOf(...input('propose_hike', { ...base, waypoints: [start, hut, summit] })));
|
||||
expect(shown.stages).toBe(2);
|
||||
expect(shown.stops.filter((stop) => stop.stageEnd).map((stop) => stop.name)).toEqual(['Hut']);
|
||||
expect(route(answerCardOf(...input('propose_hike', proposal()))).stages).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-002: lists the points and the places along the way in the order walked, timed between', () => {
|
||||
const shown = route(answerCardOf(...input('propose_hike', proposal())));
|
||||
expect(shown.stops.map((stop) => [stop.role, stop.kind, stop.atM, stop.minutes, stop.clock])).toEqual([
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
hikeCreateCommandOf,
|
||||
hikePointsOf,
|
||||
hikeProposalOf,
|
||||
hikeStagesOf,
|
||||
hikeWaypointsOf,
|
||||
keptWayOf,
|
||||
positionsAlong,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// HIKE-PLAN-001 to HIKE-PLAN-014
|
||||
// HIKE-PLAN-001 to HIKE-PLAN-017
|
||||
|
||||
/** A plan with its line, as the router answers it: without the router's legs and snaps unless given. */
|
||||
const planned = (
|
||||
@@ -393,3 +394,52 @@ describe('where and when a proposed hike reaches its points', () => {
|
||||
expect(alongPlacesOf([], LINE)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a hike over several days', () => {
|
||||
it('HIKE-PLAN-015: keeps a stage end only on a stop between the start and the end, and the way back of a round trip ends none', () => {
|
||||
const hut = { lat: 47.002, lng: 11, name: 'Hut', stage_end: true };
|
||||
expect(hikeWaypointsOf([{ ...START, stage_end: true }, hut, SUMMIT]).map((point) => point.stage_end)).toEqual([
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
]);
|
||||
expect(hikeStagesOf([START, hut, SUMMIT])).toBe(2);
|
||||
expect(hikeStagesOf([START, SUMMIT])).toBe(1);
|
||||
const loop = hikePointsOf([START, hut, SUMMIT], true);
|
||||
expect(loop.at(-1)).toMatchObject({ lat: START.lat, lng: START.lng });
|
||||
expect(loop.at(-1)?.stage_end).toBeUndefined();
|
||||
});
|
||||
|
||||
it('HIKE-PLAN-016: a proposal over several days names its stage ends and sets no schedule of a single day', () => {
|
||||
const points = [START, { lat: 47.002, lng: 11, name: 'Hut', stage_end: true }, SUMMIT];
|
||||
const day: HikeProposalContext = { dayId: 3, date: '2027-07-10', along: null, weather: null };
|
||||
const proposal = hikeProposalOf({ name: 'Trek', waypoints: points }, points, planned(ROUTED, LINE), day);
|
||||
expect(proposal.waypoints.map((point) => point.stage_end)).toEqual([false, true, false]);
|
||||
expect(proposal.schedule).toBeNull();
|
||||
expect(hikeProposalSchema.safeParse(proposal).success).toBe(true);
|
||||
const single = hikeProposalOf(
|
||||
{ name: 'Day', waypoints: [START, SUMMIT] },
|
||||
[START, SUMMIT],
|
||||
planned(ROUTED, LINE),
|
||||
day,
|
||||
);
|
||||
expect(single.schedule).not.toBeNull();
|
||||
});
|
||||
|
||||
it('HIKE-PLAN-017: new points keep the key of a saved stop they stand on, so a stage keeps its day', () => {
|
||||
const saved = [
|
||||
{ lat: 47, lng: 11, role: 'start' as const, sequence: 0, key: 'k-start' },
|
||||
{ lat: 47.002, lng: 11, role: 'via' as const, sequence: 1, key: 'k-hut', stage_end: true },
|
||||
{ lat: 47.004, lng: 11, role: 'end' as const, sequence: 2, key: 'k-end' },
|
||||
];
|
||||
const repointed = repointedWaypoints(
|
||||
[START, { lat: 47.002, lng: 11, stage_end: true }, { lat: 47.006, lng: 11 }],
|
||||
saved,
|
||||
);
|
||||
expect(repointed.map((point) => [point.key, point.stage_end])).toEqual([
|
||||
['k-start', undefined],
|
||||
['k-hut', true],
|
||||
[undefined, undefined],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type TourPoiSearchResponse,
|
||||
type UnroutedHikePlan,
|
||||
type WeatherResult,
|
||||
hikePlanRequestSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -464,6 +465,7 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
snapped_m: 12,
|
||||
minutes: 0,
|
||||
arrival: '08:30',
|
||||
stage_end: false,
|
||||
},
|
||||
{
|
||||
lat: 47.505,
|
||||
@@ -474,6 +476,7 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
snapped_m: 3,
|
||||
minutes: 26,
|
||||
arrival: '08:56',
|
||||
stage_end: false,
|
||||
},
|
||||
{
|
||||
lat: 47.51,
|
||||
@@ -484,6 +487,7 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
snapped_m: 40,
|
||||
minutes: 52,
|
||||
arrival: '09:22',
|
||||
stage_end: false,
|
||||
},
|
||||
]);
|
||||
expect(proposal.high_point).toEqual({ lat: 47.51, lng: 11.1, ele_m: 1000, at_m: 1112 });
|
||||
@@ -635,6 +639,29 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
),
|
||||
).rejects.toThrow('Day not found');
|
||||
});
|
||||
|
||||
it('HIKE-MCP-022: refuses a stage end at the start or the end of a hike, which ends no day', async () => {
|
||||
const f = await fixture();
|
||||
const [first, ...rest] = WAYPOINTS;
|
||||
await expect(
|
||||
mcp.proposeHike(
|
||||
{ tripId: f.trip.id, name: 'Wrong', waypoints: [{ ...first, stage_end: true }, ...rest] },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
).rejects.toThrow('a stage ends at a stop between start and end');
|
||||
});
|
||||
|
||||
it('HIKE-MCP-022b: the router gets the points without their stage ends, which its strict request refuses', async () => {
|
||||
const f = await fixture();
|
||||
const [first, middle, last] = WAYPOINTS;
|
||||
await mcp.proposeHike(
|
||||
{ tripId: f.trip.id, name: 'Two days', waypoints: [first, { ...middle, stage_end: true }, last] },
|
||||
ctx(f.user.id),
|
||||
);
|
||||
const sent = plan.mock.calls.at(-1)?.[2];
|
||||
expect(sent?.waypoints).toEqual(WAYPOINTS);
|
||||
expect(hikePlanRequestSchema.safeParse(sent).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HikePlanMcp: create_hike', () => {
|
||||
|
||||
@@ -31,10 +31,13 @@ import {
|
||||
import { createTestJourneyBooksRepo } from '../../helpers/journey-share-repos';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow';
|
||||
import { normalizeBookDocument } from '@trek/shared';
|
||||
import { BOOK_SCHEMA_REV, normalizeBookDocument } from '@trek/shared';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
/** A format revision newer than this build reads. */
|
||||
const NEWER = BOOK_SCHEMA_REV + 1;
|
||||
|
||||
vi.mock('../../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
||||
const db = createSnapshotTestDb();
|
||||
@@ -563,7 +566,12 @@ describe('editBook', () => {
|
||||
|
||||
const [tool, studio] = await Promise.all([
|
||||
catchDomainError(() => books.editBook(journey.id, user.id, edited('tool'), { expectedVersion: 1 })),
|
||||
books.saveBook(journey.id, user.id, { title: 'T', document: doc('studio'), baseVersion: 1 }),
|
||||
// The Studio stamps what it saves with its revision, as the book tools do (useBookStore).
|
||||
books.saveBook(journey.id, user.id, {
|
||||
title: 'T',
|
||||
document: { ...doc('studio'), schemaRev: BOOK_SCHEMA_REV },
|
||||
baseVersion: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
const toolLost = tool instanceof DomainError && tool.getStatus() === 409;
|
||||
@@ -620,6 +628,116 @@ describe('editBook', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- Format revisions ---------------------------------------------------------
|
||||
|
||||
/*
|
||||
* A book written by a newer revision of the format than the writer knows. The
|
||||
* older writer has already dropped what it could not read, so its save, from
|
||||
* an old tab or a replayed offline copy, must not land; nor may the book tools
|
||||
* of a server that cannot read the book whole.
|
||||
*/
|
||||
describe('format revisions', () => {
|
||||
/** A journey whose stored book says it was written by revision `schemaRev`. */
|
||||
async function storedAt(schemaRev: number) {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
await insertRow(await sharedTestOrm(testDb), JourneyBooks, {
|
||||
journey: journey.id,
|
||||
title: 'T',
|
||||
document: JSON.stringify({ ...doc('newer'), schemaRev }),
|
||||
version: 1,
|
||||
});
|
||||
return { user, journey };
|
||||
}
|
||||
|
||||
const refusalOf = (thrown: unknown) =>
|
||||
thrown instanceof DomainError ? [thrown.getStatus(), thrown.code, thrown.toBody().code] : thrown;
|
||||
|
||||
it('JBOOK-011: refuses a save from a writer older than the stored book with 409 and the code, writing and sending nothing', async () => {
|
||||
const { user, journey } = await storedAt(NEWER);
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const thrown = await catchDomainError(() =>
|
||||
books.saveBook(journey.id, user.id, { title: 'T', document: doc('older'), baseVersion: 1 }),
|
||||
);
|
||||
expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
|
||||
expect(thrown instanceof DomainError && thrown.toBody()).toMatchObject({
|
||||
error: 'Book was saved by a newer version of TREK Studio',
|
||||
schemaRev: NEWER,
|
||||
});
|
||||
expect(rec.sent).toEqual([]);
|
||||
const stored = await books.getBook(journey.id, user.id);
|
||||
expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', NEWER]);
|
||||
} finally {
|
||||
rec.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('JBOOK-012: refuses a document newer than this server reads, first save and later save alike', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const ahead = { ...doc('ahead'), schemaRev: 9 };
|
||||
const first = await catchDomainError(() => books.saveBook(journey.id, user.id, { title: 'T', document: ahead }));
|
||||
expect(refusalOf(first)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
|
||||
expect(await books.getBook(journey.id, user.id)).toBeNull();
|
||||
|
||||
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
|
||||
const later = await catchDomainError(() =>
|
||||
books.saveBook(journey.id, user.id, { title: 'T', document: ahead, baseVersion: 1 }),
|
||||
);
|
||||
expect(refusalOf(later)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
|
||||
expect((await books.getBook(journey.id, user.id))?.document.title).toBe('one');
|
||||
});
|
||||
|
||||
it('JBOOK-013: a save at the stored revision or above it lands as before, and a book of today stores no revision', async () => {
|
||||
const { user, journey } = await storedAt(1);
|
||||
const same = await books.saveBook(journey.id, user.id, {
|
||||
title: 'T',
|
||||
document: { ...doc('same'), schemaRev: 1 },
|
||||
baseVersion: 1,
|
||||
});
|
||||
expect(same && 'record' in same && same.record.version).toBe(2);
|
||||
|
||||
const plain = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('plain'), baseVersion: 2 });
|
||||
expect(plain && 'record' in plain && plain.record.version).toBe(3);
|
||||
const row = await findRow(await sharedTestOrm(testDb), JourneyBooks, { journey: journey.id });
|
||||
expect(JSON.parse(String(row?.document))).not.toHaveProperty('schemaRev');
|
||||
});
|
||||
|
||||
it('JBOOK-014: the book tools leave a book this server cannot read whole alone, before any edit runs', async () => {
|
||||
const { user, journey } = await storedAt(NEWER);
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const edit = vi.fn(() => ({ title: 'T', document: normalizeBookDocument(doc('tool')), result: null }));
|
||||
const thrown = await catchDomainError(() => books.editBook(journey.id, user.id, edit));
|
||||
expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
|
||||
expect(thrown instanceof DomainError && thrown.mcpMessage).toMatch(/newer version of TREK/);
|
||||
expect(edit).not.toHaveBeenCalled();
|
||||
expect(rec.sent).toEqual([]);
|
||||
expect((await books.getBook(journey.id, user.id))?.document.title).toBe('newer');
|
||||
} finally {
|
||||
rec.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('JBOOK-015: an edit that hands back a document newer than this server reads is refused, the stored book untouched', async () => {
|
||||
const { user, journey } = await storedAt(1);
|
||||
const rec = await recordSends();
|
||||
try {
|
||||
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: NEWER });
|
||||
const thrown = await catchDomainError(() =>
|
||||
books.editBook(journey.id, user.id, () => ({ title: 'T', document: ahead, result: null })),
|
||||
);
|
||||
expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
|
||||
expect(rec.sent).toEqual([]);
|
||||
const stored = await books.getBook(journey.id, user.id);
|
||||
expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', 1]);
|
||||
} finally {
|
||||
rec.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- Deleting -----------------------------------------------------------------
|
||||
|
||||
describe('deleting', () => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* stale version, and forgets the draft; a journey without a book gets a new
|
||||
* one and no revision; restoring the revision puts the book back only at the
|
||||
* version the apply left, and never writes a revision that does not read
|
||||
* back; five revisions are kept.
|
||||
* back or that a newer TREK kept; five revisions are kept.
|
||||
*/
|
||||
import { JourneyBookRevisions } from '../../../../src/db/entities/JourneyBookRevisions.entity';
|
||||
import { JourneyEntryPhotos } from '../../../../src/db/entities/JourneyEntryPhotos.entity';
|
||||
@@ -34,7 +34,12 @@ import { makeUser } from '../../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { bookDesignAnswerSchema, bookDesignApplyResultSchema, type BookDesignAnswer } from '@trek/shared';
|
||||
import {
|
||||
BOOK_SCHEMA_REV,
|
||||
bookDesignAnswerSchema,
|
||||
bookDesignApplyResultSchema,
|
||||
type BookDesignAnswer,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -321,6 +326,25 @@ describe('taking a design back', () => {
|
||||
expect(told()).toEqual([]);
|
||||
});
|
||||
|
||||
it('JBDSN-011: a revision kept by a newer TREK is refused, never put back stripped of what this one cannot read', async () => {
|
||||
const { journey, owner } = await crew();
|
||||
const answer = await designs.design(journey.id, owner.id);
|
||||
const applied = await designs.apply(journey.id, owner.id, { designId: answer.design_id });
|
||||
const revisionId = applied.revision_id ?? 0;
|
||||
const kept = await findRow(orm, JourneyBookRevisions, { id: revisionId });
|
||||
const newer = JSON.stringify({ ...JSON.parse(String(kept?.document)), schemaRev: BOOK_SCHEMA_REV + 1 });
|
||||
await updateRows(orm, JourneyBookRevisions, { id: revisionId }, { document: newer });
|
||||
t.realtime.reset();
|
||||
|
||||
const back = { revisionId, expectedVersion: applied.version };
|
||||
expect(await refusal(() => designs.restoreRevision(journey.id, owner.id, back))).toEqual({
|
||||
status: 409,
|
||||
code: 'book_schema_newer',
|
||||
});
|
||||
expect(await version(journey.id, owner.id)).toBe(applied.version);
|
||||
expect(told()).toEqual([]);
|
||||
});
|
||||
|
||||
it('JBDSN-009: a journey keeps its newest five revisions', async () => {
|
||||
const { journey, owner } = await crew();
|
||||
const books = t.get(JourneyBookService);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Regenerates src/book/book-icon-names-*.data.ts, the lucide drawings the
|
||||
// Studio's icon tab offers, from the lucide-react installed at the repo root.
|
||||
// Run from shared/: `node scripts/gen-book-icon-names.mjs`.
|
||||
//
|
||||
// Read from the package's type declarations rather than imported: the shared
|
||||
// package has no lucide dependency of its own and must not grow one, the
|
||||
// contract layer stays zod only. The names are taken the way the client's
|
||||
// iconLibrary.ts takes them from the module: every PascalCase export, less the
|
||||
// `Lucide*` and `*Icon` aliases of a name that exists bare, and less `Icon`,
|
||||
// the component every drawing is built on. client/src/components/Studio/
|
||||
// iconLibrary.test.ts fails when the files and the shipped library drift apart.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const lucide = join(here, '..', '..', 'node_modules', 'lucide-react');
|
||||
const { version } = JSON.parse(readFileSync(join(lucide, 'package.json'), 'utf8'));
|
||||
const declarations = readFileSync(join(lucide, 'dist', 'lucide-react.d.ts'), 'utf8');
|
||||
|
||||
/** The last `export { ... };` of the declarations, which names every icon and its aliases. */
|
||||
const exportLine = declarations
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('export { '))
|
||||
.pop();
|
||||
if (!exportLine) throw new Error('no export list in lucide-react.d.ts');
|
||||
|
||||
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
|
||||
const exported = exportLine
|
||||
.replace(/^export \{ /, '')
|
||||
.replace(/ \};\s*$/, '')
|
||||
.split(',')
|
||||
.map((part) =>
|
||||
part
|
||||
.trim()
|
||||
.split(/\s+as\s+/)
|
||||
.pop(),
|
||||
)
|
||||
.filter((name) => PASCAL_CASE.test(name));
|
||||
const present = new Set(exported);
|
||||
const names = [...present]
|
||||
.filter((name) => !(name.startsWith('Lucide') && present.has(name.slice(6))))
|
||||
.filter((name) => !(name.endsWith('Icon') && present.has(name.slice(0, -4))))
|
||||
.filter((name) => name !== 'Icon')
|
||||
.sort();
|
||||
|
||||
/** The three files and the initials each holds, so none of them passes a thousand lines. */
|
||||
const PARTS = [
|
||||
{ suffix: 'a-e', constant: 'BOOK_ICON_NAMES_A_E', label: 'A to E', test: /^[A-E]/ },
|
||||
{ suffix: 'f-p', constant: 'BOOK_ICON_NAMES_F_P', label: 'F to P', test: /^[F-P]/ },
|
||||
{ suffix: 'q-z', constant: 'BOOK_ICON_NAMES_Q_Z', label: 'Q to Z', test: /^[Q-Z]/ },
|
||||
];
|
||||
|
||||
for (const part of PARTS) {
|
||||
const list = names.filter((name) => part.test.test(name));
|
||||
const source = `/**
|
||||
* The lucide drawings the Studio offers, ${part.label}: part of BOOK_ICON_NAMES
|
||||
* (book-icons.ts). Generated from lucide-react ${version} by
|
||||
* scripts/gen-book-icon-names.mjs, never edited by hand; the client's
|
||||
* iconLibrary.test.ts fails when the list and the library it ships drift apart.
|
||||
*/
|
||||
export const ${part.constant}: readonly string[] = [
|
||||
${list.map((name) => ` '${name}',`).join('\n')}
|
||||
];
|
||||
`;
|
||||
writeFileSync(join(here, '..', 'src', 'book', `book-icon-names-${part.suffix}.data.ts`), source);
|
||||
}
|
||||
console.log(`wrote ${names.length} icon names from lucide-react ${version}`);
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
alignElements,
|
||||
arrangeElements,
|
||||
arrangeWithinGroup,
|
||||
boundsOfFrames,
|
||||
distributableCount,
|
||||
distributeElements,
|
||||
} from './book-arrange';
|
||||
import {
|
||||
bookPageSetupSchema,
|
||||
bookShapeElementSchema,
|
||||
bookSpreadSchema,
|
||||
type BookElement,
|
||||
type BookSpread,
|
||||
} from './book.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Paint order, alignment and spacing, with groups moving as one. The page is
|
||||
* the default square book: 210 mm pages, the fold at 210, 5 mm safe margin.
|
||||
*/
|
||||
|
||||
const pageSetup = bookPageSetupSchema.parse({});
|
||||
|
||||
const box = (id: string, x: number, y: number, w = 20, h = 20, over: Partial<BookElement> = {}) =>
|
||||
bookShapeElementSchema.parse({ id, kind: 'shape', frame: { x, y, w, h }, shape: 'rect', ...over }) as BookElement;
|
||||
|
||||
const spread = (elements: BookElement[]): BookSpread => bookSpreadSchema.parse({ id: 'sp', elements });
|
||||
const ids = (sp: BookSpread) => sp.elements.map((e) => e.id);
|
||||
const at = (sp: BookSpread, id: string) => sp.elements.find((e) => e.id === id)?.frame;
|
||||
const pick = (sp: BookSpread, list: string[]) => sp.elements.filter((e) => list.includes(e.id));
|
||||
|
||||
describe('paint order', () => {
|
||||
const sp = spread([
|
||||
box('a', 0, 0),
|
||||
box('g1', 0, 0, 20, 20, { group: 'g' }),
|
||||
box('g2', 0, 0, 20, 20, { group: 'g' }),
|
||||
box('b', 0, 0),
|
||||
]);
|
||||
|
||||
it('BKARR-001: to front and to back keep the moved ones in their order, and a group together', () => {
|
||||
expect(ids(arrangeElements(sp, ['a'], 'front'))).toEqual(['g1', 'g2', 'b', 'a']);
|
||||
expect(ids(arrangeElements(sp, ['b'], 'back'))).toEqual(['b', 'a', 'g1', 'g2']);
|
||||
// One member moved to the front takes its group along.
|
||||
expect(ids(arrangeElements(sp, ['g1'], 'front'))).toEqual(['a', 'b', 'g1', 'g2']);
|
||||
});
|
||||
|
||||
it('BKARR-002: forward and backward step one place', () => {
|
||||
// A step goes past a whole group, never into it.
|
||||
expect(ids(arrangeElements(sp, ['a'], 'forward'))).toEqual(['g1', 'g2', 'a', 'b']);
|
||||
expect(ids(arrangeElements(sp, ['g2'], 'backward'))).toEqual(['g1', 'g2', 'a', 'b']);
|
||||
expect(arrangeElements(sp, ['b'], 'front')).toBe(sp);
|
||||
expect(ids(arrangeElements(spread([box('a', 0, 0), box('b', 0, 0), box('c', 0, 0)]), ['a'], 'forward'))).toEqual([
|
||||
'b',
|
||||
'a',
|
||||
'c',
|
||||
]);
|
||||
expect(ids(arrangeElements(spread([box('a', 0, 0), box('b', 0, 0), box('c', 0, 0)]), ['c'], 'backward'))).toEqual([
|
||||
'a',
|
||||
'c',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('BKARR-003: inside a group, a member moves among the members only', () => {
|
||||
const grouped = spread([
|
||||
box('a', 0, 0),
|
||||
box('m1', 0, 0, 20, 20, { group: 'g' }),
|
||||
box('m2', 0, 0, 20, 20, { group: 'g' }),
|
||||
box('m3', 0, 0, 20, 20, { group: 'g' }),
|
||||
box('b', 0, 0),
|
||||
]);
|
||||
expect(ids(arrangeWithinGroup(grouped, 'g', ['m1'], 'front'))).toEqual(['a', 'm2', 'm3', 'm1', 'b']);
|
||||
expect(ids(arrangeWithinGroup(grouped, 'g', ['m3'], 'back'))).toEqual(['a', 'm3', 'm1', 'm2', 'b']);
|
||||
expect(arrangeWithinGroup(grouped, 'g', ['m3'], 'front')).toBe(grouped);
|
||||
expect(arrangeWithinGroup(grouped, 'g', ['m3'], 'front').elements[3]?.group).toBe('g');
|
||||
});
|
||||
});
|
||||
|
||||
describe('align and distribute', () => {
|
||||
it('BKARR-004: lines single elements up on the edge of their box', () => {
|
||||
const sp = spread([box('a', 10, 10), box('b', 40, 30)]);
|
||||
const left = alignElements(sp, pageSetup, sp.elements, 'left', 'selection');
|
||||
expect([at(left, 'a')?.x, at(left, 'b')?.x]).toEqual([10, 10]);
|
||||
const bottom = alignElements(sp, pageSetup, sp.elements, 'bottom', 'selection');
|
||||
expect([at(bottom, 'a')?.y, at(bottom, 'b')?.y]).toEqual([30, 30]);
|
||||
// The other axis is left exactly as it was.
|
||||
expect(at(left, 'b')?.y).toBe(30);
|
||||
});
|
||||
|
||||
it('BKARR-005: moves a group as one box, and lines a group up inside itself when it is all that is picked', () => {
|
||||
const sp = spread([
|
||||
box('a', 100, 10),
|
||||
box('g1', 10, 50, 20, 20, { group: 'g' }),
|
||||
box('g2', 40, 60, 20, 20, { group: 'g' }),
|
||||
]);
|
||||
const right = alignElements(sp, pageSetup, sp.elements, 'right', 'selection');
|
||||
// The group's box (10..60) goes to the right edge 120 as one: 60 to the right.
|
||||
expect([at(right, 'g1')?.x, at(right, 'g2')?.x, at(right, 'a')?.x]).toEqual([70, 100, 100]);
|
||||
const inside = alignElements(sp, pageSetup, pick(sp, ['g1', 'g2']), 'top', 'selection');
|
||||
expect([at(inside, 'g1')?.y, at(inside, 'g2')?.y]).toEqual([50, 50]);
|
||||
});
|
||||
|
||||
it('BKARR-006: aligns against the page under an element, and its safe area', () => {
|
||||
const sp = spread([box('r', pageSetup.pageWidth + 90, 10)]);
|
||||
const centred = alignElements(sp, pageSetup, sp.elements, 'center', 'page');
|
||||
expect(at(centred, 'r')?.x).toBe(pageSetup.pageWidth + (pageSetup.pageWidth - 20) / 2);
|
||||
const safe = alignElements(sp, pageSetup, sp.elements, 'top', 'safe');
|
||||
expect(at(safe, 'r')?.y).toBe(pageSetup.safe);
|
||||
const middle = alignElements(sp, pageSetup, sp.elements, 'middle', 'page');
|
||||
expect(at(middle, 'r')?.y).toBe((pageSetup.pageHeight - 20) / 2);
|
||||
});
|
||||
|
||||
it('BKARR-007: spaces units evenly, a group counted once, or by a fixed gap', () => {
|
||||
const sp = spread([
|
||||
box('a', 0, 0),
|
||||
box('g1', 30, 0, 10, 10, { group: 'g' }),
|
||||
box('g2', 45, 0, 10, 10, { group: 'g' }),
|
||||
box('c', 100, 0),
|
||||
]);
|
||||
expect(distributableCount(sp, sp.elements)).toBe(3);
|
||||
const even = distributeElements(sp, sp.elements, 'horizontal');
|
||||
// Span 0..120, sizes 20 + 25 + 20, so two gaps of 27.5; the group moves as one.
|
||||
expect([at(even, 'g1')?.x, at(even, 'g2')?.x, at(even, 'c')?.x]).toEqual([47.5, 62.5, 100]);
|
||||
const fixed = distributeElements(sp, sp.elements, 'horizontal', 5);
|
||||
expect([at(fixed, 'g1')?.x, at(fixed, 'c')?.x]).toEqual([25, 55]);
|
||||
const down = distributeElements(
|
||||
spread([box('a', 0, 0), box('b', 0, 50), box('c', 0, 30)]),
|
||||
spread([box('a', 0, 0), box('b', 0, 50), box('c', 0, 30)]).elements,
|
||||
'vertical',
|
||||
);
|
||||
expect(at(down, 'c')?.y).toBe(25);
|
||||
});
|
||||
|
||||
it('BKARR-008: leaves a page with fewer than three units as it is, and measures a box', () => {
|
||||
const sp = spread([box('a', 0, 0), box('b', 50, 0)]);
|
||||
expect(distributeElements(sp, sp.elements, 'horizontal')).toBe(sp);
|
||||
expect(
|
||||
boundsOfFrames([
|
||||
{ x: 1, y: 2, w: 3, h: 4 },
|
||||
{ x: 10, y: 0, w: 1, h: 1 },
|
||||
]),
|
||||
).toEqual({ x: 1, y: 0, w: 10, h: 6 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { commonGroup, gatherGroups } from './book-groups';
|
||||
import type { BookElement, BookFrame, BookPageSetup, BookSpread } from './book.schema';
|
||||
|
||||
/**
|
||||
* Paint order, alignment and spacing of the elements of a page.
|
||||
*
|
||||
* The `arrange`, `align` and `distribute` operations of the book engine run
|
||||
* these (book-ops-elements.ts), and the Studio's layer buttons, align bar and
|
||||
* layers panel run the same engine, so a press and an assistant's operation
|
||||
* move a page the same way.
|
||||
*
|
||||
* Groups move as one: aligning two groups lines up their boxes, not every
|
||||
* member on the same edge, and distributing spaces the boxes. When every named
|
||||
* element is in the one group, the members line up among themselves, which is
|
||||
* how the inside of a lockup is set.
|
||||
*/
|
||||
|
||||
export type BookArrangeTo = 'front' | 'forward' | 'backward' | 'back';
|
||||
export type BookAlignEdge = 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom';
|
||||
export type BookAlignTo = 'selection' | 'page' | 'safe';
|
||||
export type BookDistributeAxis = 'horizontal' | 'vertical';
|
||||
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
/**
|
||||
* The page in paint order as the things that move as one: a group's members
|
||||
* together (they are next to each other, see `gatherGroups`), every other
|
||||
* element alone.
|
||||
*/
|
||||
function paintUnits(spread: BookSpread): BookElement[][] {
|
||||
const units: BookElement[][] = [];
|
||||
for (const el of gatherGroups(spread).elements) {
|
||||
const last = units[units.length - 1];
|
||||
if (el.group && last?.[0]?.group === el.group) last.push(el);
|
||||
else units.push([el]);
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
/**
|
||||
* The named elements moved in the paint order, keeping their order among
|
||||
* themselves. A group moves whole, and a step forward or backward goes past a
|
||||
* whole group at once, never into it.
|
||||
*/
|
||||
export function arrangeElements(spread: BookSpread, ids: readonly string[], to: BookArrangeTo): BookSpread {
|
||||
const chosen = new Set(ids);
|
||||
const units = paintUnits(spread);
|
||||
const isChosen = (u: BookElement[]) => u.some((e) => chosen.has(e.id));
|
||||
let next: BookElement[][];
|
||||
if (to === 'front') next = [...units.filter((u) => !isChosen(u)), ...units.filter(isChosen)];
|
||||
else if (to === 'back') next = [...units.filter(isChosen), ...units.filter((u) => !isChosen(u))];
|
||||
else {
|
||||
next = units.slice();
|
||||
const step = to === 'forward' ? 1 : -1;
|
||||
const order = next.map((_, i) => i);
|
||||
if (step === 1) order.reverse();
|
||||
for (const i of order) {
|
||||
const here = next[i];
|
||||
const there = next[i + step];
|
||||
if (!here || !there || !isChosen(here) || isChosen(there)) continue;
|
||||
next[i] = there;
|
||||
next[i + step] = here;
|
||||
}
|
||||
}
|
||||
const elements = next.flat();
|
||||
return elements.every((e, i) => e === spread.elements[i]) ? spread : { ...spread, elements };
|
||||
}
|
||||
|
||||
/**
|
||||
* Members of one group moved in the paint order among the group's own members
|
||||
* only, the group staying where it is on the page: what "to front" does to an
|
||||
* element picked inside a group.
|
||||
*/
|
||||
export function arrangeWithinGroup(
|
||||
spread: BookSpread,
|
||||
groupId: string,
|
||||
ids: readonly string[],
|
||||
to: BookArrangeTo,
|
||||
): BookSpread {
|
||||
const slots: number[] = [];
|
||||
spread.elements.forEach((e, i) => {
|
||||
if (e.group === groupId) slots.push(i);
|
||||
});
|
||||
const members = slots.map((i) => spread.elements[i] as BookElement);
|
||||
const reordered = arrangeElements({ ...spread, elements: members.map(withoutGroupId) }, ids, to).elements;
|
||||
const byId = new Map(members.map((m) => [m.id, m]));
|
||||
const elements = spread.elements.slice();
|
||||
slots.forEach((slot, k) => {
|
||||
const moved = reordered[k];
|
||||
if (moved) elements[slot] = byId.get(moved.id) as BookElement;
|
||||
});
|
||||
return elements.every((e, i) => e === spread.elements[i]) ? spread : { ...spread, elements };
|
||||
}
|
||||
|
||||
/** A member taken out of its group for reordering among the members alone, so gathering leaves them be. */
|
||||
function withoutGroupId(el: BookElement): BookElement {
|
||||
const next = { ...el };
|
||||
delete next.group;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The box around frames. */
|
||||
export function boundsOfFrames(frames: readonly BookFrame[]): BookFrame {
|
||||
const x = Math.min(...frames.map((f) => f.x));
|
||||
const y = Math.min(...frames.map((f) => f.y));
|
||||
const right = Math.max(...frames.map((f) => f.x + f.w));
|
||||
const bottom = Math.max(...frames.map((f) => f.y + f.h));
|
||||
return { x, y, w: right - x, h: bottom - y };
|
||||
}
|
||||
|
||||
/** The page a box's centre is on, or its safe area. */
|
||||
function pageBox(spread: BookSpread, page: BookPageSetup, f: BookFrame, safe: boolean): BookFrame {
|
||||
const onRight = spread.role === 'inner' && f.x + f.w / 2 >= page.pageWidth;
|
||||
const x = onRight ? page.pageWidth : 0;
|
||||
const inset = safe ? page.safe : 0;
|
||||
return { x: x + inset, y: inset, w: page.pageWidth - inset * 2, h: page.pageHeight - inset * 2 };
|
||||
}
|
||||
|
||||
/** Where a box goes to line up on an edge of another. */
|
||||
function alignedAt(f: BookFrame, box: BookFrame, edge: BookAlignEdge): { x: number; y: number } {
|
||||
switch (edge) {
|
||||
case 'left':
|
||||
return { x: box.x, y: f.y };
|
||||
case 'center':
|
||||
return { x: box.x + (box.w - f.w) / 2, y: f.y };
|
||||
case 'right':
|
||||
return { x: box.x + box.w - f.w, y: f.y };
|
||||
case 'top':
|
||||
return { x: f.x, y: box.y };
|
||||
case 'middle':
|
||||
return { x: f.x, y: box.y + (box.h - f.h) / 2 };
|
||||
case 'bottom':
|
||||
return { x: f.x, y: box.y + box.h - f.h };
|
||||
}
|
||||
}
|
||||
|
||||
/** What moves as one: a group's named members together, every other element alone. */
|
||||
interface Unit {
|
||||
members: BookElement[];
|
||||
box: BookFrame;
|
||||
}
|
||||
|
||||
function unitsOf(spread: BookSpread, els: readonly BookElement[]): Unit[] {
|
||||
const ids = els.map((e) => e.id);
|
||||
// All in one group: the members are the units, so the inside of a group can be set.
|
||||
const inside = commonGroup(spread, ids) !== null;
|
||||
const byKey = new Map<string, BookElement[]>();
|
||||
for (const el of els) {
|
||||
const key = !inside && el.group ? `g:${el.group}` : `e:${el.id}`;
|
||||
byKey.set(key, [...(byKey.get(key) ?? []), el]);
|
||||
}
|
||||
return [...byKey.values()].map((members) => ({ members, box: boundsOfFrames(members.map((m) => m.frame)) }));
|
||||
}
|
||||
|
||||
/** Every member of a unit moved by the same offset, rounded as the document stores it. */
|
||||
function shifted(unit: Unit, dx: number, dy: number): BookElement[] {
|
||||
// An axis that does not move is left exactly as it was, unrounded or not.
|
||||
return unit.members.map((e) => {
|
||||
const x = dx === 0 ? e.frame.x : round2(e.frame.x + dx);
|
||||
const y = dy === 0 ? e.frame.y : round2(e.frame.y + dy);
|
||||
return { ...e, frame: { ...e.frame, x, y } } as BookElement;
|
||||
});
|
||||
}
|
||||
|
||||
function replaceAll(spread: BookSpread, next: readonly BookElement[]): BookSpread {
|
||||
const byId = new Map(next.map((e) => [e.id, e]));
|
||||
return { ...spread, elements: spread.elements.map((e) => byId.get(e.id) ?? e) };
|
||||
}
|
||||
|
||||
/** The named elements lined up on an edge or a centre line of their box, their page, or its safe area. */
|
||||
export function alignElements(
|
||||
spread: BookSpread,
|
||||
page: BookPageSetup,
|
||||
els: readonly BookElement[],
|
||||
edge: BookAlignEdge,
|
||||
to: BookAlignTo,
|
||||
): BookSpread {
|
||||
const units = unitsOf(spread, els);
|
||||
const selection = boundsOfFrames(units.map((u) => u.box));
|
||||
const next = units.flatMap((u) => {
|
||||
const box = to === 'selection' ? selection : pageBox(spread, page, u.box, to === 'safe');
|
||||
const at = alignedAt(u.box, box, edge);
|
||||
return shifted(u, at.x - u.box.x, at.y - u.box.y);
|
||||
});
|
||||
return replaceAll(spread, next);
|
||||
}
|
||||
|
||||
/** How many units a distribute has: it needs three. */
|
||||
export function distributableCount(spread: BookSpread, els: readonly BookElement[]): number {
|
||||
return unitsOf(spread, els).length;
|
||||
}
|
||||
|
||||
/** Three or more units spaced evenly between the outer two, or by a fixed gap in millimetres. */
|
||||
export function distributeElements(
|
||||
spread: BookSpread,
|
||||
els: readonly BookElement[],
|
||||
axis: BookDistributeAxis,
|
||||
gap?: number,
|
||||
): BookSpread {
|
||||
const units = unitsOf(spread, els);
|
||||
if (units.length < 3) return spread;
|
||||
const across = axis === 'horizontal';
|
||||
const start = (f: BookFrame) => (across ? f.x : f.y);
|
||||
const size = (f: BookFrame) => (across ? f.w : f.h);
|
||||
const sorted = units.slice().sort((a, b) => start(a.box) - start(b.box));
|
||||
const first = sorted[0] as Unit;
|
||||
const last = sorted[sorted.length - 1] as Unit;
|
||||
const total = sorted.reduce((sum, u) => sum + size(u.box), 0);
|
||||
const span = start(last.box) + size(last.box) - start(first.box);
|
||||
const space = gap ?? (span - total) / (sorted.length - 1);
|
||||
let cursor = start(first.box);
|
||||
const next = sorted.flatMap((u) => {
|
||||
const delta = round2(cursor) - start(u.box);
|
||||
cursor += size(u.box) + space;
|
||||
return across ? shifted(u, delta, 0) : shifted(u, 0, delta);
|
||||
});
|
||||
return replaceAll(spread, next);
|
||||
}
|
||||
@@ -1,17 +1,39 @@
|
||||
import { idSchema } from '../common/primitives.schema';
|
||||
import { BOOK_DESIGN_ICONS } from './book-icons';
|
||||
import {
|
||||
BOOK_BINDING_HINT,
|
||||
bookBadgeStyleSchema,
|
||||
bookBadgeVariantSchema,
|
||||
bookCountriesLayoutSchema,
|
||||
bookFontIdSchema,
|
||||
bookFrameStyleSchema,
|
||||
bookIconNameSchema,
|
||||
bookListLayoutSchema,
|
||||
bookMapClipSchema,
|
||||
bookMapSourceSchema,
|
||||
bookMapStyleSchema,
|
||||
bookMetricSchema,
|
||||
bookPhotoFilterSchema,
|
||||
bookPhotoFitSchema,
|
||||
bookPhotoMaskSchema,
|
||||
bookPinStyleSchema,
|
||||
bookRouteArcSchema,
|
||||
bookRouteDashSchema,
|
||||
bookRouteStyleSchema,
|
||||
bookShapeGradientSchema,
|
||||
bookShapeIdSchema,
|
||||
bookStatsLayoutSchema,
|
||||
bookStrokeStyleSchema,
|
||||
bookTextAlignSchema,
|
||||
bookTypesetFontSchema,
|
||||
} from './book-op-enums';
|
||||
import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids';
|
||||
import {
|
||||
BOOK_BADGES,
|
||||
BOOK_METRICS,
|
||||
BOOK_SHAPES,
|
||||
MAX_BOOK_COUNTRIES,
|
||||
bookBadgeElementSchema,
|
||||
bookCountriesElementSchema,
|
||||
bookIconElementSchema,
|
||||
bookListElementSchema,
|
||||
bookMapElementSchema,
|
||||
bookPhotoElementSchema,
|
||||
bookShapeElementSchema,
|
||||
bookStatsElementSchema,
|
||||
bookTextElementSchema,
|
||||
@@ -28,7 +50,9 @@ import { z } from 'zod';
|
||||
*
|
||||
* Same rules as the other operations: strict objects, the fields taken from the
|
||||
* document's own shapes with their defaults removed (an omitted field leaves
|
||||
* the element as it is), every string, array and number bounded.
|
||||
* the element as it is), every string, array and number bounded. The enums
|
||||
* carry a line each on what their values mean (book-op-enums.ts), since the
|
||||
* tool schema is all a model reads of them.
|
||||
*/
|
||||
|
||||
/** A position in millimetres, inside the bounds the document takes. */
|
||||
@@ -40,7 +64,6 @@ const ids30 = z.array(bookElementIdSchema).min(1).max(30);
|
||||
|
||||
export const bookFrameInSchema = z.strictObject({ x: mmIn, y: mmIn, w: sizeIn, h: sizeIn });
|
||||
|
||||
const photo = bookPhotoElementSchema.shape;
|
||||
const text = bookTextElementSchema.shape;
|
||||
const shape = bookShapeElementSchema.shape;
|
||||
const icon = bookIconElementSchema.shape;
|
||||
@@ -55,7 +78,7 @@ const COMMON = { opacity: unit01.optional() };
|
||||
|
||||
/** What the travel kinds share with text: they are typeset. */
|
||||
const TYPESET = {
|
||||
font: map.font.unwrap().optional(),
|
||||
font: bookTypesetFontSchema.optional(),
|
||||
color: map.color.unwrap().optional(),
|
||||
accent: map.accent.unwrap().optional(),
|
||||
textScale: map.textScale.unwrap().optional(),
|
||||
@@ -64,21 +87,21 @@ const TYPESET = {
|
||||
|
||||
const PHOTO_PROPS = {
|
||||
...COMMON,
|
||||
fit: photo.fit.unwrap().optional(),
|
||||
fit: bookPhotoFitSchema.optional(),
|
||||
focalX: unit01.optional(),
|
||||
focalY: unit01.optional(),
|
||||
mask: photo.mask.unwrap().optional(),
|
||||
frameStyle: photo.frameStyle.unwrap().optional(),
|
||||
filter: photo.filter.unwrap().optional(),
|
||||
mask: bookPhotoMaskSchema.optional(),
|
||||
frameStyle: bookFrameStyleSchema.optional(),
|
||||
filter: bookPhotoFilterSchema.optional(),
|
||||
radius: z.number().finite().min(0).max(500).optional(),
|
||||
};
|
||||
/** How a text is set, each field left as it is when not named: the style op's and the page edit's text op's. */
|
||||
export const TEXT_STYLE_FIELDS = {
|
||||
font: text.font.unwrap().optional(),
|
||||
font: bookFontIdSchema.optional(),
|
||||
size: text.size.unwrap().optional(),
|
||||
weight: text.weight.unwrap().optional(),
|
||||
italic: text.italic.unwrap().optional(),
|
||||
align: text.align.unwrap().optional(),
|
||||
align: bookTextAlignSchema.optional(),
|
||||
leading: text.leading.unwrap().optional(),
|
||||
tracking: text.tracking.unwrap().optional(),
|
||||
color: text.color.unwrap().optional(),
|
||||
@@ -87,10 +110,10 @@ const TEXT_PROPS = { ...COMMON, ...TEXT_STYLE_FIELDS };
|
||||
const SHAPE_PROPS = {
|
||||
...COMMON,
|
||||
fill: shape.fill.unwrap().optional(),
|
||||
gradient: shape.gradient.unwrap().optional(),
|
||||
gradient: bookShapeGradientSchema.optional(),
|
||||
stroke: shape.stroke.unwrap().optional(),
|
||||
strokeWidth: z.number().finite().min(0).max(20).optional(),
|
||||
strokeStyle: shape.strokeStyle.unwrap().optional(),
|
||||
strokeStyle: bookStrokeStyleSchema.optional(),
|
||||
radius: z.number().finite().min(0).max(500).optional(),
|
||||
};
|
||||
const ICON_PROPS = {
|
||||
@@ -101,39 +124,39 @@ const ICON_PROPS = {
|
||||
const MAP_PROPS = {
|
||||
...COMMON,
|
||||
...TYPESET,
|
||||
style: map.style.unwrap().optional(),
|
||||
style: bookMapStyleSchema.optional(),
|
||||
showLand: map.showLand.unwrap().optional(),
|
||||
showRoute: map.showRoute.unwrap().optional(),
|
||||
showPins: map.showPins.unwrap().optional(),
|
||||
showLabels: map.showLabels.unwrap().optional(),
|
||||
routeStyle: map.routeStyle.unwrap().optional(),
|
||||
routeArc: map.routeArc.unwrap().optional(),
|
||||
routeDash: map.routeDash.unwrap().optional(),
|
||||
pinStyle: map.pinStyle.unwrap().optional(),
|
||||
clip: map.clip.unwrap().optional(),
|
||||
routeStyle: bookRouteStyleSchema.optional(),
|
||||
routeArc: bookRouteArcSchema.optional(),
|
||||
routeDash: bookRouteDashSchema.optional(),
|
||||
pinStyle: bookPinStyleSchema.optional(),
|
||||
clip: bookMapClipSchema.optional(),
|
||||
fitPadding: map.fitPadding.unwrap().optional(),
|
||||
fitToCountries: map.fitToCountries.unwrap().optional(),
|
||||
};
|
||||
const STATS_PROPS = {
|
||||
...COMMON,
|
||||
...TYPESET,
|
||||
layout: stats.layout.unwrap().optional(),
|
||||
layout: bookStatsLayoutSchema.optional(),
|
||||
showIcons: stats.showIcons.unwrap().optional(),
|
||||
units: stats.units.unwrap().optional(),
|
||||
units: stats.units.unwrap().describe('Kilometres (metric) or miles (imperial).').optional(),
|
||||
};
|
||||
const COUNTRIES_PROPS = {
|
||||
...COMMON,
|
||||
...TYPESET,
|
||||
layout: countries.layout.unwrap().optional(),
|
||||
layout: bookCountriesLayoutSchema.optional(),
|
||||
showOutline: countries.showOutline.unwrap().optional(),
|
||||
showFlag: countries.showFlag.unwrap().optional(),
|
||||
showName: countries.showName.unwrap().optional(),
|
||||
align: countries.align.unwrap().optional(),
|
||||
align: countries.align.unwrap().describe('How the names sit in the frame.').optional(),
|
||||
};
|
||||
const BADGE_PROPS = {
|
||||
...COMMON,
|
||||
...TYPESET,
|
||||
style: badge.style.unwrap().optional(),
|
||||
style: bookBadgeStyleSchema.optional(),
|
||||
showIcon: badge.showIcon.unwrap().optional(),
|
||||
showLabel: badge.showLabel.unwrap().optional(),
|
||||
autoColor: badge.autoColor.unwrap().optional(),
|
||||
@@ -143,13 +166,12 @@ const BADGE_PROPS = {
|
||||
const LIST_PROPS = {
|
||||
...COMMON,
|
||||
...TYPESET,
|
||||
layout: list.layout.unwrap().optional(),
|
||||
layout: bookListLayoutSchema.optional(),
|
||||
showMarks: list.showMarks.unwrap().optional(),
|
||||
proLabel: z.string().max(80).optional(),
|
||||
conLabel: z.string().max(80).optional(),
|
||||
};
|
||||
const metric = z.enum(BOOK_METRICS);
|
||||
const METRICS = z.array(metric).min(1).max(7);
|
||||
const METRICS = z.array(bookMetricSchema).min(1).max(7);
|
||||
|
||||
/**
|
||||
* The look of an element, per kind, for `style`. Values the journey owns (a
|
||||
@@ -159,7 +181,7 @@ const METRICS = z.array(metric).min(1).max(7);
|
||||
export const bookElementPropsSchema = z.discriminatedUnion('kind', [
|
||||
z.strictObject({ kind: z.literal('photo'), ...PHOTO_PROPS }),
|
||||
z.strictObject({ kind: z.literal('text'), ...TEXT_PROPS }),
|
||||
z.strictObject({ kind: z.literal('shape'), shape: z.enum(BOOK_SHAPES).optional(), ...SHAPE_PROPS }),
|
||||
z.strictObject({ kind: z.literal('shape'), shape: bookShapeIdSchema.optional(), ...SHAPE_PROPS }),
|
||||
z.strictObject({ kind: z.literal('icon'), ...ICON_PROPS }),
|
||||
z.strictObject({ kind: z.literal('map'), ...MAP_PROPS }),
|
||||
z.strictObject({ kind: z.literal('stats'), metrics: METRICS.optional(), ...STATS_PROPS }),
|
||||
@@ -189,9 +211,15 @@ export function bindingNamesItsSource(
|
||||
const bindingIn = text.binding
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.refine(bindingNamesItsSource, { message: 'an entry binding needs its entryId, a photo binding its photoId' });
|
||||
.refine(bindingNamesItsSource, { message: 'an entry binding needs its entryId, a photo binding its photoId' })
|
||||
.describe(BOOK_BINDING_HINT);
|
||||
const iso2 = z.string().regex(/^[A-Za-z]{2}$/);
|
||||
const listItem = z.strictObject({ text: z.string().max(400), tone: z.enum(['pro', 'con', 'plain']) });
|
||||
const listItem = z.strictObject({
|
||||
text: z.string().max(400),
|
||||
tone: z
|
||||
.enum(['pro', 'con', 'plain'])
|
||||
.describe('pro or con sets the line in that column with its mark; plain has none.'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Where a map is of: the whole journey or one trip, the two scopes a stored map
|
||||
@@ -210,18 +238,21 @@ export const bookNewElementSchema = z.discriminatedUnion('kind', [
|
||||
z
|
||||
.strictObject({
|
||||
kind: z.literal('text'),
|
||||
preset: z.enum(BOOK_TEXT_PRESETS).optional(),
|
||||
preset: z
|
||||
.enum(BOOK_TEXT_PRESETS)
|
||||
.describe('A Studio text preset: heading, subheading, body, caption, or quote (the large quotation mark).')
|
||||
.optional(),
|
||||
text: z.string().max(2000).optional(),
|
||||
binding: bindingIn.optional(),
|
||||
...TEXT_PROPS,
|
||||
})
|
||||
.refine((v) => v.text !== undefined || v.binding !== undefined, { message: 'give the words or a binding' }),
|
||||
z.strictObject({ kind: z.literal('shape'), shape: z.enum(BOOK_SHAPES), ...SHAPE_PROPS }),
|
||||
z.strictObject({ kind: z.literal('icon'), name: z.enum(BOOK_DESIGN_ICONS), ...ICON_PROPS }),
|
||||
z.strictObject({ kind: z.literal('shape'), shape: bookShapeIdSchema, ...SHAPE_PROPS }),
|
||||
z.strictObject({ kind: z.literal('icon'), name: bookIconNameSchema, ...ICON_PROPS }),
|
||||
z.strictObject({
|
||||
kind: z.literal('map'),
|
||||
scope: mapScope,
|
||||
source: z.enum(['vector', 'relief', 'satellite']).optional(),
|
||||
source: bookMapSourceSchema.optional(),
|
||||
...MAP_PROPS,
|
||||
}),
|
||||
z.strictObject({ kind: z.literal('stats'), metrics: METRICS, ...STATS_PROPS }),
|
||||
@@ -232,7 +263,7 @@ export const bookNewElementSchema = z.discriminatedUnion('kind', [
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal('badge'),
|
||||
variant: z.enum(BOOK_BADGES),
|
||||
variant: bookBadgeVariantSchema,
|
||||
entryId: idSchema.optional(),
|
||||
code: z.string().max(24).optional(),
|
||||
text: z.string().max(200).optional(),
|
||||
@@ -274,20 +305,27 @@ export const BOOK_ELEMENT_OP_SCHEMAS = [
|
||||
z.strictObject({
|
||||
op: z.literal('arrange'),
|
||||
elementIds: ids30,
|
||||
to: z.enum(['front', 'forward', 'backward', 'back']),
|
||||
to: z
|
||||
.enum(['front', 'forward', 'backward', 'back'])
|
||||
.describe('front and back go to either end of the paint order; forward and backward one step.'),
|
||||
}),
|
||||
/** Line elements up on an edge or a centre line of the selection, their page, or its safe area. */
|
||||
z.strictObject({
|
||||
op: z.literal('align'),
|
||||
elementIds: ids30,
|
||||
edge: z.enum(['left', 'center', 'right', 'top', 'middle', 'bottom']),
|
||||
to: z.enum(['selection', 'page', 'safe']).default('selection'),
|
||||
edge: z
|
||||
.enum(['left', 'center', 'right', 'top', 'middle', 'bottom'])
|
||||
.describe('The edge or centre line the elements line up on.'),
|
||||
to: z
|
||||
.enum(['selection', 'page', 'safe'])
|
||||
.describe('Line up within the selection, the page, or its safe area.')
|
||||
.default('selection'),
|
||||
}),
|
||||
/** Space three or more elements evenly, or by a fixed gap in millimetres. */
|
||||
z.strictObject({
|
||||
op: z.literal('distribute'),
|
||||
elementIds: z.array(bookElementIdSchema).min(3).max(30),
|
||||
axis: z.enum(['horizontal', 'vertical']),
|
||||
axis: z.enum(['horizontal', 'vertical']).describe('Space them out across the page or down it.'),
|
||||
gap: z.number().finite().min(0).max(200).optional(),
|
||||
}),
|
||||
/** Lock or unlock elements: the one operation a locked element accepts. */
|
||||
@@ -300,7 +338,11 @@ export const BOOK_ELEMENT_OP_SCHEMAS = [
|
||||
dy: z.number().finite().min(-500).max(500).optional(),
|
||||
}),
|
||||
/** The Studio's fill: a photo to the bleed of its page or the spread, unturned, cover, at the back. */
|
||||
z.strictObject({ op: z.literal('fill'), elementId: bookElementIdSchema, scope: z.enum(['page', 'spread']) }),
|
||||
z.strictObject({
|
||||
op: z.literal('fill'),
|
||||
elementId: bookElementIdSchema,
|
||||
scope: z.enum(['page', 'spread']).describe('Fill its own page to the bleed, or both pages of the spread.'),
|
||||
}),
|
||||
/** Copy elements of another page onto this one, with new ids; photos are checked against the gallery. */
|
||||
z.strictObject({ op: z.literal('copy_from'), spreadId: bookSpreadIdSchema, elementIds: ids30 }),
|
||||
/** Bring travel figures up to date with the journey: the named elements, or every one on the page. */
|
||||
|
||||
@@ -156,13 +156,18 @@ describe('add', () => {
|
||||
expect(addRefusal({ kind: 'text', binding: { source: 'photo.caption' }, text: 'x' })).toBe('invalid_op');
|
||||
});
|
||||
|
||||
it('BOOKEL-003: adds a rule as a hairline, a banner wide, and an icon from the shelf only', () => {
|
||||
it('BOOKEL-003: adds a rule as a hairline, a banner wide, and any icon the Studio offers, by lucide name only', () => {
|
||||
expect(added({ kind: 'shape', shape: 'line' })).toMatchObject({ shape: 'rect', frame: { w: 105, h: 0.5 } });
|
||||
const banner = added({ kind: 'shape', shape: 'banner-ribbon', fill: null });
|
||||
expect(banner).toMatchObject({ fill: null, frame: { w: 107.1, h: 34.65 } });
|
||||
const icon = added({ kind: 'icon', name: 'Compass', color: '#c2410c' });
|
||||
expect(icon).toMatchObject({ name: 'Compass', color: '#c2410c', frame: { w: 37.8, h: 37.8 } });
|
||||
expect(addRefusal({ kind: 'icon', name: 'NotAnIcon' } as unknown as NewElement)).toBe('invalid_op');
|
||||
// Past the travel shelf: the whole library, its old names too, as the icon tab lists it.
|
||||
expect(added({ kind: 'icon', name: 'Pizza' })).toMatchObject({ kind: 'icon', name: 'Pizza' });
|
||||
expect(added({ kind: 'icon', name: 'AlertTriangle' })).toMatchObject({ name: 'AlertTriangle' });
|
||||
for (const name of ['NotAnIcon', 'PizzaIcon', 'pizza', 'Icon']) {
|
||||
expect(addRefusal({ kind: 'icon', name } as unknown as NewElement), name).toBe('invalid_op');
|
||||
}
|
||||
});
|
||||
|
||||
it('BOOKEL-004: adds the travel kinds from the journey, and refuses them without its figures', () => {
|
||||
|
||||
@@ -129,13 +129,17 @@ describe('book level and the page in words', () => {
|
||||
return { ...doc, spreads: [doc.spreads[0] as BookSpread, ...inner, doc.spreads[4] as BookSpread] };
|
||||
}
|
||||
|
||||
it('BOOKQA-009: finds more than two typefaces in a book, and says so once', () => {
|
||||
const fonts = ['sans', 'serif', 'bebas'].map((font, i) => [
|
||||
it('BOOKQA-009: finds more than three typefaces in a book, and says so once', () => {
|
||||
const fonts = ['sans', 'serif', 'bebas', 'playfair'].map((font, i) => [
|
||||
text(`t-${i}`, 'Hi', { x: 230, y: 20, w: 60, h: 10 }, { font }),
|
||||
]);
|
||||
const list = bookLevelFindings(bookOf(fonts));
|
||||
expect(list.filter((f) => f.code === 'font_mix')).toHaveLength(1);
|
||||
expect(bookLevelFindings(bookOf(fonts.slice(0, 2)))).toEqual([]);
|
||||
expect(list.find((f) => f.code === 'font_mix')?.message).toMatch(/4 typefaces .*keep to three/);
|
||||
// A display face, a body face and one more is a book, not a sampler.
|
||||
const mixOf = (pages: typeof fonts) => bookLevelFindings(bookOf(pages)).filter((f) => f.code === 'font_mix');
|
||||
expect(mixOf(fonts.slice(0, 3))).toEqual([]);
|
||||
expect(mixOf(fonts.slice(0, 2))).toEqual([]);
|
||||
});
|
||||
|
||||
it('BOOKQA-010: notes colours outside the book own and the same page three times running', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { compareCodeUnits } from '../common/compare';
|
||||
import { contrastOfLuminance, deltaE, greyLuminance, luminance, parseHex, requiredContrast } from './book-colour';
|
||||
import { visibleSpread } from './book-groups';
|
||||
import type { BookPhotoInfo } from './book-ops-core';
|
||||
import { BOOK_OP_LIMITS, type BookFinding } from './book-ops.schema';
|
||||
import type { BookDocument, BookElement, BookFrame, BookPageSetup, BookPhotoElement, BookSpread } from './book.schema';
|
||||
@@ -298,7 +299,7 @@ export function designFindings(
|
||||
page: BookPageSetup,
|
||||
photos?: readonly BookPhotoInfo[],
|
||||
): BookFinding[] {
|
||||
const ctx: Ctx = { spread, page, photos, out: [] };
|
||||
const ctx: Ctx = { spread: visibleSpread(spread), page, photos, out: [] };
|
||||
contrastAndBusy(ctx);
|
||||
smallText(ctx);
|
||||
crowded(ctx);
|
||||
@@ -340,16 +341,25 @@ function signatureOf(spread: BookSpread): string {
|
||||
return spread.elements.map((e) => `${e.kind}:${cm(e.frame.x)},${cm(e.frame.y)},${cm(e.frame.w)}`).join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* How many typefaces a book may be set in before it reads as a type sampler:
|
||||
* a display face, a body face and one more, a script laid over a title or a
|
||||
* face for figures. Two was the rule while the book had seven faces and no
|
||||
* scripts, and it told every lively book that set its title apart to stop.
|
||||
* Once fonts carry roles, script and mono faces will not count towards it.
|
||||
*/
|
||||
const MAX_BOOK_FAMILIES = 3;
|
||||
|
||||
/** Findings about the book as a whole: typefaces, colours, and the same page three times running. */
|
||||
export function bookLevelFindings(doc: BookDocument): BookFinding[] {
|
||||
const out: BookFinding[] = [];
|
||||
const families = familiesOf(doc);
|
||||
if (families.length > 2) {
|
||||
if (families.length > MAX_BOOK_FAMILIES) {
|
||||
out.push({
|
||||
code: 'font_mix',
|
||||
severity: 'warn',
|
||||
elementIds: [],
|
||||
message: `the book is set in ${families.length} typefaces (${families.join(', ')}); keep to two`,
|
||||
message: `the book is set in ${families.length} typefaces (${families.join(', ')}); keep to three: a display face, a body face and one more`,
|
||||
});
|
||||
}
|
||||
const colours = [...coloursOf(doc)].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bookVisiblePart, sortBookFindings } from './book-findings-design';
|
||||
import { visibleSpread } from './book-groups';
|
||||
import type { BookPhotoInfo } from './book-ops-core';
|
||||
import type { BookFinding } from './book-ops.schema';
|
||||
import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread, BookTextElement } from './book.schema';
|
||||
@@ -145,46 +146,121 @@ const placedPhotos = (spread: BookSpread) => spread.elements.filter((e): e is Bo
|
||||
/**
|
||||
* A photo under a quarter of the size of the one it lies on is an inset: the
|
||||
* auto layout sets a small picture over the hero on purpose. Context, not a
|
||||
* fault; two pictures of a size on top of each other are.
|
||||
* fault; two pictures of a size on top of each other are. The same holds for
|
||||
* a small line of type set over display type, never over running text.
|
||||
*/
|
||||
const INSET_RATIO = 0.25;
|
||||
|
||||
/**
|
||||
* Under this many degrees off straight an element reads as unturned; past it,
|
||||
* as laid down at an angle on purpose. Above the scrapbook style's own tilt of
|
||||
* 2 degrees, so a page of that style is not layered by its look alone.
|
||||
*/
|
||||
const TURNED_DEG = 3;
|
||||
|
||||
/** Past this share of the smaller one covered, two photos or texts are an accident however they are turned or framed. */
|
||||
const LAYERED_MAX_SHARE = 0.5;
|
||||
|
||||
/** Display type, which a small line may be set over: a few words at the heading floor of the styles or larger. */
|
||||
const DISPLAY_MAX_CHARS = 40;
|
||||
const DISPLAY_MIN_PT = 14;
|
||||
|
||||
/** The most overlap findings one page reports, the severe ones first. */
|
||||
const MAX_OVERLAPS = 6;
|
||||
|
||||
/** How far an element is turned from straight, 0 to 180 degrees, whichever way and however many turns. */
|
||||
function turnOf(el: BookElement): number {
|
||||
const rest = el.rotation % 360;
|
||||
const turned = rest < 0 ? rest + 360 : rest;
|
||||
return Math.min(turned, 360 - turned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two elements on top of each other read as layered on purpose: either
|
||||
* of them turned, or the upper one a photo in a frame (a polaroid, a border, a
|
||||
* shadow), which is how a print laid on a page looks. `upper` is the one
|
||||
* painted later.
|
||||
*/
|
||||
function layered(lower: BookElement, upper: BookElement): boolean {
|
||||
if (turnOf(lower) >= TURNED_DEG || turnOf(upper) >= TURNED_DEG) return true;
|
||||
return upper.kind === 'photo' && upper.frameStyle !== 'none';
|
||||
}
|
||||
|
||||
/** Whether a text is display type: a few words, set at heading size or larger. */
|
||||
function isDisplay(el: BookElement): boolean {
|
||||
return el.kind === 'text' && el.text.trim().length <= DISPLAY_MAX_CHARS && el.size >= DISPLAY_MIN_PT;
|
||||
}
|
||||
|
||||
/** Whether the smaller of two is an inset on the larger: any small photo, a small text only over display type. */
|
||||
function isInset(small: BookElement, large: BookElement, smaller: number, larger: number): boolean {
|
||||
if (smaller >= larger * INSET_RATIO) return false;
|
||||
return small.kind === 'photo' || isDisplay(large);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two photos or two texts on top of each other, `a` painted under `b`.
|
||||
*
|
||||
* Severe when it looks like an accident: both straight, of a size, the upper
|
||||
* one unframed, or more than half of the smaller one covered whatever the
|
||||
* look. A small one set over a large one, a turned or framed picture laid
|
||||
* partly over another, is how a lively page is made, and is said as context.
|
||||
* A title over a photo or a photo over a shape is never compared at all:
|
||||
* different kinds layered are a design.
|
||||
*/
|
||||
function overlapFinding(a: BookElement, b: BookElement, ba: Box, bb: Box, shared: number): BookFinding {
|
||||
const [small, large] = area(ba) <= area(bb) ? [a, b] : [b, a];
|
||||
const smaller = Math.min(area(ba), area(bb));
|
||||
const pct = Math.round((shared / Math.max(smaller, 1)) * 100);
|
||||
if (a.kind === 'photo' && smaller < Math.max(area(ba), area(bb)) * INSET_RATIO) {
|
||||
const share = shared / Math.max(smaller, 1);
|
||||
const pct = Math.round(share * 100);
|
||||
const what = a.kind === 'photo' ? 'photo' : 'text';
|
||||
if (isInset(small, large, smaller, Math.max(area(ba), area(bb)))) {
|
||||
return {
|
||||
code: 'overlap',
|
||||
severity: 'info',
|
||||
elementIds: [small.id, large.id],
|
||||
message: `photo ${small.id} is set as an inset over ${large.id} (${pct}% of it on the larger one)`,
|
||||
message: `${what} ${small.id} is set as an inset over ${large.id} (${pct}% of it on the larger one)`,
|
||||
};
|
||||
}
|
||||
if (share <= LAYERED_MAX_SHARE && layered(a, b)) {
|
||||
return {
|
||||
code: 'overlap',
|
||||
severity: 'info',
|
||||
elementIds: [a.id, b.id],
|
||||
message: `${what}s ${a.id} and ${b.id} are layered on purpose, turned or framed (${pct}% of the smaller one covered); check nothing that matters is hidden`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: 'overlap',
|
||||
severity: 'severe',
|
||||
elementIds: [a.id, b.id],
|
||||
message: `${a.kind === 'photo' ? 'photos' : 'texts'} ${a.id} and ${b.id} overlap (${pct}% of the smaller one)`,
|
||||
message: `${what}s ${a.id} and ${b.id} overlap (${pct}% of the smaller one)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every pair of photos or texts on top of each other, reported severe first up
|
||||
* to the cap, so pairs layered on purpose never crowd out an accident.
|
||||
*/
|
||||
function overlaps(ctx: Ctx): void {
|
||||
const content = ctx.spread.elements.filter((e) => e.kind === 'photo' || e.kind === 'text');
|
||||
let found = 0;
|
||||
for (let i = 0; i < content.length && found < 6; i++) {
|
||||
for (let j = i + 1; j < content.length && found < 6; j++) {
|
||||
const found: BookFinding[] = [];
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
for (let j = i + 1; j < content.length; j++) {
|
||||
const a = content[i] as BookElement;
|
||||
const b = content[j] as BookElement;
|
||||
if (a.kind !== b.kind) continue;
|
||||
// Set over each other inside one group on purpose: a lockup, a framed caption.
|
||||
if (a.group !== undefined && a.group === b.group) continue;
|
||||
const ba = elementBox(a);
|
||||
const bb = elementBox(b);
|
||||
const shared = overlapArea(ba, bb);
|
||||
if (shared < 4 || shared < Math.min(area(ba), area(bb)) * 0.05) continue;
|
||||
found += 1;
|
||||
add(ctx, overlapFinding(a, b, ba, bb, shared));
|
||||
found.push(overlapFinding(a, b, ba, bb, shared));
|
||||
}
|
||||
}
|
||||
const severe = found.filter((f) => f.severity === 'severe');
|
||||
const context = found.filter((f) => f.severity !== 'severe');
|
||||
for (const f of [...severe, ...context].slice(0, MAX_OVERLAPS)) add(ctx, f);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -420,7 +496,8 @@ export function spreadFindings(
|
||||
photos?: readonly BookPhotoInfo[],
|
||||
): BookFinding[] {
|
||||
const ctx: Ctx = {
|
||||
spread,
|
||||
// A hidden element is not on the page, so nothing about it is a fault of the page.
|
||||
spread: visibleSpread(spread),
|
||||
page,
|
||||
W: page.pageWidth * (spread.role === 'inner' ? 2 : 1),
|
||||
H: page.pageHeight,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { bookElementIdSchema } from './book-op-ids';
|
||||
import { BOOK_LAYER_NAME_MAX } from './book.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The layer operations of a book page: group, ungroup, name and hide. They
|
||||
* join the page operations of book-ops.schema.ts beside the element ones, run
|
||||
* in the one engine (book-ops-groups.ts) the Studio's layers panel and its
|
||||
* Ctrl+G use too.
|
||||
*/
|
||||
|
||||
/** A group's id as the page has it (`g-...`), never invented. */
|
||||
export const bookGroupIdSchema = z.string().regex(/^[\w-]{1,40}$/);
|
||||
|
||||
const layerName = z.string().max(BOOK_LAYER_NAME_MAX);
|
||||
|
||||
export const BOOK_GROUP_OP_SCHEMAS = [
|
||||
/**
|
||||
* Make two or more elements one group, picked, moved, aligned and arranged
|
||||
* as one. Elements already in a group leave it for the new one.
|
||||
*/
|
||||
z.strictObject({
|
||||
op: z.literal('group'),
|
||||
elementIds: z.array(bookElementIdSchema).min(2).max(30),
|
||||
name: layerName
|
||||
.describe('What the group is called in the layers panel, such as "Title lockup". Optional.')
|
||||
.optional(),
|
||||
}),
|
||||
/** Put a group's members back on their own, where they are. */
|
||||
z.strictObject({ op: z.literal('ungroup'), groupId: bookGroupIdSchema }),
|
||||
/** Name an element or a group in the layers panel; an empty name clears it. */
|
||||
z
|
||||
.strictObject({
|
||||
op: z.literal('name'),
|
||||
elementId: bookElementIdSchema.optional(),
|
||||
groupId: bookGroupIdSchema.optional(),
|
||||
name: layerName,
|
||||
})
|
||||
.refine((v) => (v.elementId === undefined) !== (v.groupId === undefined), {
|
||||
message: 'name an elementId or a groupId, not both',
|
||||
}),
|
||||
/** Hide elements (kept on the page's layer list, left out of the page and its print) or show them again. */
|
||||
z.strictObject({
|
||||
op: z.literal('hide'),
|
||||
elementIds: z.array(bookElementIdSchema).min(1).max(30),
|
||||
hidden: z.boolean(),
|
||||
}),
|
||||
] as const;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user