diff --git a/Cargo.lock b/Cargo.lock index 0c26573..e379c25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,6 +1084,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + [[package]] name = "url" version = "2.3.1" @@ -1246,4 +1252,5 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "unicode-segmentation", ] diff --git a/Cargo.toml b/Cargo.toml index 16e43a7..752e976 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,4 @@ tokio = { version = "1.25.0", features = ["full"] } tower-http = { version = "0.3.5", features = ["trace"] } tracing = "0.1.37" tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } +unicode-segmentation = "1.10.1" diff --git a/assets/index.js b/assets/index.js index 874ce13..e0f15c9 100644 --- a/assets/index.js +++ b/assets/index.js @@ -1,10 +1,10 @@ const areaId = "doctext"; const validEvents = [ - "insertText", - "insertFromPaste", - "insertLineBreak", - "deleteContentBackward", - "deleteContentForward", + "insertText", + "insertFromPaste", + "insertLineBreak", + "deleteContentBackward", + "deleteContentForward", ]; let selectionStart = 0; @@ -16,60 +16,71 @@ let ws; const uuid = self.crypto.randomUUID(); const wsUrl = "ws://localhost:3000/ws"; -function setup() { - area = document.querySelector(`#${areaId}`); - ws = new WebSocket(wsUrl); +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); - setupUi(); - setupWs(); +function setup() { + area = document.querySelector(`#${areaId}`); + ws = new WebSocket(wsUrl); + + setupUi(); + setupWs(); } function setupUi() { - document.addEventListener("selectionchange", onSelectionChange, false); - area.addEventListener("input", onInput, false); + document.addEventListener("selectionchange", onSelectionChange, false); + area.addEventListener("beforeinput", onInput, false); } function setupWs() { - ws.onclose = function (e) { - console.log(e); - setTimeout(() => { - ws = new WebSocket(wsUrl); - }, 2000); - }; + ws.onclose = function (e) { + console.log(e); + setTimeout(() => { + ws = new WebSocket(wsUrl); + }, 2000); + }; - ws.onmessage = function (e) { - let payload = JSON.parse(e.data); - if (payload.client !== uuid) { - area.value = payload.doc; - } - }; + ws.onmessage = function (e) { + let payload = JSON.parse(e.data); + if (payload.client !== uuid) { + console.log(payload.doc); + area.value = payload.doc; + } + }; } function onSelectionChange() { - const activeElement = document.activeElement; + const activeElement = document.activeElement; - if (activeElement && activeElement.id === areaId) { - selectionStart = area.selectionStart; - selectionEnd = area.selectionEnd; - } + if (activeElement && activeElement.id === areaId) { + console.debug("onSelectionChange", area); + selectionStart = area.selectionStart; + selectionEnd = area.selectionEnd; + } } function onInput(event) { - if (!validEvents.includes(event.inputType)) return; - const payload = { - client: uuid, - action: event.inputType, - data: event.data, - start: selectionStart, - end: selectionEnd, - }; + if (!validEvents.includes(event.inputType)) return; - ws.send(JSON.stringify(payload)); + // workaround for differences between firefox and chrome + // chrome does not fire selectionchange events on backspace/delete events, + // while firefox does + if (event.inputType === "deleteContentBackward") { + selectionStart = area.selectionStart; + selectionEnd = area.selectionEnd; + } - console.log(event.inputType); - console.log(selectionStart); - console.log(selectionEnd); - console.log(event.data); + const payload = { + client: uuid, + action: event.inputType, + data: event.data, + start: selectionStart, + end: selectionEnd, + }; + + ws.send(JSON.stringify(payload)); + + console.log(selectionStart, selectionEnd); } setup(); diff --git a/src/action.rs b/src/action.rs index 1feafe2..ee5f6c2 100644 --- a/src/action.rs +++ b/src/action.rs @@ -3,9 +3,7 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use crate::actions::delete::{DeleteContentBackward, DeleteContentForward}; -use crate::actions::insert::InsertText; -use crate::actions::linebreak::InsertLineBreak; -use crate::actions::paste::InsertFromPaste; +use crate::actions::insert::{InsertText, InsertFromPaste, InsertLineBreak}; use crate::actions::ActionRunner; use crate::AppState; @@ -40,7 +38,11 @@ impl Action { ActionType::DeleteContentForward => Box::new(DeleteContentForward), }; - *doc = action.run(self.start, self.end, self.data.clone(), doc.clone()); + let data = match self.data.to_owned() { + None => Vec::new(), + Some(data) => data.encode_utf16().collect() + }; + *doc = action.run(self.start, self.end, data, doc.to_owned()); } } } diff --git a/src/actions/delete.rs b/src/actions/delete.rs index c701b08..5880178 100644 --- a/src/actions/delete.rs +++ b/src/actions/delete.rs @@ -1,22 +1,45 @@ use super::ActionRunner; pub(crate) struct DeleteContentBackward; + pub(crate) struct DeleteContentForward; -impl ActionRunner for DeleteContentBackward { - fn run(&self, start: usize, _end: usize, _data: Option, mut doc: String) -> String { - if start > 0 { - doc.remove(start - 1); +fn delete(start: usize, end: usize, mut doc: Vec) -> Vec { + if start > doc.len() + 1 { + return doc; + } + + let mut start_idx = start; + let mut end_idx = end; + unsafe { + while start_idx > 0 && matches!(doc.get_unchecked(start_idx), 0xDC00..=0xDFFF) { + start_idx -= 1; } - doc + + let end_byte = doc.get_unchecked(end_idx - 1); + if end_idx < doc.len() && end_byte > &0xD800 && end_byte < &0xDC00 { + end_idx += 1; + } + } + + doc.drain(start_idx..end_idx); + doc +} + +impl ActionRunner for DeleteContentBackward { + fn run(&self, start: usize, end: usize, _data: Vec, doc: Vec) -> Vec { + if (start == 0 && start == end ) || end > doc.len() { return doc; } + let (end, start) = if start == end { (start, start - 1) } else { (end, start) }; + + delete(start, end, doc) } } impl ActionRunner for DeleteContentForward { - fn run(&self, start: usize, _end: usize, _data: Option, mut doc: String) -> String { - if doc.len() > start { - doc.remove(start); - } - doc + fn run(&self, start: usize, end: usize, _data: Vec, doc: Vec) -> Vec { + if start >= doc.len() { return doc; } + + let end = if start == end { start + 1 } else { end }; + delete(start, end, doc) } } diff --git a/src/actions/insert.rs b/src/actions/insert.rs index 868edd6..a99ad61 100644 --- a/src/actions/insert.rs +++ b/src/actions/insert.rs @@ -1,18 +1,38 @@ use super::ActionRunner; pub(crate) struct InsertText; +pub(crate) struct InsertFromPaste; +pub(crate) struct InsertLineBreak; + +const NL_BYTES: [u16; 1] = [10]; + +fn insert(start: usize, end: usize, data: Vec, mut doc: Vec) -> Vec { + if doc.len() < start || data.is_empty() { + return doc; + } + + if start < end { + doc.drain(start..end); + } + + doc.splice(start..start, data); + doc +} impl ActionRunner for InsertText { - fn run(&self, start: usize, end: usize, data: Option, mut doc: String) -> String { - if doc.len() < start || data.is_none() { - return doc; - } - - if start < end { - doc.replace_range(start..end, ""); - } - - doc.insert_str(start, &data.unwrap()); - doc + fn run(&self, start: usize, end: usize, data: Vec, doc: Vec) -> Vec { + insert(start, end, data, doc) + } +} + +impl ActionRunner for InsertFromPaste { + fn run(&self, start: usize, end: usize, data: Vec, doc: Vec) -> Vec { + insert(start, end, data, doc) + } +} + +impl ActionRunner for InsertLineBreak { + fn run(&self, start: usize, _end: usize, _data: Vec, doc: Vec) -> Vec { + insert(start, start, Vec::from(NL_BYTES), doc) } } diff --git a/src/actions/linebreak.rs b/src/actions/linebreak.rs deleted file mode 100644 index 5f4ce8d..0000000 --- a/src/actions/linebreak.rs +++ /dev/null @@ -1,12 +0,0 @@ -use super::ActionRunner; - -pub(crate) struct InsertLineBreak; - -impl ActionRunner for InsertLineBreak { - fn run(&self, start: usize, _end: usize, _data: Option, mut doc: String) -> String { - if doc.len() >= start { - doc.insert(start, '\n'); - } - doc - } -} diff --git a/src/actions/mod.rs b/src/actions/mod.rs index b6eb7dc..d33bf93 100644 --- a/src/actions/mod.rs +++ b/src/actions/mod.rs @@ -1,8 +1,6 @@ pub(crate) mod delete; pub(crate) mod insert; -pub(crate) mod linebreak; -pub(crate) mod paste; pub(crate) trait ActionRunner { - fn run(&self, start: usize, end: usize, data: Option, doc: String) -> String; + fn run(&self, start: usize, end: usize, data: Vec, doc: Vec) -> Vec; } diff --git a/src/actions/paste.rs b/src/actions/paste.rs deleted file mode 100644 index c0f6d2c..0000000 --- a/src/actions/paste.rs +++ /dev/null @@ -1,18 +0,0 @@ -use super::ActionRunner; - -pub(crate) struct InsertFromPaste; - -impl ActionRunner for InsertFromPaste { - fn run(&self, start: usize, end: usize, data: Option, mut doc: String) -> String { - if doc.len() < start || data.is_none() { - return doc; - } - - if start < end { - doc.replace_range(start..end, ""); - } - - doc.insert_str(start, &data.unwrap()); - doc - } -} diff --git a/src/main.rs b/src/main.rs index 583ec66..c7a7faa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ mod actions; mod ws; struct AppState { - doc: RwLock, + doc: RwLock>, tx: broadcast::Sender, } @@ -29,7 +29,7 @@ async fn main() { let (tx, _rx) = broadcast::channel(100); let app_state = Arc::new(AppState { - doc: RwLock::new("".to_string()), + doc: RwLock::new(Vec::new()), tx, }); diff --git a/src/ws.rs b/src/ws.rs index 5b40644..68f4279 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -31,7 +31,7 @@ async fn handle_socket(stream: WebSocket, state: Arc) { { let doc = state.doc.read().await; - let doc: String = doc.clone(); + let doc: String = String::from_utf16_lossy(&doc); let new_state = NewState { client: String::from("0000"), doc: doc.to_string(), @@ -64,7 +64,7 @@ async fn handle_socket(stream: WebSocket, state: Arc) { let new_state = NewState { client, - doc: doc.to_string(), + doc: String::from_utf16_lossy(&doc), }; let payload = serde_json::to_string(&new_state).unwrap(); let _ = tx.send(payload);