feat(studio): a book saved by a newer Studio opens read-only instead of losing what it cannot read

- A book carries schemaRev. The reader that meets a newer one opens the book
  read-only with a reload banner and saves nothing, where it used to drop the
  elements it could not read on the next save.
- The server refuses a save, a restore or a tool edit that would write an older
  revision over a newer book or one newer than itself (409 book_schema_newer),
  on REST and through every book tool alike.
- normalizeBookDocument moves to book-normalize.ts.
- Print waits for photos eagerly and up to 15 s for an export of more than 40
  photos, and skips images without a source.
This commit is contained in:
Maurice
2026-10-23 14:14:54 +02:00
parent 3292a092ae
commit 30382dc366
52 changed files with 1036 additions and 135 deletions
+26 -1
View File
@@ -15,13 +15,15 @@ import type { SaveState } from './useBookStore'
* which is exactly the wrong habit for the one time it says something else.
*/
export function SaveIndicator({
state, t, onAcceptTheirs, onKeepMine, onRetry,
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
}: {
state: SaveState
t: (k: string) => string
onAcceptTheirs: (current: BookRecord) => void
onKeepMine: (current: BookRecord) => void
onRetry: () => void
/** Load this Studio again, for a book a newer one saved. A full reload by default. */
onReload?: () => void
}) {
if (state.status === 'conflict') {
return (
@@ -43,6 +45,24 @@ export function SaveIndicator({
)
}
if (state.status === 'newer') {
/*
* The one state with a way out the user has to take themselves: a newer
* Studio saved this book, this tab cannot write it without losing what
* that one added, and only a reload brings the newer Studio here. Loud
* like a conflict, because until then nothing done here is kept.
*/
return (
<div className="st-save is-conflict" role="alert">
<AlertTriangle size={13} />
<span>{t('journey.studio.saveNewer')}</span>
<button type="button" onClick={onReload}>
{t('journey.studio.saveReload')}
</button>
</div>
)
}
if (state.status === 'readonly') {
/*
* Not an error and not retryable: this person was invited to read the
@@ -86,3 +106,8 @@ export function SaveIndicator({
return null
}
/** The default way back to a current Studio: the page again, which loads the newest build. */
function reloadStudio() {
window.location.reload()
}
+4 -1
View File
@@ -260,7 +260,10 @@ function PhotoView({ el, big, print, dropLabel }: {
src={photoSrc(el.photoId!, big)}
alt=""
draggable={false}
loading="lazy"
// Lazy on the canvas, where a long book scrolls past; eager in print,
// where every sheet is laid out at once and a picture the browser put
// off is a picture missing from the PDF.
loading={print ? 'eager' : 'lazy'}
style={{
width: '100%',
height: '100%',
@@ -0,0 +1,75 @@
/**
* FE-STUDIO-PRINT-001 to FE-STUDIO-PRINT-004
*
* When the print view of a book may be printed: every picture in, the SVG
* ones too wherever they sit (a map marker, a photo behind a clip path), and
* a ceiling that gives a book of many photographs longer before the Save
* button gives up waiting.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { printReadyCeilingMs, svgImagesToWait, whenReady } from './printReady';
const SVG = 'http://www.w3.org/2000/svg';
const XLINK = 'http://www.w3.org/1999/xlink';
/** A print document as the frame holds it, built from markup. */
function printDoc(markup: string): Document {
const doc = document.implementation.createHTMLDocument('print');
doc.body.innerHTML = markup;
return doc;
}
/** An iframe whose document and window are the given ones, as the print view's frame is once it loaded. */
function frameOf(doc: Document, win: Partial<Window> = {}): HTMLIFrameElement {
const iframe = document.createElement('iframe');
Object.defineProperty(iframe, 'contentDocument', { value: doc, configurable: true });
Object.defineProperty(iframe, 'contentWindow', { value: win, configurable: true });
return iframe;
}
afterEach(() => {
vi.useRealTimers();
});
describe('the pictures to wait for', () => {
it('FE-STUDIO-PRINT-001: finds an SVG image inside a clip path and in the defs, not only a marker photo', () => {
const doc = printDoc(
`<svg xmlns="${SVG}"><defs><clipPath id="c"><image href="/a.jpg"/></clipPath>` +
`<image id="d" href="/b.jpg"/></defs><image href="/c.jpg"/></svg>`
);
expect(svgImagesToWait(doc).map((el) => el.getAttribute('href'))).toEqual(['/a.jpg', '/b.jpg', '/c.jpg']);
});
it('FE-STUDIO-PRINT-002: takes an xlink source, and leaves out an image that names none and would never load', () => {
const doc = printDoc(`<svg xmlns="${SVG}"><image/></svg>`);
const linked = doc.createElementNS(SVG, 'image');
linked.setAttributeNS(XLINK, 'xlink:href', '/x.jpg');
doc.querySelector('svg')?.appendChild(linked);
expect(svgImagesToWait(doc)).toEqual([linked]);
});
});
describe('the ceiling', () => {
it('FE-STUDIO-PRINT-003: waits eight seconds for an ordinary book and fifteen for one of more than 40 photos', () => {
expect(printReadyCeilingMs(0)).toBe(8000);
expect(printReadyCeilingMs(40)).toBe(8000);
expect(printReadyCeilingMs(41)).toBe(15000);
});
it('FE-STUDIO-PRINT-004: holds a large book past eight seconds while an image is still loading', async () => {
vi.useFakeTimers();
const many = Array.from({ length: 41 }, (_, i) => `<image href="/p${i}.jpg"/>`).join('');
const doc = printDoc(`<svg xmlns="${SVG}">${many}</svg>`);
let ready = false;
// A window without its own timers: the wait falls back to the global one, which the fake timers drive.
void whenReady(frameOf(doc)).then(() => {
ready = true;
});
await vi.advanceTimersByTimeAsync(8100);
expect(ready).toBe(false);
await vi.advanceTimersByTimeAsync(7000);
expect(ready).toBe(true);
});
});
@@ -0,0 +1,77 @@
/**
* When the print view of a book is actually printable.
*
* Both halves matter and neither is covered by the load event: a photograph
* still decoding prints as an empty box, and a face still loading prints in the
* fallback, the exact substitution the bundled typefaces exist to prevent.
*/
/** How long the print view waits at most, and the photo count past which a book gets the longer wait. */
const PRINT_READY = {
ceilingMs: 8000,
largeCeilingMs: 15000,
largeBookPhotos: 40,
} as const;
/**
* The longest the Save button stays off for a book of `photos` pictures. A
* book of a few dozen photographs from a slow provider can take longer than
* eight seconds to decode; one that has not finished must not go to the press.
*/
export function printReadyCeilingMs(photos: number): number {
return photos > PRINT_READY.largeBookPhotos ? PRINT_READY.largeCeilingMs : PRINT_READY.ceilingMs;
}
/** What `whenReady` reads off the print document: its `<img>` elements, its SVG and its fonts. */
interface PrintDocument {
images: ArrayLike<Element & { complete?: boolean }>;
querySelectorAll?: (selector: string) => ArrayLike<Element>;
fonts?: { ready: Promise<unknown> };
}
const XLINK = 'http://www.w3.org/1999/xlink';
/**
* The SVG pictures to wait for: every `<image>` that names a source, wherever
* it sits. `doc.images` does not collect them, and they are not only the
* photographs in a map's markers: a photo cut to a mask or set behind clipped
* type is an `<image>` inside a `<clipPath>` or `<defs>`, which the selector
* reaches just the same. One that names no source would never fire an event,
* so it is left out rather than left to hold the button until the ceiling.
*/
export function svgImagesToWait(doc: PrintDocument): Element[] {
const found = Array.from(doc.querySelectorAll?.('image') ?? []);
return found.filter((el) => !!(el.getAttribute('href') || el.getAttributeNS(XLINK, 'href')));
}
/** Resolves when the element has loaded or failed: a picture that will not load must not hold the button hostage. */
function settle(el: Element): Promise<void> {
return new Promise<void>((resolve) => {
el.addEventListener('load', () => resolve(), { once: true });
el.addEventListener('error', () => resolve(), { once: true });
});
}
/**
* Wait until the print view's pictures and faces are in, or until the
* ceiling, which rises for a book with many photographs. Without `complete`
* on an SVG image there is no way to tell one that is still coming from one
* whose events fired before this ran, and a Save button that never enables is
* worse than a marker that prints blank.
*/
export async function whenReady(iframe: HTMLIFrameElement): Promise<void> {
const win = iframe.contentWindow;
const doc = iframe.contentDocument as PrintDocument | null;
if (!win || !doc) return;
const imgs = Array.from(doc.images);
const svg = svgImagesToWait(doc);
const images = imgs.map((img) => (img.complete ? Promise.resolve() : settle(img)));
const wait = win.setTimeout?.bind(win) ?? setTimeout;
const ceiling = new Promise<void>((resolve) => {
wait(resolve, printReadyCeilingMs(imgs.length + svg.length));
});
await Promise.race([Promise.all([...images, ...svg.map(settle), doc.fonts?.ready ?? Promise.resolve()]), ceiling]);
}
+2 -44
View File
@@ -23,6 +23,8 @@
* standalone PWA. An overlay with a `srcdoc` iframe works everywhere.
*/
import { whenReady } from './printReady'
/*
* The only literal colours in Studio, and deliberately so.
*
@@ -254,47 +256,3 @@ ${singleRule(input)}
return close
}
/**
* When the document is actually printable.
*
* Both halves matter and neither is covered by the load event: a photograph
* still decoding prints as an empty box, and a face still loading prints in the
* fallback — the exact substitution the bundled typefaces exist to prevent.
*/
async function whenReady(iframe: HTMLIFrameElement): Promise<void> {
const win = iframe.contentWindow
const doc = iframe.contentDocument
if (!win || !doc) return
const settle = (el: Element) => new Promise<void>(resolve => {
// A picture that will not load must not hold the button hostage — better a
// book with one gap than a dialog that never finishes.
el.addEventListener('load', () => resolve(), { once: true })
el.addEventListener('error', () => resolve(), { once: true })
})
const images = Array.from(doc.images).map(img => (img.complete ? Promise.resolve() : settle(img)))
/*
* The photographs inside the map's markers are SVG `<image>`, which
* `doc.images` does not collect and which has no `complete` to ask, so there
* is nothing to do but wait on the events. Missing them meant a book could go
* to print with its route beads still blank.
*/
const svgImages = Array.from(doc.querySelectorAll?.('image') ?? []).map(settle)
/*
* And a ceiling over the lot. Without `complete` to fall back on there is no
* way to tell a picture that is still coming from one whose events already
* fired before this ran, and a Save button that never enables is worse than a
* marker that prints blank.
*/
const wait = win.setTimeout?.bind(win) ?? setTimeout
const ceiling = new Promise<void>(resolve => { wait(resolve, 8000) })
await Promise.race([
Promise.all([...images, ...svgImages, doc.fonts?.ready ?? Promise.resolve()]),
ceiling,
])
}
+36 -10
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { BookDocument, BookRecord } from '@trek/shared'
import { normalizeBookDocument } from '@trek/shared'
import { BOOK_SCHEMA_NEWER_CODE, readBookDocument, stampBookSchemaRev } from '@trek/shared'
import { journeyApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
@@ -54,6 +54,16 @@ import { addListener, removeListener } from '../../api/websocket'
* either: it is remembered and read once that work has reached the server,
* because the save response and the broadcast travel on different channels and
* either can arrive first.
*
* ── Why a book from a newer Studio is never written ───────────────────────
*
* A document says which revision of the format wrote it (book-schema-rev.ts).
* This build reads a newer one as far as it can, and what it cannot read is
* already gone from the copy it holds: saving that copy would delete the rest
* for everyone. So such a book is shown and never written, the same way a
* viewer's is, until a reload brings the Studio that wrote it. The server
* refuses the save as well, which covers a tab that loaded the book before the
* newer Studio saved it.
*/
/** How long the document has to stay still before it is written. */
@@ -69,6 +79,8 @@ export type SaveState =
| { status: 'conflict'; current: BookRecord }
/** The journey is open to this user, but they may not write it (role 'viewer'). */
| { status: 'readonly' }
/** The book was saved by a newer Studio than this one: shown, never written, until a reload. */
| { status: 'newer' }
| { status: 'error' }
/** What the editor needs to know about a version it is handed from elsewhere. */
@@ -176,7 +188,12 @@ export function useBookStore(
* agreed document by identity, so a consumer that normalised on its own
* would hand back a different object and turn every open into a save.
*/
const book = res.book ? { ...res.book, document: normalizeBookDocument(res.book.document) } : null
const read = res.book ? readBookDocument(res.book.document) : null
const book = res.book && read ? { ...res.book, document: read.document } : null
if (read?.newer) {
blocked.current = true
setState({ status: 'newer' })
}
setRecord(book)
version.current = book?.version ?? null
bookId.current = book?.id ?? null
@@ -201,7 +218,8 @@ export function useBookStore(
try {
const saved = await journeyApi.saveBook(journeyId, {
title: next.title,
document: next.document,
// Stamped on the way out only: `synced` below keeps the editor's own object.
document: stampBookSchemaRev(next.document),
baseVersion: version.current ?? undefined,
})
setRecord(saved)
@@ -217,8 +235,12 @@ export function useBookStore(
* not need a second request: the editor can offer to take their version
* or keep going from here, and either way it can say whose work it is.
*/
const res = (err as { response?: { status?: number; data?: { current?: BookRecord } } }).response
if (res?.status === 409 && res.data?.current) {
const res = (err as { response?: { status?: number; data?: { current?: BookRecord; code?: string } } }).response
if (res?.status === 409 && res.data?.code === BOOK_SCHEMA_NEWER_CODE) {
// A newer Studio saved the book since this tab read it: nothing here may be written over it.
blocked.current = true
setState({ status: 'newer' })
} else if (res?.status === 409 && res.data?.current) {
blocked.current = true
setState({ status: 'conflict', current: res.data.current })
} else if (res?.status === 403) {
@@ -305,15 +327,15 @@ export function useBookStore(
/** Take the other version, discarding the local one. */
const acceptTheirs = useCallback((current: BookRecord) => {
const document = normalizeBookDocument(current.document)
blocked.current = false
const { document, newer } = readBookDocument(current.document)
blocked.current = newer
setRecord({ ...current, document })
version.current = current.version
bookId.current = current.id
synced.current = document
latest.current = document
pending.current = null
setState({ status: 'idle' })
setState(newer ? { status: 'newer' } : { status: 'idle' })
// A save from elsewhere passed over while the conflict was open may be
// newer than the record it carried.
catchUp.current()
@@ -388,8 +410,12 @@ export function useBookStore(
if (sameBook && !isNewer(res.book.version)) return
// Normalised for the same reason the initial load is: a document
// written by an older build is missing whatever the contract has
// gained since.
const document = normalizeBookDocument(res.book.document)
// gained since. One written by a newer build is shown and held.
const { document, newer } = readBookDocument(res.book.document)
if (newer) {
blocked.current = true
setState({ status: 'newer' })
}
const held = version.current
const taken = res.book.version
// Undoable only when every save it folds in, past the version held,