🐛 Fix text editor v3 blinks (#10654)

This commit is contained in:
Belén Albeza 2026-07-24 09:14:59 +02:00 committed by GitHub
parent c467d98c9e
commit 4f46700c94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 285 additions and 76 deletions

View File

@ -113,6 +113,11 @@
;; `input`. See on-before-input / on-input. ;; `input`. See on-before-input / on-input.
pending-replace-ref (mf/use-ref 0) pending-replace-ref (mf/use-ref 0)
;; Tracks an in-flight pointer drag-selection so `on-pointer-move` only
;; repaints the selection overlay while a drag is active (mirrors the
;; WASM `is_pointer_selection_active` guard), not on every hover move.
dragging-ref (mf/use-ref false)
fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false) fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false)
fallback-families (map (fn [font] fallback-families (map (fn [font]
(font-family-from-font-id (:font-id font))) fallback-fonts) (font-family-from-font-id (:font-id font))) fallback-fonts)
@ -157,7 +162,7 @@
(when (some? data) (when (some? data)
(text-editor/text-editor-composition-update data) (text-editor/text-editor-composition-update data)
(sync-wasm-text-editor-content!) (sync-wasm-text-editor-content!)
(wasm.api/request-render "text-composition")) (wasm.api/request-render-preserving-target "text-composition"))
(reset-input-node (mf/ref-val contenteditable-ref))))) (reset-input-node (mf/ref-val contenteditable-ref)))))
on-composition-end on-composition-end
@ -166,7 +171,7 @@
(let [data (or (.-data event) "")] (let [data (or (.-data event) "")]
(text-editor/text-editor-composition-end data) (text-editor/text-editor-composition-end data)
(sync-wasm-text-editor-content!) (sync-wasm-text-editor-content!)
(wasm.api/request-render "text-composition")) (wasm.api/request-render-preserving-target "text-composition"))
(reset-input-node (mf/ref-val contenteditable-ref)))) (reset-input-node (mf/ref-val contenteditable-ref))))
on-paste on-paste
@ -178,7 +183,7 @@
(when (and text (seq text)) (when (and text (seq text))
(text-editor/text-editor-insert-text text) (text-editor/text-editor-insert-text text)
(sync-wasm-text-editor-content!) (sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paste")) (wasm.api/request-render-preserving-target "text-paste"))
(reset-input-node (mf/ref-val contenteditable-ref))))) (reset-input-node (mf/ref-val contenteditable-ref)))))
on-copy on-copy
@ -201,7 +206,7 @@
(when (and text (seq text)) (when (and text (seq text))
(text-editor/text-editor-delete-backward) (text-editor/text-editor-delete-backward)
(sync-wasm-text-editor-content!) (sync-wasm-text-editor-content!)
(wasm.api/request-render "text-cut")))) (wasm.api/request-render-preserving-target "text-cut"))))
(reset-input-node (mf/ref-val contenteditable-ref))))) (reset-input-node (mf/ref-val contenteditable-ref)))))
on-key-down on-key-down
@ -225,7 +230,7 @@
(do (do
(dom/prevent-default event) (dom/prevent-default event)
(text-editor/text-editor-select-all) (text-editor/text-editor-select-all)
(wasm.api/request-render "text-select-all")) (wasm.api/render-text-editor-overlay!))
;; Enter ;; Enter
(= key "Enter") (= key "Enter")
@ -233,7 +238,7 @@
(dom/prevent-default event) (dom/prevent-default event)
(text-editor/text-editor-insert-paragraph) (text-editor/text-editor-insert-paragraph)
(sync-wasm-text-editor-content!) (sync-wasm-text-editor-content!)
(wasm.api/request-render "text-paragraph")) (wasm.api/request-render-preserving-target "text-paragraph"))
;; Backspace ;; Backspace
(= key "Backspace") (= key "Backspace")
@ -241,7 +246,7 @@
(dom/prevent-default event) (dom/prevent-default event)
(text-editor/text-editor-delete-backward ctrl?) (text-editor/text-editor-delete-backward ctrl?)
(sync-wasm-text-editor-content!) (sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-backward")) (wasm.api/request-render-preserving-target "text-delete-backward"))
;; Delete ;; Delete
(= key "Delete") (= key "Delete")
@ -249,51 +254,51 @@
(dom/prevent-default event) (dom/prevent-default event)
(text-editor/text-editor-delete-forward ctrl?) (text-editor/text-editor-delete-forward ctrl?)
(sync-wasm-text-editor-content!) (sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-forward")) (wasm.api/request-render-preserving-target "text-delete-forward"))
;; Insert ;; Insert
(= key "Insert") (= key "Insert")
(do (do
(dom/prevent-default event) (dom/prevent-default event)
(text-editor/text-editor-toggle-overtype-mode) (text-editor/text-editor-toggle-overtype-mode)
(wasm.api/request-render "text-overtype-mode")) (wasm.api/render-text-editor-overlay!))
;; Arrow keys ;; Arrow keys
(= key "ArrowLeft") (= key "ArrowLeft")
(do (do
(dom/prevent-default event) (dom/prevent-default event)
(text-editor/text-editor-move-cursor 0 ctrl? shift?) (text-editor/text-editor-move-cursor 0 ctrl? shift?)
(wasm.api/request-render "text-cursor-move")) (wasm.api/render-text-editor-overlay!))
(= key "ArrowRight") (= key "ArrowRight")
(do (do
(dom/prevent-default event) (dom/prevent-default event)
(text-editor/text-editor-move-cursor 1 ctrl? shift?) (text-editor/text-editor-move-cursor 1 ctrl? shift?)
(wasm.api/request-render "text-cursor-move")) (wasm.api/render-text-editor-overlay!))
(= key "ArrowUp") (= key "ArrowUp")
(do (do
(dom/prevent-default event) (dom/prevent-default event)
(text-editor/text-editor-move-cursor 2 ctrl? shift?) (text-editor/text-editor-move-cursor 2 ctrl? shift?)
(wasm.api/request-render "text-cursor-move")) (wasm.api/render-text-editor-overlay!))
(= key "ArrowDown") (= key "ArrowDown")
(do (do
(dom/prevent-default event) (dom/prevent-default event)
(text-editor/text-editor-move-cursor 3 ctrl? shift?) (text-editor/text-editor-move-cursor 3 ctrl? shift?)
(wasm.api/request-render "text-cursor-move")) (wasm.api/render-text-editor-overlay!))
(= key "Home") (= key "Home")
(do (do
(dom/prevent-default event) (dom/prevent-default event)
(text-editor/text-editor-move-cursor 4 ctrl? shift?) (text-editor/text-editor-move-cursor 4 ctrl? shift?)
(wasm.api/request-render "text-cursor-move")) (wasm.api/render-text-editor-overlay!))
(= key "End") (= key "End")
(do (do
(dom/prevent-default event) (dom/prevent-default event)
(text-editor/text-editor-move-cursor 5 ctrl? shift?) (text-editor/text-editor-move-cursor 5 ctrl? shift?)
(wasm.api/request-render "text-cursor-move")) (wasm.api/render-text-editor-overlay!))
;; Let contenteditable handle text input via on-input ;; Let contenteditable handle text input via on-input
:else nil))))) :else nil)))))
@ -341,7 +346,7 @@
(text-editor/text-editor-delete-backward))) (text-editor/text-editor-delete-backward)))
(text-editor/text-editor-insert-text data) (text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!) (sync-wasm-text-editor-content!)
(wasm.api/request-render "text-input")) (wasm.api/request-render-preserving-target "text-input"))
(mf/set-ref-val! pending-replace-ref 0) (mf/set-ref-val! pending-replace-ref 0)
;; IMPORTANT: do NOT clear the surface here (see keep-input-alive): ;; IMPORTANT: do NOT clear the surface here (see keep-input-alive):
;; the browser must retain the just-typed character so the macOS ;; the browser must retain the just-typed character so the macOS
@ -353,35 +358,47 @@
(fn [^js event] (fn [^js event]
(let [native-event (dom/event->native-event 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)]
(wasm.api/text-editor-pointer-down off-pt)))) (mf/set-ref-val! dragging-ref true)
(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!))))
on-pointer-move on-pointer-move
(mf/use-fn (mf/use-fn
(fn [^js event] (fn [^js event]
(let [native-event (dom/event->native-event 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)]
(wasm.api/text-editor-pointer-move off-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.
(when (mf/ref-val dragging-ref)
(wasm.api/render-text-editor-overlay!)))))
on-pointer-up on-pointer-up
(mf/use-fn (mf/use-fn
(fn [^js event] (fn [^js event]
(let [native-event (dom/event->native-event 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)]
(wasm.api/text-editor-pointer-up off-pt)))) (mf/set-ref-val! dragging-ref false)
(wasm.api/text-editor-pointer-up off-pt)
(wasm.api/render-text-editor-overlay!))))
on-click on-click
(mf/use-fn (mf/use-fn
(fn [^js event] (fn [^js event]
(let [native-event (dom/event->native-event 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)]
(wasm.api/text-editor-set-cursor-from-offset off-pt)))) (wasm.api/text-editor-set-cursor-from-offset off-pt)
(wasm.api/render-text-editor-overlay!))))
on-double-click on-double-click
(mf/use-fn (mf/use-fn
(fn [^js event] (fn [^js event]
(let [native-event (dom/event->native-event 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)]
(wasm.api/text-editor-select-word-boundary off-pt)))) (wasm.api/text-editor-select-word-boundary off-pt)
(wasm.api/render-text-editor-overlay!))))
on-focus on-focus
(mf/use-fn (mf/use-fn
@ -420,21 +437,30 @@
;; Focus and select all text on mount (this will trigger on-focus) ;; Focus and select all text on mount (this will trigger on-focus)
(.focus node) (.focus node)
(text-editor/text-editor-select-all) (text-editor/text-editor-select-all)
(wasm.api/request-render "text-editor-select-all-on-mount")) (wasm.api/request-render-preserving-target "text-editor-select-all-on-mount"))
;; On unmount, finalize the editor content and then dispose the WASM editor. ;; On unmount, finalize the editor content and then dispose the WASM editor.
;; We finalize on unmount instead of relying on the browser blur event, because ;; We finalize on unmount instead of relying on the browser blur event, because
;; it was not being reliable (timing issues, Firefox issues…) ;; it was not being reliable (timing issues, Firefox issues…)
(fn [] (fn []
(on-blur) (on-blur)
(text-editor/text-editor-dispose) (text-editor/text-editor-dispose)
(wasm.api/request-render "text-editor-dispose")))) (wasm.api/request-render-preserving-target "text-editor-dispose"))))
(mf/use-effect (mf/use-effect
(mf/deps)
(fn [] (fn []
(let [timeout-id (atom nil) (let [timeout-id (atom nil)
schedule-blink (fn schedule-blink [] schedule-blink (fn schedule-blink []
(when (text-editor/text-editor-has-focus?) ;; The caret only blinks for a collapsed cursor. With an active
(wasm.api/request-render "cursor-blink")) ;; selection there is nothing to animate, so skip the repaint:
;; re-compositing every interval would otherwise redraw the
;; selection over and over (a visible flicker at high zoom).
(when (and (text-editor/text-editor-has-focus?)
(not (text-editor/text-editor-has-selection?)))
;; Redraw only the caret (cached frame + overlay) instead of a
;; full `request-render`, which flashes on zoomed-in views by
;; kicking off a progressive tile-by-tile shape re-render.
(wasm.api/render-text-editor-overlay!))
(reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))] (reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))]
(schedule-blink) (schedule-blink)
(fn [] (fn []

View File

@ -586,19 +586,22 @@
(wasm.api/text-editor-apply-theme) (wasm.api/text-editor-apply-theme)
(wasm.api/request-render "text-editor-colors-theme"))))) (wasm.api/request-render "text-editor-colors-theme")))))
;; Ruler overlay updates below only change the UI surface, not the shapes. ;; Ruler overlay updates below only change the UI surface, not the shapes,
;; They use `render-from-cache!` (cached tiles + UI, atomic) instead of a full ;; and they fire on a stable viewbox (toggles / selection changes, not pan).
;; `request-render`, which would kick off a progressive tile-by-tile shape ;; They re-present via `render-from-backbuffer!` — reusing the crisp last
;; re-render that flashes on zoomed-in views (see penpot ruler-selection flash). ;; frame + fresh UI — instead of a full `request-render` (which would kick
;; off a progressive tile-by-tile re-render that flashes) or a cached-atlas
;; blit (whose scale-capped atlas flashes crisp->blurry on zoomed-in views,
;; e.g. when the text editor opens at high zoom).
(mf/with-effect [@canvas-init? frame-visible?] (mf/with-effect [@canvas-init? frame-visible?]
(when @canvas-init? (when @canvas-init?
(wasm.api/set-rulers-frame-visible! frame-visible?) (wasm.api/set-rulers-frame-visible! frame-visible?)
(wasm.api/render-from-cache!))) (wasm.api/render-from-backbuffer!)))
(mf/with-effect [@canvas-init? show-rulers?] (mf/with-effect [@canvas-init? show-rulers?]
(when @canvas-init? (when @canvas-init?
(wasm.api/set-rulers-visible! show-rulers?) (wasm.api/set-rulers-visible! show-rulers?)
(wasm.api/render-from-cache!))) (wasm.api/render-from-backbuffer!)))
(mf/with-effect [@canvas-init? show-rulers? offset-x offset-y] (mf/with-effect [@canvas-init? show-rulers? offset-x offset-y]
(when (and @canvas-init? show-rulers?) (when (and @canvas-init? show-rulers?)
@ -609,7 +612,7 @@
(some-> ruler-selection :width) (some-> ruler-selection :height)] (some-> ruler-selection :width) (some-> ruler-selection :height)]
(when (and @canvas-init? show-rulers?) (when (and @canvas-init? show-rulers?)
(wasm.api/set-rulers-selection! ruler-selection) (wasm.api/set-rulers-selection! ruler-selection)
(wasm.api/render-from-cache!))) (wasm.api/render-from-backbuffer!)))
;; Paint background + rulers instantly, before shapes finish loading. Runs ;; Paint background + rulers instantly, before shapes finish loading. Runs
;; after the ruler push effects so the WASM ruler state is already set. ;; after the ruler push effects so the WASM ruler state is already set.

View File

@ -351,6 +351,8 @@
(declare request-render) (declare request-render)
(declare set-shape-vertical-align fonts-from-text-content) (declare set-shape-vertical-align fonts-from-text-content)
(declare reload-renderer!) (declare reload-renderer!)
(declare request-render-preserving-target)
(declare render-pending?)
;; These are the type of frames we have in our ;; These are the type of frames we have in our
;; render pipeline. ;; render pipeline.
@ -419,32 +421,52 @@
(when (initialized?) (when (initialized?)
(h/call wasm/internal-module "_free_gpu_resources"))) (h/call wasm/internal-module "_free_gpu_resources")))
;; When set, the next render keeps the previously presented frame on screen
;; while the new tiles are rasterized, instead of clearing the canvas first.
;; See `request-render-preserving-target`.
(defonce ^:private preserve-target-render? (atom false))
(defn- drain-text-editor-events!
"Pop and handle every pending text-editor event.
StylesChanged syncs the caret's current styles to the toolbar. Returns true
when some event needs a full shape re-render (content or layout changed)."
[]
(loop [needs-render? false]
(let [ev (text-editor/text-editor-poll-event)]
(if (or (nil? ev) (= ev TEXT_EDITOR_EVENT_NONE))
needs-render?
(do
(when (= ev TEXT_EDITOR_EVENT_STYLES_CHANGED)
(let [current-styles (text-editor/text-editor-get-current-styles)
shape-id (text-editor/text-editor-get-active-shape-id)]
(st/emit! (texts/v3-update-text-editor-styles shape-id current-styles))))
(recur (or needs-render?
(= ev TEXT_EDITOR_EVENT_CONTENT_CHANGED)
(= ev TEXT_EDITOR_EVENT_NEEDS_LAYOUT))))))))
;; This should never be called from the outside. ;; This should never be called from the outside.
(defn- render (defn- render
[timestamp] [timestamp]
(when (and wasm/context-initialized? (not @wasm/context-lost?)) (when (and wasm/context-initialized? (not @wasm/context-lost?))
(internal-render timestamp) ;; SYNC-TILES makes WASM keep the last presented frame while the new tiles
;; are rasterized, rather than clearing to the background first. The flag is
;; one-shot on both sides: WASM clears it when the render loop starts, so
;; the progressive continuation frames behave normally.
(if (compare-and-set! preserve-target-render? true false)
(internal-render timestamp (bit-or wasm/internal-frame-type RENDER-FLAG-SYNC-TILES))
(internal-render timestamp))
;; Update text editor blink (so cursor toggles) using the same timestamp ;; Update text editor blink (so cursor toggles) using the same timestamp
(try (try
(when (is-text-editor-wasm-enabled @st/state) (when (is-text-editor-wasm-enabled @st/state)
(text-editor/text-editor-update-blink timestamp) (text-editor/text-editor-update-blink timestamp)
(text-editor/text-editor-render-overlay) (text-editor/text-editor-render-overlay)
;; Poll for editor events; if any event occurs, trigger a re-render ;; Drain editor events. Only content/layout changes need a full shape
(let [ev (text-editor/text-editor-poll-event)] ;; re-render; selection/style changes are already reflected by the
(when (and ev (not= ev TEXT_EDITOR_EVENT_NONE)) ;; overlay redrawn just above.
;; When StylesChanged, get the current styles. (when (drain-text-editor-events!)
(case ev (request-render-preserving-target "text-editor-content")))
;; StylesChanged Event
TEXT_EDITOR_EVENT_STYLES_CHANGED
(let [current-styles (text-editor/text-editor-get-current-styles)
shape-id (text-editor/text-editor-get-active-shape-id)]
(st/emit! (texts/v3-update-text-editor-styles shape-id current-styles)))
;; Default case
nil)
(request-render "text-editor-event"))))
(catch :default e (catch :default e
(js/console.error "text-editor overlay/update failed:" e))) (js/console.error "text-editor overlay/update failed:" e)))
@ -459,19 +481,51 @@
(when (and wasm/context-initialized? (not @wasm/context-lost?)) (when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render_ui_only"))) (h/call wasm/internal-module "_render_ui_only")))
(defn render-from-cache! (defn render-from-backbuffer!
"Blit the shapes from the cached tile atlas and redraw the UI overlay "Re-present the last fully rendered frame with the UI overlay (rulers,
(rulers, selection band) fresh on top, in a single atomic frame. The selection band) redrawn on top, reusing the crisp Backbuffer instead of
*shapes* are the cached part (already-rasterized tiles, not rebuilt); the UI rebuilding it from the scale-capped document atlas.
is re-rendered every call, which is what lets it reflect a new selection.
Use for UI-only updates that don't change shapes (e.g. the ruler selection For UI-only updates that must not kick off a progressive shape re-render. The
band): unlike `request-render`, it never kicks off a progressive, alternative — blitting shapes from the cached tile atlas (WASM
tile-by-tile shape re-render, so it does not flash on zoomed-in views where `_render_from_cache`, what pan/zoom uses in `set-view-box`) — is fine at
the scene spans multiple tiles." normal zoom, but on a zoomed-in view (>1000%) it is a heavy upscale that
flashes crisp->blurry->crisp. Reusing the Backbuffer is pixel-identical at
any zoom. Only valid on a stable viewbox; pan/zoom must keep using the atlas
blit, whose preview tracks the moving viewport."
[] []
(when (and wasm/context-initialized? (not @wasm/context-lost?)) (when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render_from_cache" 0))) (h/call wasm/internal-module "_render_from_backbuffer")))
(defn render-text-editor-overlay!
"Repaint the text-editor caret and selection over the last fully rendered
frame, without a full (tile-rebuilding) render.
Caret/selection changes — the blink, clicks, drag-selection, arrow-key
navigation — never alter the shapes. `text-editor-render-caret` re-composes
the frame from the Backbuffer (which still holds the last complete render)
and draws the caret/selection overlay on top, so the shapes are pixel
identical to the last full render at any zoom level. A full `request-render`
would instead blank the canvas and re-rasterize tiles progressively, which
flashes on zoomed-in views; blitting from the cached tile atlas
(`_render_from_cache`) would instead look softer when upscaled (the atlas is
scale-capped), so the blink would alternate crisp/soft — a subtler flash.
Pending editor events are still drained so the style toolbar stays in sync; a
full render is only requested when content or layout actually changed."
[]
(when (and wasm/context-initialized?
(not @wasm/context-lost?)
;; Skip when a render is already pending: `text-editor-render-caret`
;; composes from the Backbuffer, but a progressive render (multiple
;; rAF frames at high zoom) is still rebuilding it. Compositing then
;; would show a half-built frame — a sparse, timing-dependent flash.
;; The in-flight render draws the overlay itself when it completes.
(not (render-pending?)))
(when (is-text-editor-wasm-enabled @st/state)
(text-editor/text-editor-update-blink (js/performance.now))
(text-editor/text-editor-render-caret)
(when (drain-text-editor-events!)
(request-render-preserving-target "text-editor-content")))))
;; CSS-pixel blur radius for the page-transition snapshot (DPR-scaled in WASM). ;; CSS-pixel blur radius for the page-transition snapshot (DPR-scaled in WASM).
(def ^:private TRANSITION_BLUR_RADIUS 4.0) (def ^:private TRANSITION_BLUR_RADIUS 4.0)
@ -512,6 +566,12 @@
(defonce shapes-loading? (atom false)) (defonce shapes-loading? (atom false))
(defonce deferred-render? (atom false)) (defonce deferred-render? (atom false))
(defn render-pending?
"True while a render has been scheduled but not yet completed — including the
frames of an in-progress progressive render."
[]
@pending-render)
(defn- register-deferred-render! (defn- register-deferred-render!
[] []
(reset! deferred-render? true)) (reset! deferred-render? true))
@ -538,6 +598,23 @@
(throw e)))))] (throw e)))))]
(set! wasm/internal-frame-id frame-id)))))) (set! wasm/internal-frame-id frame-id))))))
(defn request-render-preserving-target
"Like `request-render`, but keeps the previously presented frame on screen
while the new tiles are rasterized instead of blanking the canvas first.
A plain `request-render` goes through WASM's `reset_canvas`, which clears to
the background colour and then fills the viewport tile by tile. When that
rasterization spans more than one frame — as it does on zoomed-in views,
where glyphs are expensive to raster — the cleared canvas is visible as a
flash. Preserving the target is what the renderer already does after a
pan/zoom gesture for exactly this reason.
Use for shape edits on a stable viewbox (typing in the text editor), where
the previous frame is a good stand-in until the new one is ready."
[requester]
(reset! preserve-target-render? true)
(request-render requester))
(defn- begin-shapes-loading! (defn- begin-shapes-loading!
[] []
(reset! shapes-loading? true) (reset! shapes-loading? true)
@ -1271,7 +1348,16 @@
"Ends the view interaction and triggers a full-quality render." "Ends the view interaction and triggers a full-quality render."
[] []
(view-interaction-end!) (view-interaction-end!)
(internal-render 0 0)) ;; Preserve the last presented frame while the new one renders. A plain render
;; goes through WASM `reset_canvas`, which clears to the background and
;; re-rasterizes the viewport tile by tile — a visible flash on zoomed-in
;; views. The content is unchanged across a view-interaction end (only the
;; view moved), so there is nothing to clear; SYNC-TILES sets `preserve_target`
;; and lets the new tiles replace the old frame in place. Zoom-end already did
;; 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 0 RENDER-FLAG-SYNC-TILES))
(def render-finish (def render-finish
(letfn [(do-render [] (letfn [(do-render []

View File

@ -206,6 +206,14 @@
(when wasm/context-initialized? (when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_render_overlay"))) (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
to the last full render at any zoom, so the blink does not flash."
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_render_caret")))
(defn text-editor-poll-event (defn text-editor-poll-event
[] []
(when wasm/context-initialized? (when wasm/context-initialized?

View File

@ -236,6 +236,23 @@ pub extern "C" fn render_from_cache(_: i32) -> Result<()> {
Ok(()) Ok(())
} }
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_from_backbuffer() -> Result<()> {
with_state!(state, {
// Re-present the last fully rendered frame from the Backbuffer with the
// UI overlay (rulers) redrawn on top. Unlike `render_from_cache`, it does
// NOT rebuild the Backbuffer from the document tile atlas — that atlas is
// capped at scale <= 1.0, so on a zoomed-in view its blit is a blurry
// upscale and swapping it in reads as a flash. Reusing the Backbuffer is
// pixel-identical at any zoom. Only valid on a stable viewbox (the
// Backbuffer must still match the current view); pan/zoom keeps using
// `render_from_cache`.
state.present_frame();
});
Ok(())
}
#[no_mangle] #[no_mangle]
#[wasm_error] #[wasm_error]
pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> { pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> {

View File

@ -942,6 +942,18 @@ impl RenderState {
/// on top of Target, then present. Backbuffer is left clean so it can be reused /// on top of Target, then present. Backbuffer is left clean so it can be reused
/// as-is across interactive-transform frames without stale overlay pixels. /// as-is across interactive-transform frames without stale overlay pixels.
pub fn present_frame(&mut self, tree: ShapesPoolRef) { pub fn present_frame(&mut self, tree: ShapesPoolRef) {
self.compose_frame(tree);
self.surfaces.flush_and_submit(SurfaceId::Target);
}
/// Compose the frame on Target — the already-rendered Backbuffer plus the
/// UI/debug overlays — *without* submitting it, so a caller can draw extra
/// overlays into the same frame and present them atomically.
///
/// Unlike `render_from_cache`, this reuses the Backbuffer as-is instead of
/// rebuilding it from the document atlas, so the result is pixel-identical
/// to the last full render at any zoom level.
pub fn compose_frame(&mut self, tree: ShapesPoolRef) {
// Viewer masked passes render a partial scene onto a transparent backbuffer. // Viewer masked passes render a partial scene onto a transparent backbuffer.
// SrcOver would keep pass-1 pixels wherever the backbuffer stays transparent. // SrcOver would keep pass-1 pixels wherever the backbuffer stays transparent.
if self.viewer_masked_pass() { if self.viewer_masked_pass() {
@ -959,7 +971,6 @@ impl RenderState {
ui::render(self, tree); ui::render(self, tree);
} }
debug::render_wasm_label(self); debug::render_wasm_label(self);
self.surfaces.flush_and_submit(SurfaceId::Target);
} }
/// Renders only the canvas background and UI surface (rulers/frame), without /// Renders only the canvas background and UI surface (rulers/frame), without

View File

@ -26,10 +26,11 @@ pub fn render_overlay(
canvas.translate((-viewbox.area.left, -viewbox.area.top)); canvas.translate((-viewbox.area.left, -viewbox.area.top));
if editor_state.selection.is_selection() { if editor_state.selection.is_selection() {
// With an active selection there is no blinking caret (the caret is one
// end of the selection); drawing it would make it toggle on top of the
// highlight while the selection is held.
render_selection(canvas, editor_state, text_content, shape); render_selection(canvas, editor_state, text_content, shape);
} } else if editor_state.cursor_visible {
if editor_state.cursor_visible {
render_cursor(canvas, zoom, options.dpr, editor_state, text_content, shape); render_cursor(canvas, zoom, options.dpr, editor_state, text_content, shape);
} }

View File

@ -68,6 +68,10 @@ impl State {
get_render_state().render_from_cache(&self.shapes); get_render_state().render_from_cache(&self.shapes);
} }
pub fn present_frame(&mut self) {
get_render_state().present_frame(&self.shapes);
}
pub fn render_ui_only(&mut self) { pub fn render_ui_only(&mut self) {
get_render_state().render_ui_only(&self.shapes); get_render_state().render_ui_only(&self.shapes);
} }

View File

@ -535,11 +535,17 @@ impl TextEditorState {
pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.set_caret(*position); self.selection.set_caret(*position);
// Restart the blink so the caret is solid right after it is placed,
// instead of keeping whatever phase it had (which can toggle off at the
// moment of the click and read as a flash). Mirrors the keyboard paths
// (`move_cursor`, `select_all`) which already reset the blink.
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged); self.push_event(TextEditorEvent::SelectionChanged);
} }
pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) { pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.extend_to(*position); self.selection.extend_to(*position);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged); self.push_event(TextEditorEvent::SelectionChanged);
} }

View File

@ -6,9 +6,10 @@ use crate::mem;
use crate::render::text_editor as text_editor_render; use crate::render::text_editor as text_editor_render;
use crate::render::SurfaceId; use crate::render::SurfaceId;
use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
use crate::state::{TextEditorEvent, TextSelection}; use crate::state::{State, TextEditorEvent, TextSelection};
use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_from_u32_quartet;
use crate::utils::uuid_to_u32_quartet; use crate::utils::uuid_to_u32_quartet;
use crate::uuid::Uuid;
use crate::wasm::fills::RawFillData; use crate::wasm::fills::RawFillData;
use crate::wasm::text::{ use crate::wasm::text::{
helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform, helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform,
@ -849,6 +850,63 @@ pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) {
get_text_editor_state().update_blink(timestamp_ms); get_text_editor_state().update_blink(timestamp_ms);
} }
/// Refresh a text shape's layout if the editor marked it dirty, so the
/// caret/selection overlay is measured against up-to-date glyph geometry.
fn update_text_layout_if_needed(state: &mut State, shape_id: Uuid) {
let Some(shape) = state.shapes.get_mut(&shape_id) else {
return;
};
let selrect = shape.selrect();
let Type::Text(text_content) = &mut shape.shape_type else {
return;
};
if text_content.needs_update_layout() {
text_content.update_layout(selrect);
}
}
/// 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.
///
/// This exists because the caret blink must erase the previous caret, which
/// means restoring the pixels underneath it. Doing that via `render_from_cache`
/// rebuilds the frame from the document atlas, and that atlas is capped at
/// scale <= 1.0 — on a zoomed-in view it gets blitted heavily upscaled, so the
/// blink alternates between the crisp render and a softer approximation, which
/// reads as a flash. Reusing the Backbuffer is pixel-identical at any zoom.
#[no_mangle]
pub extern "C" fn text_editor_render_caret() {
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;
};
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] #[no_mangle]
pub extern "C" fn text_editor_render_overlay() { pub extern "C" fn text_editor_render_overlay() {
with_state!(state, { with_state!(state, {
@ -856,18 +914,7 @@ pub extern "C" fn text_editor_render_overlay() {
return; return;
}; };
if let Some(shape) = state.shapes.get(&shape_id) { update_text_layout_if_needed(state, shape_id);
if let Type::Text(text_content) = &shape.shape_type {
if text_content.needs_update_layout() {
let selrect = shape.selrect();
if let Some(shape) = state.shapes.get_mut(&shape_id) {
if let Type::Text(text_content) = &mut shape.shape_type {
text_content.update_layout(selrect);
}
}
}
}
}
let Some(shape) = state.shapes.get(&shape_id) else { let Some(shape) = state.shapes.get(&shape_id) else {
return; return;