mirror of
https://github.com/penpot/penpot.git
synced 2026-09-11 22:49:30 +00:00
🐛 Fix text editor v3 blinks (#10654)
This commit is contained in:
parent
c467d98c9e
commit
4f46700c94
@ -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 []
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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 []
|
||||||
|
|||||||
@ -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?
|
||||||
|
|||||||
@ -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<()> {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user