backend: generate high resolution preview
This commit is contained in:
@@ -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 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
|
let url = state
|
||||||
.storage
|
.storage
|
||||||
.presign_get_object(
|
.presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS))
|
||||||
&asset.s3_key,
|
|
||||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?;
|
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?;
|
||||||
responses.insert(doc_id, to_asset_response(asset, url));
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
let (width, height) = image.dimensions();
|
fn generate_pdf_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
|
||||||
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)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<i32>), 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,33 +362,51 @@ 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:?}"))?;
|
||||||
|
|
||||||
|
for asset in assets {
|
||||||
let new_asset = NewDocumentAsset {
|
let new_asset = NewDocumentAsset {
|
||||||
id: asset_id,
|
id: asset.asset_id,
|
||||||
document_version_id: context.version.id,
|
document_version_id: context.version.id,
|
||||||
asset_type: THUMBNAIL_ASSET_TYPE.to_string(),
|
asset_type: asset.asset_type.to_string(),
|
||||||
s3_key: s3_key.to_string(),
|
s3_key: asset.s3_key.to_string(),
|
||||||
mime_type: "image/png".to_string(),
|
mime_type: "image/png".to_string(),
|
||||||
width: generated.width,
|
width: asset.generated.width,
|
||||||
height: generated.height,
|
height: asset.generated.height,
|
||||||
metadata: json!({
|
metadata: json!({
|
||||||
"generated_at": Utc::now().to_rfc3339(),
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
}),
|
}),
|
||||||
@@ -327,6 +428,7 @@ fn persist_thumbnail_metadata(
|
|||||||
))
|
))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+260
-117
@@ -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,117 +2311,58 @@ const AppLayout = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentListFocus = useCallback(() => {
|
const navigableRows = useMemo(() => {
|
||||||
if (focusedDocumentId && visibleDocumentIds.includes(focusedDocumentId)) {
|
const entries = [];
|
||||||
return;
|
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]);
|
||||||
|
|
||||||
for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) {
|
const navigableRowKeys = useMemo(
|
||||||
const candidate = selectedDocumentIds[index];
|
() => navigableRows.map((entry) => entry.key),
|
||||||
if (visibleDocumentIds.includes(candidate)) {
|
[navigableRows],
|
||||||
setFocusedDocumentId(candidate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleDocumentIds.length) {
|
|
||||||
const firstId = visibleDocumentIds[0];
|
|
||||||
applySelection([firstId], { anchor: firstId, interactedIds: [firstId] });
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
focusedDocumentId,
|
|
||||||
visibleDocumentIds,
|
|
||||||
selectedDocumentIds,
|
|
||||||
setFocusedDocumentId,
|
|
||||||
applySelection,
|
|
||||||
]);
|
|
||||||
|
|
||||||
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 prevFocusedDocIdRef = useRef(focusedDocumentId);
|
||||||
|
useEffect(() => {
|
||||||
|
const previous = prevFocusedDocIdRef.current;
|
||||||
|
if (previous === focusedDocumentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevFocusedDocIdRef.current = focusedDocumentId;
|
||||||
|
if (focusedDocumentId) {
|
||||||
|
setFocusedRowKey(`document:${focusedDocumentId}`);
|
||||||
|
} else {
|
||||||
|
setFocusedRowKey((current) => (current?.startsWith('folder:') ? current : null));
|
||||||
|
}
|
||||||
|
}, [focusedDocumentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusedRowKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 handleDocumentDragStart = useCallback(
|
const handleDocumentDragStart = useCallback(
|
||||||
(event, documentId) => {
|
(event, documentId) => {
|
||||||
const selection = selectedDocumentIds.includes(documentId)
|
const selection = selectedDocumentIds.includes(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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user