mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user