mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(journey): the Studio export dialog in the new look, and the Atlas country card draws its flag
The export dialog was the last popup on the old card: it is now the shared dialog frame with its options as rows, and it takes Escape for itself so the key no longer reaches Studio behind it. Its stylesheet goes. The Atlas country card printed the flag emoji, which Windows draws as the two letters of the country code; it uses the shared CountryFlag now.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { BookOpen, FileText, Printer, Scissors, X } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { BookOpen, FileText, Printer, Scissors } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import type { BookDocument } from '@trek/shared'
|
||||
import { BookSheetsView } from './BookSheetsView'
|
||||
import { sheetBox, sheetsFor, type SheetMode } from './bookSheets'
|
||||
@@ -38,6 +39,7 @@ export function StudioExport({
|
||||
/** Set once the user has asked for it — this is what triggers the render. */
|
||||
const [building, setBuilding] = useState(false)
|
||||
const stage = useRef<HTMLDivElement>(null)
|
||||
const labelId = useId()
|
||||
|
||||
const sheets = sheetsFor(doc, mode)
|
||||
|
||||
@@ -76,78 +78,71 @@ export function StudioExport({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [building])
|
||||
|
||||
// Studio closes itself on Escape. While this dialog is open the key is its own,
|
||||
// so it is taken in the capture phase before the editor's handler sees it.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => document.removeEventListener('keydown', onKey, true)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="st-ex" role="dialog" aria-modal="true" aria-label={t('journey.studio.export')}>
|
||||
<div className="st-ex-card">
|
||||
<div className="st-ex-head">
|
||||
<span>{t('journey.studio.export')}</span>
|
||||
<button type="button" className="st-ex-x" onClick={onClose} aria-label={t('common.close')}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="st-ex-body">
|
||||
<Choice<SheetMode>
|
||||
label={t('journey.studio.exportLayout')}
|
||||
options={[
|
||||
{
|
||||
id: 'pages',
|
||||
icon: FileText,
|
||||
name: t('journey.studio.exportPages'),
|
||||
hint: t('journey.studio.exportPagesHint'),
|
||||
},
|
||||
{
|
||||
id: 'spreads',
|
||||
icon: BookOpen,
|
||||
name: t('journey.studio.exportSpreads'),
|
||||
hint: t('journey.studio.exportSpreadsHint'),
|
||||
},
|
||||
]}
|
||||
value={mode}
|
||||
onPick={setMode}
|
||||
<>
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Printer size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
title={t('journey.studio.export')}
|
||||
/>
|
||||
|
||||
<div className="st-ex-field">
|
||||
<span className="st-ex-label">{t('journey.studio.exportFinishing')}</span>
|
||||
<button type="button"
|
||||
className={`st-ex-opt ${marks ? 'is-on' : ''}`}
|
||||
onClick={() => setMarks(!marks)}
|
||||
aria-pressed={marks}
|
||||
>
|
||||
<Scissors size={15} />
|
||||
<span className="st-ex-text">
|
||||
<span className="st-ex-name">{t('journey.studio.exportMarks')}</span>
|
||||
<span className="st-ex-hint">
|
||||
{t('journey.studio.exportMarksHint', { bleed: doc.page.bleed })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={() => setBuilding(true)} disabled={building} icon={<Printer size={14} strokeWidth={2} />}>
|
||||
{t('journey.studio.exportOpen')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
<DialogSection label={t('journey.studio.exportLayout')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Option icon={FileText} name={t('journey.studio.exportPages')} hint={t('journey.studio.exportPagesHint')} on={mode === 'pages'} onClick={() => setMode('pages')} />
|
||||
<Option icon={BookOpen} name={t('journey.studio.exportSpreads')} hint={t('journey.studio.exportSpreadsHint')} on={mode === 'spreads'} onClick={() => setMode('spreads')} />
|
||||
</div>
|
||||
</DialogSection>
|
||||
|
||||
<p className="st-ex-note">
|
||||
{t('journey.studio.exportNote', {
|
||||
sheets: sheets.length,
|
||||
width: round1(box.width),
|
||||
height: round1(box.height),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<DialogSection label={t('journey.studio.exportFinishing')}>
|
||||
<Option icon={Scissors} name={t('journey.studio.exportMarks')} hint={t('journey.studio.exportMarksHint', { bleed: doc.page.bleed })} on={marks} onClick={() => setMarks(!marks)} />
|
||||
</DialogSection>
|
||||
|
||||
<div className="st-ex-foot">
|
||||
<button type="button" className="st-ex-btn" onClick={onClose}>{t('common.cancel')}</button>
|
||||
<button type="button" className="st-ex-btn is-primary" onClick={() => setBuilding(true)} disabled={building}>
|
||||
<Printer size={14} />
|
||||
<span>{t('journey.studio.exportOpen')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
|
||||
{t('journey.studio.exportNote', {
|
||||
sheets: sheets.length,
|
||||
width: round1(box.width),
|
||||
height: round1(box.height),
|
||||
})}
|
||||
</p>
|
||||
</DialogShell>
|
||||
|
||||
{/*
|
||||
The sheets, rendered where nobody can see them.
|
||||
Off screen rather than `display: none`: a hidden subtree lays nothing
|
||||
out, and these are measured in millimetres by the same CSS that will
|
||||
print them — a book built from an unlaid-out tree is a book of empty
|
||||
boxes. `aria-hidden` keeps the whole thing out of the reading order.
|
||||
print them, a book built from an unlaid-out tree is a book of empty
|
||||
boxes. They stay inside Studio's tree, where that CSS applies, while the
|
||||
dialog itself is portalled over it. `aria-hidden` keeps them out of the
|
||||
reading order.
|
||||
*/}
|
||||
{building && (
|
||||
<div
|
||||
@@ -158,37 +153,32 @@ export function StudioExport({
|
||||
<BookSheetsView doc={doc} mode={mode} marks={marks} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10
|
||||
|
||||
function Choice<T extends string>({
|
||||
label, options, value, onPick,
|
||||
}: {
|
||||
label: string
|
||||
options: { id: T; icon: typeof FileText; name: string; hint: string }[]
|
||||
value: T
|
||||
onPick: (id: T) => void
|
||||
/** One choice of the dialog: an icon, its name and what it means, pressed while it holds. */
|
||||
function Option({ icon: Icon, name, hint, on, onClick }: {
|
||||
icon: typeof FileText
|
||||
name: string
|
||||
hint: string
|
||||
on: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="st-ex-field">
|
||||
<span className="st-ex-label">{label}</span>
|
||||
{options.map(opt => (
|
||||
<button type="button"
|
||||
key={opt.id}
|
||||
className={`st-ex-opt ${value === opt.id ? 'is-on' : ''}`}
|
||||
onClick={() => onPick(opt.id)}
|
||||
aria-pressed={value === opt.id}
|
||||
>
|
||||
<opt.icon size={15} />
|
||||
<span className="st-ex-text">
|
||||
<span className="st-ex-name">{opt.name}</span>
|
||||
<span className="st-ex-hint">{opt.hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={on}
|
||||
className={`flex w-full items-start gap-3 rounded-[12px] border px-3 py-2.5 text-left transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge-faint bg-surface-card hover:bg-surface-hover'}`}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.9} className={`mt-0.5 flex-none ${on ? 'text-content' : 'text-content-muted'}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-semibold text-content" style={fs(13, 'body')}>{name}</span>
|
||||
<span className="block text-content-muted" style={fs(11.5)}>{hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Tooltip } from '../components/shared/Tooltip'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../components/shared/DialogShell'
|
||||
import { EditorField, GRID_2 } from '../components/shared/dialogParts'
|
||||
import DawarichAtlasSidePanel from '../components/Dawarich/DawarichAtlasSidePanel'
|
||||
import CountryFlag from '../components/shared/CountryFlag'
|
||||
import { A2_TO_A3, countryCodeToFlag, findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
||||
import { continentForCountry } from '@trek/shared'
|
||||
import { useAtlas } from './atlas/useAtlas'
|
||||
@@ -787,7 +788,10 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
<>
|
||||
<div style={{ width: 2, background: bg(0.08), margin: '12px 0' }} />
|
||||
<div className="flex items-center gap-3 px-6 py-4">
|
||||
<span className="text-3xl">{countryCodeToFlag(selectedCountry)}</span>
|
||||
{/* Twemoji where the platform has no flag glyphs: Windows draws the emoji as two letters. */}
|
||||
{/^[A-Za-z]{2}$/.test(selectedCountry)
|
||||
? <CountryFlag code={selectedCountry} size={30} />
|
||||
: <span className="text-3xl">{countryCodeToFlag(selectedCountry)}</span>}
|
||||
<div>
|
||||
<p className="text-sm font-bold" style={{ color: tp }}>
|
||||
{resolveName(selectedCountry)}
|
||||
|
||||
@@ -1662,118 +1662,6 @@
|
||||
@keyframes st-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .trek-dash .st-spin { animation: none; } }
|
||||
|
||||
/* ── Export dialog (#1973) ────────────────────────────────────────────────
|
||||
Its own prefix rather than `.st-field` and friends: those already mean a
|
||||
labelled row in the toolbar, and one class doing two jobs is how a toolbar
|
||||
tweak silently reflows a dialog. */
|
||||
.trek-dash .st-ex {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: color-mix(in oklch, var(--ink) 42%, transparent);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.trek-dash .st-ex-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
/* Opaque for the same reason as .st-menu — see there. */
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
.trek-dash .st-ex-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.trek-dash .st-ex-x {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 7px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.trek-dash .st-ex-x:hover { background: color-mix(in oklch, var(--ink) 8%, transparent); color: var(--ink); }
|
||||
.trek-dash .st-ex-body { padding: 14px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.trek-dash .st-ex-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.trek-dash .st-ex-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.trek-dash .st-ex-opt {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 11px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-2);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.trek-dash .st-ex-opt:hover { border-color: color-mix(in oklch, var(--accent) 40%, var(--line)); }
|
||||
.trek-dash .st-ex-opt.is-on {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in oklch, var(--accent) 9%, var(--surface-2));
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-ex-opt svg { margin-top: 1px; flex: 0 0 auto; }
|
||||
.trek-dash .st-ex-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.trek-dash .st-ex-name { font-size: 12.5px; font-weight: 600; color: var(--ink); }
|
||||
.trek-dash .st-ex-hint { font-size: 11px; line-height: 1.35; color: var(--ink-2); }
|
||||
.trek-dash .st-ex-note {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.trek-dash .st-ex-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.trek-dash .st-ex-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 13px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--surface-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-ex-btn:hover { background: color-mix(in oklch, var(--ink) 6%, var(--surface-2)); }
|
||||
.trek-dash .st-ex-btn.is-primary {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text, #fff);
|
||||
}
|
||||
.trek-dash .st-ex-btn.is-primary:hover { filter: brightness(1.06); }
|
||||
.trek-dash .st-ex-btn[disabled] { opacity: 0.55; }
|
||||
|
||||
/* ── Presence and pointers (#1973) ────────────────────────────────────────
|
||||
The arrow is positioned in the sheet's millimetres and scales itself back
|
||||
out, so it stays one size at every zoom — see PeerCursors.tsx. */
|
||||
|
||||
@@ -42,8 +42,8 @@ afterEach(() => { vi.useRealTimers() })
|
||||
describe('the choices', () => {
|
||||
it('starts on single pages, which is what a printer takes', () => {
|
||||
open()
|
||||
expect(screen.getByText('journey.studio.exportPages').closest('button')!.className)
|
||||
.toContain('is-on')
|
||||
expect(screen.getByText('journey.studio.exportPages').closest('button')!.getAttribute('aria-pressed'))
|
||||
.toBe('true')
|
||||
})
|
||||
|
||||
it('starts with crop marks on', () => {
|
||||
@@ -55,10 +55,10 @@ describe('the choices', () => {
|
||||
it('switches to spreads when asked', () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByText('journey.studio.exportSpreads'))
|
||||
expect(screen.getByText('journey.studio.exportSpreads').closest('button')!.className)
|
||||
.toContain('is-on')
|
||||
expect(screen.getByText('journey.studio.exportPages').closest('button')!.className)
|
||||
.not.toContain('is-on')
|
||||
expect(screen.getByText('journey.studio.exportSpreads').closest('button')!.getAttribute('aria-pressed'))
|
||||
.toBe('true')
|
||||
expect(screen.getByText('journey.studio.exportPages').closest('button')!.getAttribute('aria-pressed'))
|
||||
.toBe('false')
|
||||
})
|
||||
|
||||
it('turns the marks off again', () => {
|
||||
|
||||
Reference in New Issue
Block a user