feat: quality of life sweep from the feature discussions

Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
This commit is contained in:
Maurice
2026-09-30 23:00:12 +02:00
parent 91dfab3f4b
commit 8fd46c4c18
691 changed files with 9491 additions and 927 deletions
+15 -1
View File
@@ -97,6 +97,9 @@ export function StudioCanvas({
const select = useStudioStore(s => s.select)
const removeElements = useStudioStore(s => s.removeElements)
const duplicate = useStudioStore(s => s.duplicate)
const copy = useStudioStore(s => s.copy)
const paste = useStudioStore(s => s.paste)
const hasClipboard = useStudioStore(s => !!s.clipboard)
const raise = useStudioStore(s => s.raise)
const commit = useStudioStore(s => s.commit)
const addElement = useStudioStore(s => s.addElement)
@@ -155,10 +158,21 @@ export function StudioCanvas({
const ids = deletable(spread, selection)
if (ids.length) removeElements(spreadIndex, ids)
}
// Copy and paste across pages (#2316). Text selected on the page is the
// browser's to copy, not the book's.
if (!(e.metaKey || e.ctrlKey) || e.altKey) return
const key = e.key.toLowerCase()
if (key === 'c' && selection.length && !window.getSelection()?.toString()) {
e.preventDefault()
copy(spreadIndex, selection)
} else if (key === 'v' && hasClipboard) {
e.preventDefault()
paste(spreadIndex)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [selection, spread, spreadIndex, removeElements])
}, [selection, spread, spreadIndex, removeElements, copy, paste, hasClipboard])
if (!spread) return null
@@ -1,7 +1,7 @@
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared'
import {
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp,
ChevronsDown, ChevronsUp, Copy, Italic, Lock, Trash2, Unlock,
ChevronsDown, ChevronsUp, ClipboardCopy, Copy, Italic, Lock, Trash2, Unlock,
} from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { useStudioStore } from '../../store/studioStore'
@@ -15,6 +15,7 @@ import { formatBookCoords } from './entryText'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
/** How many icons the swap grid offers at once. A search narrows it below this. */
const ICON_CHOICES = 60
@@ -78,6 +79,7 @@ export function StudioInspector({
const commit = useStudioStore(s => s.commit)
const raise = useStudioStore(s => s.raise)
const duplicate = useStudioStore(s => s.duplicate)
const copy = useStudioStore(s => s.copy)
const removeElements = useStudioStore(s => s.removeElements)
const spread = doc?.spreads[spreadIndex]
@@ -232,6 +234,15 @@ export function StudioInspector({
{sel.length > 1 && <em>{sel.length}</em>}
</span>
<span className="st-head-acts">
<Tooltip label={t('journey.studio.copyToPage')}>
<button type="button"
className="st-act"
onClick={() => copy(spreadIndex, selection)}
aria-label={t('journey.studio.copyToPage')}
>
<ClipboardCopy size={14} />
</button>
</Tooltip>
<button type="button"
className="st-act"
onClick={() => duplicate(spreadIndex, selection)}
@@ -0,0 +1,84 @@
import type { BookPageSetup } from '@trek/shared'
import { MAX_BOOK_LAYOUTS } from '@trek/shared'
import { BookmarkPlus, Trash2 } from 'lucide-react'
import { useStudioStore } from '../../store/studioStore'
import { Tooltip } from '../shared/Tooltip'
import { SpreadView } from './SpreadView'
import { layoutPreview, layoutRole, nextLayoutName } from './savedLayouts'
/**
* The book's own layouts, above the built-in ones (#2316).
*
* One button keeps the page on screen as a layout; each card lays the page on
* screen out that way, keeping its pictures and words. The cards draw the
* layout with the same renderer as the page, so what is kept is what is shown.
*/
export function StudioSavedLayouts({
page, width, pxPerMm, t,
}: { page: BookPageSetup; width: number; pxPerMm: number; t: (k: string) => string }) {
const doc = useStudioStore(s => s.doc)
const active = useStudioStore(s => s.activeSpread)
const saveLayout = useStudioStore(s => s.saveLayout)
const removeLayout = useStudioStore(s => s.removeLayout)
const applyLayout = useStudioStore(s => s.applyLayout)
const spread = doc?.spreads[active]
if (!doc || !spread) return null
const all = doc.layouts ?? []
const role = layoutRole(spread)
const fitting = all.filter(l => l.role === role)
const full = all.length >= MAX_BOOK_LAYOUTS
const canSave = spread.elements.length > 0 && !full
const single = role !== 'inner'
const wMm = page.pageWidth * (single ? 1 : 2)
const scale = width / (wMm * pxPerMm)
return (
<div className="st-saved-layouts">
<div className="st-section-label">{t('journey.studio.myLayouts')}</div>
{fitting.length === 0 && <p className="st-hint">{t('journey.studio.myLayoutsEmpty')}</p>}
<div className="st-thumbs">
{fitting.map(layout => (
<div className="st-thumb-row" key={layout.id}>
<button type="button" className="st-thumb" onClick={() => applyLayout(active, layout.id)}>
<div className="st-thumb-sheet" style={{ width, height: page.pageHeight * pxPerMm * scale }}>
<div
style={{
position: 'absolute', left: 0, top: 0,
width: `${wMm}mm`, height: `${page.pageHeight}mm`,
transform: `scale(${scale})`, transformOrigin: 'top left',
}}
>
<SpreadView spread={layoutPreview(layout, page)} page={page} />
</div>
</div>
<span className="st-thumb-label">{layout.name}</span>
</button>
<div className="st-thumb-actions">
<Tooltip label={t('journey.studio.deleteLayout')}>
<button type="button"
className="is-danger"
onClick={() => removeLayout(layout.id)}
aria-label={t('journey.studio.deleteLayout')}
>
<Trash2 size={12} />
</button>
</Tooltip>
</div>
</div>
))}
</div>
<Tooltip label={t(full ? 'journey.studio.saveLayoutFull' : 'journey.studio.saveLayoutHint')}>
<button type="button"
className="st-add-page"
disabled={!canSave}
onClick={() => saveLayout(active, nextLayoutName(all, t('journey.studio.layoutName')))}
>
<BookmarkPlus size={14} />
{t('journey.studio.saveLayout')}
</button>
</Tooltip>
<div className="st-section-label st-builtin-label">{t('journey.studio.builtInLayouts')}</div>
</div>
)
}
+27 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router'
import {
ArrowLeft, BookOpen, Check, ChevronDown, Download, Maximize2, Minus, Plus,
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, Minus, Plus,
Redo2, Sparkles, Undo2,
} from 'lucide-react'
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
@@ -17,6 +17,8 @@ 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 '../../styles/dashboard.css'
import '../../styles/studio.css'
import './bookFontFaces'
@@ -193,6 +195,7 @@ function StudioBar({
>
<Redo2 size={15} />
</button>
<PasteButton s={s} />
<div className="st-sep" />
@@ -221,6 +224,29 @@ function StudioBar({
* 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)
@@ -19,6 +19,7 @@ import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDe
import { PanelHead as Head } from './StudioPanelHead'
import { StudioElementsPanel } from './StudioElementsPanel'
import { StudioTravelPanel } from './StudioTravelPanel'
import { StudioSavedLayouts } from './StudioSavedLayouts'
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
import { StudioContentFilter } from './StudioContentFilter'
import { emptyKeyFor, matchingEntries, matchingPhotos, photosFor, type PhotoFilter } from './photoFilter'
@@ -776,6 +777,7 @@ function TemplatesPanel({
<>
<Head label={t('journey.studio.templates')} count={templates.length} />
<div className="st-panel-scroll">
<StudioSavedLayouts page={page} width={CARD_W} pxPerMm={pxPerMm} t={t} />
{(
<div className="st-thumbs" ref={list.ref}>
{templates.map(tpl => {
@@ -0,0 +1,131 @@
import type { BookElement, BookLayout, BookPageSetup, BookSpread } from '@trek/shared'
import { MAX_SPREAD_ELEMENTS } from '@trek/shared'
import { elementId } from './bookIds'
/**
* A book's own layouts (#2316): a page somebody arranged, kept to lay other
* pages out the same way.
*
* The built-in layouts in templates.ts are frames only. A saved one is the whole
* design, panels and shapes and type included, with the pictures taken out; the
* page it is applied to brings its own.
*/
/** Which saved layouts fit a spread: a double spread's design does not fit a single page. */
export const layoutRole = (spread: BookSpread): BookLayout['role'] => (spread.role === 'inner' ? 'inner' : 'single')
/** The arrangement of a spread, as a layout to keep. */
export function layoutFromSpread(spread: BookSpread, page: BookPageSetup, name: string): BookLayout {
return {
id: elementId('ly'),
name,
role: layoutRole(spread),
pageWidth: page.pageWidth,
pageHeight: page.pageHeight,
background: spread.background,
// The design, not the pictures: every frame comes out empty.
elements: spread.elements.map(el => (el.kind === 'photo' ? { ...el, photoId: null } : el)),
}
}
/** The next free "Layout n" name, so a save needs no dialog. */
export function nextLayoutName(existing: readonly BookLayout[], base: string): string {
const taken = new Set(existing.map(l => l.name))
let n = existing.length + 1
while (taken.has(`${base} ${n}`)) n += 1
return `${base} ${n}`
}
const textSize = (el: BookElement) => (el.kind === 'text' ? el.size : 0)
/**
* Lay a spread out on a saved layout, keeping what the spread holds.
*
* The same contract as applying a built-in layout: the spread's pictures go into
* the layout's frames in order, its words into the layout's text boxes (the
* largest into the largest, so the title stays the title), and whatever does not
* fit is parked rather than lost. The layout's own type keeps its look and takes
* the spread's words; a text box that was filled from a journal entry and finds
* nothing to take is left out, because its words belonged to the page it was
* saved from. The small marks and the pros and cons list are content too: each
* takes the values of the spread's own mark of the same kind, in order (the
* spread's day, its coordinates), and keeps the layout's look. Everything else
* is the design and is copied as drawn, scaled from the page it was drawn on to
* this one.
*/
export function applySavedLayout(spread: BookSpread, layout: BookLayout, page: BookPageSetup): BookSpread {
const sx = page.pageWidth / layout.pageWidth
const sy = page.pageHeight / layout.pageHeight
const pool = [...spread.elements, ...(spread.parked ?? [])]
const photos = pool.filter(e => e.kind === 'photo' && e.photoId != null)
const texts = pool.filter(e => e.kind === 'text' && e.text.trim()).sort((a, b) => textSize(b) - textSize(a))
// Which of the layout's text boxes takes which of the spread's words.
const slots = layout.elements.filter(e => e.kind === 'text').sort((a, b) => textSize(b) - textSize(a))
const wordsFor = new Map<BookElement, BookElement>()
slots.forEach((slot, i) => { if (texts[i]) wordsFor.set(slot, texts[i]) })
// The spread's own marks and lists, queued per kind, for the layout's to take their values from.
const marks = new Map<string, BookElement[]>()
for (const el of spread.elements) {
const key = el.kind === 'badge' ? `badge:${el.variant}` : el.kind === 'list' ? 'list' : null
if (key) marks.set(key, [...(marks.get(key) ?? []), el])
}
const takeMark = (key: string) => marks.get(key)?.shift()
let pi = 0
const out: BookElement[] = []
for (const el of layout.elements) {
const frame = { x: el.frame.x * sx, y: el.frame.y * sy, w: el.frame.w * sx, h: el.frame.h * sy }
const base = { ...el, id: elementId(el.kind[0]), frame } as BookElement
if (base.kind === 'photo') {
const src = photos[pi++]
out.push(src && src.kind === 'photo'
? { ...base, photoId: src.photoId, focalX: src.focalX, focalY: src.focalY }
: base)
continue
}
if (base.kind === 'text' && el.kind === 'text') {
const src = wordsFor.get(el)
if (src && src.kind === 'text') {
out.push({ ...base, text: src.text, binding: src.binding, overridden: src.overridden })
} else if (!el.binding) {
out.push(base)
}
continue
}
if (base.kind === 'badge') {
const own = takeMark(`badge:${base.variant}`)
out.push(own && own.kind === 'badge' ? { ...base, text: own.text, sub: own.sub, code: own.code } : base)
continue
}
if (base.kind === 'list') {
const own = takeMark('list')
out.push(own && own.kind === 'list' ? { ...base, items: own.items } : base)
continue
}
out.push(base)
}
const used = new Set(wordsFor.values())
const parked = [
...photos.slice(pi),
...texts.filter(t => !used.has(t)),
].slice(0, MAX_SPREAD_ELEMENTS)
return { ...spread, background: layout.background, elements: out, parked }
}
/** A saved layout drawn as a spread on this book's page, for its card. Nothing is filled in. */
export function layoutPreview(layout: BookLayout, page: BookPageSetup): BookSpread {
const sx = page.pageWidth / layout.pageWidth
const sy = page.pageHeight / layout.pageHeight
return {
id: layout.id,
role: layout.role === 'inner' ? 'inner' : 'first',
background: layout.background,
elements: layout.elements.map(el => ({ ...el, frame: { x: el.frame.x * sx, y: el.frame.y * sy, w: el.frame.w * sx, h: el.frame.h * sy } }) as BookElement),
parked: [],
entryId: null,
}
}
@@ -0,0 +1,40 @@
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
import { elementId } from './bookIds'
/**
* What was copied, and from where (#2316).
*
* The elements as they stood, in the millimetres of the spread they came from.
* The source spread's id is kept so a paste back onto the same page can step
* the copy aside rather than drop it exactly over the original.
*/
export interface StudioClipboard {
fromSpreadId: string
elements: BookElement[]
}
/** How far a paste onto its own page is stepped aside, the same step Duplicate takes. */
const OFFSET = 4
const spreadWidth = (spread: BookSpread, page: BookPageSetup) =>
page.pageWidth * (spread.role === 'inner' ? 2 : 1)
/**
* The clipboard's elements, ready to go onto `target`.
*
* Fresh ids every time, because the book is edited by several people at once
* and two elements sharing an id would merge into one on the next save. An
* element copied from a double spread onto a single page is pulled back inside
* it: its place on the right-hand page would otherwise be off the paper.
*/
export function pastedElements(clip: StudioClipboard, target: BookSpread, page: BookPageSetup): BookElement[] {
const width = spreadWidth(target, page)
const step = clip.fromSpreadId === target.id ? OFFSET : 0
return clip.elements.map(el => {
const w = Math.min(el.frame.w, width)
const h = Math.min(el.frame.h, page.pageHeight)
const x = Math.min(Math.max(el.frame.x + step, 0), width - w)
const y = Math.min(Math.max(el.frame.y + step, 0), page.pageHeight - h)
return { ...el, id: elementId(el.kind[0]), locked: false, frame: { x, y, w, h } } as BookElement
})
}