backend: generate high resolution preview

This commit is contained in:
2025-10-13 02:39:02 +02:00
parent 86c75cea8e
commit 159577dff9
5 changed files with 552 additions and 224 deletions
+91 -22
View File
@@ -93,6 +93,7 @@ pub struct DocumentResponse {
pub metadata: Value, pub metadata: Value,
pub tags: Vec<TagResponse>, pub tags: Vec<TagResponse>,
pub thumbnail: Option<DocumentAssetResponse>, pub thumbnail: Option<DocumentAssetResponse>,
pub preview: Option<DocumentAssetResponse>,
pub download_path: String, pub download_path: String,
} }
@@ -118,6 +119,12 @@ pub struct DocumentAssetResponse {
pub created_at: String, pub created_at: String,
} }
#[derive(Clone, Default)]
pub struct PrimaryDocumentAssets {
pub thumbnail: Option<DocumentAssetResponse>,
pub preview: Option<DocumentAssetResponse>,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct DocumentDetailResponse { pub struct DocumentDetailResponse {
pub document: DocumentResponse, pub document: DocumentResponse,
@@ -239,18 +246,23 @@ pub async fn list_documents(
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
drop(conn); 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()); let mut response = Vec::with_capacity(doc_ids.len());
for doc in docs { for doc in docs {
let tags = tags_map.get(&doc.id).cloned(); 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( response.push(to_document_response(
&state, &state,
user.user_id, user.user_id,
doc, doc,
tags, tags,
thumbnail, thumbnail,
preview,
)?); )?);
} }
@@ -283,6 +295,10 @@ pub async fn get_document(
.iter() .iter()
.find(|asset| asset.asset_type == "thumbnail") .find(|asset| asset.asset_type == "thumbnail")
.cloned(); .cloned();
let preview = assets
.iter()
.find(|asset| asset.asset_type == "preview")
.cloned();
Ok(Json(DocumentDetailResponse { Ok(Json(DocumentDetailResponse {
document: to_document_response( document: to_document_response(
@@ -291,6 +307,7 @@ pub async fn get_document(
doc, doc,
tags_map.get(&document_id).cloned(), tags_map.get(&document_id).cloned(),
thumbnail, thumbnail,
preview,
)?, )?,
current_version: to_version_response(current_version), current_version: to_version_response(current_version),
assets, assets,
@@ -682,6 +699,10 @@ pub async fn update_document(
.iter() .iter()
.find(|asset| asset.asset_type == "thumbnail") .find(|asset| asset.asset_type == "thumbnail")
.cloned(); .cloned();
let preview = assets
.iter()
.find(|asset| asset.asset_type == "preview")
.cloned();
Ok(Json(DocumentDetailResponse { Ok(Json(DocumentDetailResponse {
document: to_document_response( document: to_document_response(
@@ -690,6 +711,7 @@ pub async fn update_document(
document, document,
tags_map.get(&document_id).cloned(), tags_map.get(&document_id).cloned(),
thumbnail, thumbnail,
preview,
)?, )?,
current_version: to_version_response(current_version), current_version: to_version_response(current_version),
assets, assets,
@@ -973,6 +995,10 @@ async fn process_upload(
.iter() .iter()
.find(|asset| asset.asset_type == "thumbnail") .find(|asset| asset.asset_type == "thumbnail")
.cloned(); .cloned();
let preview = assets
.iter()
.find(|asset| asset.asset_type == "preview")
.cloned();
info!( info!(
document_id = %document.id, document_id = %document.id,
@@ -982,7 +1008,9 @@ async fn process_upload(
return Ok(UploadOutcome { return Ok(UploadOutcome {
detail: DocumentDetailResponse { 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), current_version: to_version_response(version),
assets, assets,
}, },
@@ -1053,7 +1081,7 @@ async fn process_upload(
}; };
let detail = DocumentDetailResponse { 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()), current_version: to_version_response(version.clone()),
assets: Vec::new(), assets: Vec::new(),
}; };
@@ -1112,14 +1140,20 @@ pub(crate) fn load_tags_for_documents(
Ok(map) Ok(map)
} }
pub(crate) async fn load_primary_thumbnails( pub(crate) async fn load_primary_assets(
state: &AppState, state: &AppState,
documents: &[Document], documents: &[Document],
) -> AppResult<HashMap<Uuid, DocumentAssetResponse>> { ) -> AppResult<HashMap<Uuid, PrimaryDocumentAssets>> {
if documents.is_empty() { if documents.is_empty() {
return Ok(HashMap::new()); return Ok(HashMap::new());
} }
#[derive(Default)]
struct RawPrimaryAssets {
thumbnail: Option<DocumentAsset>,
preview: Option<DocumentAsset>,
}
let mut current_versions: HashMap<Uuid, i32> = HashMap::new(); let mut current_versions: HashMap<Uuid, i32> = HashMap::new();
let mut doc_ids = Vec::with_capacity(documents.len()); let mut doc_ids = Vec::with_capacity(documents.len());
for doc in documents { for doc in documents {
@@ -1151,33 +1185,66 @@ pub(crate) async fn load_primary_thumbnails(
let assets: Vec<DocumentAsset> = document_assets::table let assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq_any(&version_ids)) .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(( .order((
document_assets::document_version_id.asc(), document_assets::document_version_id.asc(),
document_assets::created_at.asc(), document_assets::created_at.asc(),
)) ))
.load(&mut conn)?; .load(&mut conn)?;
drop(conn); let mut raw_assets: HashMap<Uuid, RawPrimaryAssets> = HashMap::new();
let mut first_assets: HashMap<Uuid, DocumentAsset> = HashMap::new();
for asset in assets { for asset in assets {
if let Some(doc_id) = doc_id_by_version.get(&asset.document_version_id) { 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()); drop(conn);
for (doc_id, asset) in first_assets {
let url = state let mut responses = HashMap::with_capacity(raw_assets.len());
.storage for (doc_id, raw) in raw_assets {
.presign_get_object( let mut assets = PrimaryDocumentAssets::default();
&asset.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), if let Some(asset) = raw.preview {
) let s3_key = asset.s3_key.clone();
.await let url = state
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?; .storage
responses.insert(doc_id, to_asset_response(asset, url)); .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) Ok(responses)
@@ -1189,6 +1256,7 @@ pub(crate) fn to_document_response(
doc: Document, doc: Document,
tags: Option<Vec<Tag>>, tags: Option<Vec<Tag>>,
thumbnail: Option<DocumentAssetResponse>, thumbnail: Option<DocumentAssetResponse>,
preview: Option<DocumentAssetResponse>,
) -> AppResult<DocumentResponse> { ) -> AppResult<DocumentResponse> {
let download_path = build_download_path(state, doc.id, user_id)?; let download_path = build_download_path(state, doc.id, user_id)?;
@@ -1211,6 +1279,7 @@ pub(crate) fn to_document_response(
.map(TagResponse::from) .map(TagResponse::from)
.collect(), .collect(),
thumbnail, thumbnail,
preview,
download_path, download_path,
}) })
} }
+15 -6
View File
@@ -19,8 +19,7 @@ use crate::{
}; };
use super::documents::{ use super::documents::{
load_primary_thumbnails, load_tags_for_documents, to_document_response, to_iso, load_primary_assets, load_tags_for_documents, to_document_response, to_iso, DocumentResponse,
DocumentResponse,
}; };
const QUICKWIT_MAX_HITS: usize = 200; 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)?; let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
drop(conn); 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()); let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs { for doc in docs {
let tags = tags_map.get(&doc.id).cloned(); 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( documents.push(to_document_response(
&state, &state,
user.user_id, user.user_id,
doc, doc,
tags, tags,
thumbnail, thumbnail,
preview,
)?); )?);
} }
@@ -400,17 +404,22 @@ pub async fn search_documents(
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
drop(conn); 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()); let mut response = Vec::with_capacity(doc_ids.len());
for doc in docs { for doc in docs {
let tags = tags_map.get(&doc.id).cloned(); 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( response.push(to_document_response(
&state, &state,
user.user_id, user.user_id,
doc, doc,
tags, tags,
thumbnail, thumbnail,
preview,
)?); )?);
} }
+184 -82
View File
@@ -3,9 +3,7 @@ use std::{io::Cursor, panic, sync::Arc, time::Duration};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use diesel::{pg::upsert::excluded, prelude::*}; use diesel::{pg::upsert::excluded, prelude::*};
use image::{ use image::{GenericImageView, ImageFormat, ImageReader};
codecs::png::PngEncoder, ColorType, GenericImageView, ImageEncoder, ImageFormat, ImageReader,
};
use pdfium_render::prelude::*; use pdfium_render::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
@@ -24,7 +22,10 @@ use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
const THUMBNAIL_WIDTH: u32 = 512; const THUMBNAIL_WIDTH: u32 = 512;
const THUMBNAIL_HEIGHT: 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 THUMBNAIL_ASSET_TYPE: &str = "thumbnail";
const PREVIEW_ASSET_TYPE: &str = "preview";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ThumbnailPayload { 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, Ok(result) => result,
Err(err) => { Err(err) => {
return JobExecution::Failed { error: err }; return JobExecution::Failed { error: err };
} }
}; };
let asset_id = initial let thumbnail_asset_id = initial
.existing_asset .existing_thumbnail
.as_ref() .as_ref()
.map(|asset| asset.id) .map(|asset| asset.id)
.unwrap_or_else(Uuid::new_v4); .unwrap_or_else(Uuid::new_v4);
let s3_key = format!( let thumbnail_s3_key = format!(
"documents/{}/v{}/assets/{}/{}", "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 if let Err(err) = state
.storage .storage
.put_object( .put_object(
&s3_key, &preview_s3_key,
generation.image_bytes.clone(), 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()), Some("image/png".into()),
None, None,
) )
@@ -131,7 +165,24 @@ impl JobHandler for GenerateThumbnailsJob {
let state_clone = state.clone(); let state_clone = state.clone();
match task::spawn_blocking(move || { 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 .await
{ {
@@ -159,16 +210,29 @@ impl JobHandler for GenerateThumbnailsJob {
struct ThumbnailContext { struct ThumbnailContext {
document: Document, document: Document,
version: DocumentVersion, version: DocumentVersion,
existing_asset: Option<DocumentAsset>, existing_thumbnail: Option<DocumentAsset>,
existing_preview: Option<DocumentAsset>,
skip: bool, skip: bool,
} }
struct GeneratedThumbnail { struct GeneratedImage {
image_bytes: Vec<u8>, image_bytes: Vec<u8>,
width: Option<i32>, width: Option<i32>,
height: Option<i32>, height: Option<i32>,
} }
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( fn load_thumbnail_context(
state: Arc<AppState>, state: Arc<AppState>,
payload: &ThumbnailPayload, payload: &ThumbnailPayload,
@@ -189,29 +253,45 @@ fn load_thumbnail_context(
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing: Option<DocumentAsset> = document_assets::table let existing_assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(THUMBNAIL_ASSET_TYPE)) .filter(document_assets::asset_type.eq_any(vec![
.first(&mut conn) THUMBNAIL_ASSET_TYPE.to_string(),
.optional() PREVIEW_ASSET_TYPE.to_string(),
]))
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?; .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); let (supported, _) = determine_thumbnail_support(&document);
if !supported { if !supported {
return Err("thumbnail generation not supported for this document".into()); 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 { Ok(ThumbnailContext {
document, document,
version, version,
existing_asset: existing, existing_thumbnail,
existing_preview,
skip, skip,
}) })
} }
fn generate_thumbnail(document: &Document, bytes: &[u8]) -> Result<GeneratedThumbnail, String> { fn generate_preview_and_thumbnail(
document: &Document,
bytes: &[u8],
) -> Result<GeneratedAssets, String> {
let is_pdf = document let is_pdf = document
.content_type .content_type
.as_deref() .as_deref()
@@ -225,38 +305,41 @@ fn generate_thumbnail(document: &Document, bytes: &[u8]) -> Result<GeneratedThum
.unwrap_or(false) .unwrap_or(false)
}); });
let (png_bytes, width, height) = if is_pdf { let (preview, thumbnail) = if is_pdf {
generate_pdf_thumbnail(bytes)? generate_pdf_assets(bytes)?
} else { } else {
generate_image_thumbnail(bytes)? generate_image_assets(bytes)?
}; };
Ok(GeneratedThumbnail { Ok(GeneratedAssets { preview, thumbnail })
image_bytes: png_bytes,
width,
height,
})
} }
fn generate_image_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<i32>), String> { fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
let reader = ImageReader::new(Cursor::new(bytes)) let reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format() .with_guessed_format()
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
let mut image = reader.decode().map_err(|err| err.to_string())?; let 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 (width, height) = image.dimensions(); let preview_image = if image.width() > PREVIEW_WIDTH || image.height() > PREVIEW_HEIGHT {
let mut cursor = Cursor::new(Vec::new()); image.thumbnail(PREVIEW_WIDTH, PREVIEW_HEIGHT)
image } else {
.write_to(&mut cursor, ImageFormat::Png) image.clone()
.map_err(|err| err.to_string())?; };
let buffer = cursor.into_inner();
Ok((buffer, 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 generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<i32>), String> { fn generate_pdf_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
let pdfium = panic::catch_unwind(|| Pdfium::default()) let pdfium = panic::catch_unwind(|| Pdfium::default())
.map_err(|_| "failed to initialize PDFium".to_string())?; .map_err(|_| "failed to initialize PDFium".to_string())?;
@@ -270,8 +353,8 @@ fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<
.map_err(|err| format!("load first page: {err}"))?; .map_err(|err| format!("load first page: {err}"))?;
let render_config = PdfRenderConfig::new() let render_config = PdfRenderConfig::new()
.set_target_width(THUMBNAIL_WIDTH as i32) .set_target_width(PREVIEW_WIDTH as i32)
.set_maximum_height(THUMBNAIL_HEIGHT as i32) .set_maximum_height(PREVIEW_HEIGHT as i32)
.render_form_data(true) .render_form_data(true)
.rotate_if_landscape(PdfPageRenderRotation::None, true); .rotate_if_landscape(PdfPageRenderRotation::None, true);
@@ -279,54 +362,73 @@ fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<
.render_with_config(&render_config) .render_with_config(&render_config)
.map_err(|err| format!("render pdf page: {err}"))?; .map_err(|err| format!("render pdf page: {err}"))?;
let image = bitmap.as_image().to_rgb8(); let preview_buffer = bitmap.as_image().to_rgb8();
let (width, height) = image.dimensions(); let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
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}"))?;
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<GeneratedImage, 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())?;
Ok(GeneratedImage {
image_bytes: cursor.into_inner(),
width: Some(width as i32),
height: Some(height as i32),
})
}
fn persist_assets_metadata(
state: Arc<AppState>, state: Arc<AppState>,
context: &ThumbnailContext, context: &ThumbnailContext,
generated: &GeneratedThumbnail, assets: &[AssetPersistence<'_>],
asset_id: Uuid,
s3_key: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let new_asset = NewDocumentAsset { for asset in assets {
id: asset_id, let new_asset = NewDocumentAsset {
document_version_id: context.version.id, id: asset.asset_id,
asset_type: THUMBNAIL_ASSET_TYPE.to_string(), document_version_id: context.version.id,
s3_key: s3_key.to_string(), asset_type: asset.asset_type.to_string(),
mime_type: "image/png".to_string(), s3_key: asset.s3_key.to_string(),
width: generated.width, mime_type: "image/png".to_string(),
height: generated.height, width: asset.generated.width,
metadata: json!({ height: asset.generated.height,
"generated_at": Utc::now().to_rfc3339(), metadata: json!({
}), "generated_at": Utc::now().to_rfc3339(),
}; }),
};
diesel::insert_into(document_assets::table) diesel::insert_into(document_assets::table)
.values(&new_asset) .values(&new_asset)
.on_conflict(( .on_conflict((
document_assets::document_version_id, document_assets::document_version_id,
document_assets::asset_type, document_assets::asset_type,
)) ))
.do_update() .do_update()
.set(( .set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)), document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)), document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::width.eq(excluded(document_assets::width)), document_assets::width.eq(excluded(document_assets::width)),
document_assets::height.eq(excluded(document_assets::height)), document_assets::height.eq(excluded(document_assets::height)),
document_assets::metadata.eq(excluded(document_assets::metadata)), document_assets::metadata.eq(excluded(document_assets::metadata)),
)) ))
.execute(&mut conn) .execute(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
}
Ok(()) Ok(())
} }
+257 -114
View File
@@ -517,6 +517,7 @@ const DocumentsTable = ({
onDocumentOpen, onDocumentOpen,
selectedDocumentIds, selectedDocumentIds,
focusedDocumentId, focusedDocumentId,
focusedRowKey,
draggingDocumentIds = [], draggingDocumentIds = [],
onDocumentDragStart, onDocumentDragStart,
onDocumentDragEnd, onDocumentDragEnd,
@@ -525,6 +526,7 @@ const DocumentsTable = ({
tagLookupById, tagLookupById,
onDocumentListFocus, onDocumentListFocus,
onDocumentListKeyDown, onDocumentListKeyDown,
onFocusedRowChange,
}) => { }) => {
const showingSearchResults = searchResults !== null; const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents; const rows = showingSearchResults ? searchResults : documents;
@@ -538,10 +540,19 @@ const DocumentsTable = ({
); );
const scrollRef = useRef(null); const scrollRef = useRef(null);
const ensureFocusedRowVisible = useCallback(() => { const ensureFocusedRowVisible = useCallback(() => {
if (!focusedDocumentId) return; if (!focusedRowKey) return;
const container = scrollRef.current; const container = scrollRef.current;
if (!container) return; 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)) { if (!row || !container.contains(row)) {
return; return;
} }
@@ -562,12 +573,23 @@ const DocumentsTable = ({
const nextScrollTop = rowBottom - container.clientHeight; const nextScrollTop = rowBottom - container.clientHeight;
container.scrollTop = Math.max(nextScrollTop, 0); container.scrollTop = Math.max(nextScrollTop, 0);
} }
}, [focusedDocumentId]); }, [focusedRowKey]);
useEffect(() => { useEffect(() => {
ensureFocusedRowVisible(); ensureFocusedRowVisible();
}, [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 ( return (
<section className="documents-panel column"> <section className="documents-panel column">
<div className="column-header"> <div className="column-header">
@@ -624,9 +646,7 @@ const DocumentsTable = ({
onDocumentListKeyDown(event); onDocumentListKeyDown(event);
} }
}} }}
aria-activedescendant={ aria-activedescendant={activeDescendantId}
focusedDocumentId ? `document-row-${focusedDocumentId}` : undefined
}
> >
{!showingSearchResults && !subfolders.length && rows.length === 0 ? ( {!showingSearchResults && !subfolders.length && rows.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
@@ -651,9 +671,13 @@ const DocumentsTable = ({
return ( return (
<tr <tr
key={folder.id} key={folder.id}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}`} className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
}`}
id={`folder-row-${folder.id}`}
onClick={() => { onClick={() => {
scrollRef.current?.focus({ preventScroll: true }); scrollRef.current?.focus({ preventScroll: true });
onFocusedRowChange?.(`folder:${folder.id}`);
onFolderSelect(folder.id); onFolderSelect(folder.id);
}} }}
onDragOver={(event) => onFolderDragOver(event, folder.id)} onDragOver={(event) => onFolderDragOver(event, folder.id)}
@@ -698,7 +722,11 @@ const DocumentsTable = ({
const isSelected = selectedSet.has(doc.id); const isSelected = selectedSet.has(doc.id);
const rowClasses = ['document']; const rowClasses = ['document'];
if (isSelected) rowClasses.push('selected'); 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)) { if (draggingSet.has(doc.id)) {
rowClasses.push('dragging'); rowClasses.push('dragging');
} }
@@ -711,6 +739,7 @@ const DocumentsTable = ({
aria-selected={isSelected} aria-selected={isSelected}
onClick={(event) => { onClick={(event) => {
scrollRef.current?.focus({ preventScroll: true }); scrollRef.current?.focus({ preventScroll: true });
onFocusedRowChange?.(`document:${doc.id}`);
onDocumentRowClick(doc.id, event); onDocumentRowClick(doc.id, event);
}} }}
onDoubleClick={(event) => { onDoubleClick={(event) => {
@@ -1813,6 +1842,9 @@ const AppLayout = () => {
const [selectedDocumentIds, setSelectedDocumentIds] = useState(initialSelection); const [selectedDocumentIds, setSelectedDocumentIds] = useState(initialSelection);
const [selectionOrder, setSelectionOrder] = useState(initialSelection); const [selectionOrder, setSelectionOrder] = useState(initialSelection);
const [focusedDocumentId, setFocusedDocumentId] = useState(routeDocumentId); const [focusedDocumentId, setFocusedDocumentId] = useState(routeDocumentId);
const [focusedRowKey, setFocusedRowKey] = useState(() =>
routeDocumentId ? `document:${routeDocumentId}` : null,
);
const [documentDetails, setDocumentDetails] = useState(() => new Map()); const [documentDetails, setDocumentDetails] = useState(() => new Map());
const documentDetailsRef = useRef(documentDetails); const documentDetailsRef = useRef(documentDetails);
const tokenRef = useRef(token); const tokenRef = useRef(token);
@@ -2091,9 +2123,11 @@ const AppLayout = () => {
[focusedDocumentId, setSelectionOrder], [focusedDocumentId, setSelectionOrder],
); );
const showingSearchResults = searchResults !== null;
const visibleDocuments = useMemo( const visibleDocuments = useMemo(
() => (searchResults !== null ? searchResults : documents), () => (showingSearchResults ? searchResults : documents),
[searchResults, documents], [showingSearchResults, searchResults, documents],
); );
const visibleDocumentIds = useMemo( const visibleDocumentIds = useMemo(
@@ -2277,116 +2311,57 @@ const AppLayout = () => {
], ],
); );
const handleDocumentListFocus = useCallback(() => { const navigableRows = useMemo(() => {
if (focusedDocumentId && visibleDocumentIds.includes(focusedDocumentId)) { 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; return;
} }
prevFocusedDocIdRef.current = focusedDocumentId;
for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) { if (focusedDocumentId) {
const candidate = selectedDocumentIds[index]; setFocusedRowKey(`document:${focusedDocumentId}`);
if (visibleDocumentIds.includes(candidate)) { } else {
setFocusedDocumentId(candidate); setFocusedRowKey((current) => (current?.startsWith('folder:') ? current : null));
return;
}
} }
}, [focusedDocumentId]);
if (visibleDocumentIds.length) { useEffect(() => {
const firstId = visibleDocumentIds[0]; if (!focusedRowKey) {
applySelection([firstId], { anchor: firstId, interactedIds: [firstId] }); return;
} }
}, [ if (navigableRowKeys.includes(focusedRowKey)) {
focusedDocumentId, return;
visibleDocumentIds, }
selectedDocumentIds, const docKey = focusedDocumentId ? `document:${focusedDocumentId}` : null;
setFocusedDocumentId, if (docKey && navigableRowKeys.includes(docKey)) {
applySelection, 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( const handleDocumentDragStart = useCallback(
(event, documentId) => { (event, documentId) => {
@@ -3523,6 +3498,172 @@ const AppLayout = () => {
[ensureDocumentDetail, ensurePreviewUrl, navigate], [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( const closeDocumentPreview = useCallback(
(folderId = null) => { (folderId = null) => {
setPreviewDocumentId(null); setPreviewDocumentId(null);
@@ -4501,6 +4642,7 @@ const AppLayout = () => {
onDocumentDelete: handleDocumentDelete, onDocumentDelete: handleDocumentDelete,
selectedDocumentIds, selectedDocumentIds,
focusedDocumentId, focusedDocumentId,
focusedRowKey,
draggingDocumentIds: draggedDocumentIds, draggingDocumentIds: draggedDocumentIds,
onDocumentDragStart: handleDocumentDragStart, onDocumentDragStart: handleDocumentDragStart,
onDocumentDragEnd: handleDocumentDragEnd, onDocumentDragEnd: handleDocumentDragEnd,
@@ -4508,6 +4650,7 @@ const AppLayout = () => {
tagLookupById, tagLookupById,
onDocumentListFocus: handleDocumentListFocus, onDocumentListFocus: handleDocumentListFocus,
onDocumentListKeyDown: handleDocumentListKeyDown, onDocumentListKeyDown: handleDocumentListKeyDown,
onFocusedRowChange: setFocusedRowKey,
}; };
const detailPanelProps = { const detailPanelProps = {
+5
View File
@@ -738,6 +738,11 @@ button.icon-button.ghost:hover:not([disabled]) {
font-weight: 600; 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 { .documents-panel tbody tr.document {
cursor: pointer; cursor: pointer;
} }