diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 306eaba..a8b7033 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -93,6 +93,7 @@ pub struct DocumentResponse { pub metadata: Value, pub tags: Vec, pub thumbnail: Option, + pub preview: Option, pub download_path: String, } @@ -118,6 +119,12 @@ pub struct DocumentAssetResponse { pub created_at: String, } +#[derive(Clone, Default)] +pub struct PrimaryDocumentAssets { + pub thumbnail: Option, + pub preview: Option, +} + #[derive(Serialize)] pub struct DocumentDetailResponse { pub document: DocumentResponse, @@ -239,18 +246,23 @@ pub async fn list_documents( let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; drop(conn); - let thumbnails = load_primary_thumbnails(&state, &docs).await?; + let primary_assets = load_primary_assets(&state, &docs).await?; let mut response = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); - let thumbnail = thumbnails.get(&doc.id).cloned(); + let (thumbnail, preview) = if let Some(assets) = primary_assets.get(&doc.id) { + (assets.thumbnail.clone(), assets.preview.clone()) + } else { + (None, None) + }; response.push(to_document_response( &state, user.user_id, doc, tags, thumbnail, + preview, )?); } @@ -283,6 +295,10 @@ pub async fn get_document( .iter() .find(|asset| asset.asset_type == "thumbnail") .cloned(); + let preview = assets + .iter() + .find(|asset| asset.asset_type == "preview") + .cloned(); Ok(Json(DocumentDetailResponse { document: to_document_response( @@ -291,6 +307,7 @@ pub async fn get_document( doc, tags_map.get(&document_id).cloned(), thumbnail, + preview, )?, current_version: to_version_response(current_version), assets, @@ -682,6 +699,10 @@ pub async fn update_document( .iter() .find(|asset| asset.asset_type == "thumbnail") .cloned(); + let preview = assets + .iter() + .find(|asset| asset.asset_type == "preview") + .cloned(); Ok(Json(DocumentDetailResponse { document: to_document_response( @@ -690,6 +711,7 @@ pub async fn update_document( document, tags_map.get(&document_id).cloned(), thumbnail, + preview, )?, current_version: to_version_response(current_version), assets, @@ -973,6 +995,10 @@ async fn process_upload( .iter() .find(|asset| asset.asset_type == "thumbnail") .cloned(); + let preview = assets + .iter() + .find(|asset| asset.asset_type == "preview") + .cloned(); info!( document_id = %document.id, @@ -982,7 +1008,9 @@ async fn process_upload( return Ok(UploadOutcome { detail: DocumentDetailResponse { - document: to_document_response(state, user_id, document, tags, thumbnail)?, + document: to_document_response( + state, user_id, document, tags, thumbnail, preview, + )?, current_version: to_version_response(version), assets, }, @@ -1053,7 +1081,7 @@ async fn process_upload( }; let detail = DocumentDetailResponse { - document: to_document_response(state, user_id, document, None, None)?, + document: to_document_response(state, user_id, document, None, None, None)?, current_version: to_version_response(version.clone()), assets: Vec::new(), }; @@ -1112,14 +1140,20 @@ pub(crate) fn load_tags_for_documents( Ok(map) } -pub(crate) async fn load_primary_thumbnails( +pub(crate) async fn load_primary_assets( state: &AppState, documents: &[Document], -) -> AppResult> { +) -> AppResult> { if documents.is_empty() { return Ok(HashMap::new()); } + #[derive(Default)] + struct RawPrimaryAssets { + thumbnail: Option, + preview: Option, + } + let mut current_versions: HashMap = HashMap::new(); let mut doc_ids = Vec::with_capacity(documents.len()); for doc in documents { @@ -1151,33 +1185,66 @@ pub(crate) async fn load_primary_thumbnails( let assets: Vec = document_assets::table .filter(document_assets::document_version_id.eq_any(&version_ids)) - .filter(document_assets::asset_type.eq("thumbnail")) + .filter( + document_assets::asset_type + .eq_any(vec!["thumbnail".to_string(), "preview".to_string()]), + ) .order(( document_assets::document_version_id.asc(), document_assets::created_at.asc(), )) .load(&mut conn)?; - drop(conn); - - let mut first_assets: HashMap = HashMap::new(); + let mut raw_assets: HashMap = HashMap::new(); for asset in assets { if let Some(doc_id) = doc_id_by_version.get(&asset.document_version_id) { - first_assets.entry(*doc_id).or_insert(asset); + let entry = raw_assets.entry(*doc_id).or_default(); + let asset_type = asset.asset_type.clone(); + match asset_type.as_str() { + "thumbnail" => { + if entry.thumbnail.is_none() { + entry.thumbnail = Some(asset); + } + } + "preview" => { + if entry.preview.is_none() { + entry.preview = Some(asset); + } + } + _ => {} + } } } - let mut responses = HashMap::with_capacity(first_assets.len()); - for (doc_id, asset) in first_assets { - let url = state - .storage - .presign_get_object( - &asset.s3_key, - Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), - ) - .await - .map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?; - responses.insert(doc_id, to_asset_response(asset, url)); + drop(conn); + + let mut responses = HashMap::with_capacity(raw_assets.len()); + for (doc_id, raw) in raw_assets { + let mut assets = PrimaryDocumentAssets::default(); + + if let Some(asset) = raw.preview { + let s3_key = asset.s3_key.clone(); + let url = state + .storage + .presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS)) + .await + .map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?; + assets.preview = Some(to_asset_response(asset, url)); + } + + if let Some(asset) = raw.thumbnail { + let s3_key = asset.s3_key.clone(); + let url = state + .storage + .presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS)) + .await + .map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?; + assets.thumbnail = Some(to_asset_response(asset, url)); + } + + if assets.thumbnail.is_some() || assets.preview.is_some() { + responses.insert(doc_id, assets); + } } Ok(responses) @@ -1189,6 +1256,7 @@ pub(crate) fn to_document_response( doc: Document, tags: Option>, thumbnail: Option, + preview: Option, ) -> AppResult { let download_path = build_download_path(state, doc.id, user_id)?; @@ -1211,6 +1279,7 @@ pub(crate) fn to_document_response( .map(TagResponse::from) .collect(), thumbnail, + preview, download_path, }) } diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index d435847..bc365f6 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -19,8 +19,7 @@ use crate::{ }; use super::documents::{ - load_primary_thumbnails, load_tags_for_documents, to_document_response, to_iso, - DocumentResponse, + load_primary_assets, load_tags_for_documents, to_document_response, to_iso, DocumentResponse, }; const QUICKWIT_MAX_HITS: usize = 200; @@ -216,18 +215,23 @@ pub async fn list_folder_contents( let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; drop(conn); - let thumbnails = load_primary_thumbnails(&state, &docs).await?; + let primary_assets = load_primary_assets(&state, &docs).await?; let mut documents = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); - let thumbnail = thumbnails.get(&doc.id).cloned(); + let (thumbnail, preview) = if let Some(assets) = primary_assets.get(&doc.id) { + (assets.thumbnail.clone(), assets.preview.clone()) + } else { + (None, None) + }; documents.push(to_document_response( &state, user.user_id, doc, tags, thumbnail, + preview, )?); } @@ -400,17 +404,22 @@ pub async fn search_documents( let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; drop(conn); - let thumbnails = load_primary_thumbnails(&state, &docs).await?; + let primary_assets = load_primary_assets(&state, &docs).await?; let mut response = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); - let thumbnail = thumbnails.get(&doc.id).cloned(); + let (thumbnail, preview) = if let Some(assets) = primary_assets.get(&doc.id) { + (assets.thumbnail.clone(), assets.preview.clone()) + } else { + (None, None) + }; response.push(to_document_response( &state, user.user_id, doc, tags, thumbnail, + preview, )?); } diff --git a/backend/src/workers/thumbnails.rs b/backend/src/workers/thumbnails.rs index 073e3c4..348b540 100644 --- a/backend/src/workers/thumbnails.rs +++ b/backend/src/workers/thumbnails.rs @@ -3,9 +3,7 @@ use std::{io::Cursor, panic, sync::Arc, time::Duration}; use async_trait::async_trait; use chrono::Utc; use diesel::{pg::upsert::excluded, prelude::*}; -use image::{ - codecs::png::PngEncoder, ColorType, GenericImageView, ImageEncoder, ImageFormat, ImageReader, -}; +use image::{GenericImageView, ImageFormat, ImageReader}; use pdfium_render::prelude::*; use serde::Deserialize; use serde_json::json; @@ -24,7 +22,10 @@ use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler}; const THUMBNAIL_WIDTH: u32 = 512; const THUMBNAIL_HEIGHT: u32 = 512; +const PREVIEW_WIDTH: u32 = THUMBNAIL_WIDTH * 4; +const PREVIEW_HEIGHT: u32 = THUMBNAIL_HEIGHT * 4; const THUMBNAIL_ASSET_TYPE: &str = "thumbnail"; +const PREVIEW_ASSET_TYPE: &str = "preview"; #[derive(Debug, Deserialize)] struct ThumbnailPayload { @@ -95,28 +96,61 @@ impl JobHandler for GenerateThumbnailsJob { } }; - let generation = match generate_thumbnail(&initial.document, &bytes) { + let generation = match generate_preview_and_thumbnail(&initial.document, &bytes) { Ok(result) => result, Err(err) => { return JobExecution::Failed { error: err }; } }; - let asset_id = initial - .existing_asset + let thumbnail_asset_id = initial + .existing_thumbnail .as_ref() .map(|asset| asset.id) .unwrap_or_else(Uuid::new_v4); - let s3_key = format!( + let thumbnail_s3_key = format!( "documents/{}/v{}/assets/{}/{}", - initial.document.id, initial.version.version_number, THUMBNAIL_ASSET_TYPE, asset_id + initial.document.id, + initial.version.version_number, + THUMBNAIL_ASSET_TYPE, + thumbnail_asset_id + ); + + let preview_asset_id = initial + .existing_preview + .as_ref() + .map(|asset| asset.id) + .unwrap_or_else(Uuid::new_v4); + let preview_s3_key = format!( + "documents/{}/v{}/assets/{}/{}", + initial.document.id, + initial.version.version_number, + PREVIEW_ASSET_TYPE, + preview_asset_id ); if let Err(err) = state .storage .put_object( - &s3_key, - generation.image_bytes.clone(), + &preview_s3_key, + generation.preview.image_bytes.clone(), + Some("image/png".into()), + None, + ) + .await + { + warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: err.to_string(), + }; + } + + if let Err(err) = state + .storage + .put_object( + &thumbnail_s3_key, + generation.thumbnail.image_bytes.clone(), Some("image/png".into()), None, ) @@ -131,7 +165,24 @@ impl JobHandler for GenerateThumbnailsJob { let state_clone = state.clone(); match task::spawn_blocking(move || { - persist_thumbnail_metadata(state_clone, &initial, &generation, asset_id, &s3_key) + persist_assets_metadata( + state_clone, + &initial, + &[ + AssetPersistence { + asset_type: PREVIEW_ASSET_TYPE, + asset_id: preview_asset_id, + s3_key: &preview_s3_key, + generated: &generation.preview, + }, + AssetPersistence { + asset_type: THUMBNAIL_ASSET_TYPE, + asset_id: thumbnail_asset_id, + s3_key: &thumbnail_s3_key, + generated: &generation.thumbnail, + }, + ], + ) }) .await { @@ -159,16 +210,29 @@ impl JobHandler for GenerateThumbnailsJob { struct ThumbnailContext { document: Document, version: DocumentVersion, - existing_asset: Option, + existing_thumbnail: Option, + existing_preview: Option, skip: bool, } -struct GeneratedThumbnail { +struct GeneratedImage { image_bytes: Vec, width: Option, height: Option, } +struct GeneratedAssets { + thumbnail: GeneratedImage, + preview: GeneratedImage, +} + +struct AssetPersistence<'a> { + asset_type: &'static str, + asset_id: Uuid, + s3_key: &'a str, + generated: &'a GeneratedImage, +} + fn load_thumbnail_context( state: Arc, payload: &ThumbnailPayload, @@ -189,29 +253,45 @@ fn load_thumbnail_context( .first(&mut conn) .map_err(|err| format!("{err:?}"))?; - let existing: Option = document_assets::table + let existing_assets: Vec = document_assets::table .filter(document_assets::document_version_id.eq(payload.document_version_id)) - .filter(document_assets::asset_type.eq(THUMBNAIL_ASSET_TYPE)) - .first(&mut conn) - .optional() + .filter(document_assets::asset_type.eq_any(vec![ + THUMBNAIL_ASSET_TYPE.to_string(), + PREVIEW_ASSET_TYPE.to_string(), + ])) + .load(&mut conn) .map_err(|err| format!("{err:?}"))?; + let mut existing_thumbnail = None; + let mut existing_preview = None; + for asset in existing_assets { + match asset.asset_type.as_str() { + THUMBNAIL_ASSET_TYPE => existing_thumbnail = Some(asset), + PREVIEW_ASSET_TYPE => existing_preview = Some(asset), + _ => {} + } + } + let (supported, _) = determine_thumbnail_support(&document); if !supported { return Err("thumbnail generation not supported for this document".into()); } - let skip = existing.is_some() && !payload.force; + let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force; Ok(ThumbnailContext { document, version, - existing_asset: existing, + existing_thumbnail, + existing_preview, skip, }) } -fn generate_thumbnail(document: &Document, bytes: &[u8]) -> Result { +fn generate_preview_and_thumbnail( + document: &Document, + bytes: &[u8], +) -> Result { let is_pdf = document .content_type .as_deref() @@ -225,38 +305,41 @@ fn generate_thumbnail(document: &Document, bytes: &[u8]) -> Result Result<(Vec, Option, Option), String> { +fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> { let reader = ImageReader::new(Cursor::new(bytes)) .with_guessed_format() .map_err(|err| err.to_string())?; - let mut image = reader.decode().map_err(|err| err.to_string())?; - if image.width() > THUMBNAIL_WIDTH || image.height() > THUMBNAIL_HEIGHT { - image = image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); - } + let image = reader.decode().map_err(|err| err.to_string())?; - let (width, height) = image.dimensions(); - let mut cursor = Cursor::new(Vec::new()); - image - .write_to(&mut cursor, ImageFormat::Png) - .map_err(|err| err.to_string())?; - let buffer = cursor.into_inner(); - Ok((buffer, Some(width as i32), Some(height as i32))) + let preview_image = if image.width() > PREVIEW_WIDTH || image.height() > PREVIEW_HEIGHT { + image.thumbnail(PREVIEW_WIDTH, PREVIEW_HEIGHT) + } else { + image.clone() + }; + + let thumbnail_image = + if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT { + preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) + } else { + preview_image.clone() + }; + + let preview = encode_dynamic_image(preview_image)?; + let thumbnail = encode_dynamic_image(thumbnail_image)?; + + Ok((preview, thumbnail)) } -fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec, Option, Option), String> { +fn generate_pdf_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> { let pdfium = panic::catch_unwind(|| Pdfium::default()) .map_err(|_| "failed to initialize PDFium".to_string())?; @@ -270,8 +353,8 @@ fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec, Option, Option< .map_err(|err| format!("load first page: {err}"))?; let render_config = PdfRenderConfig::new() - .set_target_width(THUMBNAIL_WIDTH as i32) - .set_maximum_height(THUMBNAIL_HEIGHT as i32) + .set_target_width(PREVIEW_WIDTH as i32) + .set_maximum_height(PREVIEW_HEIGHT as i32) .render_form_data(true) .rotate_if_landscape(PdfPageRenderRotation::None, true); @@ -279,54 +362,73 @@ fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec, Option, Option< .render_with_config(&render_config) .map_err(|err| format!("render pdf page: {err}"))?; - let image = bitmap.as_image().to_rgb8(); - let (width, height) = image.dimensions(); - let mut cursor = Cursor::new(Vec::new()); - PngEncoder::new(&mut cursor) - .write_image(image.as_raw(), width, height, ColorType::Rgb8.into()) - .map_err(|err| format!("encode pdf thumbnail: {err}"))?; + let preview_buffer = bitmap.as_image().to_rgb8(); + let preview_image = image::DynamicImage::ImageRgb8(preview_buffer); - Ok((cursor.into_inner(), Some(width as i32), Some(height as i32))) + let thumbnail_image = + if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT { + preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) + } else { + preview_image.clone() + }; + + let preview = encode_dynamic_image(preview_image)?; + let thumbnail = encode_dynamic_image(thumbnail_image)?; + + Ok((preview, thumbnail)) } -fn persist_thumbnail_metadata( +fn encode_dynamic_image(image: image::DynamicImage) -> Result { + let (width, height) = image.dimensions(); + let mut cursor = Cursor::new(Vec::new()); + image + .write_to(&mut cursor, ImageFormat::Png) + .map_err(|err| err.to_string())?; + Ok(GeneratedImage { + image_bytes: cursor.into_inner(), + width: Some(width as i32), + height: Some(height as i32), + }) +} + +fn persist_assets_metadata( state: Arc, context: &ThumbnailContext, - generated: &GeneratedThumbnail, - asset_id: Uuid, - s3_key: &str, + assets: &[AssetPersistence<'_>], ) -> Result<(), String> { let mut conn = state.db().map_err(|err| format!("{err:?}"))?; - let new_asset = NewDocumentAsset { - id: asset_id, - document_version_id: context.version.id, - asset_type: THUMBNAIL_ASSET_TYPE.to_string(), - s3_key: s3_key.to_string(), - mime_type: "image/png".to_string(), - width: generated.width, - height: generated.height, - metadata: json!({ - "generated_at": Utc::now().to_rfc3339(), - }), - }; + for asset in assets { + let new_asset = NewDocumentAsset { + id: asset.asset_id, + document_version_id: context.version.id, + asset_type: asset.asset_type.to_string(), + s3_key: asset.s3_key.to_string(), + mime_type: "image/png".to_string(), + width: asset.generated.width, + height: asset.generated.height, + metadata: json!({ + "generated_at": Utc::now().to_rfc3339(), + }), + }; - diesel::insert_into(document_assets::table) - .values(&new_asset) - .on_conflict(( - document_assets::document_version_id, - document_assets::asset_type, - )) - .do_update() - .set(( - document_assets::s3_key.eq(excluded(document_assets::s3_key)), - document_assets::mime_type.eq(excluded(document_assets::mime_type)), - document_assets::width.eq(excluded(document_assets::width)), - document_assets::height.eq(excluded(document_assets::height)), - document_assets::metadata.eq(excluded(document_assets::metadata)), - )) - .execute(&mut conn) - .map_err(|err| format!("{err:?}"))?; + diesel::insert_into(document_assets::table) + .values(&new_asset) + .on_conflict(( + document_assets::document_version_id, + document_assets::asset_type, + )) + .do_update() + .set(( + document_assets::s3_key.eq(excluded(document_assets::s3_key)), + document_assets::mime_type.eq(excluded(document_assets::mime_type)), + document_assets::width.eq(excluded(document_assets::width)), + document_assets::height.eq(excluded(document_assets::height)), + document_assets::metadata.eq(excluded(document_assets::metadata)), + )) + .execute(&mut conn) + .map_err(|err| format!("{err:?}"))?; + } Ok(()) } diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 7a0826e..baa83bd 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -517,6 +517,7 @@ const DocumentsTable = ({ onDocumentOpen, selectedDocumentIds, focusedDocumentId, + focusedRowKey, draggingDocumentIds = [], onDocumentDragStart, onDocumentDragEnd, @@ -525,6 +526,7 @@ const DocumentsTable = ({ tagLookupById, onDocumentListFocus, onDocumentListKeyDown, + onFocusedRowChange, }) => { const showingSearchResults = searchResults !== null; const rows = showingSearchResults ? searchResults : documents; @@ -538,10 +540,19 @@ const DocumentsTable = ({ ); const scrollRef = useRef(null); const ensureFocusedRowVisible = useCallback(() => { - if (!focusedDocumentId) return; + if (!focusedRowKey) return; const container = scrollRef.current; if (!container) return; - const row = container.querySelector(`#document-row-${focusedDocumentId}`); + let selector = null; + if (focusedRowKey.startsWith('document:')) { + selector = `#document-row-${focusedRowKey.slice('document:'.length)}`; + } else if (focusedRowKey.startsWith('folder:')) { + selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`; + } + if (!selector) { + return; + } + const row = container.querySelector(selector); if (!row || !container.contains(row)) { return; } @@ -562,12 +573,23 @@ const DocumentsTable = ({ const nextScrollTop = rowBottom - container.clientHeight; container.scrollTop = Math.max(nextScrollTop, 0); } - }, [focusedDocumentId]); + }, [focusedRowKey]); useEffect(() => { ensureFocusedRowVisible(); }, [ensureFocusedRowVisible]); + const activeDescendantId = useMemo(() => { + if (!focusedRowKey) return undefined; + if (focusedRowKey.startsWith('document:')) { + return `document-row-${focusedRowKey.slice('document:'.length)}`; + } + if (focusedRowKey.startsWith('folder:')) { + return `folder-row-${focusedRowKey.slice('folder:'.length)}`; + } + return undefined; + }, [focusedRowKey]); + return (
@@ -624,9 +646,7 @@ const DocumentsTable = ({ onDocumentListKeyDown(event); } }} - aria-activedescendant={ - focusedDocumentId ? `document-row-${focusedDocumentId}` : undefined - } + aria-activedescendant={activeDescendantId} > {!showingSearchResults && !subfolders.length && rows.length === 0 ? (
@@ -651,9 +671,13 @@ const DocumentsTable = ({ return ( { scrollRef.current?.focus({ preventScroll: true }); + onFocusedRowChange?.(`folder:${folder.id}`); onFolderSelect(folder.id); }} onDragOver={(event) => onFolderDragOver(event, folder.id)} @@ -698,7 +722,11 @@ const DocumentsTable = ({ const isSelected = selectedSet.has(doc.id); const rowClasses = ['document']; if (isSelected) rowClasses.push('selected'); - if (focusedDocumentId === doc.id) rowClasses.push('focused'); + if ( + focusedDocumentId === doc.id || focusedRowKey === `document:${doc.id}` + ) { + rowClasses.push('focused'); + } if (draggingSet.has(doc.id)) { rowClasses.push('dragging'); } @@ -711,6 +739,7 @@ const DocumentsTable = ({ aria-selected={isSelected} onClick={(event) => { scrollRef.current?.focus({ preventScroll: true }); + onFocusedRowChange?.(`document:${doc.id}`); onDocumentRowClick(doc.id, event); }} onDoubleClick={(event) => { @@ -1813,6 +1842,9 @@ const AppLayout = () => { const [selectedDocumentIds, setSelectedDocumentIds] = useState(initialSelection); const [selectionOrder, setSelectionOrder] = useState(initialSelection); const [focusedDocumentId, setFocusedDocumentId] = useState(routeDocumentId); + const [focusedRowKey, setFocusedRowKey] = useState(() => + routeDocumentId ? `document:${routeDocumentId}` : null, + ); const [documentDetails, setDocumentDetails] = useState(() => new Map()); const documentDetailsRef = useRef(documentDetails); const tokenRef = useRef(token); @@ -2091,9 +2123,11 @@ const AppLayout = () => { [focusedDocumentId, setSelectionOrder], ); + const showingSearchResults = searchResults !== null; + const visibleDocuments = useMemo( - () => (searchResults !== null ? searchResults : documents), - [searchResults, documents], + () => (showingSearchResults ? searchResults : documents), + [showingSearchResults, searchResults, documents], ); const visibleDocumentIds = useMemo( @@ -2277,116 +2311,57 @@ const AppLayout = () => { ], ); - const handleDocumentListFocus = useCallback(() => { - if (focusedDocumentId && visibleDocumentIds.includes(focusedDocumentId)) { + const navigableRows = useMemo(() => { + const entries = []; + if (!showingSearchResults) { + currentSubfolders.forEach((folder) => { + entries.push({ key: `folder:${folder.id}`, type: 'folder', id: folder.id }); + }); + } + visibleDocuments.forEach((doc) => { + entries.push({ key: `document:${doc.id}`, type: 'document', id: doc.id }); + }); + return entries; + }, [showingSearchResults, currentSubfolders, visibleDocuments]); + + const navigableRowKeys = useMemo( + () => navigableRows.map((entry) => entry.key), + [navigableRows], + ); + + const prevFocusedDocIdRef = useRef(focusedDocumentId); + useEffect(() => { + const previous = prevFocusedDocIdRef.current; + if (previous === focusedDocumentId) { return; } - - for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) { - const candidate = selectedDocumentIds[index]; - if (visibleDocumentIds.includes(candidate)) { - setFocusedDocumentId(candidate); - return; - } + prevFocusedDocIdRef.current = focusedDocumentId; + if (focusedDocumentId) { + setFocusedRowKey(`document:${focusedDocumentId}`); + } else { + setFocusedRowKey((current) => (current?.startsWith('folder:') ? current : null)); } + }, [focusedDocumentId]); - if (visibleDocumentIds.length) { - const firstId = visibleDocumentIds[0]; - applySelection([firstId], { anchor: firstId, interactedIds: [firstId] }); + useEffect(() => { + if (!focusedRowKey) { + return; } - }, [ - focusedDocumentId, - visibleDocumentIds, - selectedDocumentIds, - setFocusedDocumentId, - applySelection, - ]); + if (navigableRowKeys.includes(focusedRowKey)) { + return; + } + const docKey = focusedDocumentId ? `document:${focusedDocumentId}` : null; + if (docKey && navigableRowKeys.includes(docKey)) { + setFocusedRowKey(docKey); + return; + } + if (navigableRowKeys.length) { + setFocusedRowKey(navigableRowKeys[0]); + } else { + setFocusedRowKey(null); + } + }, [focusedRowKey, navigableRowKeys, focusedDocumentId]); - const handleDocumentListKeyDown = useCallback( - (event) => { - const { key, shiftKey } = event; - if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(key)) { - return; - } - - if (!visibleDocumentIds.length) { - return; - } - - event.preventDefault(); - - const activeId = (() => { - if (focusedDocumentId && visibleDocumentIds.includes(focusedDocumentId)) { - return focusedDocumentId; - } - for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) { - const candidate = selectedDocumentIds[index]; - if (visibleDocumentIds.includes(candidate)) { - return candidate; - } - } - return null; - })(); - - let currentIndex = activeId ? visibleDocumentIds.indexOf(activeId) : -1; - let nextIndex = currentIndex; - - if (key === 'ArrowDown') { - nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, visibleDocumentIds.length - 1); - } else if (key === 'ArrowUp') { - if (currentIndex === -1) { - nextIndex = visibleDocumentIds.length - 1; - } else { - nextIndex = Math.max(currentIndex - 1, 0); - } - } else if (key === 'Home') { - nextIndex = 0; - } else if (key === 'End') { - nextIndex = visibleDocumentIds.length - 1; - } - - if (nextIndex === -1 || nextIndex >= visibleDocumentIds.length) { - return; - } - - if (nextIndex === currentIndex && key !== 'Home' && key !== 'End') { - return; - } - - const targetId = visibleDocumentIds[nextIndex]; - if (!targetId) { - return; - } - - if (shiftKey) { - let anchorId = selectionAnchorRef.current; - if (!anchorId || !visibleDocumentIds.includes(anchorId)) { - anchorId = activeId || targetId; - } - - const anchorIndex = visibleDocumentIds.indexOf(anchorId); - const boundedAnchorIndex = anchorIndex === -1 ? nextIndex : anchorIndex; - const start = Math.min(boundedAnchorIndex, nextIndex); - const end = Math.max(boundedAnchorIndex, nextIndex); - const range = visibleDocumentIds.slice(start, end + 1); - applySelection(range, { anchor: anchorId, interactedIds: [targetId] }); - } else { - applySelection([targetId], { anchor: targetId, interactedIds: [targetId] }); - } - - if (focusedDocumentId !== targetId) { - setFocusedDocumentId(targetId); - } - }, - [ - visibleDocumentIds, - focusedDocumentId, - selectedDocumentIds, - applySelection, - selectionAnchorRef, - setFocusedDocumentId, - ], - ); const handleDocumentDragStart = useCallback( (event, documentId) => { @@ -3523,6 +3498,172 @@ const AppLayout = () => { [ensureDocumentDetail, ensurePreviewUrl, navigate], ); + const handleDocumentListFocus = useCallback(() => { + if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { + return; + } + + let resolvedKey = null; + for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) { + const candidate = selectedDocumentIds[index]; + const rowKey = `document:${candidate}`; + if (navigableRowKeys.includes(rowKey)) { + resolvedKey = rowKey; + break; + } + } + + if (!resolvedKey && navigableRows.length) { + resolvedKey = navigableRows[0].key; + } + + if (!resolvedKey) { + return; + } + + setFocusedRowKey(resolvedKey); + + if (resolvedKey.startsWith('document:')) { + const docId = resolvedKey.slice('document:'.length); + if (!selectedDocumentIds.includes(docId)) { + applySelection([docId], { anchor: docId, interactedIds: [docId] }); + } + } + }, [ + focusedRowKey, + navigableRowKeys, + selectedDocumentIds, + navigableRows, + applySelection, + ]); + + const handleDocumentListKeyDown = useCallback( + (event) => { + const { key, shiftKey } = event; + const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar']; + if (!triggers.includes(key)) { + return; + } + + if (!navigableRows.length) { + return; + } + + event.preventDefault(); + + let activeKey = + focusedRowKey && navigableRowKeys.includes(focusedRowKey) + ? focusedRowKey + : null; + + if (!activeKey) { + for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) { + const candidate = selectedDocumentIds[index]; + const rowKey = `document:${candidate}`; + if (navigableRowKeys.includes(rowKey)) { + activeKey = rowKey; + break; + } + } + } + + if (!activeKey) { + activeKey = navigableRows[0].key; + setFocusedRowKey(activeKey); + } + + let currentIndex = navigableRowKeys.indexOf(activeKey); + + if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { + const row = currentIndex === -1 ? navigableRows[0] : navigableRows[currentIndex]; + if (!row) { + return; + } + if (row.type === 'folder') { + setFocusedRowKey(`folder:${row.id}`); + selectFolder(row.id); + } else { + setFocusedRowKey(`document:${row.id}`); + applySelection([row.id], { anchor: row.id, interactedIds: [row.id] }); + openDocumentPreview(row.id); + } + return; + } + + let nextIndex = currentIndex; + + if (key === 'ArrowDown') { + nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1); + } else if (key === 'ArrowUp') { + nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0); + } else if (key === 'Home') { + nextIndex = 0; + } else if (key === 'End') { + nextIndex = navigableRows.length - 1; + } + + if (nextIndex === -1 || nextIndex >= navigableRows.length) { + return; + } + + if (nextIndex === currentIndex && key !== 'Home' && key !== 'End') { + return; + } + + const targetRow = navigableRows[nextIndex]; + if (!targetRow) { + return; + } + + setFocusedRowKey(targetRow.key); + + if (targetRow.type === 'folder') { + return; + } + + const targetId = targetRow.id; + if (!targetId) { + return; + } + + if (shiftKey) { + let anchorId = selectionAnchorRef.current; + if (!anchorId || !visibleDocumentIds.includes(anchorId)) { + if (focusedDocumentId && visibleDocumentIds.includes(focusedDocumentId)) { + anchorId = focusedDocumentId; + } else { + anchorId = targetId; + } + } + + const anchorIndex = visibleDocumentIds.indexOf(anchorId); + const targetIndex = visibleDocumentIds.indexOf(targetId); + if (anchorIndex !== -1 && targetIndex !== -1) { + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + const range = visibleDocumentIds.slice(start, end + 1); + applySelection(range, { anchor: anchorId, interactedIds: [targetId] }); + } else { + applySelection([targetId], { anchor: targetId, interactedIds: [targetId] }); + } + } else { + applySelection([targetId], { anchor: targetId, interactedIds: [targetId] }); + } + }, + [ + navigableRows, + navigableRowKeys, + focusedRowKey, + selectedDocumentIds, + selectFolder, + openDocumentPreview, + visibleDocumentIds, + focusedDocumentId, + applySelection, + selectionAnchorRef, + ], + ); + const closeDocumentPreview = useCallback( (folderId = null) => { setPreviewDocumentId(null); @@ -4501,6 +4642,7 @@ const AppLayout = () => { onDocumentDelete: handleDocumentDelete, selectedDocumentIds, focusedDocumentId, + focusedRowKey, draggingDocumentIds: draggedDocumentIds, onDocumentDragStart: handleDocumentDragStart, onDocumentDragEnd: handleDocumentDragEnd, @@ -4508,6 +4650,7 @@ const AppLayout = () => { tagLookupById, onDocumentListFocus: handleDocumentListFocus, onDocumentListKeyDown: handleDocumentListKeyDown, + onFocusedRowChange: setFocusedRowKey, }; const detailPanelProps = { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index b52c7b8..62a330c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -738,6 +738,11 @@ button.icon-button.ghost:hover:not([disabled]) { font-weight: 600; } +.documents-panel tbody tr.folder.focused { + box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.45); + background: rgba(43, 92, 255, 0.08); +} + .documents-panel tbody tr.document { cursor: pointer; }