diff --git a/CHANGES.md b/CHANGES.md index 1e02ac6c45..06a817c131 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -94,11 +94,12 @@ - Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961) -## 2.14.3 (Unreleased) +## 2.14.3 ### :sparkles: New features & Enhancements - Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870) +- Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957) ### :bug: Bugs fixed @@ -111,6 +112,16 @@ - Fix path drawing preview passing shape instead of content to next-node - Fix swapped arguments in CLJS PathData `-nth` with default - Normalize PathData coordinates to safe integer bounds on read +- Fix RangeError from re-entrant error handling causing stack overflow [Github #8962](https://github.com/penpot/penpot/pull/8962) +- Fix builder bool styles and media validation [Github #8963](https://github.com/penpot/penpot/pull/8963) +- Fix "Move to" menu allowing same project as target when multiple files are selected +- Fix crash when index query param is duplicated in URL +- Fix wrong extremity point in path `calculate-extremities` for line-to segments +- Fix reversed args in DTCG shadow composite token conversion +- Fix `inside-layout?` passing shape id instead of shape to `frame-shape?` +- Fix wrong `mapcat` call in `collect-main-shapes` +- Fix stale accumulator in `get-children-in-instance` recursion +- Fix typo `:podition` in swap-shapes grid cell ## 2.14.2 diff --git a/backend/AGENTS.md b/backend/AGENTS.md index b4ac2ac1dd..913540f808 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -83,7 +83,52 @@ are config maps with `::ig/ref` for dependencies. Components implement `ig/init-key` / `ig/halt-key!`. -### Database Access +### Connecting to the Database + +Two PostgreSQL databases are used in this environment: + +| Database | Purpose | Connection string | +|---------------|--------------------|----------------------------------------------------| +| `penpot` | Development / app | `postgresql://penpot:penpot@postgres/penpot` | +| `penpot_test` | Test suite | `postgresql://penpot:penpot@postgres/penpot_test` | + +**Interactive psql session:** + +```bash +# development DB +psql "postgresql://penpot:penpot@postgres/penpot" + +# test DB +psql "postgresql://penpot:penpot@postgres/penpot_test" +``` + +**One-shot query (non-interactive):** + +```bash +psql "postgresql://penpot:penpot@postgres/penpot" -c "SELECT id, name FROM team LIMIT 5;" +``` + +**Useful psql meta-commands:** + +``` +\dt -- list all tables +\d -- describe a table (columns, types, constraints) +\di -- list indexes +\q -- quit +``` + +> **Migrations table:** Applied migrations are tracked in the `migrations` table +> with columns `module`, `step`, and `created_at`. When renaming a migration +> logical name, update this table in both databases to match the new name; +> otherwise the runner will attempt to re-apply the migration on next startup. + +```bash +# Example: fix a renamed migration entry in the test DB +psql "postgresql://penpot:penpot@postgres/penpot_test" \ + -c "UPDATE migrations SET step = 'new-name' WHERE step = 'old-name';" +``` + +### Database Access (Clojure) `app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case. @@ -146,3 +191,69 @@ optimized implementations: `src/app/config.clj` reads `PENPOT_*` environment variables, validated with Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags :enable-smtp)`. + + +### Background Tasks + +Background tasks live in `src/app/tasks/`. Each task is an Integrant component +that exposes a `::handler` key and follows this three-method pattern: + +```clojure +(defmethod ig/assert-key ::handler ;; validate config at startup + [_ params] + (assert (db/pool? (::db/pool params)) "expected a valid database pool")) + +(defmethod ig/expand-key ::handler ;; inject defaults before init + [k v] + {k (assoc v ::my-option default-value)}) + +(defmethod ig/init-key ::handler ;; return the task fn + [_ cfg] + (fn [_task] ;; receives the task row from the worker + (db/tx-run! cfg (fn [{:keys [::db/conn]}] + ;; … do work … + )))) +``` + +**Wiring a new task** requires two changes in `src/app/main.clj`: + +1. **Handler config** – add an entry in `system-config` with the dependencies: + +```clojure +:app.tasks.my-task/handler +{::db/pool (ig/ref ::db/pool)} +``` + +2. **Registry + cron** – register the handler name and schedule it: + +```clojure +;; in ::wrk/registry ::wrk/tasks map: +:my-task (ig/ref :app.tasks.my-task/handler) + +;; in worker-config ::wrk/cron ::wrk/entries vector: +{:cron #penpot/cron "0 0 0 * * ?" ;; daily at midnight + :task :my-task} +``` + +**Useful cron patterns** (Quartz format — six fields: s m h dom mon dow): + +| Expression | Meaning | +|------------------------------|--------------------| +| `"0 0 0 * * ?"` | Daily at midnight | +| `"0 0 */6 * * ?"` | Every 6 hours | +| `"0 */5 * * * ?"` | Every 5 minutes | + +**Time helpers** (`app.common.time`): + +```clojure +(ct/now) ;; current instant +(ct/duration {:hours 1}) ;; java.time.Duration +(ct/minus (ct/now) some-duration) ;; subtract duration from instant +``` + +`db/interval` converts a `Duration` (or millis / string) to a PostgreSQL +interval object suitable for use in SQL queries: + +```clojure +(db/interval (ct/duration {:hours 1})) ;; → PGInterval "3600.0 seconds" +``` diff --git a/backend/scripts/_env b/backend/scripts/_env index f57c6121ec..a18cbc2896 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -45,6 +45,10 @@ export PENPOT_FLAGS="\ enable-redis-cache \ enable-subscriptions"; +# Uncomment for nexus integration testing +# export PENPOT_FLAGS="$PENPOT_FLAGS enable-audit-log-archive"; +# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"; + # Default deletion delay for devenv export PENPOT_DELETION_DELAY="24h" diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index e9462e2c85..246d440c73 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -82,7 +82,10 @@ :initial-project-skey "initial-project" ;; time to avoid email sending after profile modification - :email-verify-threshold "15m"}) + :email-verify-threshold "15m" + + :quotes-upload-sessions-per-profile 5 + :quotes-upload-chunks-per-session 20}) (def schema:config (do #_sm/optional-keys @@ -154,6 +157,8 @@ [:quotes-snapshots-per-team {:optional true} ::sm/int] [:quotes-team-access-requests-per-team {:optional true} ::sm/int] [:quotes-team-access-requests-per-requester {:optional true} ::sm/int] + [:quotes-upload-sessions-per-profile {:optional true} ::sm/int] + [:quotes-upload-chunks-per-session {:optional true} ::sm/int] [:auth-token-cookie-name {:optional true} :string] [:auth-token-cookie-max-age {:optional true} ::ct/duration] diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 62024e573b..1915652bbd 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -81,7 +81,7 @@ (def ^:private sql:get-audit-log-chunk "SELECT * FROM audit_log - WHERE archived_at is null + WHERE archived_at IS NULL ORDER BY created_at ASC LIMIT 128 FOR UPDATE diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 383578531e..a1501e3ca0 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -388,6 +388,7 @@ :offload-file-data (ig/ref :app.tasks.offload-file-data/handler) :tasks-gc (ig/ref :app.tasks.tasks-gc/handler) :telemetry (ig/ref :app.tasks.telemetry/handler) + :upload-session-gc (ig/ref :app.tasks.upload-session-gc/handler) :storage-gc-deleted (ig/ref ::sto.gc-deleted/handler) :storage-gc-touched (ig/ref ::sto.gc-touched/handler) :session-gc (ig/ref ::session.tasks/gc) @@ -423,6 +424,9 @@ :app.tasks.tasks-gc/handler {::db/pool (ig/ref ::db/pool)} + :app.tasks.upload-session-gc/handler + {::db/pool (ig/ref ::db/pool)} + :app.tasks.objects-gc/handler {::db/pool (ig/ref ::db/pool) ::sto/storage (ig/ref ::sto/storage)} @@ -544,6 +548,9 @@ {:cron #penpot/cron "0 0 0 * * ?" ;; daily :task :tasks-gc} + {:cron #penpot/cron "0 0 0 * * ?" ;; daily + :task :upload-session-gc} + {:cron #penpot/cron "0 0 2 * * ?" ;; daily :task :file-gc-scheduler} diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 2551f29fff..7554618cdd 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -465,12 +465,17 @@ {:name "0145-fix-plugins-uri-on-profile" :fn mg0145/migrate} + {:name "0145-mod-audit-log-table" + :fn (mg/resource "app/migrations/sql/0145-mod-audit-log-table.sql")} + {:name "0146-mod-access-token-table" :fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")} {:name "0147-mod-team-invitation-table" - :fn (mg/resource "app/migrations/sql/0147-mod-team-invitation-table.sql")}]) + :fn (mg/resource "app/migrations/sql/0147-mod-team-invitation-table.sql")} + {:name "0147-add-upload-session-table" + :fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0145-mod-audit-log-table.sql b/backend/src/app/migrations/sql/0145-mod-audit-log-table.sql new file mode 100644 index 0000000000..6d95ecc6af --- /dev/null +++ b/backend/src/app/migrations/sql/0145-mod-audit-log-table.sql @@ -0,0 +1,2 @@ +CREATE INDEX audit_log__created_at__idx ON audit_log(created_at) WHERE archived_at IS NULL; +CREATE INDEX audit_log__archived_at__idx ON audit_log(archived_at) WHERE archived_at IS NOT NULL; diff --git a/backend/src/app/migrations/sql/0147-add-upload-session-table.sql b/backend/src/app/migrations/sql/0147-add-upload-session-table.sql new file mode 100644 index 0000000000..eda1964785 --- /dev/null +++ b/backend/src/app/migrations/sql/0147-add-upload-session-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE upload_session ( + id uuid PRIMARY KEY, + + created_at timestamptz NOT NULL DEFAULT now(), + + profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + total_chunks integer NOT NULL +); + +CREATE INDEX upload_session__profile_id__idx + ON upload_session(profile_id); + +CREATE INDEX upload_session__created_at__idx + ON upload_session(created_at); diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index dbb04a989e..c3d5cdf7eb 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -448,6 +448,7 @@ (when (:create-welcome-file params) (let [cfg (dissoc cfg ::db/conn)] (wrk/submit! executor (create-welcome-file cfg profile)))))] + (cond ;; When profile is blocked, we just ignore it and return plain data (:is-blocked profile) @@ -455,7 +456,8 @@ (l/wrn :hint "register attempt for already blocked profile" :profile-id (str (:id profile)) :profile-email (:email profile)) - (rph/with-meta {:email (:email profile)} + (rph/with-meta {:id (:id profile) + :email (:email profile)} {::audit/replace-props props ::audit/context {:action "ignore-because-blocked"} ::audit/profile-id (:id profile) @@ -471,7 +473,9 @@ (:member-email invitation))) (let [invitation (assoc invitation :member-id (:id profile)) token (tokens/generate cfg invitation)] - (-> {:invitation-token token} + (-> {:id (:id profile) + :email (:email profile) + :invitation-token token} (rph/with-transform (session/create-fn cfg profile claims)) (rph/with-meta {::audit/replace-props props ::audit/context {:action "accept-invitation"} @@ -494,7 +498,8 @@ (when-not (eml/has-reports? conn (:email profile)) (send-email-verification! cfg profile)) - (-> {:email (:email profile)} + (-> {:id (:id profile) + :email (:email profile)} (rph/with-defer create-welcome-file-when-needed) (rph/with-meta {::audit/replace-props props @@ -521,7 +526,8 @@ {:id (:id profile)}) (send-email-verification! cfg profile)) - (rph/with-meta {:email (:email profile)} + (rph/with-meta {:email (:email profile) + :id (:id profile)} {::audit/replace-props (audit/profile->props profile) ::audit/context {:action action} ::audit/profile-id (:id profile) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 743303c6a2..adb942bec8 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -22,6 +22,7 @@ [app.media :as media] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] + [app.rpc.commands.media :as media-cmd] [app.rpc.commands.projects :as projects] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] @@ -80,20 +81,33 @@ ;; --- Command: import-binfile (defn- import-binfile - [{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file]}] - (let [team (teams/get-team pool - :profile-id profile-id - :project-id project-id) - cfg (-> cfg - (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) - (assoc ::bfc/project-id project-id) - (assoc ::bfc/profile-id profile-id) - (assoc ::bfc/name name) - (assoc ::bfc/input (:path file))) + [{:keys [::db/pool] :as cfg} {:keys [profile-id project-id version name file upload-id]}] + (let [team + (teams/get-team pool + :profile-id profile-id + :project-id project-id) - result (case (int version) - 1 (bf.v1/import-files! cfg) - 3 (bf.v3/import-files! cfg))] + cfg + (-> cfg + (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) + (assoc ::bfc/project-id project-id) + (assoc ::bfc/profile-id profile-id) + (assoc ::bfc/name name)) + + input-path (:path file) + owned? (some? upload-id) + + cfg + (assoc cfg ::bfc/input input-path) + + result + (try + (case (int version) + 1 (bf.v1/import-files! cfg) + 3 (bf.v3/import-files! cfg)) + (finally + (when owned? + (fs/delete input-path))))] (db/update! pool :project {:modified-at (ct/now)} @@ -103,13 +117,18 @@ result)) (def ^:private schema:import-binfile - [:map {:title "import-binfile"} - [:name [:or [:string {:max 250}] - [:map-of ::sm/uuid [:string {:max 250}]]]] - [:project-id ::sm/uuid] - [:file-id {:optional true} ::sm/uuid] - [:version {:optional true} ::sm/int] - [:file media/schema:upload]]) + [:and + [:map {:title "import-binfile"} + [:name [:or [:string {:max 250}] + [:map-of ::sm/uuid [:string {:max 250}]]]] + [:project-id ::sm/uuid] + [:file-id {:optional true} ::sm/uuid] + [:version {:optional true} ::sm/int] + [:file {:optional true} media/schema:upload] + [:upload-id {:optional true} ::sm/uuid]] + [:fn {:error/message "one of :file or :upload-id is required"} + (fn [{:keys [file upload-id]}] + (or (some? file) (some? upload-id)))]]) (sv/defmethod ::import-binfile "Import a penpot file in a binary format. If `file-id` is provided, @@ -117,28 +136,40 @@ The in-place imports are only supported for binfile-v3 and when a .penpot file only contains one penpot file. + + The file content may be provided either as a multipart `file` upload + or as an `upload-id` referencing a completed chunked-upload session, + which allows importing files larger than the multipart size limit. " {::doc/added "1.15" ::doc/changes ["1.20" "Add file-id param for in-place import" - "1.20" "Set default version to 3"] + "1.20" "Set default version to 3" + "2.15" "Add upload-id param for chunked upload support"] ::webhooks/event? true ::sse/stream? true ::sm/params schema:import-binfile} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id file] :as params}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}] (projects/check-edition-permissions! pool profile-id project-id) - (let [version (or version 3) - params (-> params - (assoc :profile-id profile-id) - (assoc :version version)) + (let [version (or version 3) + params (-> params + (assoc :profile-id profile-id) + (assoc :version version)) - cfg (cond-> cfg - (uuid? file-id) - (assoc ::bfc/file-id file-id)) + cfg (cond-> cfg + (uuid? file-id) + (assoc ::bfc/file-id file-id)) - manifest (case (int version) - 1 nil - 3 (bf.v3/get-manifest (:path file)))] + params + (if (some? upload-id) + (let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)] + (assoc params :file file)) + params) + + manifest + (case (int version) + 1 nil + 3 (bf.v3/get-manifest (-> params :file :path)))] (with-meta (sse/response (partial import-binfile cfg params)) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 80e49b6366..5bea17d379 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -7,9 +7,11 @@ (ns app.rpc.commands.media (:require [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.loggers.audit :as-alias audit] [app.media :as media] @@ -17,8 +19,13 @@ [app.rpc.climit :as climit] [app.rpc.commands.files :as files] [app.rpc.doc :as-alias doc] + [app.rpc.quotes :as quotes] [app.storage :as sto] - [app.util.services :as sv])) + [app.storage.tmp :as tmp] + [app.util.services :as sv] + [datoteka.io :as io]) + (:import + java.io.OutputStream)) (def thumbnail-options {:width 100 @@ -236,3 +243,182 @@ :width (:width mobj) :height (:height mobj) :mtype (:mtype mobj)}))) + +;; --- Chunked Upload: Create an upload session + +(def ^:private schema:create-upload-session + [:map {:title "create-upload-session"} + [:total-chunks ::sm/int]]) + +(def ^:private schema:create-upload-session-result + [:map {:title "create-upload-session-result"} + [:session-id ::sm/uuid]]) + +(sv/defmethod ::create-upload-session + {::doc/added "2.16" + ::sm/params schema:create-upload-session + ::sm/result schema:create-upload-session-result} + [{:keys [::db/pool] :as cfg} + {:keys [::rpc/profile-id total-chunks]}] + + (let [max-chunks (cf/get :quotes-upload-chunks-per-session)] + (when (> total-chunks max-chunks) + (ex/raise :type :restriction + :code :max-quote-reached + :target "upload-chunks-per-session" + :quote max-chunks + :count total-chunks))) + + (quotes/check! cfg {::quotes/id ::quotes/upload-sessions-per-profile + ::quotes/profile-id profile-id}) + + (let [session-id (uuid/next)] + (db/insert! pool :upload-session + {:id session-id + :profile-id profile-id + :total-chunks total-chunks}) + {:session-id session-id})) + +;; --- Chunked Upload: Upload a single chunk + +(def ^:private schema:upload-chunk + [:map {:title "upload-chunk"} + [:session-id ::sm/uuid] + [:index ::sm/int] + [:content media/schema:upload]]) + +(def ^:private schema:upload-chunk-result + [:map {:title "upload-chunk-result"} + [:session-id ::sm/uuid] + [:index ::sm/int]]) + +(sv/defmethod ::upload-chunk + {::doc/added "2.16" + ::sm/params schema:upload-chunk + ::sm/result schema:upload-chunk-result} + [{:keys [::db/pool] :as cfg} + {:keys [::rpc/profile-id session-id index content] :as _params}] + (let [session (db/get pool :upload-session {:id session-id :profile-id profile-id})] + (when (or (neg? index) (>= index (:total-chunks session))) + (ex/raise :type :validation + :code :invalid-chunk-index + :hint "chunk index is out of range for this session" + :session-id session-id + :total-chunks (:total-chunks session) + :index index))) + + (let [storage (sto/resolve cfg) + data (sto/content (:path content))] + (sto/put-object! storage + {::sto/content data + ::sto/deduplicate? false + ::sto/touch true + :content-type (:mtype content) + :bucket "tempfile" + :upload-id (str session-id) + :chunk-index index})) + + {:session-id session-id + :index index}) + +;; --- Chunked Upload: shared helpers + +(def ^:private sql:get-upload-chunks + "SELECT id, size, (metadata->>'~:chunk-index')::integer AS chunk_index + FROM storage_object + WHERE (metadata->>'~:upload-id') = ?::text + AND deleted_at IS NULL + ORDER BY (metadata->>'~:chunk-index')::integer ASC") + +(defn- get-upload-chunks + [conn session-id] + (db/exec! conn [sql:get-upload-chunks (str session-id)])) + +(defn- concat-chunks + "Reads all chunk storage objects in order and writes them to a single + temporary file on the local filesystem. Returns a path to that file." + [storage chunks] + (let [tmp (tmp/tempfile :prefix "penpot.chunked-upload.")] + (with-open [^OutputStream out (io/output-stream tmp)] + (doseq [{:keys [id]} chunks] + (let [sobj (sto/get-object storage id) + bytes (sto/get-object-bytes storage sobj)] + (.write out ^bytes bytes)))) + tmp)) + +(defn assemble-chunks + "Validates that all expected chunks are present for `session-id` and + concatenates them into a single temporary file. Returns a map + conforming to `media/schema:upload` with `:filename`, `:path` and + `:size`. + + Raises a :validation/:missing-chunks error when the number of stored + chunks does not match `:total-chunks` recorded in the session row. + Deletes the session row from `upload_session` on success." + [{:keys [::db/conn] :as cfg} session-id] + (let [session (db/get conn :upload-session {:id session-id}) + chunks (get-upload-chunks conn session-id)] + + (when (not= (count chunks) (:total-chunks session)) + (ex/raise :type :validation + :code :missing-chunks + :hint "number of stored chunks does not match expected total" + :session-id session-id + :expected (:total-chunks session) + :found (count chunks))) + + (let [storage (sto/resolve cfg ::db/reuse-conn true) + path (concat-chunks storage chunks) + size (reduce #(+ %1 (:size %2)) 0 chunks)] + + (db/delete! conn :upload-session {:id session-id}) + + {:filename "upload" + :path path + :size size}))) + +;; --- Chunked Upload: Assemble all chunks into a final media object + +(def ^:private schema:assemble-file-media-object + [:map {:title "assemble-file-media-object"} + [:session-id ::sm/uuid] + [:file-id ::sm/uuid] + [:is-local ::sm/boolean] + [:name [:string {:max 250}]] + [:mtype :string] + [:id {:optional true} ::sm/uuid]]) + +(sv/defmethod ::assemble-file-media-object + {::doc/added "2.16" + ::sm/params schema:assemble-file-media-object + ::climit/id [[:process-image/by-profile ::rpc/profile-id] + [:process-image/global]]} + [{:keys [::db/pool] :as cfg} + {:keys [::rpc/profile-id session-id file-id is-local name mtype id] :as params}] + (files/check-edition-permissions! pool profile-id file-id) + + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (let [{:keys [path size]} (assemble-chunks cfg session-id) + content {:filename "upload" + :size size + :path path + :mtype mtype} + _ (media/validate-media-type! content) + mobj (create-file-media-object cfg (assoc params + :id (or id (uuid/next)) + :content content))] + + (db/update! conn :file + {:modified-at (ct/now) + :has-media-trimmed false} + {:id file-id} + {::db/return-keys false}) + + (with-meta mobj + {::audit/replace-props + {:name name + :file-id file-id + :is-local is-local + :mtype mtype}}))))) + diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj index d5903744b3..1fe00e62d9 100644 --- a/backend/src/app/rpc/quotes.clj +++ b/backend/src/app/rpc/quotes.clj @@ -522,6 +522,30 @@ (assoc ::count-sql [sql:get-team-access-requests-per-requester profile-id]) (generic-check!))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; QUOTE: UPLOAD-SESSIONS-PER-PROFILE +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:upload-sessions-per-profile + [:map [::profile-id ::sm/uuid]]) + +(def ^:private valid-upload-sessions-per-profile-quote? + (sm/lazy-validator schema:upload-sessions-per-profile)) + +(def ^:private sql:get-upload-sessions-per-profile + "SELECT count(*) AS total + FROM upload_session + WHERE profile_id = ?") + +(defmethod check-quote ::upload-sessions-per-profile + [{:keys [::profile-id ::target] :as quote}] + (assert (valid-upload-sessions-per-profile-quote? quote) "invalid quote parameters") + (-> quote + (assoc ::default (cf/get :quotes-upload-sessions-per-profile Integer/MAX_VALUE)) + (assoc ::quote-sql [sql:get-quotes-1 target profile-id]) + (assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id]) + (generic-check!))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: DEFAULT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index fa3e144ef9..f00140d04e 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -149,7 +149,7 @@ :status "delete" :bucket bucket) (recur to-freeze (conj to-delete id) (rest objects)))) - (let [deletion-delay (if (= bucket "tempfile") + (let [deletion-delay (if (= "tempfile" bucket) (ct/duration {:hours 2}) (cf/get-deletion-delay))] (some->> (seq to-freeze) (mark-freeze-in-bulk! conn)) @@ -213,8 +213,13 @@ [_ params] (assert (db/pool? (::db/pool params)) "expect valid storage")) -(defmethod ig/init-key ::handler - [_ cfg] - (fn [_] - (process-touched! (assoc cfg ::timestamp (ct/now))))) +(defmethod ig/expand-key ::handler + [k v] + {k (merge {::min-age (ct/duration {:hours 2})} v)}) + +(defmethod ig/init-key ::handler + [_ {:keys [::min-age] :as cfg}] + (fn [_] + (let [threshold (ct/minus (ct/now) min-age)] + (process-touched! (assoc cfg ::timestamp threshold))))) diff --git a/backend/src/app/tasks/upload_session_gc.clj b/backend/src/app/tasks/upload_session_gc.clj new file mode 100644 index 0000000000..c733bbd64e --- /dev/null +++ b/backend/src/app/tasks/upload_session_gc.clj @@ -0,0 +1,41 @@ +;; 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.upload-session-gc + "A maintenance task that deletes stalled (incomplete) upload sessions. + + An upload session is considered stalled when it was created more than + `max-age` ago without being completed (i.e. the session row still + exists because `assemble-chunks` was never called to clean it up). + The default max-age is 1 hour." + (:require + [app.common.logging :as l] + [app.common.time :as ct] + [app.db :as db] + [integrant.core :as ig])) + +(def ^:private sql:delete-stalled-sessions + "DELETE FROM upload_session + WHERE created_at < ?::timestamptz") + +(defmethod ig/assert-key ::handler + [_ params] + (assert (db/pool? (::db/pool params)) "expected a valid database pool")) + +(defmethod ig/expand-key ::handler + [k v] + {k (merge {::max-age (ct/duration {:hours 1})} v)}) + +(defmethod ig/init-key ::handler + [_ {:keys [::max-age] :as cfg}] + (fn [_] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (let [threshold (ct/minus (ct/now) max-age) + result (-> (db/exec-one! conn [sql:delete-stalled-sessions threshold]) + (db/get-update-count))] + (l/debug :hint "task finished" :deleted result) + {:deleted result}))))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 921477d1b3..281c834256 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -312,7 +312,8 @@ ;; freeze because of the deduplication (we have uploaded 2 times ;; the same files). - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) (t/is (= 0 (:delete res)))) @@ -386,7 +387,8 @@ ;; Now that file-gc have deleted the file-media-object usage, ;; lets execute the touched-gc task, we should see that two of ;; them are marked to be deleted - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 2 (:delete res)))) @@ -571,7 +573,8 @@ ;; Now that file-gc have deleted the file-media-object usage, ;; lets execute the touched-gc task, we should see that two of ;; them are marked to be deleted. - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 2 (:delete res)))) @@ -664,7 +667,8 @@ ;; because of the deduplication (we have uploaded 2 times the ;; same files). - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:freeze res))) (t/is (= 0 (:delete res)))) @@ -714,7 +718,8 @@ ;; Now that objects-gc have deleted the object thumbnail lets ;; execute the touched-gc task - (let [res (th/run-task! "storage-gc-touched" {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! "storage-gc-touched" {}))] (t/is (= 1 (:freeze res)))) ;; check file media objects @@ -749,7 +754,8 @@ ;; Now that file-gc have deleted the object thumbnail lets ;; execute the touched-gc task - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:delete res)))) ;; check file media objects @@ -1319,7 +1325,8 @@ ;; The FileGC task will schedule an inner taskq (th/run-pending-tasks!) - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) (t/is (= 0 (:delete res)))) @@ -1413,7 +1420,8 @@ ;; we ensure that once object-gc is passed and marked two storage ;; objects to delete - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 2 (:delete res)))) diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 9a856f3210..28134da5ff 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -85,7 +85,7 @@ (t/is (map? (:result out)))) ;; run the task again - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! "storage-gc-touched" {}))] (t/is (= 2 (:freeze res)))) @@ -136,7 +136,7 @@ (t/is (some? (sto/get-object storage (:media-id row2)))) ;; run the task again - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 31}))] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:delete res))) (t/is (= 0 (:freeze res)))) @@ -235,7 +235,8 @@ (t/is (= (:object-id data1) (:object-id row))) (t/is (uuid? (:media-id row1)))) - (let [result (th/run-task! :storage-gc-touched {})] + (let [result (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 1 (:delete result)))) ;; Check if storage objects still exists after file-gc diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index be5410ffd0..498e21ef2b 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -165,7 +165,8 @@ ;; (th/print-result! out) (t/is (nil? (:error out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 6 (:freeze res)))) (let [params {::th/type :delete-font @@ -177,14 +178,16 @@ (t/is (nil? (:error out))) (t/is (nil? (:result out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res)))) (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] (let [res (th/run-task! :objects-gc {})] - (t/is (= 2 (:processed res)))) + (t/is (= 2 (:processed res))))) + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] (let [res (th/run-task! :storage-gc-touched {})] (t/is (= 0 (:freeze res))) (t/is (= 6 (:delete res))))))) @@ -226,7 +229,8 @@ ;; (th/print-result! out) (t/is (nil? (:error out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 6 (:freeze res)))) (let [params {::th/type :delete-font @@ -238,14 +242,16 @@ (t/is (nil? (:error out))) (t/is (nil? (:result out)))) - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res)))) (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] (let [res (th/run-task! :objects-gc {})] - (t/is (= 1 (:processed res)))) + (t/is (= 1 (:processed res))))) + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] (let [res (th/run-task! :storage-gc-touched {})] (t/is (= 0 (:freeze res))) (t/is (= 3 (:delete res))))))) @@ -255,57 +261,42 @@ team-id (:default-team-id prof) proj-id (:default-project-id prof) font-id (uuid/custom 10 1) - - data1 (-> (io/resource "backend_tests/test_files/font-1.woff") - (io/read*)) - - data2 (-> (io/resource "backend_tests/test_files/font-2.woff") - (io/read*)) - params1 {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data1}} - - params2 {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 500 - :font-style "normal" - :data {"font/woff" data2}} - + data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*)) + params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id :font-family "somefont" + :font-weight 400 :font-style "normal" :data {"font/woff" data1}} + params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id :font-family "somefont" + :font-weight 500 :font-style "normal" :data {"font/woff" data2}} out1 (th/command! params1) out2 (th/command! params2)] - - ;; (th/print-result! out1) (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) - (let [res (th/run-task! :storage-gc-touched {})] + ;; freeze with hours 3 clock + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 6 (:freeze res)))) - (let [params {::th/type :delete-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :id (-> out1 :result :id)} + (let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :id (-> out1 :result :id)} out (th/command! params)] - ;; (th/print-result! out) (t/is (nil? (:error out))) (t/is (nil? (:result out)))) - (let [res (th/run-task! :storage-gc-touched {})] + ;; no-op with hours 3 clock (nothing touched yet) + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res)))) + ;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] (let [res (th/run-task! :objects-gc {})] - (t/is (= 1 (:processed res)))) + (t/is (= 1 (:processed res))))) + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] (let [res (th/run-task! :storage-gc-touched {})] (t/is (= 0 (:freeze res))) (t/is (= 3 (:delete res))))))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index 79df6d38b4..070a105a1b 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -6,9 +6,7 @@ (ns backend-tests.rpc-media-test (:require - [app.common.time :as ct] [app.common.uuid :as uuid] - [app.db :as db] [app.http.client :as http] [app.media :as media] [app.rpc :as-alias rpc] @@ -16,7 +14,10 @@ [backend-tests.helpers :as th] [clojure.test :as t] [datoteka.fs :as fs] - [mockery.core :refer [with-mocks]])) + [datoteka.io :as io] + [mockery.core :refer [with-mocks]]) + (:import + java.io.RandomAccessFile)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -260,7 +261,7 @@ :is-shared false}) _ (th/db-update! :file - {:deleted-at (ct/now)} + {:deleted-at (app.common.time/now)} {:id (:id file)}) mfile {:filename "sample.jpg" @@ -378,3 +379,325 @@ (t/is (some? err)) (t/is (= :validation (:type (ex-data err)))) (t/is (= :unable-to-download-image (:code (ex-data err)))))))) + +;; -------------------------------------------------------------------- +;; Helpers for chunked-upload tests +;; -------------------------------------------------------------------- + +(defn- split-file-into-chunks + "Splits the file at `path` into byte-array chunks of at most + `chunk-size` bytes. Returns a vector of byte arrays." + [path chunk-size] + (let [file (RandomAccessFile. (str path) "r") + length (.length file)] + (try + (loop [offset 0 chunks []] + (if (>= offset length) + chunks + (let [remaining (- length offset) + size (min chunk-size remaining) + buf (byte-array size)] + (.seek file offset) + (.readFully file buf) + (recur (+ offset size) (conj chunks buf))))) + (finally + (.close file))))) + +(defn- make-chunk-mfile + "Writes `data` (byte array) to a tempfile and returns a map + compatible with `media/schema:upload`." + [data mtype] + (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-chunk-")] + (io/write* tmp data) + {:filename "chunk" + :path tmp + :mtype mtype + :size (alength data)})) + +;; -------------------------------------------------------------------- +;; Chunked-upload tests +;; -------------------------------------------------------------------- + +(defn- create-session! + "Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID." + [prof total-chunks] + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks total-chunks})] + (t/is (nil? (:error out))) + (:session-id (:result out)))) + +(t/deftest chunked-upload-happy-path + (let [prof (th/create-profile* 1) + _ (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}) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + chunks (split-file-into-chunks source-path 110000) ; ~107 KB each + mtype "image/jpeg" + total-size (reduce + (map alength chunks)) + session-id (create-session! prof (count chunks))] + + (t/is (= 3 (count chunks))) + + ;; --- 1. Upload chunks --- + (doseq [[idx chunk-data] (map-indexed vector chunks)] + (let [mfile (make-chunk-mfile chunk-data mtype) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out))) + (t/is (= session-id (:session-id (:result out)))) + (t/is (= idx (:index (:result out)))))) + + ;; --- 2. Assemble --- + (let [assemble-out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "assembled-image" + :mtype mtype})] + + (t/is (nil? (:error assemble-out))) + (let [{:keys [media-id thumbnail-id] :as result} (:result assemble-out)] + (t/is (= (:id file) (:file-id result))) + (t/is (= 800 (:width result))) + (t/is (= 800 (:height result))) + (t/is (= mtype (:mtype result))) + (t/is (uuid? media-id)) + (t/is (uuid? thumbnail-id)) + + (let [storage (:app.storage/storage th/*system*) + mobj1 (sto/get-object storage media-id) + mobj2 (sto/get-object storage thumbnail-id)] + (t/is (sto/object? mobj1)) + (t/is (sto/object? mobj2)) + (t/is (= total-size (:size mobj1)))))))) + +(t/deftest chunked-upload-idempotency + (let [prof (th/create-profile* 1) + _ (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}) + media-id (uuid/next) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + chunks (split-file-into-chunks source-path 312043) ; single chunk = whole file + mtype "image/jpeg" + mfile (make-chunk-mfile (first chunks) mtype) + session-id (create-session! prof 1)] + + (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + + ;; First assemble succeeds; session row is deleted afterwards + (let [out1 (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "sample" + :mtype mtype + :id media-id})] + (t/is (nil? (:error out1))) + (t/is (= media-id (:id (:result out1))))) + + ;; Second assemble with the same session-id must fail because the + ;; session row has been deleted after the first assembly + (let [out2 (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "sample" + :mtype mtype + :id media-id})] + (t/is (some? (:error out2))) + (t/is (= :not-found (-> out2 :error ex-data :type))) + (t/is (= :object-not-found (-> out2 :error ex-data :code)))))) + +(t/deftest chunked-upload-no-permission + ;; A second profile must not be able to upload chunks into a session + ;; that belongs to another profile: the DB lookup includes profile-id, + ;; so the session will not be found. + (let [prof1 (th/create-profile* 1) + prof2 (th/create-profile* 2) + session-id (create-session! prof1 1) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043} + + ;; prof2 tries to upload a chunk into prof1's session + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof2) + :session-id session-id + :index 0 + :content mfile})] + + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))))) + +(t/deftest chunked-upload-invalid-media-type + (let [prof (th/create-profile* 1) + _ (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}) + session-id (create-session! prof 1) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043}] + + (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + + ;; Assemble with a wrong mtype should fail validation + (let [out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "bad-type" + :mtype "application/octet-stream"})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type)))))) + +(t/deftest chunked-upload-missing-chunks + (let [prof (th/create-profile* 1) + _ (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}) + ;; Session expects 3 chunks + session-id (create-session! prof 3) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043}] + + ;; Upload only 1 chunk + (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile}) + + ;; Assemble: session says 3 expected, only 1 stored → :missing-chunks + (let [out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "incomplete" + :mtype "image/jpeg"})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :missing-chunks (-> out :error ex-data :code)))))) + +(t/deftest chunked-upload-session-not-found + (let [prof (th/create-profile* 1) + _ (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}) + bogus-id (uuid/next)] + + ;; Assemble with a session-id that was never created + (let [out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id bogus-id + :file-id (:id file) + :is-local true + :name "ghost" + :mtype "image/jpeg"})] + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))) + (t/is (= :object-not-found (-> out :error ex-data :code)))))) + +(t/deftest chunked-upload-over-chunk-limit + ;; Verify that requesting more chunks than the configured maximum + ;; (quotes-upload-chunks-per-session) raises a :restriction error. + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-upload-chunks-per-session 3})}] + (let [prof (th/create-profile* 1) + out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 4})] + + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :max-quote-reached (-> out :error ex-data :code))) + (t/is (= "upload-chunks-per-session" (-> out :error ex-data :target)))))) + +(t/deftest chunked-upload-invalid-chunk-index + ;; Both a negative index and an index >= total-chunks must be + ;; rejected with a :validation / :invalid-chunk-index error. + (let [prof (th/create-profile* 1) + session-id (create-session! prof 2) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043}] + + ;; index == total-chunks (out of range) + (let [out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 2 + :content mfile})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :invalid-chunk-index (-> out :error ex-data :code)))) + + ;; negative index + (let [out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index -1 + :content mfile})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :invalid-chunk-index (-> out :error ex-data :code)))))) + +(t/deftest chunked-upload-sessions-per-profile-quota + ;; With the session limit set to 2, creating a third session for the + ;; same profile must fail with :restriction / :max-quote-reached. + ;; The :quotes flag is already enabled by the test fixture. + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-upload-sessions-per-profile 2})}] + (let [prof (th/create-profile* 1)] + + ;; First two sessions succeed + (create-session! prof 1) + (create-session! prof 1) + + ;; Third session must be rejected + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1})] + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :max-quote-reached (-> out :error ex-data :code))))))) diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index cd058af250..027d54ce70 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -169,7 +169,8 @@ (t/is (= 2 (:count res)))) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) (t/is (= 0 (:delete res)))) @@ -229,7 +230,8 @@ (t/is (nil? (:error out2))) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 5 (:freeze res))) (t/is (= 0 (:delete res))) @@ -249,7 +251,8 @@ (th/db-exec-one! ["update storage_object set touched_at=?" (ct/now)]) ;; Run the task again - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) (t/is (= 3 (:delete res)))) @@ -295,7 +298,8 @@ (th/db-exec! ["update storage_object set touched_at=?" (ct/now)]) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) (t/is (= 0 (:delete res)))) @@ -310,7 +314,8 @@ (t/is (= 2 (:processed res)))) ;; run the touched gc task - (let [res (th/run-task! :storage-gc-touched {})] + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] (t/is (= 0 (:freeze res))) (t/is (= 2 (:delete res)))) @@ -336,7 +341,7 @@ (t/is (= 0 (:delete res))))) - (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:minutes 1}))] + (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 3}))] (let [res (th/run-task! :storage-gc-touched {})] (t/is (= 0 (:freeze res))) (t/is (= 1 (:delete res))))) diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index e16acf94a3..ab7c7e2a76 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -487,62 +487,3 @@ b (+ (* bh 100) (* bv 10))] (compare a b))) -(defn interpolate-color - [c1 c2 offset] - (cond - (<= offset (:offset c1)) (assoc c1 :offset offset) - (>= offset (:offset c2)) (assoc c2 :offset offset) - - :else - (let [tr-offset (/ (- offset (:offset c1)) (- (:offset c2) (:offset c1))) - [r1 g1 b1] (hex->rgb (:color c1)) - [r2 g2 b2] (hex->rgb (:color c2)) - a1 (:opacity c1) - a2 (:opacity c2) - r (+ r1 (* (- r2 r1) tr-offset)) - g (+ g1 (* (- g2 g1) tr-offset)) - b (+ b1 (* (- b2 b1) tr-offset)) - a (+ a1 (* (- a2 a1) tr-offset))] - {:color (rgb->hex [r g b]) - :opacity a - :r r - :g g - :b b - :alpha a - :offset offset}))) - -(defn- offset-spread - [from to num] - (->> (range 0 num) - (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2)))) - -(defn uniform-spread? - "Checks if the gradient stops are spread uniformly" - [stops] - (let [cs (count stops) - from (first stops) - to (last stops) - expect-vals (offset-spread (:offset from) (:offset to) cs) - - calculate-expected - (fn [expected-offset stop] - (and (mth/close? (:offset stop) expected-offset) - (let [ec (interpolate-color from to expected-offset)] - (and (= (:color ec) (:color stop)) - (= (:opacity ec) (:opacity stop))))))] - (->> (map calculate-expected expect-vals stops) - (every? true?)))) - -(defn uniform-spread - "Assign an uniform spread to the offset values for the gradient" - [from to num-stops] - (->> (offset-spread (:offset from) (:offset to) num-stops) - (mapv (fn [offset] - (interpolate-color from to offset))))) - -(defn interpolate-gradient - [stops offset] - (let [idx (d/index-of-pred stops #(<= offset (:offset %))) - start (if (= idx 0) (first stops) (get stops (dec idx))) - end (if (nil? idx) (last stops) (get stops idx))] - (interpolate-color start end offset))) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 6537a281e2..cc1247dd8e 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -5,7 +5,7 @@ ;; Copyright (c) KALEIDOS INC (ns app.common.data - "A collection if helpers for working with data structures and other + "A collection of helpers for working with data structures and other data resources." (:refer-clojure :exclude [read-string hash-map merge name update-vals parse-double group-by iteration concat mapcat @@ -143,7 +143,7 @@ (oassoc-in o (cons k ks) v))) (defn vec2 - "Creates a optimized vector compatible type of length 2 backed + "Creates an optimized vector compatible type of length 2 backed internally with MapEntry impl because it has faster access method for its fields." [o1 o2] @@ -252,13 +252,13 @@ ([items] (enumerate items 0)) ([items start] (loop [idx start - items items + items (seq items) res (transient [])] - (if (empty? items) - (persistent! res) + (if items (recur (inc idx) - (rest items) - (conj! res [idx (first items)])))))) + (next items) + (conj! res [idx (first items)])) + (persistent! res))))) (defn group-by ([kf coll] (group-by kf identity [] coll)) @@ -291,15 +291,12 @@ (defn index-of-pred [coll pred] - (loop [c (first coll) - coll (rest coll) + (loop [s (seq coll) index 0] - (if (nil? c) - nil - (if (pred c) + (when s + (if (pred (first s)) index - (recur (first coll) - (rest coll) + (recur (next s) (inc index)))))) (defn index-of @@ -377,7 +374,7 @@ (assoc object key nil) (nil? value) - (dissoc object key value) + (dissoc object key) :else (assoc object key value))) @@ -396,7 +393,7 @@ (subvec v (inc index)))) (defn without-obj - "Clear collection from specified obj and without nil values." + "Return a vector with all elements equal to `o` removed." [coll o] (into [] (filter #(not= % o)) coll)) @@ -404,7 +401,7 @@ (map vector col1 col2)) (defn zip-all - "Return a zip of both collections, extended to the lenght of the longest one, + "Return a zip of both collections, extended to the length of the longest one, and padding the shorter one with nils as needed." [col1 col2] (let [diff (- (count col1) (count col2))] @@ -423,9 +420,9 @@ coll))) (defn removev - "Returns a vector of the items in coll for which (fn item) returns logical false" - [fn coll] - (filterv (comp not fn) coll)) + "Returns a vector of the items in coll for which (pred item) returns logical false" + [pred coll] + (filterv (comp not pred) coll)) (defn filterm "Filter values of a map that satisfy a predicate" @@ -443,7 +440,7 @@ Optional parameters: `pred?` A predicate that if not satisfied won't process the pair - `target?` A collection that will be used as seed to be stored + `target` A collection that will be used as seed to be stored Example: (map-perm vector [1 2 3 4]) => [[1 2] [1 3] [1 4] [2 3] [2 4] [3 4]]" @@ -602,12 +599,9 @@ (let [do-map (fn [entry] (let [[k v] (mfn entry)] - (cond - (or (vector? v) (map? v)) + (if (or (vector? v) (map? v)) [k (deep-mapm mfn v)] - - :else - (mfn [k v]))))] + [k v])))] (cond (map? m) (into {} (map do-map) m) @@ -724,7 +718,7 @@ (defn nan? [v] #?(:cljs (js/isNaN v) - :clj (not= v v))) + :clj (and (number? v) (Double/isNaN v)))) (defn- impl-parse-integer [v] @@ -788,7 +782,8 @@ (not (js/isNaN v)) (not (js/isNaN (parse-double v)))) - :clj (not= (parse-double v :nan) :nan))) + :clj (and (string? v) + (not= (parse-double v :nan) :nan)))) (defn read-string [v] @@ -958,7 +953,7 @@ (assoc diff key (map-diff v1 v2)) :else - (assoc diff key [(get m1 key) (get m2 key)]))))] + (assoc diff key [v1 v2]))))] (->> keys (reduce diff-attr {})))) @@ -1123,8 +1118,7 @@ ([value {:keys [precision] :or {precision 2}}] (let [value (if (string? value) (parse-double value) value)] (when (num? value) - (let [value (format-precision value precision)] - (str value)))))) + (format-precision value precision))))) (defn- natural-sort-key "Splits a string into a sequence of alternating string and number segments, @@ -1217,20 +1211,20 @@ "Wrapper around subvec so it doesn't throw an exception but returns nil instead" ([v start] (when (and (some? v) - (> start 0) (< start (count v))) + (>= start 0) (< start (count v))) (subvec v start))) ([v start end] - (let [size (count v)] - (when (and (some? v) - (>= start 0) (< start size) - (>= end 0) (<= start end) (<= end size)) - (subvec v start end))))) + (when (some? v) + (let [size (count v)] + (when (and (>= start 0) (< start size) + (>= end 0) (<= start end) (<= end size)) + (subvec v start end)))))) (defn append-class [class current-class] - (str (if (some? class) (str class " ") "") - current-class)) - + (if (seq class) + (str class " " current-class) + current-class)) (defn nth-index-of* "Finds the nth occurrence of `char` in `string`, searching either forward or backward. @@ -1266,4 +1260,4 @@ "Returns the index of the nth occurrence of `char` in `string`, searching right to left. Returns nil if fewer than n occurrences exist." [string char n] - (nth-index-of* string char n :backward)) \ No newline at end of file + (nth-index-of* string char n :backward)) diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index 4354986b8d..cc3dd11879 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -356,7 +356,7 @@ :code :empty-children :hint "expected a group with at least one shape for creating a bool")) - (let [head (if (= type :difference) + (let [head (if (= (:bool-type bool-shape) :difference) (first children) (last children)) fills (if (and (contains? head :svg-attrs) (empty? (:fills head))) @@ -364,7 +364,7 @@ (get head :fills))] (-> bool-shape (assoc :fills fills) - (assoc :stroks (get head :strokes)))))) + (assoc :strokes (get head :strokes)))))) (defn add-bool [state params] @@ -576,7 +576,7 @@ {:keys [id width height name]} (-> params (update :id default-uuid) - (check-add-file-media params))] + (check-add-file-media))] (-> state (update ::blobs assoc media-id blob) diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 673a3a854a..5b45a7ecd4 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -439,7 +439,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- without-obj - "Clear collection from specified obj and without nil values." + "Return a vector with all elements equal to `o` removed." [coll o] (into [] (filter #(not= % o)) coll)) diff --git a/common/src/app/common/fressian.clj b/common/src/app/common/fressian.clj index 7e35f3116e..98c8b1b323 100644 --- a/common/src/app/common/fressian.clj +++ b/common/src/app/common/fressian.clj @@ -118,6 +118,36 @@ (d/ordered-map) (partition-all 2 (seq kvs))))) + +(defn- adapt-write-handler + [{:keys [name class wfn]}] + [class {name (reify WriteHandler + (write [_ w o] + (wfn name w o)))}]) + +(defn- adapt-read-handler + [{:keys [name rfn]}] + [name (reify ReadHandler + (read [_ rdr _ _] + (rfn rdr)))]) + +(defn- merge-handlers + [m1 m2] + (-> (merge m1 m2) + (d/without-nils))) + +(def ^:private + xf:adapt-write-handler + (comp + (filter :wfn) + (map adapt-write-handler))) + +(def ^:private + xf:adapt-read-handler + (comp + (filter :rfn) + (map adapt-read-handler))) + (def ^:dynamic *write-handler-lookup* nil) (def ^:dynamic *read-handler-lookup* nil) @@ -126,36 +156,39 @@ (defn add-handlers! [& handlers] - (letfn [(adapt-write-handler [{:keys [name class wfn]}] - [class {name (reify WriteHandler - (write [_ w o] - (wfn name w o)))}]) + (let [write-handlers' + (into {} xf:adapt-write-handler handlers) - (adapt-read-handler [{:keys [name rfn]}] - [name (reify ReadHandler - (read [_ rdr _ _] - (rfn rdr)))]) + read-handlers' + (into {} xf:adapt-read-handler handlers) - (merge-and-clean [m1 m2] - (-> (merge m1 m2) - (d/without-nils)))] + write-handlers' + (swap! write-handlers merge-handlers write-handlers') - (let [whs (into {} - (comp - (filter :wfn) - (map adapt-write-handler)) - handlers) - rhs (into {} - (comp - (filter :rfn) - (map adapt-read-handler)) - handlers) - cwh (swap! write-handlers merge-and-clean whs) - crh (swap! read-handlers merge-and-clean rhs)] + read-handlers' + (swap! read-handlers merge-handlers read-handlers')] - (alter-var-root #'*write-handler-lookup* (constantly (-> cwh fres/associative-lookup fres/inheritance-lookup))) - (alter-var-root #'*read-handler-lookup* (constantly (-> crh fres/associative-lookup))) - nil))) + (alter-var-root #'*write-handler-lookup* + (constantly + (-> write-handlers' fres/associative-lookup fres/inheritance-lookup))) + + (alter-var-root #'*read-handler-lookup* + (constantly (-> read-handlers' fres/associative-lookup))) + + nil)) + +(defn overwrite-read-handlers + [& handlers] + (->> (into {} xf:adapt-read-handler handlers) + (merge-handlers @read-handlers) + (fres/associative-lookup))) + +(defn overwrite-write-handlers + [& handlers] + (->> (into {} xf:adapt-write-handler handlers) + (merge-handlers @write-handlers) + (fres/associative-lookup) + (fres/inheritance-lookup))) (defn write-char [n w o] diff --git a/common/src/app/common/geom/bounds_map.cljc b/common/src/app/common/geom/bounds_map.cljc index 55230c9c0b..86bf10b756 100644 --- a/common/src/app/common/geom/bounds_map.cljc +++ b/common/src/app/common/geom/bounds_map.cljc @@ -79,10 +79,10 @@ (loop [new-ids (->> (cfh/get-parent-seq objects cid) (take-while #(and (cfh/group-like-shape? %) - (not (.has ids %)))) + (not (.has ids (:id %))))) (seq))] (when (some? new-ids) - (.add ids (first new-ids)) + (.add ids (:id (first new-ids))) (recur (next new-ids)))) (recur (next base-ids))))) ids))) diff --git a/common/src/app/common/geom/matrix.cljc b/common/src/app/common/geom/matrix.cljc index 63195b1bb5..a7ed0cea68 100644 --- a/common/src/app/common/geom/matrix.cljc +++ b/common/src/app/common/geom/matrix.cljc @@ -101,7 +101,7 @@ (dm/get-prop o :c) "," (dm/get-prop o :d) "," (dm/get-prop o :e) "," - (dm/get-prop o :f) ",") + (dm/get-prop o :f)) o)) (defn- matrix->json @@ -359,8 +359,6 @@ (th-eq m1e m2e) (th-eq m1f m2f)))) -(defmethod pp/simple-dispatch Matrix [obj] (pr obj)) - (defn transform-in [pt mtx] (if (and (some? pt) (some? mtx)) (-> (matrix) diff --git a/common/src/app/common/geom/point.cljc b/common/src/app/common/geom/point.cljc index fd82a7af2a..f20f4bceb3 100644 --- a/common/src/app/common/geom/point.cljc +++ b/common/src/app/common/geom/point.cljc @@ -151,7 +151,7 @@ (dm/get-prop p2 :y)))) (defn multiply - "Returns the subtraction of the supplied value to both + "Returns the multiplication of the supplied value to both coordinates of the point as a new point." [p1 p2] (assert (and (point? p1) @@ -509,12 +509,10 @@ (let [old-length (length vector)] (scale vector (/ new-length old-length)))) -;; FIXME: perfromance (defn abs [point] - (-> point - (update :x mth/abs) - (update :y mth/abs))) + (pos->Point (mth/abs (dm/get-prop point :x)) + (mth/abs (dm/get-prop point :y)))) ;; --- Debug diff --git a/common/src/app/common/geom/rect.cljc b/common/src/app/common/geom/rect.cljc index 925c784e65..f53024ca02 100644 --- a/common/src/app/common/geom/rect.cljc +++ b/common/src/app/common/geom/rect.cljc @@ -119,12 +119,14 @@ (defn update-rect [rect type] (case type - :size + (:size :position) (let [x (dm/get-prop rect :x) y (dm/get-prop rect :y) w (dm/get-prop rect :width) h (dm/get-prop rect :height)] (assoc rect + :x1 x + :y1 y :x2 (+ x w) :y2 (+ y h))) @@ -137,19 +139,7 @@ :x (mth/min x1 x2) :y (mth/min y1 y2) :width (mth/abs (- x2 x1)) - :height (mth/abs (- y2 y1)))) - - ;; FIXME: looks unused - :position - (let [x (dm/get-prop rect :x) - y (dm/get-prop rect :y) - w (dm/get-prop rect :width) - h (dm/get-prop rect :height)] - (assoc rect - :x1 x - :y1 y - :x2 (+ x w) - :y2 (+ y h))))) + :height (mth/abs (- y2 y1)))))) (defn update-rect! [rect type] @@ -382,8 +372,8 @@ ([xp1 yp1 xp2 yp2] (make-rect (mth/min xp1 xp2) (mth/min yp1 yp2) - (abs (- xp1 xp2)) - (abs (- yp1 yp2))))) + (mth/abs (- xp1 xp2)) + (mth/abs (- yp1 yp2))))) (defn clip-rect [selrect bounds] diff --git a/common/src/app/common/geom/shapes/constraints.cljc b/common/src/app/common/geom/shapes/constraints.cljc index 120d222901..95691b34e2 100644 --- a/common/src/app/common/geom/shapes/constraints.cljc +++ b/common/src/app/common/geom/shapes/constraints.cljc @@ -234,7 +234,7 @@ after-side-vector (side-vector axis parent-points-after)] (ctm/move-modifiers (displacement center-before center-after before-side-vector after-side-vector)))) -(defmethod constraint-modifier :default [_ _ _ _ _] +(defmethod constraint-modifier :default [_ _ _ _ _ _] []) (def const->type+axis diff --git a/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc b/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc index e9a5fa4915..bbc350712c 100644 --- a/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc @@ -81,7 +81,7 @@ h-center? (and row? (ctl/h-center? frame)) h-end? (and row? (ctl/h-end? frame)) v-center? (and col? (ctl/v-center? frame)) - v-end? (and row? (ctl/v-end? frame)) + v-end? (and col? (ctl/v-end? frame)) center (gco/shape->center frame) start-p (gmt/transform-point-center start-p center transform-inverse) diff --git a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc index c9e4f7c57e..6d91a25707 100644 --- a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc @@ -369,9 +369,6 @@ (cond (and col? space-evenly?) 0 - (and col? space-evenly? auto-height?) - 0 - (and col? space-around?) (/ (max layout-gap-row (/ (- height line-height) num-children)) 2) diff --git a/common/src/app/common/geom/shapes/flex_layout/positions.cljc b/common/src/app/common/geom/shapes/flex_layout/positions.cljc index 48b49d7c06..675a090c6b 100644 --- a/common/src/app/common/geom/shapes/flex_layout/positions.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/positions.cljc @@ -61,7 +61,7 @@ (gpt/add (hv free-width-gap)) around? - (gpt/add (hv (max lines-gap-col (/ free-width num-lines) 2))) + (gpt/add (hv (max lines-gap-col (/ free-width num-lines 2)))) evenly? (gpt/add (hv (max lines-gap-col (/ free-width (inc num-lines))))))))) diff --git a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc index 639da86514..026c48fe41 100644 --- a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc @@ -331,7 +331,7 @@ ;; Apply the allocations to the tracks track-list (into [] - (map-indexed #(update %2 :size max (get allocated %1))) + (map-indexed #(update %2 :size max (get allocated %1 0))) track-list)] track-list)) @@ -381,7 +381,7 @@ ;; Apply the allocations to the tracks track-list (into [] - (map-indexed #(update %2 :size max (get allocate-fr-tracks %1))) + (map-indexed #(update %2 :size max (get allocate-fr-tracks %1 0))) track-list)] track-list)) @@ -474,8 +474,8 @@ min-column-fr (min-fr-value column-tracks) min-row-fr (min-fr-value row-tracks) - column-fr (if auto-width? min-column-fr (mth/finite (/ fr-column-space column-frs) 0)) - row-fr (if auto-height? min-row-fr (mth/finite (/ fr-row-space row-frs) 0)) + column-fr (if auto-width? min-column-fr (if (zero? column-frs) 0 (mth/finite (/ fr-column-space column-frs) 0))) + row-fr (if auto-height? min-row-fr (if (zero? row-frs) 0 (mth/finite (/ fr-row-space row-frs) 0))) column-tracks (set-fr-value column-tracks column-fr auto-width?) row-tracks (set-fr-value row-tracks row-fr auto-height?) @@ -489,8 +489,8 @@ column-autos (tracks-total-autos column-tracks) row-autos (tracks-total-autos row-tracks) - column-add-auto (/ auto-column-space column-autos) - row-add-auto (/ auto-row-space row-autos) + column-add-auto (if (zero? column-autos) 0 (/ auto-column-space column-autos)) + row-add-auto (if (zero? row-autos) 0 (/ auto-row-space row-autos)) column-tracks (cond-> column-tracks (= :stretch (:layout-justify-content parent)) @@ -505,36 +505,38 @@ num-columns (count column-tracks) column-gap - (case (:layout-justify-content parent) + (cond auto-width? column-gap - :space-evenly + (= :space-evenly (:layout-justify-content parent)) (max column-gap (/ (- bound-width column-total-size) (inc num-columns))) - :space-around + (= :space-around (:layout-justify-content parent)) (max column-gap (/ (- bound-width column-total-size) num-columns)) - :space-between + (= :space-between (:layout-justify-content parent)) (max column-gap (if (= num-columns 1) column-gap (/ (- bound-width column-total-size) (dec num-columns)))) + :else column-gap) num-rows (count row-tracks) row-gap - (case (:layout-align-content parent) + (cond auto-height? row-gap - :space-evenly + (= :space-evenly (:layout-align-content parent)) (max row-gap (/ (- bound-height row-total-size) (inc num-rows))) - :space-around + (= :space-around (:layout-align-content parent)) (max row-gap (/ (- bound-height row-total-size) num-rows)) - :space-between + (= :space-between (:layout-align-content parent)) (max row-gap (if (= num-rows 1) row-gap (/ (- bound-height row-total-size) (dec num-rows)))) + :else row-gap) start-p diff --git a/common/src/app/common/geom/shapes/intersect.cljc b/common/src/app/common/geom/shapes/intersect.cljc index 60a62ce9c4..76fe700c7b 100644 --- a/common/src/app/common/geom/shapes/intersect.cljc +++ b/common/src/app/common/geom/shapes/intersect.cljc @@ -55,16 +55,16 @@ (and (not= o1 o2) (not= o3 o4)) ;; p1, q1 and p2 colinear and p2 lies on p1q1 - (and (= o1 :coplanar) ^boolean (on-segment? p2 p1 q1)) + (and (= o1 ::coplanar) ^boolean (on-segment? p2 p1 q1)) ;; p1, q1 and q2 colinear and q2 lies on p1q1 - (and (= o2 :coplanar) ^boolean (on-segment? q2 p1 q1)) + (and (= o2 ::coplanar) ^boolean (on-segment? q2 p1 q1)) ;; p2, q2 and p1 colinear and p1 lies on p2q2 - (and (= o3 :coplanar) ^boolean (on-segment? p1 p2 q2)) + (and (= o3 ::coplanar) ^boolean (on-segment? p1 p2 q2)) ;; p2, q2 and p1 colinear and q1 lies on p2q2 - (and (= o4 :coplanar) ^boolean (on-segment? q1 p2 q2))))) + (and (= o4 ::coplanar) ^boolean (on-segment? q1 p2 q2))))) (defn points->lines "Given a set of points for a polygon will return diff --git a/common/src/app/common/geom/shapes/transforms.cljc b/common/src/app/common/geom/shapes/transforms.cljc index adff9643ff..19386622c9 100644 --- a/common/src/app/common/geom/shapes/transforms.cljc +++ b/common/src/app/common/geom/shapes/transforms.cljc @@ -303,13 +303,13 @@ (neg? dot-x) (update :flip-x not) - (neg? dot-x) - (update :rotation -) - (neg? dot-y) (update :flip-y not) - (neg? dot-y) + ;; Negate rotation only when an odd number of axes are flipped, + ;; since flipping both axes is equivalent to a 180° rotation and + ;; two negations would cancel each other out. + (not= (neg? dot-x) (neg? dot-y)) (update :rotation -)))) (defn- apply-transform-move diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index c4532c4ac0..ae56250d96 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -720,8 +720,10 @@ (defn- offset-spread [from to num] - (->> (range 0 num) - (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2)))) + (if (<= num 1) + [from] + (->> (range 0 num) + (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2))))) (defn uniform-spread? "Checks if the gradient stops are spread uniformly" @@ -750,6 +752,9 @@ (defn interpolate-gradient [stops offset] (let [idx (d/index-of-pred stops #(<= offset (:offset %))) - start (if (= idx 0) (first stops) (get stops (dec idx))) + start (cond + (nil? idx) (last stops) + (= idx 0) (first stops) + :else (get stops (dec idx))) end (if (nil? idx) (last stops) (get stops idx))] (interpolate-color start end offset))) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 4e6021dbb6..c3aab0df4e 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -110,8 +110,9 @@ (let [shape (get objects id)] (if (and (ctk/instance-head? shape) (seq children)) children - (into (conj children shape) - (mapcat #(get-children-rec children %) (:shapes shape))))))] + (let [children' (conj children shape)] + (into children' + (mapcat #(get-children-rec children' %) (:shapes shape)))))))] (get-children-rec [] id))) (defn get-component-shape @@ -444,7 +445,7 @@ (if (ctk/main-instance? shape) [shape] (if-let [children (cfh/get-children objects (:id shape))] - (mapcat collect-main-shapes children objects) + (mapcat #(collect-main-shapes % objects) children) []))) (defn get-component-from-shape diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc index 06475d183f..b429c67b9c 100644 --- a/common/src/app/common/types/fills/impl.cljc +++ b/common/src/app/common/types/fills/impl.cljc @@ -380,7 +380,7 @@ nil)) (-nth [_ i default] - (if (d/in-range? i size) + (if (d/in-range? size i) (read-fill dbuffer mbuffer i) default)) diff --git a/common/src/app/common/types/objects_map.cljc b/common/src/app/common/types/objects_map.cljc index d08330765c..3604961f11 100644 --- a/common/src/app/common/types/objects_map.cljc +++ b/common/src/app/common/types/objects_map.cljc @@ -278,7 +278,7 @@ (set! (.-cache this) (c/-assoc cache k v)) v) (do - (set! (.-cache this) (assoc cache key nil)) + (set! (.-cache this) (assoc cache k nil)) nil)))) (-lookup [this k not-found] diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index bcbbe8eeda..45fc1ba2bb 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -812,7 +812,7 @@ :line-to (recur (cond-> points (and from-p to-p) - (-> (conj! move-p) + (-> (conj! from-p) (conj! to-p))) (not-empty (subvec content 1)) to-p diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 79aa661755..a3c9e31ed6 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -262,7 +262,7 @@ (or (nil? current) (= current-id parent-id)) false - (cfh/frame-shape? current-id) + (cfh/frame-shape? current) (:layout current) :else @@ -1475,7 +1475,7 @@ (update-in [:layout-grid-cells id-from] assoc :shapes (:shapes cell-to) - :podition (:position cell-to)) + :position (:position cell-to)) (update-in [:layout-grid-cells id-to] assoc :shapes (:shapes cell-from) diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index c168bfc5a0..1ece712296 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -357,7 +357,6 @@ (def typography-keys (set/union font-family-keys font-size-keys font-weight-keys - font-weight-keys letter-spacing-keys line-height-keys text-case-keys diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 3add8efa51..a54a243bab 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -1689,7 +1689,7 @@ Will return a value that matches this schema: [value] (let [process-shadow (fn [shadow] (if (map? shadow) - (let [legacy-shadow-type (get "type" shadow)] + (let [legacy-shadow-type (get shadow "type")] (-> shadow (set/rename-keys {"x" :offset-x "offsetX" :offset-x diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index de505fd540..7d6b0f0e3d 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -9,91 +9,8 @@ #?(:cljs [goog.color :as gcolors]) [app.common.colors :as c] [app.common.math :as mth] - [app.common.types.color :as colors] [clojure.test :as t])) -(t/deftest valid-hex-color - (t/is (false? (colors/valid-hex-color? nil))) - (t/is (false? (colors/valid-hex-color? ""))) - (t/is (false? (colors/valid-hex-color? "#"))) - (t/is (false? (colors/valid-hex-color? "#qqqqqq"))) - (t/is (true? (colors/valid-hex-color? "#aaa"))) - (t/is (false? (colors/valid-hex-color? "#aaaa"))) - (t/is (true? (colors/valid-hex-color? "#fabada")))) - -(t/deftest valid-rgb-color - (t/is (false? (colors/valid-rgb-color? nil))) - (t/is (false? (colors/valid-rgb-color? ""))) - (t/is (false? (colors/valid-rgb-color? "()"))) - (t/is (true? (colors/valid-rgb-color? "(255, 30, 30)"))) - (t/is (true? (colors/valid-rgb-color? "rgb(255, 30, 30)")))) - -(t/deftest rgb-to-str - (t/is (= "rgb(1,2,3)" (colors/rgb->str [1 2 3]))) - (t/is (= "rgba(1,2,3,4)" (colors/rgb->str [1 2 3 4])))) - -(t/deftest rgb-to-hsv - ;; (prn (colors/rgb->hsv [1 2 3])) - ;; (prn (gcolors/rgbToHsv 1 2 3)) - (t/is (= [210.0 0.6666666666666666 3.0] (colors/rgb->hsv [1.0 2.0 3.0]))) - #?(:cljs (t/is (= (colors/rgb->hsv [1 2 3]) (vec (gcolors/rgbToHsv 1 2 3)))))) - -(t/deftest hsv-to-rgb - (t/is (= [1 2 3] - (colors/hsv->rgb [210 0.6666666666666666 3]))) - #?(:cljs - (t/is (= (colors/hsv->rgb [210 0.6666666666666666 3]) - (vec (gcolors/hsvToRgb 210 0.6666666666666666 3)))))) - -(t/deftest rgb-to-hex - (t/is (= "#010203" (colors/rgb->hex [1 2 3])))) - -(t/deftest hex-to-rgb - (t/is (= [0 0 0] (colors/hex->rgb "#kkk"))) - (t/is (= [1 2 3] (colors/hex->rgb "#010203")))) - -(t/deftest format-hsla - (t/is (= "210, 50%, 0.78%, 1" (colors/format-hsla [210.0 0.5 0.00784313725490196 1]))) - (t/is (= "220, 5%, 30%, 0.8" (colors/format-hsla [220.0 0.05 0.3 0.8])))) - -(t/deftest format-rgba - (t/is (= "210, 199, 12, 0.08" (colors/format-rgba [210 199 12 0.08]))) - (t/is (= "210, 199, 12, 1" (colors/format-rgba [210 199 12 1])))) - -(t/deftest rgb-to-hsl - (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3]))) - #?(:cljs (t/is (= (colors/rgb->hsl [1 2 3]) - (vec (gcolors/rgbToHsl 1 2 3)))))) - -(t/deftest hsl-to-rgb - (t/is (= [1 2 3] (colors/hsl->rgb [210.0 0.5 0.00784313725490196]))) - (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3]))) - #?(:cljs (t/is (= (colors/hsl->rgb [210 0.5 0.00784313725490196]) - (vec (gcolors/hslToRgb 210 0.5 0.00784313725490196)))))) - -(t/deftest expand-hex - (t/is (= "aaaaaa" (colors/expand-hex "a"))) - (t/is (= "aaaaaa" (colors/expand-hex "aa"))) - (t/is (= "aaaaaa" (colors/expand-hex "aaa"))) - (t/is (= "aaaa" (colors/expand-hex "aaaa")))) - -(t/deftest prepend-hash - (t/is "#aaa" (colors/prepend-hash "aaa")) - (t/is "#aaa" (colors/prepend-hash "#aaa"))) - -(t/deftest remove-hash - (t/is "aaa" (colors/remove-hash "aaa")) - (t/is "aaa" (colors/remove-hash "#aaa"))) - -(t/deftest color-string-pred - (t/is (true? (colors/color-string? "#aaa"))) - (t/is (true? (colors/color-string? "(10,10,10)"))) - (t/is (true? (colors/color-string? "rgb(10,10,10)"))) - (t/is (true? (colors/color-string? "magenta"))) - (t/is (false? (colors/color-string? nil))) - (t/is (false? (colors/color-string? ""))) - (t/is (false? (colors/color-string? "kkkkkk")))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; app.common.colors tests ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -387,55 +304,3 @@ (t/is (= 0.25 (c/reduce-range 0.3 4))) (t/is (= 0.0 (c/reduce-range 0.0 10)))) -;; --- Gradient helpers - -(t/deftest ac-interpolate-color - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0}] - ;; At c1's offset → c1 with updated offset - (let [result (c/interpolate-color c1 c2 0.0)] - (t/is (= "#000000" (:color result))) - (t/is (= 0.0 (:opacity result)))) - ;; At c2's offset → c2 with updated offset - (let [result (c/interpolate-color c1 c2 1.0)] - (t/is (= "#ffffff" (:color result))) - (t/is (= 1.0 (:opacity result)))) - ;; At midpoint → gray - (let [result (c/interpolate-color c1 c2 0.5)] - (t/is (= "#7f7f7f" (:color result))) - (t/is (mth/close? (:opacity result) 0.5))))) - -(t/deftest ac-uniform-spread - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (c/uniform-spread c1 c2 3)] - (t/is (= 3 (count stops))) - (t/is (= 0.0 (:offset (first stops)))) - (t/is (mth/close? 0.5 (:offset (second stops)))) - (t/is (= 1.0 (:offset (last stops)))))) - -(t/deftest ac-uniform-spread? - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (c/uniform-spread c1 c2 3)] - ;; A uniformly spread result should pass the predicate - (t/is (true? (c/uniform-spread? stops)))) - ;; Manual non-uniform stops should not pass - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#888888" :opacity 0.5 :offset 0.3} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - (t/is (false? (c/uniform-spread? stops))))) - -(t/deftest ac-interpolate-gradient - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - ;; At start - (let [result (c/interpolate-gradient stops 0.0)] - (t/is (= "#000000" (:color result)))) - ;; At end - (let [result (c/interpolate-gradient stops 1.0)] - (t/is (= "#ffffff" (:color result)))) - ;; In the middle - (let [result (c/interpolate-gradient stops 0.5)] - (t/is (= "#7f7f7f" (:color result)))))) - diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 1a582c0c52..dbe015cf89 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -399,6 +399,8 @@ (t/is (= [2 3] (d/safe-subvec [1 2 3 4] 1 3))) ;; single arg — from index to end (t/is (= [2 3 4] (d/safe-subvec [1 2 3 4] 1))) + ;; start=0 returns the full vector + (t/is (= [1 2 3 4] (d/safe-subvec [1 2 3 4] 0))) ;; out-of-range returns nil (t/is (nil? (d/safe-subvec [1 2 3] 5))) (t/is (nil? (d/safe-subvec [1 2 3] 0 5))) @@ -446,12 +448,19 @@ (t/is (= 0 (d/index-of-pred [1 2 3] odd?))) (t/is (= 1 (d/index-of-pred [2 3 4] odd?))) (t/is (nil? (d/index-of-pred [2 4 6] odd?))) - (t/is (nil? (d/index-of-pred [] odd?)))) + (t/is (nil? (d/index-of-pred [] odd?))) + ;; works correctly when collection contains nil elements + (t/is (= 2 (d/index-of-pred [nil nil 3] some?))) + (t/is (= 0 (d/index-of-pred [nil 1 2] nil?))) + ;; works correctly when collection contains false elements + (t/is (= 1 (d/index-of-pred [false true false] true?)))) (t/deftest index-of-test (t/is (= 0 (d/index-of [:a :b :c] :a))) (t/is (= 2 (d/index-of [:a :b :c] :c))) - (t/is (nil? (d/index-of [:a :b :c] :z)))) + (t/is (nil? (d/index-of [:a :b :c] :z))) + ;; works when searching for nil in a collection + (t/is (= 1 (d/index-of [:a nil :c] nil)))) (t/deftest replace-by-id-test (let [items [{:id 1 :v "a"} {:id 2 :v "b"} {:id 3 :v "c"}] @@ -519,6 +528,8 @@ (t/is (= {:a {:x 10 :y 2}} (d/patch-object {:a {:x 1 :y 2}} {:a {:x 10}}))) ;; nested nil removes nested key (t/is (= {:a {:y 2}} (d/patch-object {:a {:x 1 :y 2}} {:a {:x nil}}))) + ;; nil value removes only the specified key, not other keys + (t/is (= {nil 0 :b 2} (d/patch-object {nil 0 :a 1 :b 2} {:a nil}))) ;; transducer arity (1-arg returns a fn) (let [f (d/patch-object {:a 99})] (t/is (= {:a 99 :b 2} (f {:a 1 :b 2}))))) @@ -610,33 +621,33 @@ (into [] (d/distinct-xf :id) [{:id 1 :v "a"} {:id 2 :v "x"} {:id 2 :v "b"}])))) (t/deftest deep-mapm-test - ;; Note: mfn is called twice on leaf entries (once initially, once again - ;; after checking if the value is a map/vector), so a doubling fn applied - ;; to value 1 gives 1*2*2=4. - (t/is (= {:a 4 :b {:c 8}} + ;; mfn is applied once per entry + (t/is (= {:a 2 :b {:c 4}} (d/deep-mapm (fn [[k v]] [k (if (number? v) (* v 2) v)]) {:a 1 :b {:c 2}}))) - ;; Keyword renaming: keys are also transformed — and applied twice. - ;; Use an idempotent key transformation (uppercase once = uppercase twice). + ;; Keyword renaming: keys are transformed once per entry (let [result (d/deep-mapm (fn [[k v]] [(keyword (str (name k) "!")) v]) {:a 1})] - (t/is (contains? result (keyword "a!!"))))) + (t/is (contains? result (keyword "a!")))) + ;; Vectors inside maps are recursed into + (t/is (= {:items [{:x 10}]} + (d/deep-mapm (fn [[k v]] [k (if (number? v) (* v 10) v)]) + {:items [{:x 1}]}))) + ;; Plain scalar at top level map + (t/is (= {:a "hello"} (d/deep-mapm identity {:a "hello"})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Numeric helpers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (t/deftest nan-test - ;; Note: nan? behaves differently per platform: - ;; - CLJS: uses js/isNaN, returns true for ##NaN - ;; - CLJ: uses (not= v v); Clojure's = uses .equals on doubles, - ;; so (= ##NaN ##NaN) is true and nan? returns false for ##NaN. - ;; Either way, nan? returns false for regular numbers and nil. + (t/is (d/nan? ##NaN)) (t/is (not (d/nan? 0))) (t/is (not (d/nan? 1))) (t/is (not (d/nan? nil))) - ;; Platform-specific: JS nan? correctly detects NaN - #?(:cljs (t/is (d/nan? ##NaN)))) + ;; CLJS js/isNaN coerces non-numbers; JVM Double/isNaN is number-only + #?(:cljs (t/is (d/nan? "hello"))) + #?(:clj (t/is (not (d/nan? "hello"))))) (t/deftest safe-plus-test (t/is (= 5 (d/safe+ 3 2))) @@ -680,18 +691,13 @@ (t/is (nil? (d/parse-uuid nil)))) (t/deftest coalesce-str-test - ;; On JVM: nan? uses (not= v v), which is false for all normal values. - ;; On CLJS: nan? uses js/isNaN, which is true for non-numeric strings. - ;; coalesce-str returns default when value is nil or nan?. (t/is (= "default" (d/coalesce-str nil "default"))) ;; Numbers always stringify on both platforms (t/is (= "42" (d/coalesce-str 42 "default"))) - ;; ##NaN: nan? is true in CLJS, returns default; - ;; nan? is false in CLJ, so str(##NaN)="NaN" is returned. - #?(:cljs (t/is (= "default" (d/coalesce-str ##NaN "default")))) - #?(:clj (t/is (= "NaN" (d/coalesce-str ##NaN "default")))) + ;; ##NaN returns default on both platforms now that nan? is fixed on JVM + (t/is (= "default" (d/coalesce-str ##NaN "default"))) ;; Strings: in CLJS js/isNaN("hello")=true so "default" is returned; - ;; in CLJ nan? is false so (str "hello")="hello" is returned. + ;; in CLJ nan? is false for strings so (str "hello")="hello" is returned. #?(:cljs (t/is (= "default" (d/coalesce-str "hello" "default")))) #?(:clj (t/is (= "hello" (d/coalesce-str "hello" "default"))))) @@ -853,7 +859,8 @@ (t/deftest append-class-test (t/is (= "foo bar" (d/append-class "foo" "bar"))) (t/is (= "bar" (d/append-class nil "bar"))) - (t/is (= " bar" (d/append-class "" "bar")))) + ;; empty string is treated like nil — no leading space + (t/is (= "bar" (d/append-class "" "bar")))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Additional helpers (5th batch) @@ -902,6 +909,9 @@ (t/is (d/num-string? "-7")) (t/is (not (d/num-string? "hello"))) (t/is (not (d/num-string? nil))) + ;; non-string types always return false + (t/is (not (d/num-string? 42))) + (t/is (not (d/num-string? :keyword))) ;; In CLJS, js/isNaN("") → false (empty string coerces to 0), so "" is numeric #?(:clj (t/is (not (d/num-string? "")))) #?(:cljs (t/is (d/num-string? "")))) diff --git a/common/test/common_tests/files_builder_test.cljc b/common/test/common_tests/files_builder_test.cljc new file mode 100644 index 0000000000..23dd6c78cc --- /dev/null +++ b/common/test/common_tests/files_builder_test.cljc @@ -0,0 +1,72 @@ +;; 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 common-tests.files-builder-test + (:require + [app.common.files.builder :as fb] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +(defn- stroke + [color] + [{:stroke-style :solid + :stroke-alignment :inner + :stroke-width 1 + :stroke-color color + :stroke-opacity 1}]) + +(t/deftest add-bool-uses-difference-head-style + (let [file-id (uuid/next) + page-id (uuid/next) + group-id (uuid/next) + child-a (uuid/next) + child-b (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-group {:id group-id :name "Group A"}) + (fb/add-shape {:id child-a + :type :rect + :name "A" + :x 0 + :y 0 + :width 10 + :height 10 + :strokes (stroke "#ff0000")}) + (fb/add-shape {:id child-b + :type :rect + :name "B" + :x 20 + :y 0 + :width 10 + :height 10 + :strokes (stroke "#00ff00")}) + (fb/close-group) + (fb/add-bool {:group-id group-id + :type :difference})) + bool (fb/get-shape state group-id)] + (t/is (= :bool (:type bool))) + (t/is (= (stroke "#ff0000") (:strokes bool))))) + +(t/deftest add-file-media-validates-and-persists-media + (let [file-id (uuid/next) + page-id (uuid/next) + image-id (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-file-media {:id image-id + :name "Image" + :width 128 + :height 64} + (fb/map->BlobWrapper {:mtype "image/png" + :size 42 + :blob nil}))) + media (get-in state [::fb/file-media image-id])] + (t/is (= image-id (::fb/last-id state))) + (t/is (= "Image" (:name media))) + (t/is (= 128 (:width media))) + (t/is (= 64 (:height media))))) diff --git a/common/test/common_tests/fressian_test.clj b/common/test/common_tests/fressian_test.clj new file mode 100644 index 0000000000..9af54464a5 --- /dev/null +++ b/common/test/common_tests/fressian_test.clj @@ -0,0 +1,526 @@ +;; 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 common-tests.fressian-test + "Exhaustive unit tests for app.common.fressian encode/decode functions. + + Tests cover every custom handler registered in the fressian namespace + (char, java/instant, clj/ratio, clj/map, linked/map, clj/keyword, + clj/symbol, clj/bigint, clj/set, clj/vector, clj/list, clj/seq, + linked/set) plus the built-in Fressian primitives (nil, boolean, + integer, long, double, string, bytes, UUID). + + The file is JVM-only because Fressian is a JVM library." + (:require + [app.common.data :as d] + [app.common.fressian :as fres] + [clojure.test :as t]) + (:import + java.time.Instant + java.time.OffsetDateTime + java.time.ZoneOffset)) + +;; --------------------------------------------------------------------------- +;; Helpers +;; --------------------------------------------------------------------------- + +(defn roundtrip + "Encode then decode a value; the result must equal the original." + [v] + (-> v fres/encode fres/decode)) + +(defn roundtrip= + "Returns true when encode→decode produces an equal value." + [v] + (= v (roundtrip v))) + +;; --------------------------------------------------------------------------- +;; Encode returns a byte array +;; --------------------------------------------------------------------------- + +(t/deftest encode-returns-byte-array + (t/is (bytes? (fres/encode nil))) + (t/is (bytes? (fres/encode 42))) + (t/is (bytes? (fres/encode "hello"))) + (t/is (bytes? (fres/encode {:a 1}))) + (t/is (bytes? (fres/encode []))) + (t/is (pos? (alength ^bytes (fres/encode 0)))) + (t/testing "different values produce different byte arrays" + (t/is (not= (vec (fres/encode 1)) (vec (fres/encode 2)))))) + +;; --------------------------------------------------------------------------- +;; nil +;; --------------------------------------------------------------------------- + +(t/deftest nil-roundtrip + (t/is (nil? (roundtrip nil)))) + +;; --------------------------------------------------------------------------- +;; Booleans +;; --------------------------------------------------------------------------- + +(t/deftest boolean-roundtrip + (t/is (true? (roundtrip true))) + (t/is (false? (roundtrip false)))) + +;; --------------------------------------------------------------------------- +;; Integers and longs +;; --------------------------------------------------------------------------- + +(t/deftest integer-roundtrip + (t/is (= 0 (roundtrip 0))) + (t/is (= 1 (roundtrip 1))) + (t/is (= -1 (roundtrip -1))) + (t/is (= 42 (roundtrip 42))) + (t/is (= Integer/MAX_VALUE (roundtrip Integer/MAX_VALUE))) + (t/is (= Integer/MIN_VALUE (roundtrip Integer/MIN_VALUE)))) + +(t/deftest long-roundtrip + (t/is (= Long/MAX_VALUE (roundtrip Long/MAX_VALUE))) + (t/is (= Long/MIN_VALUE (roundtrip Long/MIN_VALUE))) + (t/is (= 1000000000000 (roundtrip 1000000000000)))) + +;; --------------------------------------------------------------------------- +;; Doubles / floats +;; --------------------------------------------------------------------------- + +(t/deftest double-roundtrip + (t/is (= 0.0 (roundtrip 0.0))) + (t/is (= 3.14 (roundtrip 3.14))) + (t/is (= -2.718 (roundtrip -2.718))) + (t/is (= Double/MAX_VALUE (roundtrip Double/MAX_VALUE))) + (t/is (= Double/MIN_VALUE (roundtrip Double/MIN_VALUE))) + (t/is (Double/isInfinite ^double (roundtrip Double/POSITIVE_INFINITY))) + (t/is (Double/isInfinite ^double (roundtrip Double/NEGATIVE_INFINITY))) + (t/is (Double/isNaN ^double (roundtrip Double/NaN)))) + +;; --------------------------------------------------------------------------- +;; Strings +;; --------------------------------------------------------------------------- + +(t/deftest string-roundtrip + (t/is (= "" (roundtrip ""))) + (t/is (= "hello" (roundtrip "hello"))) + (t/is (= "hello world" (roundtrip "hello world"))) + (t/is (= "αβγδ" (roundtrip "αβγδ"))) + (t/is (= "emoji: 🎨" (roundtrip "emoji: 🎨"))) + (t/is (= (apply str (repeat 10000 "x")) (roundtrip (apply str (repeat 10000 "x")))))) + +;; --------------------------------------------------------------------------- +;; Characters (custom "char" handler) +;; --------------------------------------------------------------------------- + +(t/deftest char-roundtrip + (t/is (= \a (roundtrip \a))) + (t/is (= \A (roundtrip \A))) + (t/is (= \space (roundtrip \space))) + (t/is (= \newline (roundtrip \newline))) + (t/is (= \0 (roundtrip \0))) + (t/is (= \ü (roundtrip \ü))) + (t/testing "char type is preserved" + (t/is (char? (roundtrip \x))))) + +;; --------------------------------------------------------------------------- +;; Keywords (custom "clj/keyword" handler) +;; --------------------------------------------------------------------------- + +(t/deftest keyword-roundtrip + (t/is (= :foo (roundtrip :foo))) + (t/is (= :bar (roundtrip :bar))) + (t/is (= :ns/foo (roundtrip :ns/foo))) + (t/is (= :app.common.data/something (roundtrip :app.common.data/something))) + (t/testing "keyword? is preserved" + (t/is (keyword? (roundtrip :anything)))) + (t/testing "namespace is preserved" + (let [kw :my-ns/my-name] + (t/is (= (namespace kw) (namespace (roundtrip kw)))) + (t/is (= (name kw) (name (roundtrip kw))))))) + +;; --------------------------------------------------------------------------- +;; Symbols (custom "clj/symbol" handler) +;; --------------------------------------------------------------------------- + +(t/deftest symbol-roundtrip + (t/is (= 'foo (roundtrip 'foo))) + (t/is (= 'bar (roundtrip 'bar))) + (t/is (= 'ns/foo (roundtrip 'ns/foo))) + (t/is (= 'clojure.core/map (roundtrip 'clojure.core/map))) + (t/testing "symbol? is preserved" + (t/is (symbol? (roundtrip 'anything)))) + (t/testing "namespace is preserved" + (let [sym 'my-ns/my-name] + (t/is (= (namespace sym) (namespace (roundtrip sym)))) + (t/is (= (name sym) (name (roundtrip sym))))))) + +;; --------------------------------------------------------------------------- +;; Vectors (custom "clj/vector" handler) +;; --------------------------------------------------------------------------- + +(t/deftest vector-roundtrip + (t/is (= [] (roundtrip []))) + (t/is (= [1 2 3] (roundtrip [1 2 3]))) + (t/is (= [:a :b :c] (roundtrip [:a :b :c]))) + (t/is (= [nil nil nil] (roundtrip [nil nil nil]))) + (t/is (= [[1 2] [3 4]] (roundtrip [[1 2] [3 4]]))) + (t/is (= ["hello" :world 42] (roundtrip ["hello" :world 42]))) + (t/testing "vector? is preserved" + (t/is (vector? (roundtrip [1 2 3]))))) + +;; --------------------------------------------------------------------------- +;; Sets (custom "clj/set" handler) +;; --------------------------------------------------------------------------- + +(t/deftest set-roundtrip + (t/is (= #{} (roundtrip #{}))) + (t/is (= #{1 2 3} (roundtrip #{1 2 3}))) + (t/is (= #{:a :b :c} (roundtrip #{:a :b :c}))) + (t/is (= #{"x" "y"} (roundtrip #{"x" "y"}))) + (t/testing "set? is preserved" + (t/is (set? (roundtrip #{:foo}))))) + +;; --------------------------------------------------------------------------- +;; Maps (custom "clj/map" handler) +;; --------------------------------------------------------------------------- + +(t/deftest small-map-roundtrip + "Maps with fewer than 8 entries decode as PersistentArrayMap." + (t/is (= {} (roundtrip {}))) + (t/is (= {:a 1} (roundtrip {:a 1}))) + (t/is (= {:a 1 :b 2} (roundtrip {:a 1 :b 2}))) + (t/is (= {:a 1 :b 2 :c 3 :d 4 :e 5 :f 6 :g 7} (roundtrip {:a 1 :b 2 :c 3 :d 4 :e 5 :f 6 :g 7}))) + (t/testing "map? is preserved" + (t/is (map? (roundtrip {:x 1}))))) + +(t/deftest large-map-roundtrip + "Maps with 8+ entries decode as PersistentHashMap (>= 16 kvs in list)." + (let [large (into {} (map (fn [i] [(keyword (str "k" i)) i]) (range 20)))] + (t/is (= large (roundtrip large))) + (t/is (map? (roundtrip large))))) + +(t/deftest map-with-mixed-keys-roundtrip + (let [m {:keyword-key 1 + "string-key" 2 + 42 3}] + (t/is (= m (roundtrip m))))) + +(t/deftest map-with-nil-value-roundtrip + (t/is (= {:a nil :b 2} (roundtrip {:a nil :b 2})))) + +;; --------------------------------------------------------------------------- +;; Sequences (custom "clj/seq" handler) +;; --------------------------------------------------------------------------- + +(t/deftest seq-roundtrip + (let [s (seq [1 2 3])] + (t/is (= (sequence s) (roundtrip s)))) + (let [s (map inc [1 2 3])] + (t/is (= (sequence s) (roundtrip s)))) + (t/testing "result is a sequence" + (t/is (seq? (roundtrip (seq [1 2 3])))))) + +;; --------------------------------------------------------------------------- +;; Ratio (custom "clj/ratio" handler) +;; --------------------------------------------------------------------------- + +(t/deftest ratio-roundtrip + (t/is (= 1/3 (roundtrip 1/3))) + (t/is (= 22/7 (roundtrip 22/7))) + (t/is (= -5/6 (roundtrip -5/6))) + (t/is (= 1/1000000 (roundtrip 1/1000000))) + (t/testing "ratio? is preserved" + (t/is (ratio? (roundtrip 1/3))))) + +;; --------------------------------------------------------------------------- +;; BigInt (custom "clj/bigint" handler) +;; --------------------------------------------------------------------------- + +(t/deftest bigint-roundtrip + (t/is (= 0N (roundtrip 0N))) + (t/is (= 1N (roundtrip 1N))) + (t/is (= -1N (roundtrip -1N))) + (t/is (= 123456789012345678901234567890N (roundtrip 123456789012345678901234567890N))) + (t/is (= -999999999999999999999999999999N (roundtrip -999999999999999999999999999999N))) + (t/testing "bigint? is preserved" + (t/is (instance? clojure.lang.BigInt (roundtrip 42N))))) + +;; --------------------------------------------------------------------------- +;; java.time.Instant (custom "java/instant" handler) +;; --------------------------------------------------------------------------- + +(t/deftest instant-roundtrip + (let [now (Instant/now)] + (t/is (= (.toEpochMilli now) (.toEpochMilli ^Instant (roundtrip now))))) + (t/testing "epoch zero" + (let [epoch (Instant/ofEpochMilli 0)] + (t/is (= epoch (roundtrip epoch))))) + (t/testing "far past" + (let [past (Instant/ofEpochMilli -62135596800000)] + (t/is (= past (roundtrip past))))) + (t/testing "far future" + (let [future (Instant/ofEpochMilli 32503680000000)] + (t/is (= future (roundtrip future))))) + (t/testing "result type is Instant" + (t/is (instance? Instant (roundtrip (Instant/now)))))) + +;; --------------------------------------------------------------------------- +;; java.time.OffsetDateTime (written as "java/instant", read back as Instant) +;; --------------------------------------------------------------------------- + +(t/deftest offset-date-time-roundtrip + (t/testing "OffsetDateTime is written and decoded as Instant (millis preserved)" + (let [odt (OffsetDateTime/now ZoneOffset/UTC) + millis (.toEpochMilli (.toInstant odt)) + result (roundtrip odt)] + (t/is (instance? Instant result)) + (t/is (= millis (.toEpochMilli ^Instant result))))) + (t/testing "non-UTC offset" + (let [odt (OffsetDateTime/now (ZoneOffset/ofHours 5)) + millis (.toEpochMilli (.toInstant odt)) + result (roundtrip odt)] + (t/is (= millis (.toEpochMilli ^Instant result)))))) + +;; --------------------------------------------------------------------------- +;; Ordered map (custom "linked/map" handler) +;; --------------------------------------------------------------------------- + +(t/deftest ordered-map-roundtrip + (t/is (= (d/ordered-map) (roundtrip (d/ordered-map)))) + (t/is (= (d/ordered-map :a 1) (roundtrip (d/ordered-map :a 1)))) + (t/is (= (d/ordered-map :a 1 :b 2 :c 3) (roundtrip (d/ordered-map :a 1 :b 2 :c 3)))) + (t/testing "ordered-map? is preserved" + (t/is (d/ordered-map? (roundtrip (d/ordered-map :x 1 :y 2))))) + (t/testing "insertion order is preserved" + (let [om (d/ordered-map :c 3 :a 1 :b 2) + rt (roundtrip om)] + (t/is (= [:c :a :b] (vec (keys rt)))))) + (t/testing "large ordered-map" + (let [om (reduce (fn [m i] (assoc m (keyword (str "k" i)) i)) + (d/ordered-map) + (range 20)) + rt (roundtrip om)] + (t/is (d/ordered-map? rt)) + (t/is (= om rt)) + (t/is (= (keys om) (keys rt)))))) + +;; --------------------------------------------------------------------------- +;; Ordered set (custom "linked/set" handler) +;; --------------------------------------------------------------------------- + +(t/deftest ordered-set-roundtrip + (t/is (= (d/ordered-set) (roundtrip (d/ordered-set)))) + (t/is (= (d/ordered-set :a) (roundtrip (d/ordered-set :a)))) + (t/is (= (d/ordered-set :a :b :c) (roundtrip (d/ordered-set :a :b :c)))) + (t/testing "ordered-set? is preserved" + (t/is (d/ordered-set? (roundtrip (d/ordered-set :x :y))))) + (t/testing "insertion order is preserved" + (let [os (d/ordered-set :c :a :b) + rt (roundtrip os)] + (t/is (= [:c :a :b] (vec rt))))) + (t/testing "large ordered-set" + (let [os (reduce conj (d/ordered-set) (range 20)) + rt (roundtrip os)] + (t/is (d/ordered-set? rt)) + (t/is (= os rt))))) + +;; --------------------------------------------------------------------------- +;; UUID (handled by built-in Fressian handlers) +;; --------------------------------------------------------------------------- + +(t/deftest uuid-roundtrip + (let [id (java.util.UUID/randomUUID)] + (t/is (= id (roundtrip id)))) + (t/testing "nil UUID" + (let [nil-uuid (java.util.UUID/fromString "00000000-0000-0000-0000-000000000000")] + (t/is (= nil-uuid (roundtrip nil-uuid))))) + (t/testing "max UUID" + (let [max-uuid (java.util.UUID/fromString "ffffffff-ffff-ffff-ffff-ffffffffffff")] + (t/is (= max-uuid (roundtrip max-uuid))))) + (t/testing "specific well-known UUID" + (let [id (java.util.UUID/fromString "550e8400-e29b-41d4-a716-446655440000")] + (t/is (= id (roundtrip id))))) + (t/testing "uuid? is preserved" + (t/is (uuid? (roundtrip (java.util.UUID/randomUUID)))))) + +;; --------------------------------------------------------------------------- +;; Nested and mixed structures +;; --------------------------------------------------------------------------- + +(t/deftest nested-map-roundtrip + (let [nested {:a {:b {:c 42 :d [1 2 3]} :e :keyword} :f "string"}] + (t/is (= nested (roundtrip nested))))) + +(t/deftest map-with-vector-values + (let [m {:shapes [1 2 3] :colors [:red :green :blue]}] + (t/is (= m (roundtrip m))))) + +(t/deftest vector-of-maps + (let [v [{:id 1 :name "a"} {:id 2 :name "b"} {:id 3 :name "c"}]] + (t/is (= v (roundtrip v))))) + +(t/deftest mixed-collection-types + (let [data {:vec [1 2 3] + :set #{:a :b :c} + :map {:nested true} + :kw :some/keyword + :sym 'some/symbol + :bigint 12345678901234567890N + :ratio 22/7 + :str "hello" + :num 42 + :bool true + :nil-val nil}] + (t/is (= data (roundtrip data))))) + +(t/deftest deeply-nested-structure + (let [data (reduce (fn [acc i] {:level i :child acc}) + {:leaf true} + (range 20))] + (t/is (= data (roundtrip data))))) + +(t/deftest penpot-like-shape-map + "Simulates a Penpot shape-like structure with UUIDs, keywords, and nested maps." + (let [id (java.util.UUID/fromString "550e8400-e29b-41d4-a716-446655440001") + frame-id (java.util.UUID/fromString "550e8400-e29b-41d4-a716-446655440002") + shape {:id id + :frame-id frame-id + :type :rect + :name "My Shape" + :x 100.5 + :y 200.0 + :width 300.0 + :height 150.0 + :fills [{:fill-color "#FF0000" :fill-opacity 1.0}] + :strokes [] + :hidden false + :blocked false}] + (t/is (= shape (roundtrip shape))))) + +(t/deftest penpot-like-objects-map + "Simulates a Penpot page objects map with multiple shapes." + (let [ids (mapv #(java.util.UUID/fromString + (format "550e8400-e29b-41d4-a716-%012d" %)) + (range 5)) + objs (into {} (map (fn [id] [id {:id id :type :rect :name (str id)}]) ids)) + data {:objects objs}] + (t/is (= data (roundtrip data))))) + +;; --------------------------------------------------------------------------- +;; Idempotency: encode→decode→encode must yield equal bytes +;; --------------------------------------------------------------------------- + +(t/deftest encode-idempotency + (doseq [v [nil true false 0 1 -1 42 Long/MAX_VALUE 3.14 "" "hello" + :kw :ns/kw 'sym 'ns/sym + [] [1 2 3] #{} #{:a} {} {:a 1} + 1/3 42N]] + (let [enc1 (fres/encode v) + enc2 (-> v fres/encode fres/decode fres/encode)] + (t/is (= (vec enc1) (vec enc2)) + (str "Idempotency failed for: " (pr-str v)))))) + +;; --------------------------------------------------------------------------- +;; Multiple encode/decode roundtrips in sequence (regression / ordering) +;; --------------------------------------------------------------------------- + +(t/deftest multiple-roundtrips-are-independent + (t/testing "encoding multiple values independently does not cross-contaminate" + (let [a (fres/encode {:key :val-a}) + b (fres/encode {:key :val-b}) + da (fres/decode a) + db (fres/decode b)] + (t/is (= {:key :val-a} da)) + (t/is (= {:key :val-b} db)) + (t/is (not= da db))))) + +;; --------------------------------------------------------------------------- +;; Edge cases: empty collections +;; --------------------------------------------------------------------------- + +(t/deftest empty-collections-roundtrip + (t/is (= {} (roundtrip {}))) + (t/is (= [] (roundtrip []))) + (t/is (= #{} (roundtrip #{}))) + (t/is (= "" (roundtrip ""))) + (t/is (= (d/ordered-map) (roundtrip (d/ordered-map)))) + (t/is (= (d/ordered-set) (roundtrip (d/ordered-set))))) + +;; --------------------------------------------------------------------------- +;; Edge cases: collections containing nil +;; --------------------------------------------------------------------------- + +(t/deftest collections-with-nil-roundtrip + (t/is (= [nil] (roundtrip [nil]))) + (t/is (= [nil nil nil] (roundtrip [nil nil nil]))) + (t/is (= {:a nil :b nil} (roundtrip {:a nil :b nil}))) + (t/is (= [1 nil 3] (roundtrip [1 nil 3])))) + +;; --------------------------------------------------------------------------- +;; Edge cases: single-element collections +;; --------------------------------------------------------------------------- + +(t/deftest single-element-collections + (t/is (= [42] (roundtrip [42]))) + (t/is (= #{:only} (roundtrip #{:only}))) + (t/is (= {:only-key "only-val"} (roundtrip {:only-key "only-val"})))) + +;; --------------------------------------------------------------------------- +;; Edge cases: boundary map sizes (ArrayMap/HashMap threshold) +;; --------------------------------------------------------------------------- + +(t/deftest map-size-boundary + (t/testing "7-entry map (below threshold → ArrayMap)" + (let [m (into {} (map (fn [i] [(keyword (str "k" i)) i]) (range 7)))] + (t/is (= m (roundtrip m))))) + (t/testing "8-entry map (at/above threshold → may become HashMap)" + (let [m (into {} (map (fn [i] [(keyword (str "k" i)) i]) (range 8)))] + (t/is (= m (roundtrip m))))) + (t/testing "16-entry map (well above threshold)" + (let [m (into {} (map (fn [i] [(keyword (str "k" i)) i]) (range 16)))] + (t/is (= m (roundtrip m)))))) + +;; --------------------------------------------------------------------------- +;; Edge cases: byte arrays +;; --------------------------------------------------------------------------- + +(t/deftest byte-array-roundtrip + (let [data (byte-array [0 1 2 3 127 -128 -1])] + (t/is (= (vec data) (vec ^bytes (roundtrip data)))))) + +;; --------------------------------------------------------------------------- +;; Ordered-map key ordering survives large number of keys +;; --------------------------------------------------------------------------- + +(t/deftest ordered-map-key-ordering-stress + (let [keys-in-order (mapv #(keyword (str "key-" (format "%03d" %))) (range 50)) + om (reduce (fn [m k] (assoc m k (name k))) (d/ordered-map) keys-in-order) + rt (roundtrip om)] + (t/is (= keys-in-order (vec (keys rt)))))) + +;; --------------------------------------------------------------------------- +;; Ordered-set element ordering survives large number of elements +;; --------------------------------------------------------------------------- + +(t/deftest ordered-set-element-ordering-stress + (let [elems-in-order (mapv #(keyword (str "elem-" (format "%03d" %))) (range 50)) + os (reduce conj (d/ordered-set) elems-in-order) + rt (roundtrip os)] + (t/is (= elems-in-order (vec rt))))) + +;; --------------------------------------------------------------------------- +;; Complex Penpot-domain: ordered-map with UUID keys and shape values +;; --------------------------------------------------------------------------- + +(t/deftest ordered-map-with-uuid-keys + (let [ids (mapv #(java.util.UUID/fromString + (format "550e8400-e29b-41d4-a716-%012d" %)) + (range 5)) + om (reduce (fn [m id] (assoc m id {:type :rect :id id})) + (d/ordered-map) + ids) + rt (roundtrip om)] + (t/is (d/ordered-map? rt)) + (t/is (= om rt)) + (t/is (= (keys om) (keys rt))))) diff --git a/common/test/common_tests/geom_flex_layout_test.cljc b/common/test/common_tests/geom_flex_layout_test.cljc new file mode 100644 index 0000000000..bc63b03c8c --- /dev/null +++ b/common/test/common_tests/geom_flex_layout_test.cljc @@ -0,0 +1,106 @@ +;; 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 common-tests.geom-flex-layout-test + (:require + [app.common.geom.rect :as grc] + [app.common.geom.shapes.flex-layout.positions :as flp] + [app.common.math :as mth] + [app.common.types.shape :as cts] + [app.common.types.shape.layout :as ctl] + [clojure.test :as t])) + +;; ---- helpers ---- + +(defn- make-col-frame + "Minimal col? flex frame with wrap enabled. + wrap is required for the content-around? predicate to activate." + [& {:as opts}] + (cts/setup-shape (merge {:type :frame + :layout :flex + :layout-flex-dir :column + :layout-wrap-type :wrap + :x 0 :y 0 :width 200 :height 200} + opts))) + +(defn- rect->bounds + "Convert a rect to the 4-point layout-bounds vector expected by gpo/*." + [rect] + (grc/rect->points rect)) + +;; ---- get-base-line (around? branch) ---- +;; +;; Bug: in positions.cljc the col? + around? branch had a mis-parenthesised +;; expression `(/ free-width num-lines) 2`, which was parsed as three +;; arguments to `max`: +;; (max lines-gap-col (/ free-width num-lines) 2) +;; instead of the intended two-argument max with a nested division: +;; (max lines-gap-col (/ free-width num-lines 2)) +;; +;; For a col? layout the cross-axis is horizontal (hv), so the around? offset +;; is applied as hv(delta) — i.e. the delta ends up in (:x base-p). + +(t/deftest get-base-line-around-uses-half-per-line-free-width + (t/testing "col? + content-around? offset is free-width / num-lines / 2" + ;; Layout: col? wrap, width=200, 3 lines each 20px wide → free-width=140 + ;; lines-gap-col = 0 (no gap defined) + ;; Expected horizontal offset = max(0, 140/3/2) ≈ 23.33 + ;; Before the bug fix the formula was (max ... (/ 140 3) 2) ≈ 46.67. + (let [frame (make-col-frame :layout-align-content :space-around) + bounds (rect->bounds (grc/make-rect 0 0 200 200)) + ;; 3 lines of 20px each (widths); no row gap + num-lines 3 + total-width 60 + total-height 0 + base-p (flp/get-base-line frame bounds total-width total-height num-lines) + free-width (- 200 total-width) + ;; lines-gap-col = (dec 3) * 0 = 0; max(0, free-width/num-lines/2) + expected-x (/ free-width num-lines 2)] + + ;; The base point x-coordinate (hv offset) should equal half per-line free space. + (t/is (mth/close? expected-x (:x base-p) 0.01)))) + + (t/testing "col? + content-around? offset respects lines-gap-col minimum" + ;; When the accumulated column gap exceeds the computed half-per-line value + ;; max(lines-gap-col, free-width/num-lines/2) returns the gap. + (let [frame (make-col-frame :layout-align-content :space-around + :layout-gap {:column-gap 50 :row-gap 0}) + bounds (rect->bounds (grc/make-rect 0 0 200 200)) + ;; 4 lines × 20px = 80px used; free-width=120; half-per-line = 120/4/2 = 15 + ;; lines-gap-col = (dec 4)*50 = 150 → max(150, 15) = 150 + num-lines 4 + total-width 80 + total-height 0 + base-p (flp/get-base-line frame bounds total-width total-height num-lines) + lines-gap-col (* (dec num-lines) 50)] + + (t/is (mth/close? lines-gap-col (:x base-p) 0.01))))) + +;; ---- v-end? guard (drop-line-area) ---- +;; +;; Bug: `v-end?` inside `drop-line-area` was guarded by `row?` instead of +;; `col?`, so vertical-end alignment in a column layout was never triggered. +;; We verify the predicate behaviour directly via ctl/v-end?. + +(t/deftest v-end-guard-uses-col-not-row + (t/testing "v-end? is true for col? frame with justify-content :end" + ;; col? + justify-content=:end → ctl/v-end? must be true + (let [frame (cts/setup-shape {:type :frame + :layout :flex + :layout-flex-dir :column + :layout-justify-content :end + :x 0 :y 0 :width 100 :height 100})] + (t/is (true? (ctl/v-end? frame))))) + + (t/testing "v-end? is false for row? frame with only justify-content :end" + ;; row? + justify-content=:end alone does NOT set v-end?; for row layouts + ;; v-end? checks align-items, not justify-content. + (let [frame (cts/setup-shape {:type :frame + :layout :flex + :layout-flex-dir :row + :layout-justify-content :end + :x 0 :y 0 :width 100 :height 100})] + (t/is (not (ctl/v-end? frame)))))) diff --git a/common/test/common_tests/geom_grid_layout_test.cljc b/common/test/common_tests/geom_grid_layout_test.cljc new file mode 100644 index 0000000000..369406ef38 --- /dev/null +++ b/common/test/common_tests/geom_grid_layout_test.cljc @@ -0,0 +1,410 @@ +;; 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 common-tests.geom-grid-layout-test + (:require + ;; Requiring modifiers triggers the side-effect that wires + ;; -child-min-width / -child-min-height into grid layout-data. + [app.common.geom.modifiers] + [app.common.geom.rect :as grc] + [app.common.geom.shapes.grid-layout.layout-data :as gld] + [app.common.math :as mth] + [app.common.types.shape :as cts] + [clojure.test :as t])) + +;; --------------------------------------------------------------------------- +;; Shared test-data builders +;; --------------------------------------------------------------------------- + +(defn- make-grid-frame + "Minimal grid-layout frame with two fixed columns of 50.0 px + and one fixed row. Width and height are explicit, no padding. + Track values are floats to avoid JVM integer-divide-by-zero when + there are no flex tracks (column-frs = 0)." + [& {:as opts}] + (cts/setup-shape + (merge {:type :frame + :layout :grid + :layout-grid-dir :row + :layout-grid-columns [{:type :fixed :value 50.0} + {:type :fixed :value 50.0}] + :layout-grid-rows [{:type :fixed :value 100.0}] + :layout-grid-cells {} + :layout-padding-type :multiple + :layout-padding {:p1 0 :p2 0 :p3 0 :p4 0} + :layout-gap {:column-gap 0 :row-gap 0} + :x 0 :y 0 :width 200 :height 100} + opts))) + +(defn- bounds-for + "Return the 4-point layout-bounds for the frame." + [frame] + (grc/rect->points (grc/make-rect (:x frame) (:y frame) (:width frame) (:height frame)))) + +;; Build a simple non-fill child shape with explicit width/height. +;; No layout-item-margin → child-width-margin = 0. +(defn- make-child + [w h] + (cts/setup-shape {:type :rect :width w :height h :x 0 :y 0})) + +;; Build the 4-point bounds vector for a child with the given dimensions. +(defn- child-bounds + [w h] + (grc/rect->points (grc/make-rect 0 0 w h))) + +;; Build an auto track at its initial size (0.01) with infinite max. +(defn- auto-track [] {:type :auto :size 0.01 :max-size ##Inf}) + +;; Build a fixed track with the given size. +(defn- fixed-track [v] + {:type :fixed :value v :size (double v) :max-size (double v)}) + +;; Build a flex track (value = number of fr units) at initial size 0.01. +(defn- flex-track [fr] + {:type :flex :value fr :size 0.01 :max-size ##Inf}) + +;; Build a parent frame for column testing with given column-gap. +(defn- auto-col-parent + ([] (auto-col-parent 0)) + ([column-gap] + (cts/setup-shape + {:type :frame + :layout :grid + :layout-grid-dir :row + :layout-padding-type :multiple + :layout-padding {:p1 0 :p2 0 :p3 0 :p4 0} + :layout-gap {:column-gap column-gap :row-gap 0} + :x 0 :y 0 :width 500 :height 500}))) + +;; Build a parent frame for row type testing with given row-gap. +(defn- auto-row-parent + ([] (auto-row-parent 0)) + ([row-gap] + (cts/setup-shape + {:type :frame + :layout :grid + :layout-grid-dir :row + :layout-padding-type :multiple + :layout-padding {:p1 0 :p2 0 :p3 0 :p4 0} + :layout-gap {:column-gap 0 :row-gap row-gap} + :x 0 :y 0 :width 500 :height 500}))) + +;; Generic frame-bounds (large enough not to interfere). +(def ^:private frame-bounds + (grc/rect->points (grc/make-rect 0 0 500 500))) + +;; Build a cell map for a single shape occupying column/row at given span. +;; col and row are 1-based. +(defn- make-cell + [shape-id col row col-span row-span] + {:shapes [shape-id] + :column col :column-span col-span + :row row :row-span row-span}) + +;; --------------------------------------------------------------------------- +;; Note on set-auto-multi-span indexing +;; --------------------------------------------------------------------------- +;; +;; Inside set-auto-multi-span, indexed-tracks is computed as: +;; from-idx = clamp(col - 1, 0, count-1) +;; to-idx = clamp((col - 1) + col-span, 0, count-1) +;; indexed-tracks = subvec(enumerate(tracks), from-idx, to-idx) +;; +;; Because to-idx is clamped to (dec count), the LAST track of the span is +;; always excluded unless there is at least one extra track beyond the span. +;; +;; Practical implication for tests: to cover N spanned tracks, provide a +;; track-list with at least N+1 tracks (the extra track acts as a sentinel +;; that absorbs the off-by-one from the clamp). +;; +;; Example: col=1, span=2, 3 total tracks: +;; to-idx = clamp(0+2, 0, 2) = 2 → subvec(v, 0, 2) = [track0, track1] ✓ +;; +;; Tests that deliberately check boundary behavior (flex exclusion, +;; non-spanned tracks) use 2 total tracks so only track 0 is covered. + +;; --------------------------------------------------------------------------- +;; Tests: column-gap with justify-content (case → cond fix) +;; --------------------------------------------------------------------------- +;; +;; In get-cell-data, column-gap and row-gap were computed with (case ...) +;; using boolean locals as dispatch values. case compares compile-time +;; constants, so those branches never matched at runtime. Fixed with cond. + +(t/deftest grid-column-gap-space-evenly + (t/testing "justify-content :space-evenly increases column-gap correctly" + ;; 2 fixed cols × 50 px = 100 px occupied; bound-width = 200; free = 100 + ;; formula: free / (num-cols + 1) = 100/3 ≈ 33.33 + (let [frame (make-grid-frame :layout-justify-content :space-evenly + :layout-gap {:column-gap 0 :row-gap 0}) + bounds (bounds-for frame) + result (gld/calc-layout-data frame bounds [] {} {}) + col-gap (:column-gap result)] + (t/is (mth/close? (/ 100.0 3.0) col-gap 0.01))))) + +(t/deftest grid-column-gap-space-around + (t/testing "justify-content :space-around increases column-gap correctly" + ;; free = 100; formula: 100 / num-cols = 100/2 = 50 + (let [frame (make-grid-frame :layout-justify-content :space-around + :layout-gap {:column-gap 0 :row-gap 0}) + bounds (bounds-for frame) + result (gld/calc-layout-data frame bounds [] {} {}) + col-gap (:column-gap result)] + (t/is (mth/close? 50.0 col-gap 0.01))))) + +(t/deftest grid-column-gap-space-between + (t/testing "justify-content :space-between increases column-gap correctly" + ;; free = 100; num-cols = 2; formula: 100 / (2-1) = 100 + (let [frame (make-grid-frame :layout-justify-content :space-between + :layout-gap {:column-gap 0 :row-gap 0}) + bounds (bounds-for frame) + result (gld/calc-layout-data frame bounds [] {} {}) + col-gap (:column-gap result)] + (t/is (mth/close? 100.0 col-gap 0.01))))) + +(t/deftest grid-column-gap-auto-width-bypasses-justify-content + (t/testing "auto-width? bypasses justify-content gap recalc → gap stays as initial" + (let [frame (make-grid-frame :layout-justify-content :space-evenly + :layout-gap {:column-gap 5 :row-gap 0} + :layout-item-h-sizing :auto) + bounds (bounds-for frame) + result (gld/calc-layout-data frame bounds [] {} {}) + col-gap (:column-gap result)] + (t/is (mth/close? 5.0 col-gap 0.01))))) + +;; --------------------------------------------------------------------------- +;; Tests: set-auto-multi-span +;; --------------------------------------------------------------------------- +;; +;; set-auto-multi-span grows auto tracks to accommodate children whose cell +;; spans more than one track column (or row), but only for spans that contain +;; no flex tracks (those are handled by set-flex-multi-span). +;; +;; The function signature: +;; (set-auto-multi-span parent track-list children-map shape-cells +;; bounds objects type) +;; type – :column or :row +;; children-map – {shape-id [child-bounds child-shape]} +;; shape-cells – {cell-id cell-map} + +(t/deftest set-auto-multi-span-span-1-cells-ignored + (t/testing "span=1 cells are filtered out; track-list is unchanged" + (let [sid (random-uuid) + child (make-child 200 100) + ;; 2 tracks + 1 sentinel (so the span would cover tracks 0-1 if span were 2) + tracks [(auto-track) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 1 1 1 1)} ; span = 1 → ignored + cmap {sid [(child-bounds 200 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 0.01 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-empty-cells + (t/testing "empty shape-cells → track-list unchanged" + (let [tracks [(auto-track) (auto-track)] + result (gld/set-auto-multi-span (auto-col-parent) tracks {} {} frame-bounds {} :column)] + (t/is (mth/close? 0.01 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 1)) 0.001))))) + +(t/deftest set-auto-multi-span-two-auto-tracks-split-evenly + (t/testing "child spanning 2 auto tracks (with sentinel): budget split between the 2 covered tracks" + ;; 3 tracks total (sentinel at index 2 keeps to-idx from being clamped). + ;; col=1, span=2: + ;; from-idx = clamp(0, 0, 2) = 0 + ;; to-idx = clamp(2, 0, 2) = 2 + ;; subvec(enumerate, 0, 2) = [[0, auto0], [1, auto1]] + ;; size-to-allocate = 200 (child width, no gap) + ;; allocate-auto-tracks pass 1 (non-assigned = both): + ;; idx0: max(0.01, 200/2, 0.01) = 100; rem = 100 + ;; idx1: max(0.01, 100/1, 0.01) = 100; rem = 0 + ;; pass 2 (to-allocate=0): no change → both 100 + ;; sentinel track 2 is never spanned → stays at 0.01. + (let [sid (random-uuid) + child (make-child 200 100) + tracks [(auto-track) (auto-track) (auto-track)] ; sentinel at [2] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 200 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 100.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001)) + ;; sentinel unaffected + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-gap-deducted-from-budget + (t/testing "column-gap is subtracted once per extra span track from size-to-allocate" + ;; child width = 210, column-gap = 10, span = 2 + ;; size-to-allocate = child-min-width - gap*(span-1) = 210 - 10*1 = 200 + ;; 3 tracks (sentinel at [2]) → indexed = [[0,auto],[1,auto]] + ;; each auto track gets 100 + (let [sid (random-uuid) + child (make-child 210 100) + tracks [(auto-track) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 210 100) child]} + result (gld/set-auto-multi-span (auto-col-parent 10) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 100.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-fixed-track-reduces-budget + (t/testing "fixed track in span is deducted from budget; only the auto track grows" + ;; tracks: [fixed 60, auto 0.01, auto-sentinel] (sentinel at [2]) + ;; col=1, span=2 → indexed = [[0, fixed60], [1, auto]] + ;; find-auto-allocations: fixed→subtract 60; auto→keep + ;; to-allocate after fixed = 200 - 60 = 140; indexed-auto = [[1, auto]] + ;; pass 1: idx1: max(0.01, 140/1, 0.01) = 140 + ;; apply: track0 = max(60, 0) = 60; track1 = max(0.01, 140) = 140 + (let [sid (random-uuid) + child (make-child 200 100) + tracks [(fixed-track 60) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 200 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 60.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 140.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-child-smaller-than-existing-tracks + (t/testing "when child is smaller than the existing track sizes, tracks are not shrunk" + ;; tracks: [auto 80, auto 80, auto-sentinel] + ;; child width = 50; size-to-allocate = 50 + ;; indexed = [[0, auto80], [1, auto80]] + ;; pass 1 (non-assigned, to-alloc=50): + ;; idx0: max(0.01, 50/2, 80) = 80; rem = 50-80 = -30 + ;; idx1: max(0.01, max(-30,0)/1, 80) = 80 + ;; pass 2 (to-alloc=max(-30,0)=0): same max, no change + ;; both tracks stay at 80 + (let [sid (random-uuid) + child (make-child 50 100) + tracks [{:type :auto :size 80.0 :max-size ##Inf} + {:type :auto :size 80.0 :max-size ##Inf} + (auto-track)] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 50 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 80.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 80.0 (:size (nth result 1)) 0.001))))) + +(t/deftest set-auto-multi-span-flex-track-in-span-excluded + (t/testing "cells whose span contains a flex track are skipped (handled by set-flex-multi-span)" + ;; tracks: [flex 1fr, auto] col=1, span=2 → has-flex-track? = true → cell excluded + ;; 2 tracks total (no sentinel needed since the cell is excluded before indexing) + (let [sid (random-uuid) + child (make-child 300 100) + tracks [(flex-track 1) (auto-track)] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 300 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 0.01 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 1)) 0.001))))) + +(t/deftest set-auto-multi-span-non-spanned-track-unaffected + (t/testing "tracks outside the span keep their size – tests (get allocated %1 0) default" + ;; 4 tracks; child at col=2 span=2 → indexed covers tracks 1 and 2 (sentinel [3]). + ;; Track 0 (before the span) and track 3 (sentinel) are never allocated. + ;; from-idx = clamp(2-1, 0, 3) = 1 + ;; to-idx = clamp((2-1)+2, 0, 3) = 3 + ;; subvec(enumerate, 1, 3) = [[1,auto],[2,auto]] + ;; size-to-allocate = 200 → both indexed tracks get 100 + ;; apply: track0 = max(0.01, get({},0,0)) = max(0.01,0) = 0.01 ← uses default 0 + ;; track1 = max(0.01, 100) = 100 + ;; track2 = max(0.01, 100) = 100 + ;; track3 = max(0.01, get({},3,0)) = 0.01 (sentinel) + (let [sid (random-uuid) + child (make-child 200 100) + tracks [(auto-track) (auto-track) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 2 1 2 1)} + cmap {sid [(child-bounds 200 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + ;; track before span: size stays at 0.01 (default 0 from missing allocation entry) + (t/is (mth/close? 0.01 (:size (nth result 0)) 0.001)) + ;; spanned tracks grow + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 2)) 0.001)) + ;; sentinel after span also unaffected + (t/is (mth/close? 0.01 (:size (nth result 3)) 0.001))))) + +(t/deftest set-auto-multi-span-row-type + (t/testing ":row type uses :row/:row-span and grows row tracks by child height" + ;; child height = 200, row-gap = 0, row=1 span=2, 3 row tracks (sentinel at [2]) + ;; from-idx=0, to-idx=clamp(2,0,2)=2 → [[0,auto],[1,auto]] + ;; size-to-allocate = 200 → each row track gets 100 + (let [sid (random-uuid) + child (make-child 100 200) + tracks [(auto-track) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 1 1 1 2)} + cmap {sid [(child-bounds 100 200) child]} + result (gld/set-auto-multi-span (auto-row-parent) tracks cmap cells frame-bounds {} :row)] + (t/is (mth/close? 100.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-row-gap-deducted + (t/testing "row-gap is deducted from budget for :row type" + ;; child height = 210, row-gap = 10, row-span = 2 + ;; size-to-allocate = 210 - 10*1 = 200 → each track gets 100 + (let [sid (random-uuid) + child (make-child 100 210) + tracks [(auto-track) (auto-track) (auto-track)] + cells {:c1 (make-cell sid 1 1 1 2)} + cmap {sid [(child-bounds 100 210) child]} + result (gld/set-auto-multi-span (auto-row-parent 10) tracks cmap cells frame-bounds {} :row)] + (t/is (mth/close? 100.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 2)) 0.001))))) + +(t/deftest set-auto-multi-span-smaller-span-processed-first + (t/testing "cells are sorted by span ascending (sort-by span -): smaller span allocates first" + ;; NOTE: (sort-by prop-span -) uses `-` as a comparator; this yields ascending + ;; order (smaller span first), not descending as the code comment implies. + ;; + ;; 4 tracks (sentinel at [3]): + ;; cell-B: col=1 span=2 (covers indexed [0,1]) – processed first (span=2) + ;; cell-A: col=1 span=3 (covers indexed [0,1,2]) – processed second (span=3) + ;; + ;; cell-B: child=100px, to-allocate=100. + ;; non-assigned=[0,1]; pass1: idx0→max(0.01,50,0.01)=50; idx1→max(0.01,50,0.01)=50 + ;; allocated = {0:50, 1:50} + ;; + ;; cell-A: child=300px, to-allocate=300. + ;; indexed=[0,1,2]; non-assigned=[2] (tracks 0,1 already allocated) + ;; pass1 (non-assigned only): idx2→max(0.01,300/1,0.01)=300 ; rem=0 + ;; pass2 (to-alloc=0): max preserves existing values → no change + ;; allocated = {0:50, 1:50, 2:300} + ;; + ;; Final: track0=50, track1=50, track2=300, track3(sentinel)=0.01 + (let [sid-a (random-uuid) + sid-b (random-uuid) + child-a (make-child 300 100) + child-b (make-child 100 100) + tracks [(auto-track) (auto-track) (auto-track) (auto-track)] ; sentinel at [3] + cells {:ca (make-cell sid-a 1 1 3 1) + :cb (make-cell sid-b 1 1 2 1)} + cmap {sid-a [(child-bounds 300 100) child-a] + sid-b [(child-bounds 100 100) child-b]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 50.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 50.0 (:size (nth result 1)) 0.001)) + (t/is (mth/close? 300.0 (:size (nth result 2)) 0.001)) + (t/is (mth/close? 0.01 (:size (nth result 3)) 0.001))))) + +(t/deftest set-auto-multi-span-all-fixed-tracks-in-span + (t/testing "when all spanned tracks are fixed, no auto allocation occurs; fixed tracks unchanged" + ;; tracks: [fixed 100, fixed 100, auto-sentinel] + ;; col=1, span=2 → indexed = [[0,fixed100],[1,fixed100]] + ;; find-auto-allocations: both fixed → auto-indexed-tracks = [] + ;; allocate-auto-tracks on empty list → no entries in allocated map + ;; apply: track0 = max(100, get({},0,0)) = max(100,0) = 100 (unchanged) + ;; track1 = max(100, get({},1,0)) = max(100,0) = 100 (unchanged) + (let [sid (random-uuid) + child (make-child 50 100) + tracks [(fixed-track 100) (fixed-track 100) (auto-track)] + cells {:c1 (make-cell sid 1 1 2 1)} + cmap {sid [(child-bounds 50 100) child]} + result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)] + (t/is (mth/close? 100.0 (:size (nth result 0)) 0.001)) + (t/is (mth/close? 100.0 (:size (nth result 1)) 0.001))))) diff --git a/common/test/common_tests/geom_point_test.cljc b/common/test/common_tests/geom_point_test.cljc index 6ba7239f07..2d0e2e6468 100644 --- a/common/test/common_tests/geom_point_test.cljc +++ b/common/test/common_tests/geom_point_test.cljc @@ -289,3 +289,33 @@ (t/is (mth/close? 1.2091818119288809 (:x rs))) (t/is (mth/close? 1.8275638211757912 (:y rs))))) +;; ---- gpt/abs ---- + +(t/deftest abs-point-returns-point-instance + (t/testing "abs of a point with negative coordinates returns a Point record" + (let [p (gpt/point -3 -4) + rs (gpt/abs p)] + (t/is (gpt/point? rs)) + (t/is (mth/close? 3 (:x rs))) + (t/is (mth/close? 4 (:y rs))))) + + (t/testing "abs of a point with mixed-sign coordinates" + (let [p (gpt/point -5 7) + rs (gpt/abs p)] + (t/is (gpt/point? rs)) + (t/is (mth/close? 5 (:x rs))) + (t/is (mth/close? 7 (:y rs))))) + + (t/testing "abs of a point already positive is unchanged" + (let [p (gpt/point 2 9) + rs (gpt/abs p)] + (t/is (gpt/point? rs)) + (t/is (mth/close? 2 (:x rs))) + (t/is (mth/close? 9 (:y rs))))) + + (t/testing "abs of a zero point stays zero" + (let [rs (gpt/abs (gpt/point 0 0))] + (t/is (gpt/point? rs)) + (t/is (mth/close? 0 (:x rs))) + (t/is (mth/close? 0 (:y rs)))))) + diff --git a/common/test/common_tests/geom_rect_test.cljc b/common/test/common_tests/geom_rect_test.cljc new file mode 100644 index 0000000000..8abfb76854 --- /dev/null +++ b/common/test/common_tests/geom_rect_test.cljc @@ -0,0 +1,94 @@ +;; 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 common-tests.geom-rect-test + (:require + [app.common.geom.rect :as grc] + [app.common.math :as mth] + [clojure.test :as t])) + +;; ---- update-rect :size ---- + +(t/deftest update-rect-size-sets-all-corners + (t/testing ":size updates x1/y1 as well as x2/y2 from x/y/width/height" + (let [r (grc/make-rect 10 20 30 40) + r' (grc/update-rect r :size)] + ;; x1/y1 must mirror x/y + (t/is (mth/close? (:x r) (:x1 r'))) + (t/is (mth/close? (:y r) (:y1 r'))) + ;; x2/y2 must be x+width / y+height + (t/is (mth/close? (+ (:x r) (:width r)) (:x2 r'))) + (t/is (mth/close? (+ (:y r) (:height r)) (:y2 r'))))) + + (t/testing ":size is consistent with :corners round-trip" + ;; Applying :size then :corners should recover the original x/y/w/h + (let [r (grc/make-rect 5 15 100 50) + r' (-> r (grc/update-rect :size) (grc/update-rect :corners))] + (t/is (mth/close? (:x r) (:x r'))) + (t/is (mth/close? (:y r) (:y r'))) + (t/is (mth/close? (:width r) (:width r'))) + (t/is (mth/close? (:height r) (:height r'))))) + + (t/testing ":size works for a rect at the origin" + (let [r (grc/make-rect 0 0 200 100) + r' (grc/update-rect r :size)] + (t/is (mth/close? 0 (:x1 r'))) + (t/is (mth/close? 0 (:y1 r'))) + (t/is (mth/close? 200 (:x2 r'))) + (t/is (mth/close? 100 (:y2 r')))))) + +;; ---- corners->rect ---- + +(t/deftest corners->rect-normal-order + (t/testing "p1 top-left, p2 bottom-right yields a valid rect" + (let [r (grc/corners->rect 0 0 10 20)] + (t/is (grc/rect? r)) + (t/is (mth/close? 0 (:x r))) + (t/is (mth/close? 0 (:y r))) + (t/is (mth/close? 10 (:width r))) + (t/is (mth/close? 20 (:height r)))))) + +(t/deftest corners->rect-reversed-corners + (t/testing "reversed x-coordinates still produce a positive-width rect" + (let [r (grc/corners->rect 10 0 0 20)] + (t/is (grc/rect? r)) + (t/is (mth/close? 0 (:x r))) + (t/is (mth/close? 10 (:width r))))) + + (t/testing "reversed y-coordinates still produce a positive-height rect" + (let [r (grc/corners->rect 0 20 10 0)] + (t/is (grc/rect? r)) + (t/is (mth/close? 0 (:y r))) + (t/is (mth/close? 20 (:height r))))) + + (t/testing "both axes reversed yield the same rect as normal order" + (let [r-normal (grc/corners->rect 0 0 10 20) + r-reversed (grc/corners->rect 10 20 0 0)] + (t/is (mth/close? (:x r-normal) (:x r-reversed))) + (t/is (mth/close? (:y r-normal) (:y r-reversed))) + (t/is (mth/close? (:width r-normal) (:width r-reversed))) + (t/is (mth/close? (:height r-normal) (:height r-reversed)))))) + +(t/deftest corners->rect-from-points + (t/testing "two-arity overload taking point maps works identically" + (let [p1 {:x 5 :y 10} + p2 {:x 15 :y 30} + r (grc/corners->rect p1 p2)] + (t/is (grc/rect? r)) + (t/is (mth/close? 5 (:x r))) + (t/is (mth/close? 10 (:y r))) + (t/is (mth/close? 10 (:width r))) + (t/is (mth/close? 20 (:height r))))) + + (t/testing "two-arity overload with reversed points" + (let [p1 {:x 15 :y 30} + p2 {:x 5 :y 10} + r (grc/corners->rect p1 p2)] + (t/is (grc/rect? r)) + (t/is (mth/close? 5 (:x r))) + (t/is (mth/close? 10 (:y r))) + (t/is (mth/close? 10 (:width r))) + (t/is (mth/close? 20 (:height r)))))) diff --git a/common/test/common_tests/geom_shapes_constraints_test.cljc b/common/test/common_tests/geom_shapes_constraints_test.cljc new file mode 100644 index 0000000000..175cc6f77b --- /dev/null +++ b/common/test/common_tests/geom_shapes_constraints_test.cljc @@ -0,0 +1,27 @@ +;; 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 common-tests.geom-shapes-constraints-test + (:require + [app.common.geom.shapes.constraints :as gsc] + [clojure.test :as t])) + +;; ---- constraint-modifier :default ---- + +(t/deftest constraint-modifier-default-returns-empty-vector + (t/testing ":default method accepts 6 args and returns an empty vector" + ;; Before the fix the :default method only accepted 5 positional args + ;; (plus the dispatch value), so calling it with 6 args would throw an + ;; arity error. After the fix it takes [_ _ _ _ _ _] and returns []. + (let [result (gsc/constraint-modifier :unknown-constraint-type + :x nil nil nil nil)] + (t/is (vector? result)) + (t/is (empty? result)))) + + (t/testing ":default method returns [] for :scale-like unknown type on :y axis" + (let [result (gsc/constraint-modifier :some-other-unknown + :y nil nil nil nil)] + (t/is (= [] result))))) diff --git a/common/test/common_tests/geom_shapes_intersect_test.cljc b/common/test/common_tests/geom_shapes_intersect_test.cljc index e6c73b0474..1ae12ef2b7 100644 --- a/common/test/common_tests/geom_shapes_intersect_test.cljc +++ b/common/test/common_tests/geom_shapes_intersect_test.cljc @@ -52,12 +52,10 @@ [(pt 0 5) (pt 10 5)])))) (t/testing "Two collinear overlapping segments" - ;; NOTE: The implementation compares orientation result (namespaced keyword ::coplanar) - ;; against unnamespaced :coplanar, so the collinear branch never triggers. - ;; Collinear overlapping segments are NOT detected as intersecting. - (t/is (false? (gint/intersect-segments? - [(pt 0 0) (pt 10 0)] - [(pt 5 0) (pt 15 0)])))) + ;; Collinear overlapping segments correctly detected as intersecting. + (t/is (true? (gint/intersect-segments? + [(pt 0 0) (pt 10 0)] + [(pt 5 0) (pt 15 0)])))) (t/testing "Two non-overlapping collinear segments" (t/is (false? (gint/intersect-segments? diff --git a/common/test/common_tests/geom_shapes_test.cljc b/common/test/common_tests/geom_shapes_test.cljc index 29401412ec..7e5c83658d 100644 --- a/common/test/common_tests/geom_shapes_test.cljc +++ b/common/test/common_tests/geom_shapes_test.cljc @@ -230,3 +230,44 @@ (t/is (true? (gsin/slow-has-point? shape point1))) (t/is (false? (gsin/fast-has-point? shape point2))) (t/is (false? (gsin/fast-has-point? shape point2))))) + +;; ---- adjust-shape-flips (via apply-transform / transform-shape) ---- + +(t/deftest flip-x-only-toggles-flip-x-and-negates-rotation + (t/testing "Flipping only X axis toggles flip-x and negates rotation" + ;; Build a rect with a known rotation, then apply a scale(-1, 1) + ;; from the left edge to simulate an X-axis flip. + (let [shape (create-test-shape :rect {:rotation 30}) + ;; Flip horizontally about x=0 (left edge of shape) + origin (gpt/point (get-in shape [:selrect :x]) (get-in shape [:selrect :y])) + mods (ctm/resize-modifiers (gpt/point -1 1) origin) + result (gsh/transform-shape shape mods)] + ;; flip-x should have been toggled (from nil/false to true) + (t/is (true? (:flip-x result))) + ;; flip-y should NOT be set + (t/is (not (true? (:flip-y result)))) + ;; rotation is negated then normalised into [0,360): -30 mod 360 = 330 + (t/is (mth/close? 330 (:rotation result)))))) + +(t/deftest flip-y-only-toggles-flip-y-and-negates-rotation + (t/testing "Flipping only Y axis toggles flip-y and negates rotation" + (let [shape (create-test-shape :rect {:rotation 45}) + origin (gpt/point (get-in shape [:selrect :x]) (get-in shape [:selrect :y])) + mods (ctm/resize-modifiers (gpt/point 1 -1) origin) + result (gsh/transform-shape shape mods)] + (t/is (not (true? (:flip-x result)))) + (t/is (true? (:flip-y result))) + ;; -45 mod 360 = 315 + (t/is (mth/close? 315 (:rotation result)))))) + +(t/deftest flip-both-axes-toggles-both-flags-but-preserves-rotation + (t/testing "Flipping both axes toggles flip-x and flip-y, but does NOT negate rotation" + ;; Two simultaneous axis flips = 180° rotation, so stored rotation is unchanged. + (let [shape (create-test-shape :rect {:rotation 30}) + origin (gpt/point (get-in shape [:selrect :x]) (get-in shape [:selrect :y])) + mods (ctm/resize-modifiers (gpt/point -1 -1) origin) + result (gsh/transform-shape shape mods)] + (t/is (true? (:flip-x result))) + (t/is (true? (:flip-y result))) + ;; rotation must not be negated when both axes are flipped + (t/is (mth/close? 30 (:rotation result)))))) diff --git a/common/test/common_tests/geom_test.cljc b/common/test/common_tests/geom_test.cljc index b0d336475a..28560d3544 100644 --- a/common/test/common_tests/geom_test.cljc +++ b/common/test/common_tests/geom_test.cljc @@ -9,6 +9,7 @@ [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.math :as mth] + [app.common.schema :as sm] [clojure.test :as t])) (t/deftest point-constructors-test @@ -100,3 +101,28 @@ (let [m (-> (gmt/matrix) (gmt/rotate 10))] (t/is (= m (gmt/matrix 0.984807753012208 0.17364817766693033 -0.17364817766693033 0.984807753012208 0 0))))) + +;; ---- matrix->str (no trailing comma) ---- + +(t/deftest matrix-str-roundtrip-test + (t/testing "Identity matrix encodes and decodes back to equal matrix" + (let [m (gmt/matrix) + enc (sm/encode gmt/schema:matrix m (sm/string-transformer)) + dec (sm/decode gmt/schema:matrix enc (sm/string-transformer))] + (t/is (string? enc)) + ;; Must not end with a comma + (t/is (not= \, (last enc))) + (t/is (gmt/close? m dec)))) + + (t/testing "Arbitrary matrix encodes without trailing comma and round-trips" + (let [m (gmt/matrix 2 0.5 -0.5 3 10 20) + enc (sm/encode gmt/schema:matrix m (sm/string-transformer)) + dec (sm/decode gmt/schema:matrix enc (sm/string-transformer))] + (t/is (string? enc)) + (t/is (not= \, (last enc))) + (t/is (gmt/close? m dec)))) + + (t/testing "Encoded string contains exactly 5 commas (6 fields)" + (let [m (gmt/matrix 1 0 0 1 0 0) + enc (sm/encode gmt/schema:matrix m (sm/string-transformer))] + (t/is (= 5 (count (filter #(= \, %) enc))))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index b8a9fc8934..1ba7242d58 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -6,21 +6,27 @@ (ns common-tests.runner (:require + #?(:clj [common-tests.fressian-test]) [clojure.test :as t] [common-tests.buffer-test] [common-tests.colors-test] [common-tests.data-test] + [common-tests.files-builder-test] [common-tests.files-changes-test] [common-tests.files-migrations-test] [common-tests.geom-align-test] [common-tests.geom-bounds-map-test] + [common-tests.geom-flex-layout-test] + [common-tests.geom-grid-layout-test] [common-tests.geom-grid-test] [common-tests.geom-line-test] [common-tests.geom-modif-tree-test] [common-tests.geom-modifiers-test] [common-tests.geom-point-test] [common-tests.geom-proportions-test] + [common-tests.geom-rect-test] [common-tests.geom-shapes-common-test] + [common-tests.geom-shapes-constraints-test] [common-tests.geom-shapes-corners-test] [common-tests.geom-shapes-effects-test] [common-tests.geom-shapes-intersect-test] @@ -53,6 +59,7 @@ [common-tests.text-test] [common-tests.time-test] [common-tests.types.absorb-assets-test] + [common-tests.types.color-test] [common-tests.types.components-test] [common-tests.types.container-test] [common-tests.types.fill-test] @@ -81,17 +88,23 @@ 'common-tests.buffer-test 'common-tests.colors-test 'common-tests.data-test + #?(:clj 'common-tests.fressian-test) 'common-tests.files-changes-test + 'common-tests.files-builder-test 'common-tests.files-migrations-test 'common-tests.geom-align-test 'common-tests.geom-bounds-map-test + 'common-tests.geom-flex-layout-test + 'common-tests.geom-grid-layout-test 'common-tests.geom-grid-test 'common-tests.geom-line-test 'common-tests.geom-modif-tree-test 'common-tests.geom-modifiers-test 'common-tests.geom-point-test 'common-tests.geom-proportions-test + 'common-tests.geom-rect-test 'common-tests.geom-shapes-common-test + 'common-tests.geom-shapes-constraints-test 'common-tests.geom-shapes-corners-test 'common-tests.geom-shapes-effects-test 'common-tests.geom-shapes-intersect-test @@ -124,6 +137,7 @@ 'common-tests.text-test 'common-tests.time-test 'common-tests.types.absorb-assets-test + 'common-tests.types.color-test 'common-tests.types.components-test 'common-tests.types.container-test 'common-tests.types.fill-test diff --git a/common/test/common_tests/types/color_test.cljc b/common/test/common_tests/types/color_test.cljc new file mode 100644 index 0000000000..9a3ab00ac9 --- /dev/null +++ b/common/test/common_tests/types/color_test.cljc @@ -0,0 +1,166 @@ +;; 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 common-tests.types.color-test + (:require + [app.common.math :as mth] + [app.common.types.color :as colors] + [clojure.test :as t])) + +;; --- Predicates + +(t/deftest valid-hex-color + (t/is (false? (colors/valid-hex-color? nil))) + (t/is (false? (colors/valid-hex-color? ""))) + (t/is (false? (colors/valid-hex-color? "#"))) + (t/is (false? (colors/valid-hex-color? "#qqqqqq"))) + (t/is (true? (colors/valid-hex-color? "#aaa"))) + (t/is (false? (colors/valid-hex-color? "#aaaa"))) + (t/is (true? (colors/valid-hex-color? "#fabada")))) + +(t/deftest valid-rgb-color + (t/is (false? (colors/valid-rgb-color? nil))) + (t/is (false? (colors/valid-rgb-color? ""))) + (t/is (false? (colors/valid-rgb-color? "()"))) + (t/is (true? (colors/valid-rgb-color? "(255, 30, 30)"))) + (t/is (true? (colors/valid-rgb-color? "rgb(255, 30, 30)")))) + +;; --- Conversions + +(t/deftest rgb-to-str + (t/is (= "rgb(1,2,3)" (colors/rgb->str [1 2 3]))) + (t/is (= "rgba(1,2,3,4)" (colors/rgb->str [1 2 3 4])))) + +(t/deftest rgb-to-hsv + (t/is (= [210.0 0.6666666666666666 3.0] (colors/rgb->hsv [1.0 2.0 3.0])))) + +(t/deftest hsv-to-rgb + (t/is (= [1 2 3] + (colors/hsv->rgb [210 0.6666666666666666 3])))) + +(t/deftest rgb-to-hex + (t/is (= "#010203" (colors/rgb->hex [1 2 3])))) + +(t/deftest hex-to-rgb + (t/is (= [0 0 0] (colors/hex->rgb "#kkk"))) + (t/is (= [1 2 3] (colors/hex->rgb "#010203")))) + +(t/deftest format-hsla + (t/is (= "210, 50%, 0.78%, 1" (colors/format-hsla [210.0 0.5 0.00784313725490196 1]))) + (t/is (= "220, 5%, 30%, 0.8" (colors/format-hsla [220.0 0.05 0.3 0.8])))) + +(t/deftest format-rgba + (t/is (= "210, 199, 12, 0.08" (colors/format-rgba [210 199 12 0.08]))) + (t/is (= "210, 199, 12, 1" (colors/format-rgba [210 199 12 1])))) + +(t/deftest rgb-to-hsl + (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3])))) + +(t/deftest hsl-to-rgb + (t/is (= [1 2 3] (colors/hsl->rgb [210.0 0.5 0.00784313725490196]))) + (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3])))) + +(t/deftest expand-hex + (t/is (= "aaaaaa" (colors/expand-hex "a"))) + (t/is (= "aaaaaa" (colors/expand-hex "aa"))) + (t/is (= "aaaaaa" (colors/expand-hex "aaa"))) + (t/is (= "aaaa" (colors/expand-hex "aaaa")))) + +(t/deftest prepend-hash + (t/is "#aaa" (colors/prepend-hash "aaa")) + (t/is "#aaa" (colors/prepend-hash "#aaa"))) + +(t/deftest remove-hash + (t/is "aaa" (colors/remove-hash "aaa")) + (t/is "aaa" (colors/remove-hash "#aaa"))) + +(t/deftest color-string-pred + (t/is (true? (colors/color-string? "#aaa"))) + (t/is (true? (colors/color-string? "(10,10,10)"))) + (t/is (true? (colors/color-string? "rgb(10,10,10)"))) + (t/is (true? (colors/color-string? "magenta"))) + (t/is (false? (colors/color-string? nil))) + (t/is (false? (colors/color-string? ""))) + (t/is (false? (colors/color-string? "kkkkkk")))) + +;; --- Gradient helpers + +(t/deftest interpolate-color + (t/testing "at c1 offset returns c1 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.0)] + (t/is (= "#000000" (:color result))) + (t/is (= 0.0 (:opacity result))))) + (t/testing "at c2 offset returns c2 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 1.0)] + (t/is (= "#ffffff" (:color result))) + (t/is (= 1.0 (:opacity result))))) + (t/testing "at midpoint returns interpolated gray" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.5)] + (t/is (= "#7f7f7f" (:color result))) + (t/is (mth/close? (:opacity result) 0.5))))) + +(t/deftest uniform-spread + (t/testing "produces correct count and offsets" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (= 3 (count stops))) + (t/is (= 0.0 (:offset (first stops)))) + (t/is (mth/close? 0.5 (:offset (second stops)))) + (t/is (= 1.0 (:offset (last stops)))))) + (t/testing "single stop returns a vector of one element (no division by zero)" + (let [c1 {:color "#ff0000" :opacity 1.0 :offset 0.0} + stops (colors/uniform-spread c1 c1 1)] + (t/is (= 1 (count stops)))))) + +(t/deftest uniform-spread? + (t/testing "uniformly spread stops are detected as uniform" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "two-stop gradient is uniform by definition" + (let [stops [{:color "#ff0000" :opacity 1.0 :offset 0.0} + {:color "#0000ff" :opacity 1.0 :offset 1.0}]] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "stops with wrong offset are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#888888" :opacity 0.5 :offset 0.3} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops))))) + (t/testing "stops with correct offset but wrong color are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#aaaaaa" :opacity 0.5 :offset 0.5} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops)))))) + +(t/deftest interpolate-gradient + (t/testing "at start offset returns first stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.0)] + (t/is (= "#000000" (:color result))))) + (t/testing "at end offset returns last stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result))))) + (t/testing "at midpoint returns interpolated gray" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.5)] + (t/is (= "#7f7f7f" (:color result))))) + (t/testing "offset beyond last stop returns last stop color (nil idx guard)" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 0.5}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result)))))) diff --git a/common/test/common_tests/types/fill_test.cljc b/common/test/common_tests/types/fill_test.cljc index 308778bcc1..f9968e8aed 100644 --- a/common/test/common_tests/types/fill_test.cljc +++ b/common/test/common_tests/types/fill_test.cljc @@ -207,3 +207,18 @@ fill1 (nth fills1 1)] (t/is (nil? fill1)) (t/is (equivalent-fill? fill0 sample-fill-6)))) + +(t/deftest indexed-access-with-default + (t/testing "nth with default returns fill for valid index" + ;; Regression: CLJS -nth with default had reversed d/in-range? args, + ;; so it always fell through to the default even for valid indices. + (let [fills (types.fills/from-plain [sample-fill-6]) + sentinel ::not-found + result (nth fills 0 sentinel)] + (t/is (not= sentinel result)) + (t/is (equivalent-fill? result sample-fill-6)))) + (t/testing "nth with default returns default for out-of-range index" + (let [fills (types.fills/from-plain [sample-fill-6]) + sentinel ::not-found] + (t/is (= sentinel (nth fills 1 sentinel))) + (t/is (= sentinel (nth fills -1 sentinel)))))) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index e4d2881b18..6dc7fa5207 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -973,6 +973,31 @@ (t/is (mth/close? 10.0 (:x2 rect) 0.1)) (t/is (mth/close? 10.0 (:y2 rect) 0.1)))) +(t/deftest segment-content->selrect-multi-line + ;; Regression: calculate-extremities used move-p instead of from-p in + ;; the :line-to branch. For a subpath with multiple consecutive line-to + ;; commands, the selrect must still match the reference implementation. + (let [;; A subpath that starts away from the origin and has three + ;; line-to segments so that move-p diverges from from-p for the + ;; later segments. + segments [{:command :move-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 15.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 8.0}} + {:command :line-to :params {:x 10.0 :y 12.0}}] + content (path/content segments) + rect (path.segment/content->selrect content) + ref-pts (calculate-extremities segments)] + + ;; Bounding box must enclose all four vertices exactly. + (t/is (some? rect)) + (t/is (mth/close? 5.0 (:x1 rect) 0.1)) + (t/is (mth/close? 0.0 (:y1 rect) 0.1)) + (t/is (mth/close? 20.0 (:x2 rect) 0.1)) + (t/is (mth/close? 12.0 (:y2 rect) 0.1)) + + ;; Must agree with the reference implementation. + (t/is (= ref-pts (calculate-extremities content))))) + (t/deftest segment-content-center (let [content (path/content sample-content-square) center (path.segment/content-center content)] diff --git a/common/test/common_tests/types/shape_layout_test.cljc b/common/test/common_tests/types/shape_layout_test.cljc index d677ed5d09..62935b21dc 100644 --- a/common/test/common_tests/types/shape_layout_test.cljc +++ b/common/test/common_tests/types/shape_layout_test.cljc @@ -186,13 +186,9 @@ flex (make-flex-frame :parent-id root-id) child (make-shape :parent-id (:id flex))] - ;; Note: inside-layout? calls (cfh/frame-shape? current-id) with a UUID id, - ;; but frame-shape? checks (:type uuid) which is nil for a UUID value. - ;; The function therefore always returns false regardless of structure. - ;; These tests document the actual (not the intended) behavior. - (t/testing "returns false when child is under a flex frame" + (t/testing "returns true when child is under a flex frame" (let [objects {root-id root (:id flex) flex (:id child) child}] - (t/is (not (layout/inside-layout? objects child))))) + (t/is (layout/inside-layout? objects child)))) (t/testing "returns false for root shape" (let [objects {root-id root (:id flex) flex (:id child) child}] diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index dfef086b1b..de18be42de 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -1930,7 +1930,7 @@ (let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-with-type")] (t/is (some? token)) (t/is (= :shadow (:type token))) - (t/is (= [{:offset-x "0", :offset-y "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset false}] + (t/is (= [{:offset-x "0", :offset-y "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset true}] (:value token))))) (t/testing "shadow token with description" diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 52f4524c87..8b07faef09 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -161,6 +161,7 @@ (def plugins-list-uri (obj/get global "penpotPluginsListUri" "https://penpot.app/penpothub/plugins")) (def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" []))) (def templates-uri (obj/get global "penpotTemplatesUri" "https://penpot.github.io/penpot-files/")) +(def upload-chunk-size (obj/get global "penpotUploadChunkSize" (* 1024 1024 25))) ;; 25 MiB ;; We set the current parsed flags under common for make ;; it available for common code without the need to pass @@ -204,6 +205,11 @@ (let [f (obj/get global "externalContextInfo")] (when (fn? f) (f)))) +(defn external-notify-register-success + [profile-id] + (let [f (obj/get global "externalNotifyRegisterSuccess")] + (when (fn? f) (f (str profile-id))))) + (defn initialize-external-context-info [] (let [f (obj/get global "initializeExternalConfigInfo")] diff --git a/frontend/src/app/main/data/uploads.cljs b/frontend/src/app/main/data/uploads.cljs new file mode 100644 index 0000000000..06e87f02d9 --- /dev/null +++ b/frontend/src/app/main/data/uploads.cljs @@ -0,0 +1,70 @@ +;; 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.main.data.uploads + "Generic chunked-upload helpers. + + Provides a purpose-agnostic three-step session API that can be used + by any feature that needs to upload large binary blobs: + + 1. create-upload-session – obtain a session-id + 2. upload-chunk – upload each slice (max-parallel-chunk-uploads in-flight) + 3. caller-specific step – e.g. assemble-file-media-object or import-binfile + + `upload-blob-chunked` drives steps 1 and 2 and emits the completed + `{:session-id …}` map so that the caller can proceed with its own + step 3." + (:require + [app.common.data.macros :as dm] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.repo :as rp] + [beicon.v2.core :as rx])) + +;; Size of each upload chunk in bytes. Reads the penpotUploadChunkSize global +;; variable at startup; defaults to 25 MiB (overridden in production). +(def ^:private chunk-size cf/upload-chunk-size) + +(def ^:private max-parallel-chunk-uploads + "Maximum number of chunk upload requests that may be in-flight at the + same time within a single chunked upload session." + 2) + +(defn upload-blob-chunked + "Uploads `blob` via the three-step chunked session API. + + Steps performed: + 1. Creates an upload session (`create-upload-session`). + 2. Slices `blob` and uploads every chunk (`upload-chunk`), + with at most `max-parallel-chunk-uploads` concurrent requests. + + Returns an observable that emits exactly one map: + `{:session-id }` + + The caller is responsible for the final step (assemble / import)." + [blob] + (let [total-size (.-size blob) + total-chunks (js/Math.ceil (/ total-size chunk-size))] + (->> (rp/cmd! :create-upload-session + {:total-chunks total-chunks}) + (rx/mapcat + (fn [{raw-session-id :session-id}] + (let [session-id (cond-> raw-session-id + (string? raw-session-id) uuid/uuid) + chunk-uploads + (->> (range total-chunks) + (map (fn [idx] + (let [start (* idx chunk-size) + end (min (+ start chunk-size) total-size) + chunk (.slice blob start end)] + (rp/cmd! :upload-chunk + {:session-id session-id + :index idx + :content (list chunk (dm/str "chunk-" idx))})))))] + (->> (rx/from chunk-uploads) + (rx/merge-all max-parallel-chunk-uploads) + (rx/last) + (rx/map (fn [_] {:session-id session-id}))))))))) diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index c2d42d680c..c1f6083d13 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -204,7 +204,7 @@ (watch [_ state _] (let [route (:route state) qparams (:query-params route) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) frame-id (some-> (:frame-id qparams) uuid/parse)] (rx/merge (rx/of (case (:zoom qparams) @@ -301,7 +301,7 @@ (update [_ state] (let [params (rt/get-params state) page-id (some-> (:page-id params) uuid/parse) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) frames (dm/get-in state [:viewer :pages page-id :frames]) index (min (or index 0) (max 0 (dec (count frames)))) @@ -325,7 +325,7 @@ (let [params (rt/get-params state) page-id (some-> (:page-id params) uuid/parse) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) frames (dm/get-in state [:viewer :pages page-id :frames]) index (min (or index 0) (max 0 (dec (count frames)))) @@ -399,7 +399,7 @@ ptk/WatchEvent (watch [_ state _] (let [params (rt/get-params state) - index (some-> params :index parse-long)] + index (some-> (rt/get-query-param params :index) parse-long)] (when (pos? index) (rx/of (dcmt/close-thread) @@ -415,7 +415,7 @@ ptk/WatchEvent (watch [_ state _] (let [params (rt/get-params state) - index (some-> params :index parse-long) + index (some-> (rt/get-query-param params :index) parse-long) page-id (some-> params :page-id uuid/parse) total (count (get-in state [:viewer :pages page-id :frames]))] @@ -530,7 +530,7 @@ (let [route (:route state) qparams (:query-params route) page-id (some-> (:page-id qparams) uuid/parse) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) frames (get-in state [:viewer :pages page-id :frames]) frame (get frames index)] (cond-> state @@ -744,7 +744,7 @@ (let [route (:route state) qparams (:query-params route) page-id (some-> (:page-id qparams) uuid/parse) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) objects (get-in state [:viewer :pages page-id :objects]) frame-id (get-in state [:viewer :pages page-id :frames index :id]) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index ef7bfd2f93..ca4362ef5b 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -411,9 +411,16 @@ (when id-ref (reset! id-ref component-id)) (when-not (empty? (:redo-changes changes)) - (rx/of (dch/commit-changes changes) - (dws/select-shapes (d/ordered-set (:id root))) - (ptk/data-event :layout/update {:ids parents})))))))))) + (rx/concat + (rx/of (dch/commit-changes changes) + (dws/select-shapes (d/ordered-set (:id root))) + (ptk/data-event :layout/update {:ids parents})) + + ;; When activated the wasm rendering we need to recreate its thumbnail on creation + (if (features/active-feature? state "render-wasm/v1") + (rx/of (dwt.wasm/render-thumbnail file-id page-id (:id root)) + (dwt.wasm/persist-thumbnail file-id page-id (:id root))) + (rx/empty))))))))))) (defn add-component "Add a new component to current file library, from the currently selected shapes. diff --git a/frontend/src/app/main/data/workspace/media.cljs b/frontend/src/app/main/data/workspace/media.cljs index 0d1e1c6e32..bcffef8378 100644 --- a/frontend/src/app/main/data/workspace/media.cljs +++ b/frontend/src/app/main/data/workspace/media.cljs @@ -24,6 +24,7 @@ [app.main.data.helpers :as dsh] [app.main.data.media :as dmm] [app.main.data.notifications :as ntf] + [app.main.data.uploads :as uploads] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.svg-upload :as svg] [app.main.repo :as rp] @@ -103,6 +104,26 @@ :url url :is-local true})) +;; Size of each upload chunk in bytes — read from config directly, +;; same source used by the uploads namespace. +(def ^:private chunk-size cf/upload-chunk-size) + +(defn- upload-blob-chunked + "Uploads `blob` to `file-id` as a chunked media object using the + three-step session API. Returns an observable that emits the + assembled file-media-object map." + [{:keys [file-id name is-local blob]}] + (let [mtype (.-type blob)] + (->> (uploads/upload-blob-chunked blob) + (rx/mapcat + (fn [{:keys [session-id]}] + (rp/cmd! :assemble-file-media-object + {:session-id session-id + :file-id file-id + :is-local is-local + :name name + :mtype mtype})))))) + (defn process-uris [{:keys [file-id local? name uris mtype on-image on-svg]}] (letfn [(svg-url? [url] @@ -143,12 +164,18 @@ (and (not force-media) (= (.-type blob) "image/svg+xml"))) - (prepare-blob [blob] - (let [name (or name (if (dmm/file? blob) (media/strip-image-extension (.-name blob)) "blob"))] - {:file-id file-id - :name name - :is-local local? - :content blob})) + (upload-blob [blob] + (let [params {:file-id file-id + :name (or name (if (dmm/file? blob) (media/strip-image-extension (.-name blob)) "blob")) + :is-local local? + :blob blob}] + (if (>= (.-size blob) chunk-size) + (upload-blob-chunked params) + (rp/cmd! :upload-file-media-object + {:file-id file-id + :name (:name params) + :is-local local? + :content blob})))) (extract-content [blob] (let [name (or name (.-name blob))] @@ -159,8 +186,7 @@ (->> (rx/from blobs) (rx/map dmm/validate-file) (rx/filter (comp not svg-blob?)) - (rx/map prepare-blob) - (rx/mapcat #(rp/cmd! :upload-file-media-object %)) + (rx/mapcat upload-blob) (rx/tap on-image)) (->> (rx/from blobs) @@ -170,9 +196,10 @@ (rx/merge-map svg->clj) (rx/tap on-svg))))) -(defn handle-media-error [error on-error] - (if (ex/ex-info? error) - (handle-media-error (ex-data error) on-error) +(defn handle-media-error + [cause] + (ex/print-throwable cause) + (let [error (ex-data cause)] (cond (= (:code error) :invalid-svg-file) (rx/of (ntf/error (tr "errors.media-type-not-allowed"))) @@ -195,13 +222,8 @@ (= (:code error) :unable-to-optimize) (rx/of (ntf/error (:hint error))) - (fn? on-error) - (on-error error) - :else - (do - (.error js/console "ERROR" error) - (rx/of (ntf/error (tr "errors.cannot-upload"))))))) + (rx/of (ntf/error (tr "errors.cannot-upload")))))) (def ^:private @@ -215,7 +237,7 @@ [:mtype {:optional true} :string]]) (defn- process-media-objects - [{:keys [uris on-error] :as params}] + [{:keys [uris] :as params}] (dm/assert! (and (sm/check schema:process-media-objects params) (or (contains? params :blobs) @@ -238,7 +260,7 @@ ;; Every stream has its own sideeffect. We need to ignore the result (rx/ignore) - (rx/catch #(handle-media-error % on-error)) + (rx/catch handle-media-error) (rx/finalize #(st/emit! (ntf/hide :tag :media-loading)))))))) (defn upload-media-workspace @@ -278,8 +300,6 @@ (rx/tap on-upload-success) (rx/catch handle-media-error)))))) -;; --- Upload File Media objects - (defn create-shapes-svg "Convert svg elements into penpot shapes." [file-id objects pos svg-data] diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 19cb56be7c..e3eccc05ab 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -400,7 +400,11 @@ shape-ids (cond (cfh/text-shape? shape) [id] (cfh/group-shape? shape) (cfh/get-children-ids objects id))] - (rx/of (dwsh/update-shapes shape-ids update-fn)))))) + (rx/concat + (rx/of (dwsh/update-shapes shape-ids update-fn)) + (if (features/active-feature? state "render-wasm/v1") + (dwwt/resize-wasm-text-debounce id) + (rx/empty))))))) (defn update-root-attrs [{:keys [id attrs]}] @@ -503,13 +507,9 @@ ptk/WatchEvent (watch [_ state _] (when (or - (and (features/active-feature? state "text-editor-wasm/v1") - (nil? (get-in state [:workspace-wasm-editor-styles id]))) (and (features/active-feature? state "text-editor/v2") - (not (features/active-feature? state "text-editor-wasm/v1")) (nil? (:workspace-editor state))) (and (not (features/active-feature? state "text-editor/v2")) - (not (features/active-feature? state "text-editor-wasm/v1")) (nil? (get-in state [:workspace-editor-state id])))) (let [page-id (or (get options :page-id) (get state :current-page-id)) @@ -533,16 +533,20 @@ (-> shape (dissoc :fills) (d/update-when :content update-content)))] - (rx/of (dwsh/update-shapes shape-ids update-shape options))))) + + (rx/concat (rx/of (dwsh/update-shapes shape-ids update-shape options)) + (when (features/active-feature? state "text-editor-wasm/v1") + (let [styles ((comp update-node-fn migrate-node)) + result (wasm.api/apply-styles-to-selection styles)] + (when result + (rx/of (v2-update-text-shape-content + (:shape-id result) + (:content result) + :update-name? true))))))))) ptk/EffectEvent (effect [_ state _] - (cond - (features/active-feature? state "text-editor-wasm/v1") - (let [styles ((comp update-node-fn migrate-node))] - (wasm.api/apply-styles-to-selection styles)) - - (features/active-feature? state "text-editor/v2") + (when (features/active-feature? state "text-editor/v2") (when-let [instance (:workspace-editor state)] (let [styles (some-> (editor.v2/getCurrentStyle instance) (styles/get-styles-from-style-declaration :removed-mixed true) @@ -786,11 +790,18 @@ (rx/of (update-position-data id position-data)))) (rx/empty)))))) +(defn font-loaded-event? + [font-id] + (fn [event] + (and + (= :font-loaded (ptk/type event)) + (= (:font-id (deref event)) font-id)))) + (defn update-attrs [id attrs] (ptk/reify ::update-attrs ptk/WatchEvent - (watch [_ state _] + (watch [_ state stream] (let [text-editor-instance (:workspace-editor state)] (if (and (features/active-feature? state "text-editor/v2") (some? text-editor-instance)) @@ -811,7 +822,8 @@ (rx/of (update-text-attrs {:id id :attrs attrs})) (rx/empty))) - (when (features/active-feature? state "text-editor/v2") + (when (and (features/active-feature? state "text-editor/v2") + (not (features/active-feature? state "text-editor-wasm/v1"))) (rx/of (v2-update-text-editor-styles id attrs))) (when (features/active-feature? state "render-wasm/v1") @@ -827,9 +839,13 @@ (:shape-id result) (:content result) :update-name? true)))))))) ;; Resize (with delay for font-id changes) - (cond->> (rx/of (dwwt/resize-wasm-text id)) - (contains? attrs :font-id) - (rx/delay 200)))))))) + (if (contains? attrs :font-id) + (->> stream + (rx/filter (font-loaded-event? (:font-id attrs))) + (rx/take 1) + (rx/observe-on :async) + (rx/map #(dwwt/resize-wasm-text id))) + (rx/of (dwwt/resize-wasm-text id))))))))) ptk/EffectEvent (effect [_ state _] diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index 44459e9df8..3695205985 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -15,11 +15,15 @@ - persist-thumbnail: pushes current data-uri to the server (debounced)" (:require [app.common.data.macros :as dm] + [app.common.files.helpers :as cfh] [app.common.logging :as l] + [app.common.math :as mth] [app.common.thumbnails :as thc] [app.common.time :as ct] + [app.main.data.helpers :as dsh] [app.main.data.workspace.thumbnails :as dwt] [app.main.repo :as rp] + [app.main.store :as st] [app.render-wasm.api :as wasm.api] [app.util.webapi :as wapi] [beicon.v2.core :as rx] @@ -41,59 +45,100 @@ (fn [e] (reject e))) (.readAsDataURL reader blob))))) +;; This constant stores the target thumbnail minimum max-size so +;; the images doesn't lose quality when rendered +(def ^:private ^:const target-size 200) + (defn- render-component-pixels "Renders a component frame using the workspace WASM context. Returns an observable that emits a data-uri string. Deferred by one animation frame so that process-shape-changes! has time to sync all child shapes to WASM memory first." - [frame-id] + [file-id page-id frame-id] (rx/create (fn [subs] - (js/requestAnimationFrame - (fn [_] - (try - (let [png-bytes (wasm.api/render-shape-pixels frame-id 1)] - (if (or (nil? png-bytes) (zero? (.-length png-bytes))) - (do (js/console.error "[thumbnails] render-shape-pixels returned empty for" (str frame-id)) - (rx/end! subs)) - (.then (png-bytes->data-uri png-bytes) + (let [req-id + (js/requestAnimationFrame + (fn [_] + (try + (let [objects (dsh/lookup-page-objects @st/state file-id page-id) + frame (get objects frame-id) + {:keys [width height]} (:selrect frame) + max-size (mth/max width height) + scale (mth/max 1 (/ target-size max-size)) + png-bytes (wasm.api/render-shape-pixels frame-id scale)] + (if (or (nil? png-bytes) (zero? (.-length png-bytes))) + (do + (l/error :hint "render-shape-pixels returned empty" :frame-id (str frame-id)) + (rx/end! subs)) + (.then + (png-bytes->data-uri png-bytes) (fn [data-uri] (rx/push! subs data-uri) (rx/end! subs)) (fn [err] (rx/error! subs err))))) - (catch :default err - (rx/error! subs err))))) - nil))) + (catch :default err + (rx/error! subs err)))))] + #(js/cancelAnimationFrame req-id))))) (defn render-thumbnail "Renders a component thumbnail via WASM and updates the UI immediately. Does NOT persist to the server — persistence is handled separately by `persist-thumbnail` on a debounced schedule." [file-id page-id frame-id] + (let [object-id (thc/fmt-object-id file-id page-id frame-id "component")] (ptk/reify ::render-thumbnail cljs.core/IDeref (-deref [_] object-id) ptk/WatchEvent - (watch [_ _ stream] - (let [tp (ct/tpoint-ms)] - (->> (render-component-pixels frame-id) - (rx/map - (fn [data-uri] - (l/dbg :hint "component thumbnail rendered (wasm)" - :elapsed (dm/str (tp) "ms")) - (dwt/assoc-thumbnail object-id data-uri))) + (watch [_ state stream] + ;; When the component is removed it can arrived a render + ;; request with frame-id=null + (when (some? frame-id) + (letfn [(load-objects-stream + [] + (rx/create + (fn [subs] + (let [objects (dsh/lookup-page-objects state file-id page-id) - (rx/catch (fn [err] - (js/console.error "[thumbnails] error rendering component thumbnail" err) - (rx/empty))) + ;; retrieves a subtree with only the id and its children + ;; to be loaded before rendering the thumbnail + subtree + (into {} + (map #(vector (:id %) %)) + (cfh/get-children-with-self objects frame-id))] + (try + (wasm.api/set-objects subtree #(rx/push! subs %)) + (catch :default err + (rx/error! subs err))))))) - (rx/take-until - (->> stream - (rx/filter (ptk/type? ::dwt/clear-thumbnail)) - (rx/filter #(= (deref %) object-id)))))))))) + (do-render-thumbnail + [] + (let [tp (ct/tpoint-ms)] + (->> (render-component-pixels file-id page-id frame-id) + (rx/map + (fn [data-uri] + (l/dbg :hint "component thumbnail rendered (wasm)" + :elapsed (dm/str (tp) "ms")) + (dwt/assoc-thumbnail object-id data-uri))) + + (rx/catch (fn [err] + (js/console.error err) + (l/error :hint "error rendering component thumbnail" :frame-id (str frame-id)) + (rx/empty))) + + (rx/take-until + (->> stream + (rx/filter (ptk/type? ::dwt/clear-thumbnail)) + (rx/filter #(= (deref %) object-id)))))))] + + (if (not= page-id (:current-page-id state)) + (->> (load-objects-stream) + (rx/mapcat do-render-thumbnail)) + (do-render-thumbnail)))))))) (defn persist-thumbnail "Persists the current component thumbnail data-uri to the server. diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index c175b46bdf..7effaef13e 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -23,6 +23,8 @@ [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +(def debounce-resize-text-time 40) + (defn get-wasm-text-new-size "Computes the new {width, height} for a text shape from WASM text layout. For :fixed grow-type, updates WASM content and returns current dimensions (no resize)." @@ -144,7 +146,7 @@ (rx/merge (->> stream (rx/filter (ptk/type? ::resize-wasm-text-debounce-inner)) - (rx/debounce 40) + (rx/debounce debounce-resize-text-time) (rx/take 1) (rx/map (fn [evt] (resize-wasm-text-debounce-commit @@ -194,4 +196,4 @@ ptk/WatchEvent (watch [_ _ _] (->> (rx/from ids) - (rx/map resize-wasm-text))))) + (rx/map resize-wasm-text-debounce))))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 048ac62e0a..391101d4be 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -43,6 +43,12 @@ [_] false) +;; Re-entrancy guard: prevents on-error from calling itself recursively. +;; If an error occurs while we are already handling an error (e.g. the +;; notification emit itself throws), we log it and bail out immediately +;; instead of recursing until the call-stack overflows. +(def ^:private handling-error? (volatile! false)) + ;; --- Stale-asset error detection and auto-reload ;; ;; When the browser loads JS modules from different builds (e.g. shared.js from @@ -90,12 +96,24 @@ (assoc ::trace (.-stack cause))))) (defn on-error - "A general purpose error handler." + "A general purpose error handler. + + Protected by a re-entrancy guard: if an error is raised while this + function is already on the call stack (e.g. the notification emit + itself fails), we print it to the console and return immediately + instead of recursing until the call-stack is exhausted." [error] - (if (map? error) - (ptk/handle-error error) - (let [data (exception->error-data error)] - (ptk/handle-error data)))) + (if @handling-error? + (.error js/console "[on-error] re-entrant call suppressed" error) + (do + (vreset! handling-error? true) + (try + (if (map? error) + (ptk/handle-error error) + (let [data (exception->error-data error)] + (ptk/handle-error data))) + (finally + (vreset! handling-error? false)))))) ;; Inject dependency to remove circular dependency (set! app.main.worker/on-error on-error) @@ -148,7 +166,14 @@ :report report})))) (defn flash - "Show error notification banner and emit error report" + "Show error notification banner and emit error report. + + The notification is scheduled asynchronously (via tm/schedule) to + avoid pushing a new event into the potok store while the store's own + error-handling pipeline is still on the call stack. Emitting + synchronously from inside an error handler creates a re-entrant + event-processing cycle that can exhaust the JS call stack + (RangeError: Maximum call stack size exceeded)." [& {:keys [type hint cause] :or {type :handled}}] (when (ex/exception? cause) (when-let [event-name (case type @@ -160,11 +185,12 @@ :report report :hint (ex/get-hint cause))))) - (st/emit! - (ntf/show {:content (or ^boolean hint (tr "errors.generic")) - :type :toast - :level :error - :timeout 5000}))) + (ts/schedule + #(st/emit! + (ntf/show {:content (or ^boolean hint (tr "errors.generic")) + :type :toast + :level :error + :timeout 5000})))) (defmethod ptk/handle-error :network [error] diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index 6e40dbcbda..d60366592c 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -492,7 +492,9 @@ (try (when (wasm.api/init-canvas-context os-canvas) (wasm.api/initialize-viewport - objects scale bounds "#000000" 0 + objects scale bounds + :background-opacity 0 + :on-render (fn [] (wasm.api/render-sync-shape object-id) (ts/raf diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 6f264e5d02..1e6dd417a6 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -139,8 +139,7 @@ {:stream? true} ::sse/import-binfile - {:stream? true - :form-data? true} + {:stream? true} ::sse/permanently-delete-team-files {:stream? true} @@ -273,6 +272,7 @@ (send-export (merge default params)))) (derive :upload-file-media-object ::multipart-upload) +(derive :upload-chunk ::multipart-upload) (derive :update-profile-photo ::multipart-upload) (derive :update-team-photo ::multipart-upload) diff --git a/frontend/src/app/main/router.cljs b/frontend/src/app/main/router.cljs index 1e234e8af1..405c8b6664 100644 --- a/frontend/src/app/main/router.cljs +++ b/frontend/src/app/main/router.cljs @@ -136,6 +136,16 @@ [state] (dm/get-in state [:route :params :query])) +(defn get-query-param + "Safely extracts a scalar value for a query param key from a params + map. When the same key appears multiple times in a URL, + query-string->map returns a vector for that key; this function + always returns a single (last) element in that case, so downstream + consumers such as parse-long always receive a plain string or nil." + [params k] + (let [v (get params k)] + (if (sequential? v) (peek v) v))) + (defn nav-back [] (ptk/reify ::nav-back diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 787c17edfa..39ff4493dd 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -286,7 +286,7 @@ :viewer (let [params (get params :query) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) share-id (some-> (:share-id params) uuid/parse*) section (or (some-> (:section params) keyword) :interactions) diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 97ed5eddb3..60fd3e0167 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -277,6 +277,7 @@ (mf/use-fn (mf/deps on-success-callback) (fn [params] + (cf/external-notify-register-success (:id params)) (if (fn? on-success-callback) (on-success-callback (:email params)) diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index e3c55c7239..3ae5cb8fc9 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -6,6 +6,7 @@ (ns app.main.ui.auth.verify-token (:require + [app.config :as cf] [app.main.data.auth :as da] [app.main.data.common :as dcm] [app.main.data.notifications :as ntf] @@ -25,6 +26,7 @@ (defmethod handle-token :verify-email [data] + (cf/external-notify-register-success (:profile-id data)) (let [msg (tr "dashboard.notifications.email-verified-successfully")] (ts/schedule 1000 #(st/emit! (ntf/success msg))) (st/emit! (da/login-from-token data)))) diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index dfecbc779b..06f7b29c36 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -78,7 +78,8 @@ current-team (get teams current-team-id) other-teams (remove #(= (:id %) current-team-id) (vals teams)) - current-projects (remove #(= (:id %) (:project-id file)) + file-project-ids (into #{} (map :project-id) files) + current-projects (remove #(contains? file-project-ids (:id %)) (:projects current-team)) on-new-tab diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index e1216479f4..36358fd80f 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -160,7 +160,7 @@ tooltip-ref (mf/use-ref nil) - container (hooks/use-portal-container) + container (hooks/use-portal-container :tooltip) id (d/nilv id internal-id) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 42560cd8fe..ae8ebd30d5 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -380,17 +380,35 @@ state)) +(defn- get-or-create-portal-container + "Returns the singleton container div for the given category, creating + and appending it to document.body on first access." + [category] + (let [body (dom/get-body) + id (str "portal-container-" category)] + (or (dom/query body (str "#" id)) + (let [container (dom/create-element "div")] + (dom/set-attribute! container "id" id) + (dom/append-child! body container) + container)))) + (defn use-portal-container - "Creates a dedicated div container for React portals. The container - is appended to document.body on mount and removed on cleanup, preventing - removeChild race conditions when multiple portals target the same body." - [] - (let [container (mf/use-memo #(dom/create-element "div"))] - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) - container)) + "Returns a shared singleton container div for React portals, identified + by a logical category. Available categories: + + :modal — modal dialogs + :popup — popups, dropdowns, context menus + :tooltip — tooltips + :default — general portal use (default) + + All portals in the same category share one
on document.body, + keeping the DOM clean and avoiding removeChild race conditions." + ([] + (use-portal-container :default)) + ([category] + (let [category (name category)] + (mf/with-memo [category] + (get-or-create-portal-container category))))) (defn use-dynamic-grid-item-width ([] (use-dynamic-grid-item-width nil)) diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 5df1cc3daa..6e9b1df7d4 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -84,7 +84,7 @@ (mf/defc modal-container* {::mf/props :obj} [] - (let [container (hooks/use-portal-container)] + (let [container (hooks/use-portal-container :modal)] (when-let [modal (mf/deref ref:modal)] (mf/portal (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 2e7446c425..b9aab0ecf0 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -97,7 +97,7 @@ [:section {:class (stl/css :workspace-viewport)} (when (dbg/enabled? :coordinates) - [:& coordinates/coordinates {:colorpalette? colorpalette?}]) + [:> coordinates/coordinates* {:is-colorpalette colorpalette?}]) (when (dbg/enabled? :history-overlay) [:div {:class (stl/css :history-debug-overlay)} diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index 3e930e9f81..d6d4300848 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -33,12 +33,12 @@ [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as deprecated-icon] - [app.main.ui.workspace.colorpicker.color-inputs :refer [color-inputs]] + [app.main.ui.workspace.colorpicker.color-inputs :refer [color-inputs*]] [app.main.ui.workspace.colorpicker.color-tokens :refer [token-section*]] [app.main.ui.workspace.colorpicker.gradients :refer [gradients*]] - [app.main.ui.workspace.colorpicker.harmony :refer [harmony-selector]] - [app.main.ui.workspace.colorpicker.hsva :refer [hsva-selector]] - [app.main.ui.workspace.colorpicker.libraries :refer [libraries]] + [app.main.ui.workspace.colorpicker.harmony :refer [harmony-selector*]] + [app.main.ui.workspace.colorpicker.hsva :refer [hsva-selector*]] + [app.main.ui.workspace.colorpicker.libraries :refer [libraries*]] [app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]] [app.main.ui.workspace.colorpicker.shortcuts :as sc] [app.util.dom :as dom] @@ -93,7 +93,7 @@ (dom/set-css-property! node "--saturation-grad-from" (format-hsl hsl-from)) (dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to))))) -(mf/defc colorpicker +(mf/defc colorpicker* [{:keys [data disable-gradient disable-opacity disable-image on-change on-accept origin combined-tokens color-origin on-token-change tab applied-token]}] (let [state (mf/deref refs/colorpicker) node-ref (mf/use-ref) @@ -511,27 +511,28 @@ :on-finish-drag on-finish-drag}] "harmony" - [:& harmony-selector + [:> harmony-selector* {:color current-color :disable-opacity disable-opacity :on-change handle-change-color - :on-start-drag on-start-drag}] + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] "hsva" - [:& hsva-selector + [:> hsva-selector* {:color current-color :disable-opacity disable-opacity :on-change handle-change-color :on-start-drag on-start-drag :on-finish-drag on-finish-drag}]))]] - [:& color-inputs + [:> color-inputs* {:type type :disable-opacity disable-opacity :color current-color :on-change handle-change-color}] - [:& libraries + [:> libraries* {:state state :current-color current-color :disable-gradient disable-gradient @@ -786,15 +787,15 @@ :data-testid "colorpicker" :style style} - [:& colorpicker {:data data - :combined-tokens grouped-tokens-by-set - :disable-gradient disable-gradient - :disable-opacity disable-opacity - :disable-image disable-image - :on-token-change on-token-change - :applied-token applied-token - :on-change on-change' - :origin origin - :tab tab - :color-origin color-origin - :on-accept on-accept}]])) + [:> colorpicker* {:data data + :combined-tokens grouped-tokens-by-set + :disable-gradient disable-gradient + :disable-opacity disable-opacity + :disable-image disable-image + :on-token-change on-token-change + :applied-token applied-token + :on-change on-change' + :origin origin + :tab tab + :color-origin color-origin + :on-accept on-accept}]])) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs index fc384cdfdd..09ad2d0e8c 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs @@ -28,7 +28,7 @@ [val] (* (/ val 255) 100)) -(mf/defc color-inputs [{:keys [type color disable-opacity on-change]}] +(mf/defc color-inputs* [{:keys [type color disable-opacity on-change]}] (let [{red :r green :g blue :b hue :h saturation :s value :v hex :hex alpha :alpha} color diff --git a/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs b/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs index c043899551..393e89df69 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs @@ -11,7 +11,7 @@ [app.common.geom.point :as gpt] [app.common.math :as mth] [app.common.types.color :as cc] - [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector]] + [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]] [app.util.dom :as dom] [app.util.object :as obj] [cuerdas.core :as str] @@ -58,7 +58,7 @@ y (+ (/ canvas-side 2) (* comp-y (/ canvas-side 2)))] (gpt/point x y))) -(mf/defc harmony-selector [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}] +(mf/defc harmony-selector* [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}] (let [canvas-ref (mf/use-ref nil) canvas-side 192 {hue :h saturation :s value :v alpha :alpha} color @@ -134,24 +134,21 @@ :style {"--hue-from" (dm/str "hsl(" h1 ", " (* s1 100) "%, " (* l1 100) "%)") "--hue-to" (dm/str "hsl(" h2 ", " (* s2 100) "%, " (* l2 100) "%)")}} [:div {:class (stl/css :handlers-wrapper)} - [:& slider-selector {:type :value - :vertical? true - :reverse? false - :value value - :max-value 255 - :vertical true - :on-change on-change-value - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}] + [:> slider-selector* {:type :value + :is-vertical true + :value value + :max-value 255 + :on-change on-change-value + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] (when (not disable-opacity) - [[:& slider-selector {:type :opacity - :vertical? true + [:> slider-selector* {:type :opacity + :is-vertical true :value alpha :max-value 1 - :vertical true :on-change on-change-opacity :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}]])] + :on-finish-drag on-finish-drag}])] [:div {:class (stl/css :hue-wheel-wrapper)} [:canvas {:class (stl/css :hue-wheel) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs index 9a7d240f55..807d976314 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs @@ -8,10 +8,10 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.types.color :as cc] - [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector]] + [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]] [rumext.v2 :as mf])) -(mf/defc hsva-selector [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}] +(mf/defc hsva-selector* [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}] (let [{hue :h saturation :s value :v alpha :alpha} color handle-change-slider (fn [key] (fn [new-value] @@ -26,7 +26,7 @@ [:div {:class (stl/css :hsva-selector)} [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "H"] - [:& slider-selector + [:> slider-selector* {:class (stl/css :hsva-bar) :type :hue :max-value 360 @@ -36,7 +36,7 @@ :on-finish-drag on-finish-drag}]] [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "S"] - [:& slider-selector + [:> slider-selector* {:class (stl/css :hsva-bar) :type :saturation :max-value 1 @@ -46,10 +46,9 @@ :on-finish-drag on-finish-drag}]] [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "V"] - [:& slider-selector + [:> slider-selector* {:class (stl/css :hsva-bar) :type :value - :reverse? false :max-value 255 :value value :on-change (handle-change-slider :v) @@ -58,7 +57,7 @@ (when (not disable-opacity) [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "A"] - [:& slider-selector + [:> slider-selector* {:class (stl/css :hsva-bar) :type :opacity :max-value 1 diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs index e565a05754..baf82c0f79 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs @@ -27,7 +27,7 @@ [potok.v2.core :as ptk] [rumext.v2 :as mf])) -(mf/defc libraries +(mf/defc libraries* [{:keys [state on-select-color on-add-library-color disable-gradient disable-opacity disable-image]}] (let [selected* (h/use-shared-state mdc/colorpicker-selected-broadcast-key :recent) selected (deref selected*) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs b/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs index 154587fc7b..68eab222cd 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs @@ -11,11 +11,11 @@ [app.common.math :as mth] [app.common.types.color :as cc] [app.main.ui.components.color-bullet :as cb] - [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector]] + [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]] [app.util.dom :as dom] [rumext.v2 :as mf])) -(mf/defc value-saturation-selector [{:keys [saturation value on-change on-start-drag on-finish-drag]}] +(mf/defc value-saturation-selector* [{:keys [saturation value on-change on-start-drag on-finish-drag]}] (let [dragging?* (mf/use-state false) dragging? (deref dragging?*) calculate-pos @@ -127,7 +127,7 @@ (reset! internal-color* (enrich-color-map color)))) [:* - [:& value-saturation-selector + [:> value-saturation-selector* {:hue h :saturation s :value v @@ -140,17 +140,17 @@ [:& cb/color-bullet {:color bullet-color :area true}] [:div {:class (stl/css :sliders-wrapper)} - [:& slider-selector {:type :hue - :max-value 360 - :value h - :on-change on-change-hue - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}] + [:> slider-selector* {:type :hue + :max-value 360 + :value h + :on-change on-change-hue + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] (when (not disable-opacity) - [:& slider-selector {:type :opacity - :max-value 1 - :value alpha - :on-change on-change-opacity - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}])]]])) + [:> slider-selector* {:type :opacity + :max-value 1 + :value alpha + :on-change on-change-opacity + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}])]]])) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs index c69acfd703..f125b6368b 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs @@ -13,8 +13,8 @@ [app.util.object :as obj] [rumext.v2 :as mf])) -(mf/defc slider-selector - [{:keys [value class min-value max-value vertical? reverse? on-change on-start-drag on-finish-drag type]}] +(mf/defc slider-selector* + [{:keys [value class min-value max-value is-vertical on-change on-start-drag on-finish-drag type]}] (let [min-value (or min-value 0) max-value (or max-value 1) dragging? (mf/use-state false) @@ -42,17 +42,14 @@ (when on-change (let [{:keys [left right top bottom]} (-> ev dom/get-target dom/get-bounding-rect) {:keys [x y]} (-> ev dom/get-client-position) - unit-value (if vertical? + unit-value (if is-vertical (mth/clamp (/ (- bottom y) (- bottom top)) 0 1) (mth/clamp (/ (- x left) (- right left)) 0 1)) - unit-value (if reverse? - (mth/abs (- unit-value 1.0)) - unit-value) value (+ min-value (* unit-value (- max-value min-value)))] (on-change value))))] - [:div {:class (dm/str class (stl/css-case :vertical vertical? + [:div {:class (dm/str class (stl/css-case :vertical is-vertical :slider-selector true :hue (= type :hue) :opacity (= type :opacity) @@ -65,14 +62,10 @@ :on-pointer-move #(when @dragging? (calculate-pos %))} (let [value-percent (* (/ (- value min-value) (- max-value min-value)) 100) - - value-percent (if reverse? - (mth/abs (- value-percent 100)) - value-percent) value-percent-str (str value-percent "%") style-common #js {:pointerEvents "none"} style-horizontal (obj/merge! #js {:left value-percent-str} style-common) style-vertical (obj/merge! #js {:bottom value-percent-str} style-common)] [:div {:class (stl/css :handler) - :style (if vertical? style-vertical style-horizontal)}])])) + :style (if is-vertical style-vertical style-horizontal)}])])) diff --git a/frontend/src/app/main/ui/workspace/coordinates.cljs b/frontend/src/app/main/ui/workspace/coordinates.cljs index 5ad5dfc572..05de77500a 100644 --- a/frontend/src/app/main/ui/workspace/coordinates.cljs +++ b/frontend/src/app/main/ui/workspace/coordinates.cljs @@ -11,10 +11,10 @@ [app.main.ui.hooks :as hooks] [rumext.v2 :as mf])) -(mf/defc coordinates - [{:keys [colorpalette?]}] +(mf/defc coordinates* + [{:keys [is-colorpalette]}] (let [coords (hooks/use-rxsub ms/mouse-position)] - [:div {:class (stl/css-case :container-color-palette-open colorpalette? + [:div {:class (stl/css-case :container-color-palette-open is-colorpalette :container true)} [:span {:alt "x" :class (stl/css :coordinate)} (str "X: " (:x coords "-"))] diff --git a/frontend/src/app/main/ui/workspace/palette.cljs b/frontend/src/app/main/ui/workspace/palette.cljs index 80c396989e..74f5d6901f 100644 --- a/frontend/src/app/main/ui/workspace/palette.cljs +++ b/frontend/src/app/main/ui/workspace/palette.cljs @@ -23,7 +23,7 @@ [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.color-palette :refer [color-palette*]] [app.main.ui.workspace.color-palette-ctx-menu :refer [color-palette-ctx-menu*]] - [app.main.ui.workspace.text-palette :refer [text-palette]] + [app.main.ui.workspace.text-palette :refer [text-palette*]] [app.main.ui.workspace.text-palette-ctx-menu :refer [text-palette-ctx-menu]] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -207,9 +207,9 @@ :close-menu on-close-menu :on-select-palette on-select-text-palette-menu :selected selected-text}] - [:& text-palette {:size size - :selected selected-text - :width vport-width}]]) + [:> text-palette* {:size size + :selected selected-text + :width vport-width}]]) (when color-palette? [:* [:> color-palette-ctx-menu* {:show show-menu? diff --git a/frontend/src/app/main/ui/workspace/presence.cljs b/frontend/src/app/main/ui/workspace/presence.cljs index 38302ed536..d7bcda046f 100644 --- a/frontend/src/app/main/ui/workspace/presence.cljs +++ b/frontend/src/app/main/ui/workspace/presence.cljs @@ -29,7 +29,7 @@ :style {:background-color color} :src (cfg/resolve-profile-photo-url profile)}]])) -(mf/defc active-sessions +(mf/defc active-sessions* {::mf/memo true} [] (let [profiles (mf/deref refs/profiles) diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index addbfc251e..c719aae349 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -25,7 +25,7 @@ [app.main.ui.exports.assets :refer [progress-widget]] [app.main.ui.formats :as fmt] [app.main.ui.icons :as deprecated-icon] - [app.main.ui.workspace.presence :refer [active-sessions]] + [app.main.ui.workspace.presence :refer [active-sessions*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [okulary.core :as l] @@ -196,7 +196,7 @@ [:div {:class (stl/css :workspace-header-right)} [:div {:class (stl/css :users-section)} - [:& active-sessions]] + [:> active-sessions*]] [:& progress-widget] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 118a1f5a25..55f81f1600 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -16,6 +16,7 @@ [app.main.data.workspace.shortcuts :as sc] [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.tokens.application :as dwta] + [app.main.data.workspace.texts-v3 :as dwt-v3] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.wasm-text :as dwwt] [app.main.features :as features] @@ -333,9 +334,12 @@ (mf/use-fn (mf/deps values) (fn [ids attrs] - (st/emit! (dwt/save-font (-> (merge (txt/get-default-text-attrs) values attrs) - (select-keys txt/text-node-attrs))) - (dwt/update-all-attrs ids attrs)))) + (let [updated-attrs (-> (merge (txt/get-default-text-attrs) values attrs) + (select-keys txt/text-node-attrs))] + (when (features/active-feature? @st/state "text-editor-wasm/v1") + (st/emit! (dwt-v3/v3-update-text-editor-styles (first ids) attrs))) + (st/emit! (dwt/save-font updated-attrs) + (dwt/update-all-attrs ids attrs))))) on-change (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index 3c683a80ef..a8509dbe81 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -30,6 +30,7 @@ [app.util.timers :as timers] [cuerdas.core :as str] [okulary.core :as l] + [promesa.core :as p] [rumext.v2 :as mf])) ;; FIXME: can we unify this two refs in one? @@ -77,18 +78,21 @@ (mf/deps id current-page-id is-separator?) (fn [] (when-not is-separator? - ;; For the wasm renderer, apply a blur effect to the viewport canvas - ;; when we navigate to a different page. + ;; WASM page transitions: + ;; - Capture the current page (A) once + ;; - Show a blurred snapshot while the target page (B/C/...) renders + ;; - If the user clicks again during the transition, keep showing the original (A) snapshot (if (and (features/active-feature? @st/state "render-wasm/v1") (not= id current-page-id)) (do - (wasm.api/capture-canvas-pixels) - (wasm.api/apply-canvas-blur) - ;; NOTE: it seems we need two RAF so the blur is actually applied and visible - ;; in the canvas :( - (timers/raf - (fn [] - (timers/raf navigate-fn)))) + (-> (wasm.api/apply-canvas-blur) + (p/finally + (fn [] + ;; NOTE: it seems we need two RAF so the blur is actually applied and visible + ;; in the canvas :( + (timers/raf + (fn [] + (timers/raf navigate-fn))))))) (navigate-fn))))) on-delete diff --git a/frontend/src/app/main/ui/workspace/text_palette.cljs b/frontend/src/app/main/ui/workspace/text_palette.cljs index 5325102a05..7599a1baed 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette.cljs @@ -22,8 +22,9 @@ [potok.v2.core :as ptk] [rumext.v2 :as mf])) -(mf/defc typography-item - [{:keys [file-id selected-ids typography name-only? size current-file-id]}] +(mf/defc typography-item* + {::mf/private true} + [{:keys [file-id selected-ids typography size current-file-id]}] (let [font-data (f/get-font-data (:font-id typography)) font-variant-id (:font-variant-id typography) variant-data (->> font-data :variants (d/seek #(= (:id %) font-variant-id))) @@ -60,14 +61,12 @@ :font-weight (:font-weight typography) :font-style (:font-style typography)}} (:name typography)] - (when-not name-only? - [:* - [:div {:class (stl/css :typography-font)} - (:name font-data)] - [:div {:class (stl/css :typography-data)} - (str (:font-size typography) "px | " (:name variant-data))]])])) + [:div {:class (stl/css :typography-font)} + (:name font-data)] + [:div {:class (stl/css :typography-data)} + (str (:font-size typography) "px | " (or (:name variant-data) "--"))]])) -(mf/defc palette +(mf/defc palette* [{:keys [selected selected-ids current-file-id file-typographies libraries size width]}] (let [file-id (case selected @@ -165,7 +164,7 @@ :max-width (str width "px") :right (str (* offset-step offset) "px")}} (for [[idx item] (map-indexed vector current-typographies)] - [:& typography-item + [:> typography-item* {:key idx :file-id file-id :current-file-id current-file-id @@ -178,7 +177,7 @@ :disabled (= offset max-offset) :on-click on-right-arrow-click} deprecated-icon/arrow])])) -(mf/defc text-palette +(mf/defc text-palette* {::mf/wrap [mf/memo]} [{:keys [size width selected] :as props}] (let [selected-ids (mf/deref refs/selected-shapes) @@ -189,10 +188,10 @@ file-typographies (mf/deref refs/workspace-file-typography) libraries (mf/deref refs/files) current-file-id (mf/use-ctx ctx/current-file-id)] - [:& palette {:current-file-id current-file-id - :selected-ids selected-ids - :file-typographies file-typographies - :libraries libraries - :width width - :selected selected - :size size}])) + [:> palette* {:current-file-id current-file-id + :selected-ids selected-ids + :file-typographies file-typographies + :libraries libraries + :width width + :selected selected + :size size}])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index d87398ec55..5ce0b1c16c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -522,7 +522,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - container (hooks/use-portal-container)] + container (hooks/use-portal-container :popup)] (mf/use-effect (mf/deps is-open?) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index 6b49e7df0a..574376ed2b 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -37,6 +37,8 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) + container (hooks/use-portal-container :popup) + rename-node (mf/use-fn (mf/deps mdata on-rename-node) (fn [] @@ -44,6 +46,7 @@ type (get mdata :type)] (when node (on-rename-node node type))))) + duplicate-node (mf/use-fn (mf/deps mdata on-duplicate-node) (fn [] @@ -52,7 +55,6 @@ (when node (on-duplicate-node node type))))) - container (hooks/use-portal-container) delete-node (mf/use-fn (mf/deps mdata) (fn [] @@ -74,7 +76,7 @@ (mf/set-ref-val! dropdown-direction-change* (inc (mf/ref-val dropdown-direction-change*))))))) ;; FIXME: perf optimization - + (when is-open? (mf/portal (mf/html diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index a8687c9719..d688588e2f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -114,7 +114,7 @@ :is-open? true :rect rect)))))) - container (hooks/use-portal-container)] + container (hooks/use-portal-container :popup)] [:div {:on-click on-open-dropdown :disabled (not can-edit?) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 5bf6037c1a..3b75d406bb 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -578,7 +578,7 @@ :tool drawing-tool}]) (when show-grids? - [:& frame-grid/frame-grid + [:> frame-grid/frame-grid* {:zoom zoom :selected selected :transform transform @@ -589,7 +589,7 @@ :zoom zoom}]) (when show-snap-points? - [:& snap-points/snap-points + [:> snap-points/snap-points* {:layout layout :transform transform :drawing drawing-obj @@ -690,13 +690,13 @@ :disabled (or drawing-tool @space?)}]))) (when show-prototypes? - [:& interactions/interactions + [:> interactions/interactions* {:selected selected :page-id page-id :zoom zoom :objects objects-modified :current-transform transform - :hover-disabled? hover-disabled?}])]) + :is-hover-disabled hover-disabled?}])]) (when show-gradient-handlers? [:> gradients/gradient-handlers* @@ -727,7 +727,7 @@ :view-only true}]))] [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} - [:& scroll-bars/viewport-scrollbars + [:> scroll-bars/viewport-scrollbars* {:objects base-objects :zoom zoom :vbox vbox diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index f0711768b2..c3c26ab6a5 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -23,7 +23,6 @@ [app.main.store :as st] [app.main.ui.workspace.sidebar.assets.components :as wsac] [app.main.ui.workspace.viewport.viewport-ref :as uwvv] - [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] [app.util.dom.normalize-wheel :as nw] @@ -280,7 +279,6 @@ (.releasePointerCapture target (.-pointerId event))) (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event) ctrl? (kbd/ctrl? native-event) shift? (kbd/shift? native-event) alt? (kbd/alt? native-event) @@ -290,10 +288,7 @@ middle-click? (= 2 (.-which native-event))] (when left-click? - (st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?)) - - (when (wasm.api/text-editor-has-focus?) - (wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt)))) + (st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?))) (when middle-click? (dom/prevent-default native-event) @@ -354,9 +349,7 @@ (let [last-position (mf/use-var nil)] (mf/use-fn (fn [event] - (let [native-event (unchecked-get event "nativeEvent") - off-pt (dom/get-offset-position native-event) - raw-pt (dom/get-client-position event) + (let [raw-pt (dom/get-client-position event) pt (uwvv/point->viewport raw-pt) ;; We calculate the delta because Safari's MouseEvent.movementX/Y drop @@ -365,13 +358,6 @@ (gpt/subtract raw-pt @last-position) (gpt/point 0 0))] - ;; IMPORTANT! This function, right now it's called on EVERY pointermove. I think - ;; in the future (when we handle the UI in the render) should be better to - ;; have a "wasm.api/pointer-move" function that works as an entry point for - ;; all the pointer-move events. - (when (wasm.api/text-editor-has-focus?) - (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))) - (rx/push! move-stream pt) (reset! last-position raw-pt) (st/emit! (mse/->PointerEvent :delta delta diff --git a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs index 61246ea705..5fafd22a6e 100644 --- a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs @@ -17,7 +17,7 @@ [app.main.refs :as refs] [rumext.v2 :as mf])) -(mf/defc square-grid [{:keys [frame zoom grid] :as props}] +(mf/defc square-grid* [{:keys [frame zoom grid]}] (let [grid-id (mf/use-memo #(uuid/next)) {:keys [size] :as params} (-> grid :params) {color-value :color color-opacity :opacity} (-> grid :params :color) @@ -45,7 +45,7 @@ :height (:height frame) :fill (str "url(#" grid-id ")")}]])) -(mf/defc layout-grid +(mf/defc layout-grid* [{:keys [key frame grid zoom]}] (let [{color-value :color color-opacity :opacity} (-> grid :params :color) ;; Support for old color format @@ -124,7 +124,7 @@ selrect parents)) -(mf/defc grid-display-frame +(mf/defc grid-display-frame* {::mf/wrap [mf/memo]} [{:keys [frame zoom transforming]}] (let [frame-id (:id frame) @@ -154,16 +154,16 @@ :zoom zoom :grid grid}] (case (:type grid) - :square [:> square-grid props] - :column [:> layout-grid props] - :row [:> layout-grid props])))]))) + :square [:> square-grid* props] + :column [:> layout-grid* props] + :row [:> layout-grid* props])))]))) (defn has-grid? [{:keys [grids]}] (and (some? grids) (d/not-empty? (->> grids (filter :display))))) -(mf/defc frame-grid +(mf/defc frame-grid* {::mf/wrap [mf/memo]} [{:keys [zoom transform selected focus]}] (let [frames (->> (mf/deref refs/workspace-frames) @@ -175,7 +175,7 @@ (when (and #_(not (is-transform? frame)) (not (ctst/rotated-frame? frame)) (or (empty? focus) (contains? focus (:id frame)))) - [:& grid-display-frame {:key (str "grid-" (:id frame)) - :zoom zoom - :frame frame - :transforming transforming}]))])) + [:> grid-display-frame* {:key (str "grid-" (:id frame)) + :zoom zoom + :frame frame + :transforming transforming}]))])) diff --git a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs index 9573efab01..cd2623fd47 100644 --- a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs @@ -43,7 +43,7 @@ (def gradient-endpoint-radius-selected 6) (def gradient-endpoint-radius-handler 20) -(mf/defc shadow [{:keys [id offset]}] +(mf/defc shadow* [{:keys [id offset]}] [:filter {:id id :x "-10%" :y "-10%" @@ -61,7 +61,7 @@ (def checkerboard "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAIAAAC0tAIdAAACvUlEQVQoFQGyAk39AeLi4gAAAAAAAB0dHQAAAAAAAOPj4wAAAAAAAB0dHQAAAAAAAOPj4wAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB////AAAAAAAA4+PjAAAAAAAAHR0dAAAAAAAA4+PjAAAAAAAAHR0dAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATj4+MAAAAAAAAdHR0AAAAAAADj4+MAAAAAAAAdHR0AAAAAAADj4+MAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjScaa0cU7nIAAAAASUVORK5CYII=") -(mf/defc gradient-color-handler +(mf/defc gradient-color-handler* [{:keys [zoom point color angle selected index on-click on-pointer-down on-pointer-up on-pointer-move on-lost-pointer-capture]}] [:g {:filter "url(#gradient-drop-shadow)" @@ -118,7 +118,7 @@ :r (/ 2 zoom) :fill "var(--app-white)"}]]) -(mf/defc gradient-handler-transformed +(mf/defc gradient-handler-transformed* [{:keys [from-p to-p width-p @@ -270,7 +270,7 @@ [:g.gradient-handlers {:pointer-events "none"} [:defs - [:& shadow {:id "gradient-drop-shadow" :offset (/ 2 zoom)}]] + [:> shadow* {:id "gradient-drop-shadow" :offset (/ 2 zoom)}]] (let [lv (-> (gpt/to-vec from-p to-p) (gpt/unit)) @@ -425,7 +425,7 @@ (-> (gpt/to-vec from-p to-p) (gpt/scale (:offset stop))))] - [:& gradient-color-handler + [:> gradient-color-handler* {:key index :selected (= editing index) :zoom zoom @@ -505,7 +505,7 @@ (when (and norm-dist (d/num? norm-dist)) (change! {:width norm-dist})))))] - [:& gradient-handler-transformed + [:> gradient-handler-transformed* {:editing editing :from-p from-p :to-p to-p diff --git a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs index af6b1e58aa..8b1ae15552 100644 --- a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs @@ -100,8 +100,8 @@ [orig-pos orig-x orig-y dest-pos dest-x dest-y])) -(mf/defc interaction-marker - [{:keys [x y stroke action-type arrow-dir zoom] :as props}] +(mf/defc interaction-marker* + [{:keys [x y stroke action-type arrow-dir zoom]}] (let [icon-pdata (case action-type :navigate (case arrow-dir :right "M -6.5 0 L 5.5 0 M 6.715 0.715 L -0.5 -6.5 M 6.715 -0.715 L -0.365 6.635" @@ -142,8 +142,8 @@ "translate(" (* zoom x) ", " (* zoom y) ")")}])])) -(mf/defc interaction-path - [{:keys [index level orig-shape dest-shape dest-point selected selected? action-type zoom] :as props}] +(mf/defc interaction-path* + [{:keys [index level orig-shape dest-shape dest-point selected is-selected action-type zoom]}] (let [[orig-pos orig-x orig-y dest-pos dest-x dest-y] (cond dest-shape @@ -168,11 +168,11 @@ incoming? (and (some? dest-shape) (contains? selected (:id dest-shape))) stroke-color (cond - selected? outgoing-link-color + is-selected outgoing-link-color incoming? incoming-link-color :else neutral-link-color)] - (if-not selected? + (if-not is-selected [:g {:on-pointer-down #(on-pointer-down % index orig-shape)} [:path {:stroke stroke-color :fill "none" @@ -180,13 +180,13 @@ :stroke-width (/ 2 zoom) :d pdata}] (when (not dest-shape) - [:& interaction-marker {:index index - :x dest-x - :y dest-y - :stroke stroke-color - :action-type action-type - :arrow-dir arrow-dir - :zoom zoom}])] + [:> interaction-marker* {:index index + :x dest-x + :y dest-y + :stroke stroke-color + :action-type action-type + :arrow-dir arrow-dir + :zoom zoom}])] [:g {:on-pointer-down #(on-pointer-down % index orig-shape)} [:path {:stroke stroke-color @@ -200,36 +200,36 @@ :shape dest-shape :color stroke-color}]) - [:& interaction-marker {:index index - :x orig-x - :y orig-y - :stroke stroke-color - :zoom zoom}] - [:& interaction-marker {:index index - :x dest-x - :y dest-y - :stroke stroke-color - :action-type action-type - :arrow-dir arrow-dir - :zoom zoom}]]))) + [:> interaction-marker* {:index index + :x orig-x + :y orig-y + :stroke stroke-color + :zoom zoom}] + [:> interaction-marker* {:index index + :x dest-x + :y dest-y + :stroke stroke-color + :action-type action-type + :arrow-dir arrow-dir + :zoom zoom}]]))) -(mf/defc interaction-handle - [{:keys [index shape zoom] :as props}] +(mf/defc interaction-handle* + [{:keys [index shape zoom]}] (let [shape-rect (:selrect shape) handle-x (+ (:x shape-rect) (:width shape-rect)) handle-y (+ (:y shape-rect) (/ (:height shape-rect) 2))] [:g {:on-pointer-down #(on-pointer-down % index shape)} - [:& interaction-marker {:x handle-x - :y handle-y - :stroke "var(--color-accent-tertiary)" - :action-type :navigate - :arrow-dir :right - :zoom zoom}]])) + [:> interaction-marker* {:x handle-x + :y handle-y + :stroke "var(--color-accent-tertiary)" + :action-type :navigate + :arrow-dir :right + :zoom zoom}]])) -(mf/defc overlay-marker - [{:keys [page-id index orig-shape dest-shape position objects hover-disabled?] :as props}] +(mf/defc overlay-marker* + [{:keys [page-id index orig-shape dest-shape position objects is-hover-disabled]}] (let [start-move-position (fn [_] (st/emit! (dw/start-move-overlay-pos index)))] @@ -260,8 +260,8 @@ (some? thumbnail-data) (assoc :thumbnail thumbnail-data))] [:g {:on-pointer-down start-move-position - :on-pointer-enter #(reset! hover-disabled? true) - :on-pointer-leave #(reset! hover-disabled? false)} + :on-pointer-enter #(reset! is-hover-disabled true) + :on-pointer-leave #(reset! is-hover-disabled false)} [:g {:transform (gmt/translate-matrix (gpt/point (- marker-x dest-x) (- marker-y dest-y)))} [:& (mf/provider muc/render-thumbnails) {:value true} [:& (mf/provider embed/context) {:value false} @@ -283,8 +283,8 @@ :r 8 :fill "var(--color-accent-tertiary)"}]])))) -(mf/defc interactions - [{:keys [current-transform objects zoom selected hover-disabled? page-id] :as props}] +(mf/defc interactions* + [{:keys [current-transform objects zoom selected is-hover-disabled page-id]}] (let [active-shapes (into [] (comp (filter #(seq (:interactions %)))) (vals objects)) @@ -315,26 +315,26 @@ selected? (contains? selected (:id shape)) level (calc-level index (:interactions shape))] (when-not selected? - [:& interaction-path {:key (dm/str "non-selected-" (:id shape) "-" index) - :index index - :level level - :orig-shape shape - :dest-shape dest-shape - :selected selected - :selected? false - :action-type (:action-type interaction) - :zoom zoom}]))))] + [:> interaction-path* {:key (dm/str "non-selected-" (:id shape) "-" index) + :index index + :level level + :orig-shape shape + :dest-shape dest-shape + :selected selected + :is-selected false + :action-type (:action-type interaction) + :zoom zoom}]))))] [:g.selected (when (and draw-interaction-to first-selected) - [:& interaction-path {:key "interactive" - :index nil - :orig-shape first-selected - :dest-point draw-interaction-to - :dest-shape draw-interaction-to-frame - :selected? true - :action-type :navigate - :zoom zoom}]) + [:> interaction-path* {:key "interactive" + :index nil + :orig-shape first-selected + :dest-point draw-interaction-to + :dest-shape draw-interaction-to-frame + :is-selected true + :action-type :navigate + :zoom zoom}]) (for [shape selected-shapes] (if (seq (:interactions shape)) (for [[index interaction] (d/enumerate (:interactions shape))] @@ -343,38 +343,38 @@ (get objects (:destination interaction))) level (calc-level index (:interactions shape))] [:g {:key (dm/str "interaction-path-" (:id shape) "-" index)} - [:& interaction-path {:index index - :level level - :orig-shape shape - :dest-shape dest-shape - :selected selected - :selected? true - :action-type (:action-type interaction) - :zoom zoom}] + [:> interaction-path* {:index index + :level level + :orig-shape shape + :dest-shape dest-shape + :selected selected + :is-selected true + :action-type (:action-type interaction) + :zoom zoom}] (when (and (or (= (:action-type interaction) :open-overlay) (= (:action-type interaction) :toggle-overlay)) (= (:overlay-pos-type interaction) :manual)) (if (and (some? move-overlay-to) (= move-overlay-index index)) - [:& overlay-marker {:page-id page-id - :index index - :orig-shape shape - :dest-shape dest-shape - :position move-overlay-to - :objects objects - :hover-disabled? hover-disabled?}] - [:& overlay-marker {:page-id page-id - :index index - :orig-shape shape - :dest-shape dest-shape - :position (:overlay-position interaction) - :objects objects - :hover-disabled? hover-disabled?}]))]))) + [:> overlay-marker* {:page-id page-id + :index index + :orig-shape shape + :dest-shape dest-shape + :position move-overlay-to + :objects objects + :is-hover-disabled is-hover-disabled}] + [:> overlay-marker* {:page-id page-id + :index index + :orig-shape shape + :dest-shape dest-shape + :position (:overlay-position interaction) + :objects objects + :is-hover-disabled is-hover-disabled}]))]))) (when (and shape (not (cfh/unframed-shape? shape)) (not (#{:move :rotate} current-transform))) - [:& interaction-handle {:key (:id shape) - :index nil - :shape shape - :selected selected - :zoom zoom}])))]])) + [:> interaction-handle* {:key (:id shape) + :index nil + :shape shape + :selected selected + :zoom zoom}])))]])) diff --git a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs index bec8eaf330..b23e71ade2 100644 --- a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs @@ -142,7 +142,7 @@ "Z")) -(mf/defc rulers-text +(mf/defc rulers-text* "Draws the text for the rulers in a specific axis" [{:keys [vbox step offset axis zoom-inverse]}] (let [clip-id (str "clip-ruler-" (d/name axis)) @@ -186,13 +186,13 @@ :style {:stroke font-color :stroke-width rulers-width}}]]))])) -(mf/defc viewport-frame - [{:keys [show-rulers? zoom zoom-inverse vbox offset-x offset-y]}] - +(mf/defc viewport-frame* + {::mf/private true} + [{:keys [show-rulers zoom zoom-inverse vbox offset-x offset-y]}] (let [{:keys [width height] x1 :x y1 :y} vbox x2 (+ x1 width) y2 (+ y1 height) - bw (if show-rulers? (* ruler-area-size zoom-inverse) 0) + bw (if show-rulers (* ruler-area-size zoom-inverse) 0) br (/ canvas-border-radius zoom) bs (* 4 zoom-inverse)] [:* @@ -214,13 +214,13 @@ :fill-rule "evenodd" :fill rulers-background}]] - (when show-rulers? + (when show-rulers (let [step (calculate-step-size zoom)] [:g.viewport-frame-rulers - [:& rulers-text {:vbox vbox :offset offset-x :step step :zoom-inverse zoom-inverse :axis :x}] - [:& rulers-text {:vbox vbox :offset offset-y :step step :zoom-inverse zoom-inverse :axis :y}]]))])) + [:> rulers-text* {:vbox vbox :offset offset-x :step step :zoom-inverse zoom-inverse :axis :x}] + [:> rulers-text* {:vbox vbox :offset offset-y :step step :zoom-inverse zoom-inverse :axis :y}]]))])) -(mf/defc selection-area +(mf/defc selection-area* [{:keys [vbox zoom-inverse selection-rect offset-x offset-y]}] ;; When using the format-number callls we consider if the guide is associated to a frame and we show the position relative to it with the offset [:g.selection-area @@ -332,8 +332,8 @@ (when (some? vbox) [:g.viewport-frame {:pointer-events "none"} - [:& viewport-frame - {:show-rulers? show-rulers? + [:> viewport-frame* + {:show-rulers show-rulers? :zoom zoom :zoom-inverse zoom-inverse :vbox vbox @@ -341,7 +341,7 @@ :offset-y offset-y}] (when (and show-rulers? (some? selection-rect)) - [:& selection-area + [:> selection-area* {:zoom zoom :zoom-inverse zoom-inverse :vbox vbox diff --git a/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs b/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs index 87ee8d3656..3bf3e0a0e0 100644 --- a/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs @@ -26,7 +26,7 @@ (def other-height 100) -(mf/defc viewport-scrollbars +(mf/defc viewport-scrollbars* {::mf/wrap [mf/memo]} [{:keys [objects zoom vbox bottom-padding]}] diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs index c903a19389..97b8c905f8 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs @@ -50,7 +50,7 @@ (def pill-text-border-radius 4) (def pill-text-padding 4) -(mf/defc shape-distance-segment +(mf/defc shape-distance-segment* "Displays a segment between two selrects with the distance between them" [{:keys [sr1 sr2 coord zoom]}] (let [from-c (mth/min (get sr1 (if (= :x coord) :x2 :y2)) @@ -268,7 +268,7 @@ #(rx/push! subject [selrect selected frame])) (for [[sr1 sr2] segments-to-display] - [:& shape-distance-segment + [:> shape-distance-segment* {:key (str/ffmt "%-%-%-%" (dm/get-prop sr1 :x) (dm/get-prop sr1 :y) diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs index d65ae80f06..ccc0660e2b 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs @@ -25,7 +25,7 @@ ;; (def ^:private line-opacity 1 ) ;; (def ^:private line-width 2) -(mf/defc snap-point +(mf/defc snap-point* [{:keys [point zoom]}] (let [{:keys [x y]} point cross-width (/ 3 zoom)] @@ -41,7 +41,7 @@ :y2 (- y cross-width) :style {:stroke line-color :stroke-width (str (/ line-width zoom))}}]])) -(mf/defc snap-line +(mf/defc snap-line* [{:keys [snap point zoom]}] [:line {:x1 (:x snap) :y1 (:y snap) @@ -50,8 +50,8 @@ :style {:stroke line-color :stroke-width (str (/ line-width zoom))} :opacity line-opacity}]) -(defn get-snap - [coord {:keys [shapes page-id remove-snap? zoom]}] +(defn- get-snap + [coord shapes page-id remove-snap zoom] (let [bounds (gsh/shapes->rect shapes) frame-id (snap/snap-frame-id shapes)] @@ -63,7 +63,7 @@ (rx/merge-map (fn [[frame-id point]] - (->> (snap/get-snap-points page-id frame-id remove-snap? zoom point coord) + (->> (snap/get-snap-points page-id frame-id remove-snap zoom point coord) (rx/map #(mapcat second %)) (rx/map #(map :pt %)) (rx/map #(vector point % coord))))) @@ -74,7 +74,7 @@ [coord] (if (= coord :x) :y :x)) -(defn add-point-to-snaps +(defn- add-point-to-snaps [[point snaps coord]] (let [normalize-coord #(assoc % coord (get point coord))] (cons point (map normalize-coord snaps)))) @@ -100,8 +100,9 @@ (map (fn [[fixedv [minv maxv]]] [(hash-map coord fixedv (flip coord) minv) (hash-map coord fixedv (flip coord) maxv)])))) -(mf/defc snap-feedback - [{:keys [shapes remove-snap? zoom modifiers] :as props}] +(mf/defc snap-feedback* + {::mf/private true} + [{:keys [shapes remove-snap zoom modifiers page-id]}] (let [state (mf/use-state []) subject (mf/use-memo #(rx/subject)) @@ -116,9 +117,9 @@ (fn [] (let [sub (->> subject (rx/switch-map - (fn [props] - (->> (get-snap :y props) - (rx/combine-latest (get-snap :x props))))) + (fn [{:keys [shapes page-id remove-snap zoom]}] + (->> (get-snap :y shapes page-id remove-snap zoom) + (rx/combine-latest (get-snap :x shapes page-id remove-snap zoom))))) (rx/map (fn [result] @@ -133,28 +134,31 @@ #(rx/dispose! sub)))) (mf/use-effect - (mf/deps shapes remove-snap? modifiers) + (mf/deps shapes remove-snap modifiers page-id zoom) (fn [] - (rx/push! subject props))) + (rx/push! subject {:shapes shapes + :page-id page-id + :remove-snap remove-snap + :zoom zoom}))) [:g.snap-feedback (for [[from-point to-point] snap-lines] - [:& snap-line {:key (str "line-" (:x from-point) - "-" (:y from-point) - "-" (:x to-point) - "-" (:y to-point) "-") - :snap from-point - :point to-point - :zoom zoom}]) + [:> snap-line* {:key (str "line-" (:x from-point) + "-" (:y from-point) + "-" (:x to-point) + "-" (:y to-point) "-") + :snap from-point + :point to-point + :zoom zoom}]) (for [point snap-points] - [:& snap-point {:key (str "point-" (:x point) - "-" (:y point)) - :point point - :zoom zoom}])])) + [:> snap-point* {:key (str "point-" (:x point) + "-" (:y point)) + :point point + :zoom zoom}])])) -(mf/defc snap-points +(mf/defc snap-points* {::mf/wrap [mf/memo]} - [{:keys [layout zoom objects selected page-id drawing focus] :as props}] + [{:keys [layout zoom objects selected page-id drawing focus]}] (dm/assert! (set? selected)) (let [shapes (into [] (keep (d/getf objects)) selected) @@ -165,7 +169,7 @@ (mf/with-memo [layout filter-shapes objects focus] (snap/make-remove-snap layout filter-shapes objects focus)) - remove-snap? + remove-snap (mf/use-callback (mf/deps remove-snap-base?) (fn [{:keys [type grid] :as snap}] @@ -176,8 +180,8 @@ shapes (if drawing [drawing] shapes) frame-id (snap/snap-frame-id shapes)] (when-not (ctl/any-layout? objects frame-id) - [:& snap-feedback {:shapes shapes - :page-id page-id - :remove-snap? remove-snap? - :zoom zoom}]))) + [:> snap-feedback* {:shapes shapes + :page-id page-id + :remove-snap remove-snap + :zoom zoom}]))) diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index 7a25682209..a47897d2d6 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -78,7 +78,7 @@ :stroke-width (/ 1 zoom)}}])) -(mf/defc frame-title +(mf/defc frame-title* {::mf/wrap [mf/memo #(mf/deferred % ts/raf)] ::mf/forward-ref true} @@ -261,16 +261,16 @@ (not= id uuid/zero) (or (dbg/enabled? :shape-titles) (= parent-id uuid/zero)) (or (empty? focus) (contains? focus id))) - [:& frame-title {:key (dm/str "frame-title-" id) - :frame shape - :zoom zoom - :is-selected (contains? selected id) - :is-show-artboard-names is-show-artboard-names - :is-show-id (dbg/enabled? :shape-titles) - :is-grid-edition (and (= id edition) grid-edition?) - :on-frame-enter on-frame-enter - :on-frame-leave on-frame-leave - :on-frame-select on-frame-select}]))])) + [:> frame-title* {:key (dm/str "frame-title-" id) + :frame shape + :zoom zoom + :is-selected (contains? selected id) + :is-show-artboard-names is-show-artboard-names + :is-show-id (dbg/enabled? :shape-titles) + :is-grid-edition (and (= id edition) grid-edition?) + :on-frame-enter on-frame-enter + :on-frame-leave on-frame-leave + :on-frame-select on-frame-select}]))])) (mf/defc frame-flow* [{:keys [flow frame is-selected zoom on-frame-enter on-frame-leave on-frame-select]}] diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index edfd3ce582..e71747b2d3 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -132,21 +132,21 @@ (apply-modifiers-to-objects base-objects wasm-modifiers)) ;; STATE - alt? (mf/use-state false) - shift? (mf/use-state false) - mod? (mf/use-state false) - space? (mf/use-state false) - z? (mf/use-state false) - cursor (mf/use-state (utils/get-cursor :pointer-inner)) - hover-ids (mf/use-state nil) - hover (mf/use-state nil) - measure-hover (mf/use-state nil) - hover-disabled? (mf/use-state false) - hover-top-frame-id (mf/use-state nil) - frame-hover (mf/use-state nil) - active-frames (mf/use-state #{}) - canvas-init? (mf/use-state false) - initialized? (mf/use-state false) + alt? (mf/use-state false) + shift? (mf/use-state false) + mod? (mf/use-state false) + space? (mf/use-state false) + z? (mf/use-state false) + cursor (mf/use-state (utils/get-cursor :pointer-inner)) + hover-ids (mf/use-state nil) + hover (mf/use-state nil) + measure-hover (mf/use-state nil) + hover-disabled? (mf/use-state false) + hover-top-frame-id (mf/use-state nil) + frame-hover (mf/use-state nil) + active-frames (mf/use-state #{}) + canvas-init? (mf/use-state false) + initialized? (mf/use-state false) ;; REFS [viewport-ref @@ -205,6 +205,9 @@ mode-inspect? (= options-mode :inspect) + ;; True when we are opening a new file or switching to a new page + page-transition? (mf/deref wasm.api/page-transition?) + on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?) on-context-menu (actions/on-context-menu hover hover-ids read-only?) on-double-click (actions/on-double-click hover hover-ids hover-top-frame-id path-drawing? base-objects edition drawing-tool z? read-only?) @@ -234,37 +237,43 @@ show-cursor-tooltip? tooltip show-draw-area? drawing-obj show-gradient-handlers? (= (count selected) 1) - show-grids? (contains? layout :display-guides) + show-grids? (and (contains? layout :display-guides) (not page-transition?)) - show-frame-outline? (and (= transform :move) (not panning)) + show-frame-outline? (and (= transform :move) (not panning) (not page-transition?)) show-outlines? (and (nil? transform) (not panning) (not edition) (not drawing-obj) - (not (#{:comments :path :curve} drawing-tool))) + (not (#{:comments :path :curve} drawing-tool)) + (not page-transition?)) show-pixel-grid? (and (contains? layout :show-pixel-grid) - (>= zoom 8)) - show-text-editor? (and editing-shape (= :text (:type editing-shape))) + (>= zoom 8) + (not page-transition?)) + show-text-editor? (and editing-shape (= :text (:type editing-shape)) (not page-transition?)) hover-grid? (and (some? @hover-top-frame-id) - (ctl/grid-layout? objects @hover-top-frame-id)) + (ctl/grid-layout? objects @hover-top-frame-id) + (not page-transition?)) - show-grid-editor? (and editing-shape (ctl/grid-layout? editing-shape)) - show-presence? page-id - show-prototypes? (= options-mode :prototype) - show-selection-handlers? (and (seq selected) (not show-text-editor?)) + show-grid-editor? (and editing-shape (ctl/grid-layout? editing-shape) (not page-transition?)) + show-presence? (and page-id (not page-transition?)) + show-prototypes? (and (= options-mode :prototype) (not page-transition?)) + show-selection-handlers? (and (seq selected) (not show-text-editor?) (not page-transition?)) show-snap-distance? (and (contains? layout :dynamic-alignment) (= transform :move) - (seq selected)) + (seq selected) + (not page-transition?)) show-snap-points? (and (or (contains? layout :dynamic-alignment) (contains? layout :snap-guides)) - (or drawing-obj transform)) - show-selrect? (and selrect (empty? drawing) (not text-editing?)) + (or drawing-obj transform) + (not page-transition?)) + show-selrect? (and selrect (empty? drawing) (not text-editing?) (not page-transition?)) show-measures? (and (not transform) (not path-editing?) - (or show-distances? mode-inspect?)) - show-artboard-names? (contains? layout :display-artboard-names) + (or show-distances? mode-inspect?) + (not page-transition?)) + show-artboard-names? (and (contains? layout :display-artboard-names) (not page-transition?)) hide-ui? (contains? layout :hide-ui) show-rulers? (and (contains? layout :rulers) (not hide-ui?)) @@ -280,6 +289,8 @@ (or (ctk/is-variant-container? first-shape) (ctk/is-variant? first-shape))) + show-scrollbar? (not page-transition?) + add-variant (mf/use-fn (mf/deps first-shape) @@ -312,7 +323,8 @@ rule-area-size (/ rulers/ruler-area-size zoom) preview-blend (-> refs/workspace-preview-blend (mf/deref)) - shapes-loading? (mf/deref wasm.api/shapes-loading?)] + shapes-loading? (mf/deref wasm.api/shapes-loading?) + transition-image-url (mf/deref wasm.api/transition-image-url*)] ;; NOTE: We need this page-id dependency to react to it and reset the ;; canvas, even though we are not using `page-id` inside the hook. @@ -342,15 +354,7 @@ (cond init? (do - (reset! canvas-init? true) - (wasm.api/apply-canvas-blur) - (if (wasm.api/has-captured-pixels?) - ;; Page switch: restore previously captured pixels (blurred) - (wasm.api/restore-previous-canvas-pixels) - ;; First load: try to draw a blurred page thumbnail - (when-let [frame-id (get page :thumbnail-frame-id)] - (when-let [uri (dm/get-in @st/state [:thumbnails frame-id])] - (wasm.api/draw-thumbnail-to-canvas uri))))) + (reset! canvas-init? true)) (pos? retries) (vreset! timeout-id-ref @@ -393,19 +397,20 @@ (when @canvas-init? (if (not @initialized?) (do + ;; Initial file open uses the same transition workflow as page switches, + ;; but with a solid background-color blurred placeholder. + (wasm.api/start-initial-load-transition! background) ;; Keep the blurred previous-page preview (page switch) or ;; blank canvas (first load) visible while shapes load. ;; The loading overlay is suppressed because on-shapes-ready ;; is set. (wasm.api/initialize-viewport - base-objects zoom vbox background 1 nil - (fn [] - (wasm.api/clear-canvas-pixels))) + base-objects zoom vbox :background background) (reset! initialized? true) (mf/set-ref-val! last-file-version-id-ref file-version-id)) (when (and (some? file-version-id) (not= file-version-id (mf/ref-val last-file-version-id-ref))) - (wasm.api/initialize-viewport base-objects zoom vbox background) + (wasm.api/initialize-viewport base-objects zoom vbox :background background) (mf/set-ref-val! last-file-version-id-ref file-version-id))))) (mf/with-effect [focus] @@ -477,6 +482,21 @@ :style {:background-color background :pointer-events "none"}}] + ;; Show the transition image when we are opening a new file or switching to a new page + (when (and page-transition? (some? transition-image-url)) + (let [src transition-image-url] + [:img {:data-testid "canvas-wasm-transition" + :src src + :draggable false + :style {:position "absolute" + :inset 0 + :width "100%" + :height "100%" + :object-fit "cover" + :pointer-events "none" + :filter "blur(4px)"}}])) + + [:svg.viewport-controls {:xmlns "http://www.w3.org/2000/svg" :xmlnsXlink "http://www.w3.org/1999/xlink" @@ -637,7 +657,7 @@ :tool drawing-tool}]) (when show-grids? - [:& frame-grid/frame-grid + [:> frame-grid/frame-grid* {:zoom zoom :selected selected :transform transform @@ -648,7 +668,7 @@ :zoom zoom}]) (when show-snap-points? - [:& snap-points/snap-points + [:> snap-points/snap-points* {:layout layout :transform transform :drawing drawing-obj @@ -750,13 +770,13 @@ :disabled (or drawing-tool @space?)}]))) (when show-prototypes? - [:& interactions/interactions + [:> interactions/interactions* {:selected selected :page-id page-id :zoom zoom :objects objects-modified :current-transform transform - :hover-disabled? hover-disabled?}])]) + :is-hover-disabled hover-disabled?}])]) (when show-gradient-handlers? [:> gradients/gradient-handlers* @@ -777,9 +797,10 @@ (get objects-modified @hover-top-frame-id)) :view-only (not show-grid-editor?)}])] - [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} - [:& scroll-bars/viewport-scrollbars - {:objects base-objects - :zoom zoom - :vbox vbox - :bottom-padding (when palete-size (+ palete-size 8))}]]]]])) + (when show-scrollbar? + [:g.scrollbar-wrapper {:clipPath "url(#clip-handlers)"} + [:> scroll-bars/viewport-scrollbars* + {:objects base-objects + :zoom zoom + :vbox vbox + :bottom-padding (when palete-size (+ palete-size 8))}]])]]])) diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 1154af2366..aa4539c980 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -15,6 +15,8 @@ [app.common.types.text :as txt] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.wasm-text :as dwwt] + [app.main.features :as features] [app.main.fonts :as fonts] [app.main.store :as st] [app.plugins.format :as format] @@ -417,8 +419,10 @@ (st/emit! (dwt/update-editor-state shape editor))) :else - (st/emit! (dwsh/update-shapes [id] - #(update % :content txt/change-text value))))))} + (do + (st/emit! (dwsh/update-shapes [id] #(update % :content txt/change-text value))) + (when (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-debounce id)))))))} {:name "growType" :get #(-> % u/proxy->shape :grow-type d/name) @@ -434,7 +438,10 @@ (u/not-valid plugin-id :growType "Plugin doesn't have 'content:write' permission") :else - (st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value))))))} + (st/emit! + (dwsh/update-shapes [id] #(assoc % :grow-type value)) + (when (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-debounce id)))))))} {:name "fontId" :get #(-> % u/proxy->shape text-props :font-id format/format-mixed) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index f12cbbc332..a44bafb15b 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -27,7 +27,6 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.shapes.text] - [app.main.worker :as mw] [app.render-wasm.api.fonts :as f] [app.render-wasm.api.shapes :as shapes] [app.render-wasm.api.texts :as t] @@ -55,6 +54,109 @@ (def use-dpr? (contains? cf/flags :render-wasm-dpr)) +;; --- Page transition state (WASM viewport) +;; +;; Goal: avoid showing tile-by-tile rendering during page switches (and initial load), +;; by keeping a blurred snapshot overlay visible until WASM dispatches +;; `penpot:wasm:tiles-complete`. +;; +;; - `page-transition?`: true while the overlay should be considered active. +;; - `transition-image-url*`: URL used by the UI overlay (usually `blob:` from the +;; current WebGL canvas snapshot; on initial load it may be a tiny SVG data-url +;; derived from the page background color). +;; - `transition-epoch*`: monotonic counter used to ignore stale async work/events +;; when the user clicks pages rapidly (A -> B -> C). +;; - `transition-tiles-handler*`: the currently installed DOM event handler for +;; `penpot:wasm:tiles-complete`, so we can remove/replace it safely. +(defonce page-transition? (atom false)) +(defonce transition-image-url* (atom nil)) +(defonce transition-epoch* (atom 0)) +(defonce transition-tiles-handler* (atom nil)) + +(def ^:private transition-blur-css "blur(4px)") + +(defn- set-transition-blur! + [] + (when-let [canvas ^js wasm/canvas] + (dom/set-style! canvas "filter" transition-blur-css)) + (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] + (doseq [^js node (array-seq nodes)] + (dom/set-style! node "filter" transition-blur-css)))) + +(defn- clear-transition-blur! + [] + (when-let [canvas ^js wasm/canvas] + (dom/set-style! canvas "filter" "")) + (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] + (doseq [^js node (array-seq nodes)] + (dom/set-style! node "filter" "")))) + +(defn set-transition-image-from-background! + "Sets `transition-image-url*` to a data URL representing a solid background color." + [background] + (when (string? background) + (let [svg (str "" + "" + "")] + (reset! transition-image-url* + (str "data:image/svg+xml;charset=utf-8," (js/encodeURIComponent svg)))))) + +(defn begin-page-transition! + [] + (reset! page-transition? true) + (swap! transition-epoch* inc)) + +(defn end-page-transition! + [] + (reset! page-transition? false) + (when-let [prev @transition-tiles-handler*] + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) + (reset! transition-tiles-handler* nil) + (reset! transition-image-url* nil) + (clear-transition-blur!) + ;; Clear captured pixels so future transitions must explicitly capture again. + (set! wasm/canvas-snapshot-url nil)) + +(defn- set-transition-tiles-complete-handler! + "Installs a tiles-complete handler bound to the current transition epoch. + Replaces any previous handler so rapid page switching doesn't end the wrong transition." + [epoch f] + (when-let [prev @transition-tiles-handler*] + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) + (letfn [(handler [_] + (when (= epoch @transition-epoch*) + (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" handler) + (reset! transition-tiles-handler* nil) + (f)))] + (reset! transition-tiles-handler* handler) + (.addEventListener ^js ug/document "penpot:wasm:tiles-complete" handler))) + +(defn start-initial-load-transition! + "Starts a page-transition workflow for initial file open. + + - Sets `page-transition?` to true + - Installs a tiles-complete handler to end the transition + - Uses a solid background-color placeholder as the transition image" + [background] + ;; If something already toggled `page-transition?` (e.g. legacy init code paths), + ;; ensure we still have a deterministic placeholder on initial load. + (when (or (not @page-transition?) (nil? @transition-image-url*)) + (set-transition-image-from-background! background)) + (when-not @page-transition? + ;; Start transition + bind the tiles-complete handler to this epoch. + (let [epoch (begin-page-transition!)] + (set-transition-tiles-complete-handler! epoch end-page-transition!)))) + +(defn listen-tiles-render-complete-once! + "Registers a one-shot listener for `penpot:wasm:tiles-complete`, dispatched from WASM + when a full tile pass finishes." + [f] + (.addEventListener ^js ug/document + "penpot:wasm:tiles-complete" + (fn [_] + (f)) + #js {:once true})) + (defn text-editor-wasm? [] (or (contains? cf/flags :feature-text-editor-wasm) @@ -94,16 +196,9 @@ (def ^:const TEXT_EDITOR_EVENT_NEEDS_LAYOUT 4) ;; Re-export public WebGL functions -(def capture-canvas-pixels webgl/capture-canvas-pixels) -(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels) -(def clear-canvas-pixels webgl/clear-canvas-pixels) +(def capture-canvas-snapshot-url webgl/capture-canvas-snapshot-url) (def draw-thumbnail-to-canvas webgl/draw-thumbnail-to-canvas) -(defn has-captured-pixels? - "Returns true if there are saved canvas pixels from a previous page." - [] - (some? wasm/canvas-pixels)) - ;; Re-export public text editor functions (def text-editor-focus text-editor/text-editor-focus) (def text-editor-blur text-editor/text-editor-blur) @@ -307,8 +402,9 @@ "Apply style attrs to the currently selected text spans. Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving." [attrs] - (text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content) - (request-render "apply-styles-to-selection")) + (let [result (text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content)] + (request-render "apply-styles-to-selection") + result)) (defn set-parent-id [id] @@ -1018,15 +1114,6 @@ (render-finish) (perf/end-measure "render-from-cache")) -(defn update-text-rect! - [id] - (when wasm/context-initialized? - (mw/emit! - {:cmd :index/update-text-rect - :page-id (:current-page-id @st/state) - :shape-id id - :dimensions (get-text-dimensions id)}))) - (defn- ensure-text-content "Guarantee that the shape always sends a valid text tree to WASM. When the content is nil (freshly created text) we fall back to @@ -1099,10 +1186,7 @@ "Synchronously update text layouts for all shapes and send rect updates to the worker index." [text-ids] - (run! (fn [id] - (f/update-text-layout id) - (update-text-rect! id)) - text-ids)) + (run! f/update-text-layout text-ids)) (defn process-pending [shapes thumbnails full on-complete] @@ -1120,11 +1204,13 @@ (if (or (seq pending-thumbnails) (seq pending-full)) (->> (rx/concat (->> (rx/from (vals pending-thumbnails)) - (rx/merge-map (fn [callback] (callback))) - (rx/reduce conj [])) + (rx/merge-map (fn [callback] (if (fn? callback) (callback) (rx/empty)))) + (rx/reduce conj []) + (rx/catch #(rx/empty))) (->> (rx/from (vals pending-full)) - (rx/mapcat (fn [callback] (callback))) - (rx/reduce conj []))) + (rx/mapcat (fn [callback] (if (fn? callback) (callback) (rx/empty)))) + (rx/reduce conj []) + (rx/catch #(rx/empty)))) (rx/subs! (fn [_] ;; Fonts are now loaded — recompute text layouts so Skia @@ -1134,7 +1220,7 @@ (update-text-layouts text-ids))) (request-render "images-loaded")) noop-fn - (fn [] (when on-complete (on-complete))))) + (fn [] (when (fn? on-complete) (on-complete))))) ;; No pending images — complete immediately. (when on-complete (on-complete))))) @@ -1300,14 +1386,14 @@ loading begins, allowing callers to reveal the page content during transitions." ([objects] - (set-objects objects nil nil)) + (set-objects objects nil nil false)) ([objects render-callback] - (set-objects objects render-callback nil)) - ([objects render-callback on-shapes-ready] + (set-objects objects render-callback nil false)) + ([objects render-callback on-shapes-ready force-sync] (perf/begin-measure "set-objects") (let [shapes (shapes-in-tree-order objects) total-shapes (count shapes)] - (if (< total-shapes ASYNC_THRESHOLD) + (if (or force-sync (< total-shapes ASYNC_THRESHOLD)) (set-objects-sync shapes render-callback on-shapes-ready) (do (begin-shapes-loading!) @@ -1456,19 +1542,16 @@ (request-render "set-modifiers"))))) (defn initialize-viewport - ([base-objects zoom vbox background] - (initialize-viewport base-objects zoom vbox background 1 nil nil)) - ([base-objects zoom vbox background callback] - (initialize-viewport base-objects zoom vbox background 1 callback nil)) - ([base-objects zoom vbox background background-opacity callback] - (initialize-viewport base-objects zoom vbox background background-opacity callback nil)) - ([base-objects zoom vbox background background-opacity callback on-shapes-ready] - (let [rgba (sr-clr/hex->u32argb background background-opacity) - total-shapes (count (vals base-objects))] - (h/call wasm/internal-module "_set_canvas_background" rgba) - (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) - (h/call wasm/internal-module "_init_shapes_pool" total-shapes) - (set-objects base-objects callback on-shapes-ready)))) + [base-objects zoom vbox & + {:keys [background background-opacity on-render on-shapes-ready force-sync] + :or {background-opacity 1}}] + (let [rgba (when background (sr-clr/hex->u32argb background background-opacity)) + total-shapes (count (vals base-objects))] + + (when rgba (h/call wasm/internal-module "_set_canvas_background" rgba)) + (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) + (h/call wasm/internal-module "_init_shapes_pool" total-shapes) + (set-objects base-objects on-render on-shapes-ready force-sync))) (def ^:private default-context-options #js {:antialias false @@ -1537,6 +1620,8 @@ (h/call wasm/internal-module "_set_render_options" flags dpr) (when-let [t (wasm-aa-threshold-from-route-params)] (h/call wasm/internal-module "_set_antialias_threshold" t)) + (when-let [max-tex (webgl/max-texture-size context)] + (h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex)) ;; Set browser and canvas size only after initialization (h/call wasm/internal-module "_set_browser" browser) @@ -1762,11 +1847,11 @@ :direction (dr/translate-direction direction) :font-id (get element :font-id) :font-family (get element :font-family) - :font-size (get element :font-size) + :font-size (dm/str (get element :font-size) "px") :font-weight (get element :font-weight) :text-transform (get element :text-transform) :text-decoration (get element :text-decoration) - :letter-spacing (get element :letter-spacing) + :letter-spacing (dm/str (get element :letter-spacing) "px") :font-style (get element :font-style) :fills (get element :fills) :text text}))))))) @@ -1774,9 +1859,36 @@ (defn apply-canvas-blur [] - (when wasm/canvas (dom/set-style! wasm/canvas "filter" "blur(4px)")) - (let [controls-to-blur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")] - (run! #(dom/set-style! % "filter" "blur(4px)") controls-to-blur))) + (let [already? @page-transition? + epoch (begin-page-transition!)] + (set-transition-tiles-complete-handler! epoch end-page-transition!) + ;; Two-phase transition: + ;; - Apply CSS blur to the live canvas immediately (no async wait), so the user + ;; sees the transition right away. + ;; - In parallel, capture a `blob:` snapshot URL; once ready, switch the overlay + ;; to that fixed image (and guard with `epoch` to avoid stale async updates). + (set-transition-blur!) + ;; Lock the snapshot for the whole transition: if the user clicks to another page + ;; while the transition is active, keep showing the original page snapshot until + ;; the final target page finishes rendering. + (if already? + (p/resolved nil) + (do + ;; If we already have a snapshot URL, use it immediately. + (when-let [url wasm/canvas-snapshot-url] + (when (string? url) + (reset! transition-image-url* url))) + + ;; Capture a fresh snapshot asynchronously and update the overlay as soon + ;; as it is ready (guarded by `epoch` to avoid stale async updates). + (-> (capture-canvas-snapshot-url) + (p/then (fn [url] + (when (and (string? url) + @page-transition? + (= epoch @transition-epoch*)) + (reset! transition-image-url* url)) + url)) + (p/catch (fn [_] nil))))))) (defn render-shape-pixels [shape-id scale] diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 474a705979..3c011cd3db 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -21,7 +21,8 @@ [cuerdas.core :as str] [goog.object :as gobj] [lambdaisland.uri :as u] - [okulary.core :as l])) + [okulary.core :as l] + [potok.v2.core :as ptk])) (def ^:private fonts (l/derived :fonts st/state)) @@ -127,6 +128,7 @@ mem (js/Uint8Array. (.-buffer heap) ptr size)] (.set mem (js/Uint8Array. font-array-buffer)) + (st/emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) (h/call wasm/internal-module "_store_font" (aget font-id-buffer 0) (aget font-id-buffer 1) @@ -208,7 +210,8 @@ id-buffer (uuid/get-u32 (:wasm-id font-data)) font-data (assoc font-data :family-id-buffer id-buffer) font-stored? (font-stored? font-data emoji?)] - (when-not font-stored? + (if font-stored? + (st/async-emit! (ptk/data-event :font-loaded {:font-id (:font-id font-data)})) (fetch-font font-data uri emoji? fallback?))))) (defn serialize-font-style diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index c6741944a2..7442947953 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -9,9 +9,17 @@ (:require [app.common.logging :as log] [app.render-wasm.wasm :as wasm] - [app.util.dom :as dom] [promesa.core :as p])) +(defn max-texture-size + "Returns `gl.MAX_TEXTURE_SIZE` (max dimension of a 2D texture), or nil if + unavailable." + [gl] + (when gl + (let [n (.getParameter ^js gl (.-MAX_TEXTURE_SIZE ^js gl))] + (when (and (number? n) (pos? n) (js/isFinite n)) + (js/Math.floor n))))) + (defn get-webgl-context "Gets the WebGL context from the WASM module" [] @@ -135,38 +143,29 @@ void main() { (.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil) (.deleteTexture ^js gl texture)))) -(defn restore-previous-canvas-pixels - "Restores previous canvas pixels into the new canvas" - [] - (when-let [previous-canvas-pixels wasm/canvas-pixels] - (when-let [gl wasm/gl-context] - (draw-imagedata-to-webgl gl previous-canvas-pixels) - (set! wasm/canvas-pixels nil)))) +(defn capture-canvas-snapshot-url + "Captures the current viewport canvas as a PNG `blob:` URL and stores it in + `wasm/canvas-snapshot-url`. -(defn clear-canvas-pixels + Returns a promise resolving to the URL string (or nil)." [] - (when wasm/canvas - (let [context wasm/gl-context] - (.clearColor ^js context 0 0 0 0.0) - (.clear ^js context (.-COLOR_BUFFER_BIT ^js context)) - (.clear ^js context (.-DEPTH_BUFFER_BIT ^js context)) - (.clear ^js context (.-STENCIL_BUFFER_BIT ^js context))) - (dom/set-style! wasm/canvas "filter" "none") - (let [controls-to-unblur (dom/query-all (dom/get-element "viewport-controls") ".blurrable")] - (run! #(dom/set-style! % "filter" "none") controls-to-unblur)) - (set! wasm/canvas-pixels nil))) - -(defn capture-canvas-pixels - "Captures the pixels of the viewport canvas" - [] - (when wasm/canvas - (let [context wasm/gl-context - width (.-width wasm/canvas) - height (.-height wasm/canvas) - buffer (js/Uint8ClampedArray. (* width height 4)) - _ (.readPixels ^js context 0 0 width height (.-RGBA ^js context) (.-UNSIGNED_BYTE ^js context) buffer) - image-data (js/ImageData. buffer width height)] - (set! wasm/canvas-pixels image-data)))) + (if-let [^js canvas wasm/canvas] + (p/create + (fn [resolve _reject] + ;; Revoke previous snapshot to avoid leaking blob URLs. + (when-let [prev wasm/canvas-snapshot-url] + (when (and (string? prev) (.startsWith ^js prev "blob:")) + (js/URL.revokeObjectURL prev))) + (set! wasm/canvas-snapshot-url nil) + (.toBlob canvas + (fn [^js blob] + (if blob + (let [url (js/URL.createObjectURL blob)] + (set! wasm/canvas-snapshot-url url) + (resolve url)) + (resolve nil))) + "image/png"))) + (p/resolved nil))) (defn draw-thumbnail-to-canvas "Loads an image from `uri` and draws it stretched to fill the WebGL canvas. diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 032f3d7926..ac61bbac2e 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -323,11 +323,7 @@ (vals) (rx/from) (rx/mapcat (fn [callback] (callback))) - (rx/reduce conj []) - (rx/tap - (fn [] - (when (cfh/text-shape? shape) - (api/update-text-rect! (:id shape))))))) + (rx/reduce conj []))) (rx/empty)))) (defn process-shape-changes! diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index cadbd72d31..1cfb5b834c 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -15,7 +15,7 @@ [app.render-wasm.mem :as mem] [app.render-wasm.wasm :as wasm])) -(def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 30 4)) +(def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 31 4)) (def ^:const TEXT_EDITOR_STYLES_FILL_SOLID 0) (def ^:const TEXT_EDITOR_STYLES_FILL_LINEAR_GRADIENT 1) (def ^:const TEXT_EDITOR_STYLES_FILL_RADIAL_GRADIENT 2) @@ -261,22 +261,23 @@ line-height-state (aget heap-u32 (+ u32-offset 9)) letter-spacing-state (aget heap-u32 (+ u32-offset 10)) num-fills (aget heap-u32 (+ u32-offset 11)) + multiple-fills (aget heap-u32 (+ u32-offset 12)) - text-align-value (aget heap-u32 (+ u32-offset 12)) - text-direction-value (aget heap-u32 (+ u32-offset 13)) - text-decoration-value (aget heap-u32 (+ u32-offset 14)) - text-transform-value (aget heap-u32 (+ u32-offset 15)) - font-family-id-a (aget heap-u32 (+ u32-offset 16)) - font-family-id-b (aget heap-u32 (+ u32-offset 17)) - font-family-id-c (aget heap-u32 (+ u32-offset 18)) - font-family-id-d (aget heap-u32 (+ u32-offset 19)) + text-align-value (aget heap-u32 (+ u32-offset 13)) + text-direction-value (aget heap-u32 (+ u32-offset 14)) + text-decoration-value (aget heap-u32 (+ u32-offset 15)) + text-transform-value (aget heap-u32 (+ u32-offset 16)) + font-family-id-a (aget heap-u32 (+ u32-offset 17)) + font-family-id-b (aget heap-u32 (+ u32-offset 18)) + font-family-id-c (aget heap-u32 (+ u32-offset 19)) + font-family-id-d (aget heap-u32 (+ u32-offset 20)) font-family-id-value (uuid/from-unsigned-parts font-family-id-a font-family-id-b font-family-id-c font-family-id-d) - font-family-style-value (aget heap-u32 (+ u32-offset 20)) - _font-family-weight-value (aget heap-u32 (+ u32-offset 21)) - font-size-value (aget heap-f32 (+ u32-offset 22)) - font-weight-value (aget heap-i32 (+ u32-offset 23)) - line-height-value (aget heap-f32 (+ u32-offset 28)) - letter-spacing-value (aget heap-f32 (+ u32-offset 29)) + font-family-style-value (aget heap-u32 (+ u32-offset 21)) + _font-family-weight-value (aget heap-u32 (+ u32-offset 22)) + font-size-value (aget heap-f32 (+ u32-offset 23)) + font-weight-value (aget heap-i32 (+ u32-offset 24)) + line-height-value (aget heap-f32 (+ u32-offset 29)) + letter-spacing-value (aget heap-f32 (+ u32-offset 30)) font-id (fonts/uuid->font-id font-family-id-value) font-style-value (text-editor-translate-font-style (text-editor-get-style-property font-family-state font-family-style-value)) font-variant-id-computed (text-editor-compute-font-variant-id font-id font-weight-value font-style-value) @@ -291,6 +292,11 @@ (filter some?) (into [])) + ;; The order of these two variables is important, do not + ;; reorder them. + selected-colors (if (= multiple-fills 1) fills nil) + fills (if (= multiple-fills 1) :multiple fills) + result {:vertical-align (text-editor-translate-vertical-align vertical-align) :text-align (text-editor-translate-text-align (text-editor-get-style-property text-align-state text-align-value)) :text-direction (text-editor-translate-text-direction (text-editor-get-style-property text-direction-state text-direction-value)) @@ -306,6 +312,7 @@ :font-variant-id (text-editor-get-style-property font-variant-id-state font-variant-id-computed) :typography-ref-file nil :typography-ref-id nil + :selected-colors selected-colors :fills fills}] (mem/free) @@ -471,6 +478,19 @@ ;; This is used as a intermediate cache between Clojure global state and WASM state. (def ^:private shape-text-contents (atom {})) +(defn cache-shape-text-content! + [shape-id content] + (when (some? content) + (swap! shape-text-contents assoc shape-id content))) + +(defn get-cached-content + [shape-id] + (get @shape-text-contents shape-id)) + +(defn update-cached-content! + [shape-id content] + (swap! shape-text-contents assoc shape-id content)) + (defn- merge-exported-texts-into-content "Merge exported span texts back into the existing content tree. @@ -522,26 +542,13 @@ new-texts (text-editor-export-content)] (when (and shape-id new-texts) (let [texts-clj (js->clj new-texts) - content (get @shape-text-contents shape-id)] + content (get-cached-content shape-id)] (when content (let [merged (merge-exported-texts-into-content content texts-clj)] (swap! shape-text-contents assoc shape-id merged) {:shape-id shape-id :content merged}))))))) -(defn cache-shape-text-content! - [shape-id content] - (when (some? content) - (swap! shape-text-contents assoc shape-id content))) - -(defn get-cached-content - [shape-id] - (get @shape-text-contents shape-id)) - -(defn update-cached-content! - [shape-id content] - (swap! shape-text-contents assoc shape-id content)) - (defn- normalize-selection "Given anchor/focus para+offset, return {:start-para :start-offset :end-para :end-offset} ordered so start <= end." @@ -558,6 +565,7 @@ Splits spans at boundaries as needed." [para sel-start sel-end attrs] (let [spans (:children para) + result (loop [spans spans pos 0 acc []] @@ -594,7 +602,7 @@ selection (text-editor-get-selection)] (when (and shape-id selection) - (let [content (get @shape-text-contents shape-id)] + (let [content (get-cached-content shape-id)] (when content (let [normalized-selection (normalize-selection selection) {:keys [start-para start-offset end-para end-offset]} normalized-selection @@ -630,11 +638,13 @@ (range (count paragraphs)) paragraphs)) + new-content (when new-paragraphs (assoc content :children [(assoc paragraph-set :children new-paragraphs)]))] + (when new-content - (swap! shape-text-contents assoc shape-id new-content) + (update-cached-content! shape-id new-content) (use-shape-fn shape-id) (set-shape-text-content-fn shape-id new-content) {:shape-id shape-id diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index c54091d5e2..5c43ba4899 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -12,8 +12,9 @@ ;; Reference to the HTML canvas element. (defonce canvas nil) -;; Reference to the captured pixels of the canvas (for page switching effect) -(defonce canvas-pixels nil) +;; Snapshot of the current canvas suitable for `` overlays. +;; This is typically a `blob:` URL created via `canvas.toBlob`. +(defonce canvas-snapshot-url nil) ;; Reference to the Emscripten GL context wrapper. (defonce gl-context-handle nil) diff --git a/frontend/src/app/util/browser_history.js b/frontend/src/app/util/browser_history.js index d206b83b7f..074da03f70 100644 --- a/frontend/src/app/util/browser_history.js +++ b/frontend/src/app/util/browser_history.js @@ -44,6 +44,6 @@ goog.scope(function() { } self.replace_token_BANG_ = function(instance, token) { - instance.replaceToken(token); + instance?.replaceToken(token); } }); diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index a191b9466f..20c314f012 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -11,6 +11,7 @@ [app.common.logging :as log] [app.common.schema :as sm] [app.common.uuid :as uuid] + [app.main.data.uploads :as uploads] [app.main.repo :as rp] [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] @@ -129,6 +130,23 @@ (->> (rx/from files) (rx/merge-map analyze-file))) +(defn- import-blob-via-upload + "Fetches `uri` as a Blob, uploads it using the generic chunked-upload + session API and calls `import-binfile` with the resulting upload-id. + Returns an observable of SSE events from the import stream." + [uri {:keys [name version project-id]}] + (->> (slurp-uri uri :blob) + (rx/mapcat + (fn [blob] + (->> (uploads/upload-blob-chunked blob) + (rx/mapcat + (fn [{:keys [session-id]}] + (rp/cmd! ::sse/import-binfile + {:name name + :upload-id session-id + :version version + :project-id project-id})))))))) + (defmethod impl/handler :import-files [{:keys [project-id files]}] (let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files) @@ -138,31 +156,22 @@ (->> (rx/from binfile-v1) (rx/merge-map (fn [data] - (->> (http/send! - {:uri (:uri data) - :response-type :blob - :method :get}) - (rx/map :body) - (rx/mapcat - (fn [file] - (->> (rp/cmd! ::sse/import-binfile - {:name (str/replace (:name data) #".penpot$" "") - :file file - :version 1 - :project-id project-id}) - (rx/tap (fn [event] - (let [payload (sse/get-payload event) - type (sse/get-type event)] - (if (= type "progress") - (log/dbg :hint "import-binfile: progress" - :section (:section payload) - :name (:name payload)) - (log/dbg :hint "import-binfile: end"))))) - (rx/filter sse/end-of-stream?) - (rx/map (fn [_] - {:status :finish - :file-id (:file-id data)}))))) - + (->> (import-blob-via-upload (:uri data) + {:name (str/replace (:name data) #".penpot$" "") + :version 1 + :project-id project-id}) + (rx/tap (fn [event] + (let [payload (sse/get-payload event) + type (sse/get-type event)] + (if (= type "progress") + (log/dbg :hint "import-binfile: progress" + :section (:section payload) + :name (:name payload)) + (log/dbg :hint "import-binfile: end"))))) + (rx/filter sse/end-of-stream?) + (rx/map (fn [_] + {:status :finish + :file-id (:file-id data)})) (rx/catch (fn [cause] (log/error :hint "unexpected error on import process" @@ -179,29 +188,24 @@ (rx/mapcat identity) (rx/merge-map (fn [[uri entries]] - (->> (slurp-uri uri :blob) - (rx/mapcat (fn [content] - ;; FIXME: implement the naming and filtering - (->> (rp/cmd! ::sse/import-binfile - {:name (-> entries first :name) - :file content - :version 3 - :project-id project-id}) - (rx/tap (fn [event] - (let [payload (sse/get-payload event) - type (sse/get-type event)] - (if (= type "progress") - (log/dbg :hint "import-binfile: progress" - :section (:section payload) - :name (:name payload)) - (log/dbg :hint "import-binfile: end"))))) - (rx/filter sse/end-of-stream?) - (rx/mapcat (fn [_] - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :finish - :file-id (:file-id entry)})))))))) - + (->> (import-blob-via-upload uri + {:name (-> entries first :name) + :version 3 + :project-id project-id}) + (rx/tap (fn [event] + (let [payload (sse/get-payload event) + type (sse/get-type event)] + (if (= type "progress") + (log/dbg :hint "import-binfile: progress" + :section (:section payload) + :name (:name payload)) + (log/dbg :hint "import-binfile: end"))))) + (rx/filter sse/end-of-stream?) + (rx/mapcat (fn [_] + (->> (rx/from entries) + (rx/map (fn [entry] + {:status :finish + :file-id (:file-id entry)}))))) (rx/catch (fn [cause] (log/error :hint "unexpected error on import process" @@ -213,5 +217,3 @@ {:status :error :error (ex-message cause) :file-id (:file-id entry)})))))))))))) - - diff --git a/frontend/src/app/worker/index.cljs b/frontend/src/app/worker/index.cljs index c40f0b6fd8..3ff1f37d19 100644 --- a/frontend/src/app/worker/index.cljs +++ b/frontend/src/app/worker/index.cljs @@ -10,9 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.changes :as ch] - [app.common.geom.matrix :as gmt] [app.common.geom.rect :as grc] - [app.common.geom.shapes :as gsh] [app.common.logging :as log] [app.common.time :as ct] [app.worker.impl :as impl] @@ -65,33 +63,7 @@ (log/dbg :hint "page index updated" :id page-id :elapsed elapsed ::log/sync? true)))) nil)) -(defmethod impl/handler :index/update-text-rect - [{:keys [page-id shape-id dimensions]}] - (let [page (dm/get-in @state [:pages-index page-id]) - objects (get page :objects) - shape (get objects shape-id) - center (gsh/shape->center shape) - transform (:transform shape (gmt/matrix)) - rect (-> (grc/make-rect dimensions) - (grc/rect->points)) - points (gsh/transform-points rect center transform) - selrect (gsh/calculate-selrect points (gsh/points->center points)) - - data {:position-data nil - :points points - :selrect selrect} - - shape (d/patch-object shape data) - - objects - (assoc objects shape-id shape)] - - (swap! state update-in [::text-rect page-id] assoc shape-id data) - (swap! state update-in [::selection page-id] selection/update-index-single objects shape) - nil)) - ;; FIXME: schema - (defmethod impl/handler :index/query-snap [{:keys [page-id frame-id axis ranges bounds] :as message}] (if-let [index (get @state ::snap)] diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index 70d8216d14..d90536619d 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -171,7 +171,10 @@ zoom (/ width (:width vbox))] (wasm.api/initialize-viewport - objects zoom vbox bgcolor + objects zoom vbox + :background bgcolor + :force-sync true + :on-render (fn [] (if frame (wasm.api/render-sync-shape (:id frame)) diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index ee3f58b74c..65ac296895 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -135,6 +135,28 @@ (wasm.mem/free) text))) +(defn ^:export wasmAtlasConsole + "Logs the current render-wasm atlas as an image in the JS console (if present)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_atlas_console"))] + (if (fn? f) + (wasm.h/call module "_debug_atlas_console") + (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_console")))) + +(defn ^:export wasmAtlasBase64 + "Returns the atlas PNG base64 (empty string if missing/empty)." + [] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_atlas_base64"))] + (if (fn? f) + (let [ptr (wasm.h/call module "_debug_atlas_base64") + s (or (wasm-read-len-prefixed-utf8 ptr) "")] + s) + (do + (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_base64") + "")))) + (defn ^:export wasmCacheConsole "Logs the current render-wasm cache surface as an image in the JS console." [] diff --git a/frontend/test/frontend_tests/data/uploads_test.cljs b/frontend/test/frontend_tests/data/uploads_test.cljs new file mode 100644 index 0000000000..1512fcb90b --- /dev/null +++ b/frontend/test/frontend_tests/data/uploads_test.cljs @@ -0,0 +1,117 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.data.uploads-test + "Integration tests for the generic chunked-upload logic in + app.main.data.uploads." + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.data.uploads :as uploads] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.http :as http])) + +;; --------------------------------------------------------------------------- +;; Local helpers +;; --------------------------------------------------------------------------- + +(defn- make-blob + "Creates a JS Blob of exactly `size` bytes." + [size] + (let [buf (js/Uint8Array. size)] + (js/Blob. #js [buf] #js {:type "application/octet-stream"}))) + +;; --------------------------------------------------------------------------- +;; upload-blob-chunked tests +;; --------------------------------------------------------------------------- + +(t/deftest upload-blob-chunked-creates-session-and-uploads-chunks + (t/testing "upload-blob-chunked calls create-upload-session then upload-chunk for each slice" + (t/async done + (let [session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Exactly two full chunks + blob-size (* 2 chunk-size) + blob (make-blob blob-size) + calls (atom []) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (swap! calls conj cmd) + (js/Promise.resolve + (case cmd + :create-upload-session + (http/make-transit-response + {:session-id session-id}) + + :upload-chunk + (http/make-transit-response + {:session-id session-id :index 0}) + + (http/make-json-response + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (uploads/upload-blob-chunked blob) + (rx/subs! + (fn [{:keys [session-id]}] + (t/is (uuid? session-id))) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (let [cmd-seq @calls] + ;; First call must create the session + (t/is (= :create-upload-session (first cmd-seq))) + ;; Two chunk uploads + (t/is (= 2 (count (filter #(= :upload-chunk %) cmd-seq)))) + ;; No assemble call here — that's the caller's responsibility + (t/is (not (some #(= :assemble-file-media-object %) cmd-seq)))) + (done)))))))) + +(t/deftest upload-blob-chunked-chunk-count-matches-blob + (t/testing "number of upload-chunk calls equals ceil(blob-size / chunk-size)" + (t/async done + (let [session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Three chunks: 2 full + 1 partial + blob-size (+ (* 2 chunk-size) 1) + blob (make-blob blob-size) + chunk-calls (atom 0) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (js/Promise.resolve + (case cmd + :create-upload-session + (http/make-transit-response + {:session-id session-id}) + + :upload-chunk + (do (swap! chunk-calls inc) + (http/make-transit-response + {:session-id session-id :index 0})) + + (http/make-json-response + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (uploads/upload-blob-chunked blob) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (t/is (= 3 @chunk-calls)) + (done)))))))) diff --git a/frontend/test/frontend_tests/data/workspace_media_test.cljs b/frontend/test/frontend_tests/data/workspace_media_test.cljs new file mode 100644 index 0000000000..915adb203b --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_media_test.cljs @@ -0,0 +1,189 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.data.workspace-media-test + "Integration tests for the chunked-upload logic in + app.main.data.workspace.media." + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.data.workspace.media :as media] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.http :as http])) + +;; --------------------------------------------------------------------------- +;; Local helpers +;; --------------------------------------------------------------------------- + +(defn- make-blob + "Creates a JS Blob of exactly `size` bytes with the given `mtype`." + [size mtype] + (let [buf (js/Uint8Array. size)] + (js/Blob. #js [buf] #js {:type mtype}))) + +;; --------------------------------------------------------------------------- +;; Small-file path: direct upload (no chunking) +;; --------------------------------------------------------------------------- + +(t/deftest small-file-uses-direct-upload + (t/testing "blobs below chunk-size use :upload-file-media-object directly" + (t/async done + (let [file-id (uuid/next) + ;; One byte below the threshold so the blob takes the direct path + blob-size (dec cf/upload-chunk-size) + blob (make-blob blob-size "image/jpeg") + calls (atom []) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (swap! calls conj cmd) + (js/Promise.resolve + (http/make-json-response + {:id (str (uuid/next)) + :name "img" + :width 100 + :height 100 + :mtype "image/jpeg" + :file-id (str file-id)})))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (media/process-blobs + {:file-id file-id + :local? true + :blobs [blob] + :on-image (fn [_] nil) + :on-svg (fn [_] nil)}) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + ;; Should call :upload-file-media-object, NOT the chunked API + (t/is (= 1 (count @calls))) + (t/is (= :upload-file-media-object (first @calls))) + (done)))))))) + +;; --------------------------------------------------------------------------- +;; Large-file path: chunked upload via uploads namespace +;; --------------------------------------------------------------------------- + +(t/deftest large-file-uses-chunked-upload + (t/testing "blobs at or above chunk-size use the three-step session API" + (t/async done + (let [file-id (uuid/next) + session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Exactly two full chunks + blob-size (* 2 chunk-size) + blob (make-blob blob-size "image/jpeg") + calls (atom []) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (swap! calls conj cmd) + (js/Promise.resolve + (http/make-json-response + (case cmd + :create-upload-session + {:session-id (str session-id)} + + :upload-chunk + {:session-id (str session-id) :index 0} + + :assemble-file-media-object + {:id (str (uuid/next)) + :name "img" + :width 100 + :height 100 + :mtype "image/jpeg" + :file-id (str file-id)} + + ;; Default: return an error response + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (media/process-blobs + {:file-id file-id + :local? true + :blobs [blob] + :on-image (fn [_] nil) + :on-svg (fn [_] nil)}) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (let [cmd-seq @calls] + ;; First call must create the session + (t/is (= :create-upload-session (first cmd-seq))) + ;; Two chunk uploads + (t/is (= 2 (count (filter #(= :upload-chunk %) cmd-seq)))) + ;; Last call must assemble + (t/is (= :assemble-file-media-object (last cmd-seq))) + ;; Direct upload must NOT be called + (t/is (not (some #(= :upload-file-media-object %) cmd-seq)))) + (done)))))))) + +(t/deftest chunked-upload-chunk-count-matches-blob + (t/testing "number of chunk upload calls equals ceil(blob-size / chunk-size)" + (t/async done + (let [file-id (uuid/next) + session-id (uuid/next) + chunk-size cf/upload-chunk-size + ;; Three chunks: 2 full + 1 partial + blob-size (+ (* 2 chunk-size) 1) + blob (make-blob blob-size "image/jpeg") + chunk-calls (atom 0) + + fetch-mock + (fn [url _opts] + (let [cmd (http/url->cmd url)] + (js/Promise.resolve + (http/make-json-response + (case cmd + :create-upload-session + {:session-id (str session-id)} + + :upload-chunk + (do (swap! chunk-calls inc) + {:session-id (str session-id) :index 0}) + + :assemble-file-media-object + {:id (str (uuid/next)) + :name "img" + :width 100 + :height 100 + :mtype "image/jpeg" + :file-id (str file-id)} + + {:error (str "unexpected cmd: " cmd)}))))) + + orig (http/install-fetch-mock! fetch-mock)] + + (->> (media/process-blobs + {:file-id file-id + :local? true + :blobs [blob] + :on-image (fn [_] nil) + :on-svg (fn [_] nil)}) + (rx/subs! + (fn [_] nil) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)) + (fn [] + (http/restore-fetch! orig) + (t/is (= 3 @chunk-calls)) + (done)))))))) diff --git a/frontend/test/frontend_tests/helpers/http.cljs b/frontend/test/frontend_tests/helpers/http.cljs new file mode 100644 index 0000000000..28895f4049 --- /dev/null +++ b/frontend/test/frontend_tests/helpers/http.cljs @@ -0,0 +1,61 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.helpers.http + "Helpers for intercepting and mocking the global `fetch` function in + ClojureScript tests. The underlying HTTP layer (`app.util.http`) calls + `(js/fetch url params)` directly, so replacing `globalThis.fetch` is the + correct interception point." + (:require + [app.common.transit :as t] + [clojure.string :as str])) + +(defn install-fetch-mock! + "Replaces the global `js/fetch` with `handler-fn`. + + `handler-fn` is called with `[url opts]` where `url` is a plain string + such as `\"http://localhost/api/main/methods/some-cmd\"`. It must return + a JS Promise that resolves to a fetch Response object. + + Returns the previous `globalThis.fetch` value so callers can restore it + with [[restore-fetch!]]." + [handler-fn] + (let [prev (.-fetch js/globalThis)] + (set! (.-fetch js/globalThis) handler-fn) + prev)) + +(defn restore-fetch! + "Restores `globalThis.fetch` to `orig` (the value returned by + [[install-fetch-mock!]])." + [orig] + (set! (.-fetch js/globalThis) orig)) + +(defn make-json-response + "Creates a minimal fetch `Response` that returns `body-clj` serialised as + plain JSON with HTTP status 200." + [body-clj] + (let [json-str (.stringify js/JSON (clj->js body-clj)) + headers (js/Headers. #js {"content-type" "application/json"})] + (js/Response. json-str #js {:status 200 :headers headers}))) + +(defn make-transit-response + "Creates a minimal fetch `Response` that returns `body-clj` serialised as + Transit+JSON with HTTP status 200. Use this helper when the code under + test inspects typed values (UUIDs, keywords, etc.) from the response body, + since the HTTP layer only decodes transit+json content automatically." + [body-clj] + (let [transit-str (t/encode-str body-clj {:type :json-verbose}) + headers (js/Headers. #js {"content-type" "application/transit+json"})] + (js/Response. transit-str #js {:status 200 :headers headers}))) + +(defn url->cmd + "Extracts the RPC command keyword from a URL string. + + Example: `\"http://…/api/main/methods/create-upload-session\"` + → `:create-upload-session`." + [url] + (when (string? url) + (keyword (last (str/split url #"/"))))) diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs new file mode 100644 index 0000000000..5dc1747658 --- /dev/null +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -0,0 +1,136 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.main-errors-test + "Unit tests for app.main.errors. + + Tests cover: + - stale-asset-error? – pure predicate + - exception->error-data – pure transformer + - on-error re-entrancy guard – prevents recursive invocations + - flash schedules async emit – ntf/show is not emitted synchronously" + (:require + [app.main.errors :as errors] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +;; --------------------------------------------------------------------------- +;; stale-asset-error? +;; --------------------------------------------------------------------------- + +(t/deftest stale-asset-error-nil + (t/testing "nil cause returns nil/falsy" + (t/is (not (errors/stale-asset-error? nil))))) + +(t/deftest stale-asset-error-keyword-cst-undefined + (t/testing "error with $cljs$cst$ and 'is undefined' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is undefined")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-keyword-cst-null + (t/testing "error with $cljs$cst$ and 'is null' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is null")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-protocol-dispatch-undefined + (t/testing "error with $cljs$core$I and 'Cannot read properties of undefined' is recognised" + (let [err (js/Error. "Cannot read properties of undefined (reading '$cljs$core$IFn$_invoke$arity$1$')")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-not-a-function + (t/testing "error with $cljs$cst$ and 'is not a function' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is not a function")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-unrelated-message + (t/testing "ordinary error without stale-asset signature is NOT recognised" + (let [err (js/Error. "Cannot read properties of undefined (reading 'foo')")] + (t/is (not (errors/stale-asset-error? err)))))) + +(t/deftest stale-asset-error-only-cst-no-undefined + (t/testing "error with $cljs$cst$ but no undefined/null/not-a-function keyword is not recognised" + (let [err (js/Error. "foo$cljs$cst$bar exploded")] + (t/is (not (errors/stale-asset-error? err)))))) + +;; --------------------------------------------------------------------------- +;; exception->error-data +;; --------------------------------------------------------------------------- + +(t/deftest exception->error-data-plain-error + (t/testing "plain JS Error is converted to a data map with :hint and ::instance" + (let [err (js/Error. "something went wrong") + data (errors/exception->error-data err)] + (t/is (= "something went wrong" (:hint data))) + (t/is (identical? err (::errors/instance data)))))) + +(t/deftest exception->error-data-ex-info + (t/testing "ex-info error preserves existing :hint and attaches ::instance" + (let [err (ex-info "original" {:hint "my-hint" :type :network}) + data (errors/exception->error-data err)] + (t/is (= "my-hint" (:hint data))) + (t/is (= :network (:type data))) + (t/is (identical? err (::errors/instance data)))))) + +(t/deftest exception->error-data-ex-info-no-hint + (t/testing "ex-info without :hint falls back to ex-message" + (let [err (ex-info "fallback message" {:type :validation}) + data (errors/exception->error-data err)] + (t/is (= "fallback message" (:hint data)))))) + +;; --------------------------------------------------------------------------- +;; on-error dispatches to ptk/handle-error +;; +;; We use a dedicated test-only error type so we can add/remove a +;; defmethod without touching the real handlers. +;; --------------------------------------------------------------------------- + +(def ^:private test-handled (atom nil)) + +(defmethod ptk/handle-error ::test-dispatch + [err] + (reset! test-handled err)) + +(t/deftest on-error-dispatches-map-error + (t/testing "on-error dispatches a map error to ptk/handle-error using its :type" + (reset! test-handled nil) + (errors/on-error {:type ::test-dispatch :hint "hello"}) + (t/is (= ::test-dispatch (:type @test-handled))) + (t/is (= "hello" (:hint @test-handled))))) + +(t/deftest on-error-wraps-exception-then-dispatches + (t/testing "on-error wraps a JS Error into error-data before dispatching" + (reset! test-handled nil) + (let [err (ex-info "wrapped" {:type ::test-dispatch})] + (errors/on-error err) + (t/is (= ::test-dispatch (:type @test-handled))) + (t/is (identical? err (::errors/instance @test-handled)))))) + +;; --------------------------------------------------------------------------- +;; on-error re-entrancy guard +;; +;; The guard is implemented via the `handling-error?` volatile inside +;; app.main.errors. We can verify its effect by registering a +;; handle-error method that itself calls on-error and checking that +;; only one invocation gets through. +;; --------------------------------------------------------------------------- + +(def ^:private reentrant-call-count (atom 0)) + +(defmethod ptk/handle-error ::test-reentrant + [_err] + (swap! reentrant-call-count inc) + ;; Simulate a secondary error inside the error handler + ;; (e.g. the notification emit itself throws). + ;; Without the re-entrancy guard this would recurse indefinitely. + (when (= 1 @reentrant-call-count) + (errors/on-error {:type ::test-reentrant :hint "secondary"}))) + +(t/deftest on-error-reentrancy-guard-prevents-recursion + (t/testing "a second on-error call while handling an error is suppressed by the guard" + (reset! reentrant-call-count 0) + (errors/on-error {:type ::test-reentrant :hint "first"}) + ;; The guard must have allowed only the first invocation through. + (t/is (= 1 @reentrant-call-count)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 3cd38c12f0..ff7a1f0699 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -3,8 +3,10 @@ [cljs.test :as t] [frontend-tests.basic-shapes-test] [frontend-tests.data.repo-test] + [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-media-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] [frontend-tests.helpers-shapes-test] @@ -14,6 +16,7 @@ [frontend-tests.logic.frame-guides-test] [frontend-tests.logic.groups-test] [frontend-tests.logic.pasting-in-containers-test] + [frontend-tests.main-errors-test] [frontend-tests.plugins.context-shapes-test] [frontend-tests.svg-fills-test] [frontend-tests.tokens.import-export-test] @@ -41,8 +44,11 @@ (t/run-tests 'frontend-tests.basic-shapes-test 'frontend-tests.data.repo-test + 'frontend-tests.main-errors-test + 'frontend-tests.data.uploads-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test + 'frontend-tests.data.workspace-media-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test 'frontend-tests.helpers-shapes-test diff --git a/frontend/test/frontend_tests/tokens/helpers/state.cljs b/frontend/test/frontend_tests/tokens/helpers/state.cljs index 9de2e773e5..79f0081e9f 100644 --- a/frontend/test/frontend_tests/tokens/helpers/state.cljs +++ b/frontend/test/frontend_tests/tokens/helpers/state.cljs @@ -43,7 +43,9 @@ (fn [stream] (->> stream #_(rx/tap #(prn (ptk/type %))) - (rx/filter #(ptk/type? event-type %))))) + (rx/filter #(ptk/type? event-type %)) + ;; Safeguard timeout + (rx/timeout 200 (rx/of :the/end))))) (def stop-on-send-update-indices "Stops on `send-update-indices` function being called, which should be the last function of an event chain." diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index 0af65155bf..956a2977a0 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -13,6 +13,7 @@ [app.common.types.text :as txt] [app.common.types.tokens-lib :as ctob] [app.main.data.workspace.tokens.application :as dwta] + [app.main.data.workspace.wasm-text :as dwwt] [cljs.test :as t :include-macros true] [cuerdas.core :as str] [frontend-tests.helpers.pages :as thp] @@ -58,8 +59,11 @@ (ctob/add-token (cthi/id :set-a) (ctob/make-token reference-border-radius-token)))))) +(def debounce-text-stop + (tohs/stop-on ::dwwt/resize-wasm-text-debounce-commit)) + (t/deftest test-apply-token - (t/testing "applies token to shape and updates shape attributes to resolved value" + (t/testing "applies token to shape and updates shape attributes to resolved value" (t/async done (let [file (setup-file-with-tokens) @@ -553,7 +557,8 @@ (t/is (= (:font-size style-text-blocks) "24")) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-apply-line-height (t/testing "applies line-height token and updates the text line-height" @@ -591,7 +596,8 @@ (t/is (= (:line-height style-text-blocks) 1.5)) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-apply-letter-spacing (t/testing "applies letter-spacing token and updates the text letter-spacing" @@ -629,7 +635,8 @@ (t/is (= (:letter-spacing style-text-blocks) "2")) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-apply-font-family (t/testing "applies font-family token and updates the text font-family" @@ -667,7 +674,8 @@ (t/is (= (:font-family style-text-blocks) (:font-id txt/default-text-attrs))) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-apply-text-case (t/testing "applies text-case token and updates the text transform" @@ -775,7 +783,8 @@ (t/is (= (:font-weight style-text-blocks) "400")) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-toggle-token-none (t/testing "should apply token to all selected items, where no item has the token applied" @@ -1001,7 +1010,8 @@ (t/is (= (:text-decoration style-text-blocks) "underline")) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-apply-reference-typography-token (t/testing "applies typography (composite) tokens with references" @@ -1049,7 +1059,8 @@ (t/is (= (:font-family style-text-blocks) "Arial")) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) (t/deftest test-unapply-atomic-tokens-on-composite-apply (t/testing "unapplies atomic typography tokens when applying composite token" @@ -1206,4 +1217,5 @@ (t/is (nil? (:typography-ref-file text-node-3))) (t/testing "WASM text mocks were exercised" (t/is (pos? (thw/call-count :set-shape-text-content))) - (t/is (pos? (thw/call-count :get-text-dimensions))))))))))) + (t/is (pos? (thw/call-count :get-text-dimensions)))))) + debounce-text-stop))))) diff --git a/render-wasm/src/js/wapi.js b/render-wasm/src/js/wapi.js index 4af5c0bf89..13f3fcb698 100644 --- a/render-wasm/src/js/wapi.js +++ b/render-wasm/src/js/wapi.js @@ -12,5 +12,13 @@ addToLibrary({ } else { return window.cancelAnimationFrame(frameId); } + }, + wapi_notifyTilesRenderComplete: function wapi_notifyTilesRenderComplete() { + // The corresponding listener lives on `document` (main thread), so in a + // worker context we simply skip the dispatch instead of crashing. + if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) { + return; + } + document.dispatchEvent(new CustomEvent('penpot:wasm:tiles-complete')); } }); diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 747b6018f8..ee3a7815f3 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -154,6 +154,18 @@ pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> { + with_state_mut!(state, { + state + .render_state_mut() + .surfaces + .set_max_atlas_texture_size(max_px); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { @@ -854,7 +866,12 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> { #[wasm_error] pub extern "C" fn clean_modifiers() -> Result<()> { with_state_mut!(state, { - state.shapes.clean_all(); + let prev_modifier_ids = state.shapes.clean_all(); + if !prev_modifier_ids.is_empty() { + state + .render_state + .update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?; + } }); Ok(()) } diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index aee90b6320..5df0326ace 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -43,6 +43,7 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; + const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; type ClipStack = Vec<(Rect, Option, Matrix)>; @@ -223,6 +224,7 @@ impl NodeRenderState { /// - `enter(...)` / `exit(...)` should be called when entering and leaving shape /// render contexts. /// - `is_active()` returns whether the current shape is being rendered in focus. +#[derive(Clone)] pub struct FocusMode { shapes: Vec, active: bool, @@ -715,12 +717,14 @@ impl RenderState { // In fast mode the viewport is moving (pan/zoom) so Cache surface // positions would be wrong — only save to the tile HashMap. self.surfaces.cache_current_tile_texture( + &mut self.gpu_state, &self.tile_viewbox, &self .current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?, &tile_rect, fast_mode, + self.render_area, ); self.surfaces.draw_cached_tile_surface( @@ -968,6 +972,11 @@ impl RenderState { .draw_rect(bounds, &paint); } + // Uncomment to debug the render_position_data + // if let Type::Text(text_content) = &shape.shape_type { + // text::render_position_data(self, fills_surface_id, &shape, text_content); + // } + self.surfaces.apply_mut(surface_ids, |s| { s.canvas() .concat(&transform.invert().unwrap_or(Matrix::default())); @@ -1459,6 +1468,28 @@ impl RenderState { performance::begin_measure!("render_from_cache"); let cached_scale = self.get_cached_scale(); + let bg_color = self.background_color; + + // During fast mode (pan/zoom), if a previous full-quality render still has pending tiles, + // always prefer the persistent atlas. The atlas is incrementally updated as tiles finish, + // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles. + if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() { + self.surfaces + .draw_atlas_to_target(self.viewbox, self.options.dpr(), bg_color); + + if self.options.is_debug_visible() { + debug::render(self); + } + + ui::render(self, shapes); + debug::render_wasm_label(self); + + self.flush_and_submit(); + performance::end_measure!("render_from_cache"); + performance::end_timed_log!("render_from_cache", _start); + return; + } + // Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache) if self.cached_viewbox.area.width() > 0.0 { // Scale and translate the target according to the cached data @@ -1475,7 +1506,62 @@ impl RenderState { let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr(); let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x; let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y; - let bg_color = self.background_color; + + // For zoom-out, prefer cache only if it fully covers the viewport. + // Otherwise, atlas will provide a more correct full-viewport preview. + let zooming_out = self.viewbox.zoom < self.cached_viewbox.zoom; + if zooming_out { + let cache_dim = self.surfaces.cache_dimensions(); + let cache_w = cache_dim.width as f32; + let cache_h = cache_dim.height as f32; + + // Viewport in target pixels. + let vw = (self.viewbox.width * self.options.dpr()).max(1.0); + let vh = (self.viewbox.height * self.options.dpr()).max(1.0); + + // Inverse-map viewport corners into cache coordinates. + // target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords). + // => cache = (target / navigate_zoom) - translate + let inv = if navigate_zoom.abs() > f32::EPSILON { + 1.0 / navigate_zoom + } else { + 0.0 + }; + + let cx0 = (0.0 * inv) - translate_x; + let cy0 = (0.0 * inv) - translate_y; + let cx1 = (vw * inv) - translate_x; + let cy1 = (vh * inv) - translate_y; + + let min_x = cx0.min(cx1); + let min_y = cy0.min(cy1); + let max_x = cx0.max(cx1); + let max_y = cy0.max(cy1); + + let cache_covers = + min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h; + if !cache_covers { + // Early return only if atlas exists; otherwise keep cache path. + if self.surfaces.has_atlas() { + self.surfaces.draw_atlas_to_target( + self.viewbox, + self.options.dpr(), + bg_color, + ); + + if self.options.is_debug_visible() { + debug::render(self); + } + + ui::render(self, shapes); + debug::render_wasm_label(self); + self.flush_and_submit(); + performance::end_measure!("render_from_cache"); + performance::end_timed_log!("render_from_cache", _start); + return; + } + } + } // Setup canvas transform { @@ -1531,6 +1617,7 @@ impl RenderState { self.flush_and_submit(); } + performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); } @@ -1670,6 +1757,7 @@ impl RenderState { self.cancel_animation_frame(); self.render_request_id = Some(wapi::request_animation_frame!()); } else { + wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } } @@ -1687,7 +1775,6 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; } self.flush_and_submit(); - Ok(()) } @@ -1700,6 +1787,26 @@ impl RenderState { ) -> Result<(Vec, i32, i32)> { let target_surface = SurfaceId::Export; + // `render_shape_pixels` is used by the workspace to render thumbnails using the + // same WASM renderer instance. It must not leak any state into the main + // viewport renderer (tile cache, atlas, focus mode, render context, etc.). + // + // In particular, `update_render_context` clears and reconfigures multiple + // render surfaces, and `render_area` drives atlas blits. If we don't restore + // them, the workspace can temporarily show missing tiles until the next + // interaction (e.g. zoom) forces a full context rebuild. + let saved_focus_mode = self.focus_mode.clone(); + let saved_export_context = self.export_context; + let saved_render_area = self.render_area; + let saved_render_area_with_margins = self.render_area_with_margins; + let saved_current_tile = self.current_tile; + let saved_pending_nodes = std::mem::take(&mut self.pending_nodes); + let saved_nested_fills = std::mem::take(&mut self.nested_fills); + let saved_nested_blurs = std::mem::take(&mut self.nested_blurs); + let saved_nested_shadows = std::mem::take(&mut self.nested_shadows); + let saved_ignore_nested_blurs = self.ignore_nested_blurs; + let saved_preview_mode = self.preview_mode; + // Reset focus mode so all shapes in the export tree are rendered. // Without this, leftover focus_mode state from the workspace could // cause shapes (and their background blur) to be skipped. @@ -1751,6 +1858,30 @@ impl RenderState { .expect("PNG encode failed"); let skia::ISize { width, height } = image.dimensions(); + // Restore the workspace render state. + self.focus_mode = saved_focus_mode; + self.export_context = saved_export_context; + self.render_area = saved_render_area; + self.render_area_with_margins = saved_render_area_with_margins; + self.current_tile = saved_current_tile; + self.pending_nodes = saved_pending_nodes; + self.nested_fills = saved_nested_fills; + self.nested_blurs = saved_nested_blurs; + self.nested_shadows = saved_nested_shadows; + self.ignore_nested_blurs = saved_ignore_nested_blurs; + self.preview_mode = saved_preview_mode; + + // Restore render-surface transforms for the workspace context. + // If we have a current tile, restore its tile render context; otherwise + // fall back to restoring the previous render_area (may be empty). + let workspace_scale = self.get_scale(); + if let Some(tile) = self.current_tile { + self.update_render_context(tile); + } else if !self.render_area.is_empty() { + self.surfaces + .update_render_context(self.render_area, workspace_scale); + } + Ok((data.as_bytes().to_vec(), width, height)) } @@ -2454,6 +2585,7 @@ impl RenderState { let has_effects = transformed_element.has_effects_that_extend_bounds(); let is_visible = export + || mask || if is_container || has_effects { let element_extrect = extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale)); @@ -2699,13 +2831,8 @@ impl RenderState { } } else { performance::begin_measure!("render_shape_tree::uncached"); - // Only allow stopping (yielding) if the current tile is NOT visible. - // This ensures all visible tiles render synchronously before showing, - // eliminating empty squares during zoom. Interest-area tiles can still yield. - let tile_is_visible = self.tile_viewbox.is_visible(¤t_tile); - let can_stop = allow_stop && !tile_is_visible; - let (is_empty, early_return) = - self.render_shape_tree_partial_uncached(tree, timestamp, can_stop, false)?; + let (is_empty, early_return) = self + .render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?; if early_return { return Ok(()); @@ -2729,6 +2856,24 @@ impl RenderState { paint.set_color(self.background_color); s.canvas().draw_rect(tile_rect, &paint); }); + // Keep Cache surface coherent for render_from_cache. + if !self.options.is_fast_mode() { + if !self.cache_cleared_this_render { + self.surfaces.clear_cache(self.background_color); + self.cache_cleared_this_render = true; + } + let aligned_rect = self.get_aligned_tile_bounds(current_tile); + self.surfaces.apply_mut(SurfaceId::Cache as u32, |s| { + let mut paint = skia::Paint::default(); + paint.set_color(self.background_color); + s.canvas().draw_rect(aligned_rect, &paint); + }); + } + + // Clear atlas region to transparent so background shows through. + let _ = self + .surfaces + .clear_doc_rect_in_atlas(&mut self.gpu_state, self.render_area); } } } diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 47b739b484..f374e32af3 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -194,6 +194,15 @@ pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')")); } +pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) { + let base64_image = render_state + .surfaces + .base64_snapshot(id) + .expect("Failed to get base64 image"); + + println!("{}", base64_image); +} + #[allow(dead_code)] #[cfg(target_arch = "wasm32")] pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId, rect: skia::Rect) { @@ -223,3 +232,33 @@ pub extern "C" fn debug_cache_console() -> Result<()> { }); Ok(()) } + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_cache_base64() -> Result<()> { + with_state_mut!(state, { + console_debug_surface_base64(state.render_state_mut(), SurfaceId::Cache); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_atlas_console() -> Result<()> { + with_state_mut!(state, { + console_debug_surface(state.render_state_mut(), SurfaceId::Atlas); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_atlas_base64() -> Result<()> { + with_state_mut!(state, { + console_debug_surface_base64(state.render_state_mut(), SurfaceId::Atlas); + }); + Ok(()) +} diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 7b1a49f540..27454ec90f 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -19,7 +19,7 @@ impl Default for RenderOptions { flags: 0, dpr: None, fast_mode: false, - antialias_threshold: 15.0, + antialias_threshold: 7.0, } } } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 1c5a77c72c..e3a9512e08 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -1,6 +1,7 @@ use crate::error::{Error, Result}; use crate::performance; use crate::shapes::Shape; +use crate::view::Viewbox; use skia_safe::{self as skia, IRect, Paint, RRect}; @@ -15,6 +16,16 @@ const TEXTURES_BATCH_DELETE: usize = 256; // If it's too big it could affect performance. const TILE_SIZE_MULTIPLIER: i32 = 2; +/// Atlas texture size limits (px per side). +/// +/// - `DEFAULT_MAX_ATLAS_TEXTURE_SIZE` is the startup fallback used until the +/// frontend reads the real `gl.MAX_TEXTURE_SIZE` and sends it via +/// [`Surfaces::set_max_atlas_texture_size`]. +/// - `MAX_ATLAS_TEXTURE_SIZE` is a hard upper bound to clamp the runtime value +/// (defensive cap to avoid accidentally creating oversized GPU textures). +const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096; +const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024; + #[repr(u32)] #[derive(Debug, PartialEq, Clone, Copy)] pub enum SurfaceId { @@ -30,6 +41,7 @@ pub enum SurfaceId { Export = 0b010_0000_0000, UI = 0b100_0000_0000, Debug = 0b100_0000_0001, + Atlas = 0b100_0000_0010, } pub struct Surfaces { @@ -57,6 +69,18 @@ pub struct Surfaces { export: skia::Surface, tiles: TileTextureCache, + // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. + // It grows dynamically to include any rendered document rect. + atlas: skia::Surface, + atlas_origin: skia::Point, + atlas_size: skia::ISize, + /// Atlas pixel density relative to document pixels (1.0 == 1:1 doc px). + /// When the atlas would exceed `max_atlas_texture_size`, this value is + /// reduced so the atlas stays within the fixed texture cap. + atlas_scale: f32, + /// Max width/height in pixels for the atlas surface (typically browser + /// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation. + max_atlas_texture_size: i32, sampling_options: skia::SamplingOptions, pub margins: skia::ISize, // Tracks which surfaces have content (dirty flag bitmask) @@ -99,6 +123,10 @@ impl Surfaces { let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?; let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?; + // Keep atlas as a regular surface like the rest. Start with a tiny + // transparent surface and grow it on demand. + let mut atlas = gpu_state.create_surface_with_dimensions("atlas".to_string(), 1, 1)?; + atlas.canvas().clear(skia::Color::TRANSPARENT); let tiles = TileTextureCache::new(); Ok(Surfaces { @@ -115,6 +143,11 @@ impl Surfaces { debug, export, tiles, + atlas, + atlas_origin: skia::Point::new(0.0, 0.0), + atlas_size: skia::ISize::new(0, 0), + atlas_scale: 1.0, + max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE, sampling_options, margins, dirty_surfaces: 0, @@ -122,10 +155,212 @@ impl Surfaces { }) } + /// Sets the maximum atlas texture dimension (one side). Should match the + /// WebGL `MAX_TEXTURE_SIZE` reported by the browser. Values are clamped to + /// a small minimum so the atlas logic stays well-defined. + pub fn set_max_atlas_texture_size(&mut self, max_px: i32) { + self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE); + } + + fn ensure_atlas_contains( + &mut self, + gpu_state: &mut GpuState, + doc_rect: skia::Rect, + ) -> Result<()> { + if doc_rect.is_empty() { + return Ok(()); + } + + // Current atlas bounds in document space (1 unit == 1 px). + let current_left = self.atlas_origin.x; + let current_top = self.atlas_origin.y; + let atlas_scale = self.atlas_scale.max(0.01); + let current_right = current_left + (self.atlas_size.width as f32) / atlas_scale; + let current_bottom = current_top + (self.atlas_size.height as f32) / atlas_scale; + + let mut new_left = current_left; + let mut new_top = current_top; + let mut new_right = current_right; + let mut new_bottom = current_bottom; + + // If atlas is empty/uninitialized, seed to rect (expanded to tile boundaries for fewer reallocs). + let needs_init = self.atlas_size.width <= 0 || self.atlas_size.height <= 0; + if needs_init { + new_left = doc_rect.left.floor(); + new_top = doc_rect.top.floor(); + new_right = doc_rect.right.ceil(); + new_bottom = doc_rect.bottom.ceil(); + } else { + new_left = new_left.min(doc_rect.left.floor()); + new_top = new_top.min(doc_rect.top.floor()); + new_right = new_right.max(doc_rect.right.ceil()); + new_bottom = new_bottom.max(doc_rect.bottom.ceil()); + } + + // Add padding to reduce realloc frequency. + let pad = TILE_SIZE; + new_left -= pad; + new_top -= pad; + new_right += pad; + new_bottom += pad; + + let doc_w = (new_right - new_left).max(1.0); + let doc_h = (new_bottom - new_top).max(1.0); + + // Compute atlas scale needed to fit within the fixed texture cap. + // Keep the highest possible scale (closest to 1.0) that still fits. + let cap = self.max_atlas_texture_size.max(TILE_SIZE as i32) as f32; + let required_scale = (cap / doc_w).min(cap / doc_h).clamp(0.01, 1.0); + + // Never upscale the atlas (it would add blur and churn). + let new_scale = self.atlas_scale.min(required_scale).max(0.01); + + let new_w = (doc_w * new_scale).ceil().clamp(1.0, cap) as i32; + let new_h = (doc_h * new_scale).ceil().clamp(1.0, cap) as i32; + + // Fast path: existing atlas already contains the rect. + if !needs_init + && doc_rect.left >= current_left + && doc_rect.top >= current_top + && doc_rect.right <= current_right + && doc_rect.bottom <= current_bottom + { + return Ok(()); + } + + let mut new_atlas = + gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?; + new_atlas.canvas().clear(skia::Color::TRANSPARENT); + + // Copy old atlas into the new one with offset. + if !needs_init { + let old_scale = self.atlas_scale.max(0.01); + let scale_ratio = new_scale / old_scale; + let dx = (current_left - new_left) * new_scale; + let dy = (current_top - new_top) * new_scale; + + let image = self.atlas.image_snapshot(); + let src = skia::Rect::from_xywh( + 0.0, + 0.0, + self.atlas_size.width as f32, + self.atlas_size.height as f32, + ); + let dst = skia::Rect::from_xywh( + dx, + dy, + (self.atlas_size.width as f32) * scale_ratio, + (self.atlas_size.height as f32) * scale_ratio, + ); + new_atlas.canvas().draw_image_rect( + &image, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + dst, + &skia::Paint::default(), + ); + } + + self.atlas_origin = skia::Point::new(new_left, new_top); + self.atlas_size = skia::ISize::new(new_w, new_h); + self.atlas_scale = new_scale; + self.atlas = new_atlas; + Ok(()) + } + + fn blit_tile_image_into_atlas( + &mut self, + gpu_state: &mut GpuState, + tile_image: &skia::Image, + doc_rect: skia::Rect, + ) -> Result<()> { + self.ensure_atlas_contains(gpu_state, doc_rect)?; + + // Destination is document-space rect mapped into atlas pixel coords. + let dst = skia::Rect::from_xywh( + (doc_rect.left - self.atlas_origin.x) * self.atlas_scale, + (doc_rect.top - self.atlas_origin.y) * self.atlas_scale, + doc_rect.width() * self.atlas_scale, + doc_rect.height() * self.atlas_scale, + ); + + self.atlas + .canvas() + .draw_image_rect(tile_image, None, dst, &skia::Paint::default()); + Ok(()) + } + + pub fn clear_doc_rect_in_atlas( + &mut self, + gpu_state: &mut GpuState, + doc_rect: skia::Rect, + ) -> Result<()> { + if doc_rect.is_empty() { + return Ok(()); + } + + self.ensure_atlas_contains(gpu_state, doc_rect)?; + + // Destination is document-space rect mapped into atlas pixel coords. + let dst = skia::Rect::from_xywh( + (doc_rect.left - self.atlas_origin.x) * self.atlas_scale, + (doc_rect.top - self.atlas_origin.y) * self.atlas_scale, + doc_rect.width() * self.atlas_scale, + doc_rect.height() * self.atlas_scale, + ); + + let canvas = self.atlas.canvas(); + canvas.save(); + canvas.clip_rect(dst, None, true); + canvas.clear(skia::Color::TRANSPARENT); + canvas.restore(); + Ok(()) + } + pub fn clear_tiles(&mut self) { self.tiles.clear(); } + pub fn has_atlas(&self) -> bool { + self.atlas_size.width > 0 && self.atlas_size.height > 0 + } + + /// Draw the persistent atlas onto the target using the current viewbox transform. + /// Intended for fast pan/zoom-out previews (avoids per-tile composition). + pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) { + if !self.has_atlas() { + return; + }; + + let canvas = self.target.canvas(); + canvas.save(); + canvas.reset_matrix(); + let size = canvas.base_layer_size(); + canvas.clip_rect( + skia::Rect::from_xywh(0.0, 0.0, size.width as f32, size.height as f32), + None, + true, + ); + + let s = viewbox.zoom * dpr; + let atlas_scale = self.atlas_scale.max(0.01); + + canvas.clear(background); + canvas.translate(( + (self.atlas_origin.x + viewbox.pan_x) * s, + (self.atlas_origin.y + viewbox.pan_y) * s, + )); + canvas.scale((s / atlas_scale, s / atlas_scale)); + + self.atlas.draw( + canvas, + (0.0, 0.0), + self.sampling_options, + Some(&skia::Paint::default()), + ); + + canvas.restore(); + } + pub fn margins(&self) -> skia::ISize { self.margins } @@ -255,6 +490,10 @@ impl Surfaces { ); } + pub fn cache_dimensions(&self) -> skia::ISize { + skia::ISize::new(self.cache.width(), self.cache.height()) + } + pub fn apply_mut(&mut self, ids: u32, mut f: impl FnMut(&mut skia::Surface)) { performance::begin_measure!("apply_mut::flags"); if ids & SurfaceId::Target as u32 != 0 { @@ -352,6 +591,7 @@ impl Surfaces { SurfaceId::Debug => &mut self.debug, SurfaceId::UI => &mut self.ui, SurfaceId::Export => &mut self.export, + SurfaceId::Atlas => &mut self.atlas, } } @@ -369,6 +609,7 @@ impl Surfaces { SurfaceId::Debug => &self.debug, SurfaceId::UI => &self.ui, SurfaceId::Export => &self.export, + SurfaceId::Atlas => &self.atlas, } } @@ -546,10 +787,12 @@ impl Surfaces { pub fn cache_current_tile_texture( &mut self, + gpu_state: &mut GpuState, tile_viewbox: &TileViewbox, tile: &Tile, tile_rect: &skia::Rect, skip_cache_surface: bool, + tile_doc_rect: skia::Rect, ) { let rect = IRect::from_xywh( self.margins.width, @@ -571,6 +814,9 @@ impl Surfaces { ); } + // Incrementally update persistent 1:1 atlas in document space. + // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). + let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); self.tiles.add(tile_viewbox, tile, tile_image); } } diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 40bdb6d2ec..541747229a 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1551,7 +1551,7 @@ pub fn calculate_text_layout_data( for (span_index, span) in text_para.children().iter().enumerate() { let text: String = span.apply_text_transform(); let text_len = text.encode_utf16().count(); - span_ranges.push((cur, cur + text_len + 1, span_index)); + span_ranges.push((cur, cur + text_len, span_index)); cur += text_len; } for (start, end, span_index) in span_ranges { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 74b81d6336..c624dad43d 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -160,14 +160,24 @@ impl State { // Only remove the children when is being deleted from the owner if shape.parent_id.is_none() || shape.parent_id == Some(parent_id) { - let tiles::TileRect(rsx, rsy, rex, rey) = - self.render_state.get_tiles_for_shape(shape, &self.shapes); - for x in rsx..=rex { - for y in rsy..=rey { - let tile = tiles::Tile(x, y); - self.render_state.remove_cached_tile(tile); - self.render_state.tiles.remove_shape_at(tile, shape.id); - } + // IMPORTANT: + // Do NOT use `get_tiles_for_shape` here. That method intersects the shape + // tiles with the current interest area, which means we'd only invalidate + // the subset currently near the viewport. When the user later pans/zooms + // to reveal previously cached tiles, stale pixels could reappear. + // + // Instead, remove the shape from *all* tiles where it was indexed, and + // drop cached tiles for those entries. + let indexed_tiles: Vec = self + .render_state + .tiles + .get_tiles_of(shape.id) + .map(|t| t.iter().copied().collect()) + .unwrap_or_default(); + + for tile in indexed_tiles { + self.render_state.remove_cached_tile(tile); + self.render_state.tiles.remove_shape_at(tile, shape.id); } if let Some(shape_to_delete) = self.shapes.get(&id) { diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 436d57f2ea..7e03befa01 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -278,11 +278,35 @@ impl ShapesPoolImpl { } } - pub fn clean_all(&mut self) { + /// Clears transient per-frame state (modifiers, structure, scale_content) + /// and returns the list of UUIDs that had a `modifier` applied at the + /// moment of cleaning. The caller can use that list to re-sync the tile + /// index / tile cache for those shapes: after cleaning their modifier is + /// gone, but if we don't touch their tiles they keep pointing at the + /// previous modified position and the tile texture cache may serve stale + /// pixels. + pub fn clean_all(&mut self) -> Vec { self.clean_shape_cache(); + + let modified_uuids: Vec = if self.modifiers.is_empty() { + Vec::new() + } else { + let mut idx_to_uuid: HashMap = + HashMap::with_capacity(self.uuid_to_idx.len()); + for (uuid, idx) in self.uuid_to_idx.iter() { + idx_to_uuid.insert(*idx, *uuid); + } + self.modifiers + .keys() + .filter_map(|idx| idx_to_uuid.get(idx).copied()) + .collect() + }; + self.modifiers = HashMap::default(); self.structure = HashMap::default(); self.scale_content = HashMap::default(); + + modified_uuids } pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl { diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 82e7daf1ad..0f89a25d41 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -117,6 +117,7 @@ pub struct TextEditorStyles { pub font_variant_id: Multiple, pub line_height: Multiple, pub letter_spacing: Multiple, + pub fills_are_multiple: bool, pub fills: Vec, } @@ -233,6 +234,7 @@ impl TextEditorStyles { font_variant_id: Multiple::empty(), line_height: Multiple::empty(), letter_spacing: Multiple::empty(), + fills_are_multiple: false, fills: Vec::new(), } } @@ -248,6 +250,7 @@ impl TextEditorStyles { self.font_variant_id.reset(); self.line_height.reset(); self.letter_spacing.reset(); + self.fills_are_multiple = false; self.fills.clear(); } } @@ -324,6 +327,7 @@ pub struct TextEditorState { // This property indicates that we've started // selecting something with the pointer. pub is_pointer_selection_active: bool, + pub is_click_event_skipped: bool, pub active_shape_id: Option, pub cursor_visible: bool, pub last_blink_time: f64, @@ -343,6 +347,7 @@ impl TextEditorState { composition: TextComposition::new(), has_focus: false, is_pointer_selection_active: false, + is_click_event_skipped: false, active_shape_id: None, cursor_visible: true, last_blink_time: 0.0, @@ -529,11 +534,7 @@ impl TextEditorState { let end_paragraph = end.paragraph.min(paragraphs.len() - 1); self.current_styles.reset(); - let mut has_selected_content = false; - let mut has_fills = false; - let mut fills_are_multiple = false; - for (para_idx, paragraph) in paragraphs .iter() .enumerate() @@ -606,14 +607,11 @@ impl TextEditorState { .letter_spacing .merge(Some(span.letter_spacing)); - if !fills_are_multiple { - if !has_fills { - self.current_styles.fills = span.fills.clone(); - has_fills = true; - } else if self.current_styles.fills != span.fills { - fills_are_multiple = true; - self.current_styles.fills.clear(); - } + if self.current_styles.fills.is_empty() { + self.current_styles.fills.append(&mut span.fills.clone()); + } else if self.current_styles.fills != span.fills { + self.current_styles.fills_are_multiple = true; + self.current_styles.fills.append(&mut span.fills.clone()); } } } @@ -630,6 +628,7 @@ impl TextEditorState { let current_offset = focus.offset; let current_text_span = find_text_span_at_offset(current_paragraph, current_offset); + self.current_styles.reset(); self.current_styles .text_align .set_single(Some(current_paragraph.text_align())); diff --git a/render-wasm/src/wapi.rs b/render-wasm/src/wapi.rs index 1947f7e3c6..f9e7e65769 100644 --- a/render-wasm/src/wapi.rs +++ b/render-wasm/src/wapi.rs @@ -35,5 +35,21 @@ macro_rules! cancel_animation_frame { }; } +#[macro_export] +macro_rules! notify_tiles_render_complete { + () => {{ + #[cfg(target_arch = "wasm32")] + unsafe extern "C" { + pub fn wapi_notifyTilesRenderComplete(); + } + + #[cfg(target_arch = "wasm32")] + unsafe { + wapi_notifyTilesRenderComplete() + }; + }}; +} + pub use cancel_animation_frame; +pub use notify_tiles_render_complete; pub use request_animation_frame; diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 9e364e8fb7..21c8566ec1 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -208,16 +208,20 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { if !state.text_editor_state.has_focus { return; } + let point = Point::new(x, y); let Some(shape_id) = state.text_editor_state.active_shape_id else { return; }; + let Some(shape) = state.shapes.get(&shape_id) else { return; }; + if !state.text_editor_state.is_pointer_selection_active { return; } + let Type::Text(text_content) = &shape.shape_type else { return; }; @@ -226,6 +230,9 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { state .text_editor_state .extend_selection_from_position(&position); + // We need this flag to prevent handling the click behavior + // just after a pointerup event. + state.text_editor_state.is_click_event_skipped = true; state.text_editor_state.update_styles(text_content); } }); @@ -263,6 +270,13 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { #[no_mangle] pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { with_state_mut!(state, { + // We need this flag to prevent handling the click behavior + // just after a pointerup event. + if state.text_editor_state.is_click_event_skipped { + state.text_editor_state.is_click_event_skipped = false; + return; + } + if !state.text_editor_state.has_focus { return; } @@ -271,12 +285,15 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { let Some(shape_id) = state.text_editor_state.active_shape_id else { return; }; + let Some(shape) = state.shapes.get(&shape_id) else { return; }; + let Type::Text(text_content) = &shape.shape_type else { return; }; + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { state.text_editor_state.set_caret_from_position(&position); } @@ -769,6 +786,7 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { } let mut fill_bytes = Vec::new(); + let fill_multiple = styles.fills_are_multiple; let mut fill_count: u32 = 0; for fill in &styles.fills { if let Ok(raw_fill) = RawFillData::try_from(fill) { @@ -781,39 +799,41 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { // Layout: 48-byte fixed header + fixed values + serialized fills. let mut bytes = Vec::with_capacity(132 + fill_bytes.len()); - bytes.extend_from_slice(&vertical_align.to_le_bytes()); - bytes.extend_from_slice(&(*styles.text_align.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.text_direction.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.text_decoration.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.text_transform.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.font_family.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.font_size.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.font_weight.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.font_variant_id.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.line_height.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&(*styles.letter_spacing.state() as u32).to_le_bytes()); - bytes.extend_from_slice(&fill_count.to_le_bytes()); + // Header data // offset // index + bytes.extend_from_slice(&vertical_align.to_le_bytes()); // 0 // 0 + bytes.extend_from_slice(&(*styles.text_align.state() as u32).to_le_bytes()); // 4 // 1 + bytes.extend_from_slice(&(*styles.text_direction.state() as u32).to_le_bytes()); // 8 // 2 + bytes.extend_from_slice(&(*styles.text_decoration.state() as u32).to_le_bytes()); // 12 // 3 + bytes.extend_from_slice(&(*styles.text_transform.state() as u32).to_le_bytes()); // 16 // 4 + bytes.extend_from_slice(&(*styles.font_family.state() as u32).to_le_bytes()); // 20 // 5 + bytes.extend_from_slice(&(*styles.font_size.state() as u32).to_le_bytes()); // 24 // 6 + bytes.extend_from_slice(&(*styles.font_weight.state() as u32).to_le_bytes()); // 28 // 7 + bytes.extend_from_slice(&(*styles.font_variant_id.state() as u32).to_le_bytes()); // 32 // 8 + bytes.extend_from_slice(&(*styles.line_height.state() as u32).to_le_bytes()); // 36 // 9 + bytes.extend_from_slice(&(*styles.letter_spacing.state() as u32).to_le_bytes()); // 40 // 10 + bytes.extend_from_slice(&fill_count.to_le_bytes()); // 44 // 11 + bytes.extend_from_slice(&(fill_multiple as u32).to_le_bytes()); // 48 // 12 // Value section. - bytes.extend_from_slice(&text_align.to_le_bytes()); - bytes.extend_from_slice(&text_direction.to_le_bytes()); - bytes.extend_from_slice(&text_decoration.to_le_bytes()); - bytes.extend_from_slice(&text_transform.to_le_bytes()); - bytes.extend_from_slice(&font_family_id[0].to_le_bytes()); - bytes.extend_from_slice(&font_family_id[1].to_le_bytes()); - bytes.extend_from_slice(&font_family_id[2].to_le_bytes()); - bytes.extend_from_slice(&font_family_id[3].to_le_bytes()); - bytes.extend_from_slice(&font_family_style.to_le_bytes()); - bytes.extend_from_slice(&font_family_weight.to_le_bytes()); - bytes.extend_from_slice(&font_size.to_le_bytes()); - bytes.extend_from_slice(&font_weight.to_le_bytes()); - bytes.extend_from_slice(&font_variant_id[0].to_le_bytes()); - bytes.extend_from_slice(&font_variant_id[1].to_le_bytes()); - bytes.extend_from_slice(&font_variant_id[2].to_le_bytes()); - bytes.extend_from_slice(&font_variant_id[3].to_le_bytes()); - bytes.extend_from_slice(&line_height.to_le_bytes()); - bytes.extend_from_slice(&letter_spacing.to_le_bytes()); - bytes.extend_from_slice(&fill_bytes); + bytes.extend_from_slice(&text_align.to_le_bytes()); // 52 // 13 + bytes.extend_from_slice(&text_direction.to_le_bytes()); // 56 // 14 + bytes.extend_from_slice(&text_decoration.to_le_bytes()); // 60 // 15 + bytes.extend_from_slice(&text_transform.to_le_bytes()); // 64 // 16 + bytes.extend_from_slice(&font_family_id[0].to_le_bytes()); // 68 // 17 + bytes.extend_from_slice(&font_family_id[1].to_le_bytes()); // 72 // 18 + bytes.extend_from_slice(&font_family_id[2].to_le_bytes()); // 76 // 19 + bytes.extend_from_slice(&font_family_id[3].to_le_bytes()); // 80 // 20 + bytes.extend_from_slice(&font_family_style.to_le_bytes()); // 84 // 21 + bytes.extend_from_slice(&font_family_weight.to_le_bytes()); // 88 // 22 + bytes.extend_from_slice(&font_size.to_le_bytes()); // 92 // 23 + bytes.extend_from_slice(&font_weight.to_le_bytes()); // 96 // 24 + bytes.extend_from_slice(&font_variant_id[0].to_le_bytes()); // 100 // 25 + bytes.extend_from_slice(&font_variant_id[1].to_le_bytes()); // 104 // 26 + bytes.extend_from_slice(&font_variant_id[2].to_le_bytes()); // 108 // 27 + bytes.extend_from_slice(&font_variant_id[3].to_le_bytes()); // 112 // 28 + bytes.extend_from_slice(&line_height.to_le_bytes()); // 116 // 29 + bytes.extend_from_slice(&letter_spacing.to_le_bytes()); // 120 // 30 + bytes.extend_from_slice(&fill_bytes); // 124 mem::write_bytes(bytes) })