🐛 Fix text selection render overlay and double/triple click flash (#11512)

This commit is contained in:
Elena Torró 2026-09-04 12:38:31 +02:00 committed by GitHub
parent 86aebd3429
commit 0533be100d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 75 additions and 124 deletions

View File

@ -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

View File

@ -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"))))

View File

@ -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

View File

@ -108,7 +108,7 @@ flowchart TB
FFI_Cursor["_text_editor_set_cursor_from_point<br/>_text_editor_move_cursor<br/>_text_editor_select_all"]
FFI_Edit["_text_editor_insert_text<br/>_text_editor_delete_backward<br/>_text_editor_insert_paragraph"]
FFI_Query["_text_editor_export_content<br/>_text_editor_get_selection<br/>_text_editor_poll_event"]
FFI_Render["_text_editor_render_overlay<br/>_text_editor_update_blink"]
FFI_Render["_text_editor_render_caret<br/>_text_editor_update_blink"]
end
subgraph Rust["Rust Layer"]

View File

@ -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).

View File

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