From f2fc261221749a8b68188ea570bb06aa31a86b90 Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Sun, 4 Oct 2026 00:59:08 -0400 Subject: [PATCH] Fix stage zoom anchoring and continuous passepartout shading --- frontend/src/arthur/ui/layout.cljs | 2 +- frontend/src/arthur/ui/stage.cljs | 48 ++++++++++++++++++++---------- static/arthur/app.css | 6 ++++ 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/frontend/src/arthur/ui/layout.cljs b/frontend/src/arthur/ui/layout.cljs index 3d60d33..3092e20 100644 --- a/frontend/src/arthur/ui/layout.cljs +++ b/frontend/src/arthur/ui/layout.cljs @@ -179,7 +179,7 @@ (defn stage-controls [] (let [{:keys [opacity]} @(rf/subscribe [::mat])] [:<> - [:button {:on-click fit-stage! :title "Fit stage and surrounding workspace in view"} "fit"] + [:button {:on-click fit-stage! :title "Fit stage in view"} "fit"] [:details.passepartout-controls [:summary "passepartout"] [:div.view-popout diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index c80d986..8108db8 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -614,8 +614,6 @@ (reset! dragging nil) (reset! marquee nil) (reset! painting nil) (player/stroke! nil) (let-go! false))} - [:path {:d (str "M" (- w) " " (- h) "h" (* 3 w) "v" (* 3 h) "h" (* -3 w) "z M0 0v" h "h" w "v" (- h) "z") - :fill "#000" :fill-rule "evenodd" :opacity opacity :pointer-events "none"}] [ghost] (when (seq selected-pts) [:polygon.selected-paint-outline @@ -637,29 +635,48 @@ (defonce space-held? (atom false)) (defonce pan (atom nil)) +(defn- stage-key-target? [target] + (and (.closest target ".stage-area") + (not (.closest target "input, textarea, select, button, a, summary, [role='button']")) + (not (.-isContentEditable target)))) + (defonce navigation-keys (when (exists? js/window) (.addEventListener js/window "keydown" - (fn [e] (when (and (= "Space" (.-code e)) - (not (#{"INPUT" "TEXTAREA" "SELECT"} (.-tagName (.-target e)))) ) - (.preventDefault e) (reset! space-held? true)))) + (fn [e] + (when (and (= "Space" (.-code e)) + (not (or (.-ctrlKey e) (.-metaKey e) (.-altKey e))) + (stage-key-target? (.-target e))) + (.preventDefault e) + (reset! space-held? true)))) (.addEventListener js/window "keyup" #(when (= "Space" (.-code %)) (reset! space-held? false))) (.addEventListener js/window "blur" #(do (reset! space-held? false) (reset! pan nil))))) (defonce pinch-start-zoom (atom nil)) +(defonce pending-zoom (atom nil)) (defn- zoom-at! [el client-x client-y scale] - (let [z @(rf/subscribe [::layout/zoom :stage]) - wrap (.querySelector el ".stage-wrap") - wb (.getBoundingClientRect wrap) - x (- client-x (.-left wb)) y (- client-y (.-top wb)) - next (max 0.1 (min 16 scale))] + (let [wrap (.querySelector el ".stage-wrap") + next (max 0.1 (min 16 scale)) + pending @pending-zoom] + ;; Retain the anchor from the layout before this batch of events. React + ;; may not have committed the new dimensions until the animation frame. + (when-not pending + (let [z @(rf/subscribe [::layout/zoom :stage]) + box (.getBoundingClientRect wrap)] + (reset! pending-zoom {:x (/ (- client-x (.-left box)) z) + :y (/ (- client-y (.-top box)) z)}))) + (swap! pending-zoom assoc :scale next :client-x client-x :client-y client-y) (rf/dispatch-sync [::layout/set :stage next]) - (js/requestAnimationFrame - (fn [] - (let [nb (.getBoundingClientRect wrap)] - (set! (.-scrollLeft el) (+ (.-scrollLeft el) (- (+ (.-left nb) (* x (/ next z))) client-x))) - (set! (.-scrollTop el) (+ (.-scrollTop el) (- (+ (.-top nb) (* y (/ next z))) client-y)))))))) + (when-not pending + (js/requestAnimationFrame + (fn [] + (r/flush) + (let [{:keys [x y scale client-x client-y]} @pending-zoom + box (.getBoundingClientRect wrap)] + (reset! pending-zoom nil) + (set! (.-scrollLeft el) (+ (.-scrollLeft el) (- (+ (.-left box) (* x scale)) client-x))) + (set! (.-scrollTop el) (+ (.-scrollTop el) (- (+ (.-top box) (* y scale)) client-y))))))))) (defn- navigation [] {:title "Two-finger scroll to pan · Pinch or ⌘/Ctrl-wheel to zoom · Space-drag or middle-drag to pan" @@ -748,6 +765,7 @@ :height (str (* zoom h) "px")}}] [:canvas.tracing {:ref #(tracing/set-canvas! %) :width (* zoom w) :height (* zoom h)}] + [:div.stage-shade {:style {:box-shadow (str "0 0 0 100vmax rgba(0,0,0," opacity ")")}}] [:div.stage-overlay-wrap [overlay w h zoom opacity]]] (when destination-name [:div.stage-target "creating in " [:strong destination-name]]) diff --git a/static/arthur/app.css b/static/arthur/app.css index 1044cc2..9805927 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -879,6 +879,12 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } left: -100%; top: -100%; width: 300%; height: 300%; } +.stage-shade { + position: absolute; + inset: 0; + pointer-events: none; + outline: 1px solid #aaa; +} /* The footage traced over: a reference above the picture, never part of it. */ .tracing { position: absolute; inset: 0; pointer-events: none; }