diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 6d21a3d129..baccf5703b 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -35,6 +35,7 @@ [app.main.features :as features] [app.main.fonts :as fonts] [app.main.router :as rt] + [app.main.store :as st] [app.render-wasm.api :as wasm.api] [app.render-wasm.text-editor :as wasm.text-editor] [app.util.text-editor :as ted] @@ -83,17 +84,23 @@ [] (ptk/reify ::focus-editor ptk/EffectEvent - (effect [_ state _] - (let [editor (:workspace-editor state) - element (when editor (.-element editor))] - (cond - ;; V1 (DraftEditor) - (.-focus editor) - (ts/schedule #(.focus ^js editor)) + (effect [_ _ _] + ;; The focus is deferred, so we re-read the current editor at fire + ;; time: the editor present now can be unmounted before the timeout + ;; runs (e.g. switching renderer while editing a text), and focusing a + ;; stale instance throws. + (ts/schedule + (fn [] + (let [editor (:workspace-editor @st/state) + element (when editor (.-element editor))] + (cond + ;; V1 (DraftEditor) + (and (some? editor) (.-focus editor)) + (.focus ^js editor) - ;; V2 - (and element (.-focus element)) - (ts/schedule #(.focus ^js element))))))) + ;; V2 + (and element (.-focus element)) + (.focus ^js element)))))))) (defn gen-name [editor] diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 9a18d0d83a..e59bc40856 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -33,7 +33,10 @@ (get-wasm-text-new-size shape (:content shape))) ([{:keys [id selrect grow-type] :as shape} content] - (when id + ;; Skip when the WASM context is not ready (e.g. switching renderer while a + ;; text shape is being edited): there is no design state to query, and + ;; returning nil makes callers skip the WASM resize/modifier path. + (when (and id (wasm.api/initialized?)) (wasm.api/use-shape id) (wasm.api/set-shape-text-content id content) (wasm.api/set-shape-text-images id content) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs index 5825af579a..7f0e5c0a01 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs @@ -85,28 +85,34 @@ parents (mf/deref parents-by-ids-ref) + ;; Deref every editor-state ref unconditionally so the hook order + ;; stays stable when feature flags change at runtime (e.g. switching + ;; renderer while editing a text). The selection happens afterwards. + wasm-editor-styles-map (mf/deref refs/workspace-wasm-editor-styles) + v2-editor-state-map (mf/deref refs/workspace-v2-editor-state) + v1-editor-state-map (mf/deref refs/workspace-editor-state) + editor (mf/deref refs/workspace-editor) + + text-editor-wasm? (features/active-feature? @st/state "text-editor-wasm/v1") + text-editor-v2? (features/active-feature? @st/state "text-editor/v2") + state-map (cond - (features/active-feature? @st/state "text-editor-wasm/v1") - (mf/deref refs/workspace-wasm-editor-styles) - - (features/active-feature? @st/state "text-editor/v2") - (mf/deref refs/workspace-v2-editor-state) - - :else - (mf/deref refs/workspace-editor-state)) + text-editor-wasm? wasm-editor-styles-map + text-editor-v2? v2-editor-state-map + :else v1-editor-state-map) editor-styles - (when (features/active-feature? @st/state "text-editor-wasm/v1") + (when text-editor-wasm? (get state-map id)) editor-state - (when (not (features/active-feature? @st/state "text-editor/v2")) + (when (not text-editor-v2?) (get state-map id)) editor-instance - (when (features/active-feature? @st/state "text-editor/v2") - (mf/deref refs/workspace-editor)) + (when text-editor-v2? + editor) fill-values (dwt/current-text-values diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 4b78301152..9c50fb8870 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -424,9 +424,9 @@ (js/clearTimeout timeout-id)) (wasm.api/clear-canvas))))) - (mf/with-effect [show-text-editor? workspace-editor-state edition] + (mf/with-effect [show-text-editor? workspace-editor-state edition @canvas-init? @initialized?] (let [active-editor-state (get workspace-editor-state edition)] - (when (and show-text-editor? active-editor-state) + (when (and show-text-editor? active-editor-state @canvas-init? @initialized?) (let [content (-> active-editor-state (ted/get-editor-current-content) (ted/export-content))] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 562a7bb136..914dddb107 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -85,6 +85,15 @@ (def ^:private snapshot-capture-debounce-ms 250) + +(defn initialized? + "True when the WASM render context is ready to receive design-state + operations. Use it to skip WASM work during transient states (e.g. while + switching renderer with a text shape being edited)." + [] + (and wasm/context-initialized? (not @wasm/context-lost?))) + + (defn set-transition-image-from-background! "Sets `transition-image-url*` to a data URL representing a solid background color." [background] @@ -161,8 +170,7 @@ (defonce ^:private schedule-canvas-snapshot-capture! (fns/debounce (fn [] - (when (and wasm/context-initialized? - (not @wasm/context-lost?) + (when (and (initialized?) (some? wasm/canvas)) (-> (webgl/capture-canvas-snapshot-url) (p/catch (fn [_] nil))))) @@ -323,14 +331,13 @@ [] ;; check if the context has not been lost already or we will get warnings about ;; removing objects from a non-current context - (when (and wasm/context-initialized? - (not @wasm/context-lost?)) + (when (initialized?) (h/call wasm/internal-module "_free_gpu_resources"))) ;; This should never be called from the outside. (defn- render [timestamp] - (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (when (initialized?) (h/call wasm/internal-module "_render" timestamp) ;; Update text editor blink (so cursor toggles) using the same timestamp @@ -361,13 +368,13 @@ (defn render-sync [] - (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (when (initialized?) (h/call wasm/internal-module "_render_sync") (set! wasm/internal-frame-id nil))) (defn render-sync-shape [id] - (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (when (initialized?) (let [buffer (uuid/get-u32 id)] (h/call wasm/internal-module "_render_sync_shape" (aget buffer 0) @@ -380,7 +387,7 @@ "Render a lightweight preview without tile caching. Used during progressive loading for fast feedback." [] - (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (when (initialized?) (h/call wasm/internal-module "_render_preview"))) @@ -394,7 +401,7 @@ (defn request-render [_requester] - (when (and wasm/context-initialized? (not @wasm/context-lost?) (not @wasm/disable-request-render?)) + (when (and (initialized?) (not @wasm/disable-request-render?)) (if @shapes-loading? (register-deferred-render!) (when-not @pending-render @@ -431,7 +438,7 @@ (defn use-shape [id] - (when wasm/context-initialized? + (when (initialized?) (let [buffer (uuid/get-u32 id)] (h/call wasm/internal-module "_use_shape" (aget buffer 0) @@ -441,7 +448,7 @@ (defn has-shape [id] - (when wasm/context-initialized? + (when (initialized?) (let [buffer (uuid/get-u32 id) result @@ -459,17 +466,20 @@ ;; Cache content for text editor sync (text-editor/cache-shape-text-content! shape-id content) - (h/call wasm/internal-module "_clear_shape_text") + ;; The WASM design state may not be ready (e.g. while switching renderer + ;; with a text shape being edited). Skip the WASM layout calls in that case. + (when (initialized?) + (h/call wasm/internal-module "_clear_shape_text") - (set-shape-vertical-align (get content :vertical-align)) + (set-shape-vertical-align (get content :vertical-align)) - (let [fonts (f/get-content-fonts content) - fallback-fonts (fonts-from-text-content content true) - all-fonts (concat fonts fallback-fonts) - result (f/store-fonts all-fonts)] - (f/load-fallback-fonts-for-editor! fallback-fonts) - (h/call wasm/internal-module "_update_shape_text_layout") - result)) + (let [fonts (f/get-content-fonts content) + fallback-fonts (fonts-from-text-content content true) + all-fonts (concat fonts fallback-fonts) + result (f/store-fonts all-fonts)] + (f/load-fallback-fonts-for-editor! fallback-fonts) + (h/call wasm/internal-module "_update_shape_text_layout") + result))) (defn apply-styles-to-selection "Apply style attrs to the currently selected text spans. @@ -1130,21 +1140,23 @@ (use-shape id) (get-text-dimensions)) ([] - (let [offset (-> (h/call wasm/internal-module "_get_text_dimensions") - (mem/->offset-32)) - heapf32 (mem/get-heap-f32) - width (aget heapf32 (+ offset 0)) - height (aget heapf32 (+ offset 1)) - max-width (aget heapf32 (+ offset 2)) + (if-not (initialized?) + {:x 0 :y 0 :width 0 :height 0 :max-width 0} + (let [offset (-> (h/call wasm/internal-module "_get_text_dimensions") + (mem/->offset-32)) + heapf32 (mem/get-heap-f32) + width (aget heapf32 (+ offset 0)) + height (aget heapf32 (+ offset 1)) + max-width (aget heapf32 (+ offset 2)) - x (aget heapf32 (+ offset 3)) - y (aget heapf32 (+ offset 4))] - (mem/free) - {:x x :y y :width width :height height :max-width max-width}))) + x (aget heapf32 (+ offset 3)) + y (aget heapf32 (+ offset 4))] + (mem/free) + {:x x :y y :width width :height height :max-width max-width})))) (defn intersect-position-in-shape [id position] - (if (and wasm/context-initialized? (not @wasm/context-lost?)) + (if (initialized?) (let [buffer (uuid/get-u32 id) result (h/call wasm/internal-module "_intersect_position_in_shape" @@ -1175,7 +1187,7 @@ (letfn [(do-render [] ;; Check if context is still initialized before executing ;; to prevent errors when navigating quickly - (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (when (initialized?) (view-interaction-end!) ;; Use async _render: visible tiles render synchronously ;; (no yield), interest-area tiles render progressively @@ -1202,8 +1214,7 @@ (defn sync-workspace-local-viewport! "Pushes `[:workspace-local :zoom]` and `:vbox` into WASM." [state] - (when (and wasm/context-initialized? - (not @wasm/context-lost?)) + (when (initialized?) (let [zoom (get-in state [:workspace-local :zoom]) vbox (get-in state [:workspace-local :vbox])] (when (and zoom vbox) @@ -1661,7 +1672,8 @@ (defn clean-modifiers [] - (h/call wasm/internal-module "_clean_modifiers")) + (when (initialized?) + (h/call wasm/internal-module "_clean_modifiers"))) (defn set-modifiers-start "Enter interactive transform mode (drag / resize / rotate). Enables @@ -1669,7 +1681,7 @@ backdrop so tiles do not appear sequentially or flicker while the gesture is in progress." [] - (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (when (initialized?) (h/call wasm/internal-module "_set_modifiers_start"))) (defn set-modifiers-end @@ -1677,7 +1689,7 @@ scheduled under it; the caller is expected to trigger a full-quality render (via `request-render`) once the gesture is committed." [] - (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (when (initialized?) (h/call wasm/internal-module "_set_modifiers_end"))) (defn set-modifiers @@ -2164,7 +2176,7 @@ (defn calculate-position-data [shape] - (when wasm/context-initialized? + (when (initialized?) (use-shape (:id shape)) (let [heapf32 (mem/get-heap-f32) heapu32 (mem/get-heap-u32) diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index 4bfe953c0a..5a75f65dc0 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -85,6 +85,31 @@ (track! :set-shape-grow-type) nil) +(defn- mock-initialized? + [] + (track! :initialized?) + true) + +;; The functions below used to short-circuit in tests because they guarded on +;; `wasm/context-initialized?` (always false without a real WASM binary). They +;; now guard on `initialized?`, which the mock forces to `true`, so they would +;; reach the real WASM heap. Stub them as no-ops to preserve that behavior. + +(defn- mock-use-shape + [_id] + (track! :use-shape) + nil) + +(defn- mock-calculate-position-data + [_shape] + (track! :calculate-position-data) + nil) + +(defn- mock-request-render + [_requester] + (track! :request-render) + nil) + (defn- mock-set-shape-text-content [_shape-id _content] (track! :set-shape-text-content) @@ -143,7 +168,11 @@ (reset-call-counts!) ;; Save originals (reset! originals - {:clean-modifiers wasm.api/clean-modifiers + {:initialized? wasm.api/initialized? + :use-shape wasm.api/use-shape + :calculate-position-data wasm.api/calculate-position-data + :request-render wasm.api/request-render + :clean-modifiers wasm.api/clean-modifiers :set-structure-modifiers wasm.api/set-structure-modifiers :propagate-modifiers wasm.api/propagate-modifiers :set-modifiers wasm.api/set-modifiers @@ -155,6 +184,10 @@ :make-font-data wasm.fonts/make-font-data :get-content-fonts wasm.fonts/get-content-fonts}) ;; Install mocks + (set! wasm.api/initialized? mock-initialized?) + (set! wasm.api/use-shape mock-use-shape) + (set! wasm.api/calculate-position-data mock-calculate-position-data) + (set! wasm.api/request-render mock-request-render) (set! wasm.api/clean-modifiers mock-clean-modifiers) (set! wasm.api/set-structure-modifiers mock-set-structure-modifiers) (set! wasm.api/propagate-modifiers mock-propagate-modifiers) @@ -171,6 +204,10 @@ "Restore the original WASM functions saved by `setup-wasm-mocks!`." [] (let [orig @originals] + (set! wasm.api/initialized? (:initialized? orig)) + (set! wasm.api/use-shape (:use-shape orig)) + (set! wasm.api/calculate-position-data (:calculate-position-data orig)) + (set! wasm.api/request-render (:request-render orig)) (set! wasm.api/clean-modifiers (:clean-modifiers orig)) (set! wasm.api/set-structure-modifiers (:set-structure-modifiers orig)) (set! wasm.api/propagate-modifiers (:propagate-modifiers orig))