feat(library): update, rescan and stop on the folder menu, queued per roll
A folder row now asks for the rest of a roll, the whole of it from the top, or takes its request back. Requests queue behind the reading in flight and run one at a time; a row waiting its turn draws a ring that does not turn.
This commit is contained in:
@@ -18,17 +18,20 @@ import {
|
|||||||
readPhotos,
|
readPhotos,
|
||||||
removeFolder,
|
removeFolder,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
|
scanAsked,
|
||||||
scanBusy,
|
scanBusy,
|
||||||
|
scanQueue,
|
||||||
scanSession,
|
scanSession,
|
||||||
setStar,
|
setStar,
|
||||||
startScan,
|
|
||||||
stopScan,
|
stopScan,
|
||||||
|
stopScanAt,
|
||||||
unfinished,
|
unfinished,
|
||||||
watchRows,
|
watchRows,
|
||||||
watchScan,
|
watchScan,
|
||||||
type LibraryDir,
|
type LibraryDir,
|
||||||
type LibraryFolder,
|
type LibraryFolder,
|
||||||
type LibraryPhoto,
|
type LibraryPhoto,
|
||||||
|
type ScanMode,
|
||||||
} from './engine/library';
|
} from './engine/library';
|
||||||
import {
|
import {
|
||||||
backupNow,
|
backupNow,
|
||||||
@@ -210,6 +213,9 @@ export function Library() {
|
|||||||
// one more folder; `root` means it landed on a row at the top level — the head
|
// one more folder; `root` means it landed on a row at the top level — the head
|
||||||
// of a tree — which also carries folding the whole tree.
|
// of a tree — which also carries folding the whole tree.
|
||||||
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; rel?: string; node?: string; x: number; y: number } | null>(null);
|
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; rel?: string; node?: string; x: number; y: number } | null>(null);
|
||||||
|
// A reading of this roll that is still waiting its turn. The menu is where
|
||||||
|
// that wait is called off, so it has to know one exists.
|
||||||
|
const queued = menu?.folder ? scanQueue().some((j) => j.folder === menu.folder!.name) : false;
|
||||||
// Where the drag of the column divider started, and how wide the column was
|
// Where the drag of the column divider started, and how wide the column was
|
||||||
// then — the pointer only ever reports a position, not a distance moved.
|
// then — the pointer only ever reports a position, not a distance moved.
|
||||||
const grip = useRef<{ x: number; w: number } | null>(null);
|
const grip = useRef<{ x: number; w: number } | null>(null);
|
||||||
@@ -533,11 +539,17 @@ export function Library() {
|
|||||||
};
|
};
|
||||||
}, [menu]);
|
}, [menu]);
|
||||||
|
|
||||||
|
// A reading asked for here is a request, not a reading: one walk runs at a time,
|
||||||
|
// and a folder asked for while another is being read waits its turn — which is
|
||||||
|
// what makes a right click on four folders mean four readings rather than three
|
||||||
|
// refusals. The note lands when this request's turn comes, not when it is asked
|
||||||
|
// for, and a request called off before it ran says nothing at all.
|
||||||
const scan = useCallback(
|
const scan = useCallback(
|
||||||
async (folder: LibraryFolder, quiet = false, from = '') => {
|
async (folder: LibraryFolder, quiet = false, from = '', mode: ScanMode = 'update') => {
|
||||||
setNote(null);
|
setNote(null);
|
||||||
try {
|
try {
|
||||||
const result = await startScan(folder, from);
|
const result = await scanAsked(folder, from, mode);
|
||||||
|
if (!result) return;
|
||||||
if (!quiet) setNote(t('lib.scanned', { folder: from ? `${folder.name}/${from.slice(0, -1)}` : folder.name, added: result.added, total: result.total }));
|
if (!quiet) setNote(t('lib.scanned', { folder: from ? `${folder.name}/${from.slice(0, -1)}` : folder.name, added: result.added, total: result.total }));
|
||||||
// A reading kept to one folder names the folders under that one, which
|
// A reading kept to one folder names the folders under that one, which
|
||||||
// is no reason to fold the column down to them: the roll is still the
|
// is no reason to fold the column down to them: the roll is still the
|
||||||
@@ -964,7 +976,7 @@ export function Library() {
|
|||||||
<button type="button" className="btn primary" data-key="lib-add" disabled={!!live} onClick={() => void add()}>
|
<button type="button" className="btn primary" data-key="lib-add" disabled={!!live} onClick={() => void add()}>
|
||||||
{t('lib.add')}
|
{t('lib.add')}
|
||||||
</button>
|
</button>
|
||||||
{live ? (
|
{live || scanQueue().length ? (
|
||||||
<button type="button" className="btn" data-key="lib-stop" onClick={stopScan}>
|
<button type="button" className="btn" data-key="lib-stop" onClick={stopScan}>
|
||||||
{t('lib.stop')}
|
{t('lib.stop')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1113,6 +1125,10 @@ export function Library() {
|
|||||||
const dir = relOf(n.key);
|
const dir = relOf(n.key);
|
||||||
const at = live && live.folder === folder?.name && live.progress.at ? `${live.progress.at}/` : null;
|
const at = live && live.folder === folder?.name && live.progress.at ? `${live.progress.at}/` : null;
|
||||||
const scanning = dir !== null && dir !== '' && at !== null && (dir === at || at.startsWith(dir));
|
const scanning = dir !== null && dir !== '' && at !== null && (dir === at || at.startsWith(dir));
|
||||||
|
// A row whose reading has not started yet: the ring is
|
||||||
|
// there, held still, so a reader sees the roll is pending
|
||||||
|
// rather than idle.
|
||||||
|
const waiting = dir !== null && dir !== '' && !scanning && scanQueue().some((j) => j.folder === folder?.name && j.rel === dir);
|
||||||
const under = parents.has(n.key);
|
const under = parents.has(n.key);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1176,7 +1192,11 @@ export function Library() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="adm-album-name">{n.label}</span>
|
<span className="adm-album-name">{n.label}</span>
|
||||||
{scanning ? <span className="lib-spin" aria-hidden="true" /> : null}
|
{scanning ? (
|
||||||
|
<span className="lib-spin" aria-hidden="true" />
|
||||||
|
) : waiting ? (
|
||||||
|
<span className="lib-spin wait" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
<span className="adm-album-count">{n.count}</span>
|
<span className="adm-album-count">{n.count}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -1477,38 +1497,50 @@ export function Library() {
|
|||||||
<span className="lib-menu-icon" aria-hidden="true">✎</span>
|
<span className="lib-menu-icon" aria-hidden="true">✎</span>
|
||||||
{t('lib.rename')}
|
{t('lib.rename')}
|
||||||
</button>
|
</button>
|
||||||
{/* A roll being read has one thing to be asked of it — that it
|
{/* Three things can be asked of a roll: bring it up to date, read it
|
||||||
stop — where a roll at rest has the reading that brings it up
|
from the top again, or leave it — and the last only of the
|
||||||
to date; the two never both apply, so the menu carries the one
|
reading that is this roll's, or of the request for it that is
|
||||||
the row is actually in. */}
|
still waiting its turn. The first two are always there, because
|
||||||
{live?.folder === menu.folder.name ? (
|
a reading already running is not a reason a reader cannot ask
|
||||||
|
for the next one: it queues. */}
|
||||||
|
{live?.folder === menu.folder.name || queued ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="lib-menu-item"
|
className="lib-menu-item"
|
||||||
data-key={`lib-menu-stop-${menu.folder.name}`}
|
data-key={`lib-menu-stop-${menu.folder.name}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMenu(null);
|
setMenu(null);
|
||||||
stopScan();
|
stopScanAt(menu.folder!.name, menu.rel ?? '');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="lib-menu-icon" aria-hidden="true">■</span>
|
<span className="lib-menu-icon" aria-hidden="true">■</span>
|
||||||
{t('lib.stop')}
|
{t('lib.stop')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="lib-menu-item"
|
className="lib-menu-item"
|
||||||
data-key={`lib-rescan-${menu.folder.name}`}
|
data-key={`lib-update-${menu.folder.name}`}
|
||||||
disabled={!!live}
|
onClick={() => {
|
||||||
onClick={() => {
|
setMenu(null);
|
||||||
setMenu(null);
|
void scan(menu.folder!, false, menu.rel ?? '', 'update');
|
||||||
void scan(menu.folder!, false, menu.rel ?? '');
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<span className="lib-menu-icon" aria-hidden="true">↻</span>
|
||||||
<span className="lib-menu-icon" aria-hidden="true">↻</span>
|
{t('lib.update')}
|
||||||
{t('lib.update')}
|
</button>
|
||||||
</button>
|
<button
|
||||||
)}
|
type="button"
|
||||||
|
className="lib-menu-item"
|
||||||
|
data-key={`lib-rescan-${menu.folder.name}`}
|
||||||
|
onClick={() => {
|
||||||
|
setMenu(null);
|
||||||
|
void scan(menu.folder!, false, menu.rel ?? '', 'rescan');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lib-menu-icon" aria-hidden="true">⟳</span>
|
||||||
|
{t('lib.rescan')}
|
||||||
|
</button>
|
||||||
{/* A row with folders under it is a row that folds, and the branch
|
{/* A row with folders under it is a row that folds, and the branch
|
||||||
it holds goes with it: a reader who is done with a tree means
|
it holds goes with it: a reader who is done with a tree means
|
||||||
the whole of it shut, not the one row they happened to point
|
the whole of it shut, not the one row they happened to point
|
||||||
|
|||||||
@@ -1027,6 +1027,12 @@ export async function unfinished(folder: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stopScan(): void {
|
export function stopScan(): void {
|
||||||
|
// Everything waiting is a reading that is not going to happen: the button that
|
||||||
|
// stops the walk stops the afternoon, and a queue that ran on after it would be
|
||||||
|
// a stop that stopped one folder and then read the next three anyway.
|
||||||
|
waiting.forEach(drop);
|
||||||
|
waiting = [];
|
||||||
|
tell();
|
||||||
// A reading another window holds is stopped by telling it so: the button this
|
// A reading another window holds is stopped by telling it so: the button this
|
||||||
// window drew is the same button, on the window that can act on it.
|
// window drew is the same button, on the window that can act on it.
|
||||||
if (!live) {
|
if (!live) {
|
||||||
@@ -1037,6 +1043,22 @@ export function stopScan(): void {
|
|||||||
tell();
|
tell();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The row's own stop: the request for this row that is still waiting its turn,
|
||||||
|
// and the reading of this roll, which is one at a time — a row under the roll
|
||||||
|
// stops the reading the same way the roll's own row does, because the reading it
|
||||||
|
// stops is the roll's. The other rolls' requests are other readings and run on.
|
||||||
|
export function stopScanAt(folder: string, rel: string): void {
|
||||||
|
for (const job of waiting) {
|
||||||
|
// The roll's own row stops the whole roll — the requests under it are readings
|
||||||
|
// of that one roll, a folder at a time. A subfolder's row stops that folder.
|
||||||
|
if (job.folder === folder && (rel === '' || job.rel === rel)) drop(job);
|
||||||
|
}
|
||||||
|
waiting = waiting.filter((j) => !j.dropped);
|
||||||
|
if (live?.folder === folder) live.stop = true;
|
||||||
|
else if (peer?.folder === folder) say({ k: 'stop', folder });
|
||||||
|
tell();
|
||||||
|
}
|
||||||
|
|
||||||
export function jumpScan(rel: string | null): void {
|
export function jumpScan(rel: string | null): void {
|
||||||
if (live) {
|
if (live) {
|
||||||
live.jump = rel;
|
live.jump = rel;
|
||||||
@@ -1048,8 +1070,20 @@ export function jumpScan(rel: string | null): void {
|
|||||||
// Start reading a roll. Only one at a time: the walk reads one frame at a time on
|
// Start reading a roll. Only one at a time: the walk reads one frame at a time on
|
||||||
// this thread, so a second scan would only slow the first one down. The promise
|
// this thread, so a second scan would only slow the first one down. The promise
|
||||||
// settles when the scan does — the caller that started it may be long gone.
|
// settles when the scan does — the caller that started it may be long gone.
|
||||||
export function startScan(folder: LibraryFolder, from = ''): Promise<ScanProgress> {
|
//
|
||||||
|
// `update` is the reading that picks up where the last one left off: the position
|
||||||
|
// on the disk if there is one, the whole roll from the top if there is not, with
|
||||||
|
// every frame the catalogue already holds skipped on its size and its time.
|
||||||
|
// `rescan` is the same reading refusing that position — the roll walked again
|
||||||
|
// from the top — which is what finds a folder that was added while a reading was
|
||||||
|
// cut off, and what a frame written over is found by.
|
||||||
|
export function startScan(folder: LibraryFolder, from = '', mode: ScanMode = 'update'): Promise<ScanProgress> {
|
||||||
if (live) return Promise.reject(new Error('a scan is already running'));
|
if (live) return Promise.reject(new Error('a scan is already running'));
|
||||||
|
// A reading kept to one folder under the roll keeps no position of its own and
|
||||||
|
// reads that folder however it was asked for, so only a roll's own rescan has a
|
||||||
|
// position to throw away — and throwing away a position this reading is not
|
||||||
|
// going to use would cost the next update the rest of the roll.
|
||||||
|
const go = mode === 'rescan' && !from ? clearWalk(folder.name) : Promise.resolve();
|
||||||
const session: ScanSession = {
|
const session: ScanSession = {
|
||||||
folder: folder.name,
|
folder: folder.name,
|
||||||
progress: { folder: folder.name, from, total: 0, done: 0, added: 0, written: 0, dirs: [], counts: {}, at: '' },
|
progress: { folder: folder.name, from, total: 0, done: 0, added: 0, written: 0, dirs: [], counts: {}, at: '' },
|
||||||
@@ -1063,16 +1097,18 @@ export function startScan(folder: LibraryFolder, from = ''): Promise<ScanProgres
|
|||||||
// silence long enough would take the reading for abandoned and start its own.
|
// silence long enough would take the reading for abandoned and start its own.
|
||||||
if (heart) clearInterval(heart);
|
if (heart) clearInterval(heart);
|
||||||
heart = setInterval(announce, HEART_MS);
|
heart = setInterval(announce, HEART_MS);
|
||||||
return scanFolder(
|
return go.then(() =>
|
||||||
folder,
|
scanFolder(
|
||||||
(p) => {
|
folder,
|
||||||
session.progress = p;
|
(p) => {
|
||||||
tell();
|
session.progress = p;
|
||||||
announce();
|
tell();
|
||||||
},
|
announce();
|
||||||
() => session.stop,
|
},
|
||||||
() => session.jump,
|
() => session.stop,
|
||||||
from
|
() => session.jump,
|
||||||
|
from
|
||||||
|
)
|
||||||
).finally(() => {
|
).finally(() => {
|
||||||
if (heart) {
|
if (heart) {
|
||||||
clearInterval(heart);
|
clearInterval(heart);
|
||||||
@@ -1084,6 +1120,95 @@ export function startScan(folder: LibraryFolder, from = ''): Promise<ScanProgres
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- what the reader asked for, in the order they asked ---------------------
|
||||||
|
|
||||||
|
// One reading at a time is not one request at a time: a reader who right-clicks
|
||||||
|
// four folders means four readings, and the walk is one thread — a second reading
|
||||||
|
// of the same disk only slows the first. So the ones that cannot run yet wait
|
||||||
|
// here, in the order they were asked for. The folder asked for while nothing runs
|
||||||
|
// starts at once; the one asked for during a reading starts when that reading
|
||||||
|
// ends, whether it ran to its end or was stopped; and a request taken out of the
|
||||||
|
// queue is a reading that never happens.
|
||||||
|
export type ScanMode = 'update' | 'rescan';
|
||||||
|
|
||||||
|
export interface ScanJob {
|
||||||
|
// The picked folder the reading belongs to, by name.
|
||||||
|
folder: string;
|
||||||
|
// The row it was asked of, spelled the way the walk spells a path: '' for the
|
||||||
|
// picked folder itself, `2026/04/` for a folder under it.
|
||||||
|
rel: string;
|
||||||
|
mode: ScanMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Waiting extends ScanJob {
|
||||||
|
// The picker's own handle, which is what a reading is started from: a name is
|
||||||
|
// not a folder that can be walked.
|
||||||
|
handle: FileSystemDirectoryHandle;
|
||||||
|
dropped: boolean;
|
||||||
|
settle: (p: ScanProgress | null) => void;
|
||||||
|
fail: (err: unknown) => void;
|
||||||
|
// The same request asked twice is one reading, and this is the answer both
|
||||||
|
// askers are waiting for.
|
||||||
|
answer: Promise<ScanProgress | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let waiting: Waiting[] = [];
|
||||||
|
|
||||||
|
const drop = (job: Waiting): void => {
|
||||||
|
if (job.dropped) return;
|
||||||
|
job.dropped = true;
|
||||||
|
job.settle(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// What is waiting its turn, in the order it will be read: what a row draws its
|
||||||
|
// mark from, and the whole of what a screen knows about the queue.
|
||||||
|
export function scanQueue(): ScanJob[] {
|
||||||
|
return waiting.filter((j) => !j.dropped).map(({ folder, rel, mode }) => ({ folder, rel, mode }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask for a reading of one folder: the row that was right-clicked, and whether
|
||||||
|
// what is wanted is what is new under it or the whole of it again. A request is
|
||||||
|
// never refused and never starts a second reading — it waits its turn and answers
|
||||||
|
// with the reading it asked for, or with null when it was taken out of the queue
|
||||||
|
// before it ran.
|
||||||
|
export function scanAsked(folder: LibraryFolder, rel = '', mode: ScanMode = 'update'): Promise<ScanProgress | null> {
|
||||||
|
const same = waiting.find((j) => !j.dropped && j.folder === folder.name && j.rel === rel && j.mode === mode);
|
||||||
|
if (same) return same.answer;
|
||||||
|
let settle!: (p: ScanProgress | null) => void;
|
||||||
|
let fail!: (err: unknown) => void;
|
||||||
|
const answer = new Promise<ScanProgress | null>((resolve, reject) => {
|
||||||
|
settle = resolve;
|
||||||
|
fail = reject;
|
||||||
|
});
|
||||||
|
waiting.push({ folder: folder.name, rel, mode, handle: folder.handle, dropped: false, settle, fail, answer });
|
||||||
|
tell();
|
||||||
|
pump();
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand the next request its turn. The reading that just ended is the one thing
|
||||||
|
// that can make room for it, and the window that holds the reading is the one
|
||||||
|
// that hears when it ends.
|
||||||
|
function pump(): void {
|
||||||
|
if (live) return;
|
||||||
|
const job = waiting.find((j) => !j.dropped);
|
||||||
|
if (!job) return;
|
||||||
|
// A reading another window holds is waited out rather than raced: the disk is
|
||||||
|
// one disk, and two readers of one roll is the whole of what this is for. The
|
||||||
|
// turn is taken when that window says it is through, which is the word the
|
||||||
|
// screens draw their progress from.
|
||||||
|
if (peer) {
|
||||||
|
const stop = watchScan(() => {
|
||||||
|
if (peer) return;
|
||||||
|
stop();
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
waiting = waiting.filter((j) => j !== job);
|
||||||
|
startScan({ name: job.folder, handle: job.handle }, job.rel, job.mode).then(job.settle, job.fail).finally(pump);
|
||||||
|
}
|
||||||
|
|
||||||
// --- reading ---------------------------------------------------------------
|
// --- reading ---------------------------------------------------------------
|
||||||
|
|
||||||
// The catalogue, read once and kept. STUDIO and LIBRARY are two screens of one
|
// The catalogue, read once and kept. STUDIO and LIBRARY are two screens of one
|
||||||
|
|||||||
@@ -244,9 +244,10 @@ export const en: Dict = {
|
|||||||
'lib.rename': 'RENAME FOLDER',
|
'lib.rename': 'RENAME FOLDER',
|
||||||
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
|
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
|
||||||
'lib.update': 'UPDATE...',
|
'lib.update': 'UPDATE...',
|
||||||
|
'lib.rescan': 'RESCAN FROM THE TOP',
|
||||||
'lib.remove': 'REMOVE',
|
'lib.remove': 'REMOVE',
|
||||||
'lib.reconnect': 'GRANT ACCESS AGAIN',
|
'lib.reconnect': 'GRANT ACCESS AGAIN',
|
||||||
'lib.nodeHint': 'Right-click to rename, update, collapse or remove the folder.',
|
'lib.nodeHint': 'Right-click to rename, update, rescan, collapse or remove the folder.',
|
||||||
'lib.collapseAll': 'COLLAPSE ALL',
|
'lib.collapseAll': 'COLLAPSE ALL',
|
||||||
'lib.collapse': 'COLLAPSE',
|
'lib.collapse': 'COLLAPSE',
|
||||||
'lib.colHint': 'Right-click the empty part of this column to add a folder.',
|
'lib.colHint': 'Right-click the empty part of this column to add a folder.',
|
||||||
|
|||||||
@@ -256,9 +256,10 @@ export const vi = {
|
|||||||
'lib.rename': 'ĐỔI TÊN THƯ MỤC',
|
'lib.rename': 'ĐỔI TÊN THƯ MỤC',
|
||||||
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
|
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
|
||||||
'lib.update': 'CẬP NHẬT...',
|
'lib.update': 'CẬP NHẬT...',
|
||||||
|
'lib.rescan': 'QUÉT LẠI TỪ ĐẦU',
|
||||||
'lib.remove': 'BỎ',
|
'lib.remove': 'BỎ',
|
||||||
'lib.reconnect': 'CẤP LẠI QUYỀN',
|
'lib.reconnect': 'CẤP LẠI QUYỀN',
|
||||||
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, thu gọn hay bỏ thư mục.',
|
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, thu gọn hay bỏ thư mục.',
|
||||||
'lib.collapseAll': 'THU GỌN TẤT CẢ',
|
'lib.collapseAll': 'THU GỌN TẤT CẢ',
|
||||||
'lib.collapse': 'THU GỌN',
|
'lib.collapse': 'THU GỌN',
|
||||||
'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.',
|
'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.',
|
||||||
|
|||||||
@@ -1161,6 +1161,9 @@ button.hint:disabled { cursor: default; }
|
|||||||
animation: lib-spin 0.7s linear infinite;
|
animation: lib-spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes lib-spin { to { transform: rotate(360deg); } }
|
@keyframes lib-spin { to { transform: rotate(360deg); } }
|
||||||
|
/* The ring of a reading that is queued: still, and greyed, so it reads as a
|
||||||
|
wait rather than as work. */
|
||||||
|
.lib-spin.wait { animation: none; border-top-color: var(--border); opacity: 0.6; }
|
||||||
/* The same ring, out in the header: the reading belongs to this tab, not to a
|
/* The same ring, out in the header: the reading belongs to this tab, not to a
|
||||||
screen, so the studio says so too while it runs. */
|
screen, so the studio says so too while it runs. */
|
||||||
.lib-scan { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
.lib-scan { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||||
|
|||||||
Reference in New Issue
Block a user