diff --git a/docs/features/media.md b/docs/features/media.md index 3c1483ee3..e33d065b5 100644 --- a/docs/features/media.md +++ b/docs/features/media.md @@ -15,6 +15,7 @@ The workspace is canvas-style: it uses `AdminWorkspaceCanvasLayout`, the lighter - **Drag/drop:** assets can be dragged into folders from the canvas or folder tree. A drop replaces the asset's folder memberships with the target folder, matching desktop file-manager move semantics. - **Floating windows:** Asset viewer, upload queue, bulk edit. Each uses the shared `@admin/shared/FloatingWindow` shell or its `useDraggablePanel` hook with a unique id (`mediaDetachedInspector`, `mediaUploadQueue`, `mediaBulkEdit`). Position survives reload via `workspaceLayoutStorage`. - **Auto-open behavior:** upload queue opens when uploads start; bulk-edit opens at 2+ selected; viewer opens on primary selection. +- **Select all:** Ctrl/Cmd+A, or the toolbar's All button, selects every asset in `visibleAssets` — what the folder, filter and search currently show, never the whole library — and clears the selection when it already covers them. The shortcut yields to text fields and to an open modal (`aria-modal="true"`), but not to the floating windows, which leave the grid usable behind them. - **Server side:** `media_assets`, `media_folders`, `media_asset_folders` tables. Handlers under `/admin/api/cms/media`, `/admin/api/cms/media/folders`, `/admin/api/cms/media/storage`. Repositories at `server/repositories/media*.ts`. - **Storage adapters:** built-in local-disk plus plugin-registered adapters. Non-public-url adapters route through `/_instatic/media//` for signed redirects. - **Plugin media ingestion:** `api.cms.media.upsert(...)` lets plugins import allowlisted HTTPS images or contained package assets through the same validation, storage, and responsive-variant pipeline as admin uploads. diff --git a/src/__tests__/media/selectAll.test.tsx b/src/__tests__/media/selectAll.test.tsx new file mode 100644 index 000000000..29297d982 --- /dev/null +++ b/src/__tests__/media/selectAll.test.tsx @@ -0,0 +1,262 @@ +/** + * Selecting every visible media asset — Ctrl/Cmd+A, and a toolbar button + * that makes the shortcut discoverable. + * + * Emptying the trash, or tagging a folder's worth of uploads, used to mean + * clicking every file. Every case renders the real MediaCanvas and presses + * the real shortcut or button, so the tests fail if the component stops + * honouring the rule — not just if a copy of the rule changes. + */ + +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MediaCanvas } from '@admin/pages/media/components/MediaCanvas/MediaCanvas' +import { FOLDER_ALL, type UseMediaWorkspaceResult } from '@admin/pages/media/hooks/useMediaWorkspace' +import { buildFolderTree } from '@admin/pages/media/utils/folderTree' +import { AdminSessionProvider } from '@admin/session' +import type { CoreCapability } from '@core/capabilities' +import type { CmsCurrentUser } from '@core/persistence' +import type { CmsMediaAsset } from '@core/persistence/cmsMedia' + +afterEach(() => { + cleanup() + document.body.innerHTML = '' +}) + +function asset(id: string): CmsMediaAsset { + return { + id, + filename: `${id}.png`, + mimeType: 'image/png', + sizeBytes: 1200, + publicPath: `/uploads/${id}.png`, + uploadedByUserId: null, + createdAt: '2026-01-01T00:00:00.000Z', + altText: '', + caption: '', + title: '', + tags: [], + width: null, + height: null, + durationMs: null, + dominantColor: null, + deletedAt: null, + replacedAt: null, + folderIds: [], + blurHash: null, + variants: [], + posterPath: null, + } +} + +const CAPABILITIES: CoreCapability[] = ['media.read', 'media.write', 'media.replace', 'media.delete'] + +function currentUser(): CmsCurrentUser { + return { + id: 'user_media_manager', + email: 'media-manager@example.com', + displayName: 'Media Manager', + status: 'active', + role: { + id: 'role_media_manager', + slug: 'media-manager', + name: 'Media Manager', + description: 'Media test role', + isSystem: false, + capabilities: CAPABILITIES, + }, + capabilities: CAPABILITIES, + lastLoginAt: null, + failedLoginCount: 0, + lockedUntil: null, + passwordUpdatedAt: null, + mfaEnabled: false, + mfaEnabledAt: null, + mfaRecoveryCodesRemaining: 0, + stepUpAuthMode: 'password', + stepUpWindowMinutes: 15, + avatarMediaId: null, + avatarUrl: null, + gravatarHash: '', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + } +} + +/** What the canvas asked the workspace to do. */ +interface Calls { + added: string[][] + cleared: number +} + +function renderCanvas(options: { + assets?: string[] + visible?: string[] + selected?: string[] +} = {}): Calls { + const assets = (options.assets ?? ['a', 'b', 'c']).map(asset) + const visibleIds = new Set(options.visible ?? assets.map((a) => a.id)) + const visibleAssets = assets.filter((a) => visibleIds.has(a.id)) + const selectedIds = new Set(options.selected ?? []) + const calls: Calls = { added: [], cleared: 0 } + const workspace: UseMediaWorkspaceResult = { + loading: false, + error: null, + clearError: () => {}, + refresh: async () => {}, + folders: [], + folderTree: buildFolderTree([]), + folderById: new Map(), + assets, + visibleAssets, + tagPalette: [], + folderSelection: FOLDER_ALL, + setFolderSelection: () => {}, + selectedAssetId: null, + selectedAsset: null, + setSelectedAssetId: () => {}, + selectedAssetIds: selectedIds, + selectedAssets: assets.filter((a) => selectedIds.has(a.id)), + toggleAssetInSelection: () => {}, + addToSelection: (ids: string[]) => { + calls.added.push(ids) + }, + selectRange: () => {}, + clearSelection: () => { + calls.cleared += 1 + }, + uploadQueue: { + items: [], + active: false, + enqueue: () => {}, + retry: () => {}, + remove: () => {}, + clearFinished: () => {}, + cancelAll: () => {}, + }, + filters: { type: 'all', q: '', tag: '', sort: 'newest' }, + setFilterType: () => {}, + setQuery: () => {}, + setTag: () => {}, + setSort: () => {}, + uploadFiles: async () => {}, + renameAsset: async () => null, + updateAsset: async () => null, + replaceAssetFile: async () => null, + trashAsset: async () => {}, + restoreAsset: async () => null, + purgeAsset: async () => {}, + setAssetFolders: async () => null, + moveAssetsToFolder: async () => {}, + createFolder: async () => null, + renameFolder: async () => null, + moveFolder: async () => null, + deleteFolder: async () => {}, + } + render( + + + , + ) + return calls +} + +/** Ctrl+A on Windows/Linux, Cmd+A on a Mac — both modifiers, so either rule fires. */ +function pressSelectAll(target: Element = document.body) { + fireEvent.keyDown(target, { key: 'a', ctrlKey: true, metaKey: true }) +} + +function mount(element: HTMLElement): HTMLElement { + document.body.appendChild(element) + return element +} + +describe('Ctrl/Cmd+A over the media grid', () => { + it('selects every visible asset', () => { + const calls = renderCanvas() + pressSelectAll() + expect(calls.added).toEqual([['a', 'b', 'c']]) + }) + + it('selects what the filter shows, not the whole library', () => { + // The only reading that makes "select all, then delete" safe in the + // trash: it can never reach past the filter into assets not on screen. + const calls = renderCanvas({ assets: ['a', 'b', 'c'], visible: ['a', 'c'] }) + pressSelectAll() + expect(calls.added).toEqual([['a', 'c']]) + }) + + it('clears the selection when it already covers everything visible', () => { + const calls = renderCanvas({ selected: ['a', 'b', 'c'] }) + pressSelectAll() + expect(calls.cleared).toBe(1) + expect(calls.added).toEqual([]) + }) + + it('leaves the shortcut to the browser inside the search box', () => { + const calls = renderCanvas() + pressSelectAll(screen.getByRole('searchbox', { name: 'Search media' })) + expect(calls.added).toEqual([]) + }) + + it('leaves it alone in a textarea or an editable field', () => { + const calls = renderCanvas() + pressSelectAll(mount(document.createElement('textarea'))) + const editable = mount(document.createElement('div')) + editable.contentEditable = 'true' + pressSelectAll(editable) + expect(calls.added).toEqual([]) + }) + + it('stands down while a modal dialog is open', () => { + // A rename or a delete confirmation keeps its own select-all. + const calls = renderCanvas() + const modal = mount(document.createElement('div')) + modal.setAttribute('role', 'dialog') + modal.setAttribute('aria-modal', 'true') + pressSelectAll() + expect(calls.added).toEqual([]) + }) + + it('keeps working beside a floating window, which is not modal', () => { + // Selecting one asset opens the viewer window, which carries + // role="dialog" but leaves the grid usable behind it. Keying on the role + // instead of aria-modal switched the shortcut off almost everywhere. + const calls = renderCanvas() + mount(document.createElement('div')).setAttribute('role', 'dialog') + pressSelectAll() + expect(calls.added).toEqual([['a', 'b', 'c']]) + }) + + it('does nothing on an empty grid', () => { + const calls = renderCanvas({ assets: [] }) + pressSelectAll() + expect(calls.added).toEqual([]) + expect(calls.cleared).toBe(0) + }) +}) + +describe('the toolbar button', () => { + it('names how many it will select, and selects them', () => { + const calls = renderCanvas() + fireEvent.click(screen.getByRole('button', { name: 'Select all 3 items' })) + expect(calls.added).toEqual([['a', 'b', 'c']]) + }) + + it('turns into Clear selection once everything visible is selected', () => { + const calls = renderCanvas({ selected: ['a', 'b', 'c'] }) + fireEvent.click(screen.getByRole('button', { name: 'Clear selection' })) + expect(calls.cleared).toBe(1) + }) + + it('is disabled on an empty grid', () => { + const calls = renderCanvas({ assets: [] }) + const button = screen.getByRole('button', { name: 'Select all 0 items' }) + // `Button` marks a disabled button that carries a tooltip with + // aria-disabled rather than the native attribute, so the tooltip still + // shows; a click must still do nothing. + expect(button.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(button) + expect(calls.added).toEqual([]) + }) +}) diff --git a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx index 3dba08963..ca1bf2f58 100644 --- a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx +++ b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx @@ -6,6 +6,8 @@ * navigation land in M3/M4 — this component is the first interactive surface. */ import { + useEffect, + useEffectEvent, useState, type ChangeEvent, type DragEvent, @@ -196,6 +198,61 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv writeMediaAssetDragData(event.dataTransfer, dragIds) } + /** + * Select everything currently on screen. + * + * "Everything" is `visibleAssets` — what the active folder, filter, search + * and trash toggle have already narrowed to — not the whole library. That is + * what every file manager means by Select All, and it is the only reading + * that makes the trash view's "select all, then delete" safe: it cannot + * reach past the filter into live assets. + */ + const allVisibleSelected = + workspace.visibleAssets.length > 0 + && workspace.visibleAssets.every((asset) => workspace.selectedAssetIds.has(asset.id)) + + /** Select every visible asset, or clear the selection when it already covers them. */ + function toggleSelectAllVisible() { + if (workspace.visibleAssets.length === 0) return + if (allVisibleSelected) { + workspace.clearSelection() + return + } + workspace.addToSelection(workspace.visibleAssets.map((asset) => asset.id)) + } + + // Ctrl/Cmd+A, the shortcut people try first. Document-level because the grid + // is a plain div with no tabindex, so a React `onKeyDown` would only fire + // while focus happened to sit on a tile. + // + // Ignored while focus is in a text field — the browser's own select-all is + // what someone typing in the search box means — and while any dialog is + // open, so a rename or a delete confirmation keeps its own select-all. + const selectAllEvent = useEffectEvent(() => toggleSelectAllVisible()) + useEffect(() => { + function onKeyDown(event: globalThis.KeyboardEvent) { + if (event.key !== 'a' && event.key !== 'A') return + if (!(isMacLike() ? event.metaKey : event.ctrlKey)) return + const target = event.target + if ( + target instanceof HTMLInputElement + || target instanceof HTMLTextAreaElement + || (target instanceof HTMLElement && target.isContentEditable) + ) return + // Only a real MODAL takes the shortcut away. `aria-modal` is what marks + // one: `Dialog` sets it, the floating windows do not. Matching + // `role="dialog"` instead disabled the shortcut almost everywhere in + // Media, because selecting a single asset opens the viewer window — and + // that carries `role="dialog"` while deliberately leaving the grid + // usable behind it. + if (document.querySelector('[aria-modal="true"]')) return + event.preventDefault() + selectAllEvent() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, []) + function handleFolderDragStart(folder: CmsMediaFolder, event: DragEvent) { if (!canWrite) { event.preventDefault() @@ -346,6 +403,30 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv groupLabel="Filter media type" trailing={(
+ {/* Discoverability for Ctrl/Cmd+A. The shortcut is the one people + reach for, but only if they already know it exists — and the + trash is where it matters most, because emptying it was + otherwise a file-at-a-time job. */} +