mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 473132fc07 | |||
| e7a5316990 | |||
| 670c841866 | |||
| 338548bf7f | |||
| a94464a953 | |||
| 88e6f332d3 | |||
| d07b8ba56e | |||
| e9f7d5e77b | |||
| 9a7c520106 | |||
| 5ccd4674ac | |||
| d1b8cb20a3 | |||
| dd2431db97 | |||
| 746f53ff7e | |||
| 7f9a4da52a | |||
| 8043b7952d | |||
| e198392060 | |||
| deea669e28 | |||
| d6b624e400 | |||
| 263d650460 | |||
| a3fc696510 | |||
| 9f6f62c6ae | |||
| a70ff750a6 | |||
| 815a523d1c | |||
| 30382dc366 |
@@ -102,7 +102,7 @@
|
||||
"components/Studio/BookSheetsView.tsx": 2,
|
||||
"components/Studio/SpreadView.tsx": 3,
|
||||
"components/Studio/StudioElementsPanel.tsx": 8,
|
||||
"components/Studio/StudioInspector.tsx": 2,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
"components/Studio/StudioShell.tsx": 2,
|
||||
"components/Studio/StudioSidebar.tsx": 2,
|
||||
"components/Studio/TravelElements.tsx": 5,
|
||||
|
||||
@@ -22,7 +22,12 @@ vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
|
||||
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
||||
const SETTINGS: AiAdminSettings = {
|
||||
policy: 'instance_or_user',
|
||||
dailyTurns: 50,
|
||||
webSearch: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
};
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
@@ -102,6 +107,17 @@ describe('the connection and the assistant', () => {
|
||||
expect(screen.getByText('Keep chats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-001b: the web search is on as it ships and is switched off on the assistant tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('button', { name: 'AI Assistant' }));
|
||||
const toggle = screen.getByRole('button', { name: 'Web search' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText(/through the search of the AI provider it talks to/)).toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, webSearch: false });
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Check, Loader2, Sparkles } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
@@ -136,7 +137,7 @@ function InstanceConnection({ A }: { A: AiAdmin }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
|
||||
/** The assistant's own limits: turns a day while the instance pays, how long chats are kept, whether it searches the web. */
|
||||
function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
@@ -170,6 +171,19 @@ function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||
size="sm"
|
||||
/>
|
||||
</EditorField>
|
||||
{A.settings && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-body font-medium text-content">{t('assistant.admin.webSearch')}</span>
|
||||
<ToggleSwitch
|
||||
on={A.settings.webSearch}
|
||||
onToggle={() => A.setWebSearch(!A.settings?.webSearch)}
|
||||
label={t('assistant.admin.webSearch')}
|
||||
/>
|
||||
</div>
|
||||
<SettingsHint>{t('assistant.admin.webSearchHint')}</SettingsHint>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,12 @@ const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(TranslationProvider, null, children)
|
||||
);
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
||||
const SETTINGS: AiAdminSettings = {
|
||||
policy: 'instance_or_user',
|
||||
dailyTurns: 50,
|
||||
webSearch: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
};
|
||||
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
|
||||
@@ -40,7 +40,7 @@ function retentionOf(value: string): AiRetention {
|
||||
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
|
||||
}
|
||||
|
||||
/** Policy, daily limit and retention: each change is saved at once and taken back when the server refuses it. */
|
||||
/** Policy, daily limit, retention and web search: each change is saved at once and taken back when the server refuses it. */
|
||||
function useAdminSettings(onSaved: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
@@ -92,6 +92,7 @@ function useAdminSettings(onSaved: () => void) {
|
||||
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
|
||||
],
|
||||
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
|
||||
setWebSearch: (webSearch: boolean) => void save({ webSearch }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -103,11 +103,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
<div className="min-w-0 flex-1">
|
||||
{thread.isRunning ? (
|
||||
// While it works, the line over the title says what it does right now.
|
||||
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption font-medium">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="trek-assistant-pulse h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
|
||||
/>
|
||||
<p role="status" className="m-0 flex min-w-0 items-center text-caption font-medium">
|
||||
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import AssistantFollowUps from './AssistantFollowUps';
|
||||
import AssistantMarkdown from './AssistantMarkdown';
|
||||
import AssistantMentionText from './AssistantMentionText';
|
||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||
import AssistantRemembered from './AssistantRemembered';
|
||||
import AssistantShareDialog from './AssistantShareDialog';
|
||||
import AssistantSources from './AssistantSources';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
@@ -129,6 +131,10 @@ function Answer({ message }: { message: StoredMessage }) {
|
||||
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||
}
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
|
||||
if (block.kind === 'remembered') {
|
||||
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
|
||||
}
|
||||
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
|
||||
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { AssistantFoundPlace } from '@trek/shared';
|
||||
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { Check, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { createElement, type ReactNode, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { getCategoryIcon } from '../shared/categoryIcons';
|
||||
import { PopoverListPanel } from '../shared/dialogParts';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { usePopoverList } from '../shared/usePopoverList';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
||||
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
|
||||
@@ -16,110 +14,14 @@ type Strip = ReturnType<typeof useAssistantPlaceStrip>;
|
||||
const ACTION =
|
||||
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
|
||||
|
||||
/** "+ Day 3" for the day in focus, a list of the trip's days beside it; "Pick a day" when no day is in focus. */
|
||||
function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||
/** "Add to trip": opens the planner's place form filled in with the place, its details loading beside it. */
|
||||
function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { days, focusDay, disabled, blockedKey, adding } = strip;
|
||||
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
|
||||
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
|
||||
});
|
||||
const { open, toggle, close, triggerRef, listId } = list;
|
||||
const height = tall ? 'h-10' : 'h-7';
|
||||
const busy = adding === place.ref;
|
||||
|
||||
const pick = (dayId: number) => {
|
||||
close();
|
||||
void strip.suggest(place, dayId);
|
||||
};
|
||||
|
||||
const buttons = (
|
||||
<span className="inline-flex min-w-0 gap-px">
|
||||
{focusDay ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void strip.suggest(place, focusDay.id)}
|
||||
aria-label={t('assistant.place.addTo', { day: focusDay.label })}
|
||||
className={`${ACTION} ${height} rounded-e-none px-2`}
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle size={12} aria-hidden="true" className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
)}
|
||||
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
|
||||
</button>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
aria-label={t('assistant.place.pickDay')}
|
||||
className={`${ACTION} ${height} rounded-s-none px-1.5`}
|
||||
>
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
className={`${ACTION} ${height} px-2`}
|
||||
>
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
<span className="truncate">{t('assistant.place.pickDay')}</span>
|
||||
<ChevronDown size={12} aria-hidden="true" className="text-content-faint" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{blockedKey ? (
|
||||
<Tooltip label={t(blockedKey)} placement="top">
|
||||
{/* A disabled button gets no pointer events; the wrapper carries the reason. */}
|
||||
<span className="inline-flex min-w-0">{buttons}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
buttons
|
||||
)}
|
||||
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
|
||||
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day) => {
|
||||
const current = day.id === focusDay?.id;
|
||||
return (
|
||||
<Tooltip key={day.id} label={day.label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(day.id)}
|
||||
aria-label={day.label}
|
||||
aria-pressed={current}
|
||||
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="text-body font-semibold tabular-nums">{day.n}</span>
|
||||
{day.weekday && (
|
||||
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
|
||||
{day.weekday}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverListPanel>
|
||||
</>
|
||||
<button type="button" onClick={() => strip.open(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
<span className="truncate">{t('assistant.place.add')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,7 +121,7 @@ function PlaceAction({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
|
||||
return strip.canAdd ? <AddButton place={place} strip={strip} tall={tall} /> : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,9 +129,10 @@ function PlaceAction({
|
||||
* with the category it most likely belongs in on it (or that category's icon
|
||||
* on its colour), its name, address and rating, whether it is open now while
|
||||
* the trip runs, how far it is from the last stop of the day in focus, and one
|
||||
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
|
||||
* the card shows it on the map, and resting the pointer on it pins it there
|
||||
* for a moment.
|
||||
* way on: "Add to trip" for a new place, "On map" for one the trip has. A click
|
||||
* on a new place opens the place form filled in with it (one the trip has, or
|
||||
* any where nothing can be added, shows on the map), and resting the pointer on
|
||||
* it pins it there for a moment.
|
||||
*/
|
||||
export default function AssistantPlaceCard({
|
||||
place,
|
||||
@@ -259,7 +162,7 @@ export default function AssistantPlaceCard({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => strip.show(place)}
|
||||
onClick={() => (place.inTripPlaceId === null && strip.canAdd ? strip.open(place) : strip.show(place))}
|
||||
onFocus={() => strip.hover(place)}
|
||||
onBlur={() => strip.hover(null)}
|
||||
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
* FE-AI-PLACES-001 to FE-AI-PLACES-010
|
||||
*
|
||||
* The strip of places a search found: what a card says, that its picture only
|
||||
* ever comes from TREK's photo proxy, "+ Day N" for the day in focus or a
|
||||
* picked one, why it waits while a change is undecided, the map's part, and
|
||||
* no "+ Day" in a journey's chat.
|
||||
* ever comes from TREK's photo proxy, "Add to trip" opening the place form on
|
||||
* the day in focus, the map's part, and no "Add to trip" in a journey's chat.
|
||||
*/
|
||||
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -67,7 +66,6 @@ const answer = (parts: AssistantMessage['parts'] = []): AssistantMessage => ({
|
||||
parts,
|
||||
});
|
||||
|
||||
const addFoundPlace = vi.fn(async () => {});
|
||||
const requestCamera = vi.fn();
|
||||
const setPreviewPin = vi.fn();
|
||||
const onRevealMap = vi.fn();
|
||||
@@ -83,7 +81,6 @@ function seed({ focusDayId = 31, messages = [] as AssistantMessage[] } = {}) {
|
||||
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
|
||||
});
|
||||
useAssistantStore.setState({
|
||||
addFoundPlace,
|
||||
requestCamera,
|
||||
setPreviewPin,
|
||||
byTrip: {
|
||||
@@ -165,34 +162,30 @@ describe('AssistantPlaceStrip', () => {
|
||||
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-004: "+ Day 1" suggests the place for the day in focus', () => {
|
||||
it('FE-AI-PLACES-004: "Add to trip" opens the place form with the place, on the day in focus', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add to/ }));
|
||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 31);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to trip' }));
|
||||
expect(onRevealMap).toHaveBeenCalled();
|
||||
expect(useAssistantStore.getState().placeDraft).toMatchObject({ tripId: TRIP, dayId: 31, place: { ref: 's1:0' } });
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-005: another day is picked from the list, and without a focus the list is the only way', () => {
|
||||
it('FE-AI-PLACES-005: a click on a new place opens the form too, without a day when none is in focus', () => {
|
||||
seed({ focusDayId: 0 });
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Pick a day/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Nov 3/ }));
|
||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 32);
|
||||
fireEvent.click(screen.getByText('Trattoria s1:0'));
|
||||
expect(useAssistantStore.getState().placeDraft).toMatchObject({ dayId: null, place: { ref: 's1:0' } });
|
||||
expect(requestCamera).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-006: waits while a change is undecided, and offline', () => {
|
||||
it('FE-AI-PLACES-006: opening the form waits for nothing: an undecided change and offline leave it on', () => {
|
||||
const waiting = answer([
|
||||
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
|
||||
]);
|
||||
seed({ messages: [waiting] });
|
||||
const { unmount } = renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
seed();
|
||||
setForcedOffline(true);
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Add to trip' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
|
||||
@@ -234,15 +227,14 @@ describe('AssistantPlaceStrip', () => {
|
||||
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-010: a journey chat lists the places but offers no "+ Day" while the trip is on screen', () => {
|
||||
// The journey key never matches the planner's trip, so the trip check already hides "+ Day";
|
||||
it('FE-AI-PLACES-010: a journey chat lists the places but offers no "Add to trip" while the trip is on screen', () => {
|
||||
// The journey key never matches the planner's trip, so the trip check already hides "Add to trip";
|
||||
// the scope check in the hook is defence in depth for the day trips and journeys share keys.
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 }, answer(), {
|
||||
tripId: assistantChatKey({ kind: 'journey', journeyId: TRIP }),
|
||||
});
|
||||
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Add to/ })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Pick a day/ })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* FE-AI-REMEMBER-001 to FE-AI-REMEMBER-002
|
||||
*
|
||||
* A note the assistant kept on its own: a quiet line naming it, the trip
|
||||
* notes read again while the answer is written, and the notes a tap away.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import AssistantRemembered from './AssistantRemembered';
|
||||
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
const SETTINGS = useSettingsStore.getState();
|
||||
|
||||
function renderLine(live: boolean) {
|
||||
const view: AssistantView = { tripId: 4, variant: 'window', density: 'compact' };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={view}>
|
||||
<AssistantRemembered note="We eat vegetarian" live={live} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState(SETTINGS, true);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AssistantRemembered', () => {
|
||||
it('FE-AI-REMEMBER-001: names the note, and reads the notes again only while the answer is written', () => {
|
||||
const loadSettings = vi.fn(async () => {});
|
||||
useSettingsStore.setState({ loadSettings });
|
||||
const { unmount } = renderLine(false);
|
||||
expect(screen.getByText('We eat vegetarian')).toBeInTheDocument();
|
||||
expect(screen.getByText('Noted for this trip:')).toBeInTheDocument();
|
||||
expect(loadSettings).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
renderLine(true);
|
||||
expect(loadSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-REMEMBER-002: the pencil opens the trip notes', () => {
|
||||
useSettingsStore.setState({ loadSettings: vi.fn(async () => {}) });
|
||||
renderLine(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit trip notes' }));
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BookmarkCheck, Pencil } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantMemoryDialog from './AssistantMemoryDialog';
|
||||
import { useAssistantScope } from './assistantViewContext';
|
||||
|
||||
/**
|
||||
* A note the assistant kept in the trip notes on its own, as a quiet line in
|
||||
* its answer, with the notes a tap away to change or drop it. Kept while the
|
||||
* answer is written, the notes on this device are read again, so the dialog
|
||||
* never saves over the note with what it held before.
|
||||
*/
|
||||
export default function AssistantRemembered({ note, live }: { note: string; live: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const scope = useAssistantScope();
|
||||
const loadSettings = useSettingsStore((s) => s.loadSettings);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (live) void loadSettings();
|
||||
}, [live, loadSettings]);
|
||||
|
||||
const tripId = scope.kind === 'trip' ? scope.tripId : null;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-surface-secondary px-2.5 py-1.5 text-caption">
|
||||
<BookmarkCheck size={13} strokeWidth={2.2} aria-hidden="true" className="flex-none text-success" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-semibold text-content-secondary">{t('assistant.memory.kept')}</span>{' '}
|
||||
<bdi className="text-content-muted">{note}</bdi>
|
||||
</span>
|
||||
{tripId !== null && (
|
||||
<Tooltip label={t('assistant.memory.edit')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label={t('assistant.memory.edit')}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-md text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<Pencil size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{editing && tripId !== null && <AssistantMemoryDialog tripId={tripId} onClose={() => setEditing(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* FE-AI-SOURCES-001 to FE-AI-SOURCES-002
|
||||
*
|
||||
* The pages an answer cites from the provider's web search: a chip per page
|
||||
* with its site and title, opening in a new tab without telling the page who
|
||||
* sent the reader, and the block they form in an answer.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantSources from './AssistantSources';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
|
||||
describe('AssistantSources', () => {
|
||||
it('FE-AI-SOURCES-001: names each page by its site and title, and opens it in a new tab', () => {
|
||||
render(
|
||||
<AssistantSources
|
||||
sources={[
|
||||
{ url: 'https://www.rostock.de/hafen', title: 'Stadthafen Rostock' },
|
||||
{ url: 'https://example.org/a', title: 'https://example.org/a' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('navigation', { name: 'Sources' })).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: /rostock\.de/ });
|
||||
expect(link).toHaveAttribute('href', 'https://www.rostock.de/hafen');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link.getAttribute('rel')).toContain('noreferrer');
|
||||
expect(screen.getByText('Stadthafen Rostock')).toBeInTheDocument();
|
||||
// A title that is only the address shows the site alone.
|
||||
expect(screen.getByRole('link', { name: 'example.org' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-SOURCES-002: an answer keeps its sources as a block of their own after its text', () => {
|
||||
const blocks = messageBlocks(
|
||||
{
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-23T10:00:00.000Z',
|
||||
parts: [
|
||||
{ type: 'tool-call', toolCallId: 'web-1', toolName: 'web_search', args: { query: 'x' }, result: 'searched' },
|
||||
{ type: 'text', text: 'Eat at the harbour.' },
|
||||
{ type: 'data-trek-sources', data: { sources: [{ url: 'https://a.example', title: 'A' }] } },
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(blocks.map((block) => block.kind)).toEqual(['steps', 'text', 'sources']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AssistantSources as Sources } from '@trek/shared';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
/** A page's host without "www.", as the chip names it; the whole address where it does not read as one. */
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages an answer drew on from the provider's web search, under its text:
|
||||
* a row of quiet chips with each page's site and title, opening in a new tab.
|
||||
* No page's icon is loaded, so showing the sources asks no site for anything.
|
||||
*/
|
||||
export default function AssistantSources({ sources }: { sources: Sources['sources'] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<nav aria-label={t('assistant.sources.title')} className="flex flex-col gap-1.5">
|
||||
<span className="font-geist text-caption font-bold uppercase tracking-[.08em] text-content-faint">
|
||||
{t('assistant.sources.title')}
|
||||
</span>
|
||||
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
|
||||
{sources.map((source) => (
|
||||
<li key={source.url} className="min-w-0 max-w-full">
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
title={source.title}
|
||||
className="flex min-w-0 max-w-[16rem] items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2.5 py-1 text-caption text-content-secondary no-underline transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<Globe size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
|
||||
<span className="flex-none font-medium">{hostOf(source.url)}</span>
|
||||
{source.title && source.title !== source.url && (
|
||||
<bdi className="min-w-0 truncate text-content-muted">{source.title}</bdi>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ describe('AssistantSurface', () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: 'Rain on Monday, so the market is a good pick.' },
|
||||
{ type: 'text', text: 'Rain on Monday, so Nishiki Market is a good pick.' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -168,20 +168,6 @@
|
||||
animation: trek-assistant-busy 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* The dot beside what the assistant does right now: a soft beat, a halo going out. */
|
||||
@keyframes trek-assistant-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 6px transparent;
|
||||
}
|
||||
}
|
||||
.trek-assistant-pulse {
|
||||
animation: trek-assistant-pulse 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-assistant-pop,
|
||||
.trek-assistant-enter {
|
||||
@@ -190,8 +176,7 @@
|
||||
.trek-assistant-settle {
|
||||
transition: none;
|
||||
}
|
||||
.trek-assistant-busy,
|
||||
.trek-assistant-pulse {
|
||||
.trek-assistant-busy {
|
||||
animation: none;
|
||||
}
|
||||
.trek-assistant-shimmer {
|
||||
@@ -211,8 +196,7 @@
|
||||
[data-reduce-motion] .trek-assistant-settle {
|
||||
transition: none;
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-busy,
|
||||
[data-reduce-motion] .trek-assistant-pulse {
|
||||
[data-reduce-motion] .trek-assistant-busy {
|
||||
animation: none;
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-shimmer {
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('messageBlocks: found places and weather', () => {
|
||||
read('s2', []),
|
||||
places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3),
|
||||
places('s3', [found('s3:0', { lat: 40 })]),
|
||||
{ type: 'text', text: 'Three good ones.' },
|
||||
{ type: 'text', text: 'Three good ones, Place s3:0 first.' },
|
||||
]),
|
||||
false
|
||||
);
|
||||
@@ -208,10 +208,66 @@ describe('messageBlocks: found places and weather', () => {
|
||||
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
|
||||
// The same name at the same spot, or the same Google id, shows once.
|
||||
expect(blocks[1]).toMatchObject({
|
||||
found: { toolCallId: 's3', more: 5, places: [{ ref: 's1:0' }, { ref: 's1:1' }, { ref: 's3:0' }] },
|
||||
found: { toolCallId: 's3', more: 5, places: [{ ref: 's3:0' }, { ref: 's1:0' }, { ref: 's1:1' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-008d: an answer that planned a tour shows no found places, even one whose planning failed', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
places('s1', [found('s1:0', { name: 'Treseburg' })]),
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'h1',
|
||||
toolName: 'propose_hike',
|
||||
args: {},
|
||||
result: 'Router down',
|
||||
isError: true,
|
||||
},
|
||||
{ type: 'text', text: 'Treseburg is the turning point.' },
|
||||
]),
|
||||
false
|
||||
);
|
||||
expect(blocks.map((b) => b.kind)).not.toContain('places');
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-008c: a note kept in the trip notes is a line of its own, a refused or running one a step', () => {
|
||||
const kept = (id: string, extra: Record<string, unknown>): AssistantMessagePart =>
|
||||
({
|
||||
type: 'tool-call',
|
||||
toolCallId: id,
|
||||
toolName: 'remember_for_trip',
|
||||
args: { note: ' Vegetarian ' },
|
||||
...extra,
|
||||
}) as AssistantMessagePart;
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
kept('m1', { result: { notes: 'Vegetarian' } }),
|
||||
kept('m2', { result: 'The trip notes are full.', isError: true }),
|
||||
]),
|
||||
false
|
||||
);
|
||||
expect(blocks).toMatchObject([
|
||||
{ kind: 'remembered', note: 'Vegetarian' },
|
||||
{ kind: 'steps', steps: [{ toolCallId: 'm2' }] },
|
||||
]);
|
||||
expect(messageBlocks(message([kept('m3', {})]), true)).toMatchObject([{ kind: 'steps' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-008b: an answer that names none of the places it found shows no strip', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([places('s1', [found('s1:0', { name: 'Café Ñandú' })]), { type: 'text', text: 'It rains all day.' }]),
|
||||
false
|
||||
);
|
||||
expect(blocks.map((b) => b.kind)).toEqual(['text']);
|
||||
// Named with other accents and case, it is the same place.
|
||||
const named = messageBlocks(
|
||||
message([places('s1', [found('s1:0', { name: 'Café Ñandú' })]), { type: 'text', text: 'Try cafe nandu.' }]),
|
||||
false
|
||||
);
|
||||
expect(named.map((b) => b.kind)).toEqual(['places', 'text']);
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-009: drops the places a change of the model proposes, and the strip if none is left', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type AssistantMessage,
|
||||
type AssistantMessagePart,
|
||||
type AssistantPlaces,
|
||||
type AssistantSources,
|
||||
type AssistantWeatherDay,
|
||||
type TrekCard,
|
||||
} from '@trek/shared';
|
||||
@@ -66,6 +67,10 @@ type MessageBlock =
|
||||
| { kind: 'change'; key: string; items: ChangeItem[] }
|
||||
| { kind: 'places'; key: string; found: AssistantPlaces }
|
||||
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] }
|
||||
// A note the model kept in the trip notes on its own, as a quiet line.
|
||||
| { kind: 'remembered'; key: string; note: string }
|
||||
// The pages the answer cites from the provider's web search, under its text.
|
||||
| { kind: 'sources'; key: string; sources: AssistantSources['sources'] }
|
||||
| CardsBlock;
|
||||
|
||||
/** How cards are laid out: the group each joins, and which ones give way to a change they served. */
|
||||
@@ -93,6 +98,70 @@ function proposed(place: AssistantFoundPlace, effects: readonly AssistantChangeE
|
||||
});
|
||||
}
|
||||
|
||||
/** A name as it is compared with the answer's words: lower case, without accents or marks. */
|
||||
function folded(text: string): string {
|
||||
return text.normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The found places the answer names, first, then the rest; null when the
|
||||
* answer is written and names none of them. A search the answer does not
|
||||
* draw on is the model's own groundwork and shows no strip.
|
||||
*/
|
||||
function namedFirst(places: readonly AssistantFoundPlace[], text: string): AssistantFoundPlace[] | null {
|
||||
if (text.trim() === '') return [...places];
|
||||
const words = folded(text);
|
||||
const named = places.filter((place) => {
|
||||
const name = folded(place.name).trim();
|
||||
return name.length >= 3 && words.includes(name);
|
||||
});
|
||||
if (named.length === 0) return null;
|
||||
return [...named, ...places.filter((place) => !named.includes(place))];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools that plan or save a tour. An answer that ran one is about the
|
||||
* tour, planned or not: the places its searches went through to find the
|
||||
* points are the model's groundwork, not suggestions, and show no strip.
|
||||
*/
|
||||
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
|
||||
'propose_hike',
|
||||
'create_hike',
|
||||
'update_tour',
|
||||
'propose_city_walk',
|
||||
'create_city_walk',
|
||||
'plan_paddle_route',
|
||||
'plan_ski_route',
|
||||
'prepare_tour_change',
|
||||
'save_tour_change',
|
||||
]);
|
||||
|
||||
/** The tool that keeps a note in the trip notes; kept at once, without a card. */
|
||||
const REMEMBER_TOOL = 'remember_for_trip';
|
||||
|
||||
/** The note a call kept, once it is kept; null for any other call, one still running or one refused. */
|
||||
function rememberedNote(part: Extract<AssistantMessagePart, { type: 'tool-call' }>): string | null {
|
||||
if (part.toolName !== REMEMBER_TOOL || part.approval || part.result === undefined || part.isError) return null;
|
||||
const note = (part.args as { note?: unknown }).note;
|
||||
return typeof note === 'string' && note.trim() !== '' ? note.trim() : null;
|
||||
}
|
||||
|
||||
/** The strip of the answer's found places, named ones first; null when it names none or none is left. */
|
||||
function placesBlock(
|
||||
message: AssistantMessage,
|
||||
effects: readonly AssistantChangeEffect[],
|
||||
index: number
|
||||
): MessageBlock | null {
|
||||
const found = allFound(message.parts);
|
||||
const text = message.parts.map((p) => (p.type === 'text' ? p.text : '')).join(' ');
|
||||
const places = namedFirst(
|
||||
found.places.filter((place) => !proposed(place, effects)),
|
||||
text
|
||||
);
|
||||
if (!places || places.length === 0) return null;
|
||||
return { kind: 'places', key: `places-${index}`, found: { ...found, places } };
|
||||
}
|
||||
|
||||
/** Every day the answer's weather steps looked up, one entry per date (the later look wins), by date. */
|
||||
function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDay[] {
|
||||
const byDate = new Map<string, AssistantWeatherDay>();
|
||||
@@ -256,7 +325,9 @@ export function messageBlocks(
|
||||
|
||||
// An answer about a tour shows the tour, not the places its searches went through to find it.
|
||||
const changeCards = message.parts.flatMap((p) => (p.type === 'data-trek-change' && p.data.card ? [p.data.card] : []));
|
||||
const placesHidden = [...cards.values(), ...changeCards].some((card) => rules.hidesFoundPlaces?.(card) === true);
|
||||
const placesHidden =
|
||||
[...cards.values(), ...changeCards].some((card) => rules.hidesFoundPlaces?.(card) === true) ||
|
||||
message.parts.some((p) => p.type === 'tool-call' && TOUR_PLANNING_TOOLS.has(p.toolName));
|
||||
const blocks: MessageBlock[] = [];
|
||||
message.parts.forEach((part, index) => {
|
||||
if (part.type === 'text') {
|
||||
@@ -264,9 +335,12 @@ export function messageBlocks(
|
||||
return;
|
||||
}
|
||||
if (part.type === 'data-trek-places' && index === lastPlaces && !placesHidden) {
|
||||
const found = allFound(message.parts);
|
||||
const places = found.places.filter((place) => !proposed(place, effects));
|
||||
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } });
|
||||
const strip = placesBlock(message, effects, index);
|
||||
if (strip) blocks.push(strip);
|
||||
return;
|
||||
}
|
||||
if (part.type === 'data-trek-sources') {
|
||||
blocks.push({ kind: 'sources', key: `sources-${index}`, sources: part.data.sources });
|
||||
return;
|
||||
}
|
||||
if (part.type === 'data-trek-weather' && index === firstWeather) {
|
||||
@@ -286,6 +360,11 @@ export function messageBlocks(
|
||||
pushChange(blocks, item, fromStrip);
|
||||
return;
|
||||
}
|
||||
const note = rememberedNote(part);
|
||||
if (note !== null) {
|
||||
blocks.push({ kind: 'remembered', key: `remembered-${part.toolCallId}`, note });
|
||||
return;
|
||||
}
|
||||
const step: ToolStep = {
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
@@ -374,4 +453,6 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
members: { labelKey: 'assistant.change.label.members' },
|
||||
route: { labelKey: 'assistant.change.label.route' },
|
||||
difficulty: { labelKey: 'assistant.change.label.difficulty' },
|
||||
track: { labelKey: 'assistant.change.label.track' },
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -129,6 +129,8 @@ export const TOOL_LABELS: Readonly<Partial<Record<string, { labelKey: string }>>
|
||||
/** How each reading tool is named while it runs; a tool not listed reads "Working on it". */
|
||||
export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
|
||||
get_trip_summary: { stepKey: 'assistant.step.reading' },
|
||||
// The provider's own web search, told as a step of its own (ASSISTANT_WEB_SEARCH_STEP).
|
||||
web_search: { stepKey: 'assistant.step.webSearch', live: { liveKey: 'assistant.step.webSearchFor', param: 'query' } },
|
||||
list_places: { stepKey: 'assistant.step.reading' },
|
||||
search_place: {
|
||||
stepKey: 'assistant.step.searching',
|
||||
|
||||
@@ -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-2.5 pt-2.5" style={{ background: tint }}>
|
||||
<div className={`flex gap-2.5 ${meta ? 'items-start' : 'items-center'}`}>
|
||||
{tile ?? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm ${GLYPH[tone]}`}
|
||||
>
|
||||
<Icon size={15} 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(13.5, 'body')}
|
||||
>
|
||||
{title}
|
||||
</bdi>
|
||||
{count !== undefined && (
|
||||
<span
|
||||
className="inline-flex h-[18px] flex-none items-center rounded-full bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary shadow-sm"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{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-12 flex-none overflow-hidden rounded-[10px] 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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
|
||||
import { Route } from 'lucide-react';
|
||||
import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
|
||||
|
||||
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
|
||||
import type { EntityTarget, PackLook } from '../cardTypes';
|
||||
@@ -86,6 +86,15 @@ export const TOUR_LOOK = {
|
||||
verbs: {
|
||||
addTour: { Icon: Route, tone: 'success', labelKey: 'assistant.change.verb.addTour' },
|
||||
updateTour: { Icon: Route, tone: 'info', labelKey: 'assistant.change.verb.updateTour' },
|
||||
markVisited: { Icon: CircleCheck, tone: 'success', labelKey: 'assistant.change.verb.markVisited' },
|
||||
markSkipped: { Icon: CircleSlash, tone: 'info', labelKey: 'assistant.change.verb.markSkipped' },
|
||||
markPlanned: { Icon: Circle, tone: 'info', labelKey: 'assistant.change.verb.markPlanned' },
|
||||
assignTrack: { Icon: Footprints, tone: 'success', labelKey: 'assistant.change.verb.assignTrack' },
|
||||
unassignTrack: { Icon: Footprints, tone: 'danger', labelKey: 'assistant.change.verb.unassignTrack' },
|
||||
excludeTour: { Icon: ChartNoAxesColumn, tone: 'info', labelKey: 'assistant.change.verb.excludeTour' },
|
||||
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
|
||||
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
|
||||
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
|
||||
},
|
||||
tools: {},
|
||||
steps: {
|
||||
@@ -96,6 +105,7 @@ export const TOUR_LOOK = {
|
||||
list_tours: { stepKey: 'assistant.step.readingTours' },
|
||||
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
|
||||
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
|
||||
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
|
||||
},
|
||||
entities: {
|
||||
tour: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,18 @@
|
||||
import { type AssistantFoundPlace, categoryIdForKind } from '@trek/shared';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useCanDo } from '../../store/permissionsStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Assignment } from '../../types';
|
||||
import { dayLabel } from '../../utils/dayLabel';
|
||||
import { haversineKm } from '../../utils/geo';
|
||||
import { safeHexColor } from '../../utils/safeColor';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
import { hasUndecided } from './assistantParts';
|
||||
import { useAssistantScope, useAssistantView } from './assistantViewContext';
|
||||
import { useCardPoint } from './cards/parts/useCardPoint';
|
||||
|
||||
/** A day a found place can be suggested for, named the way the day list names it. */
|
||||
interface StripDay {
|
||||
id: number;
|
||||
/** Its place in the trip, 1 for the first day: "+ Day 3". */
|
||||
n: number;
|
||||
label: string;
|
||||
/** Its weekday, short ("Sat"), for the day grid; null for a day without a date. */
|
||||
weekday: string | null;
|
||||
}
|
||||
|
||||
/** The short weekday of a `YYYY-MM-DD` date, read as that calendar day wherever the reader is. */
|
||||
function weekdayOf(date: string | null | undefined, locale: string): string | null {
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
return new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`));
|
||||
}
|
||||
|
||||
/** The trip category a found place most likely belongs in, for its line and its icon. */
|
||||
interface StripCategory {
|
||||
name: string;
|
||||
@@ -54,20 +34,17 @@ function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng:
|
||||
|
||||
/**
|
||||
* What the strip of found places needs, the same for the window and the
|
||||
* phone: the trip's days to suggest a place for, the day in focus, how far a
|
||||
* place is from that day's last stop, its likely category, whether a
|
||||
* suggestion can be made now and why not, and the map's part (the pin while
|
||||
* the pointer rests on a card, the camera on a click).
|
||||
* phone: the day in focus, how far a place is from that day's last stop, its
|
||||
* likely category, whether a place can be added, and the map's part (the pin
|
||||
* while the pointer rests on a card, the camera on a click).
|
||||
*
|
||||
* Suggesting writes nothing: the server holds the place as a change card to
|
||||
* decide like any other. It waits while another change is undecided, because
|
||||
* one decision answers all open cards.
|
||||
* Adding opens the planner's own place form, filled in with the place and on
|
||||
* the day in focus: the traveller sees everything about it and saves it as
|
||||
* any other place, so nothing waits for the chat's open decisions.
|
||||
*/
|
||||
export function useAssistantPlaceStrip() {
|
||||
const { t, locale } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const { tripId, onRevealMap } = useAssistantView();
|
||||
const scope = useAssistantScope();
|
||||
const { offline } = useNetworkMode();
|
||||
const can = useCanDo();
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
|
||||
const { trip, days, assignments, categories } = useTripStore(
|
||||
@@ -81,44 +58,15 @@ export function useAssistantPlaceStrip() {
|
||||
};
|
||||
})
|
||||
);
|
||||
const { focusDayId, isRunning, threadId, waiting } = useAssistantStore(
|
||||
useShallow((s) => {
|
||||
const chat = chatOf(s, tripId);
|
||||
return {
|
||||
focusDayId: chat.focus?.dayId ?? null,
|
||||
isRunning: chat.isRunning,
|
||||
threadId: chat.threadId,
|
||||
waiting: hasUndecided(chat.messages),
|
||||
};
|
||||
})
|
||||
);
|
||||
const addFoundPlace = useAssistantStore((s) => s.addFoundPlace);
|
||||
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
|
||||
const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft);
|
||||
const point = useCardPoint();
|
||||
const [adding, setAdding] = useState<string | null>(null);
|
||||
|
||||
const stripDays = useMemo(
|
||||
(): StripDay[] =>
|
||||
(days ?? []).map((day, index) => ({
|
||||
id: day.id,
|
||||
n: index + 1,
|
||||
label: dayLabel(day, index, t, locale),
|
||||
weekday: weekdayOf(day.date, locale),
|
||||
})),
|
||||
[days, t, locale]
|
||||
);
|
||||
const focusDay = stripDays.find((d) => d.id === focusDayId) ?? null;
|
||||
const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
|
||||
const from = focusDay && assignments ? lastSpot(assignments[String(focusDay.id)]) : null;
|
||||
|
||||
/**
|
||||
* Whether "+ Day" shows at all: a trip's chat (a journey's has no days), the
|
||||
* planner holds the trip and the user may add to it.
|
||||
*/
|
||||
const canSuggest = scope.kind === 'trip' && !!trip && can('place_edit', trip) && stripDays.length > 0;
|
||||
/** Why "+ Day" is off right now, as a tooltip; null when it is on. */
|
||||
let blockedKey: string | null = null;
|
||||
if (offline) blockedKey = 'assistant.error.offline';
|
||||
else if (waiting) blockedKey = 'assistant.composer.decideFirst';
|
||||
const disabled = blockedKey !== null || isRunning || threadId === null || adding !== null;
|
||||
/** Whether "Add to trip" shows at all: a trip's chat (a journey's has no days), the planner holds the trip and the user may add to it. */
|
||||
const canAdd = scope.kind === 'trip' && !!trip && can('place_edit', trip);
|
||||
|
||||
const categoryOf = (place: AssistantFoundPlace): StripCategory | null => {
|
||||
const id = categoryIdForKind(place.kind, categories);
|
||||
@@ -130,14 +78,11 @@ export function useAssistantPlaceStrip() {
|
||||
const distanceOf = (place: AssistantFoundPlace): string | null =>
|
||||
from ? formatDistance(haversineKm(from, place), unit) : null;
|
||||
|
||||
const suggest = async (place: AssistantFoundPlace, dayId: number) => {
|
||||
if (disabled) return;
|
||||
setAdding(place.ref);
|
||||
try {
|
||||
await addFoundPlace(tripId, place.ref, dayId);
|
||||
} finally {
|
||||
setAdding(null);
|
||||
}
|
||||
/** Opens the place form with it; on the phone the chat steps aside first. */
|
||||
const open = (place: AssistantFoundPlace) => {
|
||||
if (!canAdd || !trip) return;
|
||||
onRevealMap?.();
|
||||
openPlaceDraft(trip.id, place, focusDay?.id ?? null);
|
||||
};
|
||||
|
||||
/** Shows the place on the map; on the phone the chat steps aside first. */
|
||||
@@ -151,17 +96,5 @@ export function useAssistantPlaceStrip() {
|
||||
[hoverPoint]
|
||||
);
|
||||
|
||||
return {
|
||||
days: stripDays,
|
||||
focusDay,
|
||||
canSuggest,
|
||||
disabled,
|
||||
blockedKey,
|
||||
adding,
|
||||
categoryOf,
|
||||
distanceOf,
|
||||
suggest,
|
||||
show,
|
||||
hover,
|
||||
};
|
||||
return { canAdd, categoryOf, distanceOf, open, show, hover };
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function placeEditForm(
|
||||
}
|
||||
|
||||
/** A position the form opens on: a POI tapped on the map, or a right-click on it. */
|
||||
export interface PlacePrefill {
|
||||
export type PlacePrefill = {
|
||||
lat: number
|
||||
lng: number
|
||||
name?: string
|
||||
@@ -136,6 +136,8 @@ export interface PlacePrefill {
|
||||
website?: string
|
||||
phone?: string
|
||||
osm_id?: string
|
||||
/** A place Google knows, such as one the assistant found: the details column reads it by this id. */
|
||||
google_place_id?: string
|
||||
}
|
||||
|
||||
/** A blank form at a prefilled position, with what the POI already knew. */
|
||||
@@ -149,6 +151,7 @@ export function prefillForm(prefill: PlacePrefill, extra: Partial<PlaceFormData>
|
||||
website: prefill.website || '',
|
||||
phone: prefill.phone || '',
|
||||
osm_id: prefill.osm_id,
|
||||
google_place_id: prefill.google_place_id,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
@@ -170,7 +173,7 @@ export function openingDetailsSelection(
|
||||
}
|
||||
}
|
||||
if (prefill) {
|
||||
return { placeId: prefill.osm_id || undefined, lat: prefill.lat, lng: prefill.lng, name: prefill.name || '' }
|
||||
return { placeId: prefill.google_place_id || prefill.osm_id || undefined, lat: prefill.lat, lng: prefill.lng, name: prefill.name || '' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { weekFromPeriods } from './placeHours'
|
||||
import type { PlaceOpeningHours } from '@trek/shared'
|
||||
import {
|
||||
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
|
||||
prefillForm, type PlaceFormData,
|
||||
prefillForm, type PlaceFormData, type PlacePrefill,
|
||||
} from './PlaceFormModal.helpers'
|
||||
import { useNewCategory, usePlaceForm } from './usePlaceForm'
|
||||
import { usePlaceSearch } from './usePlaceSearch'
|
||||
@@ -64,7 +64,7 @@ interface PlaceFormModalProps {
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
|
||||
place: Place | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null
|
||||
prefillCoords?: (PlacePrefill & { stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string }) | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
||||
|
||||
@@ -91,7 +91,7 @@ function SheetView({
|
||||
height: `${sheet.height + box.bleed * 2}mm`,
|
||||
// The spread's own paper colour, carried out into the bleed. Without
|
||||
// it a coloured page would be cut with a white edge on three sides.
|
||||
background: sheet.spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
|
||||
background: (sheet.spread.backgroundPaint ? null : sheet.spread.background) ?? '#ffffff', // theme-lint-disable: paper, not app chrome
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import { iconComponent } from './iconLibrary'
|
||||
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
|
||||
import { TravelElementView } from './TravelElements'
|
||||
import { PageNumbers } from './PageNumbers'
|
||||
import { PaintedShapeView } from './paint/PaintedShape'
|
||||
import { SpreadPaint } from './paint/paintRender'
|
||||
import { usePaintId } from './paint/usePaintId'
|
||||
|
||||
/**
|
||||
* One spread, drawn.
|
||||
@@ -86,7 +89,8 @@ const FRAME_INSET: Record<string, { pad: number; bottom: number }> = {
|
||||
*/
|
||||
function ShapeView({ el }: { el: BookShapeElement }) {
|
||||
const fill = el.fill ?? 'transparent'
|
||||
const gradientId = `g-${el.id}`
|
||||
// Per mounted copy: page mode draws a spread twice (paint/paintRender.tsx).
|
||||
const gradientId = usePaintId(`g-${el.id}`)
|
||||
const gradient = el.gradient !== 'none' && el.fill
|
||||
|
||||
if (el.shape === 'rect' || el.shape === 'ellipse') {
|
||||
@@ -171,7 +175,7 @@ function PhotoView({ el, big, print, dropLabel }: {
|
||||
// stylesheet that a person may well end up reading.
|
||||
const pad = round2(deco.pad * side)
|
||||
const bottom = round2(deco.bottom * side)
|
||||
const clipId = `c-${el.id}`
|
||||
const clipId = usePaintId(`c-${el.id}`)
|
||||
const clipped = el.mask && el.mask !== 'rect'
|
||||
|
||||
/*
|
||||
@@ -260,7 +264,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%',
|
||||
@@ -391,7 +398,9 @@ export function ElementView({
|
||||
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} />
|
||||
}
|
||||
|
||||
if (el.kind === 'shape') return <ShapeView el={el} />
|
||||
if (el.kind === 'shape') {
|
||||
return el.paint ? <PaintedShapeView el={el} paint={el.paint} frameStyle={frameStyle(el)} /> : <ShapeView el={el} />
|
||||
}
|
||||
|
||||
// Above the catch-all below, which routes everything that is not text into
|
||||
// the travel elements and draws null for anything they do not know.
|
||||
@@ -471,7 +480,8 @@ export function SpreadView({
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
|
||||
// Under a paint the sheet is the paper, which a see-through paint lets show.
|
||||
background: (spread.backgroundPaint ? null : spread.background) ?? '#ffffff', // theme-lint-disable: paper, not app chrome
|
||||
/*
|
||||
* On screen the page ends at the trim, so anything past it is clipped.
|
||||
* In print it must not be: the bleed is the part that runs off the edge
|
||||
@@ -482,6 +492,10 @@ export function SpreadView({
|
||||
overflow: print ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{spread.backgroundPaint && (
|
||||
<SpreadPaint paint={spread.backgroundPaint} width={w} height={page.pageHeight} bleed={page.bleed} idPrefix={spread.id} />
|
||||
)}
|
||||
|
||||
{spread.elements.map(el => (
|
||||
<ElementView
|
||||
key={el.id}
|
||||
|
||||
@@ -22,6 +22,8 @@ import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import StudioAssistantPanel, { StudioAssistantColumn, StudioAssistantToggle } from './StudioAssistantPanel';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/** The assistant's surface is loaded lazily, which can outlast findBy's default second on CI. */
|
||||
const LAZY = { timeout: 10_000 };
|
||||
const JOURNEY = 12;
|
||||
const KEY = assistantChatKey({ kind: 'journey', journeyId: JOURNEY });
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
@@ -75,7 +77,8 @@ describe('StudioAssistantPanel', () => {
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
expect(screen.getByRole('complementary', { name: 'Book assistant' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
const notice = screen.getByRole('note');
|
||||
expect(notice.textContent).toContain('Only that page goes to Anthropic');
|
||||
|
||||
@@ -97,7 +100,8 @@ describe('StudioAssistantPanel', () => {
|
||||
|
||||
useAssistantStore.setState({ status: { ...STATUS, studioImagesAcked: true } });
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -144,7 +148,8 @@ describe('StudioAssistantPanel', () => {
|
||||
it('FE-STUDIO-ASTPANEL-005: the journey chat offers no trip notes, which only a trip turn reads', async () => {
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Recent chats' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Trip notes' })).toBeNull();
|
||||
});
|
||||
@@ -175,7 +180,8 @@ describe('StudioAssistantPanel', () => {
|
||||
});
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Show it pages' })).toBeNull();
|
||||
});
|
||||
@@ -207,7 +213,8 @@ describe('StudioAssistantPanel', () => {
|
||||
|
||||
render(<StudioAssistantColumn assistant={assistant()} />);
|
||||
expect(screen.getByRole('complementary', { name: 'Book assistant' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Journey' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { bookElementId, spreadPaint, withSpreadPaint, type BookPageSetup } from '@trek/shared';
|
||||
import { Check, CopyCheck, ImageOff } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { photoSrc } from './bookRender';
|
||||
import {
|
||||
backgroundCandidates,
|
||||
backgroundPhotoOf,
|
||||
backgroundToAll,
|
||||
withBackgroundPhoto,
|
||||
withoutBackgroundPhoto,
|
||||
} from './paint/backgroundPhoto';
|
||||
import { spreadPhotoIds } from './paint/paletteModel';
|
||||
import { usePaintChannel } from './paint/usePaintChannel';
|
||||
import { Section } from './StudioControls';
|
||||
import { StudioPaintPicker } from './StudioPaintPicker';
|
||||
import type { JourneySource, StudioT } from './StudioSidebar';
|
||||
|
||||
/**
|
||||
* The page's background, in the inspector when nothing is selected.
|
||||
*
|
||||
* Until now only the assistant could set it; a person had to lay a shape
|
||||
* across the page and remember to send it to the back. Three things, in the
|
||||
* order people reach for them: the paint itself, the same background for
|
||||
* every page, and a photograph under the whole page.
|
||||
*/
|
||||
export function StudioBackgroundSection({
|
||||
spreadIndex,
|
||||
page,
|
||||
source,
|
||||
t,
|
||||
}: {
|
||||
spreadIndex: number;
|
||||
page: BookPageSetup;
|
||||
source: JourneySource;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const spread = useStudioStore((s) => s.doc?.spreads[spreadIndex]);
|
||||
const commit = useStudioStore((s) => s.commit);
|
||||
const channel = usePaintChannel((paint) => (doc) => ({
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withSpreadPaint(sp, paint) : sp)),
|
||||
}));
|
||||
|
||||
// "Applied" stands in the button for a moment, so a press that changed pages out of sight is seen to have worked.
|
||||
const [applied, setApplied] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!applied) return;
|
||||
const timer = window.setTimeout(() => setApplied(false), 1600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [applied]);
|
||||
|
||||
const current = backgroundPhotoOf(spread, page);
|
||||
const candidates = useMemo(() => {
|
||||
if (!spread) return [];
|
||||
const entry = source.entries.find((e) => e.id === spread.entryId);
|
||||
return backgroundCandidates(
|
||||
spreadPhotoIds(spread),
|
||||
entry?.photoIds ?? [],
|
||||
source.photos.map((p) => p.photoId)
|
||||
);
|
||||
}, [spread, source]);
|
||||
|
||||
if (!spread) return null;
|
||||
|
||||
const setPhoto = (photoId: number) =>
|
||||
commit((doc) => ({
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => {
|
||||
if (i !== spreadIndex) return sp;
|
||||
return withBackgroundPhoto(sp, page, photoId, () => bookElementId('p')) ?? sp;
|
||||
}),
|
||||
}));
|
||||
const clearPhoto = () =>
|
||||
commit((doc) => ({
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => (i === spreadIndex ? withoutBackgroundPhoto(sp, page) : sp)),
|
||||
}));
|
||||
const applyToAll = () => {
|
||||
commit((doc) => backgroundToAll(doc, spreadIndex));
|
||||
setApplied(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section label={t('journey.studio.paint.background')}>
|
||||
<StudioPaintPicker
|
||||
value={spreadPaint(spread)}
|
||||
channel={channel}
|
||||
t={t}
|
||||
noneLabel={t('journey.studio.paint.paper')}
|
||||
/>
|
||||
|
||||
<button type="button" className={`st-chip st-paint-apply ${applied ? 'is-done' : ''}`} onClick={applyToAll}>
|
||||
{applied ? <Check size={13} strokeWidth={2.4} /> : <CopyCheck size={13} />}
|
||||
{applied ? t('journey.studio.paint.appliedAll') : t('journey.studio.paint.applyAll')}
|
||||
</button>
|
||||
|
||||
<div className="st-paint-field">
|
||||
<span className="st-paint-caption">{t('journey.studio.paint.photoBackground')}</span>
|
||||
{candidates.length ? (
|
||||
<>
|
||||
<div className="st-bgphoto-grid">
|
||||
{candidates.map((id) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
className={`st-bgphoto ${current?.photoId === id ? 'is-on' : ''}`}
|
||||
aria-pressed={current?.photoId === id}
|
||||
aria-label={t('journey.studio.paint.photoBackground')}
|
||||
onClick={() => (current?.photoId === id ? clearPhoto() : setPhoto(id))}
|
||||
>
|
||||
<img src={photoSrc(id, false)} alt="" loading="lazy" draggable={false} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="st-hint st-paint-hint">{t('journey.studio.paint.photoBackgroundHint')}</p>
|
||||
{current && (
|
||||
<button type="button" className="st-chip is-danger st-paint-apply" onClick={clearPhoto}>
|
||||
<ImageOff size={13} />
|
||||
{t('journey.studio.paint.removePhotoBackground')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint st-paint-hint">{t('journey.studio.paint.noPhotos')}</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
|
||||
import { ShapeGlyph } from './StudioElementsPanel'
|
||||
import { TravelInspector } from './StudioTravelInspector'
|
||||
import { Swatches } from './StudioSwatches'
|
||||
import { StudioBackgroundSection } from './StudioBackgroundSection'
|
||||
import { StudioShapePaint } from './StudioShapePaint'
|
||||
import { formatBookCoords } from '@trek/shared'
|
||||
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
|
||||
@@ -100,6 +102,8 @@ export function StudioInspector({
|
||||
<aside className="st-panel st-inspector">
|
||||
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div>
|
||||
<div className="st-panel-scroll">
|
||||
<StudioBackgroundSection spreadIndex={spreadIndex} page={page} source={source} t={t} />
|
||||
|
||||
<Section label={t('journey.studio.pageNumbers')}>
|
||||
<Switch
|
||||
label={t('journey.studio.pageNumbers')}
|
||||
@@ -515,54 +519,13 @@ export function StudioInspector({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
{/*
|
||||
Fill and stroke, both named. The panel used to show one
|
||||
unlabelled swatch row that wrote `fill`, so on an outline-only
|
||||
shape it lit up a colour that was not being drawn and there was
|
||||
no way to reach the line you could actually see.
|
||||
*/}
|
||||
<Line label={t('journey.studio.fill')} wide>
|
||||
<Swatches value={el.fill ?? '#111111'} onPick={c => set({ fill: c })} />
|
||||
</Line>
|
||||
<Line label={t('journey.studio.stroke')} wide>
|
||||
<Swatches
|
||||
value={el.stroke ?? '#141414'}
|
||||
onPick={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Switch
|
||||
label={t('journey.studio.fillOn')}
|
||||
on={el.fill !== null}
|
||||
onToggle={() => set({
|
||||
// Turning the fill off on a shape with no stroke would make
|
||||
// it invisible, so the outline comes on with it.
|
||||
fill: el.fill === null ? '#111111' : null,
|
||||
...(el.fill !== null && !el.stroke ? { stroke: '#141414', strokeWidth: el.strokeWidth || 0.5 } : {}),
|
||||
})}
|
||||
/>
|
||||
<Switch
|
||||
label={t('journey.studio.gradient')}
|
||||
on={el.gradient !== 'none'}
|
||||
onToggle={() => set({ gradient: el.gradient === 'none' ? 'down' : 'none' })}
|
||||
/>
|
||||
</div>
|
||||
{el.gradient !== 'none' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.gradient')}>
|
||||
<Choice
|
||||
value={el.gradient}
|
||||
options={[
|
||||
{ value: 'down' as const, label: t('journey.studio.gradientDown') },
|
||||
{ value: 'up' as const, label: t('journey.studio.gradientUp') },
|
||||
]}
|
||||
onPick={gradient => set({ gradient })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
<StudioShapePaint
|
||||
el={el}
|
||||
spreadIndex={spreadIndex}
|
||||
selection={selection}
|
||||
onStroke={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<Section label={t('journey.studio.strokeStyle')} defaultOpen={false}>
|
||||
<Line label={t('journey.studio.strokeStyle')} wide>
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
PATTERN_SCALE,
|
||||
RADIAL_RADIUS,
|
||||
stopAlpha,
|
||||
type BookGradientPaint,
|
||||
type BookPaint,
|
||||
type BookPattern,
|
||||
type BookPatternPaint,
|
||||
} from '@trek/shared';
|
||||
import { ArrowLeftRight, Ban, Trash2 } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import '../../styles/studio-paint.css';
|
||||
import { NumField } from './StudioControls';
|
||||
import type { StudioT } from './StudioSidebar';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
import { AngleDial } from './paint/AngleDial';
|
||||
import { GradientStopBar } from './paint/GradientStopBar';
|
||||
import { PatternSwatches } from './paint/PatternSwatches';
|
||||
import { RadialPad } from './paint/RadialPad';
|
||||
import {
|
||||
PATTERN_ANGLE,
|
||||
clampRadius,
|
||||
clampScale,
|
||||
cssPaint,
|
||||
gradientAs,
|
||||
kindOf,
|
||||
normAngle,
|
||||
paintAs,
|
||||
removeStop,
|
||||
reverseStops,
|
||||
updateStop,
|
||||
type PaintKind,
|
||||
} from './paint/paintEdit';
|
||||
import type { PaintChannel } from './paint/usePaintChannel';
|
||||
|
||||
/**
|
||||
* Paint, picked: none, one colour, a gradient or a pattern.
|
||||
*
|
||||
* ── How it is laid out ───────────────────────────────────────────────────
|
||||
*
|
||||
* One row of four tabs says what kind of paint it is, each with a swatch of
|
||||
* what choosing it gives, and below it only the controls that kind has. The
|
||||
* tab is the question people ask first ("flat or a gradient?"), so it is a
|
||||
* row they can read at a glance rather than a menu.
|
||||
*
|
||||
* Switching kind keeps the colour: a solid becomes a gradient from that
|
||||
* colour to a lighter partner, a gradient becomes a pattern in its first
|
||||
* colour, and coming back to a kind already visited in this session brings
|
||||
* it back as it was left, so trying a pattern costs nothing.
|
||||
*
|
||||
* Every drag (a stop, the dial, the centre, a slider) writes live and is one
|
||||
* undo step when it is let go, through the channel the caller hands in.
|
||||
*/
|
||||
|
||||
const KINDS: readonly PaintKind[] = ['none', 'solid', 'gradient', 'pattern'];
|
||||
/** A kind's name on its tab. */
|
||||
function kindLabel(k: Exclude<PaintKind, 'none'>, t: StudioT): string {
|
||||
if (k === 'solid') return t('journey.studio.paint.solid');
|
||||
return k === 'gradient' ? t('journey.studio.paint.gradient') : t('journey.studio.paint.pattern');
|
||||
}
|
||||
|
||||
export function StudioPaintPicker({
|
||||
value,
|
||||
channel,
|
||||
t,
|
||||
noneLabel,
|
||||
}: {
|
||||
value: BookPaint | null;
|
||||
channel: PaintChannel;
|
||||
t: StudioT;
|
||||
/** What "no paint" is called here: no fill on a shape, the paper on a page. */
|
||||
noneLabel: string;
|
||||
}) {
|
||||
const kind = kindOf(value);
|
||||
// The last paint of each kind shown here, so a kind visited again comes back as it was left.
|
||||
const [memory, setMemory] = useState<Partial<Record<PaintKind, BookPaint>>>({});
|
||||
const [stop, setStop] = useState(0);
|
||||
|
||||
const write = (paint: BookPaint | null, done = true) => {
|
||||
const k = kindOf(paint);
|
||||
if (paint && k !== 'none') setMemory((m) => ({ ...m, [k]: paint }));
|
||||
if (done) channel.commit(paint);
|
||||
else channel.live(paint);
|
||||
};
|
||||
|
||||
const choose = (next: PaintKind) => {
|
||||
if (next === kind) return;
|
||||
const kept = value ? { ...memory, [kind]: value } : memory;
|
||||
if (value) setMemory((m) => ({ ...m, [kind]: value }));
|
||||
setStop(0);
|
||||
write(paintAs(next, value, kept));
|
||||
};
|
||||
|
||||
const tabPreview = (k: PaintKind) =>
|
||||
k === 'none' ? undefined : cssPaint(k === kind ? value : paintAs(k, value, memory));
|
||||
|
||||
return (
|
||||
<div className="st-paint">
|
||||
<div className="st-paint-tabs" role="tablist" aria-label={t('journey.studio.paint.kind')}>
|
||||
{KINDS.map((k) => (
|
||||
<button
|
||||
type="button"
|
||||
key={k}
|
||||
role="tab"
|
||||
aria-selected={k === kind}
|
||||
className={`st-paint-tab ${k === kind ? 'is-on' : ''}`}
|
||||
onClick={() => choose(k)}
|
||||
>
|
||||
<span
|
||||
className={`st-paint-tab-chip ${k === 'none' ? 'is-none' : ''} ${k === 'pattern' ? 'is-pattern' : ''}`}
|
||||
// A pattern's chip keeps its strokes (studio-paint.css) over the ground colour.
|
||||
style={k === 'pattern' ? { backgroundColor: tabPreview(k) } : { background: tabPreview(k) }}
|
||||
aria-hidden
|
||||
>
|
||||
{k === 'none' && <Ban size={11} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="st-paint-tab-label">{k === 'none' ? noneLabel : kindLabel(k, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{value?.type === 'solid' && (
|
||||
<Swatches
|
||||
value={value.color}
|
||||
onPick={(color) => write({ ...value, color })}
|
||||
alpha={stopAlpha(value)}
|
||||
onAlpha={(alpha, done) => write(withAlpha(value, alpha), done)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(value?.type === 'linear' || value?.type === 'radial') && (
|
||||
<GradientControls
|
||||
paint={value}
|
||||
stop={Math.min(stop, value.stops.length - 1)}
|
||||
onStop={setStop}
|
||||
write={write}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{value?.type === 'pattern' && <PatternControls paint={value} write={write} t={t} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const withAlpha = <T extends { alpha?: number }>(o: T, alpha: number): T => {
|
||||
const { alpha: _old, ...rest } = o;
|
||||
return (alpha >= 1 ? rest : { ...rest, alpha: Math.round(alpha * 100) / 100 }) as T;
|
||||
};
|
||||
|
||||
type Write = (paint: BookPaint | null, done?: boolean) => void;
|
||||
|
||||
function GradientControls({
|
||||
paint,
|
||||
stop,
|
||||
onStop,
|
||||
write,
|
||||
t,
|
||||
}: {
|
||||
paint: BookGradientPaint;
|
||||
stop: number;
|
||||
onStop: (i: number) => void;
|
||||
write: Write;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const current = paint.stops[stop];
|
||||
const canRemove = paint.stops.length > 2;
|
||||
return (
|
||||
<div className="st-paint-body">
|
||||
<div className="st-paint-row">
|
||||
<div className="st-seg" role="radiogroup" aria-label={t('journey.studio.paint.gradient')}>
|
||||
{(['linear', 'radial'] as const).map((type) => (
|
||||
<button
|
||||
type="button"
|
||||
key={type}
|
||||
role="radio"
|
||||
aria-checked={paint.type === type}
|
||||
className={`st-seg-item ${paint.type === type ? 'is-on' : ''}`}
|
||||
onClick={() => write(gradientAs(paint, type))}
|
||||
>
|
||||
{type === 'linear' ? t('journey.studio.paint.linear') : t('journey.studio.paint.radial')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="st-chip is-icon"
|
||||
title={t('journey.studio.paint.reverse')}
|
||||
aria-label={t('journey.studio.paint.reverse')}
|
||||
onClick={() => write(reverseStops(paint))}
|
||||
>
|
||||
<ArrowLeftRight size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GradientStopBar
|
||||
paint={paint}
|
||||
selected={stop}
|
||||
onSelect={onStop}
|
||||
onChange={(p, done) => write(p, done)}
|
||||
labels={{
|
||||
bar: t('journey.studio.paint.addStopHint'),
|
||||
stop: (pos) => t('journey.studio.paint.stopAt', { pos }),
|
||||
}}
|
||||
/>
|
||||
|
||||
{current && (
|
||||
<div className="st-paint-stop">
|
||||
<div className="st-paint-row">
|
||||
<span className="st-paint-caption">
|
||||
{t('journey.studio.paint.stopAt', { pos: Math.round(current.at * 100) })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="st-chip is-icon is-danger"
|
||||
disabled={!canRemove}
|
||||
title={t('journey.studio.paint.removeStop')}
|
||||
aria-label={t('journey.studio.paint.removeStop')}
|
||||
onClick={() => {
|
||||
onStop(Math.max(0, stop - 1));
|
||||
write(removeStop(paint, stop));
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<Swatches
|
||||
value={current.color}
|
||||
onPick={(color) => write(updateStop(paint, stop, { color }))}
|
||||
alpha={stopAlpha(current)}
|
||||
onAlpha={(alpha, done) => write(updateStop(paint, stop, { alpha }), done)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paint.type === 'linear' ? (
|
||||
<div className="st-paint-row is-dial">
|
||||
<AngleDial
|
||||
value={paint.angle}
|
||||
label={t('journey.studio.paint.angle')}
|
||||
onChange={(angle, done) => write({ ...paint, angle }, done)}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.paint.angle')}
|
||||
value={normAngle(paint.angle)}
|
||||
min={0}
|
||||
max={359}
|
||||
step={1}
|
||||
unit="°"
|
||||
onChange={(angle) => write({ ...paint, angle: normAngle(angle) })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="st-paint-row is-dial">
|
||||
<RadialPad paint={paint} label={t('journey.studio.paint.centre')} onChange={(p, done) => write(p, done)} />
|
||||
<Slider
|
||||
label={t('journey.studio.paint.radius')}
|
||||
value={paint.r}
|
||||
min={RADIAL_RADIUS.min}
|
||||
max={RADIAL_RADIUS.max}
|
||||
shown={`${Math.round(paint.r * 100)}%`}
|
||||
onChange={(r, done) => write({ ...paint, r: clampRadius(r) }, done)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternControls({ paint, write, t }: { paint: BookPatternPaint; write: Write; t: StudioT }) {
|
||||
const pick = (pattern: BookPattern) => write({ ...paint, pattern, angle: PATTERN_ANGLE[pattern] ?? paint.angle });
|
||||
return (
|
||||
<div className="st-paint-body">
|
||||
<PatternSwatches value={paint} onPick={pick} label={(p) => t(`journey.studio.pattern.${p}`)} />
|
||||
|
||||
<div className="st-paint-field">
|
||||
<span className="st-paint-caption">{t('journey.studio.paint.ink')}</span>
|
||||
<Swatches value={paint.fg} onPick={(fg) => write({ ...paint, fg })} />
|
||||
</div>
|
||||
|
||||
<div className="st-paint-field">
|
||||
<div className="st-paint-row">
|
||||
<span className="st-paint-caption">{t('journey.studio.paint.ground')}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={paint.bg === null}
|
||||
className={`st-chip is-pick ${paint.bg === null ? 'is-on' : ''}`}
|
||||
onClick={() => write({ ...paint, bg: paint.bg === null ? '#ffffff' : null })}
|
||||
>
|
||||
{t('journey.studio.paint.seeThrough')}
|
||||
</button>
|
||||
</div>
|
||||
{paint.bg !== null && <Swatches value={paint.bg} onPick={(bg) => write({ ...paint, bg })} />}
|
||||
</div>
|
||||
|
||||
<div className="st-paint-row is-dial">
|
||||
<AngleDial
|
||||
value={paint.angle}
|
||||
label={t('journey.studio.paint.angle')}
|
||||
onChange={(angle, done) => write({ ...paint, angle }, done)}
|
||||
/>
|
||||
<Slider
|
||||
label={t('journey.studio.paint.scale')}
|
||||
value={paint.scale}
|
||||
min={PATTERN_SCALE.min}
|
||||
max={PATTERN_SCALE.max}
|
||||
shown={`${Math.round(paint.scale * 100)}%`}
|
||||
log
|
||||
onChange={(scale, done) => write({ ...paint, scale: clampScale(scale) }, done)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled slider with its value beside it; `done` as for the dial. */
|
||||
function Slider({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
shown,
|
||||
onChange,
|
||||
log = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
shown: string;
|
||||
onChange: (v: number, done: boolean) => void;
|
||||
/** Even steps by ratio rather than by difference: a pattern's scale doubles, it does not add. */
|
||||
log?: boolean;
|
||||
}) {
|
||||
const steps = 100;
|
||||
const toValue = (raw: string) => {
|
||||
const t = Number(raw) / steps;
|
||||
return log ? min * (max / min) ** t : min + (max - min) * t;
|
||||
};
|
||||
const pos = Math.round((log ? Math.log(value / min) / Math.log(max / min) : (value - min) / (max - min)) * steps);
|
||||
// What the slider was last moved to: a controlled input may still show the old value when it is let go.
|
||||
const moved = useRef(value);
|
||||
return (
|
||||
<label className="st-paint-slider">
|
||||
<span className="st-paint-slider-head">
|
||||
<span className="st-paint-caption">{label}</span>
|
||||
<span className="st-paint-value">{shown}</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={steps}
|
||||
step={1}
|
||||
value={pos}
|
||||
onPointerDown={() => {
|
||||
moved.current = value;
|
||||
}}
|
||||
onKeyDown={() => {
|
||||
moved.current = value;
|
||||
}}
|
||||
onChange={(e) => {
|
||||
moved.current = toValue(e.target.value);
|
||||
onChange(moved.current, false);
|
||||
}}
|
||||
onPointerUp={() => onChange(moved.current, true)}
|
||||
onKeyUp={() => onChange(moved.current, true)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { shapePaint, withShapePaint, type BookElement, type BookShapeElement } from '@trek/shared';
|
||||
import { usePaintChannel } from './paint/usePaintChannel';
|
||||
import { Line, Section } from './StudioControls';
|
||||
import { StudioPaintPicker } from './StudioPaintPicker';
|
||||
import type { StudioT } from './StudioSidebar';
|
||||
import { Swatches } from './StudioSwatches';
|
||||
|
||||
/** The outline a shape is given when its fill goes and it has none, so it never turns invisible. */
|
||||
const OUTLINE = '#141414';
|
||||
|
||||
/**
|
||||
* A shape's colours: its fill as a paint, and its outline.
|
||||
*
|
||||
* The fill goes to every selected shape at once, the way the rest of the
|
||||
* inspector's properties do. Taking the fill away from a shape with no
|
||||
* outline gives it one, as the old fill switch did: a shape with neither is
|
||||
* a shape nobody can see or find again.
|
||||
*/
|
||||
export function StudioShapePaint({
|
||||
el,
|
||||
spreadIndex,
|
||||
selection,
|
||||
onStroke,
|
||||
t,
|
||||
}: {
|
||||
el: BookShapeElement;
|
||||
spreadIndex: number;
|
||||
selection: readonly string[];
|
||||
onStroke: (color: string) => void;
|
||||
t: StudioT;
|
||||
}) {
|
||||
const channel = usePaintChannel((paint) => (doc) => ({
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) =>
|
||||
i !== spreadIndex
|
||||
? sp
|
||||
: {
|
||||
...sp,
|
||||
elements: sp.elements.map((e): BookElement => {
|
||||
if (e.kind !== 'shape' || !selection.includes(e.id)) return e;
|
||||
const painted = withShapePaint(e, paint);
|
||||
return paint === null && !e.stroke
|
||||
? { ...painted, stroke: OUTLINE, strokeWidth: e.strokeWidth || 0.5 }
|
||||
: painted;
|
||||
}),
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
<Line label={t('journey.studio.fill')} wide>
|
||||
<StudioPaintPicker value={shapePaint(el)} channel={channel} t={t} noneLabel={t('journey.studio.paint.none')} />
|
||||
</Line>
|
||||
<Line label={t('journey.studio.stroke')} wide>
|
||||
<Swatches value={el.stroke ?? OUTLINE} onPick={onStroke} />
|
||||
</Line>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ProviderPicker } from '../Journey/JourneyDetailPageProviderPicker'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { StudioCanvas } from './StudioCanvas'
|
||||
import { StudioInspector } from './StudioInspector'
|
||||
import { StudioPaletteScope } from './paint/StudioPalette'
|
||||
import { StudioWordmark } from './StudioWordmark'
|
||||
import { SaveIndicator } from './SaveIndicator'
|
||||
import { downloadSpread } from './spreadFile'
|
||||
@@ -124,16 +125,18 @@ export default function StudioShell() {
|
||||
onBrowseProvider={s.browseProvider}
|
||||
/>
|
||||
<Workbench s={s} bookView={bookView} />
|
||||
<StudioInspector
|
||||
spreadIndex={s.activeSpread}
|
||||
page={s.page}
|
||||
stats={s.stats}
|
||||
path={s.path}
|
||||
source={s.source}
|
||||
setPageNumbers={s.setPageNumbers}
|
||||
t={s.t}
|
||||
locale={s.locale}
|
||||
/>
|
||||
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
|
||||
<StudioInspector
|
||||
spreadIndex={s.activeSpread}
|
||||
page={s.page}
|
||||
stats={s.stats}
|
||||
path={s.path}
|
||||
source={s.source}
|
||||
setPageNumbers={s.setPageNumbers}
|
||||
t={s.t}
|
||||
locale={s.locale}
|
||||
/>
|
||||
</StudioPaletteScope>
|
||||
</div>
|
||||
<StudioAssistantColumn assistant={assistant} title={s.journey?.title} bookVersion={s.bookVersion} />
|
||||
<StudioPageStage assistant={assistant} />
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { useRef } from 'react'
|
||||
import { BookOpen, History, Image } from 'lucide-react'
|
||||
import { useStudioPalette } from './paint/paletteModel'
|
||||
import '../../styles/studio-paint.css'
|
||||
|
||||
/**
|
||||
* The colour picker, shared by everything in the inspector that has a colour.
|
||||
*
|
||||
@@ -8,6 +13,12 @@
|
||||
* The palette is print ink rather than app chrome — it does not follow the
|
||||
* user's theme, because a book is printed once and read on paper, where an
|
||||
* accent chosen for a dark interface means nothing.
|
||||
*
|
||||
* Under the fixed inks sit up to three short rows from the open book
|
||||
* (paint/StudioPalette.tsx): the colours the book already uses, the colours
|
||||
* of the photos on this page, and what this person picked lately. Each row
|
||||
* is there only when it has something in it, so a new book shows the inks
|
||||
* alone, as before.
|
||||
*/
|
||||
|
||||
const SWATCHES = [
|
||||
@@ -16,23 +27,118 @@ const SWATCHES = [
|
||||
'#1e3a8a', '#9f1239', '#4c1d95',
|
||||
]
|
||||
|
||||
export function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }) {
|
||||
function Swatch({ color, on, onPick }: { color: string; on: boolean; onPick: (c: string) => void }) {
|
||||
return (
|
||||
<div className="st-swatches">
|
||||
{SWATCHES.map(c => (
|
||||
<button type="button"
|
||||
key={c}
|
||||
className={`st-swatch ${value.toLowerCase() === c ? 'is-on' : ''}`}
|
||||
style={{ background: c }} // theme-lint-disable — a colour picker shows the colours themselves
|
||||
onClick={() => onPick(c)}
|
||||
aria-label={c}
|
||||
/>
|
||||
<button type="button"
|
||||
className={`st-swatch ${on ? 'is-on' : ''}`}
|
||||
style={{ background: color }} // theme-lint-disable: a colour picker shows the colours themselves
|
||||
onClick={() => onPick(color)}
|
||||
aria-label={color}
|
||||
aria-pressed={on}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Swatches({ value, onPick, alpha, onAlpha, rows = true }: {
|
||||
value: string
|
||||
onPick: (c: string) => void
|
||||
/** With `onAlpha`, an opacity slider under the swatches, 0 to 1. */
|
||||
alpha?: number
|
||||
/** `done` is false while the slider is dragged and true when it is let go. */
|
||||
onAlpha?: (a: number, done: boolean) => void
|
||||
/** The book, photo and recent rows; off where only the fixed inks make sense. */
|
||||
rows?: boolean
|
||||
}) {
|
||||
const palette = useStudioPalette()
|
||||
const current = value.toLowerCase()
|
||||
// The wheel reports every colour it passes through; only the one it stops on is worth remembering.
|
||||
const wheel = useRef<string | null>(null)
|
||||
const pick = (c: string) => {
|
||||
palette?.remember(c)
|
||||
onPick(c)
|
||||
}
|
||||
|
||||
const groups = rows && palette
|
||||
? ([
|
||||
['book', BookOpen, palette.book],
|
||||
['photos', Image, palette.photos],
|
||||
['recent', History, palette.recent],
|
||||
] as const).filter(([, , list]) => list.length > 0)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="st-swatch-stack">
|
||||
<div className="st-swatches">
|
||||
{SWATCHES.map(c => <Swatch key={c} color={c} on={current === c} onPick={pick} />)}
|
||||
{/* Anything at all, for a book that has to match a cover or a brand. */}
|
||||
<label className="st-swatch st-swatch-custom">
|
||||
{/* Named by the colour it holds, the way the preset swatches above are. */}
|
||||
<input
|
||||
type="color"
|
||||
aria-label={value}
|
||||
value={value}
|
||||
onChange={e => { wheel.current = e.target.value; onPick(e.target.value) }}
|
||||
onBlur={() => { if (wheel.current) palette?.remember(wheel.current); wheel.current = null }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{groups.map(([key, Icon, list]) => (
|
||||
<div key={key} className="st-swatch-row" role="group" aria-label={palette?.labels[key]}>
|
||||
<span className="st-swatch-row-icon" title={palette?.labels[key]} aria-hidden>
|
||||
<Icon size={12} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="st-swatches">
|
||||
{list.map(c => <Swatch key={c} color={c} on={current === c} onPick={pick} />)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Anything at all, for a book that has to match a cover or a brand. */}
|
||||
<label className="st-swatch st-swatch-custom">
|
||||
{/* Named by the colour it holds, the way the preset swatches above are. */}
|
||||
<input type="color" aria-label={value} value={value} onChange={e => onPick(e.target.value)} />
|
||||
</label>
|
||||
|
||||
{onAlpha && <AlphaSlider color={value} value={alpha ?? 1} onChange={onAlpha} label={palette?.labels.opacity} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How opaque the colour is, as a slider over the colour itself.
|
||||
*
|
||||
* The track runs from nothing to the full colour over a chequerboard, so the
|
||||
* slider shows what it does rather than needing a label to explain it, and
|
||||
* the figure beside it says the exact value for anyone matching a reference.
|
||||
*/
|
||||
export function AlphaSlider({ color, value, onChange, label }: {
|
||||
color: string
|
||||
value: number
|
||||
onChange: (a: number, done: boolean) => void
|
||||
/** The slider's accessible name, in the reader's language. */
|
||||
label?: string
|
||||
}) {
|
||||
const pct = Math.round(value * 100)
|
||||
// What the slider was last moved to: a controlled input may still show the old value when it is let go.
|
||||
const moved = useRef(value)
|
||||
const move = (raw: string) => {
|
||||
moved.current = Math.max(0, Math.min(100, Number(raw))) / 100
|
||||
onChange(moved.current, false)
|
||||
}
|
||||
const finish = () => onChange(moved.current, true)
|
||||
return (
|
||||
<div className="st-alpha">
|
||||
<div className="st-alpha-track" style={{ ['--st-alpha-colour' as string]: color }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={pct}
|
||||
aria-label={label}
|
||||
onPointerDown={() => { moved.current = value }}
|
||||
onKeyDown={() => { moved.current = value }}
|
||||
onChange={e => move(e.target.value)}
|
||||
onPointerUp={finish}
|
||||
onKeyUp={finish}
|
||||
/>
|
||||
</div>
|
||||
<span className="st-alpha-value">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,91 @@
|
||||
import { useRef, type KeyboardEvent, type PointerEvent } from 'react';
|
||||
import { dialAngle, normAngle } from './paintEdit';
|
||||
|
||||
/**
|
||||
* An angle, set by turning a dial.
|
||||
*
|
||||
* A number field alone asks people to know that a gradient running to the
|
||||
* bottom right is 135 degrees; a dial lets them point at it. It snaps to the
|
||||
* eight compass angles when the pointer passes close to one (Alt turns the
|
||||
* snap off), and takes the arrow keys, Shift for steps of fifteen.
|
||||
*
|
||||
* `done` is false while the dial is turned and true on the value it is let go
|
||||
* on, so the turn is one undo step (usePaintChannel.ts).
|
||||
*/
|
||||
export function AngleDial({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (angle: number, done: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
const dial = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
const last = useRef(value);
|
||||
|
||||
const read = (e: PointerEvent<HTMLDivElement>) => {
|
||||
const box = dial.current?.getBoundingClientRect();
|
||||
if (!box) return last.current;
|
||||
return dialAngle(e.clientX - (box.left + box.width / 2), e.clientY - (box.top + box.height / 2), e.altKey);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
dragging.current = true;
|
||||
last.current = read(e);
|
||||
onChange(last.current, false);
|
||||
};
|
||||
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging.current) return;
|
||||
const a = read(e);
|
||||
if (a === last.current) return;
|
||||
last.current = a;
|
||||
onChange(a, false);
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
if (!dragging.current) return;
|
||||
dragging.current = false;
|
||||
onChange(last.current, true);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const step = e.shiftKey ? 15 : 1;
|
||||
const by =
|
||||
e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -step : 0;
|
||||
if (!by) return;
|
||||
e.preventDefault();
|
||||
last.current = normAngle(value + by);
|
||||
onChange(last.current, false);
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key.startsWith('Arrow')) onChange(last.current, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dial}
|
||||
className="st-dial"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={359}
|
||||
aria-valuenow={normAngle(value)}
|
||||
aria-valuetext={`${normAngle(value)}°`}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
>
|
||||
<span className="st-dial-needle" style={{ transform: `rotate(${normAngle(value)}deg)` }} aria-hidden>
|
||||
<span className="st-dial-knob" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { BookGradientPaint } from '@trek/shared';
|
||||
import { useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
|
||||
import { addStop, cssStops, removeStop, updateStop } from './paintEdit';
|
||||
|
||||
/**
|
||||
* The stops of a gradient, on a bar that shows the gradient itself.
|
||||
*
|
||||
* Click the bar to add a stop there, in the colour the gradient already has
|
||||
* at that point, so adding one changes nothing until it is moved or recoloured.
|
||||
* Drag a stop along the bar to move it, or pull it off the bar downwards
|
||||
* (it fades) and let go to take it out; the arrow keys move the chosen one, Delete removes it. A
|
||||
* gradient keeps at least two stops, so the last two cannot be pulled off.
|
||||
*
|
||||
* The bar runs left to right in every language, like the gradient preview
|
||||
* it carries, so it sets its own direction (studio-paint.css).
|
||||
*/
|
||||
|
||||
/** How far below the bar a stop is pulled before letting go removes it, in pixels. */
|
||||
const PULL_OFF = 28;
|
||||
|
||||
export function GradientStopBar({
|
||||
paint,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
labels,
|
||||
}: {
|
||||
paint: BookGradientPaint;
|
||||
selected: number;
|
||||
onSelect: (index: number) => void;
|
||||
onChange: (paint: BookGradientPaint, done: boolean) => void;
|
||||
labels: { bar: string; stop: (pos: number) => string };
|
||||
}) {
|
||||
const bar = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ index: number; moved: boolean; off: boolean; paint: BookGradientPaint } | null>(null);
|
||||
// The stop being pulled off, drawn faded until it is let go, when it goes.
|
||||
const [leaving, setLeaving] = useState<number | null>(null);
|
||||
const canRemove = paint.stops.length > 2;
|
||||
|
||||
const posOf = (clientX: number) => {
|
||||
const box = bar.current?.getBoundingClientRect();
|
||||
if (!box || box.width <= 0) return 0;
|
||||
return (clientX - box.left) / box.width;
|
||||
};
|
||||
|
||||
const onBarDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0 || e.target !== e.currentTarget) return;
|
||||
const added = addStop(paint, posOf(e.clientX));
|
||||
if (!added) return;
|
||||
onSelect(added.index);
|
||||
onChange(added.paint, true);
|
||||
};
|
||||
|
||||
const onStopDown = (index: number) => (e: PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
onSelect(index);
|
||||
drag.current = { index, moved: false, off: false, paint };
|
||||
};
|
||||
|
||||
const onStopMove = (e: PointerEvent<HTMLButtonElement>) => {
|
||||
const d = drag.current;
|
||||
if (!d) return;
|
||||
const box = bar.current?.getBoundingClientRect();
|
||||
const off = canRemove && !!box && e.clientY > box.bottom + PULL_OFF;
|
||||
const next = updateStop(d.paint, d.index, { at: posOf(e.clientX) });
|
||||
drag.current = { ...d, moved: true, off, paint: next };
|
||||
setLeaving(off ? d.index : null);
|
||||
onChange(next, false);
|
||||
};
|
||||
|
||||
const onStopUp = () => {
|
||||
const d = drag.current;
|
||||
drag.current = null;
|
||||
setLeaving(null);
|
||||
if (!d?.moved) return;
|
||||
if (d.off) {
|
||||
onSelect(Math.max(0, d.index - 1));
|
||||
onChange(removeStop(d.paint, d.index), true);
|
||||
return;
|
||||
}
|
||||
onChange(d.paint, true);
|
||||
};
|
||||
|
||||
const onStopKey = (index: number) => (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const stop = paint.stops[index];
|
||||
if (!stop) return;
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && canRemove) {
|
||||
e.preventDefault();
|
||||
onSelect(Math.max(0, index - 1));
|
||||
onChange(removeStop(paint, index), true);
|
||||
return;
|
||||
}
|
||||
const step = e.shiftKey ? 0.1 : 0.01;
|
||||
const by = e.key === 'ArrowRight' ? step : e.key === 'ArrowLeft' ? -step : 0;
|
||||
if (!by) return;
|
||||
e.preventDefault();
|
||||
onChange(updateStop(paint, index, { at: stop.at + by }), true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="st-stopbar-wrap">
|
||||
<div ref={bar} className="st-stopbar" title={labels.bar} aria-label={labels.bar} onPointerDown={onBarDown}>
|
||||
<span
|
||||
className="st-stopbar-fill"
|
||||
style={{ backgroundImage: `linear-gradient(90deg, ${cssStops(paint.stops)})` }}
|
||||
aria-hidden
|
||||
/>
|
||||
{paint.stops.map((stop, i) => (
|
||||
<button
|
||||
type="button"
|
||||
// The index is the stop: two stops may share a colour and a position.
|
||||
key={i}
|
||||
className={`st-stop ${i === selected ? 'is-on' : ''} ${leaving === i ? 'is-leaving' : ''}`}
|
||||
style={{ ['--at' as string]: `${Math.round(stop.at * 1000) / 10}%`, ['--stop' as string]: stop.color }}
|
||||
aria-label={labels.stop(Math.round(stop.at * 100))}
|
||||
aria-pressed={i === selected}
|
||||
onPointerDown={onStopDown(i)}
|
||||
onPointerMove={onStopMove}
|
||||
onPointerUp={onStopUp}
|
||||
onPointerCancel={onStopUp}
|
||||
onKeyDown={onStopKey(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { BookPaint, BookShapeElement } from '@trek/shared';
|
||||
import { useMemo, type CSSProperties } from 'react';
|
||||
import { HOLED_SHAPES } from '../shapes';
|
||||
import { shapeOutline, strokeDash } from './paintGeometry';
|
||||
import { PaintedPath } from './paintRender';
|
||||
|
||||
/**
|
||||
* A shape that carries a paint, drawn wholly in SVG.
|
||||
*
|
||||
* The shapes from before paint stay as SpreadView draws them, the rectangle
|
||||
* and the ellipse as CSS boxes, so no book that exists changes by a pixel.
|
||||
* A painted shape is one path whatever its kind: a gradient and a pattern
|
||||
* need an outline to lie over and to be cut to, and a rectangle with a radius
|
||||
* is as much an outline as a heart is.
|
||||
*
|
||||
* As in SpreadView, the path is drawn into the frame inset by half the
|
||||
* stroke, so the whole stroke sits inside the frame the editor snapped.
|
||||
*/
|
||||
|
||||
export function PaintedShapeView({
|
||||
el,
|
||||
paint,
|
||||
frameStyle,
|
||||
}: {
|
||||
el: BookShapeElement;
|
||||
paint: BookPaint;
|
||||
frameStyle: CSSProperties;
|
||||
}) {
|
||||
const sw = el.stroke ? el.strokeWidth : 0;
|
||||
const w = Math.max(0.01, el.frame.w - sw);
|
||||
const h = Math.max(0.01, el.frame.h - sw);
|
||||
const d = useMemo(() => shapeOutline(el, w, h, sw / 2), [el, w, h, sw]);
|
||||
const box = useMemo(() => ({ x: 0, y: 0, w, h }), [w, h]);
|
||||
|
||||
return (
|
||||
<div style={frameStyle}>
|
||||
<svg
|
||||
width={`${el.frame.w}mm`}
|
||||
height={`${el.frame.h}mm`}
|
||||
viewBox={`0 0 ${el.frame.w} ${el.frame.h}`}
|
||||
style={{ display: 'block', overflow: 'visible' }}
|
||||
>
|
||||
<g transform={sw ? `translate(${sw / 2} ${sw / 2})` : undefined}>
|
||||
<PaintedPath
|
||||
d={d}
|
||||
paint={paint}
|
||||
box={box}
|
||||
idPrefix={el.id}
|
||||
fillRule={HOLED_SHAPES.has(el.shape) ? 'evenodd' : undefined}
|
||||
stroke={el.stroke ?? undefined}
|
||||
strokeWidth={sw || undefined}
|
||||
strokeDasharray={strokeDash(el.strokeStyle, sw)}
|
||||
strokeLinecap={el.strokeStyle === 'dotted' ? 'round' : undefined}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BOOK_PATTERNS, type BookPattern, type BookPatternPaint } from '@trek/shared';
|
||||
import { useMemo } from 'react';
|
||||
import { PATTERN_ANGLE } from './paintEdit';
|
||||
import { patternGeometry } from './patterns';
|
||||
|
||||
/**
|
||||
* The twelve repeats, each drawn as a tile in the paint's own colours.
|
||||
*
|
||||
* Drawn by the same generator the page uses, at a smaller scale, so a tile
|
||||
* is a true preview of what choosing it does rather than an icon of it, and
|
||||
* a repeat that looks wrong in the tile looks wrong on the page too.
|
||||
*/
|
||||
|
||||
const TILE = 24;
|
||||
|
||||
function PatternTile({ pattern, fg, bg }: { pattern: BookPattern; fg: string; bg: string | null }) {
|
||||
const geo = useMemo(
|
||||
() =>
|
||||
patternGeometry({ pattern, scale: 0.42, angle: PATTERN_ANGLE[pattern] ?? 0 }, { x: 0, y: 0, w: TILE, h: TILE }),
|
||||
[pattern]
|
||||
);
|
||||
return (
|
||||
<svg viewBox={`0 0 ${TILE} ${TILE}`} className="st-pattern-tile-art" aria-hidden>
|
||||
{bg && <rect width={TILE} height={TILE} fill={bg} />}
|
||||
<g transform={geo.transform}>
|
||||
{geo.layers.map((layer, i) => (
|
||||
<path key={i} d={layer.d} fill={fg} fillOpacity={layer.opacity < 1 ? layer.opacity : undefined} />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatternSwatches({
|
||||
value,
|
||||
onPick,
|
||||
label,
|
||||
}: {
|
||||
value: BookPatternPaint;
|
||||
onPick: (pattern: BookPattern) => void;
|
||||
label: (pattern: BookPattern) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="st-pattern-grid" role="radiogroup">
|
||||
{BOOK_PATTERNS.map((pattern) => (
|
||||
<button
|
||||
type="button"
|
||||
key={pattern}
|
||||
role="radio"
|
||||
aria-checked={value.pattern === pattern}
|
||||
aria-label={label(pattern)}
|
||||
title={label(pattern)}
|
||||
className={`st-pattern-tile ${value.pattern === pattern ? 'is-on' : ''} ${value.bg ? '' : 'is-clear'}`}
|
||||
onClick={() => onPick(pattern)}
|
||||
>
|
||||
<PatternTile pattern={pattern} fg={value.fg} bg={value.bg} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { BookGradientPaint } from '@trek/shared';
|
||||
import { useRef, type KeyboardEvent, type PointerEvent } from 'react';
|
||||
import { clampUnit, cssStops } from './paintEdit';
|
||||
|
||||
/**
|
||||
* Where a radial gradient starts: a small preview of it with its centre on
|
||||
* it, to drag. The preview is the gradient itself, so the point being moved
|
||||
* is the bright spot or the dark one people are actually placing.
|
||||
*
|
||||
* Like the stop bar it is a picture, laid out left to right in every
|
||||
* language (studio-paint.css), with the arrow keys for a step of 2 %.
|
||||
*/
|
||||
export function RadialPad({
|
||||
paint,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
paint: Extract<BookGradientPaint, { type: 'radial' }>;
|
||||
onChange: (paint: BookGradientPaint, done: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
const pad = useRef<HTMLDivElement>(null);
|
||||
const dragging = useRef(false);
|
||||
const last = useRef(paint);
|
||||
|
||||
const at = (e: PointerEvent<HTMLDivElement>) => {
|
||||
const box = pad.current?.getBoundingClientRect();
|
||||
if (!box || box.width <= 0 || box.height <= 0) return paint;
|
||||
return {
|
||||
...paint,
|
||||
cx: clampUnit((e.clientX - box.left) / box.width),
|
||||
cy: clampUnit((e.clientY - box.top) / box.height),
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
dragging.current = true;
|
||||
last.current = at(e);
|
||||
onChange(last.current, false);
|
||||
};
|
||||
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging.current) return;
|
||||
last.current = at(e);
|
||||
onChange(last.current, false);
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
if (!dragging.current) return;
|
||||
dragging.current = false;
|
||||
onChange(last.current, true);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const dx = e.key === 'ArrowRight' ? 0.02 : e.key === 'ArrowLeft' ? -0.02 : 0;
|
||||
const dy = e.key === 'ArrowDown' ? 0.02 : e.key === 'ArrowUp' ? -0.02 : 0;
|
||||
if (!dx && !dy) return;
|
||||
e.preventDefault();
|
||||
onChange({ ...paint, cx: clampUnit(paint.cx + dx), cy: clampUnit(paint.cy + dy) }, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pad}
|
||||
className="st-radial-pad"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
aria-valuetext={`${Math.round(paint.cx * 100)}% ${Math.round(paint.cy * 100)}%`}
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at ${paint.cx * 100}% ${paint.cy * 100}%, ${cssStops(paint.stops)})`,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<span
|
||||
className="st-radial-dot"
|
||||
style={{ ['--cx' as string]: `${paint.cx * 100}%`, ['--cy' as string]: `${paint.cy * 100}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { bookColours, type BookDocument, type BookSpread, type PaletteSwatch } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
import { photoSrc } from '../bookRender';
|
||||
import {
|
||||
StudioPaletteContext,
|
||||
readRecent,
|
||||
spreadPhotoIds,
|
||||
withRecent,
|
||||
writeRecent,
|
||||
type StudioPaletteValue,
|
||||
} from './paletteModel';
|
||||
import { photoSwatches, readPhotoPalette } from './photoPalette';
|
||||
|
||||
/**
|
||||
* The colours every swatch row in the inspector offers beside its fixed inks:
|
||||
* the book's own, the ones in the page's photographs, and the ones this
|
||||
* person picked lately.
|
||||
*
|
||||
* A context rather than props, because the swatch rows sit at every depth of
|
||||
* the inspector, the travel elements' panels included, and each of them
|
||||
* should offer the same three rows without every caller passing them down.
|
||||
*/
|
||||
|
||||
/** The palettes of the page's photos, read once per photo for as long as the inspector is open. */
|
||||
function usePhotoColours(photoIds: readonly number[]): string[] {
|
||||
const [read, setRead] = useState<ReadonlyMap<number, PaletteSwatch[]>>(() => new Map());
|
||||
// The ids travel as one string, so a new array holding the same photos reads nothing again.
|
||||
const key = photoIds.join(',');
|
||||
|
||||
useEffect(() => {
|
||||
const ids = key ? key.split(',').map(Number) : [];
|
||||
const missing = ids.filter((id) => !read.has(id));
|
||||
if (!missing.length) return;
|
||||
const ctrl = new AbortController();
|
||||
void Promise.all(
|
||||
missing.map(async (id) => [id, await readPhotoPalette(photoSrc(id, false), ctrl.signal)] as const)
|
||||
).then((found) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
setRead((prev) => new Map([...prev, ...found]));
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, [key, read]);
|
||||
|
||||
return useMemo(() => {
|
||||
const ids = key ? key.split(',').map(Number) : [];
|
||||
return photoSwatches(ids.map((id) => read.get(id) ?? []));
|
||||
}, [key, read]);
|
||||
}
|
||||
|
||||
export function StudioPaletteProvider({
|
||||
doc,
|
||||
spread,
|
||||
labels,
|
||||
children,
|
||||
}: {
|
||||
doc: BookDocument | null;
|
||||
spread: BookSpread | undefined;
|
||||
labels: StudioPaletteValue['labels'];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const book = useMemo(() => bookColours(doc), [doc]);
|
||||
const photos = usePhotoColours(useMemo(() => spreadPhotoIds(spread), [spread]));
|
||||
const [recent, setRecent] = useState(readRecent);
|
||||
const remember = useCallback((hex: string) => {
|
||||
setRecent((list) => {
|
||||
const next = withRecent(list, hex);
|
||||
writeRecent(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const value = useMemo(() => ({ book, photos, recent, remember, labels }), [book, photos, recent, remember, labels]);
|
||||
return <StudioPaletteContext.Provider value={value}>{children}</StudioPaletteContext.Provider>;
|
||||
}
|
||||
|
||||
/** The provider for the open book: the inspector of the page being edited sits inside it. */
|
||||
export function StudioPaletteScope({
|
||||
spreadIndex,
|
||||
t,
|
||||
children,
|
||||
}: {
|
||||
spreadIndex: number;
|
||||
t: (k: string) => string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const doc = useStudioStore((s) => s.doc);
|
||||
const labels = useMemo(
|
||||
() => ({
|
||||
book: t('journey.studio.paint.bookColours'),
|
||||
photos: t('journey.studio.paint.photoColours'),
|
||||
recent: t('journey.studio.paint.recentColours'),
|
||||
opacity: t('journey.studio.paint.opacity'),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
return (
|
||||
<StudioPaletteProvider doc={doc} spread={doc?.spreads[spreadIndex]} labels={labels}>
|
||||
{children}
|
||||
</StudioPaletteProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookPhotoElementSchema,
|
||||
spreadPaint,
|
||||
withSpreadPaint,
|
||||
type BookDocument,
|
||||
type BookElement,
|
||||
type BookPageSetup,
|
||||
type BookPhotoElement,
|
||||
type BookSpread,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The page background's two moves beyond its paint: a photo laid under the
|
||||
* whole page, and one page's background given to every page.
|
||||
*
|
||||
* A background photo is an ordinary photo element, not a field of the page:
|
||||
* full bleed across the spread, at the back, and locked, so it does not come
|
||||
* away under a click meant for what is on top of it. Being an element, it
|
||||
* prints, crops, filters and undoes like every other picture, and the
|
||||
* assistant reaches it with the operations it already has (add, fill, lock).
|
||||
*/
|
||||
|
||||
/** The frame of a photo that covers the whole sheet, bleed included. */
|
||||
export function bleedFrame(spread: BookSpread, page: BookPageSetup) {
|
||||
const pages = spread.role === 'inner' ? 2 : 1;
|
||||
return {
|
||||
x: -page.bleed,
|
||||
y: -page.bleed,
|
||||
w: page.pageWidth * pages + page.bleed * 2,
|
||||
h: page.pageHeight + page.bleed * 2,
|
||||
};
|
||||
}
|
||||
|
||||
const near = (a: number, b: number) => Math.abs(a - b) < 0.6;
|
||||
|
||||
/** The page's background photo: the locked picture at the back that covers the whole sheet. Null when it has none. */
|
||||
export function backgroundPhotoOf(spread: BookSpread | undefined, page: BookPageSetup): BookPhotoElement | null {
|
||||
const back = spread?.elements[0];
|
||||
if (!spread || !back || back.kind !== 'photo' || !back.locked || back.photoId == null) return null;
|
||||
const want = bleedFrame(spread, page);
|
||||
const f = back.frame;
|
||||
return near(f.x, want.x) && near(f.y, want.y) && near(f.w, want.w) && near(f.h, want.h) && !back.rotation
|
||||
? back
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page with `photoId` as its background: the one it had is replaced, a
|
||||
* new one goes in at the back. Null when the page is already full.
|
||||
*/
|
||||
export function withBackgroundPhoto(
|
||||
spread: BookSpread,
|
||||
page: BookPageSetup,
|
||||
photoId: number,
|
||||
newId: () => string
|
||||
): BookSpread | null {
|
||||
const old = backgroundPhotoOf(spread, page);
|
||||
const rest = old ? spread.elements.slice(1) : spread.elements;
|
||||
if (rest.length >= MAX_SPREAD_ELEMENTS) return null;
|
||||
const photo = bookPhotoElementSchema.parse({
|
||||
id: old?.id ?? newId(),
|
||||
kind: 'photo',
|
||||
frame: bleedFrame(spread, page),
|
||||
locked: true,
|
||||
photoId,
|
||||
fit: 'cover',
|
||||
});
|
||||
return { ...spread, elements: [photo as BookElement, ...rest] };
|
||||
}
|
||||
|
||||
/** The page without its background photo. */
|
||||
export function withoutBackgroundPhoto(spread: BookSpread, page: BookPageSetup): BookSpread {
|
||||
return backgroundPhotoOf(spread, page) ? { ...spread, elements: spread.elements.slice(1) } : spread;
|
||||
}
|
||||
|
||||
const isCover = (spread: BookSpread) => spread.role === 'cover' || spread.role === 'back';
|
||||
|
||||
/**
|
||||
* Every page given the background of page `from`: its paint and colour,
|
||||
* nothing else. "Every page" is every page of its kind: an inside page
|
||||
* gives its background to the inside of the book, a cover to the covers,
|
||||
* since a cover is designed on its own and an inside page's paper is rarely
|
||||
* what the cover wants.
|
||||
*/
|
||||
export function backgroundToAll(doc: BookDocument, from: number): BookDocument {
|
||||
const source = doc.spreads[from];
|
||||
if (!source) return doc;
|
||||
const paint = spreadPaint(source);
|
||||
const kind = isCover(source);
|
||||
return {
|
||||
...doc,
|
||||
spreads: doc.spreads.map((sp, i) => (i === from || isCover(sp) !== kind ? sp : withSpreadPaint(sp, paint))),
|
||||
};
|
||||
}
|
||||
|
||||
/** The pictures offered as a background: the page's own first, then its entry's, then the gallery's. */
|
||||
export function backgroundCandidates(
|
||||
spreadPhotos: readonly number[],
|
||||
entryPhotos: readonly number[],
|
||||
gallery: readonly number[],
|
||||
limit = 8
|
||||
): number[] {
|
||||
return [...new Set([...spreadPhotos, ...entryPhotos, ...gallery])].slice(0, limit);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
MAX_PAINT_STOPS,
|
||||
PATTERN_SCALE,
|
||||
RADIAL_RADIUS,
|
||||
paintColours,
|
||||
parseHex,
|
||||
sortedStops,
|
||||
stopAlpha,
|
||||
toHex,
|
||||
type BookGradientPaint,
|
||||
type BookPaint,
|
||||
type BookPaintStop,
|
||||
type BookPattern,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The arithmetic of the paint picker, kept out of the components so it can
|
||||
* be tested on its own: switching between kinds without losing the colour,
|
||||
* stops added where they are clicked in the colour already there, angles
|
||||
* read off a pointer and snapped to the ones people mean.
|
||||
*/
|
||||
|
||||
export type PaintKind = 'none' | 'solid' | 'gradient' | 'pattern';
|
||||
|
||||
const WHITE = '#ffffff';
|
||||
const INK = '#111111';
|
||||
|
||||
export const kindOf = (paint: BookPaint | null): PaintKind =>
|
||||
!paint ? 'none' : paint.type === 'linear' || paint.type === 'radial' ? 'gradient' : paint.type;
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
const r3 = (v: number) => Math.round(v * 1000) / 1000;
|
||||
|
||||
/** Two colours mixed, `t` of the way from `a` to `b`. */
|
||||
export function mixHex(a: string, b: string, t: number): string {
|
||||
const ca = parseHex(a);
|
||||
const cb = parseHex(b);
|
||||
if (!ca || !cb) return a.toLowerCase();
|
||||
const m = (x: number, y: number) => x + (y - x) * t;
|
||||
return toHex({ r: m(ca.r, cb.r), g: m(ca.g, cb.g), b: m(ca.b, cb.b) });
|
||||
}
|
||||
|
||||
/** The colour a paint leads with: what a solid keeps when it becomes a gradient, and back. */
|
||||
export function leadColour(paint: BookPaint | null, fallback = INK): string {
|
||||
return paintColours(paint)[0] ?? fallback;
|
||||
}
|
||||
|
||||
/** A lighter partner for a colour, for the second stop of a fresh gradient or a pattern's ground. */
|
||||
export function partnerOf(color: string, t = 0.65): string {
|
||||
const rgb = parseHex(color);
|
||||
if (!rgb) return WHITE;
|
||||
// Light colours get a darker partner, so the gradient never runs white into white.
|
||||
const light = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000 > 200;
|
||||
return mixHex(color, light ? INK : WHITE, light ? 0.35 : t);
|
||||
}
|
||||
|
||||
/** The angle a pattern looks right at when first picked: stripes on the diagonal, the rest square. */
|
||||
export const PATTERN_ANGLE: Partial<Record<BookPattern, number>> = { stripes: 45, pinstripe: 0 };
|
||||
|
||||
/**
|
||||
* The paint of another kind, built from the one there is. `memory` holds the
|
||||
* last paint of each kind the picker showed, so going to gradient and back to
|
||||
* pattern returns the pattern as it was rather than a fresh default.
|
||||
*/
|
||||
export function paintAs(
|
||||
kind: PaintKind,
|
||||
from: BookPaint | null,
|
||||
memory: Partial<Record<PaintKind, BookPaint>> = {}
|
||||
): BookPaint | null {
|
||||
if (kind === 'none') return null;
|
||||
const remembered = memory[kind];
|
||||
if (remembered) return remembered;
|
||||
const lead = leadColour(from);
|
||||
switch (kind) {
|
||||
case 'solid':
|
||||
return { type: 'solid', color: lead };
|
||||
case 'gradient':
|
||||
return {
|
||||
type: 'linear',
|
||||
angle: 180,
|
||||
stops: [
|
||||
{ at: 0, color: lead },
|
||||
{ at: 1, color: partnerOf(lead) },
|
||||
],
|
||||
};
|
||||
case 'pattern':
|
||||
return { type: 'pattern', pattern: 'stripes', fg: lead, bg: partnerOf(lead, 0.82), scale: 1, angle: 45 };
|
||||
}
|
||||
}
|
||||
|
||||
/** The colour a gradient shows at a point, alpha included, for a stop added there. */
|
||||
export function colourAt(stops: readonly BookPaintStop[], at: number): { color: string; alpha: number } {
|
||||
const list = sortedStops(stops);
|
||||
const first = list[0];
|
||||
const last = list[list.length - 1];
|
||||
if (!first || !last) return { color: INK, alpha: 1 };
|
||||
if (at <= first.at) return { color: first.color.toLowerCase(), alpha: stopAlpha(first) };
|
||||
if (at >= last.at) return { color: last.color.toLowerCase(), alpha: stopAlpha(last) };
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
const a = list[i - 1];
|
||||
const b = list[i];
|
||||
if (!a || !b || at > b.at) continue;
|
||||
const t = b.at === a.at ? 0 : (at - a.at) / (b.at - a.at);
|
||||
return { color: mixHex(a.color, b.color, t), alpha: r3(stopAlpha(a) + (stopAlpha(b) - stopAlpha(a)) * t) };
|
||||
}
|
||||
return { color: last.color.toLowerCase(), alpha: stopAlpha(last) };
|
||||
}
|
||||
|
||||
const stopOf = (at: number, color: string, alpha: number): BookPaintStop =>
|
||||
alpha < 1 ? { at, color, alpha } : { at, color };
|
||||
|
||||
/** A stop added at `at` in the colour the gradient already has there. Null when the gradient is full. */
|
||||
export function addStop(paint: BookGradientPaint, at: number): { paint: BookGradientPaint; index: number } | null {
|
||||
if (paint.stops.length >= MAX_PAINT_STOPS) return null;
|
||||
const pos = r3(clamp(at, 0, 1));
|
||||
const { color, alpha } = colourAt(paint.stops, pos);
|
||||
const stops = [...paint.stops, stopOf(pos, color, alpha)];
|
||||
return { paint: { ...paint, stops }, index: stops.length - 1 };
|
||||
}
|
||||
|
||||
/** A gradient without one of its stops. A gradient keeps at least two. */
|
||||
export function removeStop(paint: BookGradientPaint, index: number): BookGradientPaint {
|
||||
if (paint.stops.length <= 2 || index < 0 || index >= paint.stops.length) return paint;
|
||||
return { ...paint, stops: paint.stops.filter((_, i) => i !== index) };
|
||||
}
|
||||
|
||||
/** One stop changed; its position kept between 0 and 1 and to three decimals. */
|
||||
export function updateStop(paint: BookGradientPaint, index: number, patch: Partial<BookPaintStop>): BookGradientPaint {
|
||||
const stops = paint.stops.map((s, i) => {
|
||||
if (i !== index) return s;
|
||||
const next = { ...s, ...patch, at: r3(clamp(patch.at ?? s.at, 0, 1)) };
|
||||
if (next.alpha !== undefined && next.alpha >= 1) delete next.alpha;
|
||||
return next;
|
||||
});
|
||||
return { ...paint, stops };
|
||||
}
|
||||
|
||||
/** The gradient run the other way. */
|
||||
export function reverseStops(paint: BookGradientPaint): BookGradientPaint {
|
||||
return { ...paint, stops: paint.stops.map((s) => ({ ...s, at: r3(1 - s.at) })) };
|
||||
}
|
||||
|
||||
/** Linear or radial, keeping the stops. */
|
||||
export function gradientAs(paint: BookGradientPaint, type: 'linear' | 'radial'): BookGradientPaint {
|
||||
if (paint.type === type) return paint;
|
||||
return type === 'linear'
|
||||
? { type: 'linear', angle: 180, stops: paint.stops }
|
||||
: { type: 'radial', cx: 0.5, cy: 0.5, r: 1, stops: paint.stops };
|
||||
}
|
||||
|
||||
/** A whole-degree angle between 0 and 359. */
|
||||
export function normAngle(deg: number): number {
|
||||
const a = Math.round(deg) % 360;
|
||||
return a < 0 ? a + 360 : a;
|
||||
}
|
||||
|
||||
/**
|
||||
* The angle a pointer at (`dx`, `dy`) from the dial's centre points at, 0 up
|
||||
* and clockwise like the paint itself. Within a few degrees of a multiple of
|
||||
* 45 it lands on it, since those are the angles people mean; `fine` (a held
|
||||
* modifier) turns the snap off.
|
||||
*/
|
||||
export function dialAngle(dx: number, dy: number, fine = false): number {
|
||||
const raw = normAngle((Math.atan2(dx, -dy) * 180) / Math.PI);
|
||||
if (fine) return raw;
|
||||
const near = Math.round(raw / 45) * 45;
|
||||
return Math.abs(raw - near) <= 4 ? normAngle(near) : raw;
|
||||
}
|
||||
|
||||
export const clampScale = (v: number) => r3(clamp(v, PATTERN_SCALE.min, PATTERN_SCALE.max));
|
||||
export const clampRadius = (v: number) => r3(clamp(v, RADIAL_RADIUS.min, RADIAL_RADIUS.max));
|
||||
export const clampUnit = (v: number) => r3(clamp(v, 0, 1));
|
||||
|
||||
/** A CSS gradient for the picker's own previews (the stop bar, the tab swatches): not for the page. */
|
||||
export function cssStops(stops: readonly BookPaintStop[]): string {
|
||||
return sortedStops(stops)
|
||||
.map((s) => {
|
||||
const rgb = parseHex(s.color);
|
||||
const c = rgb ? `rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${stopAlpha(s)})` : s.color;
|
||||
return `${c} ${Math.round(s.at * 1000) / 10}%`;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** A paint as a CSS background, for a preview chip in the picker. */
|
||||
export function cssPaint(paint: BookPaint | null): string | undefined {
|
||||
if (!paint) return undefined;
|
||||
switch (paint.type) {
|
||||
case 'solid': {
|
||||
const rgb = parseHex(paint.color);
|
||||
return rgb ? `rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${stopAlpha(paint)})` : paint.color;
|
||||
}
|
||||
case 'linear':
|
||||
return `linear-gradient(${paint.angle}deg, ${cssStops(paint.stops)})`;
|
||||
case 'radial':
|
||||
return `radial-gradient(circle at ${paint.cx * 100}% ${paint.cy * 100}%, ${cssStops(paint.stops)})`;
|
||||
case 'pattern':
|
||||
return paint.bg ?? undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { BookShapeElement } from '@trek/shared';
|
||||
import { SHAPE_PATHS, scalePath } from '../shapes';
|
||||
import type { PatternBox } from './patterns';
|
||||
|
||||
/** The outlines and lines paint is laid along, in millimetres. */
|
||||
|
||||
/** Hundredths of a millimetre, and never minus zero, which would print as "-0". */
|
||||
export const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
|
||||
|
||||
/**
|
||||
* Where a linear gradient's line runs over a box, the way CSS places it: through
|
||||
* the centre at `angle` (0 towards the top, clockwise), long enough that the
|
||||
* first and last stop land on the box's far corners.
|
||||
*/
|
||||
export function linearLine(angle: number, box: PatternBox): { x1: number; y1: number; x2: number; y2: number } {
|
||||
const a = (angle * Math.PI) / 180;
|
||||
const dx = Math.sin(a);
|
||||
const dy = -Math.cos(a);
|
||||
const half = (Math.abs(box.w * dx) + Math.abs(box.h * dy)) / 2;
|
||||
const cx = box.x + box.w / 2;
|
||||
const cy = box.y + box.h / 2;
|
||||
return { x1: r2(cx - dx * half), y1: r2(cy - dy * half), x2: r2(cx + dx * half), y2: r2(cy + dy * half) };
|
||||
}
|
||||
|
||||
/** A rectangle's outline as path data, with its corners rounded to `radius`. */
|
||||
export function roundedRectPath(w: number, h: number, radius: number): string {
|
||||
const r = Math.max(0, Math.min(radius, w / 2, h / 2));
|
||||
if (r <= 0) return `M0 0H${r2(w)}V${r2(h)}H0Z`;
|
||||
const a = `A${r2(r)} ${r2(r)} 0 0 1`;
|
||||
return (
|
||||
`M${r2(r)} 0H${r2(w - r)}${a} ${r2(w)} ${r2(r)}V${r2(h - r)}${a} ${r2(w - r)} ${r2(h)}` +
|
||||
`H${r2(r)}${a} 0 ${r2(h - r)}V${r2(r)}${a} ${r2(r)} 0Z`
|
||||
);
|
||||
}
|
||||
|
||||
/** An ellipse filling a box, as path data. */
|
||||
export function ellipsePath(w: number, h: number): string {
|
||||
const rx = r2(w / 2);
|
||||
const ry = r2(h / 2);
|
||||
return `M0 ${ry}A${rx} ${ry} 0 1 0 ${r2(w)} ${ry}A${rx} ${ry} 0 1 0 0 ${ry}Z`;
|
||||
}
|
||||
|
||||
/** The dash a stroke style draws, proportional to the stroke so its rhythm holds at any width. */
|
||||
export function strokeDash(style: BookShapeElement['strokeStyle'], sw: number): string | undefined {
|
||||
if (style === 'dashed') return `${sw * 3} ${sw * 2}`;
|
||||
if (style === 'dotted') return `${sw * 0.01} ${sw * 2}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The outline of a shape in a box of `w` × `h` millimetres. */
|
||||
export function shapeOutline(
|
||||
el: Pick<BookShapeElement, 'shape' | 'radius'>,
|
||||
w: number,
|
||||
h: number,
|
||||
inset: number
|
||||
): string {
|
||||
if (el.shape === 'rect') return roundedRectPath(w, h, Math.max(0, el.radius - inset));
|
||||
if (el.shape === 'ellipse') return ellipsePath(w, h);
|
||||
return scalePath(SHAPE_PATHS[el.shape], w, h);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { sortedStops, stopAlpha, type BookPaint, type BookPaintStop } from '@trek/shared';
|
||||
import { useMemo, type ReactNode, type SVGProps } from 'react';
|
||||
import { linearLine, r2 } from './paintGeometry';
|
||||
import { patternGeometry, type PatternBox } from './patterns';
|
||||
import { usePaintId } from './usePaintId';
|
||||
|
||||
/**
|
||||
* Paint, drawn: the one renderer for a shape's fill and a page's background.
|
||||
*
|
||||
* Every paint becomes SVG the print path already knows: a fill with
|
||||
* `fill-opacity`, a `linearGradient` or `radialGradient` in user space, or a
|
||||
* pattern drawn out as geometry (patterns.ts) under a `clipPath` in user
|
||||
* space. Nothing here is lazy, measured or raster, so what the editor shows
|
||||
* is what headless Chromium prints.
|
||||
*
|
||||
* ── Why the ids come from useId ──────────────────────────────────────────
|
||||
*
|
||||
* Page mode draws each double spread twice, once per half (bookSheets.ts),
|
||||
* and a thumbnail draws it again beside the canvas. An id taken from the
|
||||
* element alone would then exist twice in one document, and `url(#id)`
|
||||
* resolves to the first, so the second half would borrow the first half's
|
||||
* gradient, and a pattern clipped to the first copy's path would vanish from
|
||||
* the second. React's useId is unique per mounted instance and the same on
|
||||
* every render of it, which is exactly the property a defs id needs; the
|
||||
* element's own id is kept in front only to make the markup readable.
|
||||
*/
|
||||
|
||||
function Stops({ stops }: { stops: readonly BookPaintStop[] }) {
|
||||
return (
|
||||
<>
|
||||
{sortedStops(stops).map((s, i) => (
|
||||
<stop
|
||||
// Two stops may share an offset (a hard edge), so the index keys them.
|
||||
key={i}
|
||||
offset={r2(s.at)}
|
||||
stopColor={s.color}
|
||||
stopOpacity={stopAlpha(s) < 1 ? stopAlpha(s) : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a painted outline needs: the defs to put in the SVG, and how to fill the outline. */
|
||||
interface PaintParts {
|
||||
defs: ReactNode;
|
||||
fill: string;
|
||||
fillOpacity?: number;
|
||||
/** A pattern's geometry, to draw clipped to the outline over its fill. */
|
||||
pattern: { clipId: string; layers: { d: string; opacity: number }[]; transform?: string; ink: string } | null;
|
||||
}
|
||||
|
||||
function paintParts(paint: BookPaint, box: PatternBox, id: string): PaintParts {
|
||||
switch (paint.type) {
|
||||
case 'solid':
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.color,
|
||||
fillOpacity: stopAlpha(paint) < 1 ? stopAlpha(paint) : undefined,
|
||||
pattern: null,
|
||||
};
|
||||
case 'linear': {
|
||||
const line = linearLine(paint.angle, box);
|
||||
return {
|
||||
defs: (
|
||||
<linearGradient id={id} gradientUnits="userSpaceOnUse" {...line}>
|
||||
<Stops stops={paint.stops} />
|
||||
</linearGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
}
|
||||
case 'radial':
|
||||
return {
|
||||
defs: (
|
||||
<radialGradient
|
||||
id={id}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx={r2(box.x + paint.cx * box.w)}
|
||||
cy={r2(box.y + paint.cy * box.h)}
|
||||
r={r2((paint.r * Math.hypot(box.w, box.h)) / 2)}
|
||||
>
|
||||
<Stops stops={paint.stops} />
|
||||
</radialGradient>
|
||||
),
|
||||
fill: `url(#${id})`,
|
||||
pattern: null,
|
||||
};
|
||||
case 'pattern': {
|
||||
const geo = patternGeometry(paint, box);
|
||||
return {
|
||||
defs: null,
|
||||
fill: paint.bg ?? 'none',
|
||||
pattern: { clipId: `${id}-clip`, layers: geo.layers, transform: geo.transform, ink: paint.fg },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OutlineProps = Omit<SVGProps<SVGPathElement>, 'fill' | 'd'>;
|
||||
|
||||
/**
|
||||
* An outline filled with a paint, inside an `<svg>` whose user units are
|
||||
* millimetres. `box` is the outline's box in that space, which a gradient and
|
||||
* a pattern are laid over. The stroke, when the outline has one, is drawn on
|
||||
* top of a pattern rather than under it, so the edge stays crisp.
|
||||
*/
|
||||
export function PaintedPath({
|
||||
d,
|
||||
paint,
|
||||
box,
|
||||
idPrefix,
|
||||
fillRule,
|
||||
stroke,
|
||||
...rest
|
||||
}: {
|
||||
d: string;
|
||||
paint: BookPaint | null;
|
||||
box: PatternBox;
|
||||
/** The element's id, to make the markup readable; uniqueness comes from useId. */
|
||||
idPrefix: string;
|
||||
fillRule?: 'evenodd' | 'nonzero';
|
||||
} & OutlineProps) {
|
||||
const id = usePaintId(`p-${idPrefix}`);
|
||||
// The caller holds `box` steady between renders (a memo of the frame), so a re-render
|
||||
// that moved nothing does not lay the pattern out again.
|
||||
const parts = useMemo(() => (paint ? paintParts(paint, box, id) : null), [paint, box, id]);
|
||||
const outline = { d, fillRule, clipRule: fillRule };
|
||||
|
||||
if (!parts) return <path {...outline} fill="none" stroke={stroke} {...rest} />;
|
||||
|
||||
const pattern = parts.pattern;
|
||||
return (
|
||||
<>
|
||||
{(parts.defs || pattern) && (
|
||||
<defs>
|
||||
{parts.defs}
|
||||
{pattern && (
|
||||
<clipPath id={pattern.clipId} clipPathUnits="userSpaceOnUse">
|
||||
<path {...outline} />
|
||||
</clipPath>
|
||||
)}
|
||||
</defs>
|
||||
)}
|
||||
<path
|
||||
{...outline}
|
||||
fill={parts.fill}
|
||||
fillOpacity={parts.fillOpacity}
|
||||
stroke={pattern ? undefined : stroke}
|
||||
{...(pattern ? {} : rest)}
|
||||
/>
|
||||
{pattern && (
|
||||
<>
|
||||
<g clipPath={`url(#${pattern.clipId})`}>
|
||||
<g transform={pattern.transform}>
|
||||
{pattern.layers.map((layer, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={layer.d}
|
||||
fill={pattern.ink}
|
||||
fillOpacity={layer.opacity < 1 ? layer.opacity : undefined}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
{stroke && <path {...outline} fill="none" stroke={stroke} {...rest} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A page's paint, drawn under everything on it and out into the bleed.
|
||||
*
|
||||
* Positioned in the spread's own millimetres from minus the bleed, so print,
|
||||
* whose window shows the bleed, gets paint to the cut, and the editor, which
|
||||
* clips at the trim, shows the same paint up to it.
|
||||
*/
|
||||
export function SpreadPaint({
|
||||
paint,
|
||||
width,
|
||||
height,
|
||||
bleed,
|
||||
idPrefix,
|
||||
}: {
|
||||
paint: BookPaint;
|
||||
width: number;
|
||||
height: number;
|
||||
bleed: number;
|
||||
idPrefix: string;
|
||||
}) {
|
||||
const w = r2(width + bleed * 2);
|
||||
const h = r2(height + bleed * 2);
|
||||
const box = useMemo(() => ({ x: 0, y: 0, w, h }), [w, h]);
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
width={`${w}mm`}
|
||||
height={`${h}mm`}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
// A page is laid out left to right in every language; its own direction makes the start its left edge.
|
||||
style={{
|
||||
position: 'absolute',
|
||||
direction: 'ltr',
|
||||
insetInlineStart: `${-bleed}mm`,
|
||||
top: `${-bleed}mm`,
|
||||
display: 'block',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<PaintedPath d={`M0 0H${w}V${h}H0Z`} paint={paint} box={box} idPrefix={`bg-${idPrefix}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BookSpread } from '@trek/shared';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/** The palette rows' state and the pure parts of it (StudioPalette.tsx holds the provider). */
|
||||
|
||||
export interface StudioPaletteValue {
|
||||
/** The colours the book is drawn in, most used first. */
|
||||
book: string[];
|
||||
/** The colours of the photos on the page being edited. */
|
||||
photos: string[];
|
||||
/** What this person picked lately, newest first. */
|
||||
recent: string[];
|
||||
remember: (hex: string) => void;
|
||||
/** The labels the rows carry, in the reader's language. */
|
||||
labels: { book: string; photos: string; recent: string; opacity: string };
|
||||
}
|
||||
|
||||
export const StudioPaletteContext = createContext<StudioPaletteValue | null>(null);
|
||||
|
||||
/** The palette rows, or null outside a provider (a swatch row then shows its fixed inks only). */
|
||||
export function useStudioPalette(): StudioPaletteValue | null {
|
||||
return useContext(StudioPaletteContext);
|
||||
}
|
||||
|
||||
export const RECENT_KEY = 'trek.studio.recentColours';
|
||||
export const MAX_RECENT = 8;
|
||||
|
||||
/** The recent colours as stored. Storage can be missing or refuse; that is an empty list, not an error. */
|
||||
export function readRecent(): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RECENT_KEY);
|
||||
const list: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(list)
|
||||
? list.filter((c): c is string => typeof c === 'string' && /^#[0-9a-f]{6}$/.test(c)).slice(0, MAX_RECENT)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** A colour put first in the recent list, which keeps each colour once. */
|
||||
export function withRecent(list: readonly string[], hex: string): string[] {
|
||||
const c = hex.toLowerCase();
|
||||
return [c, ...list.filter((x) => x !== c)].slice(0, MAX_RECENT);
|
||||
}
|
||||
|
||||
export function writeRecent(list: readonly string[]) {
|
||||
try {
|
||||
window.localStorage.setItem(RECENT_KEY, JSON.stringify(list));
|
||||
} catch {
|
||||
// A private window or a full quota: the list lives for this session only.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** The photo ids a page shows, placed or parked, in paint order. */
|
||||
export function spreadPhotoIds(spread: BookSpread | undefined): number[] {
|
||||
if (!spread) return [];
|
||||
const ids = [...spread.elements, ...spread.parked].flatMap((e) =>
|
||||
e.kind === 'photo' && e.photoId != null ? [e.photoId] : []
|
||||
);
|
||||
return [...new Set(ids)].slice(0, 8);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import type { BookPattern } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The twelve repeats a pattern paint draws, as plain geometry.
|
||||
*
|
||||
* Not SVG `<pattern>`: a pattern tile has never been through this print path,
|
||||
* and a RIP that rasterises tiles at its own resolution prints seams. Each
|
||||
* repeat is drawn out in full instead, as a handful of `<path>` elements whose
|
||||
* `d` holds every primitive, and clipped to the shape by the caller. The DOM
|
||||
* stays a few nodes per element whatever the scale.
|
||||
*
|
||||
* Everything is in millimetres in the caller's user space, rounded to
|
||||
* hundredths as the document stores its own numbers, and every repeat is
|
||||
* capped at `MAX_PATTERN_PRIMITIVES`: past that the cell grows rather than the
|
||||
* page, so a tiny scale on a whole spread costs no more than it looks worth.
|
||||
*/
|
||||
|
||||
export const MAX_PATTERN_PRIMITIVES = 2000;
|
||||
/** The cell of a pattern at scale 1. */
|
||||
export const PATTERN_CELL_MM = 8;
|
||||
|
||||
export interface PatternBox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** One colour layer: every primitive of it in one path, at a share of the ink's opacity. */
|
||||
interface PatternLayer {
|
||||
d: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
interface PatternGeometry {
|
||||
layers: PatternLayer[];
|
||||
/** How many primitives the layers hold together. */
|
||||
count: number;
|
||||
/** Turns the drawing about the box's centre; absent when it is not turned. */
|
||||
transform?: string;
|
||||
/** The cell the repeat was drawn at, after the cap had its say. */
|
||||
cell: number;
|
||||
}
|
||||
|
||||
/** Hundredths of a millimetre, and never minus zero, which would print as "-0". */
|
||||
const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
|
||||
const f = (v: number) => String(r2(v));
|
||||
|
||||
/**
|
||||
* How many primitives a repeat needs over a region: `area` primitives per
|
||||
* cell squared, `lin` per cell along the width and the height. Read before
|
||||
* drawing, so the cap moves the cell once instead of drawing twice, and on
|
||||
* the high side, so the cap grows the cell rather than leaving a corner of
|
||||
* the box undrawn.
|
||||
*/
|
||||
const DENSITY: Record<BookPattern, { area: number; lin: number }> = {
|
||||
checker: { area: 2, lin: 0 },
|
||||
gingham: { area: 0, lin: 1 },
|
||||
stripes: { area: 0, lin: 1 },
|
||||
pinstripe: { area: 0, lin: 2 },
|
||||
dots: { area: 4, lin: 0 },
|
||||
polka: { area: 2, lin: 0 },
|
||||
zigzag: { area: 2, lin: 0 },
|
||||
waves: { area: 2, lin: 0 },
|
||||
grid: { area: 0, lin: 2 },
|
||||
scales: { area: 2, lin: 0 },
|
||||
sunburst: { area: 0, lin: 0 },
|
||||
confetti: { area: 1.2, lin: 0 },
|
||||
};
|
||||
|
||||
/** A primitive count estimate for a repeat over a region at a cell size. */
|
||||
export function estimatePrimitives(pattern: BookPattern, cell: number, w: number, h: number): number {
|
||||
if (pattern === 'sunburst') return sunburstRays(cell);
|
||||
const d = DENSITY[pattern];
|
||||
// The repeats that need room to tuck in start a cell before the region and end a cell after it.
|
||||
const pw = w + cell * 2;
|
||||
const ph = h + cell * 2;
|
||||
return (d.area * pw * ph) / (cell * cell) + (d.lin * (pw + ph)) / cell;
|
||||
}
|
||||
|
||||
/** The cell a repeat is drawn at: its own, or larger where the cap would be passed. */
|
||||
export function fittedCell(
|
||||
pattern: BookPattern,
|
||||
cell: number,
|
||||
w: number,
|
||||
h: number,
|
||||
cap = MAX_PATTERN_PRIMITIVES
|
||||
): number {
|
||||
let c = cell;
|
||||
// Two rounds cover the linear term, which grows only with the square root.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const n = estimatePrimitives(pattern, c, w, h);
|
||||
if (n <= cap * 0.95) break;
|
||||
c *= Math.sqrt(n / (cap * 0.95)) * 1.02;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Collects primitives into a path, refusing any past the cap. */
|
||||
class Pen {
|
||||
parts: string[] = [];
|
||||
count = 0;
|
||||
constructor(private readonly cap: number) {}
|
||||
|
||||
get full(): boolean {
|
||||
return this.count >= this.cap;
|
||||
}
|
||||
|
||||
private push(d: string) {
|
||||
if (this.full) return;
|
||||
this.parts.push(d);
|
||||
this.count++;
|
||||
}
|
||||
|
||||
rect(x: number, y: number, w: number, h: number) {
|
||||
this.push(`M${f(x)} ${f(y)}h${f(w)}v${f(h)}h${f(-w)}Z`);
|
||||
}
|
||||
|
||||
circle(cx: number, cy: number, r: number) {
|
||||
this.push(`M${f(cx - r)} ${f(cy)}a${f(r)} ${f(r)} 0 1 0 ${f(r * 2)} 0a${f(r)} ${f(r)} 0 1 0 ${f(-r * 2)} 0Z`);
|
||||
}
|
||||
|
||||
polygon(points: readonly (readonly [number, number])[]) {
|
||||
if (points.length < 3) return;
|
||||
this.push(`M${points.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z`);
|
||||
}
|
||||
|
||||
/** Raw path data counted as `n` primitives, for a band drawn as one outline. */
|
||||
raw(d: string, n: number) {
|
||||
if (this.full) return;
|
||||
this.parts.push(d);
|
||||
this.count += n;
|
||||
}
|
||||
|
||||
get d(): string {
|
||||
return this.parts.join('');
|
||||
}
|
||||
}
|
||||
|
||||
interface Region {
|
||||
x0: number;
|
||||
y0: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
}
|
||||
|
||||
/** Values of `start + k * step` from at or below `from` up to `to`, aligned to the region's origin. */
|
||||
function steps(from: number, to: number, step: number, offset = 0): number[] {
|
||||
const out: number[] = [];
|
||||
const first = Math.floor((from - offset) / step) * step + offset;
|
||||
for (let v = first; v < to && out.length < 4000; v += step) out.push(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
type Draw = (pen: Pen, region: Region, cell: number, alt: Pen) => void;
|
||||
|
||||
const DRAW: Record<Exclude<BookPattern, 'sunburst'>, Draw> = {
|
||||
checker: (pen, g, s) => {
|
||||
const half = s / 2;
|
||||
steps(g.y0, g.y1, half).forEach((y, j) => {
|
||||
steps(g.x0, g.x1, half).forEach((x, i) => {
|
||||
if ((i + j) % 2 === 0) pen.rect(x, y, half, half);
|
||||
});
|
||||
});
|
||||
},
|
||||
// Two layers of half-opaque bands, so where they cross reads darker: the weave.
|
||||
gingham: (pen, g, s, alt) => {
|
||||
for (const y of steps(g.y0, g.y1, s)) pen.rect(g.x0, y, g.x1 - g.x0, s / 2);
|
||||
for (const x of steps(g.x0, g.x1, s)) alt.rect(x, g.y0, s / 2, g.y1 - g.y0);
|
||||
},
|
||||
stripes: (pen, g, s) => {
|
||||
for (const x of steps(g.x0, g.x1, s)) pen.rect(x, g.y0, s / 2, g.y1 - g.y0);
|
||||
},
|
||||
pinstripe: (pen, g, s) => {
|
||||
const w = Math.max(0.2, s * 0.06);
|
||||
for (const x of steps(g.x0, g.x1, s / 2)) pen.rect(x, g.y0, w, g.y1 - g.y0);
|
||||
},
|
||||
dots: (pen, g, s) => {
|
||||
const p = s / 2;
|
||||
for (const y of steps(g.y0, g.y1 + p, p))
|
||||
for (const x of steps(g.x0, g.x1 + p, p)) pen.circle(x + p / 2, y + p / 2, s * 0.07);
|
||||
},
|
||||
// A square lattice with a second one in its gaps: the polka of a sundress.
|
||||
polka: (pen, g, s) => {
|
||||
const r = s * 0.17;
|
||||
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
|
||||
for (const x of steps(g.x0 - s, g.x1 + s, s)) {
|
||||
pen.circle(x, y, r);
|
||||
pen.circle(x + s / 2, y + s / 2, r);
|
||||
}
|
||||
}
|
||||
},
|
||||
zigzag: (pen, g, s) => {
|
||||
const half = s / 2;
|
||||
const amp = s / 4;
|
||||
const t = s * 0.14;
|
||||
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
|
||||
const xs = steps(g.x0 - s, g.x1 + s, half);
|
||||
const top = xs.map((x, i) => [x, y + (i % 2 ? amp : 0)] as const);
|
||||
const bottom = [...top].reverse().map(([x, yy]) => [x, yy + t] as const);
|
||||
pen.raw(`M${[...top, ...bottom].map(([x, yy]) => `${f(x)} ${f(yy)}`).join('L')}Z`, Math.max(1, xs.length - 1));
|
||||
}
|
||||
},
|
||||
waves: (pen, g, s) => {
|
||||
const half = s / 2;
|
||||
const amp = s * 0.18;
|
||||
const t = s * 0.12;
|
||||
for (const y of steps(g.y0 - s, g.y1 + s, s)) {
|
||||
const xs = steps(g.x0 - s, g.x1 + s, half);
|
||||
if (xs.length < 2) continue;
|
||||
const curve = (dy: number, list: number[], dir: 1 | -1) =>
|
||||
list
|
||||
.slice(1)
|
||||
.map((x, i) => {
|
||||
const from = list[i] ?? x;
|
||||
const k = dir === 1 ? i : list.length - 2 - i;
|
||||
const bend = k % 2 ? amp : -amp;
|
||||
return `Q${f((from + x) / 2)} ${f(y + dy + bend)} ${f(x)} ${f(y + dy)}`;
|
||||
})
|
||||
.join('');
|
||||
const back = [...xs].reverse();
|
||||
const first = xs[0] ?? 0;
|
||||
const last = back[0] ?? 0;
|
||||
pen.raw(`M${f(first)} ${f(y)}${curve(0, xs, 1)}L${f(last)} ${f(y + t)}${curve(t, back, -1)}Z`, xs.length - 1);
|
||||
}
|
||||
},
|
||||
grid: (pen, g, s) => {
|
||||
const w = Math.max(0.2, s * 0.05);
|
||||
for (const x of steps(g.x0, g.x1, s)) pen.rect(x, g.y0, w, g.y1 - g.y0);
|
||||
for (const y of steps(g.y0, g.y1, s)) pen.rect(g.x0, y, g.x1 - g.x0, w);
|
||||
},
|
||||
// Lower half rings in rows half a cell apart, every other row shifted, so each scale tucks under the next.
|
||||
scales: (pen, g, s) => {
|
||||
const r = s / 2;
|
||||
const t = Math.max(0.2, s * 0.08);
|
||||
steps(g.y0 - s, g.y1 + s, s / 2).forEach((y, row) => {
|
||||
for (const x of steps(g.x0 - s, g.x1 + s, s, row % 2 ? r : 0)) {
|
||||
const cx = x + r;
|
||||
pen.raw(
|
||||
`M${f(cx - r)} ${f(y)}A${f(r)} ${f(r)} 0 0 0 ${f(cx + r)} ${f(y)}` +
|
||||
`L${f(cx + r - t)} ${f(y)}A${f(r - t)} ${f(r - t)} 0 0 1 ${f(cx - r + t)} ${f(y)}Z`,
|
||||
1
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
confetti: (pen, g, s) => {
|
||||
const rand = mulberry32(Math.round(s * 1000) + 17);
|
||||
const area = (g.x1 - g.x0) * (g.y1 - g.y0);
|
||||
const n = Math.round((area / (s * s)) * 1.2);
|
||||
for (let i = 0; i < n && !pen.full; i++) {
|
||||
const x = g.x0 + rand() * (g.x1 - g.x0);
|
||||
const y = g.y0 + rand() * (g.y1 - g.y0);
|
||||
const turn = rand() * Math.PI;
|
||||
if (rand() < 0.35) {
|
||||
pen.circle(x, y, s * (0.04 + rand() * 0.03));
|
||||
continue;
|
||||
}
|
||||
const len = s * (0.12 + rand() * 0.1);
|
||||
const wid = s * 0.05;
|
||||
const cos = Math.cos(turn);
|
||||
const sin = Math.sin(turn);
|
||||
const corner = (u: number, v: number) => [x + u * cos - v * sin, y + u * sin + v * cos] as const;
|
||||
pen.polygon([
|
||||
corner(-len / 2, -wid / 2),
|
||||
corner(len / 2, -wid / 2),
|
||||
corner(len / 2, wid / 2),
|
||||
corner(-len / 2, wid / 2),
|
||||
]);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** A seeded generator, so a confetti page draws the same scatter on every render and in print. */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** How many rays a sunburst has at a cell size: more as the cell shrinks, between 8 and 72. */
|
||||
export function sunburstRays(cell: number): number {
|
||||
const n = Math.round((24 * PATTERN_CELL_MM) / Math.max(0.1, cell));
|
||||
return Math.max(8, Math.min(72, n - (n % 2)));
|
||||
}
|
||||
|
||||
function sunburst(pen: Pen, box: PatternBox, cell: number, angle: number) {
|
||||
const cx = box.x + box.w / 2;
|
||||
const cy = box.y + box.h / 2;
|
||||
// Past the corners whichever way it turns.
|
||||
const reach = Math.hypot(box.w, box.h);
|
||||
const n = sunburstRays(cell);
|
||||
const step = (Math.PI * 2) / n;
|
||||
const start = (angle * Math.PI) / 180 - Math.PI / 2;
|
||||
for (let i = 0; i < n; i += 2) {
|
||||
const a = start + i * step;
|
||||
pen.polygon([
|
||||
[cx, cy],
|
||||
[cx + Math.cos(a) * reach, cy + Math.sin(a) * reach],
|
||||
[cx + Math.cos(a + step) * reach, cy + Math.sin(a + step) * reach],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A repeat over a box. The geometry covers the box however it is turned: the
|
||||
* region drawn is the square around the box's circumscribed circle, and the
|
||||
* caller turns it with `transform` and clips it to the shape.
|
||||
*/
|
||||
export function patternGeometry(
|
||||
paint: { pattern: BookPattern; scale: number; angle: number },
|
||||
box: PatternBox,
|
||||
cap = MAX_PATTERN_PRIMITIVES
|
||||
): PatternGeometry {
|
||||
const empty = { layers: [], count: 0, cell: PATTERN_CELL_MM * paint.scale };
|
||||
if (!(box.w > 0 && box.h > 0) || !Number.isFinite(paint.scale) || paint.scale <= 0) return empty;
|
||||
|
||||
const turned = ((paint.angle % 360) + 360) % 360 !== 0;
|
||||
const cx = box.x + box.w / 2;
|
||||
const cy = box.y + box.h / 2;
|
||||
const radius = Math.hypot(box.w, box.h) / 2;
|
||||
const region: Region =
|
||||
turned && paint.pattern !== 'sunburst'
|
||||
? { x0: cx - radius, y0: cy - radius, x1: cx + radius, y1: cy + radius }
|
||||
: { x0: box.x, y0: box.y, x1: box.x + box.w, y1: box.y + box.h };
|
||||
const rw = region.x1 - region.x0;
|
||||
const rh = region.y1 - region.y0;
|
||||
const cell = fittedCell(paint.pattern, PATTERN_CELL_MM * paint.scale, rw, rh, cap);
|
||||
|
||||
const pen = new Pen(cap);
|
||||
if (paint.pattern === 'sunburst') {
|
||||
sunburst(pen, box, cell, paint.angle);
|
||||
return { layers: [{ d: pen.d, opacity: 1 }], count: pen.count, cell };
|
||||
}
|
||||
|
||||
// The second layer only ever holds gingham's cross bands; the cell fitted above keeps both under the cap together.
|
||||
const alt = new Pen(cap);
|
||||
DRAW[paint.pattern](pen, region, cell, alt);
|
||||
const count = pen.count + alt.count;
|
||||
const layers =
|
||||
paint.pattern === 'gingham'
|
||||
? [
|
||||
{ d: pen.d, opacity: 0.5 },
|
||||
{ d: alt.d, opacity: 0.5 },
|
||||
]
|
||||
: [{ d: pen.d, opacity: 1 }];
|
||||
return {
|
||||
layers: layers.filter((l) => l.d),
|
||||
count,
|
||||
cell,
|
||||
...(turned ? { transform: `rotate(${f(paint.angle)} ${f(cx)} ${f(cy)})` } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { clusterPalettes, parseHex, toHex, toHsl, type PaletteSwatch } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The colours of a photograph, read in the browser while editing.
|
||||
*
|
||||
* The server keeps no palette for a photo the client can read, and the page
|
||||
* being edited is the one whose photos matter, so the swatches are read from
|
||||
* the pictures themselves: a thumbnail drawn small onto a canvas, its pixels
|
||||
* sorted into a few clusters by k-means. Small on purpose, 48 pixels across is
|
||||
* plenty to say a picture is mostly sea and sand, and it costs nothing next to
|
||||
* the thumbnail the page has already loaded.
|
||||
*
|
||||
* The canvas is only read here, never drawn into the book: what prints stays
|
||||
* the vector page.
|
||||
*/
|
||||
|
||||
/** How many clusters one photo is read into. */
|
||||
const PHOTO_CLUSTERS = 5;
|
||||
/** The side of the thumbnail the pixels are read from. */
|
||||
const SAMPLE = 48;
|
||||
|
||||
type Rgb = { r: number; g: number; b: number };
|
||||
|
||||
const dist2 = (a: Rgb, b: Rgb) => (a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2;
|
||||
|
||||
/** The opaque pixels of an RGBA buffer, every `stride`-th one. */
|
||||
export function samplePixels(data: ArrayLike<number>, stride = 1): Rgb[] {
|
||||
const out: Rgb[] = [];
|
||||
for (let i = 0; i + 3 < data.length; i += 4 * stride) {
|
||||
if ((data[i + 3] ?? 0) < 128) continue;
|
||||
out.push({ r: data[i] ?? 0, g: data[i + 1] ?? 0, b: data[i + 2] ?? 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* k-means over pixels, seeded by farthest points from the mean so the same
|
||||
* picture always gives the same answer: no random start, no flicker as the
|
||||
* inspector re-renders. Clusters come back largest first.
|
||||
*/
|
||||
export function kmeansPalette(pixels: readonly Rgb[], k = PHOTO_CLUSTERS, rounds = 8): PaletteSwatch[] {
|
||||
if (!pixels.length || k < 1) return [];
|
||||
const mean = pixels.reduce((m, p) => ({ r: m.r + p.r, g: m.g + p.g, b: m.b + p.b }), { r: 0, g: 0, b: 0 });
|
||||
const first = { r: mean.r / pixels.length, g: mean.g / pixels.length, b: mean.b / pixels.length };
|
||||
const centres: Rgb[] = [first];
|
||||
while (centres.length < k) {
|
||||
let best: Rgb | null = null;
|
||||
let bestD = 0;
|
||||
for (const p of pixels) {
|
||||
const d = Math.min(...centres.map((c) => dist2(c, p)));
|
||||
if (d > bestD) {
|
||||
bestD = d;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
if (!best) break;
|
||||
centres.push({ ...best });
|
||||
}
|
||||
|
||||
let sizes: number[] = centres.map(() => 0);
|
||||
for (let round = 0; round < rounds; round++) {
|
||||
const sums = centres.map(() => ({ r: 0, g: 0, b: 0, n: 0 }));
|
||||
for (const p of pixels) {
|
||||
let at = 0;
|
||||
let near = Infinity;
|
||||
centres.forEach((c, i) => {
|
||||
const d = dist2(c, p);
|
||||
if (d < near) {
|
||||
near = d;
|
||||
at = i;
|
||||
}
|
||||
});
|
||||
const s = sums[at];
|
||||
if (!s) continue;
|
||||
s.r += p.r;
|
||||
s.g += p.g;
|
||||
s.b += p.b;
|
||||
s.n++;
|
||||
}
|
||||
sums.forEach((s, i) => {
|
||||
if (s.n) centres[i] = { r: s.r / s.n, g: s.g / s.n, b: s.b / s.n };
|
||||
});
|
||||
sizes = sums.map((s) => s.n);
|
||||
}
|
||||
|
||||
return centres
|
||||
.map((c, i) => ({ hex: toHex(c), share: (sizes[i] ?? 0) / pixels.length }))
|
||||
.filter((s) => s.share > 0)
|
||||
.sort((a, b) => b.share - a.share || a.hex.localeCompare(b.hex));
|
||||
}
|
||||
|
||||
/**
|
||||
* The swatches several photos offer together: the clusters they share, most
|
||||
* common first, with near-greys and near-duplicates thinned so the row is a
|
||||
* choice of colours rather than five shades of the same overcast sky.
|
||||
*/
|
||||
export function photoSwatches(palettes: readonly PaletteSwatch[][], limit = 6): string[] {
|
||||
const out: string[] = [];
|
||||
let greys = 0;
|
||||
for (const swatch of clusterPalettes(palettes, 10)) {
|
||||
const rgb = parseHex(swatch.hex);
|
||||
if (!rgb) continue;
|
||||
const grey = toHsl(rgb).s < 0.12;
|
||||
// One grey is a useful neutral; a second is the same colour again.
|
||||
if (grey && greys >= 1) continue;
|
||||
if (grey) greys++;
|
||||
out.push(swatch.hex);
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read one picture's palette. Resolves empty when the picture cannot be read, never rejects. */
|
||||
export function readPhotoPalette(src: string, signal?: AbortSignal): Promise<PaletteSwatch[]> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted || typeof document === 'undefined') {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.decoding = 'async';
|
||||
const done = (out: PaletteSwatch[]) => {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
resolve(out);
|
||||
};
|
||||
const abort = () => {
|
||||
img.src = '';
|
||||
done([]);
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
img.onerror = () => done([]);
|
||||
img.onload = () => done(paletteOfImage(img));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function paletteOfImage(img: HTMLImageElement): PaletteSwatch[] {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = SAMPLE;
|
||||
canvas.height = SAMPLE;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return [];
|
||||
try {
|
||||
ctx.drawImage(img, 0, 0, SAMPLE, SAMPLE);
|
||||
return kmeansPalette(samplePixels(ctx.getImageData(0, 0, SAMPLE, SAMPLE).data, 2));
|
||||
} catch {
|
||||
// A picture from another origin taints the canvas; it simply offers no swatches.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { BookDocument, BookPaint } from '@trek/shared';
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react';
|
||||
import { useStudioStore } from '../../../store/studioStore';
|
||||
|
||||
/**
|
||||
* How the paint picker writes: a value set while a control is dragged is
|
||||
* `live`, and the one it is let go on is `commit`.
|
||||
*
|
||||
* A drag of a gradient stop or the angle dial passes through dozens of
|
||||
* values, and each would otherwise be an undo step. So the first live value
|
||||
* opens a gesture on the store, every live value after it moves the document
|
||||
* inside that gesture, and the commit closes it: one drag, one undo. A click
|
||||
* that is a commit with no live value before it is one ordinary step.
|
||||
*/
|
||||
export interface PaintChannel {
|
||||
live: (paint: BookPaint | null) => void;
|
||||
commit: (paint: BookPaint | null) => void;
|
||||
}
|
||||
|
||||
export function usePaintChannel(write: (paint: BookPaint | null) => (doc: BookDocument) => BookDocument): PaintChannel {
|
||||
const begin = useStudioStore((s) => s.beginGesture);
|
||||
const end = useStudioStore((s) => s.endGesture);
|
||||
const apply = useStudioStore((s) => s.apply);
|
||||
const commit = useStudioStore((s) => s.commit);
|
||||
const open = useRef(false);
|
||||
// The writer is a fresh closure on every render; the channel reads the latest one.
|
||||
const writer = useRef(write);
|
||||
useLayoutEffect(() => {
|
||||
writer.current = write;
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
live: (paint) => {
|
||||
if (!open.current) {
|
||||
begin();
|
||||
open.current = true;
|
||||
}
|
||||
apply(writer.current(paint));
|
||||
},
|
||||
commit: (paint) => {
|
||||
if (!open.current) {
|
||||
commit(writer.current(paint));
|
||||
return;
|
||||
}
|
||||
apply(writer.current(paint));
|
||||
end();
|
||||
open.current = false;
|
||||
},
|
||||
}),
|
||||
[begin, end, apply, commit]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
/**
|
||||
* Defs ids for paint, unique per mounted copy of a page (paintRender.tsx says why).
|
||||
*/
|
||||
|
||||
/** A defs id unique to this mounted instance, safe inside `url(#...)`. */
|
||||
export function usePaintId(prefix: string): string {
|
||||
const raw = useId();
|
||||
return `${prefix}-${raw.replace(/[^\w-]/g, '')}`;
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -595,9 +611,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 +815,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>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import type { PlaceFormOpeners } from './usePlaceFormOpeners';
|
||||
|
||||
/**
|
||||
* A place the assistant found, opened in the place form when its card is
|
||||
* clicked: filled in with what the search knew, the details column reading
|
||||
* the rest (photos, hours, description) by its Google id. The form is the
|
||||
* planner's own, so the place is saved as any other, on the day in focus.
|
||||
*/
|
||||
export function useAssistantPlaceDraft(tripId: number, openAddPlaceFromPoi: PlaceFormOpeners['openAddPlaceFromPoi']) {
|
||||
const draft = useAssistantStore((s) => (s.placeDraft?.tripId === tripId ? s.placeDraft : null));
|
||||
const takePlaceDraft = useAssistantStore((s) => s.takePlaceDraft);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) return;
|
||||
takePlaceDraft(draft.seq);
|
||||
const { place } = draft;
|
||||
openAddPlaceFromPoi(
|
||||
{
|
||||
lat: place.lat,
|
||||
lng: place.lng,
|
||||
name: place.name,
|
||||
address: place.address,
|
||||
website: null,
|
||||
phone: null,
|
||||
osm_id: place.osmId ?? '',
|
||||
google_place_id: place.googlePlaceId,
|
||||
poi_type: place.kind,
|
||||
},
|
||||
draft.dayId
|
||||
);
|
||||
}, [draft, openAddPlaceFromPoi, takePlaceDraft]);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function usePlaceFormOpeners(options: PlaceFormOpenersOptions) {
|
||||
// Open the Add-Place form pre-filled from an OSM "explore" POI marker: all the
|
||||
// data already comes from the POI, so no reverse-geocode is needed.
|
||||
const openAddPlaceFromPoi = useCallback((
|
||||
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string; category?: string | null; poi_type?: string | null },
|
||||
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string; google_place_id?: string | null; category?: string | null; poi_type?: string | null },
|
||||
dayId?: number | null,
|
||||
/** Index within that day. Omitted, the place is appended, which is what every caller did before. */
|
||||
position?: number | null,
|
||||
@@ -100,6 +100,7 @@ export function usePlaceFormOpeners(options: PlaceFormOpenersOptions) {
|
||||
// A plugin POI's `plugin:<pluginId>:<id>` rides along as it is. The server never
|
||||
// takes that prefix for a Google place id, so the details column makes no Google call.
|
||||
osm_id: poi.osm_id,
|
||||
google_place_id: poi.google_place_id || undefined,
|
||||
// What the map search filed it under, so the form can preselect a category (#2282).
|
||||
category: poi.poi_type || poi.category || undefined,
|
||||
stop_type: stop?.stopType ?? null,
|
||||
|
||||
@@ -27,7 +27,7 @@ export function usePlannerDialogs(options: PlannerDialogsOptions) {
|
||||
const { tripId, tripActions, searchParams, setSearchParams } = options
|
||||
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
|
||||
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
|
||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null>(null)
|
||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; google_place_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null>(null)
|
||||
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
|
||||
// Day context of the open form. Set only by the day-scoped entry points (the
|
||||
// mobile day toolbar, a long-press on the mobile map); every other opener
|
||||
|
||||
@@ -52,6 +52,7 @@ import { usePlannerDialogs } from './usePlannerDialogs'
|
||||
import { usePlaceDeepLink } from './usePlaceDeepLink'
|
||||
import { useAssistantDeepLink } from './useAssistantDeepLink'
|
||||
import { useAssistantCompareView, useAssistantMapLink } from './useAssistantMapLink'
|
||||
import { useAssistantPlaceDraft } from './useAssistantPlaceDraft'
|
||||
import { usePlannerMapView } from './usePlannerMapView'
|
||||
import { useMapPlaces } from './useMapPlaces'
|
||||
import { useRoadtripFeed } from './useRoadtripFeed'
|
||||
@@ -593,6 +594,7 @@ export function useTripPlanner() {
|
||||
setPrefillCoords, setEditingPlace, setEditingAssignmentId, setPlaceFormDayId, setPlaceFormPosition, setShowPlaceForm,
|
||||
roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripCorridor, manualStopTargetFor, isTourPlace,
|
||||
})
|
||||
useAssistantPlaceDraft(tripId, openAddPlaceFromPoi)
|
||||
|
||||
const {
|
||||
handleReorder, handleReorderDays, handleUpdateDayTitle, handleMoveToDay, handleRemoveAssignment,
|
||||
|
||||
@@ -79,7 +79,12 @@ describe('aiConnectionsRepo', () => {
|
||||
});
|
||||
|
||||
it('FE-AI-CONN-REPO-004: reads and writes the admin settings and the instance connection', async () => {
|
||||
const settings = { policy: 'user' as const, dailyTurns: 50, retention: { mode: 'forever' as const } };
|
||||
const settings = {
|
||||
policy: 'user' as const,
|
||||
dailyTurns: 50,
|
||||
webSearch: true,
|
||||
retention: { mode: 'forever' as const },
|
||||
};
|
||||
const connection: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
...VIEW,
|
||||
@@ -102,6 +107,7 @@ describe('aiConnectionsRepo', () => {
|
||||
await expect(aiConnectionsRepo.saveAdminSettings({ ...settings, dailyTurns: 20 })).resolves.toEqual({
|
||||
...settings,
|
||||
dailyTurns: 20,
|
||||
webSearch: true,
|
||||
});
|
||||
await expect(aiConnectionsRepo.adminConnection()).resolves.toEqual(connection);
|
||||
await expect(aiConnectionsRepo.adminModels()).resolves.toEqual([
|
||||
|
||||
@@ -63,6 +63,8 @@ export function applyChunk(
|
||||
]);
|
||||
case 'data-trek-weather':
|
||||
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-weather', data: chunk.data }]);
|
||||
case 'data-trek-sources':
|
||||
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-sources', data: chunk.data }]);
|
||||
case 'data-trek-follow-ups':
|
||||
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-follow-ups', data: chunk.data }]);
|
||||
case 'data-trek-card':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AssistantDecision,
|
||||
type AssistantErrorCode,
|
||||
type AssistantFocus,
|
||||
type AssistantFoundPlace,
|
||||
type AssistantMapView,
|
||||
type AssistantMention,
|
||||
type AssistantMessage,
|
||||
@@ -149,6 +150,8 @@ interface AssistantState {
|
||||
routeFocus: { tripId: number; ref: string; focus: RouteProfileFocus; source: 'card' | 'map' } | null;
|
||||
/** A line a card asks the map to draw while the pointer rests on it (a day of a drive, a saved tour). */
|
||||
routePreview: { tripId: number; ref: string; lines: LatLng[][]; color: string } | null;
|
||||
/** A found place to open in the planner's place form, filled in; `seq` tells a new one from the same place asked again. */
|
||||
placeDraft: { tripId: number; seq: number; place: AssistantFoundPlace; dayId: number | null } | null;
|
||||
/** The route card the phone shows over the map. */
|
||||
routePeek: { tripId: number; toolCallId: string } | null;
|
||||
/** Where the floating window sits after its last move or resize, for the map to frame around it. */
|
||||
@@ -207,6 +210,9 @@ interface AssistantState {
|
||||
|
||||
setMapHover: (tripId: number, hoveredId: string | null, source: AssistantMapSource) => void;
|
||||
requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') => void;
|
||||
/** Opens a found place in the planner's place form; the planner takes it with `takePlaceDraft`. */
|
||||
openPlaceDraft: (tripId: number, place: AssistantFoundPlace, dayId: number | null) => void;
|
||||
takePlaceDraft: (seq: number) => void;
|
||||
setPreviewPin: (preview: AssistantState['preview']) => void;
|
||||
setWindowRect: (rect: WindowRect | null) => void;
|
||||
setMapView: (mapView: AssistantState['mapView']) => void;
|
||||
@@ -820,6 +826,11 @@ function mapActions(ctx: Ctx) {
|
||||
},
|
||||
requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') =>
|
||||
requestCameraOn(ctx, tripId, points, mode, why),
|
||||
openPlaceDraft: (tripId: number, place: AssistantFoundPlace, dayId: number | null) =>
|
||||
ctx.set({ placeDraft: { tripId, seq: (ctx.get().placeDraft?.seq ?? 0) + 1, place, dayId } }),
|
||||
takePlaceDraft: (seq: number) => {
|
||||
if (ctx.get().placeDraft?.seq === seq) ctx.set({ placeDraft: null });
|
||||
},
|
||||
setPreviewPin: (preview: AssistantState['preview']) => ctx.set({ preview }),
|
||||
setWindowRect: (rect: WindowRect | null) => ctx.set({ windowRect: rect }),
|
||||
setMapView: (mapView: AssistantState['mapView']) => ctx.set({ mapView }),
|
||||
@@ -923,6 +934,7 @@ export const useAssistantStore = create<AssistantState>((set, get) => {
|
||||
mapLink: null,
|
||||
camera: null,
|
||||
preview: null,
|
||||
placeDraft: null,
|
||||
windowRect: null,
|
||||
mapView: null,
|
||||
studioHosts: {},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,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
|
||||
/**
|
||||
@@ -135,7 +144,9 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
gestureBase: null,
|
||||
clipboard: null,
|
||||
hintSpread: null,
|
||||
readOnly: false,
|
||||
|
||||
setReadOnly: readOnly => set({ readOnly, gestureBase: null }),
|
||||
load: doc => set({ doc, selection: [], 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 }
|
||||
@@ -180,13 +191,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 => ({
|
||||
@@ -420,16 +431,16 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
|
||||
undo: () => set(s => {
|
||||
const prev = s.past[s.past.length - 1]
|
||||
if (!prev || !s.doc) return {}
|
||||
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: [] }
|
||||
}),
|
||||
|
||||
redo: () => set(s => {
|
||||
const next = s.future[0]
|
||||
if (!next || !s.doc) return {}
|
||||
if (!next || !s.doc || s.readOnly) return {}
|
||||
return { doc: next, future: s.future.slice(1), past: [...s.past, s.doc].slice(-HISTORY_LIMIT), selection: [] }
|
||||
}),
|
||||
|
||||
canUndo: () => get().past.length > 0,
|
||||
canRedo: () => get().future.length > 0,
|
||||
canUndo: () => !get().readOnly && get().past.length > 0,
|
||||
canRedo: () => !get().readOnly && get().future.length > 0,
|
||||
}))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user