From cd54389e9158ed0c8e4f98b267dc0eff8091d961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Tue, 15 Oct 2024 22:57:00 +0200 Subject: [PATCH] UPDATE: Implement copy/paste in the UI --- Cargo.lock | 112 +++++++++++- base/Cargo.toml | 2 + base/src/lib.rs | 1 + base/src/test/user_model/mod.rs | 1 + base/src/test/user_model/test_paste_csv.rs | 114 ++++++++++++ base/src/user_model/common.rs | 193 ++++++++++++++++++++- base/src/user_model/mod.rs | 1 + bindings/wasm/fix_types.py | 49 ++++++ bindings/wasm/src/lib.rs | 35 +++- bindings/wasm/types.ts | 20 +++ webapp/src/components/clipboard.ts | 2 + webapp/src/components/workbook.tsx | 133 +++++++++++++- 12 files changed, 648 insertions(+), 15 deletions(-) create mode 100644 base/src/test/user_model/test_paste_csv.rs create mode 100644 webapp/src/components/clipboard.ts diff --git a/Cargo.lock b/Cargo.lock index 9526efc..10da2d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,15 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aho-corasick" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81ce3d38065e618af2d7b77e10c5ad9a069859b4be3c2250f674af3840d9c8a5" +dependencies = [ + "memchr", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -76,6 +85,12 @@ dependencies = [ "syn", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -242,6 +257,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + +[[package]] +name = "csv-sniffer" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b8e952164bb270a505d6cb6136624174c34cfb9abd16e0011f5e53058317f39" +dependencies = [ + "bitflags", + "csv", + "csv-core", + "memchr", + "regex 0.2.11", +] + [[package]] name = "deranged" version = "0.3.11" @@ -375,10 +424,12 @@ dependencies = [ "bitcode", "chrono", "chrono-tz", + "csv", + "csv-sniffer", "js-sys", "once_cell", "rand", - "regex", + "regex 1.10.4", "ryu", "serde", "serde_json", @@ -417,6 +468,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.153" @@ -480,7 +537,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" dependencies = [ - "regex", + "regex 1.10.4", ] [[package]] @@ -689,16 +746,29 @@ dependencies = [ "getrandom", ] +[[package]] +name = "regex" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9329abc99e39129fcceabd24cf5d85b4671ef7c29c50e972bc5afe32438ec384" +dependencies = [ + "aho-corasick 0.6.10", + "memchr", + "regex-syntax 0.5.6", + "thread_local", + "utf8-ranges", +] + [[package]] name = "regex" version = "1.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" dependencies = [ - "aho-corasick", + "aho-corasick 1.1.3", "memchr", "regex-automata", - "regex-syntax", + "regex-syntax 0.8.3", ] [[package]] @@ -707,9 +777,18 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" dependencies = [ - "aho-corasick", + "aho-corasick 1.1.3", "memchr", - "regex-syntax", + "regex-syntax 0.8.3", +] + +[[package]] +name = "regex-syntax" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d707a4fa2637f2dca2ef9fd02225ec7661fe01a53623c1e6515b6916511f7a7" +dependencies = [ + "ucd-util", ] [[package]] @@ -849,6 +928,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6b53e329000edc2b34dbe8545fd20e55a333362d0a321909685a19bd28c3f1b" +dependencies = [ + "lazy_static", +] + [[package]] name = "time" version = "0.3.34" @@ -874,6 +962,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucd-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd2fc5d32b590614af8b0a20d837f32eca055edd0bbead59a9cfe80858be003" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -886,6 +980,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" +[[package]] +name = "utf8-ranges" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" + [[package]] name = "uuid" version = "1.8.0" diff --git a/base/Cargo.toml b/base/Cargo.toml index 9e810a5..9966b8b 100644 --- a/base/Cargo.toml +++ b/base/Cargo.toml @@ -18,6 +18,8 @@ chrono-tz = "0.9" regex = "1.0" once_cell = "1.16.0" bitcode = "0.6.0" +csv = "1.3.0" +csv-sniffer = "0.1" [dev-dependencies] serde_json = "1.0" diff --git a/base/src/lib.rs b/base/src/lib.rs index 8c0e45d..b235ebf 100644 --- a/base/src/lib.rs +++ b/base/src/lib.rs @@ -58,4 +58,5 @@ pub mod mock_time; pub use model::get_milliseconds_since_epoch; pub use model::Model; pub use user_model::BorderArea; +pub use user_model::ClipboardData; pub use user_model::UserModel; diff --git a/base/src/test/user_model/mod.rs b/base/src/test/user_model/mod.rs index 0bb632c..cb78813 100644 --- a/base/src/test/user_model/mod.rs +++ b/base/src/test/user_model/mod.rs @@ -11,6 +11,7 @@ mod test_keyboard_navigation; mod test_on_area_selection; mod test_on_expand_selected_range; mod test_on_paste_styles; +mod test_paste_csv; mod test_rename_sheet; mod test_row_column; mod test_styles; diff --git a/base/src/test/user_model/test_paste_csv.rs b/base/src/test/user_model/test_paste_csv.rs new file mode 100644 index 0000000..8edad9e --- /dev/null +++ b/base/src/test/user_model/test_paste_csv.rs @@ -0,0 +1,114 @@ +use crate::{expressions::types::Area, UserModel}; + +#[test] +fn csv_paste() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + model.set_user_input(0, 7, 7, "=SUM(B4:D7)").unwrap(); + assert_eq!(model.get_formatted_cell_value(0, 7, 7), Ok("0".to_string())); + + // paste some numbers in B4:C7 + let csv = "1,2,3\n4,5,6"; + let area = Area { + sheet: 0, + row: 4, + column: 2, + width: 1, + height: 1, + }; + model.paste_csv_string(&area, csv).unwrap(); + + assert_eq!( + model.get_formatted_cell_value(0, 7, 7), + Ok("21".to_string()) + ); +} + +#[test] +fn tsv_crlf_paste() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + model.set_user_input(0, 7, 7, "=SUM(B4:D7)").unwrap(); + assert_eq!(model.get_formatted_cell_value(0, 7, 7), Ok("0".to_string())); + + // paste some numbers in B4:C7 + let csv = "1\t2\t3\r\n4\t5\t6"; + let area = Area { + sheet: 0, + row: 4, + column: 2, + width: 1, + height: 1, + }; + model.paste_csv_string(&area, csv).unwrap(); + + assert_eq!( + model.get_formatted_cell_value(0, 7, 7), + Ok("21".to_string()) + ); +} + +#[test] +fn copy_paste_internal() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + model.set_user_input(0, 1, 1, "42").unwrap(); + model.set_user_input(0, 1, 2, "=A1*3+1").unwrap(); + + // set A1 bold + let range = Area { + sheet: 0, + row: 1, + column: 1, + width: 1, + height: 1, + }; + model.update_range_style(&range, "font.b", "true").unwrap(); + + model + .set_user_input(0, 2, 1, "A season of faith, \"perfection\"") + .unwrap(); + + // Select A1:B2 and copy + model.set_selected_range(1, 1, 2, 2).unwrap(); + let copy = model.copy_to_clipboard().unwrap(); + assert_eq!( + copy.csv, + "42,127\n\"A season of faith, \"\"perfection\"\"\",\n" + ); + assert_eq!(copy.range, (1, 1, 2, 2)); + + model.set_selected_cell(4, 4).unwrap(); + + // paste in cell D4 (4, 4) + model + .paste_from_clipboard((1, 1, 2, 2), ©.data) + .unwrap(); + + assert_eq!(model.get_cell_content(0, 4, 4), Ok("42".to_string())); + assert_eq!(model.get_cell_content(0, 4, 5), Ok("=D4*3+1".to_string())); + assert_eq!( + model.get_formatted_cell_value(0, 4, 5), + Ok("127".to_string()) + ); + // cell D4 must be bold + let style_d4 = model.get_cell_style(0, 4, 4).unwrap(); + assert!(style_d4.font.b); + + model.undo().unwrap(); + + assert_eq!(model.get_cell_content(0, 4, 4), Ok("".to_string())); + assert_eq!(model.get_cell_content(0, 4, 5), Ok("".to_string())); + // cell D4 must not be bold + let style_d4 = model.get_cell_style(0, 4, 4).unwrap(); + assert!(!style_d4.font.b); + + model.redo().unwrap(); + + assert_eq!(model.get_cell_content(0, 4, 4), Ok("42".to_string())); + assert_eq!(model.get_cell_content(0, 4, 5), Ok("=D4*3+1".to_string())); + assert_eq!( + model.get_formatted_cell_value(0, 4, 5), + Ok("127".to_string()) + ); + // cell D4 must be bold + let style_d4 = model.get_cell_style(0, 4, 4).unwrap(); + assert!(style_d4.font.b); +} diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index ed308f1..45602cd 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -1,13 +1,15 @@ #![deny(missing_docs)] -use std::{collections::HashMap, fmt::Debug}; +use std::{collections::HashMap, fmt::Debug, io::Cursor}; +use csv::{ReaderBuilder, WriterBuilder}; +use csv_sniffer::Sniffer; use serde::{Deserialize, Serialize}; use crate::{ constants, expressions::{ - types::Area, + types::{Area, CellReferenceIndex}, utils::{is_valid_column_number, is_valid_row}, }, model::Model, @@ -21,6 +23,23 @@ use crate::{ use crate::user_model::history::{ ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData, }; +/// Data for the clipboard +pub type ClipboardData = HashMap>; + +pub type ClipboardTuple = (i32, i32, i32, i32); + +#[derive(Serialize, Deserialize)] +pub struct ClipboardCell { + text: String, + style: Style, +} + +#[derive(Serialize, Deserialize)] +pub struct Clipboard { + pub(crate) csv: String, + pub(crate) data: ClipboardData, + pub(crate) range: (i32, i32, i32, i32), +} #[derive(Serialize, Deserialize)] pub enum BorderType { @@ -976,7 +995,7 @@ impl UserModel { /// See also: /// * [Model::get_style_for_cell] #[inline] - pub fn get_cell_style(&mut self, sheet: u32, row: i32, column: i32) -> Result { + pub fn get_cell_style(&self, sheet: u32, row: i32, column: i32) -> Result { self.model.get_style_for_cell(sheet, row, column) } @@ -1209,6 +1228,174 @@ impl UserModel { Ok(self.model.workbook.worksheet(sheet)?.show_grid_lines) } + /// Returns a copy of the selected area + pub fn copy_to_clipboard(&self) -> Result { + let selected_area = self.get_selected_view(); + let sheet = selected_area.sheet; + let mut wtr = WriterBuilder::new().from_writer(vec![]); + + let mut data = HashMap::new(); + let [row_start, column_start, row_end, column_end] = selected_area.range; + for row in row_start..=row_end { + let mut data_row = HashMap::new(); + let mut text_row = Vec::new(); + for column in column_start..=column_end { + let text = self.get_formatted_cell_value(sheet, row, column)?; + let content = self.get_cell_content(sheet, row, column)?; + let style = self.get_cell_style(sheet, row, column)?; + data_row.insert( + column, + ClipboardCell { + text: content, + style, + }, + ); + text_row.push(text); + } + wtr.write_record(text_row).unwrap(); + data.insert(row, data_row); + } + + let csv = String::from_utf8(wtr.into_inner().unwrap()).unwrap(); + + Ok(Clipboard { + csv, + data, + range: (row_start, column_start, row_end, column_end), + }) + } + + /// Paste text that we copied + pub fn paste_from_clipboard( + &mut self, + source_range: ClipboardTuple, + clipboard: &ClipboardData, + ) -> Result<(), String> { + let mut diff_list = Vec::new(); + let view = self.get_selected_view(); + let (source_first_row, source_first_column, _, _) = source_range; + let sheet = view.sheet; + let [selected_row, selected_column, _, _] = view.range; + for (source_row, data_row) in clipboard { + let delta_row = source_row - source_first_row; + let target_row = selected_row + delta_row; + for (source_column, value) in data_row { + let delta_column = source_column - source_first_column; + let target_column = selected_column + delta_column; + + // We are copying the value in + // (source_row, source_column) to (target_row , target_column) + // References in formulas are displaced + + // remain in the copied area + let source = &CellReferenceIndex { + sheet, + column: *source_column, + row: *source_row, + }; + let target = &CellReferenceIndex { + sheet, + column: target_column, + row: target_row, + }; + let new_value = self + .model + .extend_copied_value(&value.text, source, target)?; + + let old_value = self + .model + .workbook + .worksheet(sheet)? + .cell(target_row, target_column) + .cloned(); + + let old_style = self + .model + .get_style_for_cell(sheet, target_row, target_column)?; + + self.model + .set_user_input(sheet, target_row, target_column, new_value.clone())?; + self.model + .set_cell_style(sheet, target_row, target_column, &value.style)?; + + diff_list.push(Diff::SetCellValue { + sheet, + row: target_row, + column: target_column, + new_value, + old_value: Box::new(old_value), + }); + + diff_list.push(Diff::SetCellStyle { + sheet, + row: target_row, + column: target_column, + old_value: Box::new(old_style), + new_value: Box::new(value.style.clone()), + }); + } + } + self.push_diff_list(diff_list); + self.evaluate_if_not_paused(); + Ok(()) + } + + /// Paste a csv-string into the model + pub fn paste_csv_string(&mut self, area: &Area, csv: &str) -> Result<(), String> { + let mut diff_list = Vec::new(); + let sheet = area.sheet; + let mut row = area.row; + // Create a sniffer with default settings + let mut sniffer = Sniffer::new(); + let mut csv_reader = Cursor::new(csv); + + // Sniff the CSV metadata + let metadata = sniffer + .sniff_reader(&mut csv_reader) + .map_err(|_| "Failed")?; + // Reset the cursor to the beginning after sniffing + csv_reader.set_position(0); + let mut reader = ReaderBuilder::new() + .delimiter(metadata.dialect.delimiter) + .has_headers(false) + .from_reader(csv_reader); + for record in reader.records() { + match record { + Ok(r) => { + let mut column = area.column; + for value in &r { + let old_value = self + .model + .workbook + .worksheet(sheet)? + .cell(row, column) + .cloned(); + // let old_style = self.model.get_style_for_cell(sheet, row, column)?; + self.model + .set_user_input(sheet, row, column, value.to_string())?; + + diff_list.push(Diff::SetCellValue { + sheet, + row, + column, + new_value: value.to_string(), + old_value: Box::new(old_value), + }); + column += 1; + } + } + Err(_) => { + // skip + continue; + } + }; + row += 1; + } + self.push_diff_list(diff_list); + self.evaluate_if_not_paused(); + Ok(()) + } + // **** Private methods ****** // fn push_diff_list(&mut self, diff_list: DiffList) { diff --git a/base/src/user_model/mod.rs b/base/src/user_model/mod.rs index d35984b..66815c2 100644 --- a/base/src/user_model/mod.rs +++ b/base/src/user_model/mod.rs @@ -10,3 +10,4 @@ pub use common::UserModel; pub use ui::SelectedView; pub use common::BorderArea; +pub use common::ClipboardData; diff --git a/bindings/wasm/fix_types.py b/bindings/wasm/fix_types.py index 67aa826..d5db3b8 100644 --- a/bindings/wasm/fix_types.py +++ b/bindings/wasm/fix_types.py @@ -137,6 +137,52 @@ set_area_border_types = r""" setAreaWithBorder(area: Area, border_area: BorderArea): void; """ +paste_csv_string = r""" +/** +* @param {any} area +* @param {string} csv +*/ + pasteCsvText(area: any, csv: string): void; +""" + +paste_csv_string_types = r""" +/** +* @param {Area} area +* @param {string} csv +*/ + pasteCsvText(area: Area, csv: string): void; +""" + +clipboard = r""" +/** +* @returns {any} +*/ + copyToClipboard(): any; +""" + +clipboard_types = r""" +/** +* @returns {Clipboard} +*/ + copyToClipboard(): Clipboard; +""" + +paste_from_clipboard = r""" +/** +* @param {any} source_range +* @param {any} clipboard +*/ + pasteFromClipboard(source_range: any, clipboard: any): void; +""" + +paste_from_clipboard_types = r""" +/** +* @param {[number, number, number, number]} source_range +* @param {ClipboardData} clipboard +*/ + pasteFromClipboard(source_range: [number, number, number, number], clipboard: ClipboardData): void; +""" + def fix_types(text): text = text.replace(get_tokens_str, get_tokens_str_types) text = text.replace(update_style_str, update_style_str_types) @@ -147,6 +193,9 @@ def fix_types(text): text = text.replace(autofill_columns, autofill_columns_types) text = text.replace(set_cell_style, set_cell_style_types) text = text.replace(set_area_border, set_area_border_types) + text = text.replace(paste_csv_string, paste_csv_string_types) + text = text.replace(clipboard, clipboard_types) + text = text.replace(paste_from_clipboard, paste_from_clipboard_types) with open("types.ts") as f: types_str = f.read() header_types = "{}\n\n{}".format(header, types_str) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 94e9db9..5e3e6ad 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -6,7 +6,7 @@ use wasm_bindgen::{ use ironcalc_base::{ expressions::{lexer::util::get_tokens as tokenizer, types::Area, utils::number_to_column}, types::{CellType, Style}, - BorderArea, UserModel as BaseModel, + BorderArea, ClipboardData, UserModel as BaseModel, }; fn to_js_error(error: String) -> JsError { @@ -497,4 +497,37 @@ impl Model { pub fn set_name(&mut self, name: &str) { self.model.set_name(name); } + + #[wasm_bindgen(js_name = "copyToClipboard")] + pub fn copy_to_clipboard(&self) -> Result { + let data = self + .model + .copy_to_clipboard() + .map_err(|e| to_js_error(e.to_string())); + data.map(|x| serde_wasm_bindgen::to_value(&x).unwrap()) + } + + #[wasm_bindgen(js_name = "pasteFromClipboard")] + pub fn paste_from_clipboard( + &mut self, + source_range: JsValue, + clipboard: JsValue, + ) -> Result<(), JsError> { + let source_range: (i32, i32, i32, i32) = + serde_wasm_bindgen::from_value(source_range).map_err(|e| to_js_error(e.to_string()))?; + let clipboard: ClipboardData = + serde_wasm_bindgen::from_value(clipboard).map_err(|e| to_js_error(e.to_string()))?; + self.model + .paste_from_clipboard(source_range, &clipboard) + .map_err(|e| to_js_error(e.to_string())) + } + + #[wasm_bindgen(js_name = "pasteCsvText")] + pub fn paste_csv_string(&mut self, area: JsValue, csv: &str) -> Result<(), JsError> { + let range: Area = + serde_wasm_bindgen::from_value(area).map_err(|e| to_js_error(e.to_string()))?; + self.model + .paste_csv_string(&range, csv) + .map_err(|e| to_js_error(e.to_string())) + } } diff --git a/bindings/wasm/types.ts b/bindings/wasm/types.ts index 241d540..5deeb5b 100644 --- a/bindings/wasm/types.ts +++ b/bindings/wasm/types.ts @@ -205,3 +205,23 @@ export interface SelectedView { top_row: number; left_column: number; } + +// type ClipboardData = { +// [row: number]: { +// [column: number]: ClipboardCell; +// }; +// }; + +// type ClipboardData = Record>; +type ClipboardData = Map>; + +export interface ClipboardCell { + text: string; + style: CellStyle; +} + +export interface Clipboard { + csv: string; + data: ClipboardData; + range: [number, number, number, number]; +} \ No newline at end of file diff --git a/webapp/src/components/clipboard.ts b/webapp/src/components/clipboard.ts new file mode 100644 index 0000000..4a21d8a --- /dev/null +++ b/webapp/src/components/clipboard.ts @@ -0,0 +1,2 @@ +export const getNewClipboardId = () => new Date().toISOString(); +export const CLIPBOARD_ID_SESSION_STORAGE_KEY = "IronCalc-Clipboard"; diff --git a/webapp/src/components/workbook.tsx b/webapp/src/components/workbook.tsx index 6557f45..0a1fbf8 100644 --- a/webapp/src/components/workbook.tsx +++ b/webapp/src/components/workbook.tsx @@ -1,4 +1,9 @@ -import type { BorderOptions, Model, WorksheetProperties } from "@ironcalc/wasm"; +import type { + BorderOptions, + ClipboardCell, + Model, + WorksheetProperties, +} from "@ironcalc/wasm"; import { styled } from "@mui/material/styles"; import { useCallback, useEffect, useRef, useState } from "react"; import { @@ -6,6 +11,10 @@ import { LAST_COLUMN, ROW_HEIGH_SCALE, } from "./WorksheetCanvas/constants"; +import { + CLIPBOARD_ID_SESSION_STORAGE_KEY, + getNewClipboardId, +} from "./clipboard"; import FormulaBar from "./formulabar"; import Navigation from "./navigation/navigation"; import Toolbar from "./toolbar"; @@ -26,7 +35,20 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => { .map(({ name, color, sheet_id }: WorksheetProperties) => { return { name, color: color ? color : "#FFF", sheetId: sheet_id }; }); - + const focusWorkbook = useCallback(() => { + if (rootRef.current) { + rootRef.current.focus(); + // HACK: We need to select something inside the root for onCopy to work + const selection = window.getSelection(); + if (selection) { + selection.empty(); + const range = new Range(); + range.setStart(rootRef.current.firstChild as Node, 0); + range.setEnd(rootRef.current.firstChild as Node, 0); + selection.addRange(range); + } + } + }, []); const onRedo = () => { model.redo(); setRedrawId((id) => id + 1); @@ -279,7 +301,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => { return; } if (!workbookState.getEditingCell()) { - rootRef.current.focus(); + focusWorkbook(); } }); @@ -318,11 +340,112 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => { tabIndex={0} onClick={(event) => { if (!workbookState.getEditingCell()) { - rootRef.current?.focus(); + focusWorkbook(); } else { event.stopPropagation(); } }} + onPaste={(event: React.ClipboardEvent) => { + const { items } = event.clipboardData; + if (!items) { + return; + } + const mimeTypes = [ + "application/json", + "text/plain", + "text/csv", + "text/html", + ]; + let mimeType = null; + let value = null; + for (let index = 0; index < mimeTypes.length; index += 1) { + mimeType = mimeTypes[index]; + value = event.clipboardData.getData(mimeType); + if (value) { + break; + } + } + if (!mimeType || !value) { + // No clipboard data to paste + return; + } + if (mimeType === "application/json") { + // We are copying from within the application + const source = JSON.parse(value); + // const clipboardId = sessionStorage.getItem( + // CLIPBOARD_ID_SESSION_STORAGE_KEY + // ); + const data: Map> = new Map(); + const sheetData = source.sheetData; + for (const row of Object.keys(sheetData)) { + const dataRow = sheetData[row]; + const rowMap = new Map(); + for (const column of Object.keys(dataRow)) { + rowMap.set(Number.parseInt(column, 10), dataRow[column]); + } + data.set(Number.parseInt(row, 10), rowMap); + } + model.pasteFromClipboard(source.area, data); + setRedrawId((id) => id + 1); + } else if (mimeType === "text/plain") { + const { + sheet, + range: [rowStart, columnStart, rowEnd, columnEnd], + } = model.getSelectedView(); + const row = Math.min(rowStart, rowEnd); + const column = Math.min(columnStart, columnEnd); + const range = { + sheet, + row, + column, + width: Math.abs(columnEnd - columnStart) + 1, + height: Math.abs(rowEnd - rowStart) + 1, + }; + model.pasteCsvText(range, value); + setRedrawId((id) => id + 1); + } else { + // NOT IMPLEMENTED + } + event.preventDefault(); + event.stopPropagation(); + }} + onCopy={(event: React.ClipboardEvent) => { + const data = model.copyToClipboard(); + // '2024-10-18T14:07:37.599Z' + + let clipboardId = sessionStorage.getItem( + CLIPBOARD_ID_SESSION_STORAGE_KEY, + ); + if (!clipboardId) { + clipboardId = getNewClipboardId(); + sessionStorage.setItem(CLIPBOARD_ID_SESSION_STORAGE_KEY, clipboardId); + } + const sheetData: { + [row: number]: { + [column: number]: ClipboardCell; + }; + } = {}; + data.data.forEach((value, row) => { + const rowData: { + [column: number]: ClipboardCell; + } = {}; + value.forEach((val, column) => { + rowData[column] = val; + }); + sheetData[row] = rowData; + }); + const clipboardJsonStr = JSON.stringify({ + type: "copy", + area: data.range, + sheetData, + clipboardId, + }); + event.clipboardData.setData("text/plain", data.csv); + event.clipboardData.setData("application/json", clipboardJsonStr); + event.preventDefault(); + event.stopPropagation(); + }} + onCut={() => {}} > { formulaValue={formulaValue()} onChange={() => { setRedrawId((id) => id + 1); - rootRef.current?.focus(); + focusWorkbook(); }} onTextUpdated={() => { setRedrawId((id) => id + 1);