diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 4b47c19451..20cb0c150b 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -96,6 +96,7 @@ (fn [{:keys [params path-params method] :as request}] (let [handler-name (:method-name path-params) etag (yreq/get-header request "if-none-match") + session-id (yreq/get-header request "x-session-id") key-id (get request ::http/auth-key-id) profile-id (or (::session/profile-id request) @@ -108,6 +109,7 @@ (assoc ::handler-name handler-name) (assoc ::ip-addr ip-addr) (assoc ::request-at (ct/now)) + (assoc ::session-id (some-> session-id uuid/parse*)) (assoc ::cond/key etag) (cond-> (uuid? profile-id) (assoc ::profile-id profile-id))) diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index cd3cbcdf0f..8325772361 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -71,7 +71,7 @@ {::doc/added "1.20" ::sm/params schema:restore-file-snapshot ::db/transaction true} - [{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}] + [{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id ::rpc/session-id file-id id] :as params}] (files/check-edition-permissions! conn profile-id file-id) (let [file (bfc/get-file cfg file-id) team (teams/get-team conn @@ -88,7 +88,8 @@ ;; Send to the clients a notification to reload the file (mbus/pub! msgbus :topic (:id file) - :message {:type :file-restore + :message {:type :file-restored + :session-id session-id :file-id (:id file) :vern vern}) nil))) diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 8b07faef09..ddcd2c08b5 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -12,6 +12,7 @@ [app.common.logging :as log] [app.common.time :as ct] [app.common.uri :as u] + [app.common.uuid :as uuid] [app.common.version :as v] [app.util.avatars :as avatars] [app.util.extends] @@ -112,10 +113,12 @@ (def target (parse-target global)) (def browser (parse-browser)) (def platform (parse-platform)) +(def session-id (uuid/next)) (def version (parse-version global)) (def version-tag (obj/get global "penpotVersionTag")) + (defn stale-build? "Returns true when the compiled JS was built with a different version tag than the one present in the current index.html. This indicates diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 9583b86686..20830e78c7 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -11,7 +11,6 @@ [app.common.time :as ct] [app.common.transit :as t] [app.common.types.objects-map] - [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.auth :as da] [app.main.data.event :as ev] @@ -45,7 +44,8 @@ (log/inf :version (:full cf/version) :asserts *assert* :build-date cf/build-date - :public-uri (dm/str cf/public-uri)) + :public-uri (dm/str cf/public-uri) + :session-id (str cf/session-id)) (log/inf :hint "enabled flags" :flags (str/join " " (map name cf/flags)))) (declare reinit) @@ -71,7 +71,7 @@ (ptk/reify ::initialize ptk/UpdateEvent (update [_ state] - (assoc state :session-id (uuid/next))) + (assoc state :session-id cf/session-id)) ptk/WatchEvent (watch [_ _ stream] diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index cfd2cc841c..dfc925cce8 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -431,6 +431,7 @@ context (-> @context (merge (:context event)) (assoc :session session*) + (assoc :session-id cf/session-id) (assoc :external-session-id (cf/external-session-id)) (add-external-context-info) (d/without-nils))] diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index eb1f1744b9..cc23f5aa24 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -224,18 +224,12 @@ IDeref (-deref [_] bundle) + ptk/UpdateEvent (update [_ state] - (let [pending-version-id (:workspace-pending-file-version-id state) - state (-> state - (assoc :thumbnails thumbnails) - (update :files assoc file-id file) - (dissoc :workspace-pending-file-version-id))] - (cond-> state - (some? pending-version-id) - (assoc :workspace-file-version-id pending-version-id) - (nil? pending-version-id) - (dissoc :workspace-file-version-id)))))) + (-> state + (assoc :thumbnails thumbnails) + (update :files assoc file-id file))))) (defn zoom-to-frame [] @@ -253,6 +247,7 @@ (rx/of (dws/select-shapes frames-id) dwz/zoom-to-selected-shape))))) +;; FIXME: rename to `fetch-file` (defn- fetch-bundle "Multi-stage file bundle fetch coordinator" [file-id features] @@ -289,205 +284,218 @@ ;; This prevents errors when processing changes from other pages (when shape (wasm.api/process-object shape)))))) + +(defn initialize-file + [team-id file-id] + (assert (uuid? team-id) "expected valud uuid for `team-id`") + (assert (uuid? file-id) "expected valud uuid for `file-id`") + + (ptk/reify ::initialize-file + ptk/WatchEvent + (watch [_ state _] + (let [features (features/get-enabled-features state team-id)] + (log/dbg :hint "initialize-file" + :team-id (dm/str team-id) + :file-id (dm/str file-id)) + (rx/of (fetch-bundle file-id features)))))) + (defn initialize-workspace - ([team-id file-id] - (initialize-workspace team-id file-id nil)) - ([team-id file-id version-id] - (assert (uuid? team-id) "expected valud uuid for `team-id`") - (assert (uuid? file-id) "expected valud uuid for `file-id`") + [team-id file-id] + (assert (uuid? team-id) "expected valud uuid for `team-id`") + (assert (uuid? file-id) "expected valud uuid for `file-id`") - (ptk/reify ::initialize-workspace - ptk/UpdateEvent - (update [_ state] - (-> state - (assoc :recent-colors (:recent-colors storage/user)) - (assoc :recent-fonts (:recent-fonts storage/user)) - (assoc :current-file-id file-id) - (assoc :workspace-presence {}) - ;; Store pending version-id; bundle-fetched will set workspace-file-version-id - ;; when the new bundle is applied so the viewport re-inits with new data - (assoc :workspace-pending-file-version-id version-id))) + (ptk/reify ::initialize-workspace + ptk/UpdateEvent + (update [_ state] + (-> state + (assoc :recent-colors (:recent-colors storage/user)) + (assoc :recent-fonts (:recent-fonts storage/user)) + (assoc :current-file-id file-id) + (assoc :workspace-presence {}))) - ptk/WatchEvent - (watch [_ state stream] - (let [stoper-s (rx/filter (ptk/type? ::finalize-workspace) stream) - rparams (rt/get-params state) - features (features/get-enabled-features state team-id) - render-wasm? (contains? features "render-wasm/v1")] + ptk/WatchEvent + (watch [_ state stream] + (let [stoper-s (rx/filter (ptk/type? ::finalize-workspace) stream) + rparams (rt/get-params state) + features (features/get-enabled-features state team-id) + render-wasm? (contains? features "render-wasm/v1")] - (log/debug :hint "initialize-workspace" - :team-id (dm/str team-id) - :file-id (dm/str file-id)) + (log/debug :hint "initialize-workspace" + :team-id (dm/str team-id) + :file-id (dm/str file-id)) - (rx/concat - (->> (rx/merge - (rx/concat - ;; Fetch all essential data that should be loaded before the file - (rx/merge - (if ^boolean render-wasm? - (->> (rx/from @wasm/module) - (rx/filter true?) - (rx/tap (fn [_] - (let [event (ug/event "penpot:wasm:loaded")] - (ug/dispatch! event)))) - (rx/ignore)) - (rx/empty)) + (rx/concat + (->> (rx/merge + (rx/concat + ;; Fetch all essential data that should be loaded before the file + (rx/merge + (if ^boolean render-wasm? + (->> (rx/from @wasm/module) + (rx/filter true?) + (rx/tap (fn [_] + (let [event (ug/event "penpot:wasm:loaded")] + (ug/dispatch! event)))) + (rx/ignore)) + (rx/empty)) - (->> stream - (rx/filter (ptk/type? ::df/fonts-loaded)) - (rx/take 1) - (rx/ignore)) + (->> stream + (rx/filter (ptk/type? ::df/fonts-loaded)) + (rx/take 1) + (rx/ignore)) - (rx/of (ntf/hide) - (dcmt/retrieve-comment-threads file-id) - (dcmt/fetch-profiles) - (df/fetch-fonts team-id)) + (rx/of (ntf/hide) + (dcmt/retrieve-comment-threads file-id) + (dcmt/fetch-profiles) + (df/fetch-fonts team-id)) - (when (contains? cf/flags :mcp) - (rx/of (du/fetch-access-tokens)))) + (when (contains? cf/flags :mcp) + (rx/of (du/fetch-access-tokens)))) - ;; Once the essential data is fetched, lets proceed to - ;; fetch teh file bunldle - (rx/of (fetch-bundle file-id features))) + ;; Once the essential data is fetched, lets proceed to + ;; fetch teh file bunldle + (rx/of (initialize-file team-id file-id))) - (->> stream - (rx/filter (ptk/type? ::bundle-fetched)) - (rx/take 1) - (rx/map deref) - (rx/mapcat - (fn [{:keys [file]}] - (log/debug :hint "bundle fetched" - :team-id (dm/str team-id) - :file-id (dm/str file-id)) + (->> stream + (rx/filter (ptk/type? ::bundle-fetched)) + (rx/take 1) + (rx/map deref) + (rx/mapcat + (fn [{:keys [file]}] + (log/debug :hint "bundle fetched" + :team-id (dm/str team-id) + :file-id (dm/str file-id)) - (rx/of (dpj/initialize-project (:project-id file)) - (dwn/initialize team-id file-id) - (dwsl/initialize-shape-layout) - (fetch-libraries file-id features) - (-> (workspace-initialized file-id) - (with-meta {:team-id team-id - :file-id file-id})))))) + (rx/of (dpj/initialize-project (:project-id file)) + (dwn/initialize team-id file-id) + (dwsl/initialize-shape-layout) + (fetch-libraries file-id features) + (-> (workspace-initialized file-id) + (with-meta {:team-id team-id + :file-id file-id})))))) - ;; Install dev perf observers once the workspace is ready - (when (contains? cf/flags :perf-logs) - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/take 1) - (rx/tap (fn [_] (perf/setup))))) + ;; Install dev perf observers once the workspace is ready + (when (contains? cf/flags :perf-logs) + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/take 1) + (rx/tap (fn [_] (perf/setup))))) - (->> stream - (rx/filter (ptk/type? ::dps/persistence-notification)) - (rx/take 1) - (rx/map dwc/set-workspace-visited)) - (when-let [component-id (some-> rparams :component-id uuid/parse)] - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/observe-on :async) - (rx/take 1) - (rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams))))) + (->> stream + (rx/filter (ptk/type? ::dps/persistence-notification)) + (rx/take 1) + (rx/map dwc/set-workspace-visited)) - (when (:board-id rparams) - (->> stream - (rx/filter (ptk/type? ::dwv/initialize-viewport)) - (rx/take 1) - (rx/map zoom-to-frame))) + (when-let [component-id (some-> rparams :component-id uuid/parse)] + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/observe-on :async) + (rx/take 1) + (rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams))))) - (when-let [comment-id (some-> rparams :comment-id uuid/parse)] - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/observe-on :async) - (rx/take 1) - (rx/map #(dwcm/navigate-to-comment-id comment-id)))) + (when (:board-id rparams) + (->> stream + (rx/filter (ptk/type? ::dwv/initialize-viewport)) + (rx/take 1) + (rx/map zoom-to-frame))) - (when render-wasm? - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/mapcat - (fn [{:keys [redo-changes]}] - (let [added (->> redo-changes - (filter #(= (:type %) :add-obj)) - (map :id))] - (->> (rx/from added) - (rx/map process-wasm-object))))))) + (when-let [comment-id (some-> rparams :comment-id uuid/parse)] + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/observe-on :async) + (rx/take 1) + (rx/map #(dwcm/navigate-to-comment-id comment-id)))) - (when render-wasm? - (let [local-commits-s - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/filter #(and (= :local (:source %)) - (not (contains? (:tags %) :position-data)))) - (rx/filter (complement empty?))) + (when render-wasm? + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/mapcat + (fn [{:keys [redo-changes]}] + (let [added (->> redo-changes + (filter #(= (:type %) :add-obj)) + (map :id))] + (->> (rx/from added) + (rx/map process-wasm-object))))))) - notifier-s - (rx/merge - (->> local-commits-s (rx/debounce 1000)) - (->> stream (rx/filter dps/force-persist?))) + (when render-wasm? + (let [local-commits-s + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/filter #(and (= :local (:source %)) + (not (contains? (:tags %) :position-data)))) + (rx/filter (complement empty?))) - objects-s - (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) + notifier-s + (rx/merge + (->> local-commits-s (rx/debounce 1000)) + (->> stream (rx/filter dps/force-persist?))) - current-page-id-s - (rx/from-atom refs/current-page-id {:emit-current-value? true})] + objects-s + (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) - (->> local-commits-s - (rx/buffer-until notifier-s) - (rx/with-latest-from objects-s) - (rx/map - (fn [[commits objects]] - (->> commits - (mapcat :redo-changes) - (filter #(contains? #{:mod-obj :add-obj} (:type %))) - (filter #(cfh/text-shape? objects (:id %))) - (map #(vector - (:id %) - (wasm.api/calculate-position-data (get objects (:id %)))))))) + current-page-id-s + (rx/from-atom refs/current-page-id {:emit-current-value? true})] - (rx/with-latest-from current-page-id-s) - (rx/map - (fn [[text-position-data page-id]] - (let [changes - (->> text-position-data - (mapv (fn [[id position-data]] - {:type :mod-obj - :id id - :page-id page-id - :operations - [{:type :set - :attr :position-data - :val position-data - :ignore-touched true - :ignore-geometry true}]})))] - (when (d/not-empty? changes) - (dch/commit-changes - {:redo-changes changes :undo-changes [] - :save-undo? false - :tags #{:position-data}}))))) - (rx/take-until stoper-s)))) + (->> local-commits-s + (rx/buffer-until notifier-s) + (rx/with-latest-from objects-s) + (rx/map + (fn [[commits objects]] + (->> commits + (mapcat :redo-changes) + (filter #(contains? #{:mod-obj :add-obj} (:type %))) + (filter #(cfh/text-shape? objects (:id %))) + (map #(vector + (:id %) + (wasm.api/calculate-position-data (get objects (:id %)))))))) - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/mapcat - (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo? selected-before]}] - (if (and save-undo? (seq undo-changes)) - (let [entry {:undo-changes undo-changes - :redo-changes redo-changes - :undo-group undo-group - :tags tags - :selected-before selected-before}] - (rx/of (dwu/append-undo entry stack-undo?))) - (rx/empty)))))) - (rx/take-until stoper-s)) + (rx/with-latest-from current-page-id-s) + (rx/map + (fn [[text-position-data page-id]] + (let [changes + (->> text-position-data + (mapv (fn [[id position-data]] + {:type :mod-obj + :id id + :page-id page-id + :operations + [{:type :set + :attr :position-data + :val position-data + :ignore-touched true + :ignore-geometry true}]})))] + (when (d/not-empty? changes) + (dch/commit-changes + {:redo-changes changes :undo-changes [] + :save-undo? false + :tags #{:position-data}}))))) - (rx/of (mcp/notify-other-tabs-disconnect))))) + ;; FIXME: this stop-until is redundant + (rx/take-until stoper-s)))) - ptk/EffectEvent - (effect [_ _ _] - (let [name (dm/str "workspace-" file-id)] - (unchecked-set ug/global "name" name)))))) + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/mapcat + (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo? selected-before]}] + (if (and save-undo? (seq undo-changes)) + (let [entry {:undo-changes undo-changes + :redo-changes redo-changes + :undo-group undo-group + :tags tags + :selected-before selected-before}] + (rx/of (dwu/append-undo entry stack-undo?))) + (rx/empty)))))) + (rx/take-until stoper-s)) + + (rx/of (mcp/notify-other-tabs-disconnect))))) + + ptk/EffectEvent + (effect [_ _ _] + (let [name (dm/str "workspace-" file-id)] + (unchecked-set ug/global "name" name))))) (defn finalize-workspace [_team-id file-id] diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index ca4362ef5b..be0b8b6dc6 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -979,10 +979,7 @@ ;; These calls are necessary for properly sync thumbnails ;; when a main component does not live in the same page. - ;; When WASM is active, skip the "frame" tag (SVG-based) since - ;; component previews are rendered locally via WASM. - (when-not (features/active-feature? state "render-wasm/v1") - (update-component-thumbnail-sync state component-id file-id "frame")) + (update-component-thumbnail-sync state component-id file-id "frame") (update-component-thumbnail-sync state component-id file-id "component") (sync-file current-file-id file-id :components component-id undo-group) @@ -1007,10 +1004,10 @@ (dwu/commit-undo-transaction undo-id))))))) (defn update-component-thumbnail - "Persist the thumbnail of the component to the server. - For WASM, the UI is already up-to-date from the immediate render in - update-component-thumbnail-sync, so this only persists. - For SVG, this does the full render + persist." + "Update the thumbnail of the component with the given id, in the + current file and in the imported libraries. + For WASM, re-renders and persists to the server in one step. + For SVG, update-thumbnail already handles both render + persist." [component-id file-id] (ptk/reify ::update-component-thumbnail ptk/WatchEvent @@ -1020,7 +1017,7 @@ component (ctkl/get-component data component-id) page-id (:main-instance-page component) root-id (:main-instance-id component)] - (rx/of (dwt.wasm/persist-thumbnail file-id page-id root-id))) + (rx/of (dwt.wasm/render-thumbnail file-id page-id root-id :persist? true))) (rx/of (update-component-thumbnail-sync state component-id file-id "component")))))) (defn- find-shape-index @@ -1353,9 +1350,10 @@ (watch [_ _ stream] (let [stopper-s (->> stream - (rx/filter #(or (= ::dwpg/finalize-page (ptk/type %)) - (= ::watch-component-changes (ptk/type %))))) - + (rx/map ptk/type) + (rx/filter (fn [event-type] + (or (= ::dwpg/finalize-page event-type) + (= ::watch-component-changes event-type))))) workspace-data-s (->> (rx/from-atom refs/workspace-data {:emit-current-value? true}) (rx/share)) @@ -1379,7 +1377,8 @@ check-changes (fn [[event [old-data _mid_data _new-data]]] - (when old-data + (if (nil? old-data) + (rx/empty) (let [{:keys [file-id changes save-undo? undo-group]} event changed-components @@ -1397,18 +1396,9 @@ (->> (rx/from changed-components) (rx/map #(component-changed % (:id old-data) undo-group)))) ;; even if save-undo? is false, we need to update the :modified-date of the component - ;; (for example, for undos). When WASM is active, also re-render the thumbnail - ;; so undo/redo visually updates component previews. - (->> (mapcat (fn [component-id] - (if (features/active-feature? @st/state "render-wasm/v1") - (let [component (ctkl/get-component old-data component-id)] - [(touch-component component-id) - (dwt.wasm/render-thumbnail (:id old-data) - (:main-instance-page component) - (:main-instance-id component))]) - [(touch-component component-id)])) - changed-components) - (rx/from))) + ;; (for example, for undos) + (->> (rx/from changed-components) + (rx/map touch-component))) (rx/empty))))) @@ -1425,30 +1415,30 @@ (when (or (contains? cf/flags :component-thumbnails) (features/active-feature? @st/state "render-wasm/v1")) - (let [wasm? (features/active-feature? @st/state "render-wasm/v1")] - (->> (rx/merge - changes-s + (->> (rx/merge + changes-s - ;; WASM: render thumbnails immediately for instant UI feedback - (if wasm? - (->> changes-s - (rx/filter (ptk/type? ::component-changed)) - (rx/map deref) - (rx/map (fn [[component-id file-id]] - (update-component-thumbnail-sync @st/state component-id file-id "component")))) - (rx/empty)) + ;; Persist thumbnails to the server in batches after user + ;; becomes inactive for 5 seconds. + (->> changes-s + (rx/filter (ptk/type? ::component-changed)) + (rx/map deref) + (rx/buffer-until notifier-s) + (rx/mapcat #(into #{} %)) + (rx/map (fn [[component-id file-id]] + (update-component-thumbnail component-id file-id)))) - ;; Persist thumbnails to the server in batches after user - ;; becomes inactive for 5 seconds. - (->> changes-s - (rx/filter (ptk/type? ::component-changed)) - (rx/map deref) - (rx/buffer-until notifier-s) - (rx/mapcat #(into #{} %)) - (rx/map (fn [[component-id file-id]] - (update-component-thumbnail component-id file-id))))) + ;; Immediately update the component thumbnail on undos, + ;; which emit touch-component instead of component-changed. + (->> changes-s + (rx/filter (ptk/type? ::touch-component)) + (rx/map deref) + (rx/map (fn [[component-id file-id]] + (let [file-id (or file-id (:current-file-id @st/state))] + (update-component-thumbnail-sync + @st/state component-id file-id "component")))))) - (rx/take-until stopper-s)))))))) + (rx/take-until stopper-s))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Backend interactions diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index c7181abbaf..ed8e44e3ca 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -38,6 +38,23 @@ (def ^:private xf:without-uuid-zero (remove #(= % uuid/zero))) +;; Tracks whether the WASM renderer is currently in "interactive +;; transform" mode (a drag / resize / rotate gesture in progress). +;; Paired with `set-modifiers-start` / `set-modifiers-end` so the +;; native side only toggles once per gesture, regardless of how many +;; `set-wasm-modifiers` calls fire in between. +(defonce ^:private interactive-transform-active? (atom false)) + +(defn- ensure-interactive-transform-start! + [] + (when (compare-and-set! interactive-transform-active? false true) + (wasm.api/set-modifiers-start))) + +(defn- ensure-interactive-transform-end! + [] + (when (compare-and-set! interactive-transform-active? true false) + (wasm.api/set-modifiers-end))) + (def ^:private transform-attrs #{:selrect :points @@ -279,6 +296,11 @@ ptk/EffectEvent (effect [_ state _] (when (features/active-feature? state "render-wasm/v1") + ;; End interactive transform mode BEFORE cleaning modifiers so + ;; the final full-quality render triggered by subsequent shape + ;; updates is not still classified as "interactive" (which would + ;; skip shadows / blur). + (ensure-interactive-transform-end!) (wasm.api/clean-modifiers) (set-wasm-props! (dsh/lookup-page-objects state) (:wasm-props state) []))) @@ -624,6 +646,12 @@ ptk/WatchEvent (watch [_ state _] + ;; Entering an interactive transform (drag/resize/rotate). Flip + ;; the renderer into fast + atlas-backdrop mode so the live + ;; preview is cheap, tiles never appear sequentially and the main + ;; thread is not blocked. The pair is closed in + ;; `clear-local-transform`. + (ensure-interactive-transform-start!) (wasm.api/clean-modifiers) (let [prev-wasm-props (:prev-wasm-props state) wasm-props (:wasm-props state) @@ -764,6 +792,7 @@ (ptk/reify ::set-wasm-rotation-modifiers ptk/EffectEvent (effect [_ state _] + (ensure-interactive-transform-start!) (let [objects (dsh/lookup-page-objects state) ids (sequence xf-rotation-shape shapes) diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index 5e01fd4486..2dc6450abc 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -40,7 +40,7 @@ (declare handle-pointer-update) (declare handle-file-change) (declare handle-file-deleted) -(declare handle-file-restore) +(declare handle-file-restored) (declare handle-library-change) (declare handle-pointer-send) (declare handle-export-update) @@ -132,7 +132,7 @@ :pointer-update (handle-pointer-update msg) :file-change (handle-file-change msg) :file-deleted (handle-file-deleted msg) - :file-restore (handle-file-restore msg) + :file-restored (handle-file-restored msg) :library-change (handle-library-change msg) :notification (dc/handle-notification msg) :team-role-change (handle-change-team-role msg) @@ -283,22 +283,22 @@ (rt/nav :dashboard-recent {:team-id team-id}))))))) (def ^:private - schema:handle-file-restore - [:map {:title "handle-file-restore"} + schema:handle-file-restored + [:map {:title "handle-file-restored"} [:type :keyword] [:file-id ::sm/uuid] [:vern :int]]) -(def ^:private check-file-restore-params - (sm/check-fn schema:handle-file-restore)) +(def ^:private check-file-restored-params + (sm/check-fn schema:handle-file-restored)) -(defn handle-file-restore +(defn handle-file-restored [{:keys [file-id vern] :as msg}] - (assert (check-file-restore-params msg) + (assert (check-file-restored-params msg) "expected valid parameters") - (ptk/reify ::handle-file-restore + (ptk/reify ::handle-file-restored ptk/WatchEvent (watch [_ state _] (let [curr-file-id (:current-file-id state) diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index 3695205985..ff82e15972 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -61,32 +61,34 @@ (js/requestAnimationFrame (fn [_] (try - (let [objects (dsh/lookup-page-objects @st/state file-id page-id) - frame (get objects frame-id) - {:keys [width height]} (:selrect frame) - max-size (mth/max width height) - scale (mth/max 1 (/ target-size max-size)) - png-bytes (wasm.api/render-shape-pixels frame-id scale)] - (if (or (nil? png-bytes) (zero? (.-length png-bytes))) - (do - (l/error :hint "render-shape-pixels returned empty" :frame-id (str frame-id)) - (rx/end! subs)) - (.then - (png-bytes->data-uri png-bytes) - (fn [data-uri] - (rx/push! subs data-uri) - (rx/end! subs)) - (fn [err] - (rx/error! subs err))))) + (let [objects (dsh/lookup-page-objects @st/state file-id page-id)] + (if-let [frame (get objects frame-id)] + (let [{:keys [width height]} (:selrect frame) + max-size (mth/max width height) + scale (mth/max 1 (/ target-size max-size)) + png-bytes (wasm.api/render-shape-pixels frame-id scale)] + (if (or (nil? png-bytes) (zero? (.-length png-bytes))) + (do + (l/error :hint "render-shape-pixels returned empty" :frame-id (str frame-id)) + (rx/end! subs)) + (.then + (png-bytes->data-uri png-bytes) + (fn [data-uri] + (rx/push! subs data-uri) + (rx/end! subs)) + (fn [err] + (rx/error! subs err))))) + + (rx/error! subs "Frame not found"))) (catch :default err (rx/error! subs err)))))] #(js/cancelAnimationFrame req-id))))) (defn render-thumbnail "Renders a component thumbnail via WASM and updates the UI immediately. - Does NOT persist to the server — persistence is handled separately - by `persist-thumbnail` on a debounced schedule." - [file-id page-id frame-id] + When `persist?` is true, also persists the rendered thumbnail to the + server in the same observable chain (guaranteeing correct ordering)." + [file-id page-id frame-id & {:keys [persist?] :or {persist? false}}] (let [object-id (thc/fmt-object-id file-id page-id frame-id "component")] (ptk/reify ::render-thumbnail @@ -115,15 +117,30 @@ (catch :default err (rx/error! subs err))))))) + (persist-to-server + [data-uri] + (let [blob (wapi/data-uri->blob data-uri)] + (->> (rp/cmd! :create-file-object-thumbnail + {:file-id file-id + :object-id object-id + :media blob + :tag "component"}) + (rx/catch rx/empty) + (rx/ignore)))) + (do-render-thumbnail [] (let [tp (ct/tpoint-ms)] (->> (render-component-pixels file-id page-id frame-id) - (rx/map + (rx/mapcat (fn [data-uri] (l/dbg :hint "component thumbnail rendered (wasm)" :elapsed (dm/str (tp) "ms")) - (dwt/assoc-thumbnail object-id data-uri))) + (if persist? + (rx/merge + (rx/of (dwt/assoc-thumbnail object-id data-uri)) + (persist-to-server data-uri)) + (rx/of (dwt/assoc-thumbnail object-id data-uri))))) (rx/catch (fn [err] (js/console.error err) diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 11ed461366..85630cfccb 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -11,9 +11,10 @@ [app.common.schema :as sm] [app.common.time :as ct] [app.main.data.event :as ev] - [app.main.data.helpers :as dsh] + [app.main.data.notifications :as ntf] [app.main.data.persistence :as dwp] [app.main.data.workspace :as dw] + [app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.thumbnails :as th] [app.main.refs :as refs] [app.main.repo :as rp] @@ -92,33 +93,59 @@ (->> (rp/cmd! :update-file-snapshot {:id id :label label}) (rx/map fetch-versions))))))) +(defn- initialize-version + [] + (ptk/reify ::initialize-version + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + file-id (:current-file-id state) + team-id (:current-team-id state)] + + (rx/merge + (->> stream + (rx/filter (ptk/type? ::dw/bundle-fetched)) + (rx/take 1) + (rx/map #(dwpg/initialize-page file-id page-id))) + + (rx/of (ntf/hide :tag :restore-dialog) + (dw/initialize-file team-id file-id))))) + + ptk/EffectEvent + (effect [_ _ _] + (th/clear-queue!)))) + +(defn- wait-for-persistence + [file-id snapshot-id] + (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) + (rx/filter #(or (nil? %) (= :saved %))) + (rx/take 1) + (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id snapshot-id})))) + (defn restore-version [id origin] (assert (uuid? id) "expected valid uuid for `id`") (ptk/reify ::restore-version ptk/WatchEvent (watch [_ state _] - (let [file-id (:current-file-id state) - team-id (:current-team-id state)] + (let [file-id (:current-file-id state) + team-id (:current-team-id state) + event-name (case origin + :version "restore-pin-version" + :snapshot "restore-autosave" + :plugin "restore-version-plugin")] + (rx/concat (rx/of ::dwp/force-persist (dw/remove-layout-flag :document-history)) - (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) - (rx/filter #(or (nil? %) (= :saved %))) - (rx/take 1) - (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) - (rx/tap #(th/clear-queue!)) - (rx/map #(dw/initialize-workspace team-id file-id id))) - (case origin - :version - (rx/of (ptk/event ::ev/event {::ev/name "restore-pin-version"})) - :snapshot - (rx/of (ptk/event ::ev/event {::ev/name "restore-autosave"})) - - :plugin - (rx/of (ptk/event ::ev/event {::ev/name "restore-version-plugin"})) + (->> (wait-for-persistence file-id id) + (rx/map #(initialize-version))) + (if event-name + (rx/of (ev/event {::ev/name event-name + :file-id file-id + :team-id team-id})) (rx/empty))))))) (defn delete-version @@ -220,18 +247,15 @@ (ptk/reify ::restore-version-from-plugins ptk/WatchEvent (watch [_ state _] - (let [file (dsh/lookup-file state file-id) - team-id (or (:team-id file) (:current-file-id state))] + (let [team-id (:current-team-id state)] (rx/concat - (rx/of (ptk/event ::ev/event {::ev/name "restore-version-plugin"}) + (rx/of (ev/event {::ev/name "restore-version-plugin" + :file-id file-id + :team-id team-id}) ::dwp/force-persist) - ;; FIXME: we should abstract this - (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) - (rx/filter #(or (nil? %) (= :saved %))) - (rx/take 1) - (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) - (rx/map #(dw/initialize-workspace team-id file-id id))) + (->> (wait-for-persistence file-id id) + (rx/map #(initialize-version))) (->> (rx/of 1) (rx/tap resolve) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 391101d4be..8d57dc9361 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -428,52 +428,71 @@ (ex/print-throwable instance :prefix "Server Error")) (st/async-emit! (rt/assign-exception error))) +(defn- from-extension? + "True when the error stack trace originates from a browser extension." + [cause] + (let [stack (.-stack cause)] + (and (string? stack) + (or (str/includes? stack "chrome-extension://") + (str/includes? stack "moz-extension://"))))) + +(defn- from-posthog? + "True when the error stack trace originates from PostHog analytics." + [cause] + (let [stack (.-stack cause)] + (and (string? stack) + (str/includes? stack "posthog")))) + +(defn is-ignorable-exception? + "True when the error is known to be harmless (browser extensions, analytics, + React/extension DOM conflicts, etc.) and should NOT be surfaced to the user." + [cause] + (let [message (ex-message cause)] + (or (from-extension? cause) + (from-posthog? cause) + (= message "Possible side-effect in debug-evaluate") + (= message "Unexpected end of input") + (str/starts-with? message "invalid props on component") + (str/starts-with? message "Unexpected token ") + ;; Native AbortError DOMException: raised when an in-flight + ;; HTTP fetch is cancelled via AbortController (e.g. by an + ;; RxJS unsubscription / take-until chain). These are + ;; handled gracefully inside app.util.http/fetch and must NOT + ;; be surfaced as application errors. + (= (.-name ^js cause) "AbortError") + ;; Zone.js (injected by browser extensions such as Angular + ;; DevTools) wraps event listeners and assigns a custom + ;; .toString to its wrapper functions using + ;; Object.defineProperty. When the wrapper was previously + ;; defined with {writable: false}, a subsequent plain assignment + ;; in strict mode (our libs.js uses "use strict") throws this + ;; TypeError. This is a known Zone.js / browser-extension + ;; incompatibility and is NOT a Penpot bug. + (str/starts-with? message "Cannot assign to read only property 'toString'") + ;; NotFoundError DOMException: "Failed to execute + ;; 'removeChild' on 'Node'" — Thrown by React's commit + ;; phase when the DOM tree has been modified externally + ;; (typically by browser extensions like Grammarly, + ;; LastPass, translation tools, or ad blockers that + ;; inject/remove nodes). The entire stack trace is inside + ;; React internals (libs.js) with no application code, + ;; so there is nothing actionable on our side. React's + ;; error boundary already handles recovery. + (and (= (.-name ^js cause) "NotFoundError") + (str/includes? message "removeChild"))))) + +(defn- from-plugin? + "Check if the error is marked as originating from plugin code. The + plugin runtime tracks plugin errors in a WeakMap, which works even + in SES hardened environments where error objects may be frozen." + [cause] + (try + (is-plugin-error? cause) + (catch :default _ + false))) + (defonce uncaught-error-handler - (letfn [(from-extension? [cause] - (let [stack (.-stack cause)] - (and (string? stack) - (or (str/includes? stack "chrome-extension://") - (str/includes? stack "moz-extension://"))))) - - (from-posthog? [cause] - (let [stack (.-stack cause)] - (and (string? stack) - (str/includes? stack "posthog")))) - - ;; Check if the error is marked as originating from plugin code. - ;; The plugin runtime tracks plugin errors in a WeakMap, which works - ;; even in SES hardened environments where error objects may be frozen. - (from-plugin? [cause] - (try - (is-plugin-error? cause) - (catch :default _ - false))) - - (is-ignorable-exception? [cause] - (let [message (ex-message cause)] - (or (from-extension? cause) - (from-posthog? cause) - (= message "Possible side-effect in debug-evaluate") - (= message "Unexpected end of input") - (str/starts-with? message "invalid props on component") - (str/starts-with? message "Unexpected token ") - ;; Native AbortError DOMException: raised when an in-flight - ;; HTTP fetch is cancelled via AbortController (e.g. by an - ;; RxJS unsubscription / take-until chain). These are - ;; handled gracefully inside app.util.http/fetch and must NOT - ;; be surfaced as application errors. - (= (.-name ^js cause) "AbortError") - ;; Zone.js (injected by browser extensions such as Angular - ;; DevTools) wraps event listeners and assigns a custom - ;; .toString to its wrapper functions using - ;; Object.defineProperty. When the wrapper was previously - ;; defined with {writable: false}, a subsequent plain assignment - ;; in strict mode (our libs.js uses "use strict") throws this - ;; TypeError. This is a known Zone.js / browser-extension - ;; incompatibility and is NOT a Penpot bug. - (str/starts-with? message "Cannot assign to read only property 'toString'")))) - - (on-unhandled-error [event] + (letfn [(on-unhandled-error [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "error")] (cond diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 0c80841cf6..8b65b32fb6 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -259,9 +259,6 @@ (def workspace-layout (l/derived :workspace-layout st/state)) -(def workspace-file-version-id - (l/derived :workspace-file-version-id st/state)) - (def snap-pixel? (l/derived #(contains? % :snap-pixel-grid) workspace-layout)) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 1e6dd417a6..9b5b9872ab 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -182,6 +182,7 @@ :credentials "include" :headers {"accept" "application/transit+json,text/event-stream,*/*" "x-external-session-id" (cf/external-session-id) + "x-session-id" (str cf/session-id) "x-event-origin" (::ev/origin (meta params))} :body (when (= method :post) (if form-data? diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index b9aab0ecf0..0ef0936d22 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -52,7 +52,7 @@ (mf/defc workspace-content* {::mf/private true} - [{:keys [file layout page wglobal file-version-id]}] + [{:keys [file layout page wglobal]}] (let [palete-size (mf/use-state nil) selected (mf/deref refs/selected-shapes) @@ -110,7 +110,6 @@ :wglobal wglobal :selected selected :layout layout - :file-version-id file-version-id :palete-size (when (and (or colorpalette? textpalette?) (not hide-ui?)) @palete-size)}]]] @@ -170,7 +169,7 @@ (mf/defc workspace-inner* {::mf/private true} - [{:keys [page-id file-id file layout wglobal file-version-id]}] + [{:keys [page-id file-id file layout wglobal]}] (let [page-ref (mf/with-memo [file-id page-id] (make-page-ref file-id page-id)) page (mf/deref page-ref)] @@ -189,8 +188,7 @@ [:> workspace-content* {:file file :page page :wglobal wglobal - :layout layout - :file-version-id file-version-id}] + :layout layout}] [:> workspace-loader*]))) (mf/defc workspace* @@ -202,7 +200,6 @@ layout (mf/deref refs/workspace-layout) wglobal (mf/deref refs/workspace-global) - file-version-id (mf/deref refs/workspace-file-version-id) team-ref (mf/with-memo [team-id] (make-team-ref team-id)) @@ -278,8 +275,7 @@ :file-id file-id :file file :wglobal wglobal - :layout layout - :file-version-id file-version-id}]) + :layout layout}]) (when (or (not (and file-loaded? page-id)) ;; in wasm renderer, extend the pixel loader until the first frame is rendered ;; but do not apply it when switching pages diff --git a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs index 71533852e8..90b27f6ee2 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs @@ -265,54 +265,68 @@ prev-transforms (mf/use-var nil)] (mf/with-effect [add-children] - (ts/raf - #(doseq [{:keys [shape]} add-children-prev] - (let [shape-node (get-shape-node shape) - mirror-node (dom/query (dm/fmt ".mirror-shape[href='#shape-%'" shape))] - (when mirror-node (.remove mirror-node)) - (dom/remove-attribute! (dom/get-parent shape-node) "display")))) + (let [raf-id1 + (ts/raf + #(doseq [{:keys [shape]} add-children-prev] + (let [shape-node (get-shape-node shape) + mirror-node (dom/query (dm/fmt ".mirror-shape[href='#shape-%'" shape))] + (when mirror-node (.remove mirror-node)) + (when-let [parent (some-> shape-node dom/get-parent)] + (dom/remove-attribute! parent "display"))))) - (ts/raf - #(doseq [{:keys [frame shape]} add-children] - (let [frame-node (get-shape-node frame) - shape-node (get-shape-node shape) + raf-id2 + (ts/raf + #(doseq [{:keys [frame shape]} add-children] + (let [frame-node (get-shape-node frame) + shape-node (get-shape-node shape)] + (when (and (some? frame-node) (some? shape-node)) + (let [clip-id + (-> (dom/query frame-node ":scope > defs > .frame-clip-def") + (dom/get-attribute "id")) - clip-id - (-> (dom/query frame-node ":scope > defs > .frame-clip-def") - (dom/get-attribute "id")) + use-node + (dom/create-element "http://www.w3.org/2000/svg" "use") - use-node - (dom/create-element "http://www.w3.org/2000/svg" "use") + contents-node + (or (dom/query frame-node ".frame-children") frame-node)] - contents-node - (or (dom/query frame-node ".frame-children") frame-node)] - - (dom/set-attribute! use-node "href" (dm/fmt "#shape-%" shape)) - (dom/set-attribute! use-node "clip-path" (dm/fmt "url(#%)" clip-id)) - (dom/add-class! use-node "mirror-shape") - (dom/append-child! contents-node use-node) - (dom/set-attribute! (dom/get-parent shape-node) "display" "none"))))) + (dom/set-attribute! use-node "href" (dm/fmt "#shape-%" shape)) + (dom/set-attribute! use-node "clip-path" (dm/fmt "url(#%)" clip-id)) + (dom/add-class! use-node "mirror-shape") + (dom/append-child! contents-node use-node) + (dom/set-attribute! (dom/get-parent shape-node) "display" "none"))))))] + (fn [] + (js/cancelAnimationFrame raf-id1) + (js/cancelAnimationFrame raf-id2)))) (mf/with-effect [transforms] (let [curr-shapes-set (into #{} (map :id) shapes) prev-shapes-set (into #{} (map :id) @prev-shapes) new-shapes (->> shapes (remove #(contains? prev-shapes-set (:id %)))) - removed-shapes (->> @prev-shapes (remove #(contains? curr-shapes-set (:id %))))] + removed-shapes (->> @prev-shapes (remove #(contains? curr-shapes-set (:id %)))) - ;; NOTE: we schedule the dom modifications to be executed - ;; asynchronously for avoid component flickering when react18 - ;; is used. + ;; NOTE: we schedule the dom modifications to be executed + ;; asynchronously for avoid component flickering when react18 + ;; is used. - (when (d/not-empty? new-shapes) - (ts/raf #(start-transform! node new-shapes))) + raf-id1 + (when (d/not-empty? new-shapes) + (ts/raf #(start-transform! node new-shapes))) - (when (d/not-empty? shapes) - (ts/raf #(update-transform! node shapes transforms modifiers))) + raf-id2 + (when (d/not-empty? shapes) + (ts/raf #(update-transform! node shapes transforms modifiers))) - (when (d/not-empty? removed-shapes) - (ts/raf #(remove-transform! node removed-shapes)))) + raf-id3 + (when (d/not-empty? removed-shapes) + (ts/raf #(remove-transform! node removed-shapes)))] - (reset! prev-modifiers modifiers) - (reset! prev-transforms transforms) - (reset! prev-shapes shapes)))) + (reset! prev-modifiers modifiers) + (reset! prev-transforms transforms) + (reset! prev-shapes shapes) + + (fn [] + (when raf-id1 (js/cancelAnimationFrame raf-id1)) + (when raf-id2 (js/cancelAnimationFrame raf-id2)) + (when raf-id3 (js/cancelAnimationFrame raf-id3))))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs index 66fd558ccd..14f494504d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -22,6 +22,7 @@ [app.main.data.modal :as modal] [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.thumbnails-wasm :as dwt.wasm] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.variants :as dwv] [app.main.features :as features] @@ -242,7 +243,11 @@ ;; afterwards, in the next render cycle. (dom/append-child! item-el counter-el) (dnd/set-drag-image! event item-el (:x offset) (:y offset)) - (ts/raf #(.removeChild ^js item-el counter-el)))) + ;; Guard against race condition: if the user navigates away + ;; before the RAF fires, item-el may have been unmounted and + ;; counter-el is no longer a child — removeChild would throw. + (ts/raf #(when (dom/child? counter-el item-el) + (dom/remove-child! item-el counter-el))))) (defn on-asset-drag-start [event file-id asset selected item-ref asset-type on-drag-start] @@ -282,6 +287,9 @@ (let [page-id (:main-instance-page component) root-id (:main-instance-id component) retry (mf/use-state 0) + wasm? (features/active-feature? @st/state "render-wasm/v1") + current-page-id (mf/deref refs/current-page-id) + thumbnail-requested? (mf/use-ref false) thumbnail-uri* (mf/with-memo [file-id page-id root-id] @@ -298,9 +306,23 @@ (when (< @retry 3) (inc retry))))] + ;; Lazy WASM thumbnail rendering: when the component becomes + ;; visible, has no cached thumbnail, and lives on the current page + ;; trigger a render. Ref is used to avoid triggering multiple renders + ;; while the component is still not rendered and the thumbnail URI + ;; is not available. + (mf/use-effect + (mf/deps is-hidden thumbnail-uri wasm? current-page-id file-id page-id) + (fn [] + (if (some? thumbnail-uri) + (mf/set-ref-val! thumbnail-requested? false) + (when (and wasm? (not is-hidden) (not (mf/ref-val thumbnail-requested?)) (= page-id current-page-id)) + (mf/set-ref-val! thumbnail-requested? true) + (st/emit! (dwt.wasm/render-thumbnail file-id page-id root-id)))))) + (if (and (some? thumbnail-uri) (or (contains? cf/flags :component-thumbnails) - (features/active-feature? @st/state "render-wasm/v1"))) + wasm?)) [:& component-svg-thumbnail {:thumbnail-uri thumbnail-uri :class class diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 401b01d67b..3a8f07a136 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -589,7 +589,7 @@ :on-click on-select :disabled loop} (when visible? - [:> cmm/component-item-thumbnail* {:file-id (:file-id item) + [:> cmm/component-item-thumbnail* {:file-id file-id :class (stl/css :swap-item-thumbnail) :root-shape root-shape :component item diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index 769d3a182c..c8e43e7f1c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -531,16 +531,18 @@ [:map [:values schema:layout-item-props-schema] [:applied-tokens [:maybe [:map-of :keyword :string]]] - [:ids [::sm/vec ::sm/uuid]] - [:v-sizing {:optional true} [:maybe [:enum :fill :fix :auto]]]]) + [:ids [::sm/vec ::sm/uuid]]]) (mf/defc layout-size-constraints* {::mf/private true ::mf/schema (sm/schema schema:layout-size-constraints)} - [{:keys [values v-sizing ids applied-tokens] :as props}] + [{:keys [values ids applied-tokens] :as props}] (let [token-numeric-inputs (features/use-feature "tokens/numeric-input") + v-sizing + (:layout-item-v-sizing values) + min-w (get values :layout-item-min-w) max-w (get values :layout-item-max-w) @@ -904,5 +906,4 @@ (= v-sizing :fill)) [:> layout-size-constraints* {:ids ids :values values - :applied-tokens applied-tokens - :v-sizing v-sizing}])])])) + :applied-tokens applied-tokens}])])])) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 3b75d406bb..6807834d87 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -81,6 +81,7 @@ selected)) (mf/defc viewport-classic* + {::mf/private true} [{:keys [selected wglobal layout file page palete-size]}] (let [{:keys [edit-path panning @@ -108,8 +109,8 @@ ;; DEREFS drawing (mf/deref refs/workspace-drawing) focus (mf/deref refs/workspace-focus-selected) - file-id (get file :id) + vern (get file :vern) page-id (get page :id) objects (get page :objects) background (get page :background clr/canvas) @@ -341,7 +342,7 @@ :opacity 0.6}} (when (and (:can-edit permissions) (not read-only?)) [:& stvh/viewport-texts - {:key (dm/str "texts-" page-id) + {:key (dm/str "viewport-texts-" page-id "-" vern) :page-id page-id :objects objects :modifiers modifiers @@ -367,7 +368,7 @@ :xmlnsXlink "http://www.w3.org/1999/xlink" :xmlns:penpot "https://penpot.app/xmlns" :preserveAspectRatio "xMidYMid meet" - :key (str "render" page-id) + :key (dm/str "viewport-svg-" page-id "-" vern) :width (:width vport 0) :height (:height vport 0) :view-box (utils/format-viewbox vbox) @@ -401,7 +402,7 @@ [:& (mf/provider ctx/current-vbox) {:value vbox'} [:& (mf/provider use/include-metadata-ctx) {:value (dbg/enabled? :show-export-metadata)} ;; Render root shape - [:& shapes/root-shape {:key page-id + [:& shapes/root-shape {:key (str page-id) :objects base-objects :active-frames @active-frames}]]]] @@ -409,7 +410,7 @@ {:xmlns "http://www.w3.org/2000/svg" :xmlnsXlink "http://www.w3.org/1999/xlink" :preserveAspectRatio "xMidYMid meet" - :key (str "viewport" page-id) + :key (dm/str "viewport-controls-" page-id "-" vern) :view-box (utils/format-viewbox vbox) :ref on-viewport-ref :class (dm/str @cursor (when drawing-tool " drawing") " " (stl/css :viewport-controls)) @@ -720,7 +721,7 @@ (not= @hover-top-frame-id (:id frame))) [:& grid-layout/editor {:zoom zoom - :key (dm/str (:id frame)) + :key (dm/str "viewport-frame-" (:id frame)) :objects base-objects :modifiers modifiers :shape frame @@ -734,8 +735,11 @@ :bottom-padding (when palete-size (+ palete-size 8))}]]]]])) (mf/defc viewport* - [props] - (let [wasm-renderer-enabled? (features/use-feature "render-wasm/v1")] - (if ^boolean wasm-renderer-enabled? - [:> viewport.wasm/viewport* props] - [:> viewport-classic* props]))) + [{:keys [file page] :as props}] + (let [vern (get file :vern) + page-id (get page :id) + render-wasm? (features/use-feature "render-wasm/v1")] + [:* {:key (dm/str "viewport-" page-id "-" vern)} + (if ^boolean render-wasm? + [:> viewport.wasm/viewport* props] + [:> viewport-classic* props])])) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index e71747b2d3..5b15c06f7e 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -79,7 +79,7 @@ (apply-modifiers-to-objects objects (select-keys (into {} modifiers) selected))) (mf/defc viewport* - [{:keys [selected wglobal layout file page palete-size file-version-id]}] + [{:keys [selected wglobal layout file page palete-size]}] (let [;; When adding data from workspace-local revisit `app.main.ui.workspace` to check ;; that the new parameter is sent @@ -111,6 +111,7 @@ workspace-editor-state (mf/deref refs/workspace-editor-state) file-id (get file :id) + vern (get file :vern) objects (get page :objects) page-id (get page :id) background (get page :background clr/canvas) @@ -154,7 +155,7 @@ canvas-ref (mf/use-ref nil) text-editor-ref (mf/use-ref nil) - last-file-version-id-ref (mf/use-ref nil) + last-vern-ref (mf/use-ref nil) ;; STATE REFS disable-paste-ref (mf/use-ref false) @@ -393,10 +394,11 @@ (when (and @canvas-init? preview-blend) (wasm.api/request-render "with-effect"))) - (mf/with-effect [@canvas-init? file-version-id zoom vbox background] + (mf/with-effect [@canvas-init? vern zoom vbox background] (when @canvas-init? (if (not @initialized?) (do + (mf/set-ref-val! last-vern-ref vern) ;; Initial file open uses the same transition workflow as page switches, ;; but with a solid background-color blurred placeholder. (wasm.api/start-initial-load-transition! background) @@ -404,14 +406,12 @@ ;; blank canvas (first load) visible while shapes load. ;; The loading overlay is suppressed because on-shapes-ready ;; is set. - (wasm.api/initialize-viewport - base-objects zoom vbox :background background) - (reset! initialized? true) - (mf/set-ref-val! last-file-version-id-ref file-version-id)) - (when (and (some? file-version-id) - (not= file-version-id (mf/ref-val last-file-version-id-ref))) (wasm.api/initialize-viewport base-objects zoom vbox :background background) - (mf/set-ref-val! last-file-version-id-ref file-version-id))))) + (reset! initialized? true)) + + (when (and (some? vern) (not= vern (mf/ref-val last-vern-ref))) + (wasm.api/initialize-viewport base-objects zoom vbox :background background) + (mf/set-ref-val! last-vern-ref vern))))) (mf/with-effect [focus] (when (and @canvas-init? @initialized?) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index a44bafb15b..c3e1a899c0 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1518,6 +1518,23 @@ [] (h/call wasm/internal-module "_clean_modifiers")) +(defn set-modifiers-start + "Enter interactive transform mode (drag / resize / rotate). Enables + fast-mode effect skipping in the renderer and activates an atlas + backdrop so tiles do not appear sequentially or flicker while the + gesture is in progress." + [] + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (h/call wasm/internal-module "_set_modifiers_start"))) + +(defn set-modifiers-end + "Leave interactive transform mode. Cancels any pending async render + scheduled under it; the caller is expected to trigger a full-quality + render (via `request-render`) once the gesture is committed." + [] + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (h/call wasm/internal-module "_set_modifiers_end"))) + (defn set-modifiers [modifiers] @@ -1583,6 +1600,42 @@ (when (and (number? n) (not (js/isNaN n)) (pos? n)) n)))) +(defn- wasm-blur-downscale-threshold-from-route-params + "Reads optional `aa_threshold` query param from the router" + [] + (when-let [raw (let [p (rt/get-params @st/state)] + (:blur_downscale_threshold p))] + (let [n (if (string? raw) (js/parseFloat raw) raw)] + (when (and (number? n) (not (js/isNaN n)) (pos? n)) + n)))) + +(defn- wasm-max-blocking-time-ms-from-route-params + "Reads optional `aa_threshold` query param from the router" + [] + (when-let [raw (let [p (rt/get-params @st/state)] + (:max_blocking_time_ms p))] + (let [n (if (string? raw) (js/parseInt raw 10) raw)] + (when (and (number? n) (not (js/isNaN n)) (pos? n)) + n)))) + +(defn- wasm-node-batch-threshold-from-route-params + "Reads optional `aa_threshold` query param from the router" + [] + (when-let [raw (let [p (rt/get-params @st/state)] + (:node_batch_threshold p))] + (let [n (if (string? raw) (js/parseInt raw 10) raw)] + (when (and (number? n) (not (js/isNaN n)) (pos? n)) + n)))) + +(defn- wasm-viewport-interest-area-threshold-from-route-params + "Reads optional `aa_threshold` query param from the router" + [] + (when-let [raw (let [p (rt/get-params @st/state)] + (:viewport_interest_area_threshold p))] + (let [n (if (string? raw) (js/parseInt raw 10) raw)] + (when (and (number? n) (not (js/isNaN n)) (pos? n)) + n)))) + (defn set-canvas-size [canvas] (let [width (or (.-clientWidth ^js canvas) (.-width ^js canvas)) @@ -1620,6 +1673,14 @@ (h/call wasm/internal-module "_set_render_options" flags dpr) (when-let [t (wasm-aa-threshold-from-route-params)] (h/call wasm/internal-module "_set_antialias_threshold" t)) + (when-let [t (wasm-viewport-interest-area-threshold-from-route-params)] + (h/call wasm/internal-module "_set_viewport_interest_area_threshold" t)) + (when-let [t (wasm-max-blocking-time-ms-from-route-params)] + (h/call wasm/internal-module "_set_max_blocking_time_ms" t)) + (when-let [t (wasm-node-batch-threshold-from-route-params)] + (h/call wasm/internal-module "_set_node_batch_threshold" t)) + (when-let [t (wasm-blur-downscale-threshold-from-route-params)] + (h/call wasm/internal-module "_set_blur_downscale_threshold" t)) (when-let [max-tex (webgl/max-texture-size context)] (h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex)) diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 3c011cd3db..b46bf6cafc 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -121,24 +121,25 @@ ;; IMPORTANT: Only TTF fonts can be stored. (defn- store-font-buffer [font-data font-array-buffer emoji? fallback?] - (let [font-id-buffer (:family-id-buffer font-data) - size (.-byteLength font-array-buffer) - ptr (h/call wasm/internal-module "_alloc_bytes" size) - heap (gobj/get ^js wasm/internal-module "HEAPU8") - mem (js/Uint8Array. (.-buffer heap) ptr size)] + (when wasm/context-initialized? + (let [font-id-buffer (:family-id-buffer font-data) + size (.-byteLength font-array-buffer) + ptr (h/call wasm/internal-module "_alloc_bytes" size) + heap (gobj/get ^js wasm/internal-module "HEAPU8") + mem (js/Uint8Array. (.-buffer heap) ptr size)] - (.set mem (js/Uint8Array. font-array-buffer)) - (st/emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) - (h/call wasm/internal-module "_store_font" - (aget font-id-buffer 0) - (aget font-id-buffer 1) - (aget font-id-buffer 2) - (aget font-id-buffer 3) - (:weight font-data) - (:style font-data) - emoji? - fallback?) - true)) + (.set mem (js/Uint8Array. font-array-buffer)) + (st/emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) + (h/call wasm/internal-module "_store_font" + (aget font-id-buffer 0) + (aget font-id-buffer 1) + (aget font-id-buffer 2) + (aget font-id-buffer 3) + (:weight font-data) + (:style font-data) + emoji? + fallback?) + true))) ;; Tracks fonts currently being fetched: {url -> fallback?} ;; When the same font is requested as both primary and fallback, diff --git a/frontend/test/frontend_tests/errors_test.cljs b/frontend/test/frontend_tests/errors_test.cljs new file mode 100644 index 0000000000..8d217fca04 --- /dev/null +++ b/frontend/test/frontend_tests/errors_test.cljs @@ -0,0 +1,95 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.errors-test + (:require + [app.main.errors :as errors] + [cljs.test :as t :include-macros true])) + +(defn- make-error + "Create a JS Error-like object with the given name, message, and optional stack." + [error-name message & {:keys [stack] :or {stack ""}}] + (let [err (js/Error. message)] + (set! (.-name err) error-name) + (when (some? stack) + (set! (.-stack err) stack)) + err)) + +;; --------------------------------------------------------------------------- +;; is-ignorable-exception? tests +;; --------------------------------------------------------------------------- + +(t/deftest test-ignorable-chrome-extension + (t/testing "Errors from Chrome extensions are ignorable" + (let [cause (make-error "Error" "some error" + :stack "Error: some error\n at chrome-extension://abc123/content.js:1:1")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-moz-extension + (t/testing "Errors from Firefox extensions are ignorable" + (let [cause (make-error "Error" "some error" + :stack "Error: some error\n at moz-extension://abc123/content.js:1:1")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-posthog + (t/testing "Errors from PostHog are ignorable" + (let [cause (make-error "Error" "some error" + :stack "Error: some error\n at https://app.posthog.com/static/array.js:1:1")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-debug-evaluate + (t/testing "Debug-evaluate side-effect errors are ignorable" + (let [cause (make-error "Error" "Possible side-effect in debug-evaluate")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-unexpected-end-of-input + (t/testing "Unexpected end of input errors are ignorable" + (let [cause (make-error "SyntaxError" "Unexpected end of input")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-invalid-props + (t/testing "Invalid React props errors are ignorable" + (let [cause (make-error "Error" "invalid props on component Foo")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-unexpected-token + (t/testing "Unexpected token errors are ignorable" + (let [cause (make-error "SyntaxError" "Unexpected token <")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-abort-error + (t/testing "AbortError DOMException is ignorable" + (let [cause (make-error "AbortError" "The operation was aborted")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-zone-js-tostring + (t/testing "Zone.js toString read-only property error is ignorable" + (let [cause (make-error "TypeError" + "Cannot assign to read only property 'toString' of function 'function () { [native code] }'")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-ignorable-not-found-error-remove-child + (t/testing "NotFoundError with removeChild message is ignorable" + (let [cause (make-error "NotFoundError" + "Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node." + :stack "NotFoundError: Failed to execute 'removeChild'\n at zLe (libs.js:1:1)")] + (t/is (true? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-not-ignorable-not-found-error-other + (t/testing "NotFoundError without removeChild is NOT ignorable" + (let [cause (make-error "NotFoundError" + "Failed to execute 'insertBefore' on 'Node': something else")] + (t/is (false? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-not-ignorable-regular-error + (t/testing "Regular application errors are NOT ignorable" + (let [cause (make-error "Error" "Cannot read property 'x' of undefined")] + (t/is (false? (errors/is-ignorable-exception? cause)))))) + +(t/deftest test-not-ignorable-type-error + (t/testing "Regular TypeError is NOT ignorable" + (let [cause (make-error "TypeError" "undefined is not a function")] + (t/is (false? (errors/is-ignorable-exception? cause)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index ff7a1f0699..b7b53f8fbb 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -9,6 +9,7 @@ [frontend-tests.data.workspace-media-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] + [frontend-tests.errors-test] [frontend-tests.helpers-shapes-test] [frontend-tests.logic.comp-remove-swap-slots-test] [frontend-tests.logic.components-and-tokens] @@ -44,6 +45,7 @@ (t/run-tests 'frontend-tests.basic-shapes-test 'frontend-tests.data.repo-test + 'frontend-tests.errors-test 'frontend-tests.main-errors-test 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 33ba407d23..3f6c19bd10 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -386,6 +386,9 @@ For many tasks, it can be critical to visually inspect the design. Remember to u * When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design. NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to non-creative defaults such as white/black if you are lacking information). +* When creating new designs, + - ensure a clean internal structure by applying flex and grid layouts when appropriate + - ensure proper semantic naming of elements. # Revising Designs diff --git a/package.json b/package.json index 26baa0d989..fbb4c5d92f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,6 @@ "@github/copilot": "^1.0.21", "@types/node": "^25.5.2", "esbuild": "^0.28.0", - "opencode-ai": "^1.4.3" + "opencode-ai": "^1.14.19" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 276b2891e2..32b4fa3382 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^0.28.0 version: 0.28.0 opencode-ai: - specifier: ^1.4.3 - version: 1.4.3 + specifier: ^1.14.19 + version: 1.14.19 packages: @@ -227,67 +227,67 @@ packages: engines: {node: '>=18'} hasBin: true - opencode-ai@1.4.3: - resolution: {integrity: sha512-WwCSrLgJiS+sLIWoi9pa62vAw3l6VI3a+ShhjDDMUJBBG2FxU18xEhk8xhEedLMKyHo1p0nwD41+iKZ1y+rdAw==} + opencode-ai@1.14.19: + resolution: {integrity: sha512-67h56qYcJivd2U9VK8LJvyMBCc3ZE3HcJ/qL4YtaidSnjEumy4SxO+HHlDISsLase7TUQ0w1nULOAibdZxGzbQ==} hasBin: true - opencode-darwin-arm64@1.4.3: - resolution: {integrity: sha512-d/MT28Is5yhdFY+36AqKc5r31zx8lXTQIYblfn5R8kdhamXijZVGdD0pHl3eJc1ZolUHNwzg2B+IqV22uyU9GQ==} + opencode-darwin-arm64@1.14.19: + resolution: {integrity: sha512-gjJ97dTiBbCas3Y7K6KQMQm5/KNIBOM9+10KZHqNr2bQfn0N09O977ZkXoX6IVBBE2632Ahaa71d4pzLKN9ULw==} cpu: [arm64] os: [darwin] - opencode-darwin-x64-baseline@1.4.3: - resolution: {integrity: sha512-WTqf7WBNRZcv6pClqnN4F7X/T/osgcPGikNHkHUSLszKWg9flqz7Z68kHR4i9ae8Bn3ke9MQRgzRdOt2PgLL0w==} + opencode-darwin-x64-baseline@1.14.19: + resolution: {integrity: sha512-UuwLpa511h7qQ+rTmOUmsHch/N4NBQT64dzg+iLWnR5yoR/81inENpbwxiS7hXpVdzCUGo/YnxI1u6SIBoMlTQ==} cpu: [x64] os: [darwin] - opencode-darwin-x64@1.4.3: - resolution: {integrity: sha512-8FUHeybVmaCYt4S2YmWcf32o/xa/ahCfI258bpWssrzs7Xg51JgUB/Csoble0I1mH7RpW39SKy/hHUtHGuJfJg==} + opencode-darwin-x64@1.14.19: + resolution: {integrity: sha512-uksrjOtWI7Ob5JvjZBSsrKuy3JVF9d89oYZYfWS5m8ordNyv1Nob39MXJXizv85ozsXjSb0rqjpJurnJw8K+tQ==} cpu: [x64] os: [darwin] - opencode-linux-arm64-musl@1.4.3: - resolution: {integrity: sha512-3Ej2klaep8+fxcc44UyEuRpb/UFiNkwfzIDLIST83hFUtjzprjpTRqg6zHmOfzyfjNAaNpB4VZw6e9y3mGBpiQ==} + opencode-linux-arm64-musl@1.14.19: + resolution: {integrity: sha512-R1BJuBGWHfBxfKvIA/Hb4nhYaJgCKl1B+mAGNydu+z0CLtGtwU8r+kQWF/G2N0y8Vx6Y6DRfJiv1X0eZEfD1BQ==} cpu: [arm64] os: [linux] - opencode-linux-arm64@1.4.3: - resolution: {integrity: sha512-9jpVSOEF7TX3gPPAHVAsBT9XEO3LgYafI+IUmOzbBB9CDiVVNJw6JmEffmSpSxY4nkAh322xnMbNjVGEyXQBRA==} + opencode-linux-arm64@1.14.19: + resolution: {integrity: sha512-Bo+aZOppLF366mgGfK0CnIcAVy1EmsrBv93eot1CmPSN1oeud07GpGdn3Bjl5f6KuBx1io6JsvjQyHno+MH5AA==} cpu: [arm64] os: [linux] - opencode-linux-x64-baseline-musl@1.4.3: - resolution: {integrity: sha512-aned/3FQTHXXQv2PPKDprJwQaQkoadriQ6AByGhRl6/bHhSkhkiVl6cHHvYMKxYEwN4bVOydWhasfgm/xru/xw==} + opencode-linux-x64-baseline-musl@1.14.19: + resolution: {integrity: sha512-+K8MuGoHugtUec4P/nKcTwZFUipHfW7oPpwlIoPiAQou3bNFTzzP6rslbzzNwjXlQRsUw9GAtuIPDOCL6CkgDg==} cpu: [x64] os: [linux] - opencode-linux-x64-baseline@1.4.3: - resolution: {integrity: sha512-HpzdgYaI90qqt0WokcyBhadgFQ0EYMhq4TZ4EcaSPuZTssS2Drb6kp70Si54uOJL/MUAdc9+E0BYYIAdOJ6h1g==} + opencode-linux-x64-baseline@1.14.19: + resolution: {integrity: sha512-KuvITzg4iK0hdIjpNZepwu3bLZ/dUZDI6BwCoV4w//VEP1j3UfDyeS3vWghKcQLd2T1+yybuEMM/3RXcwm/lGQ==} cpu: [x64] os: [linux] - opencode-linux-x64-musl@1.4.3: - resolution: {integrity: sha512-ibUevyDxVrwkp6FWu8UBCBsrzlKDT/uEug2NHCKaHIwo9uwVf5zsL/0ueHYqmH14SHK+M6wzWewYk6WuW9f0zQ==} + opencode-linux-x64-musl@1.14.19: + resolution: {integrity: sha512-0qe2+X76UJdrCdhdlJyfubMC4tveHAVxjSmPq7g9Zm95heBeJdcQDCLeyQk/lGgeXgsZzVPfLmyTWNtBvCZYFQ==} cpu: [x64] os: [linux] - opencode-linux-x64@1.4.3: - resolution: {integrity: sha512-RS6TsDqTUrW5sefxD1KD9Xy9mSYGXAlr2DlGrdi8vNm9e/Bt4r4u557VB7f/Uj2CxTt2Gf7OWl08ZoPlxMJ5Gg==} + opencode-linux-x64@1.14.19: + resolution: {integrity: sha512-2GljfL7BeG4xALBJVRwaAGCM/dzYF5aQf6bfLTKsQIl6QpLUguYSF+fkStBHLeehyqbDP5MtiEEuXjC0+mecjA==} cpu: [x64] os: [linux] - opencode-windows-arm64@1.4.3: - resolution: {integrity: sha512-2ViH17WpIQbRVfQaOBMi49pu73gqTQYT/4/WxFjShmRagX40/KkG18fhvyDAZrBKfkhPtdwgFsFxMSYP9F6QCQ==} + opencode-windows-arm64@1.14.19: + resolution: {integrity: sha512-8/vRHe5tHexikfPceLmpjsQiEhuDTOSCSlEmP4s0Yq3UAkVaDAxpiWq7Bx4g8hjr1gzfXD9vbjV+WHq/BtMC/w==} cpu: [arm64] os: [win32] - opencode-windows-x64-baseline@1.4.3: - resolution: {integrity: sha512-SWYDli9SAKQd/pS/hVfuq1KEsc+gnAJdv+YtBmxaHOw57y0euqLwbGFUYFq78GAMGt/RnTYWZIEUbRK/ZiX3UA==} + opencode-windows-x64-baseline@1.14.19: + resolution: {integrity: sha512-Z8imEJK/srE/r1fr7oNLvpLTeRJQyuL7vsbXvCt3T7j2Ew9BOZ7RuYa8EE0R6bNqQ+MLhBGPiAG7NWc++MgK8Q==} cpu: [x64] os: [win32] - opencode-windows-x64@1.4.3: - resolution: {integrity: sha512-UxmKDIw3t4XHST6JSUWHmSrCGIEK1LRTAOpO82HBC3XkIjH78gVIeauRR6RULjWAApmy9I1C3TukO2sDUi7Gvw==} + opencode-windows-x64@1.14.19: + resolution: {integrity: sha512-/TqGN91WiUzx7IPMPwmpMIzRixi5TMjaBcC9FH1TgD7DCqKnP6pokvu+ak0C9xwA4wKorE9PoZzeRt/+c3rDCQ==} cpu: [x64] os: [win32] @@ -434,55 +434,55 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 - opencode-ai@1.4.3: + opencode-ai@1.14.19: optionalDependencies: - opencode-darwin-arm64: 1.4.3 - opencode-darwin-x64: 1.4.3 - opencode-darwin-x64-baseline: 1.4.3 - opencode-linux-arm64: 1.4.3 - opencode-linux-arm64-musl: 1.4.3 - opencode-linux-x64: 1.4.3 - opencode-linux-x64-baseline: 1.4.3 - opencode-linux-x64-baseline-musl: 1.4.3 - opencode-linux-x64-musl: 1.4.3 - opencode-windows-arm64: 1.4.3 - opencode-windows-x64: 1.4.3 - opencode-windows-x64-baseline: 1.4.3 + opencode-darwin-arm64: 1.14.19 + opencode-darwin-x64: 1.14.19 + opencode-darwin-x64-baseline: 1.14.19 + opencode-linux-arm64: 1.14.19 + opencode-linux-arm64-musl: 1.14.19 + opencode-linux-x64: 1.14.19 + opencode-linux-x64-baseline: 1.14.19 + opencode-linux-x64-baseline-musl: 1.14.19 + opencode-linux-x64-musl: 1.14.19 + opencode-windows-arm64: 1.14.19 + opencode-windows-x64: 1.14.19 + opencode-windows-x64-baseline: 1.14.19 - opencode-darwin-arm64@1.4.3: + opencode-darwin-arm64@1.14.19: optional: true - opencode-darwin-x64-baseline@1.4.3: + opencode-darwin-x64-baseline@1.14.19: optional: true - opencode-darwin-x64@1.4.3: + opencode-darwin-x64@1.14.19: optional: true - opencode-linux-arm64-musl@1.4.3: + opencode-linux-arm64-musl@1.14.19: optional: true - opencode-linux-arm64@1.4.3: + opencode-linux-arm64@1.14.19: optional: true - opencode-linux-x64-baseline-musl@1.4.3: + opencode-linux-x64-baseline-musl@1.14.19: optional: true - opencode-linux-x64-baseline@1.4.3: + opencode-linux-x64-baseline@1.14.19: optional: true - opencode-linux-x64-musl@1.4.3: + opencode-linux-x64-musl@1.14.19: optional: true - opencode-linux-x64@1.4.3: + opencode-linux-x64@1.14.19: optional: true - opencode-windows-arm64@1.4.3: + opencode-windows-arm64@1.14.19: optional: true - opencode-windows-x64-baseline@1.4.3: + opencode-windows-x64-baseline@1.14.19: optional: true - opencode-windows-x64@1.4.3: + opencode-windows-x64@1.14.19: optional: true undici-types@7.18.2: {} diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ee3a7815f3..9f298c3900 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -145,6 +145,48 @@ pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_viewport_interest_area_threshold( + viewport_interest_area_threshold: i32, +) -> Result<()> { + with_state_mut!(state, { + let render_state = state.render_state_mut(); + render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_max_blocking_time_ms(max_blocking_time_ms: i32) -> Result<()> { + with_state_mut!(state, { + let render_state = state.render_state_mut(); + render_state.set_max_blocking_time_ms(max_blocking_time_ms); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_node_batch_threshold(node_batch_threshold: i32) -> Result<()> { + with_state_mut!(state, { + let render_state = state.render_state_mut(); + render_state.set_node_batch_threshold(node_batch_threshold); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_blur_downscale_threshold(blur_downscale_threshold: f32) -> Result<()> { + with_state_mut!(state, { + let render_state = state.render_state_mut(); + render_state.set_blur_downscale_threshold(blur_downscale_threshold); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> { @@ -401,6 +443,42 @@ pub extern "C" fn set_view_end() -> Result<()> { Ok(()) } +/// Enter interactive transform mode (drag / resize / rotate of a +/// shape). Activates the same expensive-effect skipping as pan/zoom +/// (`fast_mode`) but keeps per-frame flushing enabled so the Target is +/// presented every rAF, and triggers atlas-backed backdrops so +/// invalidated tiles do not appear sequentially or flicker. +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_modifiers_start() -> Result<()> { + with_state_mut!(state, { + performance::begin_measure!("set_modifiers_start"); + let opts = &mut state.render_state.options; + opts.set_fast_mode(true); + opts.set_interactive_transform(true); + performance::end_measure!("set_modifiers_start"); + }); + Ok(()) +} + +/// Leave interactive transform mode and cancel any pending async +/// render scheduled under it. The caller is responsible for triggering +/// a final full-quality render (typically via `_render`) once the +/// modifiers have been committed. +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_modifiers_end() -> Result<()> { + with_state_mut!(state, { + performance::begin_measure!("set_modifiers_end"); + let opts = &mut state.render_state.options; + opts.set_fast_mode(false); + opts.set_interactive_transform(false); + state.render_state.cancel_animation_frame(); + performance::end_measure!("set_modifiers_end"); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn clear_focus_mode() -> Result<()> { @@ -939,6 +1017,10 @@ pub extern "C" fn render_shape_pixels( ) -> Result<*mut u8> { let id = uuid_from_u32_quartet(a, b, c, d); + if !scale.is_finite() { + return Err(Error::CriticalError("Scale is not finite".to_string())); + } + with_state_mut!(state, { let (data, width, height) = state.render_shape_pixels(&id, scale, performance::get_time())?; diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5df0326ace..6272d8d9a3 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -37,15 +37,6 @@ use crate::wapi; pub use fonts::*; pub use images::*; -// This is the extra area used for tile rendering (tiles beyond viewport). -// Higher values pre-render more tiles, reducing empty squares during pan but using more memory. -const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; - -const MAX_BLOCKING_TIME_MS: i32 = 32; -const NODE_BATCH_THRESHOLD: i32 = 3; - -const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; - type ClipStack = Vec<(Rect, Option, Matrix)>; #[derive(Debug)] @@ -345,9 +336,8 @@ pub(crate) struct RenderState { pub cache_cleared_this_render: bool, } -pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { +pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { // First we retrieve the extended area of the viewport that we could render. - let interest = VIEWPORT_INTEREST_AREA_THRESHOLD; let TileRect(isx, isy, iex, iey) = tiles::get_tiles_for_viewbox_with_interest(viewbox, interest, scale); @@ -382,10 +372,11 @@ impl RenderState { let viewbox = Viewbox::new(width as f32, height as f32); let tiles = tiles::TileHashMap::new(); + let options = RenderOptions::default(); Ok(RenderState { gpu_state: gpu_state.clone(), - options: RenderOptions::default(), + options, surfaces, fonts, viewbox, @@ -402,7 +393,7 @@ impl RenderState { tiles, tile_viewbox: tiles::TileViewbox::new_with_interest( viewbox, - VIEWPORT_INTEREST_AREA_THRESHOLD, + options.viewport_interest_area_threshold, 1.0, ), pending_tiles: PendingTiles::new_empty(), @@ -631,6 +622,22 @@ impl RenderState { self.options.set_antialias_threshold(value); } + pub fn set_viewport_interest_area_threshold(&mut self, value: i32) { + self.options.set_viewport_interest_area_threshold(value); + } + + pub fn set_node_batch_threshold(&mut self, value: i32) { + self.options.set_node_batch_threshold(value); + } + + pub fn set_max_blocking_time_ms(&mut self, value: i32) { + self.options.set_max_blocking_time_ms(value); + } + + pub fn set_blur_downscale_threshold(&mut self, value: f32) { + self.options.set_blur_downscale_threshold(value); + } + pub fn set_background_color(&mut self, color: skia::Color) { self.background_color = color; } @@ -1495,7 +1502,7 @@ impl RenderState { // Scale and translate the target according to the cached data let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom; - let interest = VIEWPORT_INTEREST_AREA_THRESHOLD; + let interest = self.options.viewport_interest_area_threshold; let TileRect(start_tile_x, start_tile_y, _, _) = tiles::get_tiles_for_viewbox_with_interest( self.cached_viewbox, @@ -1666,6 +1673,24 @@ impl RenderState { self.cache_cleared_this_render = false; self.reset_canvas(); + + // During an interactive shape transform (drag/resize/rotate) the + // Target is repainted tile-by-tile. If only a subset of the + // invalidated tiles finishes in this rAF the remaining area + // would either show stale content from the previous frame or, + // on buffer swaps, show blank pixels — either way the user + // perceives tiles appearing sequentially. Paint the persistent + // 1:1 atlas as a stable backdrop so every flush presents a + // coherent picture: unchanged tiles come from the atlas and + // invalidated tiles are overwritten on top as they finish. + if self.options.is_interactive_transform() && self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + self.background_color, + ); + } + let surface_ids = SurfaceId::Strokes as u32 | SurfaceId::Fills as u32 | SurfaceId::InnerShadows as u32 @@ -1674,15 +1699,25 @@ impl RenderState { s.canvas().scale((scale, scale)); }); - let viewbox_cache_size = get_cache_size(self.viewbox, scale); - let cached_viewbox_cache_size = get_cache_size(self.cached_viewbox, scale); + let viewbox_cache_size = get_cache_size( + self.viewbox, + scale, + self.options.viewport_interest_area_threshold, + ); + let cached_viewbox_cache_size = get_cache_size( + self.cached_viewbox, + scale, + self.options.viewport_interest_area_threshold, + ); // Only resize cache if the new size is larger than the cached size // This avoids unnecessary surface recreations when the cache size decreases if viewbox_cache_size.width > cached_viewbox_cache_size.width || viewbox_cache_size.height > cached_viewbox_cache_size.height { - self.surfaces - .resize_cache(viewbox_cache_size, VIEWPORT_INTEREST_AREA_THRESHOLD)?; + self.surfaces.resize_cache( + viewbox_cache_size, + self.options.viewport_interest_area_threshold, + )?; } // FIXME - review debug @@ -1744,12 +1779,16 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, true)?; } - // In fast mode (pan/zoom in progress), render_from_cache owns - // the Target surface — skip flush so we don't present stale - // tile positions. The rAF still populates the Cache surface - // and tile HashMap so render_from_cache progressively shows - // more complete content. - if !self.options.is_fast_mode() { + // In a pure viewport interaction (pan/zoom), render_from_cache + // owns the Target surface — skip flush so we don't present + // stale tile positions. The rAF still populates the Cache + // surface and tile HashMap so render_from_cache progressively + // shows more complete content. + // + // During interactive shape transforms (drag/resize/rotate) we + // still need to flush every rAF so the user sees the updated + // shape position — render_from_cache is not in the loop here. + if !self.options.is_viewport_interaction() { self.flush_and_submit(); } @@ -1887,8 +1926,26 @@ impl RenderState { #[inline] pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { - iteration % NODE_BATCH_THRESHOLD == 0 - && performance::get_time() - timestamp > MAX_BLOCKING_TIME_MS + if iteration % self.options.node_batch_threshold != 0 { + return false; + } + if performance::get_time() - timestamp <= self.options.max_blocking_time_ms { + return false; + } + + // During interactive shape transforms we must complete every + // visible tile in a single rAF so the user never sees tiles + // popping in sequentially. Only yield once all visible work is + // done and we are processing the interest-area pre-render. + if self.options.is_interactive_transform() { + if let Some(tile) = self.current_tile { + if self.tile_viewbox.is_visible(&tile) { + return false; + } + } + } + + true } #[inline] @@ -2263,9 +2320,10 @@ impl RenderState { // Bounds above were computed from the original sigma so filter surface coverage is correct. // Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8x): beyond that the // filter surface becomes too small and quality degrades noticeably. - const MIN_BLUR_DOWNSCALE: f32 = 1.0 / BLUR_DOWNSCALE_THRESHOLD; - let blur_downscale = if shadow.blur > BLUR_DOWNSCALE_THRESHOLD { - (BLUR_DOWNSCALE_THRESHOLD / shadow.blur).max(MIN_BLUR_DOWNSCALE) + let blur_downscale_threshold: f32 = self.options.blur_downscale_threshold; + let min_blur_downscale: f32 = 1.0 / blur_downscale_threshold; + let blur_downscale = if shadow.blur > blur_downscale_threshold { + (blur_downscale_threshold / shadow.blur).max(min_blur_downscale) } else { 1.0 }; diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 51bf9dbbe0..e1c66b2a51 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -2,7 +2,7 @@ use crate::math::Rect as MathRect; use crate::shapes::ImageFill; use crate::uuid::Uuid; -use crate::error::{Error, Result}; +use crate::error::Result; use skia_safe::gpu::{surfaces, Budgeted, DirectContext}; use skia_safe::{self as skia, Codec, ISize}; use std::collections::HashMap; @@ -159,7 +159,7 @@ impl ImageStore { let key = (id, is_thumbnail); if self.images.contains_key(&key) { - return Err(Error::RecoverableError("Image already exists".to_string())); + return Ok(()); } let raw_data = image_data.to_vec(); @@ -186,7 +186,7 @@ impl ImageStore { let key = (id, is_thumbnail); if self.images.contains_key(&key) { - return Err(Error::RecoverableError("Image already exists".to_string())); + return Ok(()); } // Create a Skia image from the existing GL texture diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 27454ec90f..40a3125ccd 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -4,13 +4,30 @@ const PROFILE_REBUILD_TILES: u32 = 0x02; const TEXT_EDITOR_V3: u32 = 0x04; const SHOW_WASM_INFO: u32 = 0x08; +// Render performance options +// This is the extra area used for tile rendering (tiles beyond viewport). +// Higher values pre-render more tiles, reducing empty squares during pan but using more memory. +const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; +const MAX_BLOCKING_TIME_MS: i32 = 32; +const NODE_BATCH_THRESHOLD: i32 = 3; +const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; +const ANTIALIAS_THRESHOLD: f32 = 7.0; #[derive(Debug, Copy, Clone, PartialEq)] pub struct RenderOptions { pub flags: u32, pub dpr: Option, fast_mode: bool, + /// Active while the user is interacting with a shape (drag, resize, + /// rotate). Implies `fast_mode` semantics for expensive effects but + /// keeps per-frame flushing enabled (unlike pan/zoom, where + /// `render_from_cache` drives target presentation). + interactive_transform: bool, /// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled. pub antialias_threshold: f32, + pub viewport_interest_area_threshold: i32, + pub max_blocking_time_ms: i32, + pub node_batch_threshold: i32, + pub blur_downscale_threshold: f32, } impl Default for RenderOptions { @@ -19,7 +36,12 @@ impl Default for RenderOptions { flags: 0, dpr: None, fast_mode: false, - antialias_threshold: 7.0, + interactive_transform: false, + antialias_threshold: ANTIALIAS_THRESHOLD, + viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, + max_blocking_time_ms: MAX_BLOCKING_TIME_MS, + node_batch_threshold: NODE_BATCH_THRESHOLD, + blur_downscale_threshold: BLUR_DOWNSCALE_THRESHOLD, } } } @@ -42,6 +64,26 @@ impl RenderOptions { self.fast_mode = enabled; } + /// Interactive transform is ON while the user is dragging, resizing + /// or rotating a shape. Callers use it to keep per-frame flushing + /// enabled and to render visible tiles in a single frame so tiles + /// never appear sequentially or flicker during the gesture. + pub fn is_interactive_transform(&self) -> bool { + self.interactive_transform + } + + pub fn set_interactive_transform(&mut self, enabled: bool) { + self.interactive_transform = enabled; + } + + /// True only when the viewport is the one being moved (pan/zoom) + /// and the dedicated `render_from_cache` path owns Target + /// presentation. In this mode `process_animation_frame` must not + /// flush to avoid presenting stale tile positions. + pub fn is_viewport_interaction(&self) -> bool { + self.fast_mode && !self.interactive_transform + } + pub fn dpr(&self) -> f32 { self.dpr.unwrap_or(1.0) } @@ -59,4 +101,28 @@ impl RenderOptions { self.antialias_threshold = value; } } + + pub fn set_blur_downscale_threshold(&mut self, value: f32) { + if value.is_finite() && value > 0.0 { + self.blur_downscale_threshold = value; + } + } + + pub fn set_viewport_interest_area_threshold(&mut self, value: i32) { + if value >= 0 { + self.viewport_interest_area_threshold = value; + } + } + + pub fn set_node_batch_threshold(&mut self, value: i32) { + if value > 0 { + self.node_batch_threshold = value; + } + } + + pub fn set_max_blocking_time_ms(&mut self, value: i32) { + if value > 0 { + self.max_blocking_time_ms = value; + } + } } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index e3a9512e08..ca7f2a3ef2 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -938,36 +938,49 @@ impl Surfaces { if max_w > self.extra_tile_dims.width || max_h > self.extra_tile_dims.height { self.extra_tile_dims = skia::ISize::new(max_w, max_h); - self.drop_shadows = self + + if let Some(surface) = self .drop_shadows .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); - self.inner_shadows = self + { + self.drop_shadows = surface; + } + + if let Some(surface) = self .inner_shadows .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); - self.text_drop_shadows = self + { + self.inner_shadows = surface; + } + + if let Some(surface) = self .text_drop_shadows .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); - self.text_drop_shadows = self - .text_drop_shadows - .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); - self.shape_strokes = self + { + self.text_drop_shadows = surface; + } + + if let Some(surface) = self .shape_strokes .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); - self.shape_fills = self + { + self.shape_strokes = surface; + } + + if let Some(surface) = self .shape_strokes .new_surface_with_dimensions((max_w, max_h)) - .unwrap(); + { + self.shape_fills = surface; + } } - self.export = self + if let Some(surface) = self .export .new_surface_with_dimensions((target_w, target_h)) - .unwrap(); + { + self.export = surface; + } } } diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index a9acc7bd57..9478e8a0eb 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -1,9 +1,17 @@ +use crate::render::options::RenderOptions; use crate::shapes::{Shape, TextContent, Type, VerticalAlign}; use crate::state::{TextEditorState, TextSelection}; +use crate::view::Viewbox; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; use skia_safe::{BlendMode, Canvas, Paint, Rect}; -pub fn render_overlay(canvas: &Canvas, editor_state: &TextEditorState, shape: &Shape) { +pub fn render_overlay( + canvas: &Canvas, + viewbox: &Viewbox, + options: &RenderOptions, + editor_state: &TextEditorState, + shape: &Shape, +) { if !editor_state.has_focus { return; } @@ -13,17 +21,24 @@ pub fn render_overlay(canvas: &Canvas, editor_state: &TextEditorState, shape: &S }; canvas.save(); + let zoom = viewbox.zoom * options.dpr(); + canvas.scale((zoom, zoom)); + canvas.translate((-viewbox.area.left, -viewbox.area.top)); + if editor_state.selection.is_selection() { render_selection(canvas, editor_state, text_content, shape); } + if editor_state.cursor_visible { - render_cursor(canvas, editor_state, text_content, shape); + render_cursor(canvas, zoom, editor_state, text_content, shape); } + canvas.restore(); } fn render_cursor( canvas: &Canvas, + zoom: f32, editor_state: &TextEditorState, text_content: &TextContent, shape: &Shape, @@ -32,6 +47,9 @@ fn render_cursor( return; }; + let mut cursor_rect = Rect::new_empty(); + cursor_rect.set_xywh(rect.x(), rect.y(), 1.5 / zoom, rect.height()); + let mut paint = Paint::default(); paint.set_color(editor_state.theme.cursor_color); paint.set_anti_alias(true); @@ -39,7 +57,7 @@ fn render_cursor( let shape_matrix = shape.get_matrix(); canvas.save(); canvas.concat(&shape_matrix); - canvas.draw_rect(rect, &paint); + canvas.draw_rect(cursor_rect, &paint); canvas.restore(); } @@ -160,7 +178,7 @@ fn calculate_cursor_rect( return Some(Rect::from_xywh( cursor_x, y_offset + cursor_y, - editor_state.theme.cursor_width, + 1.0, // cursor_width cursor_height, )); } diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 0f89a25d41..6bba86b3a3 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -100,7 +100,6 @@ pub enum TextEditorEvent { /// FIXME: It should be better to get these constants from the frontend through the API. const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184); -const CURSOR_WIDTH: f32 = 1.5; const CURSOR_COLOR: Color = Color::BLACK; const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0; @@ -257,7 +256,6 @@ impl TextEditorStyles { pub struct TextEditorTheme { pub selection_color: Color, - pub cursor_width: f32, pub cursor_color: Color, } @@ -340,7 +338,6 @@ impl TextEditorState { Self { theme: TextEditorTheme { selection_color: SELECTION_COLOR, - cursor_width: CURSOR_WIDTH, cursor_color: CURSOR_COLOR, }, selection: TextSelection::new(), diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index f34aa10cf2..88726de91e 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -314,9 +314,9 @@ pub extern "C" fn set_shape_grow_type(grow_type: u8) { with_current_shape_mut!(state, |shape: &mut Shape| { if let Type::Text(text_content) = &mut shape.shape_type { text_content.set_grow_type(GrowType::from(grow_type)); - } else { - panic!("Trying to update grow type in a shape that it's not a text shape"); } + // Don't throw error if the object is not text. + // On swap component opperations is convenient. }); } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 21c8566ec1..9db9910ee2 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -32,16 +32,11 @@ pub enum CursorDirection { // ============================================================================ #[no_mangle] -pub extern "C" fn text_editor_apply_theme( - selection_color: u32, - cursor_width: f32, - cursor_color: u32, -) { +pub extern "C" fn text_editor_apply_theme(selection_color: u32, cursor_color: u32) { with_state_mut!(state, { // NOTE: In the future could be interesting to fill al this data from // a structure pointer. state.text_editor_state.theme.selection_color = Color::new(selection_color); - state.text_editor_state.theme.cursor_width = cursor_width; state.text_editor_state.theme.cursor_color = Color::new(cursor_color); }) } @@ -912,13 +907,14 @@ pub extern "C" fn text_editor_render_overlay() { }; let canvas = state.render_state.surfaces.canvas(SurfaceId::Target); - canvas.save(); let viewbox = state.render_state.viewbox; - let zoom = viewbox.zoom * state.render_state.options.dpr(); - canvas.scale((zoom, zoom)); - canvas.translate((-viewbox.area.left, -viewbox.area.top)); - text_editor_render::render_overlay(canvas, &state.text_editor_state, shape); - canvas.restore(); + text_editor_render::render_overlay( + canvas, + &viewbox, + &state.render_state.options, + &state.text_editor_state, + shape, + ); state.render_state.flush_and_submit(); }); } @@ -1103,12 +1099,11 @@ fn get_cursor_rect( (pos.position as f32, height) }; - let cursor_width = 2.0; let selrect = shape.selrect(); let base_x = selrect.x(); let base_y = selrect.y() + y_offset; - return Some(Rect::from_xywh(base_x + x, base_y, cursor_width, height)); + return Some(Rect::from_xywh(base_x + x, base_y, 1.0, height)); } y_offset += laid_out_para.height(); }