collective

Browse / github.com/storytold/wordcraft / patch

fix(web): copy, cut and paste with formatting; Mod+V pastes once

Web build: Cmd/Ctrl+V pastes twice ## Summary In the web build, once something has been copied in WordCraft, **Cmd/Ctrl+V pastes twice**: first WordCraft's own last copy, then the clipboard. Seen in the official v0.11.0 web build and on current `main` (2cf1d6c7ae94723a45bd6e7e8c884fe375148750), in Chrome on macOS, served from `http://127.0.0.1` (a secure context). (Split from the original report at the triage bot's request. The two formatting problems are now #4536 and #4537.) ## Steps to reproduce 1. Open the web build, then File ▸ New ▸ Blank document. Type `Quiet harbour`. 2. Press Cmd+A, then Cmd+C, then → (Right arrow

Status
open
Score
0.00
Adopters
0 other operators
Author
inter0925 (imported from pull request #4525, open)
Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750
Size
+251 −4 in 6 files
Review
pending
Shared
2026-10-28
Id
2bb48976207eb8a68b0eecd4

How it went

No other operator has reported using it yet.

Diff

raw · apps/wordcraft-web/Cargo.toml apps/wordcraft-web/src/clipboard_input.rs apps/wordcraft-web/src/main.rs apps/wordcraft-web/src/web.rs apps/wordcraft-web/src/web/clipboard.rs docs/typing-parity.md

diff --git a/apps/wordcraft-web/Cargo.toml b/apps/wordcraft-web/Cargo.toml--- a/apps/wordcraft-web/Cargo.toml+++ b/apps/wordcraft-web/Cargo.toml@@ -29,4 +29,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3"-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "KeyboardEvent", "BeforeUnloadEvent", "AddEventListenerOptions", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }diff --git a/apps/wordcraft-web/src/clipboard_input.rs b/apps/wordcraft-web/src/clipboard_input.rsnew file mode 100644--- /dev/null+++ b/apps/wordcraft-web/src/clipboard_input.rs@@ -0,0 +1,79 @@+//! What the browser's clipboard gestures hand the app (`web::clipboard`), kept apart from the+//! browser so it can be tested natively.++use serde_json::{Value, json};+use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;++/// A press of Mod+C, Mod+X or Mod+V with no Shift or Alt. The browser sends these as key presses+/// and then as its copy, cut or paste event, which is the gesture, so the press is dropped+/// before the app sees it: as a shortcut it would run the command a second time.+pub(crate) fn is_clipboard_key(e: &egui::Event) -> bool {+    matches!(e, egui::Event::Key { key: egui::Key::C | egui::Key::X | egui::Key::V, pressed: true, modifiers, .. }+        if modifiers.command && !modifiers.shift && !modifiers.alt)+}++/// The `edit.paste` params for a paste event's text and HTML, as the desktop app pastes another+/// program's formatted copy. An empty clipboard (no text, HTML or files) pastes WordCraft's own+/// copy, as Mod+V did as a shortcut: a copied picture alone leaves the browser's clipboard+/// empty. `None` for text alone, or when either is larger than a paste may be: the text is then+/// pasted as before. Also `None` for HTML without text: a browser's Copy Image puts the picture+/// beside HTML that only points at it, and the picture listener inserts the picture.+pub(crate) fn paste_params(text: &str, html: &str, files: bool) -> Option<Value> {+    if text.is_empty() && html.trim().is_empty() && !files {+        return Some(json!({}));+    }+    if text.is_empty() || html.trim().is_empty() || html.len() > MAX_PASTE_BYTES || text.len() > MAX_PASTE_BYTES {+        return None;+    }+    Some(json!({"text": text.replace("\r\n", "\n"), "html": html}))+}++#[cfg(test)]+mod tests {+    use super::*;++    fn key(key: egui::Key, pressed: bool, modifiers: egui::Modifiers) -> egui::Event {+        egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }+    }++    #[test]+    fn only_the_plain_clipboard_presses_are_the_browsers() {+        use egui::{Key, Modifiers};+        for m in [Modifiers::COMMAND, Modifiers::CTRL | Modifiers::COMMAND, Modifiers::MAC_CMD | Modifiers::COMMAND] {+            for k in [Key::C, Key::X, Key::V] {+                assert!(is_clipboard_key(&key(k, true, m)), "{k:?} with {m:?}");+                assert!(!is_clipboard_key(&key(k, false, m)), "the release stays: {k:?}");+            }+        }+        // Copy and Paste Formatting, Paste Special, plain letters and other shortcuts stay.+        assert!(!is_clipboard_key(&key(Key::C, true, Modifiers::COMMAND | Modifiers::SHIFT)));+        assert!(!is_clipboard_key(&key(Key::V, true, Modifiers::COMMAND | Modifiers::SHIFT)));+        assert!(!is_clipboard_key(&key(Key::V, true, Modifiers::COMMAND | Modifiers::ALT)));+        assert!(!is_clipboard_key(&key(Key::V, true, Modifiers::NONE)));+        assert!(!is_clipboard_key(&key(Key::B, true, Modifiers::COMMAND)));+        assert!(!is_clipboard_key(&egui::Event::Paste("text".into())));+        assert!(!is_clipboard_key(&egui::Event::Copy));+    }++    #[test]+    fn a_paste_with_html_keeps_it_beside_the_text() {+        let html = "<p><b>Bold</b> plain</p>";+        assert_eq!(paste_params("Bold plain\r\nnext", html, false), Some(json!({"text": "Bold plain\nnext", "html": html})));+        assert_eq!(paste_params("", "<img src=\"https://example.com/a.png\">", false), None, "a copied picture is the picture listener's");+        assert_eq!(paste_params("plain", "", false), None, "text alone is eframe's");+        assert_eq!(paste_params("plain", " \n ", false), None);+        let big = "x".repeat(MAX_PASTE_BYTES + 1);+        assert_eq!(paste_params("plain", &big, false), None, "too large");+        assert_eq!(paste_params(&big, html, false), None, "too large");+    }++    /// A picture copied alone in WordCraft leaves the browser's clipboard empty; Mod+V pastes+    /// WordCraft's own copy, as it did when the key press ran Paste.+    #[test]+    fn an_empty_clipboard_pastes_wordcrafts_own_copy() {+        assert_eq!(paste_params("", "", false), Some(json!({})));+        assert_eq!(paste_params("", " ", false), Some(json!({})));+        assert_eq!(paste_params("", "", true), None, "a pasted picture file is the picture listener's");+        assert_eq!(paste_params("plain", "", false), None, "text is eframe's");+    }+}diff --git a/apps/wordcraft-web/src/main.rs b/apps/wordcraft-web/src/main.rs--- a/apps/wordcraft-web/src/main.rs+++ b/apps/wordcraft-web/src/main.rs@@ -13,6 +13,8 @@ //! comments and tracked changes. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(target_arch = "wasm32", test))]+mod clipboard_input; #[cfg(any(target_arch = "wasm32", test))] mod prefs; #[cfg(any(target_arch = "wasm32", test))]diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs--- a/apps/wordcraft-web/src/web.rs+++ b/apps/wordcraft-web/src/web.rs@@ -12,6 +12,7 @@ use wordcraft_ui_egui::{Inbox, Services, WordApp}; const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]; const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"]; const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;+mod clipboard; mod font_store; const CANVAS_ID: &str = "wordcraft_canvas"; const LOADING_ID: &str = "wordcraft_loading";@@ -38,13 +39,16 @@ pub fn start() {         {             log::error!("no unsaved-changes guard: {e}");         }+        let in_clipboard_event = clipboard::InEvent::default();+        let app_in_clipboard_event = in_clipboard_event.clone();         let mut options = eframe::WebOptions::default();         if query().contains("webgl")             && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup         {             create.instance_descriptor.backends = eframe::wgpu::Backends::GL;         }-        let result = eframe::WebRunner::new()+        let runner = eframe::WebRunner::new();+        let result = runner             .start(                 canvas,                 options,@@ -69,6 +73,8 @@ pub fn start() {                     session.system_region = region;                     let messages = Rc::new(RefCell::new(Vec::new()));                     let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());+                    // A copy's HTML goes in the browser's copy event (`clipboard`).+                    services.set_clipboard_html = Some(app_in_clipboard_event.service());                     if let Some(bridge) = &app_bridge {                         bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());                     }@@ -99,10 +105,15 @@ pub fn start() {                         crate::host::Bridge::set_author(&mut app, &author);                         prefs.keep_own_author(&app.session.author, &own);                     }-                    Ok(Box::new(WebShell { app, inbox, messages, prefs }))+                    Ok(Box::new(WebShell { app, inbox, messages, prefs, ctx: cc.egui_ctx.clone() }))                 }),             )             .await;+        if result.is_ok()+            && let Err(e) = clipboard::install(&runner, in_clipboard_event)+        {+            log::error!("copying and pasting with formatting is unavailable: {e}");+        }         // Ready only once the runner is up: starting can still fail after the app is made.         if let Some(bridge) = &bridge {             match &result {@@ -134,6 +145,7 @@ struct WebShell {     inbox: Inbox,     messages: Rc<RefCell<Vec<String>>>,     prefs: crate::prefs::PrefsSync,+    ctx: egui::Context, }  fn local_storage() -> Option<web_sys::Storage> {@@ -146,6 +158,12 @@ fn stored_prefs() -> Option<String> { }  impl eframe::App for WebShell {+    /// For the clipboard listeners, which run a command inside the browser's event+    /// ([`eframe::WebRunner::app_mut`]).+    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {+        Some(&mut *self)+    }+     /// Runs one browser frame: forwards queued host messages to the status bar, advances the app,     /// then writes changed preferences to browser storage immediately, because a reload or closed     /// tab never runs the exit path.@@ -170,6 +188,11 @@ impl eframe::App for WebShell {      /// Forwards this frame's raw browser input to the app before widgets run.     fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {+        // Mod+C, Mod+X and Mod+V are the browser's copy, cut and paste events (`clipboard`, or+        // eframe's text-only handlers). As shortcuts their presses ran the command a second+        // time: Mod+V pasted WordCraft's own last copy and then the clipboard, and Mod+C wrote+        // plain text over the copy's formatting. (egui-winit sends the desktop app only the events.)+        raw.events.retain(|e| !crate::clipboard_input::is_clipboard_key(e));         self.app.raw_input_hook(ctx, raw);     } diff --git a/apps/wordcraft-web/src/web/clipboard.rs b/apps/wordcraft-web/src/web/clipboard.rsnew file mode 100644--- /dev/null+++ b/apps/wordcraft-web/src/web/clipboard.rs@@ -0,0 +1,134 @@+//! Copy, Cut and Paste with the browser's clipboard in its own formats. eframe's handlers carry+//! only text: a copy reaches the clipboard as plain text (written after the event, through the+//! asynchronous clipboard API) and a paste reads only its text, so formatting was lost both ways,+//! while the desktop app puts HTML beside the text and reads another program's HTML+//! (`wordcraft_ui_egui::system_clipboard`).+//!+//! A page reads and writes the clipboard's formats synchronously only in the trusted `copy`,+//! `cut` and `paste` events, while they run, and the canvas handles eframe's copy event in the+//! next frame, after it is over. So these listeners run the command then and there with the+//! event's `clipboardData`, ahead of eframe's handlers, and stop the event so the command doesn't+//! run twice. What they leave alone (the keyboard elsewhere than the document's text, an input+//! method composing, an equation, nothing selected, a paste with no HTML or no text, Paste+//! Special's modifiers) goes to eframe and the picture listener as before.++use std::cell::Cell;+use std::rc::Rc;++use serde_json::{Value, json};+use wasm_bindgen::JsCast as _;++use super::WebShell;++/// Set while a copy or cut event runs its command: the copy's HTML and text then go in the+/// event's `clipboardData`, so egui must not write its plain text over them afterwards.+#[derive(Clone, Default)]+pub(super) struct InEvent(Rc<Cell<bool>>);++impl InEvent {+    /// The `set_clipboard_html` service: `true` (the HTML and text are on the clipboard) only+    /// during a copy or cut event, where the listener writes them.+    pub(super) fn service(&self) -> Box<dyn Fn(&str, &str, &str) -> bool> {+        let on = self.0.clone();+        Box::new(move |_, _, _| on.get())+    }+}++/// Listen for copy, cut and paste on the document, ahead of eframe (capture phase).+pub(super) fn install(runner: &eframe::WebRunner, in_event: InEvent) -> Result<(), String> {+    let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;+    let options = web_sys::AddEventListenerOptions::new();+    options.set_capture(true);+    // Alt and Shift as the browser has them now: egui's input only catches up in the next frame,+    // and a quick Alt+Mod+V can reach the paste event before that.+    let held = Rc::new(Cell::new((false, false)));+    for kind in ["keydown", "keyup"] {+        let held = held.clone();+        let on_key = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(move |e: web_sys::KeyboardEvent| {+            held.set((e.alt_key(), e.shift_key()));+        });+        document+            .add_event_listener_with_callback_and_add_event_listener_options(kind, on_key.as_ref().unchecked_ref(), &options)+            .map_err(|e| format!("{e:?}"))?;+        on_key.forget();+    }+    for kind in ["copy", "cut", "paste"] {+        let runner = runner.clone();+        let in_event = in_event.clone();+        let held = held.clone();+        let on_event = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {+            if !e.is_trusted() {+                return;+            }+            let Some(data) = e.clipboard_data() else { return };+            let Some(mut shell) = runner.app_mut::<WebShell>() else { return };+            let handled = if kind == "paste" { paste(&mut shell, &data, held.get()) } else { copy(&mut shell, &data, kind == "cut", &in_event) };+            if handled {+                e.prevent_default();+                e.stop_immediate_propagation();+                shell.ctx.request_repaint();+            }+        });+        document+            .add_event_listener_with_callback_and_add_event_listener_options(kind, on_event.as_ref().unchecked_ref(), &options)+            .map_err(|e| format!("{e:?}"))?;+        // The listeners live as long as the page.+        on_event.forget();+    }+    Ok(())+}++/// Whether the document's text has the keyboard, with no composition under way.+fn editing(shell: &WebShell) -> bool {+    shell.app.canvas.focused && shell.app.canvas.ime_preedit.is_empty() && shell.app.session.math.is_none()+}++/// Copy or Cut the selection into the event's clipboard: its text, and its HTML when it has+/// formatting to carry. A copy with no text (a lone picture) is left as the app puts it. `false`+/// (eframe copies) when nothing is selected in the document's text.+fn copy(shell: &mut WebShell, data: &web_sys::DataTransfer, cut: bool, in_event: &InEvent) -> bool {+    if !editing(shell) || shell.app.session.sel.is_collapsed() {+        return false;+    }+    in_event.0.set(true);+    let r = shell.app.run(if cut { "edit.cut" } else { "edit.copy" }, json!({}));+    in_event.0.set(false);+    let r = match r {+        Ok(r) => r,+        Err(e) => {+            log::warn!("copy failed: {e}");+            // The command ran (and said why it failed in the status bar): not again in eframe's handler.+            return true;+        }+    };+    let s = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("");+    if s("text").is_empty() {+        return true;+    }+    let mut ok = data.set_data("text/plain", s("text")).is_ok();+    if !s("html").is_empty() {+        ok &= data.set_data("text/html", s("html")).is_ok();+    }+    if !ok {+        log::warn!("the browser refused the copy's clipboard data");+    }+    true+}++/// Paste the event's HTML with its text, or WordCraft's own copy when the clipboard is empty+/// ([`crate::clipboard_input::paste_params`]). `false` (eframe and the picture listener take it)+/// for text or pictures alone, when it is too large, or with Alt or Shift held (`held`: Paste+/// Special, Keep Text Only).+fn paste(shell: &mut WebShell, data: &web_sys::DataTransfer, held: (bool, bool)) -> bool {+    if !editing(shell) || held.0 || held.1 {+        return false;+    }+    let html = data.get_data("text/html").unwrap_or_default();+    let text = data.get_data("text/plain").unwrap_or_default();+    let files = data.files().is_some_and(|f| f.length() > 0);+    let Some(params) = crate::clipboard_input::paste_params(&text, &html, files) else { return false };+    if let Err(e) = shell.app.run("edit.paste", params) {+        log::warn!("paste failed: {e}");+    }+    true+}diff --git a/docs/typing-parity.md b/docs/typing-parity.md--- a/docs/typing-parity.md+++ b/docs/typing-parity.md@@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** trivial (the web build copies, cuts and pastes with formatting through the browser's clipboard, and ⌘V pastes once; previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4  What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in@@ -93,6 +93,14 @@ audit, which measured WordCraft's own history. | Paste (⌘V) of plain text with several lines from another program, in the middle of a right-to-left paragraph | pasted as Keep Text Only pastes it: every line becomes a paragraph formatted like the paragraph it lands in (direction, style, alignment, indents, list), and so does the text after the caret. One ⌘Z takes the whole paste out ² | `plain_text_paste_gives_every_line_the_formatting_of_its_paragraph` | | … with empty lines, or a line break at the end | each empty line is an empty paragraph with that formatting too, in a list an empty item. The text takes the formatting before the caret (bold there makes it bold) ² | `plain_text_paste_keeps_empty_lines_as_paragraphs`, `plain_text_paste_keeps_an_empty_paragraph_in_a_list` | +In the web build, ⌘C / ⌘X put the copy on the browser's clipboard with its formatting (HTML+beside the text, as the desktop app does), and ⌘V of another program's formatted copy keeps its+formatting; one press pastes once. Before, the browser's clipboard got only text, a paste read+only text, and ⌘V first pasted WordCraft's own last copy, then the clipboard+(`clipboard_input::tests::only_the_plain_clipboard_presses_are_the_browsers`,+`a_paste_with_html_keeps_it_beside_the_text` in `apps/wordcraft-web`; the browser events+themselves are checked by hand, in Chrome).+ ² From Microsoft's description of the paste options: Word offers no options for text without formatting, and Keep Text Only gives text the style of the paragraph where it is pasted and the formatting of the text before the cursor. Not yet compared side by side in Word.@@ -492,6 +500,7 @@ again.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | trivial | Paste (web build): ⌘C / ⌘X put HTML beside the text on the browser's clipboard, ⌘V keeps another program's formatting, and ⌘V no longer pastes WordCraft's own last copy before the clipboard. | | 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). | | 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). | | 2026-10-24 | trivial | Input methods: reject queued old-composition replacements before mutation after body/equation target replacement (new regression validation pending); equation `DeleteSurrounding` is one transactional command whose checked revision proves a receipt; a caret-only step in a mixed request refuses the whole deletion, and an over-cap request clears the painted pre-edit and the previous replacement join; a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, never a later direct commit; a tracked own-insertion shifts the following range; a body deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its replacement undo join to the matching `text.insert`; refusals clear the painted pre-edit and joins; AutoCorrect/AutoFormat classification compares the paragraph as typed; equation replacement interrupts the composition |