diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 16e52c4c71..593c1fed7e 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -1617,16 +1617,29 @@ (when-let [{:keys [shape-id content]} (wasm.text-editor/text-editor-sync-content)] (v3-update-editor-content shape-id content :finalize? finalize?))) +(defn v3-restore-history + [direction] + (ptk/reify ::v3-restore-history + ptk/WatchEvent + (watch [_ _ _] + (if-let [{:keys [shape-id content]} (wasm.api/restore-text-editor-history direction)] + (do + (wasm.api/request-render-preserving-target "text-editor-history") + (rx/of (v3-update-editor-content shape-id content))) + (rx/empty))))) + (defn v3-apply-pending-caret-styles "Event saving an insertion made at `before` that consumed the pending caret style: the inserted text gets that style as its own span. Nil when nothing changed." [shape-id before] - (let [range (wasm.text-editor/typed-range before (wasm.text-editor/caret-position))] - ;; Sync first so the cached content stays index-aligned with WASM. - (wasm.text-editor/text-editor-sync-content) - (if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)] - (v3-update-editor-content shape-id content) - (v3-sync-editor-content)))) + (wasm.text-editor/with-history-edit + (fn [] + (let [range (wasm.text-editor/typed-range before (wasm.text-editor/caret-position))] + ;; Sync first so the cached content stays index-aligned with WASM. + (wasm.text-editor/text-editor-sync-content) + (if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)] + (v3-update-editor-content shape-id content) + (v3-sync-editor-content)))))) (defn- sync-editor-content-stream "Stream of the sync event for `reason`, after asking WASM to repaint." @@ -1853,31 +1866,33 @@ "Insert the text of `fragment` at the caret and restyle it: with its own styles or overrides, else with the pending caret style. Returns the event saving it." [state {:keys [fragment penpot?] :as paste}] - (let [shape-id (wasm.text-editor/text-editor-get-active-shape-id) - start (wasm.text-editor/selection-start) - styled? (or penpot? (text-paste/styled? fragment)) - pending? (some? (wasm.text-editor/get-pending-caret-styles shape-id))] - (when styled? - (wasm.text-editor/clear-pending-caret-styles!)) - (wasm.text-editor/text-editor-insert-text (text-paste/fragment->text fragment)) - (cond - (nil? start) - (v3-sync-editor-content) + (wasm.text-editor/with-history-edit + (fn [] + (let [shape-id (wasm.text-editor/text-editor-get-active-shape-id) + start (wasm.text-editor/selection-start) + styled? (or penpot? (text-paste/styled? fragment)) + pending? (some? (wasm.text-editor/get-pending-caret-styles shape-id))] + (when styled? + (wasm.text-editor/clear-pending-caret-styles!)) + (wasm.text-editor/text-editor-insert-text (text-paste/fragment->text fragment)) + (cond + (nil? start) + (v3-sync-editor-content) - styled? - (do - ;; Sync first so the cached content stays index-aligned with WASM. - (wasm.text-editor/text-editor-sync-content) - (if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start penpot?)] - (let [shape (dsh/lookup-shape state shape-id)] - (v3-update-editor-content shape-id (move-pasted-fills shape content paste start))) - (v3-sync-editor-content))) + styled? + (do + ;; Sync first so the cached content stays index-aligned with WASM. + (wasm.text-editor/text-editor-sync-content) + (if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start penpot?)] + (let [shape (dsh/lookup-shape state shape-id)] + (v3-update-editor-content shape-id (move-pasted-fills shape content paste start))) + (v3-sync-editor-content))) - pending? - (v3-apply-pending-caret-styles shape-id start) + pending? + (v3-apply-pending-caret-styles shape-id start) - :else - (v3-sync-editor-content)))) + :else + (v3-sync-editor-content)))))) (defn v3-paste "Paste clipboard `html` and `text` at the caret, replacing the selection." diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 6d497fef9c..0efcb5bda6 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -25,7 +25,9 @@ [app.util.keyboard :as kbd] [app.util.text.geometry :as text.geom] [app.util.timers :as ts] + [beicon.v2.core :as rx] [cuerdas.core :as str] + [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def caret-blink-interval-ms 250) @@ -609,6 +611,7 @@ ;; makes ibus abort and commit. The surface already sits on the ;; caret (see `update-ime-caret!`). (when (and (text-editor/text-editor-has-focus?) + (not (mf/ref-val composing-ref)) (not (composing-event? event))) (let [key (.-key event) ctrl? (or (.-ctrlKey event) (.-metaKey event)) @@ -620,6 +623,12 @@ (cond ;; NOTE: Escape is handled in a document key-up listener (see effect below). + (and ctrl? (contains? #{"z" "y"} (str/lower key))) + (do + (dom/prevent-default event) + (dom/stop-propagation event) + (st/emit! (if (or shift? (= (str/lower key) "y")) dwu/redo dwu/undo))) + ;; Ctrl+A: select all (key is "a" or "A" depending on platform) (and ctrl? (= (str/lower key) "a")) (do @@ -727,6 +736,11 @@ (mf/use-fn (fn [^js native] (mf/set-ref-val! pending-replace-ref 0) + (when (and (contains? #{"historyUndo" "historyRedo"} (.-inputType native)) + (not (mf/ref-val composing-ref)) + (not (.-isComposing native))) + (dom/prevent-default native) + (st/emit! (if (= (.-inputType native) "historyUndo") dwu/undo dwu/redo))) (when (and (= (.-inputType native) "insertText") (not (.-isComposing native)) (not (text-editor/text-editor-has-selection?))) @@ -917,6 +931,22 @@ ;; Keep the caret-translation origin in sync with the current render. (mf/set-ref-val! origin-ref {:x x :y y :width ime-width :vertical? vertical?}) + ;; The workspace leaves undo to the active editor. Handle the same events + ;; for keyboard shortcuts, native history input and workspace menu actions. + (mf/use-effect + (mf/deps) + (fn [] + (let [subscription + (->> st/stream + (rx/filter #(or (ptk/type? ::dwu/undo %) (ptk/type? ::dwu/redo %))) + (rx/subs! (fn [event] + (when-not (mf/ref-val composing-ref) + (st/emit! (dwt/v3-restore-history + (if (ptk/type? ::dwu/undo event) :undo :redo))) + (reset-input-node (mf/ref-val contenteditable-ref)) + (schedule-ime-caret!)))))] + #(rx/dispose! subscription)))) + ;; Exit on Escape via a document key-up listener (like v2). On key-down the trailing ;; key-up is read as a non-editing Escape and deselects the shape. (mf/use-effect diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 7c0406a6ba..2ac2ec1bce 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -769,6 +769,10 @@ (h/call wasm/internal-module "_update_shape_text_layout") result))) +(defn restore-text-editor-history + [direction] + (text-editor/restore-history direction wselect/use-shape set-shape-text-content)) + (defn apply-styles-to-selection "Apply style attrs to the currently selected text spans. Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving. diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index d2e44f5562..706a1c6e7b 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -20,6 +20,7 @@ ;; Required for side effects: binds the generated enums. [app.render-wasm.api.enums] [app.render-wasm.api.fonts :as fonts] + [app.render-wasm.text-editor-history :as history] [app.util.color :as uc] [app.util.dom :as dom])) @@ -199,6 +200,12 @@ (when selection (h/call wasm/internal-module "_text_editor_apply_theme" selection color invert?))))) +(def ^:private editing-history (atom nil)) + +(declare checkpoint-history record-content-history get-cached-content + default-empty-text-content text-editor-get-active-shape-id + text-editor-get-selection clear-pending-caret-styles!) + (defn text-editor-focus [id] (when (wasm/ready?) @@ -208,7 +215,9 @@ (aget buffer 1) (aget buffer 2) (aget buffer 3)) - (throw (js/Error. "TextEditor focus failed")))))) + (throw (js/Error. "TextEditor focus failed"))) + (when (not= id (:shape-id @editing-history)) + (reset! editing-history (assoc history/empty-history :shape-id id)))))) (defn text-editor-set-cursor-from-offset "Sets caret position from shape relative coordinates" @@ -417,6 +426,8 @@ (defn text-editor-composition-start [] (when (wasm/ready?) + (checkpoint-history) + (swap! editing-history assoc :composing true) (h/call wasm/internal-module "_text_editor_composition_start"))) (defn text-editor-composition-update @@ -437,11 +448,13 @@ (when (wasm/ready?) (text-editor-encode-text-pre text) (h/call wasm/internal-module "_text_editor_composition_end") - (text-editor-encode-text-post text))) + (text-editor-encode-text-post text) + (swap! editing-history assoc :composing false))) (defn text-editor-insert-text [text] (when (wasm/ready?) + (checkpoint-history) (text-editor-encode-text-pre text) (h/call wasm/internal-module "_text_editor_insert_text") (text-editor-encode-text-post text))) @@ -451,6 +464,7 @@ (text-editor-delete-backward false)) ([word-boundary] (when (wasm/ready?) + (checkpoint-history) (h/call wasm/internal-module "_text_editor_delete_backward" word-boundary)))) (defn text-editor-delete-forward @@ -458,10 +472,12 @@ (text-editor-delete-forward false)) ([word-boundary] (when (wasm/ready?) + (checkpoint-history) (h/call wasm/internal-module "_text_editor_delete_forward" word-boundary)))) (defn text-editor-insert-paragraph [] (when (wasm/ready?) + (checkpoint-history) (h/call wasm/internal-module "_text_editor_insert_paragraph"))) (defn text-editor-move-cursor @@ -493,7 +509,8 @@ (defn text-editor-dispose [] (when (wasm/ready?) - (h/call wasm/internal-module "_text_editor_dispose"))) + (h/call wasm/internal-module "_text_editor_dispose")) + (reset! editing-history nil)) (defn text-editor-has-focus? ([id] @@ -571,6 +588,12 @@ (defn cache-shape-text-content! [shape-id content] (when (some? content) + ;; Local edits update the cache before their store commit. A different + ;; incoming tree starts a new baseline, so undo cannot revert remote edits. + (when (and (= shape-id (:shape-id @editing-history)) + (not (:restoring @editing-history)) + (not= content (get @shape-text-contents shape-id))) + (reset! editing-history (assoc history/empty-history :shape-id shape-id))) (swap! shape-text-contents assoc shape-id content))) (defn get-cached-content @@ -579,8 +602,62 @@ (defn update-cached-content! [shape-id content] + (record-content-history shape-id content) (swap! shape-text-contents assoc shape-id content)) +(defn- history-snapshot + [shape-id] + {:content (or (get-cached-content shape-id) (default-empty-text-content)) + :selection (text-editor-get-selection)}) + +(defn- checkpoint-history + [] + (when-let [{:keys [shape-id composing before]} @editing-history] + (when (and (not composing) (nil? before)) + (swap! editing-history assoc :before (history-snapshot shape-id))))) + +(defn- record-content-history + [shape-id content] + (let [{active-id :shape-id :keys [composing restoring grouped before] :as edits} @editing-history] + (when (and (= active-id shape-id) (not composing) (not restoring) (not grouped)) + (let [before (or before (history-snapshot shape-id)) + after {:content content :selection (text-editor-get-selection)}] + (reset! editing-history (dissoc (history/record edits before after) :before)))))) + +(defn with-history-edit + "Keep insertion and its style updates in one history entry." + [f] + (if (or (nil? @editing-history) (:grouped @editing-history)) + (f) + (do + (checkpoint-history) + (swap! editing-history assoc :grouped true) + (try + (f) + (finally + (swap! editing-history dissoc :grouped) + (let [shape-id (:shape-id @editing-history)] + (record-content-history shape-id (get-cached-content shape-id)))))))) + +(defn restore-history + "Restore one edit from this session. An empty history never reaches canvas undo." + [direction use-shape set-content] + (when (and (wasm/ready?) (text-editor-has-focus?) + (not (:composing @editing-history))) + (let [{:keys [shape-id] :as edits} @editing-history] + (when-let [{:keys [history snapshot]} (history/step edits direction (history-snapshot shape-id))] + (reset! editing-history (assoc history :restoring true)) + (try + (clear-pending-caret-styles!) + (use-shape shape-id) + (set-content shape-id (:content snapshot)) + (let [{:keys [anchor-para anchor-offset focus-para focus-offset]} (:selection snapshot)] + (h/call wasm/internal-module "_text_editor_restore_selection" + anchor-para anchor-offset focus-para focus-offset)) + {:shape-id shape-id :content (:content snapshot)} + (finally + (swap! editing-history dissoc :restoring :before))))))) + ;; Typography chosen at a collapsed caret: not applied to existing text, but ;; picked up (as a new span) by the next inserted text. Keyed by shape-id. (def ^:private pending-caret-styles (atom {})) @@ -690,6 +767,7 @@ (default-empty-text-content))] (when content (let [merged (merge-exported-texts-into-content content texts-clj)] + (record-content-history shape-id merged) (swap! shape-text-contents assoc shape-id merged) {:shape-id shape-id :content merged}))))))) diff --git a/frontend/src/app/render_wasm/text_editor_history.cljs b/frontend/src/app/render_wasm/text_editor_history.cljs new file mode 100644 index 0000000000..e239a690c3 --- /dev/null +++ b/frontend/src/app/render_wasm/text_editor_history.cljs @@ -0,0 +1,26 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.render-wasm.text-editor-history) + +(def empty-history {:past [] :future []}) + +(defn record + [history before after] + (if (= (:content before) (:content after)) + history + (-> history + (update :past #(conj (vec (take-last 99 %)) before)) + (assoc :future [])))) + +(defn step + [history direction current] + (let [[from to] (case direction :undo [:past :future] :redo [:future :past])] + (when-let [snapshot (peek (get history from))] + {:snapshot snapshot + :history (-> history + (update from pop) + (update to conj current))}))) diff --git a/frontend/test/frontend_tests/render_wasm/texts_test.cljs b/frontend/test/frontend_tests/render_wasm/texts_test.cljs index 2adbea2f47..d1689734ef 100644 --- a/frontend/test/frontend_tests/render_wasm/texts_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/texts_test.cljs @@ -20,6 +20,7 @@ [app.render-wasm.api :as api] [app.render-wasm.api.texts :as texts] [app.render-wasm.text-editor :as text-editor] + [app.render-wasm.text-editor-history :as history] [app.util.i18n :as i18n] [app.util.text.content.styles :as content-styles] [cljs.test :as t :include-macros true] @@ -170,6 +171,80 @@ (text-editor/selection-japanese-styles content (selection 0 2 0 3))))))) +(t/deftest editor-history-restores-styled-content-and-selection + (let [before {:content (editor-content [{:text "A😀B" :ruby "a"}]) + :selection (selection 0 1 0 3)} + after {:content (editor-content [{:text "A你好B" :ruby "a"}]) + :selection (selection 0 3 0 3)} + edits (history/record history/empty-history before after) + undone (history/step edits :undo after)] + (t/is (= before (:snapshot undone))) + (t/is (= after (:snapshot (history/step (:history undone) :redo before)))))) + +(t/deftest editor-history-keeps-no-ops-out-and-discards-redo-after-editing + (let [a {:content "A" :selection (selection 0 1 0 1)} + b {:content "AB" :selection (selection 0 2 0 2)} + c {:content "AC" :selection (selection 0 2 0 2)} + edited (history/record history/empty-history a b) + undone (:history (history/step edited :undo b))] + (t/is (= undone (history/record undone a a))) + (t/is (nil? (history/step (history/record undone a c) :redo c))) + (t/is (nil? (history/step history/empty-history :undo a))))) + +(t/deftest editor-history-bounds-the-number-of-saved-edits + (let [edits (reduce (fn [edits n] + (history/record edits {:content n} {:content (inc n)})) + history/empty-history (range 150))] + (t/is (= 100 (count (:past edits)))) + (t/is (= 50 (:content (first (:past edits))))))) + +(t/deftest ^:async editor-history-groups-style-updates-and-committed-composition + (let [edits @#'text-editor/editing-history + cache @#'text-editor/shape-text-contents + old-edits @edits + old-cache @cache + before (editor-content [{:text "A😀B"}]) + inserted (editor-content [{:text "A你好B"}]) + styled (editor-content [{:text "A你好B" :ruby "nǐ hǎo"}]) + range (selection 0 1 0 3)] + (try + (await + (mock/with-mocks* + {text-editor/text-editor-get-selection (constantly range)} + (reset! cache {:fixture before}) + (reset! edits (assoc history/empty-history :shape-id :fixture)) + (text-editor/with-history-edit + (fn [] + (text-editor/update-cached-content! :fixture inserted) + (text-editor/update-cached-content! :fixture styled))) + (t/is (= [{:content before :selection range}] (:past @edits))) + (t/is (= styled (text-editor/get-cached-content :fixture))) + ;; Preview updates must never enter history. Commit the whole final + ;; reading once; a cancelled composition is a no-op and keeps redo. + (reset! cache {:fixture before}) + (reset! edits (assoc history/empty-history :shape-id :fixture)) + (@#'text-editor/checkpoint-history) + (swap! edits assoc :composing true) + (@#'text-editor/record-content-history :fixture inserted) + (t/is (empty? (:past @edits))) + (swap! edits assoc :composing false) + (text-editor/update-cached-content! :fixture inserted) + (t/is (= [before] (mapv :content (:past @edits)))) + (reset! cache {:fixture before}) + (reset! edits (assoc history/empty-history :shape-id :fixture + :future [{:content inserted :selection range}])) + (@#'text-editor/checkpoint-history) + (text-editor/update-cached-content! :fixture before) + (t/is (= [inserted] (mapv :content (:future @edits)))) + ;; Incoming content that bypasses local editor edits must not become + ;; a local undo entry or leave redo able to overwrite the new tree. + (text-editor/cache-shape-text-content! :fixture styled) + (t/is (empty? (:past @edits))) + (t/is (empty? (:future @edits))))) + (finally + (reset! edits old-edits) + (reset! cache old-cache))))) + (t/deftest composition-caret-goes-to-the-end-of-the-changed-part ;; Typing appends, converting replaces all, and a candidate for a middle ;; clause changes only that clause. diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index bd875ffdcb..d4c4345a2d 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1174,12 +1174,93 @@ pub extern "C" fn text_get_range_origin( }) } +fn restore_editor_selection( + editor: &mut TextEditorState, + content: &TextContent, + anchor_para: u32, + anchor_offset: u32, + focus_para: u32, + focus_offset: u32, +) -> bool { + let paragraphs = content.paragraphs(); + if paragraphs.is_empty() { + return false; + } + let position = |paragraph: u32, offset: u32| { + let paragraph = (paragraph as usize).min(paragraphs.len() - 1); + let mut utf16 = 0; + let offset = paragraphs[paragraph] + .children() + .iter() + .flat_map(|span| span.text.chars()) + .take_while(|ch| { + utf16 += ch.len_utf16(); + utf16 <= offset as usize + }) + .count(); + TextPositionWithAffinity::new_downstream_affinity(paragraph, offset) + }; + editor.selection.anchor = position(anchor_para, anchor_offset); + editor.selection.focus = position(focus_para, focus_offset); + editor.update_styles(content); + editor.reset_blink(); + editor.push_event(TextEditorEvent::SelectionChanged); + true +} + +/// Restore the host's UTF-16 selection after restoring a history entry. +#[no_mangle] +pub extern "C" fn text_editor_restore_selection( + anchor_para: u32, + anchor_offset: u32, + focus_para: u32, + focus_offset: u32, +) -> bool { + with_state!(state, { + let editor = get_text_editor_state(); + if !editor.has_focus || editor.composition.is_composing { + return false; + } + let Some(shape) = editor.active_shape_id.and_then(|id| state.shapes.get(&id)) else { + return false; + }; + let Type::Text(content) = &shape.shape_type else { + return false; + }; + restore_editor_selection( + editor, + content, + anchor_para, + anchor_offset, + focus_para, + focus_offset, + ) + }) +} + #[cfg(test)] mod tests { use super::*; use crate::shapes::{Group, GrowType, Paragraph, TextDirection, TextSpan, WritingMode}; use crate::tiles; + #[test] + fn history_selection_restores_utf16_ranges_without_splitting_surrogates() { + let mut resources = crate::render::RenderResources::try_new_headless().expect("resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let (mut state, id) = preview_state(GrowType::Fixed, WritingMode::VerticalRl); + let content = text_content_for_edit(&mut state, id).unwrap(); + content.paragraphs_mut()[0].children_mut()[0].text = "A😀한B".into(); + let mut editor = TextEditorState::new(); + assert!(restore_editor_selection(&mut editor, content, 0, 1, 0, 3)); + assert_eq!(editor.selection.anchor.offset, 1); + assert_eq!(editor.selection.focus.offset, 2); + assert!(restore_editor_selection(&mut editor, content, 0, 2, 9, 99)); + assert_eq!(editor.selection.anchor.offset, 1); + assert_eq!(editor.selection.focus.paragraph, 0); + assert_eq!(editor.selection.focus.offset, 4); + } + fn preview_state(grow_type: GrowType, writing_mode: WritingMode) -> (State, Uuid) { let id = Uuid::new_v4(); let mut state = State::new();