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 7f0806fd34..fe0dc8a03b 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 @@ -159,6 +159,10 @@ (or (.-isComposing native) (= 229 (.-keyCode event))))) +(defn- double-click? + [^js native-event] + (= (.-detail native-event) 2)) + (defn- triple-click? [^js native-event] (>= (.-detail native-event) 3)) @@ -194,6 +198,8 @@ ;; WASM `is_pointer_selection_active` guard), not on every hover move. dragging-ref (mf/use-ref false) + deferred-press-ref (mf/use-ref nil) + fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false) fallback-families (map (fn [font] (font-family-from-font-id (:font-id font))) fallback-fonts) @@ -459,23 +465,29 @@ (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] + off-pt (dom/get-offset-position native-event)] ;; Repositioning the caret abandons the pending caret style (also ;; covers click and double-click, which fire pointer-down first). (text-editor/clear-pending-caret-styles!) - (mf/set-ref-val! dragging-ref true) (if (.-shiftKey event) - (wasm.api/text-editor-pointer-down-extend off-pt) - (wasm.api/text-editor-pointer-down off-pt)) - ;; Repaint the caret over the cached tiles instead of a full render, - ;; which flashes at high zoom (see `render-text-editor-overlay!`). - (wasm.api/render-text-editor-overlay!)))) + (do + (mf/set-ref-val! dragging-ref true) + (wasm.api/text-editor-pointer-down-extend off-pt) + ;; Repaint the caret over the cached tiles instead of a full + ;; render, which flashes at high zoom. + (wasm.api/render-text-editor-overlay!)) + (mf/set-ref-val! deferred-press-ref off-pt))))) on-pointer-move (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] + off-pt (dom/get-offset-position native-event)] + (when-let [pressed-pt (and (pos? (.-buttons native-event)) + (mf/ref-val deferred-press-ref))] + (mf/set-ref-val! deferred-press-ref nil) + (mf/set-ref-val! dragging-ref true) + (wasm.api/text-editor-pointer-down pressed-pt)) (wasm.api/text-editor-pointer-move off-pt) ;; Only while dragging: `text-editor-pointer-move` is a no-op ;; otherwise, so avoid repainting on plain hover. @@ -486,20 +498,37 @@ (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] + off-pt (dom/get-offset-position native-event) + dragging? (mf/ref-val dragging-ref)] (mf/set-ref-val! dragging-ref false) + (mf/set-ref-val! deferred-press-ref nil) (wasm.api/text-editor-pointer-up off-pt) - (wasm.api/render-text-editor-overlay!)))) + ;; Without a drag there is no pointer selection to close; the + ;; caret is placed by `on-click`. + (when dragging? + (wasm.api/render-text-editor-overlay!))))) on-click (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] - (if (triple-click? native-event) - (wasm.api/text-editor-select-paragraph off-pt) - (wasm.api/text-editor-set-cursor-from-offset off-pt)) - (wasm.api/render-text-editor-overlay!)))) + off-pt (dom/get-offset-position native-event)] + (cond + (triple-click? native-event) + (do + (wasm.api/text-editor-select-paragraph off-pt) + (wasm.api/render-text-editor-overlay!)) + + ;; `dblclick` selects the word right after. Shift+click still goes + ;; through: WASM consumes its skip-click flag there. + (and (double-click? native-event) + (not (.-shiftKey event))) + nil + + :else + (do + (wasm.api/text-editor-set-cursor-from-offset off-pt) + (wasm.api/render-text-editor-overlay!)))))) on-double-click (mf/use-fn diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 07fb91e98b..a9c1718580 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -397,11 +397,6 @@ (or (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) (= wasm/internal-frame-type FRAME_TYPE_VIEWPORT_READY))) -(defn- frame-presented-target? - "True when this frame recomposited Target (full or early viewport present)." - [] - (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL)) - (def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause). (defn- internal-render @@ -507,18 +502,8 @@ (try (when (is-text-editor-wasm-enabled @st/state) (text-editor/text-editor-update-blink timestamp) - ;; Only repaint the overlay when this frame recomposited Target (a full - ;; frame or early viewport present). A partial frame is flushed but not - ;; presented - Target still shows the last presented frame with the - ;; overlay already on it - so repainting the translucent selection over - ;; it stacks another layer every progressive frame: it darkens, then - ;; snaps back when the final frame presents from the clean Backbuffer - ;; (the blink at the end of a zoom over a selection, gh-10709). - (when (frame-presented-target?) - (text-editor/text-editor-render-overlay)) - ;; Drain editor events. Only content/layout changes need a full shape - ;; re-render; selection/style changes are already reflected by the - ;; overlay redrawn just above. + ;; The editor overlay is painted by the WASM frame composition; only + ;; content/layout changes need a full shape re-render here. (when (drain-text-editor-events!) (request-render-preserving-target "text-editor-content"))) (catch :default e @@ -1421,28 +1406,6 @@ (let [local (get @st/state :workspace-local)] (or (:panning local) (:zooming local)))) -(defn- render-text-editor-overlay-if-active! - "Redraw the editor caret/selection straight onto the current Target frame when - an editor is active (no-op otherwise). Used after the direct `_render_from_cache` - / `internal-render` calls of a view interaction, which bypass the rAF `render` - loop that normally repaints the overlay. Without it the selection blinks out - for the duration of a pan/zoom gesture over a text shape (gh-10709)." - [] - (when (is-text-editor-wasm-enabled @st/state) - (text-editor/text-editor-render-overlay))) - -(defn- render-text-editor-overlay-after-frame! - "Repaint the overlay after a direct `internal-render`, but only when that - render recomposited Target (a full frame or early viewport present). A partial - frame is only flushed - Target keeps the last presented frame with the overlay - already on it - so repainting the translucent selection then stacks another - layer and it visibly darkens across the progressive frames before snapping - back on the final present (the blink at the end of a zoom over a selection, - gh-10709). The final full frame's own repaint keeps the overlay in place." - [] - (when (frame-presented-target?) - (render-text-editor-overlay-if-active!))) - (defn finalize-view-interaction! "Ends an in-progress pan/zoom view interaction and triggers a full-quality render. No-ops when no view interaction is active. @@ -1461,12 +1424,7 @@ ;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered ;; ends (e.g. selecting a shape opens the options panel and resizes the ;; viewport), which previously blanked. - (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) - ;; The direct render above bypasses the rAF `render` loop, so repaint the - ;; editor overlay explicitly. Only when this was a full frame: a progressive - ;; render keeps painting through the rAF loop and its partial frames must not - ;; be over-stamped (see `render-text-editor-overlay-after-frame!`). - (render-text-editor-overlay-after-frame!))) + (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES))) (def render-finish (letfn [(do-render [] @@ -1475,9 +1433,7 @@ (when (initialized?) (if (view-gesture-active?) ;; Pan/zoom pause: render without ending the interaction. - (do - (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) - (render-text-editor-overlay-after-frame!)) + (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) (finalize-view-interaction!))))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) @@ -1495,15 +1451,6 @@ (perf/begin-measure "render-from-cache") (h/call wasm/internal-module "_render_from_cache" 0) - ;; Keep the text-editor caret/selection glued to the shapes while the view - ;; changes. `_render_from_cache` re-composites shapes + UI at the new viewbox - ;; but omits the editor overlay, so without this the selection would vanish for - ;; the whole pan/zoom gesture and only flash back when the debounced full - ;; render lands — the blink seen when zooming in/out over a selection at high - ;; zoom (gh-10709). `_text_editor_render_overlay` draws straight onto the - ;; freshly composited Target (no Backbuffer re-compose) and no-ops when no - ;; editor is active. - (render-text-editor-overlay-if-active!) (render-finish) (perf/end-measure "render-from-cache")))) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index cbf6bf6bbd..9e73ff4ce8 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -245,11 +245,6 @@ (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms))) -(defn text-editor-render-overlay - [] - (when (wasm/ready?) - (h/call wasm/internal-module "_text_editor_render_overlay"))) - (defn text-editor-render-caret "Re-compose the frame from the Backbuffer (the last full render) and draw the caret/selection overlay on top, submitting one atomic frame. Pixel identical diff --git a/render-wasm/docs/text_editor.md b/render-wasm/docs/text_editor.md index 8b65fb1f22..21c73c55db 100644 --- a/render-wasm/docs/text_editor.md +++ b/render-wasm/docs/text_editor.md @@ -108,7 +108,7 @@ flowchart TB FFI_Cursor["_text_editor_set_cursor_from_point
_text_editor_move_cursor
_text_editor_select_all"] FFI_Edit["_text_editor_insert_text
_text_editor_delete_backward
_text_editor_insert_paragraph"] FFI_Query["_text_editor_export_content
_text_editor_get_selection
_text_editor_poll_event"] - FFI_Render["_text_editor_render_overlay
_text_editor_update_blink"] + FFI_Render["_text_editor_render_caret
_text_editor_update_blink"] end subgraph Rust["Rust Layer"] diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 932e8f80a4..64767b29bb 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -27,6 +27,7 @@ use options::RenderOptions; pub use surfaces::{SurfaceId, Surfaces}; use crate::error::{Error, Result}; +use crate::globals::get_text_editor_state; use crate::math; use crate::shapes::{ all_with_ancestors, modifier_changes_text_layout, radius_to_sigma, Blur, BlurType, Corners, @@ -985,11 +986,34 @@ impl RenderState { debug::render(self); } if !self.preview_mode { + self.render_text_editor_overlay(tree); ui::render(self, tree); } debug::render_wasm_label(self); } + /// Drawn on Target before the UI surface is composited, so rulers and guides + /// stay above the selection band + fn render_text_editor_overlay(&mut self, tree: ShapesPoolRef) { + let editor_state = get_text_editor_state(); + let Some(shape_id) = editor_state.active_shape_id else { + return; + }; + let Some(shape) = tree.get(&shape_id) else { + return; + }; + + let viewbox = self.viewbox; + let options = self.options; + text_editor::render_overlay( + self.surfaces.canvas(SurfaceId::Target), + &viewbox, + &options, + editor_state, + shape, + ); + } + /// Renders only the canvas background and UI surface (rulers/frame), without /// rebuilding or drawing any shape tiles. Used to show the viewport frame /// immediately before shape tiles are built (e.g., right after a DPR change). diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 258acd6601..5db04aab98 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -3,8 +3,6 @@ use macros::{wasm_error, ToJs}; use crate::globals::{get_render_state, get_text_editor_state}; use crate::math::{Matrix, Point}; use crate::mem; -use crate::render::text_editor as text_editor_render; -use crate::render::SurfaceId; use crate::shapes::{TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::state::{State, TextEditorEvent, TextEditorState}; use crate::utils::uuid_from_u32_quartet; @@ -851,7 +849,7 @@ fn update_text_layout_if_needed(state: &mut State, shape_id: Uuid) { /// Repaint the caret/selection over the last fully rendered frame. /// /// Re-composes Target from the Backbuffer (which still holds the last complete -/// render) and draws the editor overlay on top, in a single submitted frame. +/// render); the compose step draws the editor overlay itself. /// /// This exists because the caret blink must erase the previous caret, which /// means restoring the pixels underneath it. Doing that via `render_from_cache` @@ -867,49 +865,7 @@ pub extern "C" fn text_editor_render_caret() { }; update_text_layout_if_needed(state, shape_id); - - let Some(shape) = state.shapes.get(&shape_id) else { - return; - }; - - get_render_state().compose_frame(&state.shapes); - - let canvas = get_render_state().surfaces.canvas(SurfaceId::Target); - let viewbox = get_render_state().viewbox; - text_editor_render::render_overlay( - canvas, - &viewbox, - &get_render_state().options, - get_text_editor_state(), - shape, - ); - get_render_state().flush_and_submit(); - }); -} - -#[no_mangle] -pub extern "C" fn text_editor_render_overlay() { - with_state!(state, { - let Some(shape_id) = get_text_editor_state().active_shape_id else { - return; - }; - - update_text_layout_if_needed(state, shape_id); - - let Some(shape) = state.shapes.get(&shape_id) else { - return; - }; - - let canvas = get_render_state().surfaces.canvas(SurfaceId::Target); - let viewbox = get_render_state().viewbox; - text_editor_render::render_overlay( - canvas, - &viewbox, - &get_render_state().options, - get_text_editor_state(), - shape, - ); - get_render_state().flush_and_submit(); + get_render_state().present_frame(&state.shapes); }); }