diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index bbb5bee4dd3388c51b064fdb71c50803aebe0e55..dc8765a15759371842e12de4fb13ba66c3cfc721 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -2503,6 +2503,9 @@ impl CanvasEditor { engine: &mut TextEngine, mut file: onestore::page::Attachment, ) -> Result<(), EditorError> { + if self.active_outline().title && self.leave_title_for_body(engine)? { + return self.insert_in_flow(engine, ParagraphContent::Attachment(file)); + } if let Focus::Caret { outline, .. } = &self.active { [file.layout.x, file.layout.y] = outline.origin().map(Some); let layout = Box::new(crate::outline::page_file(engine, &file)?); diff --git a/crates/canvas/src/search.rs b/crates/canvas/src/search.rs index 8a127fab4a9ea754dfd34c6ea2463e90b1e13c0e..0a8a22d0f9f84e590ecabb2fb5b0d1db2a37cc4a 100644 --- a/crates/canvas/src/search.rs +++ b/crates/canvas/src/search.rs @@ -516,11 +516,7 @@ pub fn page_matches(editor: &CanvasEditor, query: &Query) -> Vec { start = span.end; } for hit in query.find(&shown) { - let end = source[hit.end - 1] - + paragraph.text()[source[hit.end - 1]..] - .chars() - .next() - .map_or(0, char::len_utf8); + let end = source[hit.end - 1] + 1; let (Ok(from), Ok(to)) = ( paragraph.utf16_offset(source[hit.start]), paragraph.utf16_offset(end), diff --git a/crates/canvas/tests/attachment_insert.rs b/crates/canvas/tests/attachment_insert.rs index 9a2c767028c8233f9cf892d7110b538c3b78eb94..08cea2564eeffba619861e6655a2e1a0a7e23a65 100644 --- a/crates/canvas/tests/attachment_insert.rs +++ b/crates/canvas/tests/attachment_insert.rs @@ -7,8 +7,8 @@ use canvas::{editor::CanvasEditor, layout::TextEngine}; use draw::edit::Movement; use onestore::{ - Arena, Section, Store, - op::{Edit, Op}, + Arena, PageCreation, Section, Store, + op::{Edit, Op, SectionOp}, page::{Attachment, PageObject, ParagraphContent}, }; use std::sync::Arc; @@ -62,6 +62,80 @@ fn file(name: &str, bytes: Vec, preview: Option>) -> Attachment { } } +#[test] +fn a_file_attached_from_the_title_stores_in_the_body() { + let mut image = onestore::create_section("files.one", "First page", "Author").unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, image.clone()).unwrap(); + section + .apply( + "Author", + &Edit { + at: 133_000_000_000_000_000, + ops: vec![Op::Section(SectionOp::Create( + PageCreation::new(None, Some("Attachment from title"), "Author").unwrap(), + ))], + }, + ) + .unwrap(); + let (space, ..) = section.pages().unwrap()[1].clone(); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); + let title = editor + .outlines() + .iter() + .find(|outline| outline.title) + .unwrap() + .id; + editor.focus_outline(title).unwrap(); + let attached = file( + "attachment.txt", + b"Snowbound bounded import fixture\n".to_vec(), + Some(native_icon()), + ); + editor + .insert_attachment(&mut engine, attached.clone()) + .unwrap(); + assert!(!editor.active_outline().title); + let ops = editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect(); + section + .apply( + "Author", + &Edit { + at: 133_000_000_010_000_000, + ops, + }, + ) + .unwrap(); + section.seal().unwrap().unwrap().apply(&mut image).unwrap(); + let arena = Arena::default(); + let mut reopened = Section::open(&arena, image.clone()).unwrap(); + assert_eq!(reopened.pages().unwrap()[1].1, "Attachment from title"); + let page = reopened.page(space).unwrap(); + let files: Vec<_> = page + .objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(&outline.paragraphs), + _ => None, + }) + .flatten() + .filter_map(|paragraph| match ¶graph.content { + ParagraphContent::Attachment(file) => Some(file), + _ => None, + }) + .collect(); + assert_eq!(files, [&attached]); + if let Some(path) = std::env::var_os("CANVAS_TITLE_ATTACHMENT_EXPORT") { + std::fs::write(path, image).unwrap(); + } +} + #[test] fn attached_files_store_their_bytes_between_the_text_around_them() { let source = onestore::create_section("files.one", "Before the file", "Author").unwrap(); diff --git a/crates/canvas/tests/search.rs b/crates/canvas/tests/search.rs index 50fc681d4303a44f1f40108bbd4d0195e1a5b115..5b91e0a3d1d35a99d4d70261dd1e1e43516dc2ec 100644 --- a/crates/canvas/tests/search.rs +++ b/crates/canvas/tests/search.rs @@ -135,6 +135,27 @@ fn find_on_page_counts_what_onenote_counts() { assert!(page_matches(&editor, &Query::new("zzq")).is_empty()); } +#[test] +fn find_on_page_keeps_unicode_matches_on_character_boundaries() { + let mut engine = TextEngine::default(); + let page = page(&mut engine, "Unicode", "雪 ☃ 🦀 café"); + let editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + for (query, offsets) in [ + ("雪", [0, 1]), + ("☃", [2, 3]), + ("🦀", [4, 6]), + ("cafe", [7, 11]), + ] { + let matches = page_matches(&editor, &Query::new(query)); + assert_eq!(matches.len(), 1, "{query}"); + assert_eq!( + matches[0].1.positions.map(|at| at.offset), + offsets, + "{query}" + ); + } +} + #[test] fn folding_ignores_case_and_diacritics() { assert_eq!(fold("Crème BRÛLÉE"), "creme brulee"); diff --git a/crates/notebook/src/bin/onestore-diagnostic.rs b/crates/notebook/src/bin/onestore-diagnostic.rs index 23bb70ca1889732c6fe646a18877f34396fb705b..891f013a9a0766c15772121f08504f5ba8816be0 100644 --- a/crates/notebook/src/bin/onestore-diagnostic.rs +++ b/crates/notebook/src/bin/onestore-diagnostic.rs @@ -83,7 +83,7 @@ fn run(args: &[std::ffi::OsString]) -> Result> &mut notebook::discover::Local::open(root)?, notebook::discover::Limits { entries: 100_000, - bytes_per_file: 256 * 1024 * 1024, + bytes_per_file: notebook::MAX_FILE_BYTES, depth: 64, }, )?; diff --git a/crates/notebook/src/fs.rs b/crates/notebook/src/fs.rs index 8ec0a8e5491aeb10063c1a1916898ef93f7103fc..e8917c598c304acc66767b9dfc8bcffeeb2d2425 100644 --- a/crates/notebook/src/fs.rs +++ b/crates/notebook/src/fs.rs @@ -20,7 +20,52 @@ mod web; #[cfg(target_arch = "wasm32")] pub use web::*; +/// Reads a regular file within `limit`, including when it grows during the read. +pub fn read_limited(path: impl AsRef, limit: usize) -> std::io::Result> { + use std::io::Read; + let file = File::open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(std::io::ErrorKind::InvalidInput.into()); + } + if metadata.len() > limit as u64 { + return Err(std::io::ErrorKind::FileTooLarge.into()); + } + let mut bytes = Vec::new(); + file.take((limit as u64).saturating_add(1)) + .read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(std::io::ErrorKind::FileTooLarge.into()); + } + Ok(bytes) +} + #[cfg(not(target_arch = "wasm32"))] pub async fn durable() -> std::io::Result<()> { Ok(()) } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + #[test] + fn bounded_reads_refuse_an_eight_gib_file() { + let file = tempfile::NamedTempFile::new().unwrap(); + write(file.path(), b"hello").unwrap(); + assert_eq!(read_limited(file.path(), 5).unwrap(), b"hello"); + assert_eq!( + read_limited(file.path(), 4).unwrap_err().kind(), + std::io::ErrorKind::FileTooLarge + ); + file.as_file().set_len(8 << 30).unwrap(); + assert_eq!( + read_limited(file.path(), crate::MAX_FILE_BYTES) + .unwrap_err() + .kind(), + std::io::ErrorKind::FileTooLarge + ); + let folder = tempfile::tempdir().unwrap(); + assert!(read_limited(folder.path(), crate::MAX_FILE_BYTES).is_err()); + } +} diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index 9410e8cc99f3563e6fd50bf6c7c278c2a9c7ba26..d2de81f25a5e92a2451481aefa3a0b93f16e8190 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -49,6 +49,9 @@ mod working; pub use smb::SmbRemote; pub use worker::SyncWorker; +/// The largest file a notebook session or Live Share reads or writes whole. +pub const MAX_FILE_BYTES: usize = 256 << 20; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] diff --git a/crates/notebook/src/live/share.rs b/crates/notebook/src/live/share.rs index 84ac703c5f098247a4afdcb2f9a102be78dd49df..ca8a440d2aacb6f042c434d90c0384914ce817d0 100644 --- a/crates/notebook/src/live/share.rs +++ b/crates/notebook/src/live/share.rs @@ -42,8 +42,7 @@ const CHUNK: usize = 128 << 10; const WINDOW: usize = 512 << 10; /// How long a request waits for its reply. const TIMEOUT: Duration = Duration::from_secs(60); -/// The largest file read or written whole. -const LIMIT: usize = 256 << 20; +use crate::MAX_FILE_BYTES as LIMIT; /// Snapshots of files being read, per guest, and how long one is kept unread. const SNAPSHOTS: usize = 8; const SNAPSHOT_AGE: Duration = Duration::from_secs(120); @@ -501,14 +500,28 @@ impl Served { let handle = request.handle.unwrap_or_default(); let bytes = request.bytes.unwrap_or_default(); let mut puts = self.puts.lock().unwrap(); - let held = puts.entry((*peer, handle)).or_default(); - if request.offset != Some(held.len() as u64) || held.len() + bytes.len() > LIMIT { + let key = (*peer, handle); + let length = puts.get(&key).map_or(0, Vec::len); + if request.offset != Some(length as u64) || bytes.is_empty() { + puts.remove(&key); return Err(refused( io::ErrorKind::InvalidInput, "An upload out of order", )); } - held.extend_from_slice(&bytes); + let held: usize = puts + .iter() + .filter(|((guest, _), _)| guest == peer) + .map(|(_, bytes)| bytes.len()) + .sum(); + if bytes.len() > LIMIT.saturating_sub(held) { + puts.remove(&key); + return Err(refused( + io::ErrorKind::FileTooLarge, + "An upload is too large", + )); + } + puts.entry(key).or_default().extend_from_slice(&bytes); done } kind::COMMIT => { @@ -1228,6 +1241,9 @@ impl Guest { request: &mut Request, bytes: Vec, ) -> std::result::Result<(), Failed> { + if bytes.len() > LIMIT { + return Err(Failed::Unsent(io::ErrorKind::FileTooLarge.into())); + } if bytes.len() <= CHUNK { request.bytes = Some(bytes); return Ok(()); @@ -1436,16 +1452,19 @@ impl Guest { edits: &[crate::PendingEdit], revisions: &BTreeMap, ) -> std::result::Result<(), CommitError> { - let bytes = serde_json::to_vec(&batch::Edits { - edits: edits - .iter() - .map(|edit| (edit.author.clone(), edit.edit.clone())) - .collect(), - revisions: revisions.clone(), - }) + let bytes = batch::encode( + &batch::Edits { + edits: edits + .iter() + .map(|edit| (edit.author.clone(), edit.edit.clone())) + .collect(), + revisions: revisions.clone(), + }, + LIMIT, + ) .map_err(|error| CommitError { state: CommitState::NotCommitted, - error: io::Error::other(error), + error, })?; let mut request = Request { path: path.to_owned(), @@ -1877,6 +1896,9 @@ impl Storage for Hosted { } fn create(&self, path: &str, bytes: &[u8]) -> Result<()> { + if bytes.len() > LIMIT { + return Err(io::Error::from(io::ErrorKind::FileTooLarge).into()); + } let mut request = Request { path: path.to_owned(), ..Request::default() @@ -1954,6 +1976,60 @@ impl Storage for Hosted { mod tests { use super::*; + #[test] + fn refused_uploads_release_their_bytes_and_share_one_guest_budget() { + let directory = tempfile::tempdir().unwrap(); + std::fs::create_dir(directory.path().join("notebook")).unwrap(); + let served = Arc::new(Served { + storage: crate::session::Notebook::open( + directory.path().join("notebook"), + directory.path().join("cache"), + ) + .unwrap() + .into_storage(), + images: Mutex::default(), + snapshots: Mutex::default(), + puts: Mutex::default(), + guests: Mutex::default(), + writers: Mutex::default(), + host: Mutex::default(), + room: Mutex::default(), + }); + let peer = [1; 16]; + let put = |handle, offset, bytes| { + served.answer( + &peer, + kind::PUT, + Request { + handle: Some(handle), + offset: Some(offset), + bytes: Some(bytes), + ..Request::default() + }, + ) + }; + put(1, 0, vec![1; 3]).unwrap(); + put(2, 0, vec![2; 2]).unwrap(); + assert!(put(1, 2, vec![3]).is_err()); + assert_eq!( + served.puts.lock().unwrap().get(&(peer, 2)).unwrap(), + &[2; 2] + ); + assert!(!served.puts.lock().unwrap().contains_key(&(peer, 1))); + put(1, 0, vec![4]).unwrap(); + assert!(put(1, 1, Vec::new()).is_err()); + served + .puts + .lock() + .unwrap() + .insert((peer, 3), vec![0; LIMIT - 2]); + assert!(put(2, 2, vec![5]).is_err()); + assert!(!served.puts.lock().unwrap().contains_key(&(peer, 2))); + assert!(put(3, (LIMIT - 2) as u64, vec![6; 3]).is_err()); + assert!(served.puts.lock().unwrap().is_empty()); + put(4, 0, vec![7]).unwrap(); + } + /// A commit's writes, found by comparing images, rebuild the image after it from the one /// before, and a change touching most of the file is left to be read whole. #[test] diff --git a/crates/notebook/src/live/share/batch.rs b/crates/notebook/src/live/share/batch.rs index cc60919c8661d6a4b51febcd903288c74485f031..9304ac807248712fe0a6bf0f0d0468f485c7ed72 100644 --- a/crates/notebook/src/live/share/batch.rs +++ b/crates/notebook/src/live/share/batch.rs @@ -10,6 +10,37 @@ pub(super) struct Edits { pub revisions: BTreeMap, } +pub(super) fn encode(edits: &Edits, limit: usize) -> io::Result> { + struct Buffer { + bytes: Vec, + limit: usize, + } + impl io::Write for Buffer { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.len() > self.limit.saturating_sub(self.bytes.len()) { + return Err(io::ErrorKind::FileTooLarge.into()); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let mut buffer = Buffer { + bytes: Vec::new(), + limit, + }; + serde_json::to_writer(&mut buffer, edits).map_err(|error| { + io::Error::new( + error.io_error_kind().unwrap_or(io::ErrorKind::InvalidData), + error, + ) + })?; + Ok(buffer.bytes) +} + pub(super) struct Waiting { peer: [u8; 16], request: Request, @@ -262,3 +293,62 @@ impl Served { Ok(transaction) } } + +#[cfg(test)] +mod tests { + use super::*; + use onestore::{ + op::{Op, PageOp}, + page::{Attachment, PageObject}, + }; + + #[test] + fn attachment_encoding_stops_at_the_upload_budget() { + let id = ExGuid { + guid: [1; 16], + n: 1, + }; + let bytes: Arc<[u8]> = (0..4096) + .map(|at| (at % 251) as u8) + .collect::>() + .into(); + let edits = Edits { + edits: vec![( + "Ada".into(), + Edit { + at: 0, + ops: vec![Op::Page { + space: id, + op: PageOp::Add { + object: PageObject::Attachment(Attachment { + id, + filename: "payload.bin".into(), + source_path: None, + size: None, + layout: Default::default(), + bytes: Some(Arc::clone(&bytes)), + preview: None, + recording: None, + tags: Vec::new(), + }), + before: None, + }, + }], + }, + )], + revisions: BTreeMap::new(), + }; + let encoded = serde_json::to_vec(&edits).unwrap(); + assert_eq!(encode(&edits, encoded.len()).unwrap(), encoded); + let decoded: Edits = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(decoded.edits, edits.edits); + assert_eq!( + encode(&edits, encoded.len() - 1).unwrap_err().kind(), + io::ErrorKind::FileTooLarge + ); + assert_eq!( + encode(&edits, 128).unwrap_err().kind(), + io::ErrorKind::FileTooLarge + ); + } +} diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index b3dab38d7798a51b8a879988ab11ea7f1d8be8ba..84d642abec9b0bc0423898bbd8f7b65671f3b4a7 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -326,7 +326,7 @@ impl Storage for Share { fn read(&self, path: &str) -> Result> { Ok(self .client - .read_storage(&self.path(path), 256 * 1024 * 1024)?) + .read_storage(&self.path(path), crate::MAX_FILE_BYTES)?) } fn read_file(&self, path: &str, limit: usize) -> Result> { @@ -403,7 +403,7 @@ impl Storage for Share { pub(crate) const LIMITS: discover::Limits = discover::Limits { entries: 100_000, - bytes_per_file: 256 * 1024 * 1024, + bytes_per_file: crate::MAX_FILE_BYTES, depth: 64, }; diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index f8e0afa3d96195589ddda9de739ac154280c6316..4da1984b25821903ab18e4fa7d0e25e7a3e95ae3 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -22,17 +22,23 @@ pub use math::Math; /// Picture and attachment payloads travel with the model (queued intents replay them), /// as base64 text. mod payload { - use base64::Engine; + use base64::{Engine, display::Base64Display, engine::general_purpose::STANDARD}; use std::sync::Arc; + struct Encoded<'a>(&'a [u8]); + + impl serde::Serialize for Encoded<'_> { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(&Base64Display::new(self.0, &STANDARD)) + } + } + pub fn serialize( bytes: &Option>, serializer: S, ) -> Result { match bytes { - Some(bytes) => { - serializer.serialize_some(&base64::engine::general_purpose::STANDARD.encode(bytes)) - } + Some(bytes) => serializer.serialize_some(&Encoded(bytes)), None => serializer.serialize_none(), } } @@ -42,7 +48,7 @@ mod payload { ) -> Result>, D::Error> { let text: Option = serde::Deserialize::deserialize(deserializer)?; text.map(|text| { - base64::engine::general_purpose::STANDARD + STANDARD .decode(text) .map(Arc::from) .map_err(serde::de::Error::custom) diff --git a/crates/snowbound/src/attachment.rs b/crates/snowbound/src/attachment.rs index 91fdbe66229601fbc0b12b8d5bb177cb70646bcb..e0cab49b677159c00c2c96f6ab653794dccd1088 100644 --- a/crates/snowbound/src/attachment.rs +++ b/crates/snowbound/src/attachment.rs @@ -2,44 +2,76 @@ use crate::{State, platform}; use onestore::page::Attachment; -use std::{error::Error, path::Path}; +use std::{ + error::Error, + path::{Path, PathBuf}, +}; impl State { - /// Attach File, or a file dropped at `at`, a window point: a copy of the file's bytes - /// at the caret or at `at`, with the icon the system shows for it; audio and video are - /// recordings, as OneNote attaches them. - pub(crate) fn attach( - &mut self, - path: &Path, - at: Option<[f32; 2]>, - ) -> Result<(), Box> { + /// Inserts a file at the caret or window point `at`, as a picture when requested. + pub(crate) fn import_file(&mut self, path: &Path, at: Option<[f32; 2]>, picture: bool) { if self.session.as_ref().is_some_and(crate::Session::read_only) { - return Ok(()); + return; } - let (Some(name), Ok(bytes)) = ( - path.file_name().and_then(|name| name.to_str()), - notebook::fs::read(path), - ) else { - platform::alert("Couldn't attach the file", "Choose a file you can open."); - return Ok(()); - }; - let file = Attachment { - id: onestore::page::text::new_id()?, - filename: name.to_owned(), - source_path: path.to_str().map(str::to_owned), - size: Some(canvas::gpu::page::ICON_SIZE), - layout: Default::default(), - preview: platform::file_icon(path).map(Into::into), - recording: canvas::recording::attached(name, &bytes), - bytes: Some(bytes.into()), - tags: Vec::new(), - }; - let response = match at { - Some(point) => self.view.drop_attachment(self.page_point(point), file)?, - None => self.view.insert_attachment(file)?, - }; - self.respond(response); - Ok(()) + let loading = self.loading; + let reply = self.reply( + move |state, (path, read): (PathBuf, std::io::Result>)| { + let bytes = match read { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::FileTooLarge => { + too_large(); + return Ok(()); + } + Err(_) => { + platform::alert("Couldn't insert the file", "Choose a file you can open."); + return Ok(()); + } + }; + if state.loading != loading { + platform::alert( + "File wasn't inserted", + "Return to the page and insert the file again.", + ); + return Ok(()); + } + if state + .session + .as_ref() + .is_some_and(crate::Session::read_only) + { + return Ok(()); + } + if picture && draw::RasterImage::measure(&bytes).is_ok() { + return state.insert_picture(bytes, at); + } + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or("No file name")?; + let file = Attachment { + id: onestore::page::text::new_id()?, + filename: name.to_owned(), + source_path: path.to_str().map(str::to_owned), + size: Some(canvas::gpu::page::ICON_SIZE), + layout: Default::default(), + preview: platform::file_icon(&path).map(Into::into), + recording: canvas::recording::attached(name, &bytes), + bytes: Some(bytes.into()), + tags: Vec::new(), + }; + let response = match at { + Some(point) => state.view.drop_attachment(state.page_point(point), file)?, + None => state.view.insert_attachment(file)?, + }; + state.respond(response); + Ok(()) + }, + ); + let path = path.to_owned(); + crate::spawn(move || { + let bytes = notebook::fs::read_limited(&path, notebook::MAX_FILE_BYTES); + reply.send((path, bytes)); + }); } /// Open: a recording plays; another file opens as a copy, in a folder of its own, with @@ -89,6 +121,16 @@ impl State { } } +pub(crate) fn too_large() { + platform::alert( + "File is too large", + &format!( + "Choose files totaling at most {} MiB.", + notebook::MAX_FILE_BYTES >> 20 + ), + ); +} + /// A file another program stored beside the section rather than in it. fn unstored() { platform::alert( diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index d4c64ba29cef5b8cc217262f4632412aca435b90..9820d4165e07409d5efc80c1b8a88bfa7d6768b0 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -1247,7 +1247,7 @@ impl State { Id::Undo | Id::Redo => enabled(writable && !field && self.can_step(id == Id::Redo)), Id::Cut => enabled(writable && selected), Id::Copy => enabled(selected), - Id::Paste => enabled(text && !field), + Id::Paste => enabled(field || text), Id::SelectAll => enabled(field || page), Id::Back | Id::Forward => { enabled(!modal && self.can_travel()[usize::from(id == Id::Forward)]) @@ -1573,6 +1573,17 @@ impl State { self.respond(response); return Ok(()); } + Id::Paste if field.is_some() => { + let text = match &mut self.clipboard { + crate::Clipboard::System(clipboard) => clipboard.get_text().ok(), + crate::Clipboard::Memory(text, _) => Some(text.clone()), + }; + if let Some(text) = text { + self.ui + .event(ui::Event::Ime(winit::event::Ime::Commit(text))); + } + return Ok(()); + } Id::Paste => Work::Page(Request::Paste), Id::FormatPainter => { self.painter = match self.painter { @@ -1659,7 +1670,10 @@ impl State { return Ok(()); } Id::Attachment => { - let reply = self.reply(|state, path: std::path::PathBuf| state.attach(&path, None)); + let reply = self.reply(|state, path: std::path::PathBuf| { + state.import_file(&path, None, false); + Ok(()) + }); platform::pick_file("Attach File", &[], reply); return Ok(()); } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 87bc10c0794990d39fbf11737f11f5d47a5693bf..333af46c7e9b037c3a22cdb9a7e4ef2d8c24ef97 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -6630,7 +6630,7 @@ impl ApplicationHandler for App { WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)), WindowEvent::DroppedFile(path) => { let at = platform::drop_point(&state.window); - state.place_file(&path, at)?; + state.import_file(&path, at, true); state.window.request_redraw(); } WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?, diff --git a/crates/snowbound/src/paste.rs b/crates/snowbound/src/paste.rs index 990c9a751170cac6a3b0c1f8e4534b52b3dd090c..c6768f1151f12bc97db0fa63e4727dc5da304b17 100644 --- a/crates/snowbound/src/paste.rs +++ b/crates/snowbound/src/paste.rs @@ -3,10 +3,7 @@ use crate::{State, platform}; use canvas::editor::{Clip, Piece}; -use std::{ - error::Error, - path::{Path, PathBuf}, -}; +use std::{error::Error, path::PathBuf}; /// Copy offers text and HTML, or a picture as PNG, beside Snowbound's lossless clip. pub(crate) struct Copied { @@ -123,7 +120,7 @@ impl State { match self.clipboard.pasted() { Some(Pasted::Files(paths)) => { for path in paths { - self.place_file(&path, None)?; + self.import_file(&path, None, true); } } Some(Pasted::Clip(clip)) => { @@ -180,21 +177,6 @@ impl State { Ok(()) } - /// A pasted file, or one dropped at `at`, a window point: a picture file as its picture, - /// as OneNote 2010 pastes one (lab, 2026-09-30), and any other file attached. - pub(crate) fn place_file( - &mut self, - path: &Path, - at: Option<[f32; 2]>, - ) -> Result<(), Box> { - match notebook::fs::read(path) { - Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => { - self.insert_picture(bytes, at) - } - _ => self.attach(path, at), - } - } - /// Puts an encoded picture at the caret, or where it is dropped at `at`, a window point, /// at the size its resolution gives it, as OneNote 2010 inserts and pastes one. pub(crate) fn insert_picture( diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index cf9ecbf8e88e25d2789d657fb61a8544e2721f43..c8e93c2fcf1d4c7bd5c67986b9692e94082bcdb7 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -1376,6 +1376,16 @@ pub fn appearance_changed(dark: bool) { send(UserEvent::Appearance); } +/// Rejects a selection before JavaScript reads its files. +#[wasm_bindgen] +pub fn accepts_files(size: f64) -> bool { + let accepted = size.is_finite() && size >= 0.0 && size <= notebook::MAX_FILE_BYTES as f64; + if !accepted { + crate::attachment::too_large(); + } + accepted +} + /// Files chosen or dropped, as `[name, bytes]` pairs, for `purpose`: `open` opens them as /// notebooks, sections or packages, `place` puts them at the caret. #[wasm_bindgen] @@ -1388,7 +1398,8 @@ pub fn files(purpose: &str, files: js_sys::Array) { _ => { for path in keep(files, CHOSEN) { send(UserEvent::Then(Box::new(move |state| { - state.place_file(&path, None) + state.import_file(&path, None, true); + Ok(()) }))); } } diff --git a/crates/snowbound/tests/replay.rs b/crates/snowbound/tests/replay.rs index 87264e8d90751a5f58b70d5a0e1663540327fca5..6adb9217a1f2f672972d57a2c0170bd20d9538d3 100644 --- a/crates/snowbound/tests/replay.rs +++ b/crates/snowbound/tests/replay.rs @@ -606,6 +606,48 @@ fn the_find_bar_keeps_room_for_the_query() { ); } +#[test] +fn paste_replaces_a_focused_input_without_editing_the_page() { + let scratch = Scratch::new("paste-field"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); + let steps = [ + "move 900 600", + "press", + "release", + "type 雪 paste ☃", + "settle", + "modifiers command shift", + "key Left", + "modifiers command", + "key c", + "modifiers", + "settle", + "modifiers command", + "key f", + "modifiers", + "settle", + "type replace me", + "settle", + "modifiers command", + "key a", + "modifiers", + "settle", + "modifiers command", + "key v", + "modifiers", + "accessibility pasted", + ]; + let [pasted] = replay(&scratch, Some(¬ebook), &steps) + .try_into() + .unwrap(); + assert!( + pasted.contains(r#"SearchInput "Find on Page" = "雪 paste ☃" [focused]"#), + "{pasted}" + ); + assert_eq!(pasted.matches("雪 paste ☃").count(), 2, "{pasted}"); +} + /// A launch shows the page each notebook was left on, as the settings recall them: the /// notebook shown at once, and another as its section is opened. #[test] diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index 52e7ea7d93959e1a6a11617141ef557794ed957f..7a49576deb8979e9b4f90c85931f6597223539a0 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -611,10 +611,12 @@ function modifiers(event) { ); } -function read(list) { - return Promise.all( - [...list].map(async (file) => [file.name, new Uint8Array(await file.arrayBuffer())]), - ); +async function read(list) { + const files = [...list]; + if (!wasm.accepts_files(files.reduce((size, file) => size + file.size, 0))) return []; + const read = []; + for (const file of files) read.push([file.name, new Uint8Array(await file.arrayBuffer())]); + return read; } const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i;