diff --git a/packages/reader/src/ChalkReader.tsx b/packages/reader/src/ChalkReader.tsx index 1536072..6e2f305 100644 --- a/packages/reader/src/ChalkReader.tsx +++ b/packages/reader/src/ChalkReader.tsx @@ -530,7 +530,7 @@ function Reader(props: Omit & { book: Book; config: }, [streamed, page, pages.length, config.streaming.window]); const wantsAuthor = authoringOn && mode === 'author'; // while the rest of a streamed book loads for editing, say how far it is (not a notice that fades: it stays until the book is whole) - const [loadProgress, setLoadProgress] = useState<{ done: number; total: number } | null>(null); + const [loadProgress, setLoadProgress] = useState<{ done: number; total: number; finished?: boolean } | null>(null); useEffect(() => { if (!streamed || !wantsAuthor || streamed.complete) { setLoadProgress(null); return; } let last = 0; @@ -538,11 +538,13 @@ function Reader(props: Omit & { book: Book; config: show(); // pages arrive many times a second: draw the count a few times a second const off = streamed.subscribe(() => { const now = Date.now(); if (now - last >= 200) { last = now; show(); } }); + let fade: ReturnType | undefined; streamed.loadAll().then( - () => setLoadProgress(null), + // the bar fills, then fades away while the editor's tools fade in over their placeholders (nothing on the page moves) + () => { setLoadProgress((p) => (p ? { done: p.total, total: p.total, finished: true } : null)); fade = setTimeout(() => setLoadProgress(null), 450); }, (e: unknown) => { setLoadProgress(null); setToast(e instanceof Error ? e.message : String(e)); }, ); - return off; + return () => { off(); clearTimeout(fade); }; }, [streamed, wantsAuthor]); // eslint-disable-line react-hooks/exhaustive-deps // search a streamed book with its text file: pages not loaded yet are searched by their text runs useEffect(() => { if (streamed && (panel === 'search' || query)) void streamed.searchText().then(setStreamText); }, [streamed, panel, query]); @@ -809,6 +811,9 @@ function Reader(props: Omit & { book: Book; config: return lesson.pages.find((p) => p.id === sel.pageId)?.objects.find((o) => o.id === sel.id) ?? null; }, [sel, lesson]); const sidebar = author && (rootWidth >= 720 || !!selectedObject || !!adding); + // the book is loading before editing begins: the editor's layout is already in place (placeholders for the rail and the sidebar), + // so when it is ready its tools fade in where the placeholders were and the pages do not move or change size + const preparing = wantsAuthor && !author; // a selection that was undone away is gone useEffect(() => { if (sel && !selectedObject) setSel(null); }, [sel, selectedObject]); // on a phone the properties sheet covers the lower part: bring a newly selected object up above it @@ -835,7 +840,7 @@ function Reader(props: Omit & { book: Book; config: return ( -
+
= 720) ? '' : undefined} data-mode={authoringOn ? mode : undefined} lang={locale} style={props.style} onKeyDown={onKey}> & { book: Book; config: requestAnimationFrame(() => root.current?.querySelector('.chalk-sidebar :is(input, button, select):not([disabled])')?.focus({ preventScroll: true })); }} /> )} + {preparing &&
} + {preparing && rootWidth >= 720 &&
} {author && } {sidebar && ( x.id === sel.pageId) : pages[page]} selected={selectedObject} @@ -896,7 +903,17 @@ function Reader(props: Omit & { book: Book; config: { const f = e.target.files?.[0]; if (f) void importNotes(f); e.target.value = ''; }} /> {loadProgress - ?
{at_.loadingProgress.replace('{done}', num(authorLocale, loadProgress.done)).replace('{total}', num(authorLocale, loadProgress.total))}
+ ? (() => { + // a bar that fills as the pages arrive, with the count under it in words + const label = at_.loadingProgress.replace('{done}', num(authorLocale, loadProgress.done)).replace('{total}', num(authorLocale, loadProgress.total)); + const pct = loadProgress.total ? Math.min(100, (loadProgress.done / loadProgress.total) * 100) : 0; + return ( +
+ {label} + +
+ ); + })() : (toast ?? saveNote) &&
{toast ?? saveNote}
} {lookup.missing.size > 0 && goTo(i)} />} {dockCfg && ( diff --git a/packages/reader/src/styles.css b/packages/reader/src/styles.css index 589ce84..750d98a 100644 --- a/packages/reader/src/styles.css +++ b/packages/reader/src/styles.css @@ -477,6 +477,29 @@ button.chalk-box:focus-visible { outline: 3px solid var(--chalk-focus); outline- font-size: var(--chalk-text-sm); box-shadow: var(--chalk-float-shadow); animation: chalk-fade-in var(--chalk-dur) var(--chalk-ease-out); } @keyframes chalk-fade-in { from { opacity: 0; } to { opacity: 1; } } +/* the whole book loading for editing: a bar that fills, with the count over it */ +.chalk-load { display: grid; gap: 8px; width: min(340px, calc(100% - 32px)); } +.chalk-load-track { display: block; height: 6px; border-radius: 999px; background: color-mix(in oklch, var(--chalk-surface) 22%, transparent); overflow: hidden; } +.chalk-load-fill { display: block; height: 100%; border-radius: inherit; background: var(--chalk-accent-soft); transition: width 200ms linear; } +/* filled: it fades away while the editor's tools fade in */ +.chalk-load[data-finished] { animation: chalk-fade-out 450ms var(--chalk-ease-out) forwards; } +@keyframes chalk-fade-out { from { opacity: 1; } to { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { .chalk-load-fill { transition: none; } .chalk-load[data-finished] { animation: none; } } + +/* the editor getting ready (the whole book loading): its rail and sidebar as quiet placeholders, in the places they will take */ +.chalk-ghost-rail { width: 46px; height: min(320px, 60%); padding: 0; box-shadow: none; background: color-mix(in oklch, var(--chalk-surface) 60%, transparent); } +.chalk-ghost-sidebar { gap: 14px; padding: var(--chalk-space-lg); box-shadow: none; background: color-mix(in oklch, var(--chalk-surface) 60%, transparent); } +.chalk-ghost-sidebar span { display: block; height: 12px; border-radius: 6px; background: var(--chalk-surface-2); } +.chalk-ghost-sidebar span:first-child { width: 55%; height: 16px; } +.chalk-ghost-sidebar span:nth-child(3) { width: 80%; } +.chalk-ghost-sidebar span:nth-child(4) { width: 65%; } +.chalk-ghost-rail, .chalk-ghost-sidebar span { animation: chalk-ghost-pulse 1.6s ease-in-out infinite; } +@keyframes chalk-ghost-pulse { 50% { opacity: 0.55; } } +/* and the real ones fade in over them */ +.chalk-reader[data-mode="author"] .chalk-rail:not(.chalk-ghost-rail), .chalk-reader[data-mode="author"] .chalk-sidebar:not(.chalk-ghost-sidebar) { animation: chalk-fade-in 260ms var(--chalk-ease-out); } +@media (prefers-reduced-motion: reduce) { + .chalk-ghost-rail, .chalk-ghost-sidebar span, .chalk-reader[data-mode="author"] .chalk-rail, .chalk-reader[data-mode="author"] .chalk-sidebar { animation: none; } +} /* in a side dock the page number stacks: the current page over the count, a button wide */ .chalk-dock.is-left .chalk-dock-page, .chalk-dock.is-right .chalk-dock-page { flex-direction: column; align-items: center; justify-content: center; gap: 0; width: var(--chalk-btn); height: 48px; padding: 0; line-height: 1.15;