feat(workspace): selection bars stay in view, Back to top, side panels stay with you (#531) (#541)

Closes #531
This commit is contained in:
Vito Sansevero
2026-10-30 16:52:45 -07:00
parent 494a5d0bfa
commit e272c84efd
16 changed files with 535 additions and 18 deletions
+2 -2
View File
@@ -35,7 +35,7 @@ export function collectionTargets(images, { allMatching = false, total = images.
* the dialog seam. A stale source (409) calls `onSourceChanged()` after its toast, so the page reloads the listing.
*/
export function openAddToCollection({ scope, collections, images, trigger = null, allMatching = false, total = images.length,
notify = showWorkspaceToast, openInput = openInputDialog, onSourceChanged = null }) {
notify = showWorkspaceToast, openInput = openInputDialog, onSourceChanged = null, onAdded = null }) {
const { targets, refusal } = collectionTargets(images, { allMatching, total });
if (refusal) { notify(refusal); return; }
openInput({ scope, trigger, title: ADD_DIALOG_TITLE, label: ADD_DIALOG_LABEL, action: ADD_DIALOG_ACTION,
@@ -45,7 +45,7 @@ export function openAddToCollection({ scope, collections, images, trigger = null
if (name === null) { notify(ADD_FAILED_TOAST); return; }
try {
const result = await collections.add({ name }, targets);
if (!scope.signal.aborted) notify(addedMessage(result));
if (!scope.signal.aborted) { notify(addedMessage(result)); onAdded?.(); }
} catch (error) {
if (scope.signal.aborted) return;
notify(writeFailure(error, ADD_FAILED_TOAST));
+6 -3
View File
@@ -1,3 +1,4 @@
import { keepingPlace, publishBarHeight } from './sticky-chrome.mjs';
import { createButton } from '../shared/toolkit/core.js';
import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs';
import { inspectPath, toInspectionRecord } from './inspection-state.mjs';
@@ -51,7 +52,7 @@ function el(tag, text, className) {
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null,
collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null, removedView = null,
collection = null, onCollectionsChanged = null, onSourceChanged = null, onCollectionAdded = null, onDeleted = null, removedView = null,
onReturn = null }) {
let checked = initial;
let exporting = false, categorizing = false;
@@ -59,6 +60,7 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
let anchor = null, allMatching = false, touchMode = false, lastPointer = 'mouse', shiftBox = null;
const element = el('div');
element.id = 'bulkbar';
publishBarHeight(scope, element);
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const selectMatching = createButton('');
scope.listen(selectMatching, 'click', () => {
@@ -87,7 +89,7 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
if (collections) {
const addToCollection = createButton('Add to collection');
scope.listen(addToCollection, 'click', () => openAddToCollection({ scope, collections, images: checkedImages(),
trigger: addToCollection, ...collectionSelection(), onSourceChanged }));
trigger: addToCollection, ...collectionSelection(), onSourceChanged, onAdded: onCollectionAdded }));
bulk.append(addToCollection);
}
if (collections && collection) {
@@ -124,7 +126,8 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
}
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
function render() {
function render() { keepingPlace(element, paintBar); }
function paintBar() {
if (!checked.size && !allMatching) { touchMode = false; bulk.remove(); return; }
paintMode();
const listing = getListing();
+32 -7
View File
@@ -5,6 +5,7 @@ import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './imag
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { mountSelectionBar } from './images-selection.mjs';
import { scrollToPageTop } from './sticky-chrome.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { promptRoles } from './prompt-blocks.mjs';
import { createWorkspaceIcon } from './icons.mjs';
@@ -301,6 +302,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
collection.append(continuation);
// After a Load more or Retry press, the index of the first image that batch appends (concept: focus it).
let focusAfterLoad = null;
let panelHold = false;
// The concept's side panel (app.js a426, D20); the Library-embedded gallery has its own page around it.
const details = el('aside', undefined, 'detail-panel');
if (!embedded) details.id = 'image-inspector';
@@ -310,11 +312,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys),
onDeleted:deleted,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); scrollToPageTop(); }, activity,
// #531: a finished bulk action shows its result from the top. Tagging and collections keep the selection (D85, D138).
onTagged:keys => { refreshTagged(keys); bulkRefreshed(); },
onDeleted:() => { deleted(); scrollToPageTop(); },
// #498: the removed view's bar holds only Return to library and Clear.
removedView:() => filters.removed,
onReturn:(images, extra, trigger) => returnSome({images, ...extra, trigger}),
onReturn:(images, extra, trigger) => returnSome({images, ...extra, trigger, onDone:scrollToPageTop}),
// The Set category field suggests the toolbar's categories (#332 MC13).
getCategories:() => categoryNames,
// #392 "Select all N matching": the listing's total and its filter, as the server lists them.
@@ -325,7 +329,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
return filter.root !== undefined && typeof scope === 'string' ? {...filter, root_scope:scope} : filter;
},
// #331 D138: Add to collection, and Remove from collection on an open collection, which then lists it again.
collections, collection:filters.collection || null, onCollectionsChanged:collectionChanged,
collections, collection:filters.collection || null, onCollectionsChanged:() => { collectionChanged(); scrollToPageTop(); }, onCollectionAdded:bulkRefreshed,
onSourceChanged:refreshInspectionCollection});
if (bar) main.append(bar.element);
main.append(body);
@@ -402,7 +406,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (event.target.closest('.star-button')) toggleFavorite(api, image);
else if (event.target.closest('.image-open')) previewImage(image);
else if (action && !openCardTagging({api, scope, image, action, embedded, onSourceChanged:recovery.recover,
onRefresh:refreshInspectionCollection})) inspect(image);
onRefresh:refreshInspectionCollection})) {
inspect(image);
bringPanelIntoView();
}
});
function renderVisible() {
@@ -637,7 +644,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
}
scope.listen(more, 'click', pressLoad);
scope.listen(retry, 'click', () => (state?.error?.code === 'cursor-stale' ? refreshInspectionCollection() : pressLoad()));
/**
* D189: at one column the panel follows the whole list, so Info goes to it. That passes the continuation, so loading
* waits for the reader's next move: a batch appended now would push the panel away.
*/
function bringPanelIntoView() {
if (getComputedStyle(details).position !== 'static') return;
panelHold = true;
details.scrollIntoView({ block: 'start' });
}
const releasePanel = () => { if (panelHold) { panelHold = false; scheduleLoad(); } };
for (const name of ['wheel', 'touchmove', 'keydown']) scope.listen(window, name, releasePanel, { passive: true });
function maybeLoad() {
if (panelHold) return;
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('#viewer-dialog[open]')) return;
const bounds = continuation.getBoundingClientRect();
if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore();
@@ -702,8 +721,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
else if (!showRemoved.isConnected) heading.actions.prepend(showRemoved);
}
/** #498: Return to library for the checked images, "all N matching" (`filter`) or the inspector's one. */
function returnSome({images = [], filter = null, count = images.length, trigger = null}) {
return returnImages({api, scope, store:activity, images, filter, count, trigger, onReturned:() => { if (!scope.signal.aborted) reset(); }});
function returnSome({images = [], filter = null, count = images.length, trigger = null, onDone = null}) {
return returnImages({api, scope, store:activity, images, filter, count, trigger, onReturned:() => { if (!scope.signal.aborted) { reset(); onDone?.(); } }});
}
/** #480: Delete… for one file, from the inspector or the viewer (`after` closes the viewer first). */
function deleteOne(target, trigger, after = null) {
@@ -819,6 +838,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
* is read again in place, keeping the scroll and the checked files still listed. A failed re-read lists the page
* again from its start, whose failure shows the usual Retry (or the deleted-collection page).
*/
/** #531: after a bulk action that keeps the selection: the listing re-read, shown from the top. */
function bulkRefreshed() {
if (scope.signal.aborted) return;
scrollToPageTop();
if (!recovering) stream.refresh().catch(() => { if (!scope.signal.aborted) reset(); });
}
async function collectionChanged() {
if (scope.signal.aborted || !filters.collection || recovering) return;
try {
+2
View File
@@ -4,6 +4,7 @@ import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigat
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS,
JOB_END_COUNTS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountBackToTop } from './sticky-chrome.mjs';
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
import { forgetLibraryVisit, mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { mountVocabulary } from './vocabulary.mjs';
@@ -83,6 +84,7 @@ function mountWorkspace() {
// row's count and the Activity page, on every route.
const activity = createActivityStore({ api, scope, connect: url => new EventSource(resolveUrl(url)) });
mountActivityTopbar({ store: activity, scope });
mountBackToTop({ scope, toast: document.getElementById('workspace-toast') });
activity.load();
// D129: a node-folder library is offered a move (or "Keep it here") above every page.
mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope });
+7 -1
View File
@@ -1,3 +1,4 @@
import { keepingPlace, publishBarHeight } from './sticky-chrome.mjs';
import { createButton } from '../shared/toolkit/core.js';
import { selectionLabel, toggleSelection } from './images-state.mjs';
import { applyKeys, rangeKeys } from './images-multiselect.mjs';
@@ -129,10 +130,11 @@ export function categoryProblem(value) {
* to `focusFallback` when it was on the bar the reload hides.
*/
export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, getCategories, focusFallback, onCommitted,
onReload, getShelf = () => 'active', nameOf = () => null }) {
onReload, getShelf = () => 'active', nameOf = () => null, onPlaceKept = () => {} }) {
let checked = Object.freeze(new Set()), anchor = null, shiftBox = null, writing = false;
const element = el('div');
element.id = 'library-bulkbar';
publishBarHeight(scope, element);
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const addTags = createButton('Add tags');
const setCategory = createButton('Set category');
@@ -164,6 +166,10 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
const ids = () => getItems().map(item => item.id);
function render() {
const shift = keepingPlace(element, paintBar);
if (shift) onPlaceKept(shift);
}
function paintBar() {
if (!checked.size) { bulk.remove(); return; }
const shelf = getShelf() === 'archived' ? 'archived' : 'active';
if (shelf !== shownShelf) {
+4 -2
View File
@@ -16,6 +16,7 @@ import {mountToolbar} from './toolbar.mjs';
import {libraryFilters,libraryViewState} from './saved-views.mjs';
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
import {mountLibrarySelection} from './library-selection.mjs';
import {scrollToPageTop} from './sticky-chrome.mjs';
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome,reviewPromptDelete} from './library-delete.mjs';
import {createMoreMenu} from './more-menu.mjs';
@@ -190,10 +191,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts');
// #437 D148: select prompts and set their category (the legacy admin's Set Category); a write reloads the shelf.
let categoryNames=[];
const selection=mountLibrarySelection({scope,api,grid,getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
// #531: the bar's compensating scroll is not the reader moving, so a pending anchor keeps its offset.
const selection=mountLibrarySelection({scope,api,grid,onPlaceKept:shift=>{observedScrollY+=shift;},getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},getShelf:()=>filters.shelf,
nameOf:id=>{const item=state?.items.find(entry=>entry.id===id);return item?displayTitle(item):null;},
onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();}});
onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();scrollToPageTop();}});
const message=el('p','','library-status');message.setAttribute('role','status');
const continuation=el('div',undefined,'library-continuation');
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
+2
View File
@@ -314,6 +314,8 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
}
// Info selects the item, as the concept's `inspect` does on this page (app.js a1244–1248).
controller.select(item.id);
// D189: at one column the panel cannot stay beside the queue, so Info brings it into view.
if (getComputedStyle(panel).position === 'static') panel.scrollIntoView({ block: 'nearest' });
});
let shownTotal = null;
+91
View File
@@ -0,0 +1,91 @@
/**
* The chrome that stays with you as a page scrolls (#531, beyond the concept; TESTING.md D186 and D187).
*
* - `keepingPlace`: repaint a sticky selection bar without moving the cards under the pointer.
* - `publishBarHeight`: tell the root how much of the viewport's top a shown bar takes, for the Images panel's sticky
* offset and for `scroll-margin` (a focused card is not left under the bar).
* - `mountBackToTop`: the shell's chip, which rests above the toast's spot and rises only for a taller toast.
*/
/** How far off the top of the page the chip appears, in CSS pixels. */
export const BACK_TO_TOP_AFTER = 200;
/** The gap between the stuck bar and whatever it clears (its `inset-block-start` above, and as much below). */
const BAR_GAP = 16;
const TOAST_GAP = 8;
/**
* Run `paint`, which may change the height of `element`. While the element's place in the page's flow is above the
* viewport (the bar is stuck), scroll by the difference, so the content below stays where it was. On screen, the bar
* pushes the content as the concept's does. Native scroll anchoring would add its own correction in some browsers, so
* it is off until the next frame. Returns the distance scrolled.
*/
export function keepingPlace(element, paint) {
const next = element.nextElementSibling;
if (!next || !element.isConnected) { paint(); return 0; }
const before = next.getBoundingClientRect().top;
const root = document.documentElement;
const anchoring = root.style.overflowAnchor;
root.style.overflowAnchor = 'none';
paint();
const shift = next.getBoundingClientRect().top - before;
requestAnimationFrame(() => { root.style.overflowAnchor = anchoring; });
if (!shift || before >= Math.max(0, element.getBoundingClientRect().bottom)) return 0;
const from = window.scrollY;
window.scrollBy({ top: shift, behavior: 'instant' });
return window.scrollY - from;
}
/** Publish the height of the bar inside `element` as `--selection-bar-block` while it shows; cleared with `scope`. */
export function publishBarHeight(scope, element) {
const root = document.documentElement;
const clear = () => root.style.removeProperty('--selection-bar-block');
const update = () => {
const height = element.getBoundingClientRect().height;
if (height > 0) root.style.setProperty('--selection-bar-block', `${height + BAR_GAP}px`);
else clear();
};
const observer = new ResizeObserver(update);
observer.observe(element);
scope.cleanup(() => { observer.disconnect(); clear(); });
}
/** Go to the top of the page at once: the pages' own height corrections would cancel a smooth scroll part way. */
export function scrollToPageTop() {
window.scrollTo({ top: 0, behavior: 'instant' });
}
/** One chip for the whole shell: shown off the top of any page; pressing it goes to the top at once. */
export function mountBackToTop({ scope, toast }) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'back-to-top';
button.hidden = true;
const arrow = document.createElement('span');
arrow.setAttribute('aria-hidden', 'true');
arrow.textContent = '↑';
button.append(arrow, ' Back to top');
toast.after(button);
// Scroll events already come once a frame, and this only reads `scrollY`.
const update = () => { button.hidden = window.scrollY <= BACK_TO_TOP_AFTER; };
scope.listen(window, 'scroll', update, { passive: true });
scope.listen(button, 'click', () => {
// Never animated, so reduced motion holds. The chip hides under the focus, so focus moves to the page.
scrollToPageTop();
document.getElementById('workspace-main')?.focus({ preventScroll: true });
update();
});
const root = document.documentElement;
const followToast = () => {
const height = toast.getBoundingClientRect().height;
if (height > 0) root.style.setProperty('--toast-block', `${height + TOAST_GAP}px`);
else root.style.removeProperty('--toast-block');
};
const toastObserver = new MutationObserver(followToast);
toastObserver.observe(toast, { childList: true, characterData: true, subtree: true });
scope.listen(window, 'resize', followToast);
scope.cleanup(() => { toastObserver.disconnect(); root.style.removeProperty('--toast-block'); button.remove(); });
update();
return button;
}
+32 -1
View File
@@ -290,7 +290,7 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
}
/* #249 D42: a discovery alert a rescan leaves scrolls clear of the concept's fixed toast (22px inset, 12px at 700px and
below, two 12px lines at 390px), never under it; the toast itself keeps the concept's placement. */
.options-status { font-size: 12px; padding-inline: 4px; scroll-margin-block-end: 96px; /* D42 */ }
.options-status { font-size: 12px; padding-inline: 4px; scroll-margin-block-end: 140px; /* D42, D187: clear of the toast and the chip above it */ }
/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */
/* #249 D23: real prompts are far longer than the concept's samples, so the card clamps the positive to two lines; copy
@@ -542,3 +542,34 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
overflow: hidden; overflow-wrap: anywhere;
}
}
/* D186 (#531, beyond the concept): the selection bars stay in view while you select. This one rule is their position. `--selection-bar-block`
(sticky-chrome.mjs) is the room a shown bar takes; a focused card control and the Images panel keep clear of it
(scroll-margin on them, not scroll-padding on the root, which would scroll the page to reveal the bar's own buttons). At one
column the bar is one row that scrolls sideways, so it does not take a fifth of a phone screen. */
#bulkbar, #library-bulkbar { position: sticky; inset-block-start: 8px; z-index: 5; }
:is(#image-grid, .library-grid) :is(article, a, button, input, select, textarea, [tabindex]) { scroll-margin-block-start: var(--selection-bar-block, 0px); }
.detail-panel { inset-block-start: max(20px, var(--selection-bar-block, 0px)); max-block-size: calc(100dvh - 20px - max(20px, var(--selection-bar-block, 0px))); /* D186 */ }
@media (max-width: 700px) {
.detail-panel { max-block-size: none; /* D186 */ }
.bulkbar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: thin; /* D186 */ }
.bulkbar > * { flex: none; /* D186 */ }
}
@container images-page (inline-size < 600px) { .detail-panel { max-block-size: none; /* D186 */ } }
/* D187 (#531, beyond the concept): Back to top, in the toast's column. It rests above a two-line toast, so the usual
toast does not move it under a finger, and rises only for a taller one. */
.ca-tools .back-to-top {
--toast-rest: 70px;
position: fixed; inset-inline-start: 50%; transform: translateX(-50%); z-index: 10;
inset-block-end: calc(22px + max(var(--toast-rest), var(--toast-block, 0px)));
padding: 6px 12px; border: 1px solid var(--ca-line); border-radius: 999px; background: var(--ca-surface);
box-shadow: 0 8px 35px #0004; font-size: 12px; white-space: nowrap;
}
@media (max-width: 700px) { .ca-tools .back-to-top { inset-block-end: calc(12px + max(var(--toast-rest), var(--toast-block, 0px))); } }
@media (pointer: coarse) { .ca-tools .back-to-top { min-block-size: 40px; } }
/* D188 (#531, beyond the concept): the Resolve panel stays with you as the Images panel does. */
.review-panel { position: sticky; inset-block-start: 20px; }
@media (max-width: 700px) { .review-panel { position: static; } }
@media (min-width: 701px) and (max-height: 720px) { .review-panel { max-block-size: calc(100dvh - 40px); overflow: auto; } }