Compare commits

...

10 Commits

50 changed files with 1088 additions and 505 deletions
+1 -1
View File
@@ -1300,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => p.locator('.st-back'),
target: p => p.locator('.st-head-close'),
},
],
},
+1 -1
View File
@@ -26,5 +26,5 @@
"src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 2009
"src/styles/studio.css": 1936
}
@@ -195,7 +195,11 @@ function useElementAdders(page: BookPageSetup) {
const els = gridElements(grid, defaultGridBox(page, single))
// The page's own cap, as a duplicate keeps it: frames that would make the book unsaveable are not added.
const on = useStudioStore.getState().doc?.spreads[active]
if (!on || on.elements.length + els.length > MAX_SPREAD_ELEMENTS) return
if (!on) return
if (on.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
useStudioStore.getState().refuseFull()
return
}
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { StudioHeader } from './StudioHeader';
import type { Studio } from './StudioShell';
import type { StudioAssistant } from './useStudioAssistant';
/**
* FE-STUDIO-HEAD-001 to FE-STUDIO-HEAD-007: the Studio's head band, with the
* hook's answer stubbed to what the band reads.
*/
function studio(over: Partial<Studio> = {}): Studio {
return {
t: (key: string) => key,
journey: { title: 'Japan 2026' },
coverUrl: '/uploads/cover.jpg',
saveState: { status: 'idle' },
peers: [],
canUndo: true,
canRedo: false,
canEdit: true,
activeSpread: 0,
doc: { spreads: [] },
preset: 'square-210',
page: { pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
canRelayoutSpread: true,
undo: vi.fn(),
redo: vi.fn(),
close: vi.fn(),
setPreset: vi.fn(),
setPageSize: vi.fn(),
setPageEdge: vi.fn(),
relayoutCurrentSpread: vi.fn(),
relayoutBook: vi.fn(),
loadDoc: vi.fn(),
acceptTheirs: vi.fn(),
keepMine: vi.fn(),
saveNow: vi.fn(),
...over,
} as unknown as Studio;
}
const noAssistant = { available: false } as unknown as StudioAssistant;
function head(s = studio(), onExport = vi.fn()) {
render(<StudioHeader s={s} onExport={onExport} assistant={noAssistant} />);
return { s, onExport };
}
describe('StudioHeader', () => {
it("FE-STUDIO-HEAD-001: names the journey under the Studio's mark, with its cover as the tile", () => {
head();
expect(screen.getByRole('heading', { name: 'Japan 2026' })).toBeInTheDocument();
expect(screen.getByText('journey.studio.beta')).toBeInTheDocument();
expect(document.querySelector('.st-head-tile img')?.getAttribute('src')).toBe('/uploads/cover.jpg');
});
it('FE-STUDIO-HEAD-002: the round button closes the Studio, export hands over', () => {
const { s, onExport } = head();
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.backToJourney' }));
expect(s.close).toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /journey.studio.export/ }));
expect(onExport).toHaveBeenCalled();
});
it('FE-STUDIO-HEAD-003: undo runs, redo waits for something to redo, paste for something copied', () => {
const { s } = head();
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.undo' }));
expect(s.undo).toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'journey.studio.redo' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'journey.studio.paste' })).toBeDisabled();
});
it('FE-STUDIO-HEAD-004: the format chip names the format and opens the list, a pick sets it', () => {
const { s } = head();
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
expect(chip).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(chip);
const options = screen.getAllByRole('option');
expect(options.length).toBeGreaterThan(1);
fireEvent.click(options[options.length - 1]);
expect(s.setPreset).toHaveBeenCalled();
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
it('FE-STUDIO-HEAD-005: a custom format reads its size, and Escape or a press outside closes the list', () => {
head(
studio({ preset: 'custom', page: { pageWidth: 180.4, pageHeight: 240, bleed: 3, safe: 5 } } as Partial<Studio>)
);
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
expect(chip).toHaveTextContent('180 × 240 mm');
fireEvent.click(chip);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
fireEvent.click(chip);
fireEvent.pointerDown(document.body);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
it('FE-STUDIO-HEAD-006: auto layout offers this spread and the whole book', () => {
const { s } = head();
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
fireEvent.click(screen.getByRole('menuitem', { name: /journey.studio.relayoutSpread\b/ }));
expect(s.relayoutCurrentSpread).toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
fireEvent.click(screen.getByRole('menuitem', { name: /journey.studio.relayoutBook\b/ }));
expect(s.relayoutBook).toHaveBeenCalled();
});
it('FE-STUDIO-HEAD-007: with something copied, paste puts it on the page on screen', () => {
const paste = vi.fn();
useStudioStore.setState({ clipboard: { elements: [] } as never, paste } as never);
head(studio({ activeSpread: 2 } as Partial<Studio>));
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.paste' }));
expect(paste).toHaveBeenCalledWith(2);
});
});
@@ -0,0 +1,379 @@
import { Check, ChevronDown, ClipboardPaste, Download, Redo2, Sparkles, Undo2, X } from 'lucide-react';
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-head.css';
import HelpButton from '../Help/HelpButton';
import { Tooltip } from '../shared/Tooltip';
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets';
import { PeerBadges } from './PeerBadges';
import { SaveIndicator } from './SaveIndicator';
import { StudioAssistantToggle } from './StudioAssistantPanel';
import type { Studio } from './StudioShell';
import { StudioWordmark } from './StudioWordmark';
import { TrimField } from './TrimField';
import type { StudioAssistant } from './useStudioAssistant';
/**
* The Studio's head band, in the shape every TREK dialog has: a raised tile on
* the left, a small line above the title, the title, a line under it, and the
* round close button at the far end.
*
* The tile is the journey's cover and the title its name, so it is always clear
* whose book this is. What the book is printed as and whether it is saved sit
* under the title, because they describe it. The tools on the right are grouped
* by what they do: who else is here, editing (undo, redo, paste), making
* (auto layout, the assistant), and getting the book out (help, export). How
* the page is looked at, the book view, lives with the zoom under the page.
*/
export function StudioHeader({
s,
onExport,
assistant,
}: {
s: Studio;
onExport: () => void;
assistant: StudioAssistant;
}) {
return (
<header className="st-bar st-head">
<span className="st-head-tile">{s.coverUrl ? <img src={s.coverUrl} alt="" /> : null}</span>
<div className="st-head-text">
<div className="st-head-eyebrow">
<StudioWordmark style={{ height: 17 }} />
<span className="st-beta">{s.t('journey.studio.beta')}</span>
</div>
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
<div className="st-head-sub">
<FormatPicker s={s} />
<SaveIndicator
state={s.saveState}
t={s.t}
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
onKeepMine={s.keepMine}
onRetry={() => {
void s.saveNow();
}}
/>
</div>
</div>
<div className="st-head-tools">
<PeerBadges peers={s.peers} t={s.t} />
<div className="st-head-group">
<IconTool label={s.t('journey.studio.undo')} disabled={!s.canUndo} onClick={s.undo}>
<Undo2 size={15} />
</IconTool>
<IconTool label={s.t('journey.studio.redo')} disabled={!s.canRedo} onClick={s.redo}>
<Redo2 size={15} />
</IconTool>
<PasteButton s={s} />
</div>
<div className="st-head-group">
<AutoLayoutButton s={s} />
{/* The book's assistant; only where it can run for this user. */}
<StudioAssistantToggle assistant={assistant} />
</div>
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
<HelpButton className="st-tool is-icon" />
<button
type="button"
className="st-tool is-primary"
onClick={onExport}
disabled={!s.doc}
title={s.t('journey.studio.export')}
>
<Download size={14} />
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
</button>
<Tooltip label={s.t('journey.studio.backToJourney')}>
<button
type="button"
onClick={s.close}
className="st-head-close"
aria-label={s.t('journey.studio.backToJourney')}
>
<X size={16} strokeWidth={2.2} />
</button>
</Tooltip>
</div>
</header>
);
}
function IconTool({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<Tooltip label={label}>
<button type="button" className="st-tool is-icon" disabled={disabled} onClick={onClick} aria-label={label}>
{children}
</button>
</Tooltip>
);
}
/**
* Closes a popover on a pointer press outside it and on Escape. Escape is
* swallowed, or the shell's own handler would close Studio itself.
*/
function useDismiss(box: RefObject<HTMLElement | null>, open: boolean, setOpen: (open: boolean) => void) {
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
setOpen(false);
}
};
document.addEventListener('pointerdown', onDown);
document.addEventListener('keydown', onKey, true);
return () => {
document.removeEventListener('pointerdown', onDown);
document.removeEventListener('keydown', onKey, true);
};
}, [box, open, setOpen]);
}
/**
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
* because it is the same kind of thing, and there so the keyboard shortcut is
* not the only way to find it.
*/
function PasteButton({ s }: { s: Studio }) {
const clipboard = useStudioStore((st) => st.clipboard);
const paste = useStudioStore((st) => st.paste);
const label = s.t('journey.studio.paste');
return (
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
<button
type="button"
className="st-tool is-icon"
disabled={!clipboard || !s.canEdit}
onClick={() => paste(s.activeSpread)}
aria-label={label}
>
<ClipboardPaste size={15} />
</button>
</Tooltip>
);
}
/**
* Lay out again: this spread, or the whole book.
*
* A split button rather than two: they are the same action at two scopes, and
* the spread is the one you want nine times out of ten. Rebuilding the whole
* book is behind the chevron, where it cannot be hit by accident, and both are
* ordinary undo steps.
*/
function AutoLayoutButton({ s }: { s: Studio }) {
const [open, setOpen] = useState(false);
const box = useRef<HTMLDivElement>(null);
useDismiss(box, open, setOpen);
return (
<div className="st-picker" ref={box}>
<button
type="button"
className="st-tool"
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
title={s.t('journey.studio.autoLayout')}
>
<Sparkles size={14} />
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
<ChevronDown size={13} style={{ opacity: 0.5 }} />
</button>
{open && (
<div className="st-menu" role="menu">
<button
type="button"
className="st-menu-item"
role="menuitem"
disabled={!s.canRelayoutSpread}
onClick={() => {
s.relayoutCurrentSpread();
setOpen(false);
}}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
<span className="st-menu-dim">
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
</span>
</span>
</button>
<button
type="button"
className="st-menu-item"
role="menuitem"
onClick={() => {
s.relayoutBook();
setOpen(false);
}}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
</span>
</button>
</div>
)}
</div>
);
}
/**
* The format, as a chip under the book's name: it says what the book is
* printed as, and opening it changes that.
*
* A native <select> was the first draft and it was wrong: the browser draws the
* list in its own chrome, so it ignores the theme entirely and drops a list that
* runs off the bottom of the screen. This is a small popover instead, themed,
* anchored under the chip, and it shows each format's proportions rather than
* only naming them.
*/
function FormatPicker({ s }: { s: Studio }) {
const [open, setOpen] = useState(false);
const box = useRef<HTMLDivElement>(null);
useDismiss(box, open, setOpen);
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210'];
const width = s.preset === 'custom' ? s.page.pageWidth : active.pageWidthMm;
const height = s.preset === 'custom' ? s.page.pageHeight : active.pageHeightMm;
return (
<div className="st-picker" ref={box}>
<button
type="button"
className="st-head-chip"
onClick={() => setOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={s.t('journey.studio.format')}
>
<span
className="st-head-chip-shape"
style={
width >= height
? { width: 12, height: Math.max(6, Math.round((12 * height) / width)) }
: { height: 12, width: Math.max(6, Math.round((12 * width) / height)) }
}
/>
<span>
{s.preset === 'custom'
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
: s.t(active.labelKey)}
</span>
<ChevronDown size={12} style={{ opacity: 0.55 }} />
</button>
{open && (
<div className="st-menu st-head-menu" role="listbox">
{PAGE_PRESET_ORDER.map((id) => {
const p = PAGE_PRESETS[id];
const on = id === s.preset;
return (
<button
type="button"
key={id}
role="option"
aria-selected={on}
className={`st-menu-item ${on ? 'is-active' : ''}`}
onClick={() => {
s.setPreset(id);
setOpen(false);
}}
>
<span className="st-menu-tile">
<span
className="st-menu-shape"
style={
p.pageWidthMm >= p.pageHeightMm
? { width: 24, height: Math.round((24 * p.pageHeightMm) / p.pageWidthMm) }
: { height: 24, width: Math.round((24 * p.pageWidthMm) / p.pageHeightMm) }
}
/>
</span>
<span className="st-menu-text">
<span className="st-menu-name">{s.t(p.labelKey)}</span>
<span className="st-menu-dim">
{p.pageWidthMm} × {p.pageHeightMm} mm
</span>
</span>
{on && <Check size={14} />}
</button>
);
})}
{/*
The two fields live under the list rather than behind a "custom"
entry that opens a second popover: picking the custom row and then
typing into the fields it reveals is two steps for one decision, and
typing into them *is* picking custom; the hook switches the preset
for you.
*/}
<div className="st-menu-sep" />
<div className="st-menu-custom">
<TrimField
label={s.t('journey.studio.width')}
value={s.page.pageWidth}
onCommit={(v) => s.setPageSize('w', v)}
/>
<span className="st-menu-times">×</span>
<TrimField
label={s.t('journey.studio.height')}
value={s.page.pageHeight}
onCommit={(v) => s.setPageSize('h', v)}
/>
<span className="st-menu-unit">mm</span>
</div>
{/*
What the press takes off, and what it must not print into. Under the
trim size because they answer the same question, how big is this book
really, and because a vendor states all three in one breath.
*/}
<div className="st-menu-custom">
<TrimField
label={s.t('journey.studio.bleed')}
value={s.page.bleed}
min={0}
max={20}
step={0.5}
onCommit={(v) => s.setPageEdge('bleed', v)}
/>
<span className="st-menu-times" />
<TrimField
label={s.t('journey.studio.safeArea')}
value={s.page.safe}
min={0}
max={40}
step={0.5}
onCommit={(v) => s.setPageEdge('safe', v)}
/>
<span className="st-menu-unit">mm</span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,23 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { useStudioStore } from '../../store/studioStore';
import { useToast } from '../shared/Toast';
/**
* What the Studio says about a click it could not carry out. Today one thing:
* the page already holds as many elements as a page can, so a sticker, a frame,
* a paste or a duplicate was not added. Every path that refuses counts it in the
* store, and this says it once per count rather than each path in its own words.
*/
export function StudioNotices() {
const { t } = useTranslation();
const toast = useToast();
const refused = useStudioStore((s) => s.refusedFull);
const seen = useRef(refused);
useEffect(() => {
if (refused === seen.current) return;
seen.current = refused;
toast.warning(t('journey.studio.pageFull'));
}, [refused, toast, t]);
return null;
}
+21 -355
View File
@@ -1,12 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router'
import {
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, Minus, Plus,
Redo2, Sparkles, Undo2,
} from 'lucide-react'
import { BookOpen, Download, Maximize2, Minus, Plus } from 'lucide-react'
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
import { foliosOf } from './bookSheets'
import { StudioSidebar } from './StudioSidebar'
import type { RailSection } from './sidebar/railSections'
@@ -15,21 +11,16 @@ 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'
import { StudioExport } from './StudioExport'
import { PeerBadges } from './PeerBadges'
import { TrimField } from './TrimField'
import { Tooltip } from '../shared/Tooltip'
import { useStudioStore } from '../../store/studioStore'
import { StudioAssistantColumn, StudioAssistantToggle, StudioPageStage } from './StudioAssistantPanel'
import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
import { StudioAssistantColumn, StudioPageStage } from './StudioAssistantPanel'
import { useStudioAssistant } from './useStudioAssistant'
import { StudioHeader } from './StudioHeader'
import { StudioNotices } from './StudioNotices'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
import './bookFontFaces'
import { useWarmBookFaces } from './bookFontWarm'
import HelpButton from '../Help/HelpButton'
/**
* The Studio shell: top bar, page rail, workbench, inspector.
@@ -93,13 +84,8 @@ export default function StudioShell() {
aria-modal="true"
aria-label={s.t('journey.studio.title')}
>
<StudioBar
s={s}
bookView={bookView}
setBookView={setBookView}
onExport={() => setExporting(true)}
assistant={assistant}
/>
<StudioHeader s={s} onExport={() => setExporting(true)} assistant={assistant} />
<StudioNotices />
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
{s.providerPick && s.journey && (
<ProviderPicker
@@ -132,7 +118,7 @@ export default function StudioShell() {
section={railSection}
onSection={setRailSection}
/>
<Workbench s={s} bookView={bookView} />
<Workbench s={s} bookView={bookView} setBookView={setBookView} />
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
<StudioInspector
spreadIndex={s.activeSpread}
@@ -164,339 +150,9 @@ export default function StudioShell() {
)
}
type Studio = ReturnType<typeof useJourneyStudio>
export type Studio = ReturnType<typeof useJourneyStudio>
function StudioBar({
s, bookView, setBookView, onExport, assistant,
}: {
s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void
assistant: StudioAssistant
}) {
return (
<div className="st-bar">
<button type="button" onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
<ArrowLeft size={16} />
{s.coverUrl
? <img src={s.coverUrl} alt="" className="st-cover" />
: <span className="st-cover" />}
<span className="st-back-label">{s.journey?.title || s.t('journey.title')}</span>
</button>
<div className="st-sep" />
{/*
The lockup, not a name field. The book is the journey's, and it was
already titled after it — a second place to type the same name is a
field whose only job is to be left alone. What the bar is short of is
somewhere to say what this thing *is*.
*/}
<div className="st-brand">
<StudioWordmark style={{ height: 26 }} />
<span className="st-beta">{s.t('journey.studio.beta')}</span>
</div>
<PeerBadges peers={s.peers} t={s.t} />
<SaveIndicator
state={s.saveState}
t={s.t}
onAcceptTheirs={current => s.loadDoc(s.acceptTheirs(current))}
onKeepMine={s.keepMine}
onRetry={() => { void s.saveNow() }}
/>
<div className="st-bar-group">
<button type="button"
className={`st-tool ${bookView ? 'is-on' : ''}`}
onClick={() => setBookView(!bookView)}
title={s.t('journey.studio.bookView')}
>
<BookOpen size={14} />
<span className="st-tool-label">{s.t('journey.studio.bookView')}</span>
</button>
<div className="st-sep" />
<FormatPicker s={s} />
<div className="st-sep" />
<button type="button"
className="st-tool is-icon"
disabled={!s.canUndo}
onClick={s.undo}
title={s.t('journey.studio.undo')}
aria-label={s.t('journey.studio.undo')}
>
<Undo2 size={15} />
</button>
<button type="button"
className="st-tool is-icon"
disabled={!s.canRedo}
onClick={s.redo}
title={s.t('journey.studio.redo')}
aria-label={s.t('journey.studio.redo')}
>
<Redo2 size={15} />
</button>
<PasteButton s={s} />
<div className="st-sep" />
<AutoLayoutButton s={s} />
{/* The book's assistant, as a column beside the page; only where it can run for this user. */}
<StudioAssistantToggle assistant={assistant} />
<button type="button"
className="st-tool is-primary"
onClick={onExport}
disabled={!s.doc}
title={s.t('journey.studio.export')}
>
<Download size={14} />
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
</button>
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
<HelpButton className="st-tool is-icon" />
</div>
</div>
)
}
/**
* Lay out again — this spread, or the whole book.
*
* A split button rather than two: they are the same action at two scopes, and
* the spread is the one you want nine times out of ten. Rebuilding the whole
* book is behind the chevron, where it cannot be hit by accident, and both are
* ordinary undo steps.
*/
/**
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
* because it is the same kind of thing, and there so the keyboard shortcut is
* not the only way to find it.
*/
function PasteButton({ s }: { s: Studio }) {
const clipboard = useStudioStore(st => st.clipboard)
const paste = useStudioStore(st => st.paste)
const label = s.t('journey.studio.paste')
return (
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
<button type="button"
className="st-tool is-icon"
disabled={!clipboard || !s.canEdit}
onClick={() => paste(s.activeSpread)}
aria-label={label}
>
<ClipboardPaste size={15} />
</button>
</Tooltip>
)
}
function AutoLayoutButton({ s }: { s: Studio }) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: PointerEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
}
document.addEventListener('pointerdown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('pointerdown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [open])
return (
<div className="st-picker" ref={box}>
<button type="button"
className="st-tool"
onClick={() => setOpen(o => !o)}
aria-haspopup="menu"
aria-expanded={open}
title={s.t('journey.studio.autoLayout')}
>
<Sparkles size={14} />
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
<ChevronDown size={13} style={{ opacity: .5 }} />
</button>
{open && (
<div className="st-menu" role="menu">
<button type="button"
className="st-menu-item"
role="menuitem"
disabled={!s.canRelayoutSpread}
onClick={() => { s.relayoutCurrentSpread(); setOpen(false) }}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
<span className="st-menu-dim">
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
</span>
</span>
</button>
<button type="button"
className="st-menu-item"
role="menuitem"
onClick={() => { s.relayoutBook(); setOpen(false) }}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
</span>
</button>
</div>
)}
</div>
)
}
/**
* The format picker.
*
* A native <select> was the first draft and it was wrong: the browser draws the
* list in its own chrome, so it ignores the theme entirely and drops a list that
* runs off the bottom of the screen. This is a small popover instead — themed,
* anchored under the bar, and it shows each format's proportions rather than
* only naming them.
*/
function FormatPicker({ s }: { s: Studio }) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: PointerEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
// Swallow it, or the shell's Escape handler would close Studio itself.
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
}
document.addEventListener('pointerdown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('pointerdown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [open])
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210']
return (
<div className="st-picker" ref={box}>
<button type="button"
className="st-tool"
onClick={() => setOpen(o => !o)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={s.t('journey.studio.format')}
>
<span className="st-tool-label">
{s.preset === 'custom'
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
: s.t(active.labelKey)}
</span>
<ChevronDown size={13} style={{ opacity: .5 }} />
</button>
{open && (
<div className="st-menu" role="listbox">
{PAGE_PRESET_ORDER.map(id => {
const p = PAGE_PRESETS[id]
const on = id === s.preset
return (
<button type="button"
key={id}
role="option"
aria-selected={on}
className={`st-menu-item ${on ? 'is-active' : ''}`}
onClick={() => { s.setPreset(id); setOpen(false) }}
>
<span className="st-menu-tile">
<span
className="st-menu-shape"
style={
p.pageWidthMm >= p.pageHeightMm
? { width: 24, height: Math.round(24 * p.pageHeightMm / p.pageWidthMm) }
: { height: 24, width: Math.round(24 * p.pageWidthMm / p.pageHeightMm) }
}
/>
</span>
<span className="st-menu-text">
<span className="st-menu-name">{s.t(p.labelKey)}</span>
<span className="st-menu-dim">{p.pageWidthMm} × {p.pageHeightMm} mm</span>
</span>
{on && <Check size={14} />}
</button>
)
})}
{/*
The two fields live under the list rather than behind a "custom"
entry that opens a second popover: picking the custom row and then
typing into the fields it reveals is two steps for one decision, and
typing into them *is* picking custom — the hook switches the preset
for you.
*/}
<div className="st-menu-sep" />
<div className="st-menu-custom">
<TrimField
label={s.t('journey.studio.width')}
value={s.page.pageWidth}
onCommit={v => s.setPageSize('w', v)}
/>
<span className="st-menu-times">×</span>
<TrimField
label={s.t('journey.studio.height')}
value={s.page.pageHeight}
onCommit={v => s.setPageSize('h', v)}
/>
<span className="st-menu-unit">mm</span>
</div>
{/*
What the press takes off, and what it must not print into.
Under the trim size because they answer the same question — how big
is this book, really — and because a vendor states all three in one
breath. They were fixed at 3 and 5, which is what most photo-book
printers ask for and is useless to the one that asks for 5 and 10.
*/}
<div className="st-menu-custom">
<TrimField
label={s.t('journey.studio.bleed')}
value={s.page.bleed}
min={0}
max={20}
step={0.5}
onCommit={v => s.setPageEdge('bleed', v)}
/>
<span className="st-menu-times" />
<TrimField
label={s.t('journey.studio.safeArea')}
value={s.page.safe}
min={0}
max={40}
step={0.5}
onCommit={v => s.setPageEdge('safe', v)}
/>
<span className="st-menu-unit">mm</span>
</div>
</div>
)}
</div>
)
}
function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
function Workbench({ s, bookView, setBookView }: { s: Studio; bookView: boolean; setBookView: (on: boolean) => void }) {
return (
<div className="st-work-wrap">
<div className="st-work" ref={s.workRef}>
@@ -538,6 +194,16 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
<button type="button" onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
<Maximize2 size={14} />
</button>
{/* How the page is looked at, so with the zoom rather than in the head band. */}
<button type="button"
onClick={() => setBookView(!bookView)}
aria-pressed={bookView}
aria-label={s.t('journey.studio.bookView')}
title={s.t('journey.studio.bookView')}
className={bookView ? 'is-on' : undefined}
>
<BookOpen size={14} />
</button>
{/*
The page, as a file.
@@ -99,7 +99,11 @@ export function StudioTextStyles({
const place = (lockup: BookLockup) => {
const els = placeLockup(lockup, { page, spread, words, gallery: source.photos, newId: bookElementId });
// The page's own cap, as a duplicate keeps it: a set that would make the book unsaveable is not placed.
if (!els.length || !spread || spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) return;
if (!els.length || !spread) return;
if (spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
useStudioStore.getState().refuseFull();
return;
}
commit((d) => ({
...d,
spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)),
+6 -1
View File
@@ -54,6 +54,7 @@ interface Host {
styleClip: BookCopiedStyle | null;
commit: (fn: (doc: BookDocument) => BookDocument) => void;
apply: (fn: (doc: BookDocument) => BookDocument) => void;
refuseFull: () => void;
}
type Set = (partial: Partial<Host> | ((s: Host) => Partial<Host>)) => void;
@@ -370,7 +371,11 @@ function panelActions(
if (!doc) return;
const chosen = spread.elements.filter((e) => ids.includes(e.id));
// The page's own cap: copies that would make the book unsaveable are not made.
if (!chosen.length || spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) return;
if (!chosen.length) return;
if (spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) {
get().refuseFull();
return;
}
const copies = chosen.map(
(e) =>
({
+19 -1
View File
@@ -35,6 +35,14 @@ interface StudioState extends StudioLayerActions {
gestureBase: BookDocument | null
/** Elements copied for pasting onto any page of the book (#2316). Kept across page switches. */
clipboard: StudioClipboard | null
/**
* Counts the times something was not added because the page already holds
* MAX_SPREAD_ELEMENTS. The Studio says so once per count (StudioNotices), so
* a full page no longer swallows a click without a word.
*/
refusedFull: number
/** Notes that an addition was refused because the page is full. */
refuseFull: () => void
/**
* The page a chat card points at, outlined in the page rail (journey-studio.md
* 4.2). Not part of the book or its history; opening a book clears it.
@@ -147,6 +155,8 @@ export const useStudioStore = create<StudioState>((set, get) => ({
future: [],
gestureBase: null,
clipboard: null,
refusedFull: 0,
refuseFull: () => set(st => ({ refusedFull: st.refusedFull + 1 })),
hintSpread: null,
readOnly: false,
@@ -228,7 +238,11 @@ export const useStudioStore = create<StudioState>((set, get) => ({
addElement: (spreadIndex, el) => {
// The page's own cap, as paste and duplicate keep it: what would make the book unsaveable is not added.
const target = get().doc?.spreads[spreadIndex]
if (!target || target.elements.length >= MAX_SPREAD_ELEMENTS) return false
if (!target) return false
if (target.elements.length >= MAX_SPREAD_ELEMENTS) {
get().refuseFull()
return false
}
get().commit(doc =>
replaceSpread(doc, spreadIndex, sp => ({ ...sp, elements: [...sp.elements, el] })))
/*
@@ -268,6 +282,10 @@ export const useStudioStore = create<StudioState>((set, get) => ({
const room = Math.max(0, MAX_SPREAD_ELEMENTS - target.elements.length)
const source = doc.spreads.find(sp => sp.id === clipboard.fromSpreadId)
// A copied group arrives as a group of its own, never as part of one already on the page.
if (room === 0) {
get().refuseFull()
return
}
const copies = regroupCopies(pastedElements(clipboard, target, doc.page).slice(0, room), source, bookElementId)
if (!copies.elements.length) return
get().commit(d => replaceSpread(d, spreadIndex, sp => withCopies(sp, copies)))
+2 -11
View File
@@ -3,9 +3,8 @@
inspector keep all their room. */
/* The toggle is the bar's newest tool, so it gives up its label first: from 1360
to 1519 it is an icon while the others keep theirs, and the bar fits where it
did before. The journey's name gives way too rather than push the bar past
the window. */
to 1519 it is an icon while the others keep theirs, and the head band fits
where it did before. */
@media (max-width: 1519px) {
.trek-dash .st-assistant-toggle .st-tool-label {
display: none;
@@ -15,14 +14,6 @@
}
}
.trek-dash .st-bar .st-back {
min-width: 0;
}
.trek-dash .st-bar .st-back > svg {
flex: none;
}
.trek-dash .st-assistant-notice {
margin: 8px 12px 0;
}
+173
View File
@@ -0,0 +1,173 @@
/* ----------------- head band (StudioHeader.tsx) ----------------- *
* The shape every TREK dialog head has: a tinted band, a raised tile, a small
* line above the title and a round close button at the end. The band keeps the
* glass of the old bar under its tint, so the journey still shows through.
*/
.trek-dash .st-head {
/* Its own stacking context, above the rails: the panels below carry z-index 1,
and without this the format menu would open behind them. */
position: relative;
z-index: 5;
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
padding-inline: 14px 16px;
background:
linear-gradient(
color-mix(in srgb, var(--accent) 7%, transparent),
color-mix(in srgb, var(--accent) 7%, transparent)
),
var(--glass-bg);
border-bottom: 1px solid var(--glass-border);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
}
/* The journey's cover, so it is always clear whose book this is. */
.trek-dash .st-head-tile {
width: 44px;
height: 44px;
flex: none;
overflow: hidden;
border-radius: 13px;
background: var(--surface);
box-shadow: var(--sh-sm);
}
.trek-dash .st-head-tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.trek-dash .st-head-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1 1 auto;
}
/* The wordmark and the beta mark: what this screen is, quietly, above its name. */
.trek-dash .st-head-eyebrow {
display: flex;
align-items: center;
gap: 7px;
height: 17px;
color: var(--ink-2);
}
.trek-dash .st-head-eyebrow svg {
flex: none;
}
.trek-dash .st-head-title {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ink);
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.25;
}
.trek-dash .st-head-sub {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
/* The format, as a chip that opens the format menu. */
.trek-dash .st-head-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 22px;
padding-inline: 7px 8px;
border-radius: 999px;
background: var(--surface);
box-shadow: var(--sh-xs);
color: var(--ink-2);
font-size: 11.5px;
font-weight: 500;
white-space: nowrap;
transition:
color 0.16s,
box-shadow 0.16s;
}
.trek-dash .st-head-chip:hover {
color: var(--ink);
box-shadow: var(--sh-sm);
}
.trek-dash .st-head-chip:focus {
outline: none;
}
.trek-dash .st-head-chip:focus-visible {
box-shadow:
0 0 0 2px var(--accent-soft),
0 0 0 3px var(--accent);
}
.trek-dash .st-head-chip-shape {
flex: none;
border: 1.5px solid currentColor;
border-radius: 2px;
opacity: 0.7;
}
/* The chip sits on the left, so its menu opens towards the page, not off the window. */
.trek-dash .st-head-menu {
inset-inline-start: 0;
inset-inline-end: auto;
}
.trek-dash .st-head-tools {
display: flex;
align-items: center;
gap: 8px;
flex: none;
margin-inline-start: auto;
}
/* Tools that belong together share one raised group, like a segmented control. */
.trek-dash .st-head-group {
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 12px;
background: var(--surface);
box-shadow: var(--sh-xs);
}
.trek-dash .st-head-close {
display: grid;
place-items: center;
width: 36px;
height: 36px;
flex: none;
margin-inline-start: 2px;
border-radius: 999px;
background: var(--surface);
box-shadow: var(--sh-sm);
color: var(--ink-3);
transition: color 0.16s;
}
.trek-dash .st-head-close:hover {
color: var(--ink);
}
.trek-dash .st-head-close:focus {
outline: none;
}
.trek-dash .st-head-close:focus-visible {
box-shadow:
0 0 0 2px var(--accent-soft),
0 0 0 3px var(--accent);
}
/* The book view moved to the zoom, where the page's view lives; on reads as pressed. */
.trek-dash .st-zoom button.is-on {
background: color-mix(in oklch, var(--accent) 14%, transparent);
color: var(--ink);
}
+2 -75
View File
@@ -25,7 +25,7 @@
z-index: 9999;
inset: 16px;
display: grid;
grid-template-rows: 56px minmax(0, 1fr);
grid-template-rows: 72px minmax(0, 1fr);
border-radius: var(--r-xl);
overflow: hidden;
border: 1px solid var(--glass-border);
@@ -88,66 +88,7 @@
.st-exit { animation-name: st-fade-out; }
}
/* ----------------- top bar ----------------- */
.trek-dash .st-bar {
/* Its own stacking context, above the rails: the panels below carry z-index 1,
and without this the format menu would open behind them. */
position: relative;
z-index: 5;
display: flex;
align-items: center;
gap: 14px;
padding-block: 0;
padding-inline: 10px 18px;
min-width: 0;
background: var(--glass-bg);
border-bottom: 1px solid var(--glass-border);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
}
.trek-dash .st-back {
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 12px 0 8px;
border-radius: 999px;
color: var(--ink-2);
font-size: 13px;
font-weight: 500;
letter-spacing: -0.01em;
max-width: 240px;
transition: background .18s, color .18s;
}
.trek-dash .st-back:hover { background: var(--accent-soft); color: var(--ink); }
.trek-dash .st-back .st-back-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The journey's own cover, so you never lose track of whose book this is. */
.trek-dash .st-cover {
width: 26px;
height: 26px;
border-radius: 7px;
object-fit: cover;
flex: none;
border: 1px solid var(--line-2);
background: var(--surface-2);
}
.trek-dash .st-bar-group {
display: flex;
align-items: center;
gap: 7px;
flex: none;
margin-inline-start: auto;
}
/* The undo/redo pair belongs together; everything else in the group breathes. */
.trek-dash .st-bar-group .st-tool.is-icon + .st-tool.is-icon { margin-inline-start: -5px; }
/* ----------------- tools (head band and elsewhere) ----------------- */
.trek-dash .st-tool {
display: inline-flex;
align-items: center;
@@ -1506,20 +1447,6 @@
color: var(--ink);
}
/* ----------------- brand ----------------- *
* The lockup takes the bar's ink through `currentColor`, so it is dark on a
* light theme and light on a dark one from one copy of the artwork.
*/
.trek-dash .st-brand {
display: flex;
align-items: center;
gap: 9px;
flex: 0 0 auto;
color: var(--ink);
min-width: 0;
}
.trek-dash .st-brand svg { flex: none; }
/* Small, quiet and set apart — a beta mark states a fact about the feature; it
is not a call to action and should not read as one. */
.trek-dash .st-beta {
+76 -37
View File
@@ -44,10 +44,26 @@ const VECTOR_CACHE = 'gl-map-offline'
*/
const GLYPH_RANGES = ['0-255', '256-511', '512-767', '768-1023', '1024-1279', '1280-1535', '1536-1791', '8192-8447', '8448-8703']
interface StyleSource {
type?: string
url?: string
tiles?: string[]
minzoom?: number
maxzoom?: number
}
/** One tiled source of the style, with the zooms it actually serves. */
interface TileSource {
template: string
minZoom: number
maxZoom: number
vector: boolean
}
interface StyleDoc {
sprite?: string
glyphs?: string
sources?: Record<string, { url?: string; tiles?: string[] }>
sources?: Record<string, StyleSource>
layers?: { layout?: { 'text-font'?: string[] } }[]
}
@@ -100,28 +116,67 @@ async function openVectorCache(): Promise<Cache | null> {
}
}
/** Tile coordinates covering the box, low zoom first so a truncated run is still useful. */
function enumerateVectorTiles(bbox: TileBbox, minZoom: number, maxZoom: number): [number, number, number][] {
const out: [number, number, number][] = []
for (let z = minZoom; z <= maxZoom; z++) {
// Same screen-widened rectangle as the raster side: the opening fitBounds
// view shows more than the places extent on the non-limiting axis (#2180).
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
out.push([z, x, y])
if (out.length >= MAX_VECTOR_TILES) return out
/**
* Tile URLs covering the box, vector sources first and low zoom first within
* each, so a truncated run is still useful. A source is only asked for the
* zooms it serves: OpenFreeMap's shaded relief stops at z6 and answers 404
* above it.
*/
function enumerateTileUrls(bbox: TileBbox, sources: TileSource[]): string[] {
const out: string[] = []
for (const source of sources) {
for (let z = Math.max(source.minZoom, VECTOR_MIN_ZOOM); z <= Math.min(source.maxZoom, VECTOR_MAX_ZOOM); z++) {
// Same screen-widened rectangle as the raster side: the opening fitBounds
// view shows more than the places extent on the non-limiting axis (#2180).
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
out.push(source.template.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y)))
if (out.length >= MAX_VECTOR_TILES) return out
}
}
}
}
return out
}
/**
* The style's tiled sources, vector first. A source given as a TileJSON
* reference gets its template and zooms from there: the tile path carries a
* planet version, and asking without that segment answers 200 with an empty
* body, so it cannot be guessed. A source without tiles (GeoJSON) is skipped.
*/
async function readTileSources(style: StyleDoc, cache: Cache | null): Promise<TileSource[]> {
const out: TileSource[] = []
for (const source of Object.values(style.sources ?? {})) {
let { tiles, minzoom, maxzoom } = source
if (!tiles?.[0] && source.url) {
await warm(cache, source.url)
const tileJson = await fetchJson<{ tiles?: string[]; minzoom?: number; maxzoom?: number }>(source.url)
tiles = tileJson?.tiles
minzoom ??= tileJson?.minzoom
maxzoom ??= tileJson?.maxzoom
}
if (!tiles?.[0]) continue
out.push({
template: tiles[0],
minZoom: minzoom ?? VECTOR_MIN_ZOOM,
maxZoom: maxzoom ?? VECTOR_MAX_ZOOM,
vector: source.type !== 'raster' && source.type !== 'raster-dem',
})
}
return out.sort((a, b) => Number(b.vector) - Number(a.vector))
}
/**
* The device-wide half: the style, its TileJSON, the sprite and the glyphs.
* Returns the tile URL template, or null when the style could not be read.
* Returns the vector tile URL template, or null when the style could not be read.
*/
export async function prefetchStyleAssets(styleUrl: string): Promise<string | null> {
return (await prefetchStyle(styleUrl))?.[0]?.template ?? null
}
async function prefetchStyle(styleUrl: string): Promise<TileSource[] | null> {
const cache = await openVectorCache()
const style = await fetchJson<StyleDoc>(styleUrl)
if (!style) return null
@@ -148,19 +203,7 @@ export async function prefetchStyleAssets(styleUrl: string): Promise<string | nu
)
}
// The vector source is a TileJSON reference, and the tile path it hands back
// carries a planet version. Asking without that segment answers 200 with an
// empty body, so the template has to come from the TileJSON rather than be
// guessed.
for (const source of Object.values(style.sources ?? {})) {
if (source.tiles?.[0]) return source.tiles[0]
if (source.url) {
await warm(cache, source.url)
const tileJson = await fetchJson<{ tiles?: string[] }>(source.url)
if (tileJson?.tiles?.[0]) return tileJson.tiles[0]
}
}
return null
return readTileSources(style, cache)
}
export interface VectorPrefetchResult {
@@ -184,25 +227,21 @@ export async function prefetchVectorForPlaces(
const bbox = computeBbox(places)
if (!bbox) return { tiles: 0, template: null }
const template = await prefetchStyleAssets(styleUrl)
if (!template || isCancelled()) return { tiles: 0, template }
const sources = await prefetchStyle(styleUrl)
const template = sources?.[0]?.template ?? null
if (!sources?.length || isCancelled()) return { tiles: 0, template }
const cache = await openVectorCache()
const coords = enumerateVectorTiles(bbox, VECTOR_MIN_ZOOM, VECTOR_MAX_ZOOM)
const urls = enumerateTileUrls(bbox, sources)
let cursor = 0
let fetched = 0
async function worker(): Promise<void> {
while (cursor < coords.length) {
while (cursor < urls.length) {
if (isCancelled() || !navigator.onLine) return
const next = coords[cursor++]
if (!next) return
const [z, x, y] = next
const url = template!
.replace('{z}', String(z))
.replace('{x}', String(x))
.replace('{y}', String(y))
const url = urls[cursor++]
if (!url) return
if (await warm(cache, url)) fetched++
}
}
+5 -1
View File
@@ -149,7 +149,11 @@ async function syncTrip(tripId: number): Promise<void> {
// first sync is in. Ahead of the writes and rethrowing anything but a 404, it
// threw the whole downloaded bundle away for that trip while the run still
// reported it stored, so Settings said "N trips ready" over an empty database.
await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional addon, optional cache */ })
// Asked only while the addon is on, like the tours below: with it off, every
// trip of a dashboard load spent a request on a 404.
if (useAddonStore.getState().isEnabled('roadtrip')) {
await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional cache */ })
}
// Tours, the same way: optional, and never fatal. Without them every Tour
// reads as a plain place offline. Asked only while the addon is on, so a
@@ -0,0 +1,89 @@
import { act, render } from '@testing-library/react';
import { MAX_SPREAD_ELEMENTS, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { StudioNotices } from '../../../src/components/Studio/StudioNotices';
import { useStudioStore } from '../../../src/store/studioStore';
/**
* FE-STUDIO-FULL-001 to FE-STUDIO-FULL-004: a page that already holds
* MAX_SPREAD_ELEMENTS takes nothing more, and the Studio says so instead of
* swallowing the click.
*/
const el = (id: string): BookElement =>
({
id,
kind: 'shape',
frame: { x: 0, y: 0, w: 10, h: 10 },
rotation: 0,
opacity: 1,
locked: false,
shape: 'rect',
fill: '#000000',
gradient: 'none',
stroke: null,
strokeWidth: 0,
strokeStyle: 'solid',
radius: 0,
}) as BookElement;
const spread = (id: string, elements: BookElement[]): BookSpread => ({
id,
role: 'inner',
background: null,
elements,
parked: [],
entryId: null,
});
const book = (...spreads: BookSpread[]): BookDocument =>
({
version: 1,
title: 'T',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads,
}) as BookDocument;
const full = () => Array.from({ length: MAX_SPREAD_ELEMENTS }, (_, i) => el(`f${i}`));
const store = () => useStudioStore.getState();
beforeEach(() => {
store().load(book(spread('full', full()), spread('room', [el('a')])));
useStudioStore.setState({ refusedFull: 0, clipboard: null });
});
describe('a full page', () => {
it('FE-STUDIO-FULL-001: refuses one more element and counts the refusal', () => {
expect(store().addElement(0, el('extra'))).toBe(false);
expect(store().doc!.spreads[0].elements).toHaveLength(MAX_SPREAD_ELEMENTS);
expect(store().refusedFull).toBe(1);
expect(store().addElement(1, el('fits'))).toBe(true);
expect(store().refusedFull).toBe(1);
});
it('FE-STUDIO-FULL-002: a paste onto it is refused and counted', () => {
useStudioStore.setState({ clipboard: { fromSpreadId: 'room', elements: [el('a')] } });
store().paste(0);
expect(store().doc!.spreads[0].elements).toHaveLength(MAX_SPREAD_ELEMENTS);
expect(store().refusedFull).toBe(1);
});
it('FE-STUDIO-FULL-003: a duplicate on it is refused and counted', () => {
store().duplicate(0, ['f0']);
expect(store().doc!.spreads[0].elements).toHaveLength(MAX_SPREAD_ELEMENTS);
expect(store().refusedFull).toBe(1);
});
it('FE-STUDIO-FULL-004: the Studio says it once per refusal', () => {
const addToast = vi.fn();
window.__addToast = addToast;
render(<StudioNotices />);
expect(addToast).not.toHaveBeenCalled();
act(() => store().refuseFull());
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast.mock.calls[0][1]).toBe('warning');
act(() => store().refuseFull());
expect(addToast).toHaveBeenCalledTimes(2);
});
});
@@ -201,6 +201,46 @@ describe('prefetchVectorForPlaces', () => {
});
});
// OpenFreeMap's liberty style lists its shaded relief (raster, up to z6) before the
// vector planet. Taking the first source used to prefetch that relief at z0 to z14,
// hundreds of 404s, and never the vector tiles the map needs offline.
describe('a style with a raster source ahead of the vector one', () => {
const places = [buildPlace({ lat: 52.52, lng: 13.405 })];
const RELIEF = 'https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png';
const LIBERTY = {
sources: {
ne2_shaded: { type: 'raster', tiles: [RELIEF], maxzoom: 6 },
openmaptiles: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' },
},
};
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn((url: string, init?: { mode?: string }) => {
requested.push({ url, mode: init?.mode });
if (url === STYLE_URL) return Promise.resolve({ ok: true, json: () => Promise.resolve(LIBERTY), clone: () => ({}) });
return Promise.resolve(respond(url));
}),
);
});
it('FE-SYNC-GLPF-015: the template is the vector source, not the first one listed', async () => {
await expect(prefetchStyleAssets(STYLE_URL)).resolves.toBe(TILE_TEMPLATE);
});
it('FE-SYNC-GLPF-016: downloads the vector tiles and the relief only at the zooms it serves', async () => {
const result = await prefetchVectorForPlaces(places, STYLE_URL);
expect(result.template).toBe(TILE_TEMPLATE);
const urls = requested.map(r => r.url);
const vectorZooms = new Set(urls.filter(u => u.includes('/planet/')).map(u => Number(u.split('/planet/')[1]!.split('/')[1])));
expect(Math.max(...vectorZooms)).toBe(14);
const reliefZooms = urls.filter(u => u.includes('/ne2sr/')).map(u => Number(u.split('/ne2sr/')[1]!.split('/')[0]));
expect(reliefZooms.length).toBeGreaterThan(0);
expect(Math.max(...reliefZooms)).toBe(6);
});
});
describe('clearVectorCache', () => {
it('FE-SYNC-GLPF-012: drops the offline vector basemap', async () => {
await clearVectorCache();
+3 -1
View File
@@ -476,7 +476,9 @@ async function carryOn(
const { text, calls, images } = answer;
turn.history.push({ role: 'assistant', text, toolCalls: calls, providerState: step.providerState });
for (const part of step.parts) {
if (part.type !== 'tool-call') continue;
// A web search is the provider's own record, kept in the provider state; an answer to it would
// name a call the provider never made, and it refuses the next step over that.
if (part.type !== 'tool-call' || part.toolName === ASSISTANT_WEB_SEARCH_STEP) continue;
const seen = images.get(part.toolCallId);
turn.history.push({
role: 'tool',
+2 -2
View File
@@ -347,8 +347,8 @@ export class PlacesMcp {
name: 'search_place',
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap, or Google Places alone with provider "google" and whenever the admin switch "Search with Google only" is on: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.',
inputSchema: {
query: z.string().min(1).max(500).describe('Place name or address to search for'),
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'),
query: z.string().min(1).max(500).describe('One place name or address. Several places in one query match none of them well; search each on its own, the calls can run side by side'),
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Use where the place should be, such as a stop already planned in that neighbourhood, not the middle of the city: the TREK index ranks by distance from it, OpenStreetMap orders its results by it and Google searches a circle around it'),
lang: z.string().max(35).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja". Defaults to English'),
provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored unless Google holds the keyed slot: an instance without a Google key, or one whose admin picked Amap or OpenStreetMap as the places provider, answers from the TREK index and OpenStreetMap as usual'),
},
@@ -858,4 +858,22 @@ describe('runAgentLoop', () => {
await runAgentLoop(unable.deps, unable.input, unable.writer, new AbortController().signal);
expect(unable.requests[0]?.webSearch).toBe(false);
});
it('ASTAGT-041: a step that searched the web and called a read answers only the read, never the search', async () => {
const t = setup(
[
[
{ type: 'web-search', query: 'Mount Takao trails' },
{ type: 'tool-call', call: { id: 'call_1', name: 'list_places', args: { tripId: 1 } } },
{ type: 'finish', reason: 'tool-calls' },
],
[{ type: 'text-delta', delta: 'Trail 1 up, trail 6 down.' }],
],
{ webSearch: true },
true,
);
await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal);
const answers = t.requests[1].messages.filter((m) => m.role === 'tool');
expect(answers.map((m) => (m as { toolCallId: string }).toolCallId)).toEqual(['call_1']);
});
});
+17
View File
@@ -264,6 +264,23 @@ describe('a mark set as text on a page laid out again', () => {
});
});
describe('the page background a saved layout brings', () => {
it("BOOKLAY-030: replaces the page's colour and its link to a theme colour, so a theme cannot paint over it", () => {
const from = bookSpreadSchema.parse({ id: 'a', role: 'inner', background: '#123456', elements: [] });
const layout = bookLayoutFromSpread(from, page, 'Navy');
const linked = bookSpreadSchema.parse({
id: 'b',
role: 'inner',
background: '#ffffff',
backgroundToken: 'paper',
elements: [],
});
const next = applySavedBookLayout(linked, layout, page);
expect(next.background).toBe('#123456');
expect(next).not.toHaveProperty('backgroundToken');
});
});
describe('letters filled with a photo in a saved layout', () => {
const frame = { x: 10, y: 10, w: 80, h: 40 };
const filledWith = (photoId: number) => ({ type: 'photo' as const, photoId, focalX: 0.5, focalY: 0.5, zoom: 1 });
+3 -1
View File
@@ -145,7 +145,9 @@ export function applySavedBookLayout(spread: BookSpread, layout: BookLayout, pag
const used = new Set(wordsFor.values());
const parked = [...photos.slice(pi), ...texts.filter((t) => !used.has(t))].slice(0, MAX_SPREAD_ELEMENTS);
const { backgroundPaint: _old, ...rest } = spread;
// The layout's background replaces the page's, so the page's link to a theme
// colour goes with it: kept, the next theme would paint over the layout's colour.
const { backgroundPaint: _old, backgroundToken: _unlinked, ...rest } = spread;
const backgroundPaint = layout.backgroundPaint ? { backgroundPaint: layout.backgroundPaint } : {};
return { ...rest, background: layout.background, ...backgroundPaint, elements: out, parked };
}
+27 -16
View File
@@ -79,31 +79,40 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
id: 'croissant',
theme: 'food',
tags: ['croissant', 'pastry', 'bakery', 'france', 'paris', 'breakfast', 'boulangerie', 'cafe'],
descr: 'A golden, flaky croissant curved into a crescent, its rolled layers shaded darker at each fold.',
descr: 'A golden croissant curved into a crescent, its rolled layers marked by darker folds and a baked underside.',
box: [110, 70],
slots: [
{ name: 'pastry', color: '#e2a04a' },
{ name: 'crust', color: '#b86b24' },
],
layers: [
{ p: 0, d: 'M8 50C4 44 8 36 16 36C18 40 22 44 28 46C26 52 18 56 12 54Z' },
{ p: 0, d: 'M102 50C106 44 102 36 94 36C92 40 88 44 82 46C84 52 92 56 98 54Z' },
{ p: 0, d: 'M18 34C20 24 28 18 36 18C38 26 40 34 40 46C32 48 24 44 18 34Z' },
{ p: 0, d: 'M92 34C90 24 82 18 74 18C72 26 70 34 70 46C78 48 86 44 92 34Z' },
{ p: 0, d: 'M36 16C42 8 50 6 55 6C60 6 68 8 74 16C72 28 70 40 68 50C60 54 50 54 42 50C40 40 38 28 36 16Z' },
// One closed crescent, so no seam between two pieces can let the page show through.
{
p: 1,
d: 'M28 46C24 44 20 40 16 36M40 46C40 34 38 26 36 18M70 46C70 34 72 26 74 18M82 46C86 44 90 40 94 36',
o: 0.8,
w: 1.6,
p: 0,
d: 'M6 54C3 46 10 38 20 34C27 21 39 11 55 10C71 11 83 21 90 34C100 38 107 46 104 54C98 57 92 55 86 50C78 54 66 56 55 56C44 56 32 54 24 50C18 55 12 57 6 54Z',
},
{
p: 'shade',
d: 'M8 51C14 53 19 51 24 47C32 51 44 53 55 53C66 53 78 51 86 47C91 51 96 53 102 51L104 54C98 57 92 55 86 50C78 54 66 56 55 56C44 56 32 54 24 50C18 55 12 57 6 54Z',
o: 0.22,
},
{
p: 'shade',
d: 'M20 34C26 36 30 40 32 52C28 52 26 51 24 50C21 46 20 40 20 34ZM90 34C84 36 80 40 78 52C82 52 84 51 86 50C89 46 90 40 90 34Z',
o: 0.12,
},
{
p: 1,
d: 'M18 34C24 44 32 48 40 46L42 50C32 52 22 46 18 34ZM92 34C86 44 78 48 70 46L68 50C78 52 88 46 92 34ZM8 50C10 54 14 55 18 54L12 54C10 53 9 52 8 50Z',
o: 0.6,
d: 'M23 49C19 44 18.5 38.5 21 34M39 54C35 44 34.5 28 38.5 14.5M71 54C75 44 75.5 28 71.5 14.5M87 49C91 44 91.5 38.5 89 34',
o: 0.85,
w: 2,
},
{
p: 'light',
d: 'M47 15.5C50.5 13 59.5 13 63 15.5M26.5 31C28.5 27 31.5 24 35 22M75 22C78.5 24 81.5 27 83.5 31M9.5 46C10.5 43 13 40.5 16 39M94 39C97 40.5 99.5 43 100.5 46',
o: 0.5,
w: 1.8,
},
{ p: 'shade', d: 'M42 50C50 54 60 54 68 50L68 52C60 57 50 57 42 52Z', o: 0.2 },
{ p: 'light', d: 'M44 14C48 10 54 9 58 9M24 28C26 24 30 21 34 20M78 22C82 23 86 26 88 30', o: 0.45, w: 1.6 },
],
license: 'in-house',
},
@@ -122,7 +131,7 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
{ p: 0, d: 'M8 28L18 62C20 66 24 68 30 68L70 68C76 68 80 66 82 62L92 28Z' },
{
p: 'shade',
d: 'M14 30L22 66M23 30L29.4 66M32 30L36.8 66M41 30L44.2 66M50 30L51.6 66M59 30L59 66M68 30L66.4 66M77 30L73.8 66M86 30L81.2 66',
d: 'M14 30L21.2 62M23 30L29.4 66M32 30L36.8 66M41 30L44.2 66M50 30L51.6 66M59 30L59 66M68 30L66.4 66M77 30L73.8 66M86 30L81.8 62',
o: 0.18,
w: 1,
},
@@ -395,6 +404,8 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
],
layers: [
{ p: '#c9a77c', d: 'M22 8L98 36M30 4L102 30', w: 2.4 },
// The bowl's rim behind the broth, and its front edge reaching over the broth below.
{ p: 0, d: 'M102 46C102 53.2 81 59 55 59C29 59 8 53.2 8 46C8 38.8 29 33 55 33C81 33 102 38.8 102 46Z' },
{ p: 1, d: 'M101 46C101 52.6 80.4 58 55 58C29.6 58 9 52.6 9 46C9 39.4 29.6 34 55 34C80.4 34 101 39.4 101 46Z' },
{
p: '#f3e3b8',
@@ -424,7 +435,7 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
d: 'M21.6 50C21.6 50.9 20.9 51.6 20 51.6C19.1 51.6 18.4 50.9 18.4 50C18.4 49.1 19.1 48.4 20 48.4C20.9 48.4 21.6 49.1 21.6 50ZM20.7 50C20.7 50.4 20.4 50.7 20 50.7C19.6 50.7 19.3 50.4 19.3 50C19.3 49.6 19.6 49.3 20 49.3C20.4 49.3 20.7 49.6 20.7 50ZM25.6 48C25.6 48.9 24.9 49.6 24 49.6C23.1 49.6 22.4 48.9 22.4 48C22.4 47.1 23.1 46.4 24 46.4C24.9 46.4 25.6 47.1 25.6 48ZM24.7 48C24.7 48.4 24.4 48.7 24 48.7C23.6 48.7 23.3 48.4 23.3 48C23.3 47.6 23.6 47.3 24 47.3C24.4 47.3 24.7 47.6 24.7 48ZM53.6 52C53.6 52.9 52.9 53.6 52 53.6C51.1 53.6 50.4 52.9 50.4 52C50.4 51.1 51.1 50.4 52 50.4C52.9 50.4 53.6 51.1 53.6 52ZM52.7 52C52.7 52.4 52.4 52.7 52 52.7C51.6 52.7 51.3 52.4 51.3 52C51.3 51.6 51.6 51.3 52 51.3C52.4 51.3 52.7 51.6 52.7 52ZM87.6 44C87.6 44.9 86.9 45.6 86 45.6C85.1 45.6 84.4 44.9 84.4 44C84.4 43.1 85.1 42.4 86 42.4C86.9 42.4 87.6 43.1 87.6 44ZM86.7 44C86.7 44.4 86.4 44.7 86 44.7C85.6 44.7 85.3 44.4 85.3 44C85.3 43.6 85.6 43.3 86 43.3C86.4 43.3 86.7 43.6 86.7 44ZM91.6 48C91.6 48.9 90.9 49.6 90 49.6C89.1 49.6 88.4 48.9 88.4 48C88.4 47.1 89.1 46.4 90 46.4C90.9 46.4 91.6 47.1 91.6 48ZM90.7 48C90.7 48.4 90.4 48.7 90 48.7C89.6 48.7 89.3 48.4 89.3 48C89.3 47.6 89.6 47.3 90 47.3C90.4 47.3 90.7 47.6 90.7 48ZM57.6 40C57.6 40.9 56.9 41.6 56 41.6C55.1 41.6 54.4 40.9 54.4 40C54.4 39.1 55.1 38.4 56 38.4C56.9 38.4 57.6 39.1 57.6 40ZM56.7 40C56.7 40.4 56.4 40.7 56 40.7C55.6 40.7 55.3 40.4 55.3 40C55.3 39.6 55.6 39.3 56 39.3C56.4 39.3 56.7 39.6 56.7 40Z',
eo: 1,
},
{ p: 0, d: 'M8 46C8 72 30 86 55 86C80 86 102 72 102 46C96 54 80 60 55 60C30 60 14 54 8 46Z' },
{ p: 0, d: 'M8 46C8 72 30 86 55 86C80 86 102 72 102 46C96 53 80 57 55 57C30 57 14 53 8 46Z' },
{ p: 'shade', d: 'M72 59C84 56 98 52 102 46C102 72 80 86 55 86C74 80 78 70 72 59Z', o: 0.18 },
{
p: '#fbf8f0',
+2
View File
@@ -493,6 +493,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'نسخ للصق في أي صفحة (Ctrl+C)',
'journey.studio.paste': 'لصق (Ctrl+V)',
'journey.studio.pasteEmpty': 'انسخ شيئًا أولًا، ثم الصقه في أي صفحة',
'journey.studio.pageFull':
'هذه الصفحة ممتلئة: تحتوي بالفعل على أقصى عدد من العناصر. احذف شيئًا أولًا أو استخدم صفحة أخرى.',
'journey.studio.myLayouts': 'تخطيطاتي',
'journey.studio.myLayoutsEmpty': 'احتفظ بصفحة رتّبتها، ورتّب صفحات أخرى بالطريقة نفسها. تبقى صورها ونصوصها كما هي.',
'journey.studio.saveLayout': 'حفظ هذه الصفحة كتخطيط',
+2
View File
@@ -510,6 +510,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'İstənilən səhifəyə yapışdırmaq üçün kopyala (Ctrl+C)',
'journey.studio.paste': 'Yapışdır (Ctrl+V)',
'journey.studio.pasteEmpty': 'Əvvəlcə nəyisə kopyalayın, sonra onu istənilən səhifəyə yapışdırın',
'journey.studio.pageFull':
'Bu səhifə doludur: bir səhifəyə daha çox element sığmır. Əvvəlcə nəyisə silin və ya başqa səhifədən istifadə edin.',
'journey.studio.myLayouts': 'Mənim tərtibatlarım',
'journey.studio.myLayoutsEmpty':
'Tərtib etdiyiniz səhifəni saxlayın və digər səhifələri eyni şəkildə düzün. Onların fotoları və mətnləri qalır.',
+2
View File
@@ -466,6 +466,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Copiar, para colar em qualquer página (Ctrl+C)',
'journey.studio.paste': 'Colar (Ctrl+V)',
'journey.studio.pasteEmpty': 'Copie algo primeiro e depois cole em qualquer página',
'journey.studio.pageFull':
'Esta página está cheia: ela já tem o máximo de elementos de uma página. Remova algo primeiro ou use outra página.',
'journey.studio.myLayouts': 'Minhas disposições',
'journey.studio.myLayoutsEmpty':
'Guarde uma página que você organizou e disponha outras páginas do mesmo jeito. As fotos e os textos delas continuam.',
+2
View File
@@ -465,6 +465,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Copia, per enganxar a qualsevol pàgina (Ctrl+C)',
'journey.studio.paste': 'Enganxa (Ctrl+V)',
'journey.studio.pasteEmpty': 'Primer copia alguna cosa i després enganxa-la a qualsevol pàgina',
'journey.studio.pageFull':
'Aquesta pàgina és plena: ja té tots els elements que hi caben. Treu-ne algun primer o fes servir una altra pàgina.',
'journey.studio.myLayouts': 'Les meves disposicions',
'journey.studio.myLayoutsEmpty':
'Guarda una pàgina que hagis organitzat i disposa altres pàgines de la mateixa manera. Les seves fotos i textos es mantenen.',
+2
View File
@@ -470,6 +470,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Kopírovat pro vložení na libovolnou stranu (Ctrl+C)',
'journey.studio.paste': 'Vložit (Ctrl+V)',
'journey.studio.pasteEmpty': 'Nejprve něco zkopírujte a pak to vložte na libovolnou stranu',
'journey.studio.pageFull':
'Tato stránka je plná: víc prvků se na stránku nevejde. Nejdřív něco odeberte, nebo použijte jinou stránku.',
'journey.studio.myLayouts': 'Moje rozvržení',
'journey.studio.myLayoutsEmpty':
'Uložte si stranu, kterou jste uspořádali, a rozvrhněte stejně i další strany. Jejich fotky a texty zůstanou.',
+2
View File
@@ -466,6 +466,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Kopieren, um es auf einer beliebigen Seite einzufügen (Ctrl+C)',
'journey.studio.paste': 'Einfügen (Ctrl+V)',
'journey.studio.pasteEmpty': 'Kopiere zuerst etwas und füge es dann auf einer beliebigen Seite ein',
'journey.studio.pageFull':
'Diese Seite ist voll: Mehr Elemente passen auf eine Seite nicht. Entferne zuerst etwas oder nimm eine andere Seite.',
'journey.studio.myLayouts': 'Meine Anordnungen',
'journey.studio.myLayoutsEmpty':
'Behalte eine Seite, die du angeordnet hast, und ordne andere Seiten genauso an. Ihre Fotos und Texte bleiben erhalten.',
+2
View File
@@ -463,6 +463,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Copy, to paste on any page (Ctrl+C)',
'journey.studio.paste': 'Paste (Ctrl+V)',
'journey.studio.pasteEmpty': 'Copy something first, then paste it on any page',
'journey.studio.pageFull':
'This page is full: it already holds as many elements as a page can. Remove something first, or use another page.',
'journey.studio.myLayouts': 'My layouts',
'journey.studio.myLayoutsEmpty':
'Keep a page you arranged and lay other pages out the same way. Their photos and words stay.',
+2
View File
@@ -466,6 +466,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Copiar, para pegar en cualquier página (Ctrl+C)',
'journey.studio.paste': 'Pegar (Ctrl+V)',
'journey.studio.pasteEmpty': 'Copia algo primero y luego pégalo en cualquier página',
'journey.studio.pageFull':
'Esta página está llena: ya tiene todos los elementos que caben. Quita algo primero o usa otra página.',
'journey.studio.myLayouts': 'Mis disposiciones',
'journey.studio.myLayoutsEmpty':
'Guarda una página que hayas organizado y dispón otras páginas de la misma forma. Sus fotos y textos se mantienen.',
+2
View File
@@ -464,6 +464,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Kopeeri, et kleepida mis tahes lehele (Ctrl+C)',
'journey.studio.paste': 'Kleebi (Ctrl+V)',
'journey.studio.pasteEmpty': 'Kopeeri esmalt midagi, siis kleebi see mis tahes lehele',
'journey.studio.pageFull':
'See leht on täis: rohkem elemente ühele lehele ei mahu. Eemalda esmalt midagi või kasuta teist lehte.',
'journey.studio.myLayouts': 'Minu paigutused',
'journey.studio.myLayoutsEmpty':
'Salvesta enda kujundatud leht ja paiguta teised lehed samamoodi. Nende fotod ja tekstid jäävad alles.',
+2
View File
@@ -464,6 +464,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Copier, pour coller sur n’importe quelle page (Ctrl+C)',
'journey.studio.paste': 'Coller (Ctrl+V)',
'journey.studio.pasteEmpty': 'Copiez d’abord quelque chose, puis collez-le sur n’importe quelle page',
'journey.studio.pageFull':
"Cette page est pleine : elle contient déjà le maximum d'éléments. Retirez d'abord un élément ou utilisez une autre page.",
'journey.studio.myLayouts': 'Mes dispositions',
'journey.studio.myLayoutsEmpty':
'Conservez une page que vous avez agencée et disposez d’autres pages de la même façon. Leurs photos et textes restent.',
+2
View File
@@ -469,6 +469,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Αντιγραφή, για επικόλληση σε οποιαδήποτε σελίδα (Ctrl+C)',
'journey.studio.paste': 'Επικόλληση (Ctrl+V)',
'journey.studio.pasteEmpty': 'Αντιγράψτε πρώτα κάτι και μετά επικολλήστε το σε οποιαδήποτε σελίδα',
'journey.studio.pageFull':
'Αυτή η σελίδα είναι γεμάτη: έχει ήδη τα περισσότερα στοιχεία που χωρά μια σελίδα. Αφαιρέστε πρώτα κάτι ή χρησιμοποιήστε άλλη σελίδα.',
'journey.studio.myLayouts': 'Οι διατάξεις μου',
'journey.studio.myLayoutsEmpty':
'Κρατήστε μια σελίδα που έχετε διαμορφώσει και στήστε άλλες σελίδες με τον ίδιο τρόπο. Οι φωτογραφίες και τα κείμενά τους παραμένουν.',
+2
View File
@@ -463,6 +463,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Másolás, bármelyik oldalra beilleszthető (Ctrl+C)',
'journey.studio.paste': 'Beillesztés (Ctrl+V)',
'journey.studio.pasteEmpty': 'Előbb másoljon ki valamit, aztán illessze be bármelyik oldalra',
'journey.studio.pageFull':
'Ez az oldal megtelt: több elem nem fér egy oldalra. Előbb távolíts el valamit, vagy használj másik oldalt.',
'journey.studio.myLayouts': 'Saját elrendezéseim',
'journey.studio.myLayoutsEmpty':
'Mentsen el egy elrendezett oldalt, és rendezzen el más oldalakat is ugyanígy. Azok fotói és szövegei megmaradnak.',
+2
View File
@@ -455,6 +455,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Salin, untuk ditempel di halaman mana pun (Ctrl+C)',
'journey.studio.paste': 'Tempel (Ctrl+V)',
'journey.studio.pasteEmpty': 'Salin sesuatu dulu, lalu tempel di halaman mana pun',
'journey.studio.pageFull':
'Halaman ini penuh: sudah berisi elemen sebanyak yang muat dalam satu halaman. Hapus sesuatu dulu atau gunakan halaman lain.',
'journey.studio.myLayouts': 'Tata letakku',
'journey.studio.myLayoutsEmpty':
'Simpan halaman yang sudah kamu susun dan tata halaman lain dengan cara yang sama. Foto dan teksnya tetap.',
+2
View File
@@ -465,6 +465,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Copia, per incollare su qualsiasi pagina (Ctrl+C)',
'journey.studio.paste': 'Incolla (Ctrl+V)',
'journey.studio.pasteEmpty': 'Copia prima qualcosa, poi incollalo su qualsiasi pagina',
'journey.studio.pageFull':
"Questa pagina è piena: contiene già il massimo di elementi. Rimuovi prima qualcosa o usa un'altra pagina.",
'journey.studio.myLayouts': 'Le mie disposizioni',
'journey.studio.myLayoutsEmpty':
'Conserva una pagina che hai sistemato e disponi le altre pagine allo stesso modo. Le loro foto e i testi restano.',
+2
View File
@@ -453,6 +453,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'コピーして任意のページに貼り付け (Ctrl+C)',
'journey.studio.paste': '貼り付け (Ctrl+V)',
'journey.studio.pasteEmpty': 'まず何かをコピーしてから、任意のページに貼り付けてください',
'journey.studio.pageFull':
'このページはいっぱいです。1ページに置ける要素の上限に達しています。先に何かを削除するか、別のページを使ってください。',
'journey.studio.myLayouts': 'マイレイアウト',
'journey.studio.myLayoutsEmpty':
'配置したページを保存して、他のページも同じ配置にできます。写真と文章はそのまま残ります。',
+2
View File
@@ -450,6 +450,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': '복사해서 아무 페이지에나 붙여넣기 (Ctrl+C)',
'journey.studio.paste': '붙여넣기 (Ctrl+V)',
'journey.studio.pasteEmpty': '먼저 무언가를 복사한 뒤 아무 페이지에나 붙여넣으세요',
'journey.studio.pageFull':
'이 페이지는 가득 찼습니다. 한 페이지에 넣을 수 있는 요소가 이미 최대입니다. 먼저 무언가를 지우거나 다른 페이지를 사용하세요.',
'journey.studio.myLayouts': '내 레이아웃',
'journey.studio.myLayoutsEmpty':
'배치한 페이지를 저장해 두고 다른 페이지도 같은 방식으로 배치하세요. 사진과 글은 그대로 유지됩니다.',
+2
View File
@@ -466,6 +466,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Kopiëren, om op elke pagina te plakken (Ctrl+C)',
'journey.studio.paste': 'Plakken (Ctrl+V)',
'journey.studio.pasteEmpty': 'Kopieer eerst iets en plak het dan op een willekeurige pagina',
'journey.studio.pageFull':
'Deze pagina is vol: er passen niet meer elementen op een pagina. Verwijder eerst iets of gebruik een andere pagina.',
'journey.studio.myLayouts': 'Mijn indelingen',
'journey.studio.myLayoutsEmpty':
'Bewaar een pagina die je hebt ingedeeld en deel andere pagina’s op dezelfde manier in. Hun foto’s en teksten blijven staan.',
+2
View File
@@ -482,6 +482,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Kopiuj, aby wkleić na dowolnej stronie (Ctrl+C)',
'journey.studio.paste': 'Wklej (Ctrl+V)',
'journey.studio.pasteEmpty': 'Najpierw coś skopiuj, a potem wklej to na dowolnej stronie',
'journey.studio.pageFull':
'Ta strona jest pełna: więcej elementów się na niej nie zmieści. Najpierw coś usuń albo użyj innej strony.',
'journey.studio.myLayouts': 'Moje układy',
'journey.studio.myLayoutsEmpty':
'Zachowaj ułożoną stronę i rozmieść inne strony w ten sam sposób. Ich zdjęcia i teksty pozostaną.',
+2
View File
@@ -481,6 +481,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Копировать, чтобы вставить на любую страницу (Ctrl+C)',
'journey.studio.paste': 'Вставить (Ctrl+V)',
'journey.studio.pasteEmpty': 'Сначала скопируйте что-нибудь, затем вставьте на любую страницу',
'journey.studio.pageFull':
'Эта страница заполнена: больше элементов на страницу не помещается. Сначала удалите что-нибудь или используйте другую страницу.',
'journey.studio.myLayouts': 'Мои макеты',
'journey.studio.myLayoutsEmpty':
'Сохраните оформленную страницу и расположите другие страницы так же. Их фото и тексты останутся.',
+2
View File
@@ -474,6 +474,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Kopírovať na vloženie na ľubovoľnú stranu (Ctrl+C)',
'journey.studio.paste': 'Vložiť (Ctrl+V)',
'journey.studio.pasteEmpty': 'Najprv niečo skopírujte a potom to vložte na ľubovoľnú stranu',
'journey.studio.pageFull':
'Táto stránka je plná: viac prvkov sa na stránku nezmestí. Najprv niečo odstráňte alebo použite inú stránku.',
'journey.studio.myLayouts': 'Moje rozloženia',
'journey.studio.myLayoutsEmpty':
'Uložte si stranu, ktorú ste usporiadali, a rozložte rovnako aj ďalšie strany. Ich fotky a texty zostanú.',
+2
View File
@@ -463,6 +463,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Kopiera för att klistra in på valfri sida (Ctrl+C)',
'journey.studio.paste': 'Klistra in (Ctrl+V)',
'journey.studio.pasteEmpty': 'Kopiera något först och klistra sedan in det på valfri sida',
'journey.studio.pageFull':
'Den här sidan är full: fler element får inte plats på en sida. Ta bort något först eller använd en annan sida.',
'journey.studio.myLayouts': 'Mina layouter',
'journey.studio.myLayoutsEmpty':
'Spara en sida du har ordnat och lägg upp andra sidor på samma sätt. Deras foton och texter finns kvar.',
+2
View File
@@ -449,6 +449,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'คัดลอกเพื่อวางในหน้าใดก็ได้ (Ctrl+C)',
'journey.studio.paste': 'วาง (Ctrl+V)',
'journey.studio.pasteEmpty': 'คัดลอกบางอย่างก่อน แล้ววางในหน้าใดก็ได้',
'journey.studio.pageFull':
'หน้านี้เต็มแล้ว ใส่องค์ประกอบได้ครบจำนวนสูงสุดของหนึ่งหน้า โปรดลบบางอย่างก่อนหรือใช้หน้าอื่น',
'journey.studio.myLayouts': 'เค้าโครงของฉัน',
'journey.studio.myLayoutsEmpty':
'เก็บหน้าที่คุณจัดไว้ แล้วจัดหน้าอื่นในแบบเดียวกัน รูปภาพและข้อความของหน้าเหล่านั้นยังคงอยู่',
+2
View File
@@ -464,6 +464,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Kopyala, herhangi bir sayfaya yapıştırmak için (Ctrl+C)',
'journey.studio.paste': 'Yapıştır (Ctrl+V)',
'journey.studio.pasteEmpty': 'Önce bir şey kopyala, sonra herhangi bir sayfaya yapıştır',
'journey.studio.pageFull':
'Bu sayfa dolu: bir sayfaya daha fazla öğe sığmıyor. Önce bir şey kaldırın ya da başka bir sayfa kullanın.',
'journey.studio.myLayouts': 'Yerleşimlerim',
'journey.studio.myLayoutsEmpty':
'Düzenlediğin bir sayfayı sakla ve diğer sayfaları da aynı şekilde yerleştir. Fotoğrafları ve metinleri olduğu gibi kalır.',
+2
View File
@@ -485,6 +485,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Копіювати, щоб вставити на будь-яку сторінку (Ctrl+C)',
'journey.studio.paste': 'Вставити (Ctrl+V)',
'journey.studio.pasteEmpty': 'Спершу щось скопіюйте, потім вставте на будь-яку сторінку',
'journey.studio.pageFull':
'Ця сторінка заповнена: більше елементів на сторінку не вміщується. Спершу видаліть щось або скористайтеся іншою сторінкою.',
'journey.studio.myLayouts': 'Мої макети',
'journey.studio.myLayoutsEmpty':
'Збережіть сторінку, яку ви впорядкували, і розкладайте інші сторінки так само. Їхні фото й тексти залишаться.',
+2
View File
@@ -455,6 +455,8 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': 'Sao chép để dán vào trang bất kỳ (Ctrl+C)',
'journey.studio.paste': 'Dán (Ctrl+V)',
'journey.studio.pasteEmpty': 'Hãy sao chép thứ gì đó trước, rồi dán vào trang bất kỳ',
'journey.studio.pageFull':
'Trang này đã đầy: không thể thêm thành phần nào nữa. Hãy xóa bớt trước hoặc dùng trang khác.',
'journey.studio.myLayouts': 'Bố cục của tôi',
'journey.studio.myLayoutsEmpty':
'Lưu một trang bạn đã sắp xếp và dàn các trang khác theo cùng cách. Ảnh và chữ của chúng được giữ nguyên.',
+1
View File
@@ -447,6 +447,7 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': '複製,可貼到任何頁面 (Ctrl+C)',
'journey.studio.paste': '貼上 (Ctrl+V)',
'journey.studio.pasteEmpty': '請先複製內容,再貼到任何頁面',
'journey.studio.pageFull': '此頁面已滿:單頁已放滿元素。請先刪除一些內容,或使用其他頁面。',
'journey.studio.myLayouts': '我的版面',
'journey.studio.myLayoutsEmpty': '保存你排好的頁面,讓其他頁面套用相同的版面。它們的照片與文字會保留。',
'journey.studio.saveLayout': '將此頁面儲存為版面',
+1
View File
@@ -447,6 +447,7 @@ const journey: TranslationStrings = {
'journey.studio.copyToPage': '复制,可粘贴到任意页面 (Ctrl+C)',
'journey.studio.paste': '粘贴 (Ctrl+V)',
'journey.studio.pasteEmpty': '请先复制内容,然后粘贴到任意页面',
'journey.studio.pageFull': '此页面已满:单页已放满元素。请先删除一些内容,或使用其他页面。',
'journey.studio.myLayouts': '我的版式',
'journey.studio.myLayoutsEmpty': '保存你排好的页面,让其他页面采用相同的排版。它们的照片和文字保持不变。',
'journey.studio.saveLayout': '将此页面保存为版式',