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);
});
}