feat(web): show recent thumbnails
Cache previews in OPFS and migrate existing recent entries by reading only their embedded DWG preview data.
This commit is contained in:
parent
d4e792b2bc
commit
969a7605b7
4 changed files with 264 additions and 20 deletions
|
|
@ -26,6 +26,33 @@ pub fn extract(path: &Path, max_dim: u32) -> Option<RgbaImage> {
|
||||||
Some(downscale(img, max_dim.max(1)))
|
Some(downscale(img, max_dim.max(1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract an embedded preview from an in-memory DWG. This is the browser
|
||||||
|
/// counterpart of [`extract`], where the file picker supplies bytes instead of
|
||||||
|
/// a reusable filesystem path.
|
||||||
|
pub fn extract_bytes(bytes: &[u8], max_dim: u32) -> Option<RgbaImage> {
|
||||||
|
if bytes.get(..2)? != b"AC" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base = i32::from_le_bytes(bytes.get(0x0D..0x11)?.try_into().ok()?);
|
||||||
|
if base <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base = base as u64;
|
||||||
|
let start = usize::try_from(base).ok()?;
|
||||||
|
let total = preview_container_len(bytes.get(start..start.checked_add(20)?)?)?;
|
||||||
|
let container = bytes.get(start..start.checked_add(total)?)?;
|
||||||
|
extract_container(container, base, max_dim)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a preview container read from a DWG at `file_offset`. Exposing this
|
||||||
|
/// narrow entry point lets browser callers use `Blob.slice()` to migrate old
|
||||||
|
/// recent files without copying the entire drawing into memory.
|
||||||
|
pub fn extract_container(container: &[u8], file_offset: u64, max_dim: u32) -> Option<RgbaImage> {
|
||||||
|
let (format, data) = parse_preview_container(container, file_offset)?;
|
||||||
|
let img = decode(format, data)?;
|
||||||
|
Some(downscale(img, max_dim.max(1)))
|
||||||
|
}
|
||||||
|
|
||||||
/// White "DWG" wordmark, composited (centered) onto the format band.
|
/// White "DWG" wordmark, composited (centered) onto the format band.
|
||||||
static DWG_LABEL_PNG: &[u8] = include_bytes!("../assets/dwg-label.png");
|
static DWG_LABEL_PNG: &[u8] = include_bytes!("../assets/dwg-label.png");
|
||||||
/// Format band colour — OCS brand red.
|
/// Format band colour — OCS brand red.
|
||||||
|
|
@ -119,20 +146,32 @@ fn read_preview(path: &Path) -> Option<(Fmt, Vec<u8>)> {
|
||||||
f.seek(SeekFrom::Start(base)).ok()?;
|
f.seek(SeekFrom::Start(base)).ok()?;
|
||||||
let mut head = [0u8; 20];
|
let mut head = [0u8; 20];
|
||||||
f.read_exact(&mut head).ok()?;
|
f.read_exact(&mut head).ok()?;
|
||||||
if head[..16] != PREVIEW_SENTINEL {
|
let total = preview_container_len(&head)?;
|
||||||
|
f.seek(SeekFrom::Start(base)).ok()?;
|
||||||
|
let mut buf = vec![0u8; total];
|
||||||
|
f.read_exact(&mut buf).ok()?;
|
||||||
|
let (format, data) = parse_preview_container(&buf, base)?;
|
||||||
|
Some((format, data.to_vec()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview_container_len(head: &[u8]) -> Option<usize> {
|
||||||
|
if head.get(..16)? != PREVIEW_SENTINEL {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let overall = u32::from_le_bytes([head[16], head[17], head[18], head[19]]) as usize;
|
let overall = u32::from_le_bytes(head.get(16..20)?.try_into().ok()?) as usize;
|
||||||
if overall == 0 || overall > 64 * 1024 * 1024 {
|
if overall == 0 || overall > 64 * 1024 * 1024 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Whole container = sentinel(16) + size(4) + overall + end sentinel(16).
|
// Whole container = sentinel(16) + size(4) + overall + end sentinel(16).
|
||||||
let total = 36 + overall;
|
36usize.checked_add(overall)
|
||||||
f.seek(SeekFrom::Start(base)).ok()?;
|
}
|
||||||
let mut buf = vec![0u8; total];
|
|
||||||
f.read_exact(&mut buf).ok()?;
|
|
||||||
|
|
||||||
let count = buf[20] as usize;
|
fn parse_preview_container(buf: &[u8], base: u64) -> Option<(Fmt, &[u8])> {
|
||||||
|
let total = preview_container_len(buf.get(..20)?)?;
|
||||||
|
if buf.len() < total {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let count = *buf.get(20)? as usize;
|
||||||
let mut off = 21usize;
|
let mut off = 21usize;
|
||||||
for _ in 0..count {
|
for _ in 0..count {
|
||||||
if off + 9 > buf.len() {
|
if off + 9 > buf.len() {
|
||||||
|
|
@ -155,7 +194,7 @@ fn read_preview(path: &Path) -> Option<(Fmt, Vec<u8>)> {
|
||||||
if size == 0 || end > buf.len() {
|
if size == 0 || end > buf.len() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return Some((fmt, buf[rel..end].to_vec()));
|
return Some((fmt, &buf[rel..end]));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,14 @@ impl OpenCADStudio {
|
||||||
/// the background task that decodes its thumbnail.
|
/// the background task that decodes its thumbnail.
|
||||||
pub(super) fn push_recent(&mut self, path: PathBuf) -> iced::Task<crate::app::Message> {
|
pub(super) fn push_recent(&mut self, path: PathBuf) -> iced::Task<crate::app::Message> {
|
||||||
self.recent_files.retain(|r| r != &path);
|
self.recent_files.retain(|r| r != &path);
|
||||||
|
self.recent_thumbs.remove(&path);
|
||||||
self.recent_files.insert(0, path);
|
self.recent_files.insert(0, path);
|
||||||
let evicted = self
|
let evicted = self
|
||||||
.recent_files
|
.recent_files
|
||||||
.split_off(self.recent_limit.min(self.recent_files.len()));
|
.split_off(self.recent_limit.min(self.recent_files.len()));
|
||||||
|
for path in &evicted {
|
||||||
|
self.recent_thumbs.remove(path);
|
||||||
|
}
|
||||||
remove_cached_copies(evicted);
|
remove_cached_copies(evicted);
|
||||||
self.save_config();
|
self.save_config();
|
||||||
self.refresh_recent_thumbs()
|
self.refresh_recent_thumbs()
|
||||||
|
|
@ -42,11 +46,36 @@ impl OpenCADStudio {
|
||||||
if missing.is_empty() {
|
if missing.is_empty() {
|
||||||
return iced::Task::none();
|
return iced::Task::none();
|
||||||
}
|
}
|
||||||
// The web build has no spawnable threads and no filesystem previews —
|
|
||||||
// recents there simply show without thumbnails.
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
return iced::Task::none();
|
return iced::Task::perform(
|
||||||
|
async move {
|
||||||
|
let mut thumbnails = Vec::with_capacity(missing.len());
|
||||||
|
for path in missing {
|
||||||
|
let handle = if let Some(name) = path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
{
|
||||||
|
crate::io::web_recent::read_thumbnail(&name)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|thumbnail| {
|
||||||
|
iced::widget::image::Handle::from_rgba(
|
||||||
|
thumbnail.width,
|
||||||
|
thumbnail.height,
|
||||||
|
thumbnail.rgba,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
thumbnails.push((path, handle));
|
||||||
|
}
|
||||||
|
thumbnails
|
||||||
|
},
|
||||||
|
crate::app::Message::RecentThumbsLoaded,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
|
|
@ -71,6 +100,7 @@ impl OpenCADStudio {
|
||||||
/// Drop a path from the recents list (manual removal from the Start page).
|
/// Drop a path from the recents list (manual removal from the Start page).
|
||||||
pub(super) fn remove_recent(&mut self, path: &Path) {
|
pub(super) fn remove_recent(&mut self, path: &Path) {
|
||||||
self.recent_files.retain(|r| r.as_path() != path);
|
self.recent_files.retain(|r| r.as_path() != path);
|
||||||
|
self.recent_thumbs.remove(path);
|
||||||
remove_cached_copies([path.to_path_buf()]);
|
remove_cached_copies([path.to_path_buf()]);
|
||||||
self.save_config();
|
self.save_config();
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +112,9 @@ impl OpenCADStudio {
|
||||||
let evicted = self
|
let evicted = self
|
||||||
.recent_files
|
.recent_files
|
||||||
.split_off(self.recent_limit.min(self.recent_files.len()));
|
.split_off(self.recent_limit.min(self.recent_files.len()));
|
||||||
|
for path in &evicted {
|
||||||
|
self.recent_thumbs.remove(path);
|
||||||
|
}
|
||||||
remove_cached_copies(evicted);
|
remove_cached_copies(evicted);
|
||||||
self.save_config();
|
self.save_config();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2829,6 +2829,16 @@ pub(super) fn recent_files_panel<'a>(
|
||||||
.parent()
|
.parent()
|
||||||
.map(|p| p.to_string_lossy().into_owned())
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
// Browsers intentionally do not reveal the source folder selected
|
||||||
|
// by the user. The reusable copy lives in origin-private browser
|
||||||
|
// storage, which is the truthful web equivalent of native's parent
|
||||||
|
// directory line.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let dir = if dir.is_empty() {
|
||||||
|
"Browser storage".to_string()
|
||||||
|
} else {
|
||||||
|
dir
|
||||||
|
};
|
||||||
|
|
||||||
// Leading DWG preview thumbnail (fixed box keeps rows aligned even
|
// Leading DWG preview thumbnail (fixed box keeps rows aligned even
|
||||||
// when a file has no readable preview).
|
// when a file has no readable preview).
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,41 @@ use wasm_bindgen::JsCast;
|
||||||
use wasm_bindgen_futures::JsFuture;
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
|
||||||
const RECENT_DIRECTORY: &str = "opencadstudio-recent";
|
const RECENT_DIRECTORY: &str = "opencadstudio-recent";
|
||||||
|
const THUMBNAIL_MAGIC: &[u8; 4] = b"OCST";
|
||||||
|
const THUMBNAIL_MAX_DIM: u32 = 256;
|
||||||
|
|
||||||
|
pub struct Thumbnail {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> {
|
pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||||
let directory = recent_directory(true).await?;
|
let directory = recent_directory(true).await?;
|
||||||
|
write_entry(&directory, &cache_key(name), bytes).await?;
|
||||||
|
|
||||||
|
if let Some(image) = dwg_thumbnailer::extract_bytes(bytes, THUMBNAIL_MAX_DIM) {
|
||||||
|
let thumbnail = encode_thumbnail(image);
|
||||||
|
// The drawing copy is the durable part of a recent entry. Thumbnail
|
||||||
|
// caching must not make an otherwise successful open/save fail under
|
||||||
|
// browser quota pressure.
|
||||||
|
let _ = write_entry(&directory, &thumbnail_key(name), &thumbnail).await;
|
||||||
|
} else {
|
||||||
|
// An overwrite may replace a drawing that had a preview with one that
|
||||||
|
// does not. Do not leave the old image attached to the new file.
|
||||||
|
let _ = JsFuture::from(directory.remove_entry(&thumbnail_key(name))).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_entry(
|
||||||
|
directory: &web_sys::FileSystemDirectoryHandle,
|
||||||
|
key: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<(), String> {
|
||||||
let options = web_sys::FileSystemGetFileOptions::new();
|
let options = web_sys::FileSystemGetFileOptions::new();
|
||||||
options.set_create(true);
|
options.set_create(true);
|
||||||
let handle = JsFuture::from(directory.get_file_handle_with_options(&cache_key(name), &options))
|
let handle = JsFuture::from(directory.get_file_handle_with_options(key, &options))
|
||||||
.await
|
.await
|
||||||
.map_err(js_error)?
|
.map_err(js_error)?
|
||||||
.dyn_into::<web_sys::FileSystemFileHandle>()
|
.dyn_into::<web_sys::FileSystemFileHandle>()
|
||||||
|
|
@ -31,28 +60,71 @@ pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
|
||||||
pub async fn read(name: &str) -> Result<Vec<u8>, String> {
|
pub async fn read(name: &str) -> Result<Vec<u8>, String> {
|
||||||
let directory = recent_directory(false).await?;
|
let directory = recent_directory(false).await?;
|
||||||
let handle = JsFuture::from(directory.get_file_handle(&cache_key(name)))
|
read_entry(&directory, &cache_key(name)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_entry(
|
||||||
|
directory: &web_sys::FileSystemDirectoryHandle,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
let file = get_file(directory, key).await?;
|
||||||
|
read_blob(file.as_ref()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_file(
|
||||||
|
directory: &web_sys::FileSystemDirectoryHandle,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<web_sys::File, String> {
|
||||||
|
let handle = JsFuture::from(directory.get_file_handle(key))
|
||||||
.await
|
.await
|
||||||
.map_err(js_error)?
|
.map_err(js_error)?
|
||||||
.dyn_into::<web_sys::FileSystemFileHandle>()
|
.dyn_into::<web_sys::FileSystemFileHandle>()
|
||||||
.map_err(js_error)?;
|
.map_err(js_error)?;
|
||||||
let file = JsFuture::from(handle.get_file())
|
JsFuture::from(handle.get_file())
|
||||||
.await
|
.await
|
||||||
.map_err(js_error)?
|
.map_err(js_error)?
|
||||||
.dyn_into::<web_sys::File>()
|
.dyn_into::<web_sys::File>()
|
||||||
.map_err(js_error)?;
|
.map_err(js_error)
|
||||||
let buffer = JsFuture::from(file.array_buffer())
|
}
|
||||||
|
|
||||||
|
async fn read_blob(blob: &web_sys::Blob) -> Result<Vec<u8>, String> {
|
||||||
|
let buffer = JsFuture::from(blob.array_buffer())
|
||||||
.await
|
.await
|
||||||
.map_err(js_error)?;
|
.map_err(js_error)?;
|
||||||
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load a cached preview. Records created before thumbnail sidecars existed are
|
||||||
|
/// migrated by slicing only the DWG header and preview container from the OPFS
|
||||||
|
/// file; the potentially large drawing body is never copied or parsed.
|
||||||
|
pub async fn read_thumbnail(name: &str) -> Result<Option<Thumbnail>, String> {
|
||||||
|
let directory = recent_directory(false).await?;
|
||||||
|
if let Ok(bytes) = read_entry(&directory, &thumbnail_key(name)).await {
|
||||||
|
if let Some(thumbnail) = decode_thumbnail(bytes) {
|
||||||
|
return Ok(Some(thumbnail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = get_file(&directory, &cache_key(name)).await?;
|
||||||
|
let Some(image) = extract_embedded_thumbnail(&file).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let encoded = encode_thumbnail(image);
|
||||||
|
let thumbnail = decode_thumbnail(encoded.clone())
|
||||||
|
.ok_or_else(|| "generated recent thumbnail is invalid".to_string())?;
|
||||||
|
// Migration caching is best-effort: a readable drawing should still show
|
||||||
|
// its preview even if quota pressure prevents writing the sidecar.
|
||||||
|
let _ = write_entry(&directory, &thumbnail_key(name), &encoded).await;
|
||||||
|
Ok(Some(thumbnail))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn remove(name: &str) -> Result<(), String> {
|
pub async fn remove(name: &str) -> Result<(), String> {
|
||||||
let directory = recent_directory(false).await?;
|
let directory = recent_directory(false).await?;
|
||||||
JsFuture::from(directory.remove_entry(&cache_key(name)))
|
let drawing = JsFuture::from(directory.remove_entry(&cache_key(name)))
|
||||||
.await
|
.await
|
||||||
.map_err(js_error)?;
|
.map_err(js_error);
|
||||||
Ok(())
|
let _ = JsFuture::from(directory.remove_entry(&thumbnail_key(name))).await;
|
||||||
|
drawing.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recent_directory(create: bool) -> Result<web_sys::FileSystemDirectoryHandle, String> {
|
async fn recent_directory(create: bool) -> Result<web_sys::FileSystemDirectoryHandle, String> {
|
||||||
|
|
@ -74,12 +146,102 @@ async fn recent_directory(create: bool) -> Result<web_sys::FileSystemDirectoryHa
|
||||||
/// Stable, short OPFS entry name. The original display name remains in
|
/// Stable, short OPFS entry name. The original display name remains in
|
||||||
/// `AppConfig::recent`; only the browser-private cache uses this key.
|
/// `AppConfig::recent`; only the browser-private cache uses this key.
|
||||||
fn cache_key(name: &str) -> String {
|
fn cache_key(name: &str) -> String {
|
||||||
|
format!("{}.cad", name_hash(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn thumbnail_key(name: &str) -> String {
|
||||||
|
format!("{}.thumb", name_hash(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name_hash(name: &str) -> String {
|
||||||
let mut hash = 0xcbf29ce484222325_u64;
|
let mut hash = 0xcbf29ce484222325_u64;
|
||||||
for byte in name.as_bytes() {
|
for byte in name.as_bytes() {
|
||||||
hash ^= u64::from(*byte);
|
hash ^= u64::from(*byte);
|
||||||
hash = hash.wrapping_mul(0x100000001b3);
|
hash = hash.wrapping_mul(0x100000001b3);
|
||||||
}
|
}
|
||||||
format!("{hash:016x}.cad")
|
format!("{hash:016x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_thumbnail(image: dwg_thumbnailer::RgbaImage) -> Vec<u8> {
|
||||||
|
let mut bytes = Vec::with_capacity(12 + image.as_raw().len());
|
||||||
|
bytes.extend_from_slice(THUMBNAIL_MAGIC);
|
||||||
|
bytes.extend_from_slice(&image.width().to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&image.height().to_le_bytes());
|
||||||
|
bytes.extend_from_slice(image.as_raw());
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_thumbnail(bytes: Vec<u8>) -> Option<Thumbnail> {
|
||||||
|
if bytes.get(..4)? != THUMBNAIL_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let width = u32::from_le_bytes(bytes.get(4..8)?.try_into().ok()?);
|
||||||
|
let height = u32::from_le_bytes(bytes.get(8..12)?.try_into().ok()?);
|
||||||
|
let expected = usize::try_from(width)
|
||||||
|
.ok()?
|
||||||
|
.checked_mul(usize::try_from(height).ok()?)?
|
||||||
|
.checked_mul(4)?;
|
||||||
|
if width == 0
|
||||||
|
|| height == 0
|
||||||
|
|| width > THUMBNAIL_MAX_DIM
|
||||||
|
|| height > THUMBNAIL_MAX_DIM
|
||||||
|
|| bytes.len() != 12 + expected
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Thumbnail {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgba: bytes[12..].to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn extract_embedded_thumbnail(
|
||||||
|
file: &web_sys::File,
|
||||||
|
) -> Result<Option<dwg_thumbnailer::RgbaImage>, String> {
|
||||||
|
let header = read_file_range(file, 0, 0x11).await?;
|
||||||
|
if header.get(..2) != Some(b"AC") {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(offset) = header
|
||||||
|
.get(0x0D..0x11)
|
||||||
|
.and_then(|bytes| bytes.try_into().ok())
|
||||||
|
.map(i32::from_le_bytes)
|
||||||
|
.filter(|offset| *offset > 0)
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let offset = offset as u64;
|
||||||
|
let container_header = read_file_range(file, offset, offset + 20).await?;
|
||||||
|
let Some(overall) = container_header
|
||||||
|
.get(16..20)
|
||||||
|
.and_then(|bytes| bytes.try_into().ok())
|
||||||
|
.map(u32::from_le_bytes)
|
||||||
|
.map(u64::from)
|
||||||
|
.filter(|size| *size > 0 && *size <= 64 * 1024 * 1024)
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(end) = offset
|
||||||
|
.checked_add(36)
|
||||||
|
.and_then(|end| end.checked_add(overall))
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let container = read_file_range(file, offset, end).await?;
|
||||||
|
Ok(dwg_thumbnailer::extract_container(
|
||||||
|
&container,
|
||||||
|
offset,
|
||||||
|
THUMBNAIL_MAX_DIM,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_file_range(file: &web_sys::File, start: u64, end: u64) -> Result<Vec<u8>, String> {
|
||||||
|
let blob: &web_sys::Blob = file.as_ref();
|
||||||
|
let slice = blob
|
||||||
|
.slice_with_f64_and_f64(start as f64, end as f64)
|
||||||
|
.map_err(js_error)?;
|
||||||
|
read_blob(&slice).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn js_error(value: wasm_bindgen::JsValue) -> String {
|
fn js_error(value: wasm_bindgen::JsValue) -> String {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue