authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 21:17:27-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 21:53:44-07:00
logc2174276845a26013191ed67398382758312ac8f
tree1f19b0c6ceb9679db8438c5a67be3032f101fe88
parent206bd8fba08030b8e592fdf5adcc4743005bd83a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: paste into fields and bound file uploads

- Paste plain text into focused input fields, including dialogs and search. - Reject oversized files before reading them, import accepted files off the frame thread, and bound Live Share uploads and encoding. - Keep Unicode search matches on character boundaries. - Insert attachments from the title into the page body so they save correctly. Assisted-by: gpt-6.1-sol

18 files changed, 497 insertions(+), 90 deletions(-)

crates/canvas/src/editor.rs+3
......@@ -2503,6 +2503,9 @@ impl CanvasEditor {
25032503 engine: &mut TextEngine,
25042504 mut file: onestore::page::Attachment,
25052505 ) -> Result<(), EditorError> {
2506 if self.active_outline().title && self.leave_title_for_body(engine)? {
2507 return self.insert_in_flow(engine, ParagraphContent::Attachment(file));
2508 }
25062509 if let Focus::Caret { outline, .. } = &self.active {
25072510 [file.layout.x, file.layout.y] = outline.origin().map(Some);
25082511 let layout = Box::new(crate::outline::page_file(engine, &file)?);
crates/canvas/src/search.rs+1-5
......@@ -516,11 +516,7 @@ pub fn page_matches(editor: &CanvasEditor, query: &Query) -> Vec<PageMatch> {
516516 start = span.end;
517517 }
518518 for hit in query.find(&shown) {
519 let end = source[hit.end - 1]
520 + paragraph.text()[source[hit.end - 1]..]
521 .chars()
522 .next()
523 .map_or(0, char::len_utf8);
519 let end = source[hit.end - 1] + 1;
524520 let (Ok(from), Ok(to)) = (
525521 paragraph.utf16_offset(source[hit.start]),
526522 paragraph.utf16_offset(end),
crates/canvas/tests/attachment_insert.rs+76-2
......@@ -7,8 +7,8 @@
77use canvas::{editor::CanvasEditor, layout::TextEngine};
88use draw::edit::Movement;
99use onestore::{
10 Arena, Section, Store,
11 op::{Edit, Op},
10 Arena, PageCreation, Section, Store,
11 op::{Edit, Op, SectionOp},
1212 page::{Attachment, PageObject, ParagraphContent},
1313};
1414use std::sync::Arc;
......@@ -62,6 +62,80 @@ fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment {
6262 }
6363}
6464
65#[test]
66fn a_file_attached_from_the_title_stores_in_the_body() {
67 let mut image = onestore::create_section("files.one", "First page", "Author").unwrap();
68 let arena = Arena::default();
69 let mut section = Section::open(&arena, image.clone()).unwrap();
70 section
71 .apply(
72 "Author",
73 &Edit {
74 at: 133_000_000_000_000_000,
75 ops: vec![Op::Section(SectionOp::Create(
76 PageCreation::new(None, Some("Attachment from title"), "Author").unwrap(),
77 ))],
78 },
79 )
80 .unwrap();
81 let (space, ..) = section.pages().unwrap()[1].clone();
82 let mut engine = TextEngine::default();
83 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
84 let title = editor
85 .outlines()
86 .iter()
87 .find(|outline| outline.title)
88 .unwrap()
89 .id;
90 editor.focus_outline(title).unwrap();
91 let attached = file(
92 "attachment.txt",
93 b"Snowbound bounded import fixture\n".to_vec(),
94 Some(native_icon()),
95 );
96 editor
97 .insert_attachment(&mut engine, attached.clone())
98 .unwrap();
99 assert!(!editor.active_outline().title);
100 let ops = editor
101 .take_ops()
102 .unwrap()
103 .into_iter()
104 .map(|op| Op::Page { space, op })
105 .collect();
106 section
107 .apply(
108 "Author",
109 &Edit {
110 at: 133_000_000_010_000_000,
111 ops,
112 },
113 )
114 .unwrap();
115 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
116 let arena = Arena::default();
117 let mut reopened = Section::open(&arena, image.clone()).unwrap();
118 assert_eq!(reopened.pages().unwrap()[1].1, "Attachment from title");
119 let page = reopened.page(space).unwrap();
120 let files: Vec<_> = page
121 .objects
122 .iter()
123 .filter_map(|object| match object {
124 PageObject::Outline(outline) if !outline.title => Some(&outline.paragraphs),
125 _ => None,
126 })
127 .flatten()
128 .filter_map(|paragraph| match &paragraph.content {
129 ParagraphContent::Attachment(file) => Some(file),
130 _ => None,
131 })
132 .collect();
133 assert_eq!(files, [&attached]);
134 if let Some(path) = std::env::var_os("CANVAS_TITLE_ATTACHMENT_EXPORT") {
135 std::fs::write(path, image).unwrap();
136 }
137}
138
65139#[test]
66140fn attached_files_store_their_bytes_between_the_text_around_them() {
67141 let source = onestore::create_section("files.one", "Before the file", "Author").unwrap();
crates/canvas/tests/search.rs+21
......@@ -135,6 +135,27 @@ fn find_on_page_counts_what_onenote_counts() {
135135 assert!(page_matches(&editor, &Query::new("zzq")).is_empty());
136136}
137137
138#[test]
139fn find_on_page_keeps_unicode_matches_on_character_boundaries() {
140 let mut engine = TextEngine::default();
141 let page = page(&mut engine, "Unicode", "雪 ☃ 🦀 café");
142 let editor = CanvasEditor::from_page(page, &mut engine).unwrap();
143 for (query, offsets) in [
144 ("雪", [0, 1]),
145 ("☃", [2, 3]),
146 ("🦀", [4, 6]),
147 ("cafe", [7, 11]),
148 ] {
149 let matches = page_matches(&editor, &Query::new(query));
150 assert_eq!(matches.len(), 1, "{query}");
151 assert_eq!(
152 matches[0].1.positions.map(|at| at.offset),
153 offsets,
154 "{query}"
155 );
156 }
157}
158
138159#[test]
139160fn folding_ignores_case_and_diacritics() {
140161 assert_eq!(fold("Crème BRÛLÉE"), "creme brulee");
crates/notebook/src/bin/onestore-diagnostic.rs+1-1
......@@ -83,7 +83,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>>
8383 &mut notebook::discover::Local::open(root)?,
8484 notebook::discover::Limits {
8585 entries: 100_000,
86 bytes_per_file: 256 * 1024 * 1024,
86 bytes_per_file: notebook::MAX_FILE_BYTES,
8787 depth: 64,
8888 },
8989 )?;
crates/notebook/src/fs.rs+45
......@@ -20,7 +20,52 @@ mod web;
2020#[cfg(target_arch = "wasm32")]
2121pub use web::*;
2222
23/// Reads a regular file within `limit`, including when it grows during the read.
24pub fn read_limited(path: impl AsRef<std::path::Path>, limit: usize) -> std::io::Result<Vec<u8>> {
25 use std::io::Read;
26 let file = File::open(path)?;
27 let metadata = file.metadata()?;
28 if !metadata.is_file() {
29 return Err(std::io::ErrorKind::InvalidInput.into());
30 }
31 if metadata.len() > limit as u64 {
32 return Err(std::io::ErrorKind::FileTooLarge.into());
33 }
34 let mut bytes = Vec::new();
35 file.take((limit as u64).saturating_add(1))
36 .read_to_end(&mut bytes)?;
37 if bytes.len() > limit {
38 return Err(std::io::ErrorKind::FileTooLarge.into());
39 }
40 Ok(bytes)
41}
42
2343#[cfg(not(target_arch = "wasm32"))]
2444pub async fn durable() -> std::io::Result<()> {
2545 Ok(())
2646}
47
48#[cfg(all(test, not(target_arch = "wasm32")))]
49mod tests {
50 use super::*;
51
52 #[test]
53 fn bounded_reads_refuse_an_eight_gib_file() {
54 let file = tempfile::NamedTempFile::new().unwrap();
55 write(file.path(), b"hello").unwrap();
56 assert_eq!(read_limited(file.path(), 5).unwrap(), b"hello");
57 assert_eq!(
58 read_limited(file.path(), 4).unwrap_err().kind(),
59 std::io::ErrorKind::FileTooLarge
60 );
61 file.as_file().set_len(8 << 30).unwrap();
62 assert_eq!(
63 read_limited(file.path(), crate::MAX_FILE_BYTES)
64 .unwrap_err()
65 .kind(),
66 std::io::ErrorKind::FileTooLarge
67 );
68 let folder = tempfile::tempdir().unwrap();
69 assert!(read_limited(folder.path(), crate::MAX_FILE_BYTES).is_err());
70 }
71}
crates/notebook/src/lib.rs+3
......@@ -49,6 +49,9 @@ mod working;
4949pub use smb::SmbRemote;
5050pub use worker::SyncWorker;
5151
52/// The largest file a notebook session or Live Share reads or writes whole.
53pub const MAX_FILE_BYTES: usize = 256 << 20;
54
5255#[derive(Debug, thiserror::Error)]
5356pub enum Error {
5457 #[error(transparent)]
crates/notebook/src/live/share.rs+89-13
......@@ -42,8 +42,7 @@ const CHUNK: usize = 128 << 10;
4242const WINDOW: usize = 512 << 10;
4343/// How long a request waits for its reply.
4444const TIMEOUT: Duration = Duration::from_secs(60);
45/// The largest file read or written whole.
46const LIMIT: usize = 256 << 20;
45use crate::MAX_FILE_BYTES as LIMIT;
4746/// Snapshots of files being read, per guest, and how long one is kept unread.
4847const SNAPSHOTS: usize = 8;
4948const SNAPSHOT_AGE: Duration = Duration::from_secs(120);
......@@ -501,14 +500,28 @@ impl Served {
501500 let handle = request.handle.unwrap_or_default();
502501 let bytes = request.bytes.unwrap_or_default();
503502 let mut puts = self.puts.lock().unwrap();
504 let held = puts.entry((*peer, handle)).or_default();
505 if request.offset != Some(held.len() as u64) || held.len() + bytes.len() > LIMIT {
503 let key = (*peer, handle);
504 let length = puts.get(&key).map_or(0, Vec::len);
505 if request.offset != Some(length as u64) || bytes.is_empty() {
506 puts.remove(&key);
506507 return Err(refused(
507508 io::ErrorKind::InvalidInput,
508509 "An upload out of order",
509510 ));
510511 }
511 held.extend_from_slice(&bytes);
512 let held: usize = puts
513 .iter()
514 .filter(|((guest, _), _)| guest == peer)
515 .map(|(_, bytes)| bytes.len())
516 .sum();
517 if bytes.len() > LIMIT.saturating_sub(held) {
518 puts.remove(&key);
519 return Err(refused(
520 io::ErrorKind::FileTooLarge,
521 "An upload is too large",
522 ));
523 }
524 puts.entry(key).or_default().extend_from_slice(&bytes);
512525 done
513526 }
514527 kind::COMMIT => {
......@@ -1228,6 +1241,9 @@ impl Guest {
12281241 request: &mut Request,
12291242 bytes: Vec<u8>,
12301243 ) -> std::result::Result<(), Failed> {
1244 if bytes.len() > LIMIT {
1245 return Err(Failed::Unsent(io::ErrorKind::FileTooLarge.into()));
1246 }
12311247 if bytes.len() <= CHUNK {
12321248 request.bytes = Some(bytes);
12331249 return Ok(());
......@@ -1436,16 +1452,19 @@ impl Guest {
14361452 edits: &[crate::PendingEdit],
14371453 revisions: &BTreeMap<onestore::ExGuid, onestore::ExGuid>,
14381454 ) -> std::result::Result<(), CommitError> {
1439 let bytes = serde_json::to_vec(&batch::Edits {
1440 edits: edits
1441 .iter()
1442 .map(|edit| (edit.author.clone(), edit.edit.clone()))
1443 .collect(),
1444 revisions: revisions.clone(),
1445 })
1455 let bytes = batch::encode(
1456 &batch::Edits {
1457 edits: edits
1458 .iter()
1459 .map(|edit| (edit.author.clone(), edit.edit.clone()))
1460 .collect(),
1461 revisions: revisions.clone(),
1462 },
1463 LIMIT,
1464 )
14461465 .map_err(|error| CommitError {
14471466 state: CommitState::NotCommitted,
1448 error: io::Error::other(error),
1467 error,
14491468 })?;
14501469 let mut request = Request {
14511470 path: path.to_owned(),
......@@ -1877,6 +1896,9 @@ impl Storage for Hosted {
18771896 }
18781897
18791898 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {
1899 if bytes.len() > LIMIT {
1900 return Err(io::Error::from(io::ErrorKind::FileTooLarge).into());
1901 }
18801902 let mut request = Request {
18811903 path: path.to_owned(),
18821904 ..Request::default()
......@@ -1954,6 +1976,60 @@ impl Storage for Hosted {
19541976mod tests {
19551977 use super::*;
19561978
1979 #[test]
1980 fn refused_uploads_release_their_bytes_and_share_one_guest_budget() {
1981 let directory = tempfile::tempdir().unwrap();
1982 std::fs::create_dir(directory.path().join("notebook")).unwrap();
1983 let served = Arc::new(Served {
1984 storage: crate::session::Notebook::open(
1985 directory.path().join("notebook"),
1986 directory.path().join("cache"),
1987 )
1988 .unwrap()
1989 .into_storage(),
1990 images: Mutex::default(),
1991 snapshots: Mutex::default(),
1992 puts: Mutex::default(),
1993 guests: Mutex::default(),
1994 writers: Mutex::default(),
1995 host: Mutex::default(),
1996 room: Mutex::default(),
1997 });
1998 let peer = [1; 16];
1999 let put = |handle, offset, bytes| {
2000 served.answer(
2001 &peer,
2002 kind::PUT,
2003 Request {
2004 handle: Some(handle),
2005 offset: Some(offset),
2006 bytes: Some(bytes),
2007 ..Request::default()
2008 },
2009 )
2010 };
2011 put(1, 0, vec![1; 3]).unwrap();
2012 put(2, 0, vec![2; 2]).unwrap();
2013 assert!(put(1, 2, vec![3]).is_err());
2014 assert_eq!(
2015 served.puts.lock().unwrap().get(&(peer, 2)).unwrap(),
2016 &[2; 2]
2017 );
2018 assert!(!served.puts.lock().unwrap().contains_key(&(peer, 1)));
2019 put(1, 0, vec![4]).unwrap();
2020 assert!(put(1, 1, Vec::new()).is_err());
2021 served
2022 .puts
2023 .lock()
2024 .unwrap()
2025 .insert((peer, 3), vec![0; LIMIT - 2]);
2026 assert!(put(2, 2, vec![5]).is_err());
2027 assert!(!served.puts.lock().unwrap().contains_key(&(peer, 2)));
2028 assert!(put(3, (LIMIT - 2) as u64, vec![6; 3]).is_err());
2029 assert!(served.puts.lock().unwrap().is_empty());
2030 put(4, 0, vec![7]).unwrap();
2031 }
2032
19572033 /// A commit's writes, found by comparing images, rebuild the image after it from the one
19582034 /// before, and a change touching most of the file is left to be read whole.
19592035 #[test]
crates/notebook/src/live/share/batch.rs+90
......@@ -10,6 +10,37 @@ pub(super) struct Edits {
1010 pub revisions: BTreeMap<ExGuid, ExGuid>,
1111}
1212
13pub(super) fn encode(edits: &Edits, limit: usize) -> io::Result<Vec<u8>> {
14 struct Buffer {
15 bytes: Vec<u8>,
16 limit: usize,
17 }
18 impl io::Write for Buffer {
19 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
20 if bytes.len() > self.limit.saturating_sub(self.bytes.len()) {
21 return Err(io::ErrorKind::FileTooLarge.into());
22 }
23 self.bytes.extend_from_slice(bytes);
24 Ok(bytes.len())
25 }
26
27 fn flush(&mut self) -> io::Result<()> {
28 Ok(())
29 }
30 }
31 let mut buffer = Buffer {
32 bytes: Vec::new(),
33 limit,
34 };
35 serde_json::to_writer(&mut buffer, edits).map_err(|error| {
36 io::Error::new(
37 error.io_error_kind().unwrap_or(io::ErrorKind::InvalidData),
38 error,
39 )
40 })?;
41 Ok(buffer.bytes)
42}
43
1344pub(super) struct Waiting {
1445 peer: [u8; 16],
1546 request: Request,
......@@ -262,3 +293,62 @@ impl Served {
262293 Ok(transaction)
263294 }
264295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use onestore::{
301 op::{Op, PageOp},
302 page::{Attachment, PageObject},
303 };
304
305 #[test]
306 fn attachment_encoding_stops_at_the_upload_budget() {
307 let id = ExGuid {
308 guid: [1; 16],
309 n: 1,
310 };
311 let bytes: Arc<[u8]> = (0..4096)
312 .map(|at| (at % 251) as u8)
313 .collect::<Vec<_>>()
314 .into();
315 let edits = Edits {
316 edits: vec![(
317 "Ada".into(),
318 Edit {
319 at: 0,
320 ops: vec![Op::Page {
321 space: id,
322 op: PageOp::Add {
323 object: PageObject::Attachment(Attachment {
324 id,
325 filename: "payload.bin".into(),
326 source_path: None,
327 size: None,
328 layout: Default::default(),
329 bytes: Some(Arc::clone(&bytes)),
330 preview: None,
331 recording: None,
332 tags: Vec::new(),
333 }),
334 before: None,
335 },
336 }],
337 },
338 )],
339 revisions: BTreeMap::new(),
340 };
341 let encoded = serde_json::to_vec(&edits).unwrap();
342 assert_eq!(encode(&edits, encoded.len()).unwrap(), encoded);
343 let decoded: Edits = serde_json::from_slice(&encoded).unwrap();
344 assert_eq!(decoded.edits, edits.edits);
345 assert_eq!(
346 encode(&edits, encoded.len() - 1).unwrap_err().kind(),
347 io::ErrorKind::FileTooLarge
348 );
349 assert_eq!(
350 encode(&edits, 128).unwrap_err().kind(),
351 io::ErrorKind::FileTooLarge
352 );
353 }
354}
crates/notebook/src/session.rs+2-2
......@@ -326,7 +326,7 @@ impl Storage for Share {
326326 fn read(&self, path: &str) -> Result<Vec<u8>> {
327327 Ok(self
328328 .client
329 .read_storage(&self.path(path), 256 * 1024 * 1024)?)
329 .read_storage(&self.path(path), crate::MAX_FILE_BYTES)?)
330330 }
331331
332332 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> {
......@@ -403,7 +403,7 @@ impl Storage for Share {
403403
404404pub(crate) const LIMITS: discover::Limits = discover::Limits {
405405 entries: 100_000,
406 bytes_per_file: 256 * 1024 * 1024,
406 bytes_per_file: crate::MAX_FILE_BYTES,
407407 depth: 64,
408408};
409409
crates/onestore/src/page/mod.rs+11-5
......@@ -22,17 +22,23 @@ pub use math::Math;
2222/// Picture and attachment payloads travel with the model (queued intents replay them),
2323/// as base64 text.
2424mod payload {
25 use base64::Engine;
25 use base64::{Engine, display::Base64Display, engine::general_purpose::STANDARD};
2626 use std::sync::Arc;
2727
28 struct Encoded<'a>(&'a [u8]);
29
30 impl serde::Serialize for Encoded<'_> {
31 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
32 serializer.collect_str(&Base64Display::new(self.0, &STANDARD))
33 }
34 }
35
2836 pub fn serialize<S: serde::Serializer>(
2937 bytes: &Option<Arc<[u8]>>,
3038 serializer: S,
3139 ) -> Result<S::Ok, S::Error> {
3240 match bytes {
33 Some(bytes) => {
34 serializer.serialize_some(&base64::engine::general_purpose::STANDARD.encode(bytes))
35 }
41 Some(bytes) => serializer.serialize_some(&Encoded(bytes)),
3642 None => serializer.serialize_none(),
3743 }
3844 }
......@@ -42,7 +48,7 @@ mod payload {
4248 ) -> Result<Option<Arc<[u8]>>, D::Error> {
4349 let text: Option<String> = serde::Deserialize::deserialize(deserializer)?;
4450 text.map(|text| {
45 base64::engine::general_purpose::STANDARD
51 STANDARD
4652 .decode(text)
4753 .map(Arc::from)
4854 .map_err(serde::de::Error::custom)
crates/snowbound/src/attachment.rs+76-34
......@@ -2,44 +2,76 @@
22
33use crate::{State, platform};
44use onestore::page::Attachment;
5use std::{error::Error, path::Path};
5use std::{
6 error::Error,
7 path::{Path, PathBuf},
8};
69
710impl State {
8 /// Attach File, or a file dropped at `at`, a window point: a copy of the file's bytes
9 /// at the caret or at `at`, with the icon the system shows for it; audio and video are
10 /// recordings, as OneNote attaches them.
11 pub(crate) fn attach(
12 &mut self,
13 path: &Path,
14 at: Option<[f32; 2]>,
15 ) -> Result<(), Box<dyn Error>> {
11 /// Inserts a file at the caret or window point `at`, as a picture when requested.
12 pub(crate) fn import_file(&mut self, path: &Path, at: Option<[f32; 2]>, picture: bool) {
1613 if self.session.as_ref().is_some_and(crate::Session::read_only) {
17 return Ok(());
14 return;
1815 }
19 let (Some(name), Ok(bytes)) = (
20 path.file_name().and_then(|name| name.to_str()),
21 notebook::fs::read(path),
22 ) else {
23 platform::alert("Couldn't attach the file", "Choose a file you can open.");
24 return Ok(());
25 };
26 let file = Attachment {
27 id: onestore::page::text::new_id()?,
28 filename: name.to_owned(),
29 source_path: path.to_str().map(str::to_owned),
30 size: Some(canvas::gpu::page::ICON_SIZE),
31 layout: Default::default(),
32 preview: platform::file_icon(path).map(Into::into),
33 recording: canvas::recording::attached(name, &bytes),
34 bytes: Some(bytes.into()),
35 tags: Vec::new(),
36 };
37 let response = match at {
38 Some(point) => self.view.drop_attachment(self.page_point(point), file)?,
39 None => self.view.insert_attachment(file)?,
40 };
41 self.respond(response);
42 Ok(())
16 let loading = self.loading;
17 let reply = self.reply(
18 move |state, (path, read): (PathBuf, std::io::Result<Vec<u8>>)| {
19 let bytes = match read {
20 Ok(bytes) => bytes,
21 Err(error) if error.kind() == std::io::ErrorKind::FileTooLarge => {
22 too_large();
23 return Ok(());
24 }
25 Err(_) => {
26 platform::alert("Couldn't insert the file", "Choose a file you can open.");
27 return Ok(());
28 }
29 };
30 if state.loading != loading {
31 platform::alert(
32 "File wasn't inserted",
33 "Return to the page and insert the file again.",
34 );
35 return Ok(());
36 }
37 if state
38 .session
39 .as_ref()
40 .is_some_and(crate::Session::read_only)
41 {
42 return Ok(());
43 }
44 if picture && draw::RasterImage::measure(&bytes).is_ok() {
45 return state.insert_picture(bytes, at);
46 }
47 let name = path
48 .file_name()
49 .and_then(|name| name.to_str())
50 .ok_or("No file name")?;
51 let file = Attachment {
52 id: onestore::page::text::new_id()?,
53 filename: name.to_owned(),
54 source_path: path.to_str().map(str::to_owned),
55 size: Some(canvas::gpu::page::ICON_SIZE),
56 layout: Default::default(),
57 preview: platform::file_icon(&path).map(Into::into),
58 recording: canvas::recording::attached(name, &bytes),
59 bytes: Some(bytes.into()),
60 tags: Vec::new(),
61 };
62 let response = match at {
63 Some(point) => state.view.drop_attachment(state.page_point(point), file)?,
64 None => state.view.insert_attachment(file)?,
65 };
66 state.respond(response);
67 Ok(())
68 },
69 );
70 let path = path.to_owned();
71 crate::spawn(move || {
72 let bytes = notebook::fs::read_limited(&path, notebook::MAX_FILE_BYTES);
73 reply.send((path, bytes));
74 });
4375 }
4476
4577 /// Open: a recording plays; another file opens as a copy, in a folder of its own, with
......@@ -89,6 +121,16 @@ impl State {
89121 }
90122}
91123
124pub(crate) fn too_large() {
125 platform::alert(
126 "File is too large",
127 &format!(
128 "Choose files totaling at most {} MiB.",
129 notebook::MAX_FILE_BYTES >> 20
130 ),
131 );
132}
133
92134/// A file another program stored beside the section rather than in it.
93135fn unstored() {
94136 platform::alert(
crates/snowbound/src/commands.rs+16-2
......@@ -1247,7 +1247,7 @@ impl State {
12471247 Id::Undo | Id::Redo => enabled(writable && !field && self.can_step(id == Id::Redo)),
12481248 Id::Cut => enabled(writable && selected),
12491249 Id::Copy => enabled(selected),
1250 Id::Paste => enabled(text && !field),
1250 Id::Paste => enabled(field || text),
12511251 Id::SelectAll => enabled(field || page),
12521252 Id::Back | Id::Forward => {
12531253 enabled(!modal && self.can_travel()[usize::from(id == Id::Forward)])
......@@ -1573,6 +1573,17 @@ impl State {
15731573 self.respond(response);
15741574 return Ok(());
15751575 }
1576 Id::Paste if field.is_some() => {
1577 let text = match &mut self.clipboard {
1578 crate::Clipboard::System(clipboard) => clipboard.get_text().ok(),
1579 crate::Clipboard::Memory(text, _) => Some(text.clone()),
1580 };
1581 if let Some(text) = text {
1582 self.ui
1583 .event(ui::Event::Ime(winit::event::Ime::Commit(text)));
1584 }
1585 return Ok(());
1586 }
15761587 Id::Paste => Work::Page(Request::Paste),
15771588 Id::FormatPainter => {
15781589 self.painter = match self.painter {
......@@ -1659,7 +1670,10 @@ impl State {
16591670 return Ok(());
16601671 }
16611672 Id::Attachment => {
1662 let reply = self.reply(|state, path: std::path::PathBuf| state.attach(&path, None));
1673 let reply = self.reply(|state, path: std::path::PathBuf| {
1674 state.import_file(&path, None, false);
1675 Ok(())
1676 });
16631677 platform::pick_file("Attach File", &[], reply);
16641678 return Ok(());
16651679 }
crates/snowbound/src/main.rs+1-1
......@@ -6630,7 +6630,7 @@ impl ApplicationHandler<UserEvent> for App {
66306630 WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)),
66316631 WindowEvent::DroppedFile(path) => {
66326632 let at = platform::drop_point(&state.window);
6633 state.place_file(&path, at)?;
6633 state.import_file(&path, at, true);
66346634 state.window.request_redraw();
66356635 }
66366636 WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?,
crates/snowbound/src/paste.rs+2-20
......@@ -3,10 +3,7 @@
33
44use crate::{State, platform};
55use canvas::editor::{Clip, Piece};
6use std::{
7 error::Error,
8 path::{Path, PathBuf},
9};
6use std::{error::Error, path::PathBuf};
107
118/// Copy offers text and HTML, or a picture as PNG, beside Snowbound's lossless clip.
129pub(crate) struct Copied {
......@@ -123,7 +120,7 @@ impl State {
123120 match self.clipboard.pasted() {
124121 Some(Pasted::Files(paths)) => {
125122 for path in paths {
126 self.place_file(&path, None)?;
123 self.import_file(&path, None, true);
127124 }
128125 }
129126 Some(Pasted::Clip(clip)) => {
......@@ -180,21 +177,6 @@ impl State {
180177 Ok(())
181178 }
182179
183 /// A pasted file, or one dropped at `at`, a window point: a picture file as its picture,
184 /// as OneNote 2010 pastes one (lab, 2026-09-30), and any other file attached.
185 pub(crate) fn place_file(
186 &mut self,
187 path: &Path,
188 at: Option<[f32; 2]>,
189 ) -> Result<(), Box<dyn Error>> {
190 match notebook::fs::read(path) {
191 Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => {
192 self.insert_picture(bytes, at)
193 }
194 _ => self.attach(path, at),
195 }
196 }
197
198180 /// Puts an encoded picture at the caret, or where it is dropped at `at`, a window point,
199181 /// at the size its resolution gives it, as OneNote 2010 inserts and pastes one.
200182 pub(crate) fn insert_picture(
crates/snowbound/src/web.rs+12-1
......@@ -1376,6 +1376,16 @@ pub fn appearance_changed(dark: bool) {
13761376 send(UserEvent::Appearance);
13771377}
13781378
1379/// Rejects a selection before JavaScript reads its files.
1380#[wasm_bindgen]
1381pub fn accepts_files(size: f64) -> bool {
1382 let accepted = size.is_finite() && size >= 0.0 && size <= notebook::MAX_FILE_BYTES as f64;
1383 if !accepted {
1384 crate::attachment::too_large();
1385 }
1386 accepted
1387}
1388
13791389/// Files chosen or dropped, as `[name, bytes]` pairs, for `purpose`: `open` opens them as
13801390/// notebooks, sections or packages, `place` puts them at the caret.
13811391#[wasm_bindgen]
......@@ -1388,7 +1398,8 @@ pub fn files(purpose: &str, files: js_sys::Array) {
13881398 _ => {
13891399 for path in keep(files, CHOSEN) {
13901400 send(UserEvent::Then(Box::new(move |state| {
1391 state.place_file(&path, None)
1401 state.import_file(&path, None, true);
1402 Ok(())
13921403 })));
13931404 }
13941405 }
crates/snowbound/tests/replay.rs+42
......@@ -606,6 +606,48 @@ fn the_find_bar_keeps_room_for_the_query() {
606606 );
607607}
608608
609#[test]
610fn paste_replaces_a_focused_input_without_editing_the_page() {
611 let scratch = Scratch::new("paste-field");
612 let notebook =
613 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
614 let steps = [
615 "move 900 600",
616 "press",
617 "release",
618 "type 雪 paste ☃",
619 "settle",
620 "modifiers command shift",
621 "key Left",
622 "modifiers command",
623 "key c",
624 "modifiers",
625 "settle",
626 "modifiers command",
627 "key f",
628 "modifiers",
629 "settle",
630 "type replace me",
631 "settle",
632 "modifiers command",
633 "key a",
634 "modifiers",
635 "settle",
636 "modifiers command",
637 "key v",
638 "modifiers",
639 "accessibility pasted",
640 ];
641 let [pasted] = replay(&scratch, Some(&notebook), &steps)
642 .try_into()
643 .unwrap();
644 assert!(
645 pasted.contains(r#"SearchInput "Find on Page" = "雪 paste ☃" [focused]"#),
646 "{pasted}"
647 );
648 assert_eq!(pasted.matches("雪 paste ☃").count(), 2, "{pasted}");
649}
650
609651/// A launch shows the page each notebook was left on, as the settings recall them: the
610652/// notebook shown at once, and another as its section is opened.
611653#[test]
crates/snowbound/web/glue.js+6-4
......@@ -611,10 +611,12 @@ function modifiers(event) {
611611 );
612612}
613613
614function read(list) {
615 return Promise.all(
616 [...list].map(async (file) => [file.name, new Uint8Array(await file.arrayBuffer())]),
617 );
614async function read(list) {
615 const files = [...list];
616 if (!wasm.accepts_files(files.reduce((size, file) => size + file.size, 0))) return [];
617 const read = [];
618 for (const file of files) read.push([file.name, new Uint8Array(await file.arrayBuffer())]);
619 return read;
618620}
619621
620622const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i;