mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user