Merge remote-tracking branch 'origin/develop' into token-studio-develop

This commit is contained in:
Andrés Moya 2024-12-12 17:16:07 +01:00
commit cd6d5491fa
456 changed files with 15629 additions and 10760 deletions

View file

@ -44,7 +44,6 @@ jobs:
- ~/.m2
key: v1-dependencies-{{ checksum "common/deps.edn"}}
test-frontend:
docker:
- image: penpotapp/devenv:latest
@ -93,7 +92,6 @@ jobs:
- ~/.m2
key: v1-dependencies-{{ checksum "frontend/deps.edn"}}
test-integration:
docker:
- image: penpotapp/devenv:latest
@ -180,7 +178,6 @@ jobs:
- ~/.m2
key: v1-dependencies-{{ checksum "backend/deps.edn" }}
test-exporter:
docker:
- image: penpotapp/devenv:latest
@ -210,6 +207,29 @@ jobs:
yarn run fmt:clj:check
yarn run lint:clj
test-render-wasm:
docker:
- image: penpotapp/devenv:latest
working_directory: ~/repo
resource_class: medium+
environment:
steps:
- checkout
- run:
name: "fmt check"
working_directory: "./render-wasm"
command: |
cargo fmt --check
- run:
name: "cargo tests"
working_directory: "./render-wasm"
command: |
./test
workflows:
penpot:
jobs:
@ -218,3 +238,4 @@ workflows:
- test-backend
- test-common
- test-exporter
- test-render-wasm

View file

@ -38,12 +38,14 @@
### :sparkles: New features
- Viewer role for team members [Taiga #1056 & #6590](https://tree.taiga.io/project/penpot/us/1056 & https://tree.taiga.io/project/penpot/us/6590)
- File history versions management [Taiga](https://tree.taiga.io/project/penpot/us/187?milestone=411120)
- Viewer role for team members [Taiga #1056](https://tree.taiga.io/project/penpot/us/1056) & [Taiga #6590](https://tree.taiga.io/project/penpot/us/6590)
- File history versions management [Taiga #187](https://tree.taiga.io/project/penpot/us/187?milestone=411120)
- Rename selected layer via keyboard shortcut and context menu option [Taiga #8882](https://tree.taiga.io/project/penpot/us/8882)
- New .penpot file format [Taiga #8657](https://tree.taiga.io/project/penpot/us/8657)
### :bug: Bugs fixed
- Fix problem with some texts desynchronization [Taiga #9379](https://tree.taiga.io/project/penpot/issue/9379)
## 2.3.3

View file

@ -42,7 +42,6 @@
:rpc-rlimit-config "resources/rlimit.edn"
:rpc-climit-config "resources/climit.edn"
:auto-file-snapshot-total 10
:auto-file-snapshot-every 5
:auto-file-snapshot-timeout "3h"
@ -101,7 +100,6 @@
[:telemetry-uri {:optional true} :string]
[:telemetry-with-taiga {:optional true} ::sm/boolean] ;; DELETE
[:auto-file-snapshot-total {:optional true} ::sm/int]
[:auto-file-snapshot-every {:optional true} ::sm/int]
[:auto-file-snapshot-timeout {:optional true} ::dt/duration]

View file

@ -226,8 +226,8 @@
[:priority {:optional true} [:enum :high :low]]
[:extra-data {:optional true} ::sm/text]])
(def ^:private valid-context?
(sm/validator schema:context))
(def ^:private check-context
(sm/check-fn schema:context))
(defn template-factory
[& {:keys [id schema]}]
@ -236,10 +236,8 @@
(sm/check-fn schema)
(constantly nil))]
(fn [context]
(assert (valid-context? context) "expected a valid context")
(check-fn context)
(let [email (build-email-template id context)]
(let [context (-> context check-context check-fn)
email (build-email-template id context)]
(when-not email
(ex/raise :type :internal
:code :email-template-does-not-exists
@ -271,7 +269,7 @@
"Schedule an already defined email to be sent using asynchronously
using worker task."
[{:keys [::conn ::factory] :as context}]
(assert (db/connection? conn) "expected a valid database connection")
(assert (db/connectable? conn) "expected a valid database connection or pool")
(let [email (if factory
(factory context)
@ -348,7 +346,7 @@
[:subject ::sm/text]
[:content ::sm/text]])
(def feedback
(def user-feedback
"A profile feedback email."
(template-factory
:id ::feedback

View file

@ -884,8 +884,10 @@
:shapes (or (:shapes shape) [])
:hide-in-viewer (if frame? (boolean (:hide-in-viewer shape)) true)
:show-content (if frame? (boolean (:show-content shape)) true)
:rx (or (:rx shape) 0)
:ry (or (:ry shape) 0)))
:r1 (or (:r1 shape) 0)
:r2 (or (:r2 shape) 0)
:r3 (or (:r3 shape) 0)
:r4 (or (:r4 shape) 0)))
shape))]
(-> file-data
(update :pages-index update-vals fix-container)

View file

@ -349,7 +349,6 @@
:file-gc (ig/ref :app.tasks.file-gc/handler)
:file-gc-scheduler (ig/ref :app.tasks.file-gc-scheduler/handler)
:offload-file-data (ig/ref :app.tasks.offload-file-data/handler)
:file-xlog-gc (ig/ref :app.tasks.file-xlog-gc/handler)
:tasks-gc (ig/ref :app.tasks.tasks-gc/handler)
:telemetry (ig/ref :app.tasks.telemetry/handler)
:storage-gc-deleted (ig/ref ::sto.gc-deleted/handler)
@ -405,10 +404,6 @@
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}
:app.tasks.file-xlog-gc/handler
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}
:app.tasks.telemetry/handler
{::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)

View file

@ -17,7 +17,7 @@
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
(declare ^:private send-feedback!)
(declare ^:private send-user-feedback!)
(def ^:private schema:send-user-feedback
[:map {:title "send-user-feedback"}
@ -34,14 +34,16 @@
:hint "feedback not enabled"))
(let [profile (profile/get-profile pool profile-id)]
(send-feedback! pool profile params)
(send-user-feedback! pool profile params)
nil))
(defn- send-feedback!
(defn- send-user-feedback!
[pool profile params]
(let [dest (cf/get :feedback-destination)]
(let [dest (or (cf/get :user-feedback-destination)
;; LEGACY
(cf/get :feedback-destination))]
(eml/send! {::eml/conn pool
::eml/factory eml/feedback
::eml/factory eml/user-feedback
:from dest
:to dest
:profile profile

View file

@ -575,7 +575,7 @@
(if-let [media-id (:media-id row)]
(-> row
(dissoc :media-id)
(assoc :thumbnail-uri (resolve-public-uri media-id)))
(assoc :thumbnail-id media-id))
(dissoc row :media-id))))
(map #(assoc % :library-summary (get-library-summary cfg %)))
(map #(dissoc % :data))))))
@ -698,11 +698,7 @@
(defn get-team-recent-files
[conn team-id]
(->> (db/exec! conn [sql:team-recent-files team-id])
(mapv (fn [row]
(if-let [media-id (:thumbnail-id row)]
(assoc row :thumbnail-uri (resolve-public-uri media-id))
(dissoc row :media-id))))))
(db/exec! conn [sql:team-recent-files team-id]))
(def ^:private schema:get-team-recent-files
[:map {:title "get-team-recent-files"}

View file

@ -28,13 +28,19 @@
[cuerdas.core :as str]))
(def sql:get-file-snapshots
"SELECT id, label, revn, created_at, created_by, profile_id
FROM file_change
WHERE file_id = ?
AND data IS NOT NULL
AND (deleted_at IS NULL OR deleted_at > now())
ORDER BY created_at DESC
LIMIT 20")
"WITH changes AS (
SELECT id, label, revn, created_at, created_by, profile_id
FROM file_change
WHERE file_id = ?
AND data IS NOT NULL
AND (deleted_at IS NULL OR deleted_at > now())
), versions AS (
(SELECT * FROM changes WHERE created_by = 'system' LIMIT 1000)
UNION ALL
(SELECT * FROM changes WHERE created_by != 'system' LIMIT 1000)
)
SELECT * FROM versions
ORDER BY created_at DESC;")
(defn get-file-snapshots
[conn file-id]

View file

@ -50,8 +50,7 @@
" where file_id=? and tag=? and deleted_at is null")
res (db/exec! conn [sql file-id tag])]
(->> res
(d/index-by :object-id (fn [row]
(files/resolve-public-uri (:media-id row))))
(d/index-by :object-id :media-id)
(d/without-nils))))
(defn- get-object-thumbnails
@ -62,8 +61,7 @@
" where file_id=? and deleted_at is null")
res (db/exec! conn [sql file-id])]
(->> res
(d/index-by :object-id (fn [row]
(files/resolve-public-uri (:media-id row))))
(d/index-by :object-id :media-id)
(d/without-nils))))
([conn file-id object-ids]
@ -75,8 +73,7 @@
res (db/exec! conn [sql file-id ids])]
(->> res
(d/index-by :object-id (fn [row]
(files/resolve-public-uri (:media-id row))))
(d/index-by :object-id :media-id)
(d/without-nils)))))
(sv/defmethod ::get-file-object-thumbnails
@ -127,8 +124,11 @@
(if-let [frame (-> frames first)]
(let [frame-id (:id frame)
object-id (thc/fmt-object-id (:id file) page-id frame-id "frame")
frame (if-let [thumb (get thumbnails object-id)]
(assoc frame :thumbnail thumb :shapes [])
frame (if-let [media-id (get thumbnails object-id)]
(-> frame
(assoc :thumbnail-id media-id)
(assoc :shapes []))
(dissoc frame :thumbnail))
children-ids

View file

@ -223,15 +223,6 @@
(let [storage (sto/resolve cfg ::db/reuse-conn true)]
(some->> (:data-ref-id file) (sto/touch-object! storage))))
(-> cfg
(assoc ::wrk/task :file-xlog-gc)
(assoc ::wrk/label (str "xlog:" (:id file)))
(assoc ::wrk/params {:file-id (:id file)})
(assoc ::wrk/delay (dt/duration "5m"))
(assoc ::wrk/dedupe true)
(assoc ::wrk/priority 1)
(wrk/submit!))
(persist-file! cfg file)
(let [params (assoc params :file file)

View file

@ -60,15 +60,25 @@
(media/validate-media-type! content)
(media/validate-media-size! content)
(db/run! cfg (fn [cfg]
(let [object (create-file-media-object cfg params)
props {:name (:name params)
:file-id file-id
:is-local (:is-local params)
:size (:size content)
:mtype (:mtype content)}]
(with-meta object
{::audit/replace-props props})))))
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
;; We get the minimal file for proper checking if
;; file is not already deleted
(let [_ (files/get-minimal-file conn file-id)
mobj (create-file-media-object cfg params)]
(db/update! conn :file
{:modified-at (dt/now)
:has-media-trimmed false}
{:id file-id}
{::db/return-keys false})
(with-meta mobj
{::audit/replace-props
{:name (:name params)
:file-id file-id
:is-local (:is-local params)
:size (:size content)
:mtype (:mtype content)}})))))
(defn- big-enough-for-thumbnail?
"Checks if the provided image info is big enough for
@ -142,20 +152,14 @@
:always
(assoc ::image (process-main-image info)))))
(defn create-file-media-object
[{:keys [::sto/storage ::db/conn ::wrk/executor]}
(defn- create-file-media-object
[{:keys [::sto/storage ::db/conn ::wrk/executor] :as cfg}
{:keys [id file-id is-local name content]}]
(let [result (px/invoke! executor (partial process-image content))
image (sto/put-object! storage (::image result))
thumb (when-let [params (::thumb result)]
(sto/put-object! storage params))]
(db/update! conn :file
{:modified-at (dt/now)
:has-media-trimmed false}
{:id file-id})
(db/exec-one! conn [sql:create-file-media-object
(or id (uuid/next))
file-id is-local name
@ -182,7 +186,18 @@
::sm/params schema:create-file-media-object-from-url}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(files/check-edition-permissions! pool profile-id file-id)
(create-file-media-object-from-url cfg (assoc params :profile-id profile-id)))
;; We get the minimal file for proper checking if file is not
;; already deleted
(let [_ (files/get-minimal-file cfg file-id)
mobj (create-file-media-object-from-url cfg (assoc params :profile-id profile-id))]
(db/update! pool :file
{:modified-at (dt/now)
:has-media-trimmed false}
{:id file-id}
{::db/return-keys false})
mobj))
(defn download-image
[{:keys [::http/client]} uri]

View file

@ -422,7 +422,9 @@
:deleted-at deleted-at
:id profile-id}})
(rph/with-transform {} (session/delete-fn cfg)))))
(-> (rph/wrap nil)
(rph/with-transform (session/delete-fn cfg))))))
;; --- HELPERS
@ -431,8 +433,11 @@
"WITH owner_teams AS (
SELECT tpr.team_id AS id
FROM team_profile_rel AS tpr
JOIN team AS t ON (t.id = tpr.team_id)
WHERE tpr.is_owner IS TRUE
AND tpr.profile_id = ?
AND (t.deleted_at IS NULL OR
t.deleted_at > now())
)
SELECT tpr.team_id AS id,
count(tpr.profile_id) - 1 AS participants

View file

@ -9,7 +9,6 @@
[app.common.schema :as sm]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.files :refer [resolve-public-uri]]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
@ -61,7 +60,7 @@
(if-let [media-id (:media-id row)]
(-> row
(dissoc :media-id)
(assoc :thumbnail-uri (resolve-public-uri media-id)))
(assoc :thumbnail-id media-id))
(dissoc row :media-id))))))
(def ^:private schema:search-files

View file

@ -77,7 +77,7 @@
:share-links links
:libraries libs
:file file
:team team
:team (assoc team :permissions perms)
:permissions perms}))
(def schema:get-view-only-bundle

View file

@ -1,64 +0,0 @@
;; 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 app.tasks.file-xlog-gc
(:require
[app.common.logging :as l]
[app.config :as cf]
[app.db :as db]
[integrant.core :as ig]))
;; Get the latest available snapshots without exceeding the total
;; snapshot limit
(def ^:private sql:get-latest-snapshots
"SELECT fch.id, fch.created_at
FROM file_change AS fch
WHERE fch.file_id = ?
AND fch.created_by = 'system'
AND fch.data IS NOT NULL
AND fch.deleted_at > now()
ORDER BY fch.created_at DESC
LIMIT ?")
;; Mark all snapshots that are outside the allowed total threshold
;; available for the GC
(def ^:private sql:delete-snapshots
"UPDATE file_change
SET deleted_at = now()
WHERE file_id = ?
AND deleted_at > now()
AND data IS NOT NULL
AND created_by = 'system'
AND created_at < ?")
(defn- get-alive-snapshots
[conn file-id]
(let [total (cf/get :auto-file-snapshot-total 10)
snapshots (db/exec! conn [sql:get-latest-snapshots file-id total])]
(not-empty snapshots)))
(defn- delete-old-snapshots!
[{:keys [::db/conn] :as cfg} file-id]
(when-let [snapshots (get-alive-snapshots conn file-id)]
(let [last-date (-> snapshots peek :created-at)
result (db/exec-one! conn [sql:delete-snapshots file-id last-date])]
(l/inf :hint "delete old file snapshots"
:file-id (str file-id)
:current (count snapshots)
:deleted (db/get-update-count result)))))
(defmethod ig/assert-key ::handler
[_ params]
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
(defmethod ig/init-key ::handler
[_ cfg]
(fn [{:keys [props] :as task}]
(let [file-id (:file-id props)]
(assert (uuid? file-id) "expected file-id on props")
(-> cfg
(assoc ::db/rollback (:rollback props false))
(db/tx-run! delete-old-snapshots! file-id)))))

View file

@ -1090,8 +1090,7 @@
(t/is (contains? result :file-id))
(t/is (= (:id file) (:file-id result)))
(t/is (str/starts-with? (get-in result [:page :objects frame1-id :thumbnail])
"http://localhost:3449/assets/by-id/"))
(t/is (uuid? (get-in result [:page :objects frame1-id :thumbnail-id])))
(t/is (= [] (get-in result [:page :objects frame1-id :shapes]))))
;; Delete thumbnail data

View file

@ -10,6 +10,7 @@
[app.db :as db]
[app.rpc :as-alias rpc]
[app.storage :as sto]
[app.util.time :as dt]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]))
@ -245,3 +246,35 @@
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? (:media-id result)))
(t/is (uuid? (:thumbnail-id result))))))
(t/deftest media-object-upload-command-when-file-is-deleted
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
_ (th/db-update! :file
{:deleted-at (dt/now)}
{:id (:id file)})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile}
out (th/command! params)]
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found)))))

View file

@ -203,7 +203,24 @@
edata (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type edata) :validation))
(t/is (= (:code edata) :owner-teams-with-people))))))
(t/is (= (:code edata) :owner-teams-with-people)))
(let [params {::th/type :delete-team
::rpc/profile-id (:id prof1)
:id (:id team1)}
out (th/command! params)]
;; (th/print-result! out)
(let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})]
(t/is (dt/instant? (:deleted-at team)))))
;; Request profile to be deleted
(let [params {::th/type :delete-profile
::rpc/profile-id (:id prof1)}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:result out)))
(t/is (nil? (:error out)))))))
(t/deftest profile-deletion-3
(let [prof1 (th/create-profile* 1)
@ -291,7 +308,7 @@
out (th/command! params)]
;; (th/print-result! out)
(t/is (= {} (:result out)))
(t/is (nil? (:result out)))
(t/is (nil? (:error out))))
;; query files after profile soft deletion
@ -336,7 +353,7 @@
::rpc/profile-id (:id prof1)}
out (th/command! params)]
;; (th/print-result! out)
(t/is (= {} (:result out)))
(t/is (nil? (:result out)))
(t/is (nil? (:error out))))
(th/run-pending-tasks!)

View file

@ -64,7 +64,7 @@
;; (def shapes [{:stroke-color "#ff0000"
;; :stroke-width 3
;; :fill-color "#0000ff"
;; :x 1000 :y 2000 :rx nil}
;; :x 1000 :y 2000}
;; {:stroke-width "#ff0000"
;; :stroke-width 5
;; :x 1500 :y 2000}])
@ -72,13 +72,17 @@
;; (get-attrs-multi shapes [:stroke-color
;; :stroke-width
;; :fill-color
;; :rx
;; :ry])
;; :r1
;; :r2
;; :r3
;; :r4])
;; >>> {:stroke-color "#ff0000"
;; :stroke-width :multiple
;; :fill-color "#0000ff"
;; :rx nil
;; :ry nil}
;; :r1 nil
;; :r2 nil
;; :r3 nil
;; :r4 nil}
;;
(defn get-attrs-multi
([objs attrs]

View file

@ -828,13 +828,13 @@
(apply-changes-local)))
(defn delete-token-set-path
[changes prefixed-full-set-path]
[changes token-set-path]
(assert-library! changes)
(let [library-data (::library-data (meta changes))
prev-token-sets (some-> (get library-data :tokens-lib)
(ctob/get-sets-at-prefix-path prefixed-full-set-path))]
(ctob/get-path-sets token-set-path))]
(-> changes
(update :redo-changes conj {:type :del-token-set-path :path prefixed-full-set-path})
(update :redo-changes conj {:type :del-token-set-path :path token-set-path})
(update :undo-changes conj {:type :add-token-sets :token-sets prev-token-sets})
(apply-changes-local))))

View file

@ -6,4 +6,4 @@
(ns app.common.files.defaults)
(def version 57)
(def version 58)

View file

@ -1130,6 +1130,45 @@
(update :pages-index dissoc nil)
(update :pages-index update-vals update-page))))
(defn migrate-up-58
[data]
(letfn [(update-object [object]
(if (and (:rx object) (not (:r1 object)))
(-> object
(assoc :r1 (:rx object))
(assoc :r2 (:rx object))
(assoc :r3 (:rx object))
(assoc :r4 (:rx object)))
object))
(update-container [container]
(d/update-when container :objects update-vals update-object))]
(-> data
(update :pages-index update-vals update-container)
(update :components update-vals update-container))))
(defn migrate-down-58
[data]
(letfn [(update-object [object]
(if (= (:r1 object) (:r2 object) (:r3 object) (:r4 object))
(-> object
(dissoc :r1 :r2 :r3 :r4)
(assoc :rx (:r1 object))
(assoc :ry (:r1 object)))
object))
(update-container [container]
(d/update-when container :objects update-vals update-object))]
(-> data
(update :pages-index update-vals update-container)
(update :components update-vals update-container))))
(def migrations
"A vector of all applicable migrations"
[{:id 2 :migrate-up migrate-up-2}
@ -1178,5 +1217,6 @@
{:id 54 :migrate-up migrate-up-54}
{:id 55 :migrate-up migrate-up-55}
{:id 56 :migrate-up migrate-up-56}
{:id 57 :migrate-up migrate-up-57}])
{:id 57 :migrate-up migrate-up-57}
{:id 58 :migrate-up migrate-up-58 :migrate-down migrate-down-58}])

View file

@ -434,8 +434,10 @@
(assoc shape :type :frame
:fills []
:hide-in-viewer true
:rx 0
:ry 0))]
:r1 0
:r2 0
:r3 0
:r4 0))]
(log/dbg :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id)
(-> (pcb/empty-changes nil page-id)

View file

@ -43,9 +43,9 @@
(defn shape-corners-1
"Retrieve the effective value for the corner given a single value for corner."
[{:keys [width height rx] :as shape}]
(if (and (some? rx) (not (mth/almost-zero? rx)))
(fix-radius width height rx)
[{:keys [width height r1] :as shape}]
(if (and (some? r1) (not (mth/almost-zero? r1)))
(fix-radius width height r1)
0))
(defn shape-corners-4
@ -55,26 +55,11 @@
(fix-radius width height r1 r2 r3 r4)
[r1 r2 r3 r4]))
(defn update-corners-scale-1
"Scales round corners (using a single value)"
[shape scale]
(update shape :rx * scale))
(defn update-corners-scale-4
"Scales round corners (using four values)"
(defn update-corners-scale
"Scales round corners"
[shape scale]
(-> shape
(update :r1 * scale)
(update :r2 * scale)
(update :r3 * scale)
(update :r4 * scale)))
(defn update-corners-scale
"Scales round corners"
[shape scale]
(cond-> shape
(and (some? (:rx shape)) (> (:rx shape) 0))
(update-corners-scale-1 scale)
(and (some? (:r1 shape)) (> (:r1 shape) 0))
(update-corners-scale-4 scale)))

View file

@ -438,12 +438,14 @@
;; Resize parent containers that need to
(pcb/resize-parents parents))))
(defn change-show-in-viewer [shape hide?]
(defn change-show-in-viewer
[shape hide?]
(assoc shape :hide-in-viewer hide?))
(defn add-new-interaction [shape interaction]
(-> shape
(update :interactions ctsi/add-interaction interaction)))
(defn add-new-interaction
[shape interaction]
(update shape :interactions ctsi/add-interaction interaction))
(defn show-in-viewer [shape]
(defn show-in-viewer
[shape]
(dissoc shape :hide-in-viewer))

View file

@ -1010,6 +1010,9 @@
(def valid-safe-number?
(lazy-validator ::safe-number))
(def valid-text?
(validator ::text))
(def check-safe-int!
(check-fn ::safe-int))

View file

@ -40,3 +40,76 @@
(map (fn [segment]
(.toPersistentMap ^js segment)))
(parser/parse path-str)))))
#?(:cljs
(defn content->buffer
"Converts the path content into binary format."
[content]
(let [total (count content)
ssize 28
buffer (new js/ArrayBuffer (* total ssize))
dview (new js/DataView buffer)]
(loop [index 0]
(when (< index total)
(let [segment (nth content index)
offset (* index ssize)]
(case (:command segment)
:move-to
(let [{:keys [x y]} (:params segment)]
(.setInt16 dview (+ offset 0) 1)
(.setFloat32 dview (+ offset 20) x)
(.setFloat32 dview (+ offset 24) y))
:line-to
(let [{:keys [x y]} (:params segment)]
(.setInt16 dview (+ offset 0) 2)
(.setFloat32 dview (+ offset 20) x)
(.setFloat32 dview (+ offset 24) y))
:curve-to
(let [{:keys [c1x c1y c2x c2y x y]} (:params segment)]
(.setInt16 dview (+ offset 0) 3)
(.setFloat32 dview (+ offset 4) c1x)
(.setFloat32 dview (+ offset 8) c1y)
(.setFloat32 dview (+ offset 12) c2x)
(.setFloat32 dview (+ offset 16) c2y)
(.setFloat32 dview (+ offset 20) x)
(.setFloat32 dview (+ offset 24) y))
:close-path
(.setInt16 dview (+ offset 0) 4))
(recur (inc index)))))
buffer)))
#?(:cljs
(defn buffer->content
"Converts the a buffer to a path content vector"
[buffer]
(assert (instance? js/ArrayBuffer buffer) "expected ArrayBuffer instance")
(let [ssize 28
total (/ (.-byteLength buffer) ssize)
dview (new js/DataView buffer)]
(loop [index 0
result []]
(if (< index total)
(let [offset (* index ssize)
type (.getInt16 dview (+ offset 0))
command (case type
1 :move-to
2 :line-to
3 :curve-to
4 :close-path)
params (case type
1 {:x (.getFloat32 dview (+ offset 20))
:y (.getFloat32 dview (+ offset 24))}
2 {:x (.getFloat32 dview (+ offset 20))
:y (.getFloat32 dview (+ offset 24))}
3 {:c1x (.getFloat32 dview (+ offset 4))
:c1y (.getFloat32 dview (+ offset 8))
:c2x (.getFloat32 dview (+ offset 12))
:c2y (.getFloat32 dview (+ offset 16))
:x (.getFloat32 dview (+ offset 20))
:y (.getFloat32 dview (+ offset 24))}
4 {})]
(recur (inc index)
(conj result {:command command
:params params})))
result)))))

View file

@ -412,7 +412,6 @@
(recur (when continue? (rest styles)) taking? to result))
result))))
(defn content->text
"Given a root node of a text content extracts the texts with its associated styles"
[content]

View file

@ -65,8 +65,6 @@
:fill-color :fill-group
:fill-opacity :fill-group
:rx :radius-group
:ry :radius-group
:r1 :radius-group
:r2 :radius-group
:r3 :radius-group

View file

@ -33,7 +33,7 @@
[:id ::sm/uuid]
[:axis [::sm/one-of #{:x :y}]]
[:position ::sm/safe-number]
[:frame-id {:optional true} ::sm/uuid]])
[:frame-id {:optional true} [:maybe ::sm/uuid]]])
(def schema:guides
[:map-of {:gen/max 2} ::sm/uuid schema:guide])

View file

@ -192,8 +192,6 @@
[:constraints-v {:optional true}
[::sm/one-of vertical-constraint-types]]
[:fixed-scroll {:optional true} :boolean]
[:rx {:optional true} ::sm/safe-number]
[:ry {:optional true} ::sm/safe-number]
[:r1 {:optional true} ::sm/safe-number]
[:r2 {:optional true} ::sm/safe-number]
[:r3 {:optional true} ::sm/safe-number]
@ -400,13 +398,17 @@
:fills [{:fill-color default-color
:fill-opacity 1}]
:strokes []
:rx 0
:ry 0})
:r1 0
:r2 0
:r3 0
:r4 0})
(def ^:private minimal-image-attrs
{:type :image
:rx 0
:ry 0
:r1 0
:r2 0
:r3 0
:r4 0
:fills []
:strokes []})
@ -417,6 +419,10 @@
:strokes []
:name "Board"
:shapes []
:r1 0
:r2 0
:r3 0
:r4 0
:hide-fill-on-export false})
(def ^:private minimal-circle-attrs

View file

@ -15,7 +15,6 @@
{:frame #{:proportion-lock
:width :height
:x :y
:rx :ry
:r1 :r2 :r3 :r4
:rotation
:selrect
@ -126,7 +125,6 @@
:width :height
:x :y
:rotation
:rx :ry
:r1 :r2 :r3 :r4
:selrect
:points
@ -372,7 +370,6 @@
:width :height
:x :y
:rotation
:rx :ry
:r1 :r2 :r3 :r4
:selrect
:points
@ -410,7 +407,6 @@
:width :height
:x :y
:rotation
:rx :ry
:r1 :r2 :r3 :r4
:selrect
:points
@ -467,7 +463,6 @@
:width :height
:x :y
:rotation
:rx :ry
:r1 :r2 :r3 :r4
:selrect
:points

View file

@ -9,69 +9,42 @@
[app.common.types.shape.attrs :refer [editable-attrs]]))
;; There are some shapes that admit border radius, as rectangles
;; frames and images. Those shapes may define the radius of the corners in two modes:
;; - radius-1 all corners have the same radius (although we store two
;; values :rx and :ry because svg uses it this way).
;; - radius-4 each corner (top-left, top-right, bottom-right, bottom-left)
;; has an independent value. SVG does not allow this directly, so we
;; emulate it with paths.
;; A shape never will have both :rx and :r1 simultaneously
;; frames components and images.
;; Those shapes may define the radius of the corners with four values:
;; One for each corner (top-left, top-right, bottom-right, bottom-left)
;; has an independent value. SVG does not allow this directly, so we
;; emulate it with paths.
;; All operations take into account that the shape may not be a one of those
;; shapes that has border radius, and so it hasn't :rx nor :r1.
;; shapes that has border radius, and so it hasn't :r1.
;; In this case operations must leave shape untouched.
(defn can-get-border-radius?
[shape]
(contains? #{:rect :frame} (:type shape)))
(defn has-radius?
[shape]
(contains? (get editable-attrs (:type shape)) :rx))
(defn radius-mode
[shape]
(if (:r1 shape)
:radius-4
:radius-1))
(defn radius-1?
[shape]
(and (:rx shape) (not= (:rx shape) 0)))
(defn radius-4?
[shape]
(and (:r1 shape)
(or (not= (:r1 shape) 0)
(not= (:r2 shape) 0)
(not= (:r3 shape) 0)
(not= (:r4 shape) 0))))
(contains? (get editable-attrs (:type shape)) :r1))
(defn all-equal?
[shape]
(= (:r1 shape) (:r2 shape) (:r3 shape) (:r4 shape)))
(defn switch-to-radius-1
(defn radius-mode
[shape]
(let [r (if (all-equal? shape) (:r1 shape) 0)]
(-> shape
(assoc :rx r :ry r)
(dissoc :r1 :r2 :r3 :r4))))
(if (all-equal? shape)
:radius-1
:radius-4))
(defn switch-to-radius-4
[shape]
(let [rx (:rx shape 0)]
(-> (assoc shape :r1 rx :r2 rx :r3 rx :r4 rx)
(dissoc :rx :ry))))
(defn set-radius-1
(defn set-radius-to-all-corners
[shape value]
;; Only Apply changes to shapes that support Border Radius
(cond-> shape
(:r1 shape)
(-> (dissoc :r1 :r2 :r3 :r4)
(assoc :rx 0 :ry 0))
(can-get-border-radius? shape)
(assoc :r1 value :r2 value :r3 value :r4 value)))
:always
(assoc :rx value :ry value)))
(defn set-radius-4
(defn set-radius-to-single-corner
[shape attr value]
(let [attr (cond->> attr
(:flip-x shape)
@ -79,11 +52,7 @@
(:flip-y shape)
(get {:r1 :r4 :r2 :r3 :r3 :r2 :r4 :r1}))]
;; Only Apply changes to shapes that support border Radius
(cond-> shape
(:rx shape)
(-> (dissoc :rx :rx)
(assoc :r1 0 :r2 0 :r3 0 :r4 0))
:always
(can-get-border-radius? shape)
(assoc attr value))))

View file

@ -86,8 +86,6 @@
(sm/register!
^{::sm/type ::border-radius}
[:map
[:rx {:optional true} token-name-ref]
[:ry {:optional true} token-name-ref]
[:r1 {:optional true} token-name-ref]
[:r2 {:optional true} token-name-ref]
[:r3 {:optional true} token-name-ref]
@ -229,3 +227,4 @@
(defn unapply-token-id [shape attributes]
(update shape :applied-tokens d/without-keys attributes))

View file

@ -216,8 +216,49 @@
set-name (add-set-path-prefix (last full-path))]
(conj set-path set-name)))
(defn split-token-set-path [path]
(split-path path set-separator))
(defn split-set-prefix [set-path]
(some->> set-path
(re-matches #"^([SG]-)(.*)")
(rest)))
(defn add-set-prefix [set-name]
(str set-prefix set-name))
(defn add-set-group-prefix [group-path]
(str set-group-prefix group-path))
(defn add-token-set-paths-prefix
"Returns token-set paths with prefixes to differentiate between sets and set-groups.
Sets will be prefixed with `set-prefix` (S-).
Set groups will be prefixed with `set-group-prefix` (G-)."
[paths]
(let [set-path (mapv add-set-group-prefix (butlast paths))
set-name (add-set-prefix (last paths))]
(conj set-path set-name)))
(defn split-token-set-path [token-set-path]
(split-path token-set-path set-separator))
(defn split-token-set-name [token-set-name]
(-> (split-token-set-path token-set-name)
(add-token-set-paths-prefix)))
(defn get-token-set-path [token-set]
(let [path (get-path token-set set-separator)]
(add-token-set-paths-prefix path)))
(defn set-name->set-path-string [set-name]
(-> (split-token-set-name set-name)
(join-set-path)))
(defn set-path->set-name [set-path]
(->> (split-token-set-path set-path)
(map (fn [path-part]
(or (-> (split-set-prefix path-part)
(second))
path-part)))
(join-set-path)))
(defn get-token-set-final-name [path]
(-> (split-token-set-path path)
@ -413,6 +454,7 @@ When `before-set-name` is nil, move set to bottom")
(get-set-tree [_] "get a nested tree of all sets in the library")
(get-in-set-tree [_ path] "get `path` in nested tree of all sets in the library")
(get-sets [_] "get an ordered sequence of all sets in the library")
(get-path-sets [_ path] "get an ordered sequence of sets at `path` in the library")
(get-sets-at-prefix-path [_ prefixed-path] "get an ordered sequence of sets at `prefixed-path` in the library")
(get-sets-at-path [_ path-str] "get an ordered sequence of sets at `path` in the library")
(rename-set-group [_ from-path-str to-path-str] "renames set groups and all child set names from `from-path-str` to `to-path-str`")
@ -744,6 +786,11 @@ Will return a value that matches this schema:
(->> (tree-seq d/ordered-map? vals sets)
(filter (partial instance? TokenSet))))
(get-path-sets [_ path]
(some->> (get-in sets (split-token-set-path path))
(tree-seq d/ordered-map? vals)
(filter (partial instance? TokenSet))))
(get-sets-at-prefix-path [_ prefixed-path]
(some->> (get-in sets (split-token-set-path prefixed-path))
(tree-seq d/ordered-map? vals)

View file

@ -18,11 +18,18 @@
java.nio.ByteBuffer)))
(defn uuid
"Parse string uuid representation into proper UUID instance."
"Creates an UUID instance from string, expectes valid uuid strings,
the existense of validation is implementation detail"
[s]
#?(:clj (UUID/fromString s)
:cljs (c/uuid s)))
(defn parse
"Parse string uuid representation into proper UUID instance, validates input"
[s]
#?(:clj (UUID/fromString s)
:cljs (c/parse-uuid s)))
(defn next
[]
#?(:clj (UUIDv8/create)
@ -44,15 +51,15 @@
[v]
(= zero v))
#?(:clj
(defn get-word-high
[id]
(.getMostSignificantBits ^UUID id)))
(defn get-word-high
[id]
#?(:clj (.getMostSignificantBits ^UUID id)
:cljs (impl/getHi (.-uuid ^UUID id))))
#?(:clj
(defn get-word-low
[id]
(.getLeastSignificantBits ^UUID id)))
(defn get-word-low
[id]
#?(:clj (.getLeastSignificantBits ^UUID id)
:cljs (impl/getLo (.-uuid ^UUID id))))
(defn get-bytes
[^UUID o]
@ -80,12 +87,21 @@
[id]
(impl/shortV8 (dm/str id))))
#?(:cljs
(defn get-unsigned-parts
"Get a Uint32 array of length 4 that represents the UUID, needed
for interact with wasm"
[this]
(impl/getUnsignedParts (.-uuid ^UUID this))))
#?(:cljs
(defn get-u32
"A cached variant of get-unsigned-parts"
[this]
(let [buffer (unchecked-get this "__u32_buffer")]
(if (nil? buffer)
(let [buffer (impl/getUnsignedInt32Array (.-uuid ^UUID this))]
(let [buffer (get-unsigned-parts this)]
(unchecked-set this "__u32_buffer" buffer)
buffer)
buffer))))
@ -97,3 +113,33 @@
b (.getLeastSignificantBits ^UUID id)]
(+ (clojure.lang.Murmur3/hashLong a)
(clojure.lang.Murmur3/hashLong b)))))
;; Commented code used for debug
;; #?(:cljs
;; (defn ^:export test-uuid
;; []
;; (let [expected #uuid "a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"]
;;
;; (js/console.log "===> to-from-bytes-roundtrip")
;; (js/console.log (uuid.impl/getBytes (str expected)))
;; (js/console.log (uuid.impl/fromBytes (uuid.impl/getBytes (str expected))))
;;
;; (js/console.log "===> HI LO roundtrip")
;; (let [hi (uuid.impl/getHi (str expected))
;; lo (uuid.impl/getLo (str expected))
;; res (uuid.impl/custom hi lo)]
;;
;; (js/console.log "HI:" hi)
;; (js/console.log "LO:" lo)
;; (js/console.log "RS:" res))
;;
;; (js/console.log "===> OTHER")
;; (let [parts (uuid.impl/getUnsignedParts (str expected))
;; res (uuid.impl/fromUnsignedParts (aget parts 0)
;; (aget parts 1)
;; (aget parts 2)
;; (aget parts 3))]
;; (js/console.log "PARTS:" parts)
;; (js/console.log "RES: " res))
;;
;; )))

View file

@ -192,6 +192,76 @@ goog.scope(function() {
}
};
const fillBytes = (uuid) => {
let rest;
int8[0] = (rest = parseInt(uuid.slice(0, 8), 16)) >>> 24;
int8[1] = (rest >>> 16) & 0xff;
int8[2] = (rest >>> 8) & 0xff;
int8[3] = rest & 0xff;
// Parse ........-####-....-....-............
int8[4] = (rest = parseInt(uuid.slice(9, 13), 16)) >>> 8;
int8[5] = rest & 0xff;
// Parse ........-....-####-....-............
int8[6] = (rest = parseInt(uuid.slice(14, 18), 16)) >>> 8;
int8[7] = rest & 0xff;
// Parse ........-....-....-####-............
int8[8] = (rest = parseInt(uuid.slice(19, 23), 16)) >>> 8;
int8[9] = rest & 0xff,
// Parse ........-....-....-....-############
// (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes)
int8[10] = ((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff;
int8[11] = (rest / 0x100000000) & 0xff;
int8[12] = (rest >>> 24) & 0xff;
int8[13] = (rest >>> 16) & 0xff;
int8[14] = (rest >>> 8) & 0xff;
int8[15] = rest & 0xff;
}
const fromPair = (hi, lo) => {
view.setBigInt64(0, hi);
view.setBigInt64(8, lo);
return encoding.bufferToHex(int8, true);
}
const getHi = (uuid) => {
fillBytes(uuid);
return view.getBigInt64(0);
}
const getLo = (uuid) => {
fillBytes(uuid);
return view.getBigInt64(8);
}
const getBytes = (uuid) => {
fillBytes(uuid);
return Int8Array.from(int8);
}
const getUnsignedParts = (uuid) => {
fillBytes(uuid);
const result = new Uint32Array(4);
result[0] = view.getUint32(0)
result[1] = view.getUint32(4);
result[2] = view.getUint32(8);
result[3] = view.getUint32(12);
return result;
}
const fromUnsignedParts = (a, b, c, d) => {
view.setUint32(0, a)
view.setUint32(4, b)
view.setUint32(8, c)
view.setUint32(12, d)
return encoding.bufferToHex(int8, true);
}
const fromArray = (u8data) => {
int8.set(u8data);
return encoding.bufferToHex(int8, true);
@ -209,8 +279,14 @@ goog.scope(function() {
};
factory.create = create;
factory.setTag = setTag;
factory.fromArray = fromArray;
factory.fromPair = fromPair;
factory.fromUnsignedParts = fromUnsignedParts;
factory.getBytes = getBytes;
factory.getHi = getHi;
factory.getLo = getLo;
factory.getUnsignedParts = getUnsignedParts;
factory.setTag = setTag;
return factory;
})();
@ -220,67 +296,44 @@ goog.scope(function() {
return encoding.bufferToBase62(short);
};
self.custom = function formatAsUUID(mostSigBits, leastSigBits) {
const most = mostSigBits.toString("16").padStart(16, "0");
const least = leastSigBits.toString("16").padStart(16, "0");
return `${most.substring(0, 8)}-${most.substring(8, 12)}-${most.substring(12)}-${least.substring(0, 4)}-${least.substring(4)}`;
self.custom = function formatAsUUID(hi, lo) {
if (!(hi instanceof BigInt)) {
hi = BigInt(hi);
}
if (!(hi instanceof BigInt)) {
lo = BigInt(lo);
}
return self.v8.fromPair(hi, lo);
};
self.fromBytes = function(data) {
if (data instanceof Uint8Array) {
return self.v8.fromArray(data);
} else if (data instanceof Int8Array) {
data = Uint8Array.from(data);
return self.v8.fromArray(data);
} else {
let buffer = data?.buffer;
if (buffer instanceof ArrayBuffer) {
data = new Uint8Array(buffer);
return self.v8.fromArray(data);
} else {
throw new Error("invalid array type received");
}
throw new Error("invalid array type received");
}
};
// Code based from uuidjs/parse.ts
self.getBytes = function parse(uuid) {
const buffer = new ArrayBuffer(16);
const view = new Int8Array(buffer);
let rest;
return self.v8.getBytes(uuid);
};
// Parse ########-....-....-....-............
view[0] = (rest = parseInt(uuid.slice(0, 8), 16)) >>> 24;
view[1] = (rest >>> 16) & 0xff;
view[2] = (rest >>> 8) & 0xff;
view[3] = rest & 0xff;
self.getUnsignedParts = function (uuid) {
return self.v8.getUnsignedParts(uuid);
};
// Parse ........-####-....-....-............
view[4] = (rest = parseInt(uuid.slice(9, 13), 16)) >>> 8;
view[5] = rest & 0xff;
self.fromUnsignedParts = function(a,b,c,d) {
return self.v8.fromUnsignedParts(a,b,c,d);
};
// Parse ........-....-####-....-............
view[6] = (rest = parseInt(uuid.slice(14, 18), 16)) >>> 8;
view[7] = rest & 0xff;
// Parse ........-....-....-####-............
view[8] = (rest = parseInt(uuid.slice(19, 23), 16)) >>> 8;
view[9] = rest & 0xff,
// Parse ........-....-....-....-############
// (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes)
view[10] = ((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff;
view[11] = (rest / 0x100000000) & 0xff;
view[12] = (rest >>> 24) & 0xff;
view[13] = (rest >>> 16) & 0xff;
view[14] = (rest >>> 8) & 0xff;
view[15] = rest & 0xff;
return view;
self.getHi = function (uuid) {
return self.v8.getHi(uuid);
}
self.getUnsignedInt32Array = function (uuid) {
const bytes = self.getBytes(uuid);
return new Uint32Array(bytes.buffer);
self.getLo = function (uuid) {
return self.v8.getLo(uuid);
}
});

View file

@ -58,7 +58,7 @@
(defn- apply-all-tokens
[file]
(-> file
(tht/apply-token-to-shape :frame1 "token-radius" [:rx :ry] [:rx :ry] 10)
(tht/apply-token-to-shape :frame1 "token-radius" [:r1 :r2 :r3 :r4] [:r1 :r2 :r3 :r4] 10)
(tht/apply-token-to-shape :frame1 "token-rotation" [:rotation] [:rotation] 30)
(tht/apply-token-to-shape :frame1 "token-opacity" [:opacity] [:opacity] 0.7)
(tht/apply-token-to-shape :frame1 "token-stroke-width" [:stroke-width] [:stroke-width] 2)
@ -90,7 +90,7 @@
:attributes []})
(cto/maybe-apply-token-to-shape {:token token-radius
:shape $
:attributes [:rx :ry]})
:attributes [:r1 :r2 :r3 :r4]})
(cto/maybe-apply-token-to-shape {:token token-rotation
:shape $
:attributes [:rotation]})
@ -119,9 +119,11 @@
applied-tokens' (:applied-tokens frame1')]
;; ==== Check
(t/is (= (count applied-tokens') 9))
(t/is (= (:rx applied-tokens') "token-radius"))
(t/is (= (:ry applied-tokens') "token-radius"))
(t/is (= (count applied-tokens') 11))
(t/is (= (:r1 applied-tokens') "token-radius"))
(t/is (= (:r2 applied-tokens') "token-radius"))
(t/is (= (:r3 applied-tokens') "token-radius"))
(t/is (= (:r4 applied-tokens') "token-radius"))
(t/is (= (:rotation applied-tokens') "token-rotation"))
(t/is (= (:opacity applied-tokens') "token-opacity"))
(t/is (= (:stroke-width applied-tokens') "token-stroke-width"))
@ -144,7 +146,7 @@
(cls/generate-update-shapes [(:id frame1)]
(fn [shape]
(-> shape
(cto/unapply-token-id [:rx :ry])
(cto/unapply-token-id [:r1 :r2 :r3 :r4])
(cto/unapply-token-id [:rotation])
(cto/unapply-token-id [:opacity])
(cto/unapply-token-id [:stroke-width])
@ -177,8 +179,10 @@
(cls/generate-update-shapes [(:id frame1)]
(fn [shape]
(-> shape
(ctn/set-shape-attr :rx 0)
(ctn/set-shape-attr :ry 0)
(ctn/set-shape-attr :r1 0)
(ctn/set-shape-attr :r2 0)
(ctn/set-shape-attr :r3 0)
(ctn/set-shape-attr :r4 0)
(ctn/set-shape-attr :rotation 0)
(ctn/set-shape-attr :opacity 0)
(ctn/set-shape-attr :strokes [])

View file

@ -258,10 +258,11 @@
(ctob/delete-set-path "S-not-existing-set"))
token-set' (ctob/get-set tokens-lib' "updated-name")
token-theme' (ctob/get-theme tokens-lib' "" "test-token-theme")]
;;token-theme' (ctob/get-theme tokens-lib' "" "test-token-theme")
]
(t/is (= (ctob/set-count tokens-lib') 0))
(t/is (= (:sets token-theme') #{}))
;; (t/is (= (:sets token-theme') #{})) TODO: fix this
(t/is (nil? token-set'))))
(t/deftest active-themes-set-names

View file

@ -43,5 +43,54 @@
(t/is (= result uuid))))))
(t/deftest bytes-roundtrip-2
(let [uuid (uuid/uuid "a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8")
result-bytes (uuid/get-bytes uuid)
expected-hi #?(:clj -6799692559624781374
:cljs (js/BigInt "-6799692559624781374"))
expected-lo #?(:clj -3327364263599220776
:cljs (js/BigInt "-3327364263599220776"))
expected-bytes [-95, -94, -93, -92, -79, -78, -63, -62, -47, -46, -45, -44, -43, -42, -41, -40]]
(t/testing "get-bytes"
(let [data (uuid/get-bytes uuid)]
(t/is (= (nth expected-bytes 0) (aget data 0)))
(t/is (= (nth expected-bytes 1) (aget data 1)))
(t/is (= (nth expected-bytes 2) (aget data 2)))
(t/is (= (nth expected-bytes 3) (aget data 3)))
(t/is (= (nth expected-bytes 4) (aget data 4)))
(t/is (= (nth expected-bytes 5) (aget data 5)))
(t/is (= (nth expected-bytes 6) (aget data 6)))
(t/is (= (nth expected-bytes 7) (aget data 7)))
(t/is (= (nth expected-bytes 8) (aget data 8)))
(t/is (= (nth expected-bytes 9) (aget data 9)))
(t/is (= (nth expected-bytes 10) (aget data 10)))
(t/is (= (nth expected-bytes 11) (aget data 11)))
(t/is (= (nth expected-bytes 12) (aget data 12)))
(t/is (= (nth expected-bytes 13) (aget data 13)))
(t/is (= (nth expected-bytes 14) (aget data 14)))
(t/is (= (nth expected-bytes 15) (aget data 15)))))
(t/testing "from-bytes"
(let [data (create-array expected-bytes)
result (uuid/from-bytes data)]
(t/is (= result uuid))))
(t/testing "hi-low"
(let [hi (uuid/get-word-high uuid)
lo (uuid/get-word-low uuid)]
(t/is (= hi expected-hi))
(t/is (= lo expected-lo))))
#?(:cljs
(t/testing "unsigned-parts"
(let [parts (uuid/get-unsigned-parts uuid)
expected [2711790500, 2981282242, 3520254932, 3587626968]]
(t/is (instance? js/Uint32Array parts))
(t/is (= (nth expected 0) (aget parts 0)))
(t/is (= (nth expected 1) (aget parts 1)))
(t/is (= (nth expected 2) (aget parts 2)))
(t/is (= (nth expected 3) (aget parts 3))))))))

View file

@ -99,6 +99,7 @@ RUN set -ex; \
libnss3 \
libgbm1 \
xvfb \
libfontconfig-dev \
; \
rm -rf /var/lib/apt/lists/*;

View file

@ -125,5 +125,5 @@ services:
- "10636:10636"
ulimits:
nofile:
soft: "1024"
hard: "1024"
soft: 1024
hard: 1024

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View file

@ -4,7 +4,8 @@ title: 1. Self-hosting Guide
# Self-hosting Guide
This guide explains how to get your own Penpot instance, running on a machine you control, to test it, use it by you or your team, or even customize and extend it any way you like.
This guide explains how to get your own Penpot instance, running on a machine you control,
to test it, use it by you or your team, or even customize and extend it any way you like.
If you need more context you can look at the <a
href="https://community.penpot.app/t/self-hosting-penpot-i/2336" target="_blank">post
@ -14,18 +15,30 @@ about self-hosting</a> in Penpot community.
href="https://design.penpot.app">our SaaS offer</a> for Penpot and your
self-hosted Penpot platform!**
There are two main options for creating a Penpot instance:
There are three main options for creating a Penpot instance:
1. Using the platform of our partner <a href="https://elest.io/open-source/penpot" target="_blank">Elestio</a>.
2. Using <a href="https://docker.com" target="_blank">Docker</a> tool.
3. Using <a href="https://kubernetes.io/" target="_blank">Kubernetes</a>.
<p class="advice">
The recommended way is to use Elestio, since it's simpler, fully automatic and still greatly flexible. Use Docker if you already know the tool, if need full control of the process or have extra requirements and do not want to depend on any external provider, or need to do any special customization.
The recommended way is to use Elestio, since it's simpler, fully automatic and still greatly flexible.
Use Docker if you already know the tool, if need full control of the process or have extra requirements
and do not want to depend on any external provider, or need to do any special customization.
</p>
Or you can try <a href="#unofficial-self-host-options">other options</a>,
offered by Penpot community.
## Recommended settings
To self-host Penpot, youll need a server with the following specifications:
* **CPU:** 1-2 CPUs
* **RAM:** 4 GiB of RAM
* **Disk Space:** Disk requirements depend on your usage. Disk usage primarily involves the database and any files uploaded by users.
This setup should be sufficient for a smooth experience with typical usage (your mileage may vary).
## Install with Elestio
This section explains how to get Penpot up and running using <a href="https://elest.io/open-source/penpot"
@ -261,7 +274,7 @@ itself.
This section details everything you need to know to get Penpot up and running in
production environments using a Kubernetes cluster of your choice. To do this, we have
created a <a href="https://helm.sh/" target="_blank">Helm<a> repository with everything
created a <a href="https://helm.sh/" target="_blank">Helm</a> repository with everything
you need.
Therefore, your prerequisite will be to have a Kubernetes cluster on which we can install
@ -287,7 +300,7 @@ in turn have its own release name.
With these concepts in mind, we can now explain Helm like this:
> Helm installs charts into Kubernetes clusters, creating a new release for each
> installation. And to find new charts, you can search Helm chart repositories.
> installation. To find new charts, you can search Helm chart repositories.
### Install Helm

View file

@ -20,6 +20,8 @@ machine.
* In the [Install with Docker][2] section, you can find the official Docker installation guide.
* In the [Install with Kubernetes][7] section, you can find the official Kubernetes installation guide.
* In the [Configuration][3] section, you can find all the customization options you can set up after installing.
* Or you can try other, not supported by Penpot, [Unofficial options][4].
@ -28,9 +30,11 @@ machine.
The [Integration Guide][5] explains how to connect Penpot with external apps, so they get notified
when certain events occur and may create your own interconnections and collaboration features.
## Developing Penpot
Also, if you are a developer, you can get into the code, to explore it, learn how it is made, or extend it and contribute with new functionality. For this, we have a different Docker installation.
Also, if you are a developer, you can get into the code, to explore it, learn how it is made,
or extend it and contribute with new functionality. For this, we have a different Docker installation.
In the [Developer Guide][6] you can find how to setup a development environment and many other dev-oriented documentation.
[1]: /technical-guide/getting-started/#install-with-elestio
@ -39,3 +43,4 @@ In the [Developer Guide][6] you can find how to setup a development environment
[4]: /technical-guide/getting-started/#unofficial-self-host-options
[5]: /technical-guide/integration/
[6]: /technical-guide/developer/
[7]: /technical-guide/getting-started/#install-with-kubernetes

View file

@ -5,45 +5,25 @@ title: 14· Import/export files
<h1 id="import-export">Import and export files</h1>
<p class="main-paragraph">You can export Penpot files to your computer and import them from your computer to your projects.</p>
<h2 id="penpot-formats">Penpot file formats</h2>
<p>There are two different formats in which you can import/export Penpot files. A standard one and a binary one. You always have the chance to use both for any file.</p>
<h3>Penpot file (.penpot).</h3>
<p>The fast one. Binary Penpot specific.</p>
<ul>
<li>✅ Highly efficient in terms of memory and transfer time when exporting and importing.</li>
<li>❌ It can be opened only in Penpot.</li>
<li>❌ Not transparent, code difficult to explore.</li>
</ul>
<h3>Standard file (.zip).</h3>
<p>The open one. A compressed file that includes SVG and JSON.</p>
<ul>
<li>✅ Allows the file to be opened by other softwares (still, for those cases export to SVG seems to be the common practice).</li>
<li>✅ Allows some automations and integrations.</li>
<li>✅ Is a transparent, existing, open standard format.</li>
<li>❌ Highly inefficient in terms of memory and transfer time when exporting and importing (this is because SVG).</li>
</ul>
<h2 id="files-export">Export Penpot files</h2>
<p>Exporting files is useful for many reasons. Sometimes you want to have a backup of your files and sometimes it is useful to share Penpot files with a user that does not belong to one of your teams, or you want to have a backup of your files outside Penpot, both SaaS (design.penpot.app) or at a self-hosted instance.</p>
<h3 id="export-penpot-files">How to export Penpot files</h3>
<h4>Export a single file</h4>
<p>You can download (export) files from the workspace and from the dashboard.</p>
<ul>
<li>
<strong>From the <a href="/user-guide/the-interface/#interface-workspace">workspace</a></strong>: Select the download option at the main menu.
<figure><img src="/img/import-export/export-card.webp" alt="Export penpot file" /></figure>
</li>
<li>
<strong>From the <a href="/user-guide/the-interface/#interface-dashboard">dashboard</a></strong>: Select the download option at the file card menu.
<figure><img src="/img/import-export/export-menu.webp" alt="Export penpot file" /></figure>
</li>
</ul>
<p>
<strong>From the <a href="/user-guide/the-interface/#interface-dashboard">dashboard</a></strong>: Select the download option at the file card menu.
<figure><img src="/img/import-export/export-card.webp" alt="Export penpot file" /></figure>
</p>
<p>
<strong>From the <a href="/user-guide/the-interface/#interface-workspace">workspace</a></strong>: Select the download option at the main menu.
<figure><img src="/img/import-export/export-menu.webp" alt="Export penpot file" /></figure>
</p>
<h4>Export multiple files</h4>
<p>Select multiple files to export them at the same time. An overlay will show you the progress of the different exports.</p>
<figure>
<video title="Export multiple files" muted="" playsinline="" controls="" width="100%" poster="/img/import-export/export-multiple.webp" height="auto">
<video title="Export multiple files" muted="" playsinline="" controls="" width="auto" poster="/img/import-export/export-multiple.webp" height="auto">
<source src="/img/import-export/export-multiple.mp4" type="video/mp4">
</video>
</figure>
@ -63,4 +43,27 @@ title: 14· Import/export files
<p>The import option is at the projects menu. Press “Import files” and then select one or more .penpot files to import. You can import a .zip file as well.</p>
<figure><img src="/img/import-export/import-menu.webp" alt="Import penpot file" /></figure>
<p>Right before importing the files to your project, youll still have the opportunity to review the items to be imported, have the information about the ones that can not be imported and also the chance to discard files.</p>
<figure><img src="/img/import-export/import-selection.webp" alt="Import penpot file" /></figure
<figure><img src="/img/import-export/import-selection.webp" alt="Import penpot file" /></figure>
<h2 id="penpot-formats">Penpot file format</h2>
<p>Penpot export to a unique format that streamline the import and export of files and assets by being more efficient and interoperable.</p>
<p>Unlike other design tools, <strong>Penpot's format is built on standard languages</strong>. The exported file is essentially a ZIP archive containing binary assets (such as bitmap and vector images) alongside a readable JSON structure. By avoiding proprietary formats, Penpot empowers users with autonomy from specific tools while enabling seamless third-party integrations.</p>
<h3>Deprecated Penpot file formats</h3>
<p class="advice">These formats can only be exported from version 2.3 or earlier versions, but can be imported to any Penpot version</p>
<p>There are two different deprecated Penpot file formats in which you can import/export Penpot files. A standard one and a binary one. You always have the chance to use both for any file.</p>
<h4>[Deprecated] Penpot file (.penpot).</h4>
<p>The fast one. Binary Penpot specific.</p>
<ul>
<li>✅ Highly efficient in terms of memory and transfer time when exporting and importing.</li>
<li>❌ It can be opened only in Penpot.</li>
<li>❌ Not transparent, code difficult to explore.</li>
</ul>
<h4>[Deprecated] Standard file (.zip).</h4>
<p>The open one. A compressed file that includes SVG and JSON.</p>
<ul>
<li>✅ Allows the file to be opened by other softwares (still, for those cases export to SVG seems to be the common practice).</li>
<li>✅ Allows some automations and integrations.</li>
<li>✅ Is a transparent, existing, open standard format.</li>
<li>❌ Highly inefficient in terms of memory and transfer time when exporting and importing (this is because SVG).</li>
</ul>

View file

@ -307,6 +307,11 @@ title: Shortcuts
<td style="text-align: center;"><kbd>Shift</kbd><kbd>↑</kbd></td>
<td style="text-align: center;"><kbd>⇧</kbd><kbd>↑</kbd></td>
</tr>
<tr>
<td>Rename selected layer</td>
<td style="text-align: center;"><kbd>Alt</kbd><kbd>N</kbd></td>
<td style="text-align: center;"><kbd>⌥</kbd><kbd>N</kbd></td>
</tr>
<tr>
<td>Send backwards</td>
<td style="text-align: center;"><kbd>Ctrl</kbd><kbd>↓</kbd></td>
@ -424,11 +429,6 @@ title: Shortcuts
<td style="text-align: center;"><kbd>Alt</kbd><kbd>P</kbd></td>
<td style="text-align: center;"><kbd>⌥</kbd><kbd>P</kbd></td>
</tr>
<tr>
<td>History</td>
<td style="text-align: center;"><kbd>Alt</kbd><kbd>H</kbd></td>
<td style="text-align: center;"><kbd>⌥</kbd><kbd>H</kbd></td>
</tr>
<tr>
<td>Layers</td>
<td style="text-align: center;"><kbd>Alt</kbd><kbd>L</kbd></td>

View file

@ -156,3 +156,29 @@ title: 06· Styling
<source src="/img/styling/blur.mp4" type="video/mp4">
</video>
</figure>
<h2 id="blend">Opacity and blend</h2>
<p>Set the overal opacity for layers and their blend mode.</p>
<p>Blend allows you to control how a layer interacts with the layers beneath it, determining how pixels from the current layer are combined with pixels in the underlying layers. Use blend to achive various effects, such as shading, highlights, or creative visual styles.</p>
<figure>
<img alt="Layer blend and opacity" src="/img/styling/blend-opacity.webp"/>
</figure>
<p>Blend options available:</p>
<ul>
<li><strong>Normal</strong></li>
<li><strong>Darken</strong></li>
<li><strong>Multiply</strong></li>
<li><strong>Color burn</strong></li>
<li><strong>Lighten</strong></li>
<li><strong>Screen</strong></li>
<li><strong>Color dodge</strong></li>
<li><strong>Overlay</strong></li>
<li><strong>Soft light</strong></li>
<li><strong>Hard light</strong></li>
<li><strong>Difference</strong></li>
<li><strong>Exclusion</strong></li>
<li><strong>Hue</strong></li>
<li><strong>Saturation</strong></li>
<li><strong>Color</strong></li>
<li><strong>Luminosity</strong></li>
</ul>

View file

@ -36,9 +36,10 @@ member is allowed to do depends on their permissions.</p>
<h3>Team roles</h3>
<p>These are the team roles currently available at Penpot:</p>
<ul>
<li><strong>Owner:</strong> There's only one owner per team, the role is automatically assigned to the team creator. Owners have permissions to change every other member role, including transfering ownership. Owners can update team settings, invite members and delete teams.</strong></li>
<li><strong>Admin:</strong> Permissions to change every other member role except owners. Can invite members and update team settings.</strong></li>
<li><strong>Editor:</strong> Without permissions to change member roles, invite members or update team settings.</strong></li>
<li><strong>Viewer:</strong> Viewers can view, comment on and inspect files but will not be able to edit them, nor do they have permissions to manage team settings.</strong></li>
<li><strong>Editor:</strong> Editors can create, import, edit and manage files and libraries, but do not have permissions to manage team settings.</strong></li>
<li><strong>Admin:</strong> Admins have the same permissions as editors, with the added ability to change every other member's role except owners. They can invite members and update team settings.</strong></li>
<li><strong>Owner:</strong> There's only one owner per team, the role is automatically assigned to the team creator. Owners have all the permissions of admins, with the additional ability to change any member's role, including transferring ownership. Owners can update team settings, invite members and delete teams.</strong></li>
</ul>
<figure><img src="/img/teams/teams-permissions.webp" alt="Team members" /></figure>
<p class="advice">More team roles will be eventually available, as well as fine grained permissions management to control members access and actions.</p>

View file

@ -199,20 +199,57 @@ geometric structure. In Penpot there are three types of guides:
<img src="/img/workspace-basics/shortcuts.webp" alt="Shortcuts panel" />
</figure>
<h2 id="history">History</h2>
<p>The history panel keeps track of the latest changes on an opened file.</p>
<h2 id="history">File history versions</h2>
<p>The history panel keeps track of the latest changes on an opened file as well as the different versions of the file, making it easier to track changes, revert to previous states and collaborate.</p>
<h4>View history</h4>
<p>To view the recent history of a file at the workspace press <kbd>Ctrl/⌘</kbd> + <kbd>H</kbd> or click at the history icon on the toolbar at the left.</p>
<p>At the history you can see items with information about the last changes. At first sight you have object type (rectangle, text, image...) and type of change (New, Modified, Deleted...). If you press the item further details are shown.</p>
<h3>View history</h3>
<p>To view the recent history of a file at the workspace click the history icon on the navbar at the left:</p>
<ul>
<li>To see the history of file versions go to the <strong>History</strong> tab.</li>
<li>To see the history of item changes go to the <strong>Actions</strong> tab.</li>
</ul>
<figure>
<img src="/img/workspace-basics/history.webp" alt="History panel" />
<img src="/img/workspace-basics/history-view.webp" alt="History versions button" />
</figure>
<p><strong>Note:</strong> History panel is still in a very early state and shows only a limited list of changes at a current browser tab session. Refreshing the browser means refreshing the History as well. Eventually, Penpot will have a proper version history capacity.</p>
<h4>Navigate history</h4>
<p>To navigate through the history press <kbd>Ctrl/⌘</kbd> + <kbd>Z</kbd> to go backwards and <kbd>Ctrl/⌘</kbd> + <kbd>Shift/⇧</kbd> + <kbd>Z</kbd> to go forward.</p>
<p>You can also press any item of the history list to get to this specific state.</p>
<h3>History panel</h3>
<p>At the History panel, you can save the current version of your file, as well as access previous versions for up to 7 days.</p>
<h4>Restore versions</h4>
<p>All saved versions of the file—whether manually saved, autosaved, or pinned—can be restored, reverting the file back to its state at the selected time.</p>
<figure>
<img src="/img/workspace-basics/history-restore.webp" alt="Restore versions" />
</figure>
<h4>Saved versions</h4>
<p>You can save the current version of your file by clicking the pin icon at the History tab. This will allow the version to be named and it will add it to your list of versions.</p>
<figure>
<img src="/img/workspace-basics/history-save.webp" alt="Saved versions" />
</figure>
<h4>Autosaved versions</h4>
<p>When you start working on a file, Penpot will start to automatically save versions of that file across time so that you can later restore them as needed.</p>
<p>In the History tab, if you click on the autosaved versions, youll see a list of the exact date and time when the version was automatically saved.</p>
<figure>
<img src="/img/workspace-basics/history-autosaved.webp" alt="Autosaved versions" />
</figure>
<h4>Pinned versions</h4>
<p>File versions can also be pinned. Pinning a file version will allow you to name it, making it easier to access at the History tab. Pinned file versions will be saved forever and can be renamed, restored or deleted at any time.</p>
<figure>
<img src="/img/workspace-basics/history-pin.webp" alt="Pin versions" />
</figure>
<h3>Actions panel</h3>
<p>At the Actions panel, you have the object type (rectangle, text, image...) and type of change (New, Modified, Deleted...). If you press the item, it will be reverted to its state before that specific action was performed.</p>
<figure>
<img src="/img/workspace-basics/history-actions.webp" alt="Actions panel" />
</figure>
<p class="advice">The Actions panel shows only a limited list of changes at a current browser tab session. Refreshing the browser means refreshing the history of actions as well.</p>
<h4>Navigate actions</h4>
<p>To navigate through the actions press <kbd>Ctrl/⌘</kbd> + <kbd>Z</kbd> to go backwards and <kbd>Ctrl/⌘</kbd> + <kbd>Shift/⇧</kbd> + <kbd>Z</kbd> to go forward.</p>
<p>You can also press any item of the actions list to get to this specific state.</p>
<figure>
<video title="Navigate history" muted="" playsinline="" controls="" width="auto" poster="/img/workspace-basics/history-navigate.webp" height="auto">
<source src="/img/workspace-basics/history-navigate.mp4" type="video/mp4">

View file

@ -2,7 +2,7 @@
const config = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
staticDirs: ["../resources/public"],
addons: ["@storybook/addon-essentials", "@storybook/addon-themes"],
addons: ["@storybook/addon-essentials", "@storybook/addon-themes", "@storybook/addon-interactions"],
core: {
builder: "@storybook/builder-vite",
options: {

View file

@ -20,8 +20,8 @@
:git/url "https://github.com/funcool/beicon.git"}
funcool/rumext
{:git/tag "v2.14"
:git/sha "0016623"
{:git/tag "v2.15"
:git/sha "28783a7"
:git/url "https://github.com/funcool/rumext.git"}
instaparse/instaparse {:mvn/version "1.5.0"}
@ -43,7 +43,9 @@
{:extra-paths ["dev"]
:extra-deps
{thheller/shadow-cljs {:mvn/version "2.28.18"}
com.bhauman/rebel-readline {:mvn/version "RELEASE"}
org.clojure/tools.namespace {:mvn/version "RELEASE"}
criterium/criterium {:mvn/version "RELEASE"}
cider/cider-nrepl {:mvn/version "0.48.0"}}}
:shadow-cljs

34
frontend/dev/user.clj Normal file
View file

@ -0,0 +1,34 @@
;; 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 user
(:require
[app.common.data :as d]
[app.common.pprint :as pp]
[clojure.java.io :as io]
[clojure.tools.namespace.repl :as repl]
[clojure.pprint :refer [pprint print-table]]
[clojure.repl :refer :all]
[clojure.walk :refer [macroexpand-all]]
[criterium.core :as crit]))
;; --- Benchmarking Tools
(defmacro run-quick-bench
[& exprs]
`(crit/with-progress-reporting (crit/quick-bench (do ~@exprs) :verbose)))
(defmacro run-quick-bench'
[& exprs]
`(crit/quick-bench (do ~@exprs)))
(defmacro run-bench
[& exprs]
`(crit/with-progress-reporting (crit/bench (do ~@exprs) :verbose)))
(defmacro run-bench'
[& exprs]
`(crit/bench (do ~@exprs)))

View file

@ -48,11 +48,13 @@
},
"devDependencies": {
"@playwright/test": "1.48.1",
"@storybook/addon-essentials": "^8.3.6",
"@storybook/addon-themes": "^8.3.6",
"@storybook/blocks": "^8.3.6",
"@storybook/react": "^8.3.6",
"@storybook/react-vite": "^8.3.6",
"@storybook/addon-essentials": "^8.4.6",
"@storybook/addon-interactions": "^8.4.6",
"@storybook/addon-themes": "^8.4.6",
"@storybook/blocks": "^8.4.6",
"@storybook/react": "^8.4.6",
"@storybook/react-vite": "^8.4.6",
"@storybook/test": "^8.4.6",
"@types/node": "^22.7.7",
"autoprefixer": "^10.4.20",
"concurrently": "^9.0.1",
@ -86,7 +88,7 @@
"sass": "^1.80.3",
"sass-embedded": "^1.80.3",
"shadow-cljs": "2.28.18",
"storybook": "^8.3.6",
"storybook": "^8.4.6",
"svg-sprite": "^2.0.4",
"typescript": "^5.6.3",
"vite": "^5.4.9",

View file

@ -97,7 +97,10 @@
"~ue117f7f6-433c-807e-8004-862a18bba46f": {
"~#shape": {
"~:y": 220,
"~:rx": 0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,
@ -449,7 +452,10 @@
"~ue117f7f6-433c-807e-8004-862a8c166257": {
"~#shape": {
"~:y": 97,
"~:rx": 0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,

View file

@ -1,6 +1,6 @@
{
"~:id": "~ue5a24d1b-ef1e-812f-8004-52bab84be6f7",
"~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6",
"~:team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d",
"~:created-at": "~m1715266551088",
"~:modified-at": "~m1715266551088",
"~:is-default": false,

View file

@ -6,6 +6,7 @@
"~:modified-at": "~m1714045654874",
"~:name": "New File 2",
"~:revn": 1,
"~:thumbnail-id": "~u95d6fdd8-48d8-8148-8004-38af910d2dbe",
"~:is-shared": false
},
{
@ -15,6 +16,7 @@
"~:modified-at": "~m1713519762931",
"~:name": "New File 1",
"~:revn": 1,
"~:thumbnail-id": "~u95d6fdd8-48d8-8148-8004-38af910d2dbe",
"~:is-shared": false
}
]

View file

@ -111,7 +111,10 @@
"~ua30724ae-f8d8-8003-8004-69eca9b27c8c": {
"~#shape": {
"~:y": 168,
"~:rx": 0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,

View file

@ -0,0 +1,30 @@
[
{
"~:is-admin": true,
"~:email": "bar@example.com",
"~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3",
"~:name": "Han Solo",
"~:fullname": "Han Solo",
"~:is-owner": true,
"~:modified-at": "~m1713533116365",
"~:can-edit": true,
"~:is-active": true,
"~:id": "~u1e162163-87b7-805b-8005-5fd05514b6d3",
"~:profile-id": "~u1e162163-87b7-805b-8005-5fd05514b6d3",
"~:created-at": "~m1733324626956"
},
{
"~:is-admin": true,
"~:email": "foo@example.com",
"~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3",
"~:name": "Princesa Leia",
"~:fullname": "Princesa Leia",
"~:is-owner": false,
"~:modified-at": "~m1713533116365",
"~:can-edit": true,
"~:is-active": true,
"~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
"~:profile-id": "~uf56647eb-19a7-8115-8003-b6bc939ecd1b",
"~:created-at": "~m1713533116365"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -185,7 +185,10 @@
"~ub574c052-1a31-80bb-8004-75636a9b8205": {
"~#shape": {
"~:y": 136,
"~:rx": 0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,

View file

@ -127,7 +127,10 @@
"~u2ace9ce8-8e01-8086-8004-7ba745d4305a":{
"~#shape":{
"~:y":221,
"~:rx":0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform":{
"~#matrix":{
"~:a":1.0,

View file

@ -0,0 +1,23 @@
[{
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": false,
"~:is-admin": false,
"~:can-edit": false
},
"~:name": "Default",
"~:modified-at": "~m1713533116375",
"~:id": "~uc7ce0794-0992-8105-8004-38e630f7920a",
"~:created-at": "~m1713533116375",
"~:is-default": true
}]

View file

@ -0,0 +1,23 @@
[{
"~:features": {
"~#set": [
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true
},
"~:name": "Default",
"~:modified-at": "~m1713533116375",
"~:id": "~uc7ce0794-0992-8105-8004-38e630f7920a",
"~:created-at": "~m1713533116375",
"~:is-default": true
}]

View file

@ -35,7 +35,7 @@
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-owner": false,
"~:is-admin": true,
"~:can-edit": true
},

View file

@ -205,7 +205,10 @@
"~u86087f92-9a17-8067-8004-7cdec98dfa7f": {
"~#shape": {
"~:y": 375,
"~:rx": 0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,

View file

@ -183,7 +183,10 @@
"~u2e0995e6-d90f-80ed-8005-2fd17ece880a": {
"~#shape": {
"~:y": 221,
"~:rx": 0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,

View file

@ -97,7 +97,10 @@
"~u2e0995e6-d90f-80ed-8005-2fd0bd35e183": {
"~#shape": {
"~:y": 214,
"~:rx": 0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,

View file

@ -1674,7 +1674,10 @@
"~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd3": {
"~#shape": {
"~:y": 589.9999999999999,
"~:rx": 0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,

View file

@ -127,7 +127,10 @@
"~uc70224ec-c410-807b-8004-743400e00be8":{
"~#shape":{
"~:y":255,
"~:rx":0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform":{
"~#matrix":{
"~:a":1.0,

View file

@ -126,7 +126,10 @@
"~u7c75e310-c3a2-80fd-8004-7cc641479aef":{
"~#shape":{
"~:y":436,
"~:rx":0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform":{
"~#matrix":{
"~:a":1.0,

View file

@ -113,7 +113,10 @@
"~u2e0995e6-d90f-80ed-8005-2fd0bd35e183": {
"~#shape": {
"~:y": 214,
"~:rx": 0,
"~:r1": 0,
"~:r2": 0,
"~:r3": 0,
"~:r4": 0,
"~:transform": {
"~#matrix": {
"~:a": 1.0,

View file

@ -1 +1 @@
{"~:id":"~u406b7b01-d3e2-80e4-8005-3138b7cc5f0b","~:file-id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449c","~:created-at":"~m1730197748513","~:data":{"~:options":{},"~:objects":{"~u00000000-0000-0000-0000-000000000000":{"~#shape":{"~:y":0,"~:hide-fill-on-export":false,"~:transform":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:rotation":0,"~:name":"Root Frame","~:width":0.01,"~:type":"~:frame","~:points":[{"~#point":{"~:x":0.0,"~:y":0.0}},{"~#point":{"~:x":0.01,"~:y":0.0}},{"~#point":{"~:x":0.01,"~:y":0.01}},{"~#point":{"~:x":0.0,"~:y":0.01}}],"~:proportion-lock":false,"~:transform-inverse":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:id":"~u00000000-0000-0000-0000-000000000000","~:parent-id":"~u00000000-0000-0000-0000-000000000000","~:frame-id":"~u00000000-0000-0000-0000-000000000000","~:strokes":[],"~:x":0,"~:proportion":1.0,"~:selrect":{"~#rect":{"~:x":0,"~:y":0,"~:width":0.01,"~:height":0.01,"~:x1":0,"~:y1":0,"~:x2":0.01,"~:y2":0.01}},"~:fills":[{"~:fill-color":"#FFFFFF","~:fill-opacity":1}],"~:flip-x":null,"~:height":0.01,"~:flip-y":null,"~:shapes":["~ua88f39e6-60a5-80c2-8005-3138aeee944b"]}},"~ua88f39e6-60a5-80c2-8005-3138aeee944b":{"~#shape":{"~:y":427,"~:hide-fill-on-export":false,"~:transform":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:rotation":0,"~:grow-type":"~:fixed","~:hide-in-viewer":false,"~:name":"Board","~:width":551,"~:type":"~:frame","~:points":[{"~#point":{"~:x":637,"~:y":427}},{"~#point":{"~:x":1188,"~:y":427}},{"~#point":{"~:x":1188,"~:y":761}},{"~#point":{"~:x":637,"~:y":761}}],"~:proportion-lock":false,"~:transform-inverse":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:id":"~ua88f39e6-60a5-80c2-8005-3138aeee944b","~:parent-id":"~u00000000-0000-0000-0000-000000000000","~:frame-id":"~u00000000-0000-0000-0000-000000000000","~:strokes":[],"~:x":637,"~:proportion":1,"~:selrect":{"~#rect":{"~:x":637,"~:y":427,"~:width":551,"~:height":334,"~:x1":637,"~:y1":427,"~:x2":1188,"~:y2":761}},"~:fills":[{"~:fill-color":"#FFFFFF","~:fill-opacity":1}],"~:flip-x":null,"~:height":334,"~:flip-y":null,"~:shapes":["~ua88f39e6-60a5-80c2-8005-3138b4d36f07"]}},"~ua88f39e6-60a5-80c2-8005-3138b196dd95":{"~#shape":{"~:y":489,"~:rx":0,"~:transform":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:rotation":0,"~:grow-type":"~:fixed","~:hide-in-viewer":false,"~:name":"Rectangle","~:width":149,"~:type":"~:rect","~:points":[{"~#point":{"~:x":677,"~:y":489}},{"~#point":{"~:x":826,"~:y":489}},{"~#point":{"~:x":826,"~:y":629}},{"~#point":{"~:x":677,"~:y":629}}],"~:proportion-lock":false,"~:transform-inverse":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:id":"~ua88f39e6-60a5-80c2-8005-3138b196dd95","~:parent-id":"~ua88f39e6-60a5-80c2-8005-3138b4d36f07","~:frame-id":"~ua88f39e6-60a5-80c2-8005-3138aeee944b","~:strokes":[],"~:x":677,"~:proportion":1,"~:selrect":{"~#rect":{"~:x":677,"~:y":489,"~:width":149,"~:height":140,"~:x1":677,"~:y1":489,"~:x2":826,"~:y2":629}},"~:fills":[{"~:fill-color":"#B1B2B5","~:fill-opacity":1}],"~:flip-x":null,"~:ry":0,"~:height":140,"~:flip-y":null}},"~ua88f39e6-60a5-80c2-8005-3138b4d36f07":{"~#shape":{"~:y":489,"~:transform":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:rotation":0,"~:index":1,"~:name":"Group","~:width":149,"~:type":"~:group","~:points":[{"~#point":{"~:x":677,"~:y":489}},{"~#point":{"~:x":826,"~:y":489}},{"~#point":{"~:x":826,"~:y":629}},{"~#point":{"~:x":677,"~:y":629}}],"~:proportion-lock":false,"~:transform-inverse":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:id":"~ua88f39e6-60a5-80c2-8005-3138b4d36f07","~:parent-id":"~ua88f39e6-60a5-80c2-8005-3138aeee944b","~:frame-id":"~ua88f39e6-60a5-80c2-8005-3138aeee944b","~:strokes":[],"~:x":677,"~:proportion":1,"~:selrect":{"~#rect":{"~:x":677,"~:y":489,"~:width":149,"~:height":140,"~:x1":677,"~:y1":489,"~:x2":826,"~:y2":629}},"~:fills":[],"~:flip-x":null,"~:height":140,"~:flip-y":null,"~:shapes":["~ua88f39e6-60a5-80c2-8005-3138b196dd95"]}}},"~:id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449d","~:name":"Page 1"}}
{"~:id":"~u406b7b01-d3e2-80e4-8005-3138b7cc5f0b","~:file-id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449c","~:created-at":"~m1730197748513","~:data":{"~:options":{},"~:objects":{"~u00000000-0000-0000-0000-000000000000":{"~#shape":{"~:y":0,"~:hide-fill-on-export":false,"~:transform":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:rotation":0,"~:name":"Root Frame","~:width":0.01,"~:type":"~:frame","~:points":[{"~#point":{"~:x":0.0,"~:y":0.0}},{"~#point":{"~:x":0.01,"~:y":0.0}},{"~#point":{"~:x":0.01,"~:y":0.01}},{"~#point":{"~:x":0.0,"~:y":0.01}}],"~:proportion-lock":false,"~:transform-inverse":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:id":"~u00000000-0000-0000-0000-000000000000","~:parent-id":"~u00000000-0000-0000-0000-000000000000","~:frame-id":"~u00000000-0000-0000-0000-000000000000","~:strokes":[],"~:x":0,"~:proportion":1.0,"~:selrect":{"~#rect":{"~:x":0,"~:y":0,"~:width":0.01,"~:height":0.01,"~:x1":0,"~:y1":0,"~:x2":0.01,"~:y2":0.01}},"~:fills":[{"~:fill-color":"#FFFFFF","~:fill-opacity":1}],"~:flip-x":null,"~:height":0.01,"~:flip-y":null,"~:shapes":["~ua88f39e6-60a5-80c2-8005-3138aeee944b"]}},"~ua88f39e6-60a5-80c2-8005-3138aeee944b":{"~#shape":{"~:y":427,"~:hide-fill-on-export":false,"~:transform":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:rotation":0,"~:grow-type":"~:fixed","~:hide-in-viewer":false,"~:name":"Board","~:width":551,"~:type":"~:frame","~:points":[{"~#point":{"~:x":637,"~:y":427}},{"~#point":{"~:x":1188,"~:y":427}},{"~#point":{"~:x":1188,"~:y":761}},{"~#point":{"~:x":637,"~:y":761}}],"~:proportion-lock":false,"~:transform-inverse":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:id":"~ua88f39e6-60a5-80c2-8005-3138aeee944b","~:parent-id":"~u00000000-0000-0000-0000-000000000000","~:frame-id":"~u00000000-0000-0000-0000-000000000000","~:strokes":[],"~:x":637,"~:proportion":1,"~:selrect":{"~#rect":{"~:x":637,"~:y":427,"~:width":551,"~:height":334,"~:x1":637,"~:y1":427,"~:x2":1188,"~:y2":761}},"~:fills":[{"~:fill-color":"#FFFFFF","~:fill-opacity":1}],"~:flip-x":null,"~:height":334,"~:flip-y":null,"~:shapes":["~ua88f39e6-60a5-80c2-8005-3138b4d36f07"]}},"~ua88f39e6-60a5-80c2-8005-3138b196dd95":{"~#shape":{"~:y":489,"~:r1":0, "~:r2":0, "~:r3":0, "~:r4":0,"~:transform":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:rotation":0,"~:grow-type":"~:fixed","~:hide-in-viewer":false,"~:name":"Rectangle","~:width":149,"~:type":"~:rect","~:points":[{"~#point":{"~:x":677,"~:y":489}},{"~#point":{"~:x":826,"~:y":489}},{"~#point":{"~:x":826,"~:y":629}},{"~#point":{"~:x":677,"~:y":629}}],"~:proportion-lock":false,"~:transform-inverse":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:id":"~ua88f39e6-60a5-80c2-8005-3138b196dd95","~:parent-id":"~ua88f39e6-60a5-80c2-8005-3138b4d36f07","~:frame-id":"~ua88f39e6-60a5-80c2-8005-3138aeee944b","~:strokes":[],"~:x":677,"~:proportion":1,"~:selrect":{"~#rect":{"~:x":677,"~:y":489,"~:width":149,"~:height":140,"~:x1":677,"~:y1":489,"~:x2":826,"~:y2":629}},"~:fills":[{"~:fill-color":"#B1B2B5","~:fill-opacity":1}],"~:flip-x":null,"~:ry":0,"~:height":140,"~:flip-y":null}},"~ua88f39e6-60a5-80c2-8005-3138b4d36f07":{"~#shape":{"~:y":489,"~:transform":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:rotation":0,"~:index":1,"~:name":"Group","~:width":149,"~:type":"~:group","~:points":[{"~#point":{"~:x":677,"~:y":489}},{"~#point":{"~:x":826,"~:y":489}},{"~#point":{"~:x":826,"~:y":629}},{"~#point":{"~:x":677,"~:y":629}}],"~:proportion-lock":false,"~:transform-inverse":{"~#matrix":{"~:a":1.0,"~:b":0.0,"~:c":0.0,"~:d":1.0,"~:e":0.0,"~:f":0.0}},"~:id":"~ua88f39e6-60a5-80c2-8005-3138b4d36f07","~:parent-id":"~ua88f39e6-60a5-80c2-8005-3138aeee944b","~:frame-id":"~ua88f39e6-60a5-80c2-8005-3138aeee944b","~:strokes":[],"~:x":677,"~:proportion":1,"~:selrect":{"~#rect":{"~:x":677,"~:y":489,"~:width":149,"~:height":140,"~:x1":677,"~:y1":489,"~:x2":826,"~:y2":629}},"~:fills":[],"~:flip-x":null,"~:height":140,"~:flip-y":null,"~:shapes":["~ua88f39e6-60a5-80c2-8005-3138b196dd95"]}}},"~:id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449d","~:name":"Page 1"}}

View file

@ -110,6 +110,10 @@ export class DashboardPage extends BaseWebSocketPage {
"get-project-files?project-id=*",
"dashboard/get-project-files.json",
);
await this.mockRPC(/assets\/by-id/gi, "dashboard/thumbnail.png", {
contentType: "image/png",
});
}
async setupNewProject() {
@ -207,60 +211,64 @@ export class DashboardPage extends BaseWebSocketPage {
async goToDashboard() {
await this.page.goto(
`#/dashboard/team/${DashboardPage.anyTeamId}/projects`,
`#/dashboard/recent?team-id=${DashboardPage.anyTeamId}`,
);
await expect(this.mainHeading).toBeVisible();
}
async goToSecondTeamDashboard() {
await this.page.goto(
`#/dashboard/team/${DashboardPage.secondTeamId}/projects`,
`#/dashboard/recent?team-id=${DashboardPage.secondTeamId}`,
);
}
async goToSecondTeamMembersSection() {
await this.page.goto(
`#/dashboard/team/${DashboardPage.secondTeamId}/members`,
`#/dashboard/members?team-id=${DashboardPage.secondTeamId}`,
);
}
async goToSecondTeamInvitationsSection() {
await this.page.goto(
`#/dashboard/team/${DashboardPage.secondTeamId}/invitations`,
`#/dashboard/invitations?team-id=${DashboardPage.secondTeamId}`,
);
}
async goToSecondTeamWebhooksSection() {
await this.page.goto(
`#/dashboard/team/${DashboardPage.secondTeamId}/webhooks`,
`#/dashboard/webhooks?team-id=${DashboardPage.secondTeamId}`,
);
}
async goToSecondTeamWebhooksSection() {
await this.page.goto(
`#/dashboard/team/${DashboardPage.secondTeamId}/webhooks`,
`#/dashboard/webhooks?team-id=${DashboardPage.secondTeamId}`,
);
}
async goToSecondTeamSettingsSection() {
await this.page.goto(
`#/dashboard/team/${DashboardPage.secondTeamId}/settings`,
`#/dashboard/settings?team-id=${DashboardPage.secondTeamId}`,
);
}
async goToSearch() {
await this.page.goto(`#/dashboard/team/${DashboardPage.anyTeamId}/search`);
await this.page.goto(
`#/dashboard/search?team-id=${DashboardPage.anyTeamId}`,
);
}
async goToDrafts() {
await this.page.goto(
`#/dashboard/team/${DashboardPage.anyTeamId}/projects/${DashboardPage.draftProjectId}`,
`#/dashboard/files?team-id=${DashboardPage.anyTeamId}&project-id=${DashboardPage.draftProjectId}`,
);
await expect(this.mainHeading).toHaveText("Drafts");
}
async goToFonts() {
await this.page.goto(`#/dashboard/team/${DashboardPage.anyTeamId}/fonts`);
await this.page.goto(
`#/dashboard/fonts?team-id=${DashboardPage.anyTeamId}`,
);
await expect(this.mainHeading).toHaveText("Fonts");
}

View file

@ -85,7 +85,7 @@ export class ViewerPage extends BaseWebSocketPage {
pageId = ViewerPage.anyPageId,
} = {}) {
await this.page.goto(
`/#/view/${fileId}?page-id=${pageId}&section=interactions&index=0`,
`/#/view?file-id=${fileId}&page-id=${pageId}&section=interactions&index=0`,
);
this.#ws = await this.waitForNotificationsWebSocket();

View file

@ -36,6 +36,14 @@ export class WorkspacePage extends BaseWebSocketPage {
"get-team?id=*",
"workspace/get-team-default.json",
);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams.json");
await BaseWebSocketPage.mockRPC(
page,
"get-team-members?team-id=*",
"logged-in-user/get-team-members-your-penpot.json",
);
await BaseWebSocketPage.mockRPC(
page,
"get-profiles-for-file-comments?file-id=*",
@ -43,6 +51,7 @@ export class WorkspacePage extends BaseWebSocketPage {
);
}
static anyTeamId = "c7ce0794-0992-8105-8004-38e630f7920a";
static anyProjectId = "c7ce0794-0992-8105-8004-38e630f7920b";
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a";
@ -89,7 +98,7 @@ export class WorkspacePage extends BaseWebSocketPage {
pageId = WorkspacePage.anyPageId,
} = {}) {
await this.page.goto(
`/#/workspace/${WorkspacePage.anyProjectId}/${fileId}?page-id=${pageId}`,
`/#/workspace?team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
);
this.#ws = await this.waitForNotificationsWebSocket();

View file

@ -96,3 +96,20 @@ test("User has add font button", async ({ page }) => {
await dashboardPage.goToFonts();
await expect(dashboardPage.page.getByText("add custom font")).toBeVisible();
});
test("Bug 9443, Admin can not demote owner", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.setupDashboardFull();
await DashboardPage.mockRPC(
page,
"get-team-members?team-id=*",
"dashboard/get-team-members-admin.json",
);
await dashboardPage.goToSecondTeamMembersSection();
await expect(page.getByRole("heading", { name: "Members" })).toBeVisible();
await expect(page.getByRole("combobox", { name: "Admin" })).toBeVisible();
await expect(page.getByText("Owner")).toBeVisible();
await expect(page.getByRole("combobox", { name: "Owner" })).toHaveCount(0);
});

View file

@ -30,7 +30,7 @@ test("Save and restore version", async ({ page }) => {
"workspace/versions-snapshot-1.json",
);
await page.getByLabel("History (Alt+H)").click();
await page.getByLabel("History").click();
await workspacePage.mockRPC(
"create-file-snapshot",
@ -58,10 +58,11 @@ test("Save and restore version", async ({ page }) => {
await page.getByRole("textbox").press("Enter");
await page
.locator("li")
.filter({ hasText: "INIT" })
.getByRole("button")
.click();
.getByLabel("History", { exact: true })
.locator("div")
.nth(3)
.hover();
await page.getByRole("button", { name: "Open version menu" }).click();
await page.getByRole("button", { name: "Restore" }).click();
await workspacePage.mockRPC(
@ -70,4 +71,7 @@ test("Save and restore version", async ({ page }) => {
);
await page.getByRole("button", { name: "Restore" }).click();
// check that the history panel is closed after restore
await expect(page.getByRole("tab", { name: "design" })).toBeVisible();
});

View file

@ -7,11 +7,7 @@ test.beforeEach(async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await WorkspacePage.mockRPC(
page,
"get-team?id=*",
"workspace/get-team-role-viewer.json",
);
await WorkspacePage.mockRPC(page, "get-teams", "get-teams-role-viewer.json");
await workspacePage.goToWorkspace();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.5 3.5h-2m2 0v-2m0 2h9m-9 0v9m9-9v-2m0 2h2m-2 0v9m0 0h2m-2 0v2m0-2h-9m0 0v2m0-2h-2"/>
</svg>

After

Width:  |  Height:  |  Size: 214 B

View file

@ -41,13 +41,6 @@ body {
scrollbar-width: thin;
}
// Firefox-only hack
@-moz-document url-prefix() {
* {
scrollbar-width: auto;
}
}
img {
height: auto;
width: 100%;

View file

@ -237,13 +237,18 @@ async function renderTemplate(path, context = {}, partials = {}) {
return mustache.render(content, context, partials);
}
const renderer = {
link(href, title, text) {
return `<a href="${href}" target="_blank">${text}</a>`;
const extension = {
useNewRenderer: true,
renderer: {
link(token) {
const href = token.href;
const text = token.text;
return `<a href="${href}" target="_blank">${text}</a>`;
},
},
};
marked.use({ renderer });
marked.use(extension);
async function readTranslations() {
const langs = [
@ -503,6 +508,7 @@ export async function compileStyles() {
const start = process.hrtime();
log.info("init: compile styles");
let result = await compileSassAll(worker);
result = concatSass(result);

6
frontend/scripts/repl Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
export OPTIONS="-A:dev -J-XX:-OmitStackTraceInFastThrow";
set -ex
exec clojure $OPTIONS -M -m rebel-readline.main

View file

@ -24,15 +24,22 @@ async function compileSassAll() {
async function compileSass(path) {
const start = process.hrtime();
log.info("changed:", path);
const result = await h.compileSass(worker, path, { modules: true });
sass.index[result.outputPath] = result.css;
const output = h.concatSass(sass);
try {
const result = await h.compileSass(worker, path, { modules: true });
sass.index[result.outputPath] = result.css;
await fs.writeFile("./resources/public/css/main.css", output);
const output = h.concatSass(sass);
const end = process.hrtime(start);
log.info("done:", `(${ppt(end)})`);
await fs.writeFile("./resources/public/css/main.css", output);
const end = process.hrtime(start);
log.info("done:", `(${ppt(end)})`);
} catch (cause) {
console.error(cause);
const end = process.hrtime(start);
log.error("error:", `(${ppt(end)})`);
}
}
await fs.mkdir("./resources/public/css/", { recursive: true });

Some files were not shown because too many files have changed in this diff Show more