Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh
2026-03-17 18:29:08 +01:00
42 changed files with 427 additions and 214 deletions

View File

@@ -6,8 +6,11 @@
(ns app.config
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.flags :as flags]
[app.common.logging :as log]
[app.common.time :as ct]
[app.common.uri :as u]
[app.common.version :as v]
[app.util.avatars :as avatars]
@@ -15,6 +18,8 @@
[app.util.globals :refer [global location]]
[app.util.navigator :as nav]
[app.util.object :as obj]
[app.util.storage :as sto]
[app.util.timers :as ts]
[cuerdas.core :as str]))
(set! *assert* js/goog.DEBUG)
@@ -43,7 +48,7 @@
^boolean (check-safari-16?) :safari-16
^boolean (check-safari-17?) :safari-17
^boolean (check-safari?) :safari
:else :other)))
:else :unknown)))
(defn- parse-platform
[]
@@ -81,6 +86,16 @@
"unknown"
date)))
;; --- Compile-time version tag
;;
;; This value is baked into the compiled JS at build time via closure-defines,
;; so it travels with the JS bundle. In contrast, `version-tag` (below) is read
;; at runtime from globalThis.penpotVersionTag which is set by the always-fresh
;; index.html. Comparing the two lets us detect when the browser has loaded
;; stale cached JS files.
(goog-define compiled-version-tag "develop")
;; --- Global Config Vars
(def default-theme "default")
@@ -90,12 +105,50 @@
(def build-date (parse-build-date global))
(def flags (parse-flags global))
(def version (parse-version global))
(def target (parse-target global))
(def browser (parse-browser))
(def platform (parse-platform))
(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
the browser has cached JS from a previous deployment."
^boolean
[]
(not= compiled-version-tag version-tag))
;; --- Throttled reload
;;
;; A generic reload mechanism with loop protection via sessionStorage.
;; Used by both the boot-time stale-build check and the runtime
;; stale-asset error handler.
(def ^:private reload-storage-key "penpot-last-reload-timestamp")
(def ^:private reload-cooldown-ms 30000)
(defn throttled-reload
"Force a hard page reload unless one was already triggered within the
last 30 seconds (tracked in sessionStorage). Returns true when a
reload is initiated, false when suppressed."
[& {:keys [reason]}]
(let [now (ct/now)
prev-ts (-> (sto/get-item sto/session-storage reload-storage-key)
(d/parse-integer))]
(if (and (some? prev-ts)
(< (- now prev-ts) reload-cooldown-ms))
(do
(log/wrn :hint "reload suppressed (cooldown active)"
:reason reason)
false)
(do
(log/wrn :hint "forcing page reload" :reason reason)
(sto/set-item sto/session-storage reload-storage-key (str now))
(ts/asap #(.reload ^js location true))
true))))
(def terms-of-service-uri (obj/get global "penpotTermsOfServiceURI"))
(def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI"))
(def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/"))

View File

@@ -100,16 +100,24 @@
(defn ^:export init
[options]
(some-> (unchecked-get options "defaultTranslations")
(i18n/set-default-translations))
;; Before initializing anything, check if the browser has loaded
;; stale JS from a previous deployment. If so, do a hard reload so
;; the browser fetches fresh assets matching the current index.html.
(if (cf/stale-build?)
(cf/throttled-reload
:reason (dm/str "stale JS: compiled=" cf/compiled-version-tag
" expected=" cf/version-tag))
(do
(some-> (unchecked-get options "defaultTranslations")
(i18n/set-default-translations))
(mw/init!)
(i18n/init)
(cur/init-styles)
(mw/init!)
(i18n/init)
(cur/init-styles)
(init-ui)
(st/emit! (plugins/initialize)
(initialize)))
(init-ui)
(st/emit! (plugins/initialize)
(initialize)))))
(defn ^:export reinit
([]

View File

@@ -362,7 +362,7 @@
(ptk/reify ::toggle-project-pin
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:projects id :is-pinned] (not is-pinned)))
(d/update-in-when state [:projects id] assoc :is-pinned (not is-pinned)))
ptk/WatchEvent
(watch [_ state _]
@@ -379,7 +379,7 @@
ptk/UpdateEvent
(update [_ state]
(-> state
(update-in [:projects id :name] (constantly name))
(d/update-in-when [:projects id] assoc :name name)
(update :dashboard-local dissoc :project-for-edit)))
ptk/WatchEvent
@@ -409,7 +409,7 @@
(ptk/reify ::file-deleted
ptk/UpdateEvent
(update [_ state]
(update-in state [:projects project-id :count] dec))))
(d/update-in-when state [:projects project-id :count] dec))))
(defn delete-file
[{:keys [id project-id] :as params}]
@@ -514,7 +514,7 @@
(-> state
(assoc-in [:files id] file)
(assoc-in [:recent-files id] file)
(update-in [:projects project-id :count] inc))))))
(d/update-in-when [:projects project-id :count] inc))))))
(defn create-file
[{:keys [project-id name] :as params}]

View File

@@ -373,7 +373,15 @@
(when (contains? cf/flags :mcp)
(->> mbc/stream
(rx/filter (mbc/type? :mcp-enabled-change))
(rx/filter (mbc/type? :mcp-enabled-change-connection))
(rx/map deref)
(rx/mapcat (fn [value]
(rx/of (mcp/update-mcp-connection value)
(mcp/disconnect-mcp))))))
(when (contains? cf/flags :mcp)
(->> mbc/stream
(rx/filter (mbc/type? :mcp-enabled-change-status))
(rx/map deref)
(rx/map mcp/update-mcp-status)))

View File

@@ -9,10 +9,14 @@
[app.common.logging :as log]
[app.common.uri :as u]
[app.config :as cf]
[app.main.broadcast :as mbc]
[app.main.data.event :as ev]
[app.main.data.notifications :as ntf]
[app.main.data.plugins :as dp]
[app.main.repo :as rp]
[app.main.store :as st]
[app.plugins.register :refer [mcp-plugin-id]]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@@ -34,6 +38,37 @@
[event]
(= (ptk/type event) :app.main.data.workspace/finalize-workspace))
(defn disconnect-mcp
[]
(st/emit! (ptk/data-event ::disconnect)))
(defn connect-mcp
[]
(ptk/reify ::connect-mcp
ptk/WatchEvent
(watch [_ _ stream]
(mbc/emit! :mcp-enabled-change-connection false)
(->> stream
(rx/filter (ptk/type? ::disconnect))
(rx/take 1)
(rx/map #(ptk/data-event ::connect))))))
(defn manage-notification
[mcp-enabled? mcp-connected?]
(if mcp-enabled?
(if mcp-connected?
(rx/of (ntf/hide))
(rx/of (ntf/dialog :content (tr "notifications.mcp.active-tab-switching.text")
:cancel {:label (tr "labels.dismiss")
:callback #(st/emit! (ntf/hide)
(ptk/event ::ev/event {::ev/name "confirm-mcp-tab-switch"
::ev/origin "workspace-notification"}))}
:accept {:label (tr "labels.switch")
:callback #(st/emit! (connect-mcp)
(ptk/event ::ev/event {::ev/name "dismiss-mcp-tab-switch"
::ev/origin "workspace-notification"}))})))
(rx/of (ntf/hide))))
(defn update-mcp-status
[value]
(ptk/reify ::update-mcp-status
@@ -42,18 +77,26 @@
(update-in state [:profile :props] assoc :mcp-enabled value))
ptk/WatchEvent
(watch [_ _ _]
(case value
true (rx/of (ptk/data-event ::connect))
false (rx/of (ptk/data-event ::disconnect))
nil))))
(watch [_ state _]
(rx/merge
(let [mcp-connected? (-> state :workspace-local :mcp :connected)]
(manage-notification value mcp-connected?))
(case value
true (rx/of (ptk/data-event ::connect))
false (rx/of (ptk/data-event ::disconnect))
nil)))))
(defn update-mcp-connection
[value]
(ptk/reify ::update-mcp-plugin-connection
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :mcp] assoc :connected value))))
(update-in state [:workspace-local :mcp] assoc :connected value))
ptk/WatchEvent
(watch [_ state _]
(let [mcp-enabled? (-> state :profile :props :mcp-enabled)]
(manage-notification mcp-enabled? value)))))
(defn init-mcp!
[stream]
@@ -94,14 +137,6 @@
(rx/take-until stopper)
(rx/subs! #(cb))))))}}))))))
(defn disconnect-mcp
[]
(st/emit! (ptk/data-event ::disconnect)))
(defn connect-mcp
[]
(st/emit! (ptk/data-event ::connect)))
(defn init-mcp-connection
[]
(ptk/reify ::init-mcp-connection

View File

@@ -68,7 +68,7 @@
(let [content (st/get-path state :content)
content (if (and (not preserve-move-to)
(= (-> content last :command) :move-to))
(into [] (take (dec (count content)) content))
(path/content (take (dec (count content)) content))
content)]
(st/set-content state content)))

View File

@@ -8,7 +8,7 @@
(:require
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.types.path.segment :as path.segm]
[app.common.types.path :as path]
[app.main.data.workspace.path.state :as pst]
[app.main.snap :as snap]
[app.main.store :as st]
@@ -167,7 +167,7 @@
ranges-stream
(->> content-stream
(rx/filter some?)
(rx/map path.segm/get-points)
(rx/map path/get-points)
(rx/map snap/create-ranges))]
(->> ms/mouse-position

View File

@@ -33,6 +33,27 @@
;; Will contain last uncaught exception
(def last-exception nil)
;; --- Stale-asset error detection and auto-reload
;;
;; When the browser loads JS modules from different builds (e.g. shared.js from
;; build A and main-dashboard.js from build B because you loaded it in the
;; middle of a deploy per example), keyword constants referenced across modules
;; will be undefined. This manifests as TypeError messages containing
;; "$cljs$cst$" and "is undefined" or "is null".
(defn stale-asset-error?
"Returns true if the error matches the signature of a cross-build
module mismatch: accessing a ClojureScript keyword constant that
doesn't exist on the shared $APP object."
[cause]
(when (some? cause)
(let [message (ex-message cause)]
(and (string? message)
(str/includes? message "$cljs$cst$")
(or (str/includes? message "is undefined")
(str/includes? message "is null")
(str/includes? message "is not a function"))))))
(defn exception->error-data
[cause]
(let [data (ex-data cause)]
@@ -127,24 +148,15 @@
(ex/print-throwable cause :prefix "Unexpected Error")
(flash :cause cause :type :unhandled))))
(defmethod ptk/handle-error :wasm-non-blocking
(defmethod ptk/handle-error :wasm-error
[error]
(when-let [cause (::instance error)]
(flash :cause cause)))
(defmethod ptk/handle-error :wasm-critical
[error]
(when-let [cause (::instance error)]
(ex/print-throwable cause :prefix "WASM critical error"))
(st/emit! (rt/assign-exception error)))
(defmethod ptk/handle-error :wasm-exception
[error]
(when-let [cause (::instance error)]
(let [prefix (or (:prefix error) "Exception")]
(ex/print-throwable cause :prefix prefix)))
(st/emit! (rt/assign-exception error)))
(ex/print-throwable cause)
(let [code (get error :code)]
(if (or (= code :panic)
(= code :webgl-context-lost))
(st/emit! (rt/assign-exception error))
(flash :type :handled :cause cause)))))
;; We receive a explicit authentication error; If the uri is for
;; workspace, dashboard, viewer or settings, then assign the exception
@@ -373,22 +385,31 @@
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "error")]
(when-not (is-ignorable-exception? cause)
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (#{:wasm-critical :wasm-non-blocking :wasm-exception} type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/schedule #(flash :cause cause :type :unhandled))))))))
(if (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/asap #(flash :cause cause :type :unhandled)))))))))
(on-unhandled-rejection [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "reason")]
(when-not (is-ignorable-exception? cause)
(set! last-exception cause)
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/schedule #(flash :cause cause :type :unhandled)))))]
(if (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/asap #(flash :cause cause :type :unhandled)))))))))]
(.addEventListener g/window "error" on-unhandled-error)
(.addEventListener g/window "unhandledrejection" on-unhandled-rejection)

View File

@@ -9,18 +9,17 @@
(:require
["react-error-boundary" :as reb]
[app.common.exceptions :as ex]
[app.config :as cf]
[app.main.errors :as errors]
[app.main.refs :as refs]
[goog.functions :as gfn]
[rumext.v2 :as mf]))
(mf/defc error-boundary*
{::mf/props :obj}
[{:keys [fallback children]}]
(let [fallback-wrapper
(mf/with-memo [fallback]
(mf/fnc fallback-wrapper*
{::mf/props :obj}
[{:keys [error reset-error-boundary]}]
(let [route (mf/deref refs/route)
data (errors/exception->error-data error)]
@@ -35,13 +34,19 @@
;; very small amount of time, so we debounce for 100ms for
;; avoid duplicate and redundant reports
(gfn/debounce (fn [error info]
(set! errors/last-exception error)
(ex/print-throwable error)
(js/console.error
"Component trace: \n"
(unchecked-get info "componentStack")
"\n"
error))
;; If the error is a stale-asset error (cross-build
;; module mismatch), force a hard page reload instead
;; of showing the error page to the user.
(if (errors/stale-asset-error? error)
(cf/throttled-reload :reason (ex-message error))
(do
(set! errors/last-exception error)
(ex/print-throwable error)
(js/console.error
"Component trace: \n"
(unchecked-get info "componentStack")
"\n"
error))))
100))]
[:> reb/ErrorBoundary

View File

@@ -214,10 +214,11 @@
(mf/use-effect
deps
(fn []
(let [sub (->> stream (rx/subs! on-subscribe))]
#(do
(rx/dispose! sub)
(when on-dispose (on-dispose))))))))
(when stream
(let [sub (->> stream (rx/subs! on-subscribe))]
#(do
(rx/dispose! sub)
(when on-dispose (on-dispose)))))))))
;; https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state
;; FIXME: replace with rumext

View File

@@ -279,7 +279,7 @@
(ev/event {::ev/name "enable-mcp"
::ev/origin "integrations"
:source "key-creation"}))
(mbc/emit! :mcp-enabled-change true)
(mbc/emit! :mcp-enabled-change-status true)
(reset! created? true)))]
[:div {:class (stl/css :modal-overlay)}
@@ -320,7 +320,7 @@
(du/update-profile-props {:mcp-enabled true})
(ev/event {::ev/name "regenerate-mcp-key"
::ev/origin "integrations"}))
(mbc/emit! :mcp-enabled-change true)
(mbc/emit! :mcp-enabled-change-status true)
(reset! created? true)))]
[:div {:class (stl/css :modal-overlay)}
@@ -431,7 +431,7 @@
(ev/event {::ev/name (if (true? value) "enable-mcp" "disable-mcp")
::ev/origin "integrations"
:source "toggle"}))
(mbc/emit! :mcp-enabled-change value)))
(mbc/emit! :mcp-enabled-change-status value)))
handle-generate-mcp-key
(mf/use-fn
@@ -449,7 +449,7 @@
mdata {:on-success #(st/emit! (du/fetch-access-tokens))}]
(st/emit! (du/delete-access-token (with-meta params mdata))
(du/update-profile-props {:mcp-enabled false}))
(mbc/emit! :mcp-enabled-change false))))
(mbc/emit! :mcp-enabled-change-status false))))
on-copy-to-clipboard
(mf/use-fn

View File

@@ -477,8 +477,8 @@
:service-unavailable
[:> service-unavailable*]
:wasm-exception
(case (get data :exception-type)
:wasm-error
(case (get data :code)
:webgl-context-lost
[:> webgl-context-lost*]

View File

@@ -57,6 +57,7 @@
[app.render-wasm.api :as wasm.api]
[app.util.debug :as dbg]
[app.util.text-editor :as ted]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[promesa.core :as p]
[rumext.v2 :as mf]))
@@ -361,9 +362,9 @@
(mf/with-effect [focus]
(when (and @canvas-init? @initialized?)
(if (empty? focus)
(wasm.api/clear-focus-mode)
(wasm.api/set-focus-mode focus))))
(ts/asap #(if (empty? focus)
(wasm.api/clear-focus-mode)
(wasm.api/set-focus-mode focus)))))
(mf/with-effect [vbox zoom]
(when (and @canvas-init? initialized?)

View File

@@ -53,6 +53,13 @@
[rumext.v2 :as mf]))
(def use-dpr? (contains? cf/flags :render-wasm-dpr))
(defn text-editor-wasm?
[]
(let [runtime-features (get @st/state :features-runtime)
enabled-features (get @st/state :features)]
(or (contains? runtime-features "text-editor-wasm/v1")
(contains? enabled-features "text-editor-wasm/v1"))))
(def ^:const UUID-U8-SIZE 16)
(def ^:const UUID-U32-SIZE (/ UUID-U8-SIZE 4))
@@ -149,11 +156,8 @@
;; Determine if text-editor-wasm feature is active without requiring
;; app.main.features to avoid circular dependency: check runtime and
;; persisted feature sets in the store state.
(let [runtime-features (get @st/state :features-runtime)
enabled-features (get @st/state :features)]
(when (or (contains? runtime-features "text-editor-wasm/v1")
(contains? enabled-features "text-editor-wasm/v1"))
(text-editor/text-editor-render-overlay)))
(when (text-editor-wasm?)
(text-editor/text-editor-render-overlay))
;; Poll for editor events; if any event occurs, trigger a re-render
(let [ev (text-editor/text-editor-poll-event)]
(when (and ev (not= ev 0))
@@ -1395,7 +1399,9 @@
[]
(cond-> 0
(dbg/enabled? :wasm-viewbox)
(bit-or 2r00000000000000000000000000000001)))
(bit-or 2r00000000000000000000000000000001)
(text-editor-wasm?)
(bit-or 2r00000000000000000000000000001000)))
(defn set-canvas-size
[canvas]
@@ -1404,27 +1410,13 @@
(set! (.-width canvas) (* dpr width))
(set! (.-height canvas) (* dpr height))))
(defn- get-browser
[]
(when (exists? js/navigator)
(let [user-agent (.-userAgent js/navigator)]
(when user-agent
(cond
(re-find #"(?i)firefox" user-agent) :firefox
(re-find #"(?i)chrome" user-agent) :chrome
(re-find #"(?i)safari" user-agent) :safari
(re-find #"(?i)edge" user-agent) :edge
:else :unknown)))))
(defn- on-webgl-context-lost
[event]
(dom/prevent-default event)
(reset! wasm/context-lost? true)
(log/warn :hint "WebGL context lost")
(ex/raise :type :wasm-exception
:exception-type :webgl-context-lost
:prefix "WebGL context lost"
:hint "WebGL context lost"))
(ex/raise :type :wasm-error
:code :webgl-context-lost
:hint "WASM Error: WebGL context lost"))
(defn init-canvas-context
[canvas]
@@ -1433,8 +1425,7 @@
context-id (if (dbg/enabled? :wasm-gl-context-init-error) "fail" "webgl2")
context (.getContext ^js canvas context-id default-context-options)
context-init? (not (nil? context))
browser (get-browser)
browser (sr/translate-browser browser)]
browser (sr/translate-browser cf/browser)]
(when-not (nil? context)
(let [handle (.registerContext ^js gl context #js {"majorVersion" 2})]
(.makeContextCurrent ^js gl handle)

View File

@@ -5,11 +5,13 @@
;; Copyright (c) KALEIDOS INC
(ns app.render-wasm.helpers
#?(:cljs (:require-macros [app.render-wasm.helpers])))
#?(:cljs (:require-macros [app.render-wasm.helpers]))
(:require [app.common.data :as d]))
(def ^:export error-code
(def error-code
"WASM error code constants (must match render-wasm/src/error.rs and mem.rs)."
{0x01 :wasm-non-blocking 0x02 :wasm-critical})
{0x01 :non-blocking
0x02 :panic})
(defmacro call
"A helper for calling a wasm function.
@@ -18,19 +20,19 @@
- :wasm-non-blocking: call app.main.errors/on-error (eventually, shows a toast and logs the error)
- :wasm-critical or unknown: throws an exception to be handled by the global error handler (eventually, shows the internal error page)"
[module name & params]
(let [fn-sym (with-meta (gensym "fn-") {:tag 'function})
e-sym (gensym "e")
code-sym (gensym "code")]
(let [fn-sym (with-meta (gensym "fn-") {:tag 'function})
cause-sym (gensym "cause")]
`(let [~fn-sym (cljs.core/unchecked-get ~module ~name)]
(try
(~fn-sym ~@params)
(catch :default ~e-sym
(let [read-code# (cljs.core/unchecked-get ~module "_read_error_code")
~code-sym (when read-code# (read-code#))
type# (or (get app.render-wasm.helpers/error-code ~code-sym) :wasm-critical)
ex# (ex-info (str "WASM error (type: " type# ")")
{:fn ~name :type type# :message (.-message ~e-sym) :error-code ~code-sym}
~e-sym)]
(if (= type# :wasm-non-blocking)
(@~'app.main.store/on-error ex#)
(throw ex#))))))))
(catch :default ~cause-sym
(let [read-code-fn# (cljs.core/unchecked-get ~module "_read_error_code")
code-num# (when read-code-fn# (read-code-fn#))
code# (get error-code code-num# :wasm-critical)
hint# (str "WASM Error (" (d/name code#) ")")
context# {:type :wasm-error
:code code#
:hint hint#
:fn ~name}
cause# (ex-info hint# context# ~cause-sym)]
(throw cause#)))))))

View File

@@ -271,6 +271,8 @@
:firefox 0
:chrome 1
:safari 2
:safari-16 2
:safari-17 2
:edge 3
:unknown 4
4))

View File

@@ -25,6 +25,7 @@
(defonce context-initialized? false)
(defonce context-lost? (atom false))
(defonce serializers
#js {:blur-type shared/RawBlurType
:blend-mode shared/RawBlendMode

View File

@@ -17,10 +17,10 @@
;; Using ex/ignoring because can receive a DOMException like this when
;; importing the code as a library: Failed to read the 'localStorage'
;; property from 'Window': Storage is disabled inside 'data:' URLs.
(defonce ^:private local-storage-backend
(defonce local-storage
(ex/ignoring (unchecked-get g/global "localStorage")))
(defonce ^:private session-storage-backend
(defonce session-storage
(ex/ignoring (unchecked-get g/global "sessionStorage")))
(def ^:dynamic *sync*
@@ -69,6 +69,17 @@
(persistent! result))))
{}))
(defn set-item
[storage key val]
(when (and (some? storage)
(string? key))
(.setItem ^js storage key val)))
(defn get-item
[storage key]
(when (some? storage)
(.getItem storage key)))
(defn create-storage
[backend prefix]
(let [initial (load-data backend prefix)
@@ -154,10 +165,10 @@
(-remove-watch [_ key]
(.delete watches key)))))
(defonce global (create-storage local-storage-backend "penpot-global"))
(defonce user (create-storage local-storage-backend "penpot-user"))
(defonce storage (create-storage local-storage-backend "penpot"))
(defonce session (create-storage session-storage-backend "penpot"))
(defonce global (create-storage local-storage "penpot-global"))
(defonce user (create-storage local-storage "penpot-user"))
(defonce storage (create-storage local-storage "penpot"))
(defonce session (create-storage session-storage "penpot"))
(defonce before-unload
(letfn [(on-before-unload [_]

View File

@@ -7,8 +7,7 @@
(ns app.util.timers
(:require
[app.common.data :as d]
[beicon.v2.core :as rx]
[promesa.core :as p]))
[beicon.v2.core :as rx]))
(defn schedule
([func]
@@ -30,8 +29,8 @@
(defn asap
[f]
(-> (p/resolved nil)
(p/then (fn [_] (f)))))
(-> (js/Promise.resolve nil)
(.then (fn [_] (f)))))
(defn interval
[ms func]