mirror of
https://github.com/penpot/penpot.git
synced 2026-02-26 03:37:14 -05:00
Compare commits
34 Commits
test-inner
...
xaviju-127
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73b855339e | ||
|
|
a4e6aa0588 | ||
|
|
7fe20b65dc | ||
|
|
e5638cd769 | ||
|
|
8e79dfcb82 | ||
|
|
508db99a57 | ||
|
|
3c6c9894da | ||
|
|
972b23e6c0 | ||
|
|
28f550d533 | ||
|
|
2b20f75fd4 | ||
|
|
4d6d7a6a3d | ||
|
|
db1ab7be69 | ||
|
|
fcbe9d92dc | ||
|
|
9998ce0bb4 | ||
|
|
6061391c89 | ||
|
|
eabf6e36ed | ||
|
|
04274e53fa | ||
|
|
52dd9271a9 | ||
|
|
8f5a81e179 | ||
|
|
a940c08da9 | ||
|
|
3de4473251 | ||
|
|
0735140f07 | ||
|
|
dc8a07099d | ||
|
|
90dcf04fb0 | ||
|
|
f84c236e02 | ||
|
|
63959a22cc | ||
|
|
8840246425 | ||
|
|
62ec66cd15 | ||
|
|
e3b87390f6 | ||
|
|
d9ab28e6ed | ||
|
|
9183dbbc43 | ||
|
|
74d00473e9 | ||
|
|
1c70f5a36b | ||
|
|
b23e0c0642 |
28
.github/workflows/tests.yml
vendored
28
.github/workflows/tests.yml
vendored
@@ -8,8 +8,6 @@ on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
push:
|
||||
branches:
|
||||
@@ -17,7 +15,7 @@ on:
|
||||
- staging
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
group: ${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -91,6 +89,30 @@ jobs:
|
||||
run: |
|
||||
yarn run lint:scss;
|
||||
|
||||
test-render-wasm:
|
||||
name: "Render WASM Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Format
|
||||
working-directory: ./render-wasm
|
||||
run: |
|
||||
cargo fmt --check
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./render-wasm
|
||||
run: |
|
||||
./lint
|
||||
|
||||
- name: Test
|
||||
working-directory: ./render-wasm
|
||||
run: |
|
||||
./test
|
||||
|
||||
test-backend:
|
||||
name: "Backend Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
From: {{profile.fullname}} <{{profile.email}}> / {{profile.id}}
|
||||
Subject: {{feedback-subject}}
|
||||
Type: {{feedback-type}}
|
||||
{%- if feedback-error-href %}
|
||||
|
||||
{% if feedback-error-href %}
|
||||
HREF: {{feedback-error-href}}
|
||||
{% endif -%}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
:uid uuid/zero})
|
||||
body (t/encode {:events events})
|
||||
headers {"content-type" "application/transit+json"
|
||||
"origin" (cf/get :public-uri)
|
||||
"origin" (str (cf/get :public-uri))
|
||||
"cookie" (u/map->query-string {:auth-token token})}
|
||||
params {:uri uri
|
||||
:timeout 12000
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
[app.common.exceptions :as ex]
|
||||
[selmer.parser :as sp]))
|
||||
|
||||
(sp/cache-off!)
|
||||
;; (sp/cache-off!)
|
||||
|
||||
(defn render
|
||||
[path context]
|
||||
|
||||
@@ -318,3 +318,35 @@
|
||||
;; check that we have all no objects
|
||||
(let [rows (th/db-exec! ["select * from storage_object where deleted_at is null"])]
|
||||
(t/is (= 0 (count rows))))))
|
||||
|
||||
(t/deftest tempfile-bucket-test
|
||||
(let [storage (-> (:app.storage/storage th/*system*)
|
||||
(configure-storage-backend))
|
||||
content1 (sto/content "content1")
|
||||
now (ct/now)
|
||||
|
||||
object1 (sto/put-object! storage {::sto/content content1
|
||||
::sto/touched-at (ct/plus now {:minutes 1})
|
||||
:bucket "tempfile"
|
||||
:content-type "text/plain"})]
|
||||
|
||||
|
||||
(binding [ct/*clock* (clock/fixed now)]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 0 (:delete res)))))
|
||||
|
||||
|
||||
(binding [ct/*clock* (clock/fixed (ct/plus now {:minutes 1}))]
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 1 (:delete res)))))
|
||||
|
||||
|
||||
(binding [ct/*clock* (clock/fixed (ct/plus now {:hours 1}))]
|
||||
(let [res (th/run-task! :storage-gc-deleted {})]
|
||||
(t/is (= 0 (:deleted res)))))
|
||||
|
||||
(binding [ct/*clock* (clock/fixed (ct/plus now {:hours 2}))]
|
||||
(let [res (th/run-task! :storage-gc-deleted {})]
|
||||
(t/is (= 0 (:deleted res)))))))
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
org.slf4j/slf4j-api {:mvn/version "2.0.17"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.40"}
|
||||
|
||||
selmer/selmer {:mvn/version "1.12.62"}
|
||||
selmer/selmer {:mvn/version "1.12.69"}
|
||||
criterium/criterium {:mvn/version "0.4.6"}
|
||||
|
||||
metosin/jsonista {:mvn/version "0.3.13"}
|
||||
@@ -48,12 +48,8 @@
|
||||
com.sun.mail/jakarta.mail {:mvn/version "2.0.2"}
|
||||
org.la4j/la4j {:mvn/version "0.6.0"}
|
||||
|
||||
;; exception printing
|
||||
fipp/fipp {:mvn/version "0.6.29"}
|
||||
|
||||
me.flowthing/pp {:mvn/version "2024-11-13.77"}
|
||||
|
||||
|
||||
io.aviso/pretty {:mvn/version "1.4.4"}
|
||||
environ/environ {:mvn/version "1.2.0"}}
|
||||
:paths ["src" "vendor" "target/classes"]
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
(:refer-clojure :exclude [get-in select-keys str with-open max])
|
||||
#?(:cljs (:require-macros [app.common.data.macros]))
|
||||
(:require
|
||||
#?(:clj [cljs.analyzer.api :as aapi])
|
||||
#?(:clj [clojure.core :as c]
|
||||
:cljs [cljs.core :as c])
|
||||
[app.common.data :as d]
|
||||
[cljs.analyzer.api :as aapi]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defmacro select-keys
|
||||
@@ -44,42 +44,43 @@
|
||||
[& params]
|
||||
`(str/concat ~@params))
|
||||
|
||||
(defmacro export
|
||||
"A helper macro that allows reexport a var in a current namespace."
|
||||
[v]
|
||||
(if (boolean (:ns &env))
|
||||
#?(:clj
|
||||
(defmacro export
|
||||
"A helper macro that allows reexport a var in a current namespace."
|
||||
[v]
|
||||
(if (boolean (:ns &env))
|
||||
|
||||
;; Code for ClojureScript
|
||||
(let [mdata (aapi/resolve &env v)
|
||||
arglists (second (get-in mdata [:meta :arglists]))
|
||||
sym (symbol (c/name v))
|
||||
andsym (symbol "&")
|
||||
procarg #(if (= % andsym) % (gensym "param"))]
|
||||
(if (pos? (count arglists))
|
||||
`(def
|
||||
~(with-meta sym (:meta mdata))
|
||||
(fn ~@(for [args arglists]
|
||||
(let [args (map procarg args)]
|
||||
(if (some #(= andsym %) args)
|
||||
(let [[sargs dargs] (split-with #(not= andsym %) args)]
|
||||
`([~@sargs ~@dargs] (apply ~v ~@sargs ~@(rest dargs))))
|
||||
`([~@args] (~v ~@args)))))))
|
||||
`(def ~(with-meta sym (:meta mdata)) ~v)))
|
||||
;; Code for ClojureScript
|
||||
(let [mdata (aapi/resolve &env v)
|
||||
arglists (second (get-in mdata [:meta :arglists]))
|
||||
sym (symbol (c/name v))
|
||||
andsym (symbol "&")
|
||||
procarg #(if (= % andsym) % (gensym "param"))]
|
||||
(if (pos? (count arglists))
|
||||
`(def
|
||||
~(with-meta sym (:meta mdata))
|
||||
(fn ~@(for [args arglists]
|
||||
(let [args (map procarg args)]
|
||||
(if (some #(= andsym %) args)
|
||||
(let [[sargs dargs] (split-with #(not= andsym %) args)]
|
||||
`([~@sargs ~@dargs] (apply ~v ~@sargs ~@(rest dargs))))
|
||||
`([~@args] (~v ~@args)))))))
|
||||
`(def ~(with-meta sym (:meta mdata)) ~v)))
|
||||
|
||||
;; Code for Clojure
|
||||
(let [vr (resolve v)
|
||||
m (meta vr)
|
||||
n (:name m)
|
||||
n (with-meta n
|
||||
(cond-> {}
|
||||
(:dynamic m) (assoc :dynamic true)
|
||||
(:protocol m) (assoc :protocol (:protocol m))))]
|
||||
`(let [m# (meta ~vr)]
|
||||
(def ~n (deref ~vr))
|
||||
(alter-meta! (var ~n) merge (dissoc m# :name))
|
||||
;; (when (:macro m#)
|
||||
;; (.setMacro (var ~n)))
|
||||
~vr))))
|
||||
;; Code for Clojure
|
||||
(let [vr (resolve v)
|
||||
m (meta vr)
|
||||
n (:name m)
|
||||
n (with-meta n
|
||||
(cond-> {}
|
||||
(:dynamic m) (assoc :dynamic true)
|
||||
(:protocol m) (assoc :protocol (:protocol m))))]
|
||||
`(let [m# (meta ~vr)]
|
||||
(def ~n (deref ~vr))
|
||||
(alter-meta! (var ~n) merge (dissoc m# :name))
|
||||
;; (when (:macro m#)
|
||||
;; (.setMacro (var ~n)))
|
||||
~vr)))))
|
||||
|
||||
(defmacro fmt
|
||||
"String interpolation helper. Can only be used with strings known at
|
||||
|
||||
@@ -842,38 +842,6 @@
|
||||
choices))]
|
||||
{:pred pred}))})
|
||||
|
||||
;; (register!
|
||||
;; {:type ::inst
|
||||
;; :pred tm/instant?
|
||||
;; :type-properties
|
||||
;; {:title "inst"
|
||||
;; :description "Satisfies Inst protocol"
|
||||
;; :error/message "should be an instant"
|
||||
;; :gen/gen (->> (sg/small-int :min 0 :max 100000)
|
||||
;; (sg/fmap (fn [v] (tm/parse-inst v))))
|
||||
|
||||
;; :decode/string tm/parse-inst
|
||||
;; :encode/string tm/format-inst
|
||||
;; :decode/json tm/parse-inst
|
||||
;; :encode/json tm/format-inst
|
||||
;; ::oapi/type "string"
|
||||
;; ::oapi/format "iso"}})
|
||||
|
||||
;; (register!
|
||||
;; {:type ::timestamp
|
||||
;; :pred tm/instant?
|
||||
;; :type-properties
|
||||
;; {:title "inst"
|
||||
;; :description "Satisfies Inst protocol, the same as ::inst but encodes to epoch"
|
||||
;; :error/message "should be an instant"
|
||||
;; :gen/gen (->> (sg/small-int)
|
||||
;; (sg/fmap (fn [v] (tm/parse-inst v))))
|
||||
;; :decode/string tm/parse-inst
|
||||
;; :encode/string inst-ms
|
||||
;; :decode/json tm/parse-inst
|
||||
;; :encode/json inst-ms
|
||||
;; ::oapi/type "string"
|
||||
;; ::oapi/format "number"}})
|
||||
|
||||
#?(:clj
|
||||
(register!
|
||||
@@ -951,7 +919,7 @@
|
||||
:pred #(and (string? %) (not (str/blank? %)))
|
||||
:property-pred
|
||||
(fn [{:keys [min max] :as props}]
|
||||
(if (seq props)
|
||||
(if (or min max)
|
||||
(fn [value]
|
||||
(let [size (count value)]
|
||||
(cond
|
||||
|
||||
@@ -107,7 +107,7 @@ RUN set -eux; \
|
||||
|
||||
FROM base AS setup-jvm
|
||||
|
||||
ENV CLOJURE_VERSION=1.12.2.1565
|
||||
ENV CLOJURE_VERSION=1.12.3.1577
|
||||
|
||||
RUN set -eux; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
|
||||
@@ -12,7 +12,7 @@ http {
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
keepalive_timeout 0;
|
||||
types_hash_max_size 2048;
|
||||
server_tokens off;
|
||||
|
||||
@@ -223,16 +223,6 @@ http {
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
}
|
||||
|
||||
location ~ ^/js/config.js$ {
|
||||
add_header Cache-Control "no-store, no-cache, max-age=0" always;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|jpg|svg|png|mjs|map)$ {
|
||||
# We set no cache only on devenv
|
||||
add_header Cache-Control "no-store, no-cache, max-age=0" always;
|
||||
# add_header Cache-Control "max-age=604800" always; # 7 days
|
||||
}
|
||||
|
||||
location ~ ^/(/|css|fonts|images|js|wasm|mjs|map) {
|
||||
}
|
||||
|
||||
@@ -240,9 +230,10 @@ http {
|
||||
return 301 " /404";
|
||||
}
|
||||
|
||||
add_header Last-Modified $date_gmt;
|
||||
add_header Cache-Control "no-store, no-cache, max-age=0" always;
|
||||
if_modified_since off;
|
||||
add_header Cache-Control "no-store";
|
||||
add_header Connection close always;
|
||||
# This header is what we need to use on prod
|
||||
# add_header Cache-Control "public, must-revalidate, max-age=0";
|
||||
try_files $uri /index.html$is_args$args /index.html =404;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
docs/img/variants/07-variants-boolean.webp
Normal file
BIN
docs/img/variants/07-variants-boolean.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -107,6 +107,25 @@ desc: Streamline your design workflow with Penpot's Components guide! Learn to c
|
||||
<li>Select the variant copy, press right-click, and select the menu option <strong>Restore variant</strong> (will show if the main component still exists).</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="component-variants-toggle">Toggle for boolean variants</h3>
|
||||
<p>When a variant property represents a boolean state, Penpot can display it as a toggle instead of a dropdown. This offers a quicker and more visual way to switch between two opposite values when working with copies.</p>
|
||||
<p>The toggle appears in place of the property values dropdown, <strong>only when a copy is selected</strong>.</p>
|
||||
<figure>
|
||||
<img src="/img/variants/07-variants-boolean.webp" alt="Boolean variant option" />
|
||||
</figure>
|
||||
<h4>Accepted value pairs</h4>
|
||||
<p>For Penpot to recognize the property as a boolean and display the toggle, the property must be defined with exactly two opposing values. These can be any of the following pairs:</p>
|
||||
<ul>
|
||||
<li><code>true</code> / <code>false</code></li>
|
||||
<li><code>on</code> / <code>off</code></li>
|
||||
<li><code>yes</code> / <code>no</code></li>
|
||||
</ul>
|
||||
<p>The order of the values does not matter. Penpot automatically maps them to ON and OFF states:</p>
|
||||
<ul>
|
||||
<li><strong>ON state:</strong> <code>true</code>, <code>yes</code>, <code>on</code></li>
|
||||
<li><strong>OFF state:</strong> <code>false</code>, <code>no</code>, <code>off</code></li>
|
||||
</ul>
|
||||
|
||||
<h3 id="component-use-variants">Use variants</h3>
|
||||
<p>Once you have created your variants, you can place a copy of a component with variants into your design and then switch between its different versions.</p>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
|
||||
"build:app:libs": "node ./scripts/build-libs.js",
|
||||
"build:app:main": "clojure -M:dev:shadow-cljs release main worker",
|
||||
"build:app:worker": "clojure -M:dev:shadow-cljs release worker",
|
||||
"build:app": "yarn run clear:shadow-cache && yarn run build:app:main && yarn run build:app:libs",
|
||||
"e2e:server": "node ./scripts/e2e-server.js",
|
||||
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
|
||||
@@ -44,9 +45,9 @@
|
||||
"translations": "node ./scripts/translations.js",
|
||||
"watch:app:assets": "node ./scripts/watch.js",
|
||||
"watch:app:libs": "node ./scripts/build-libs.js --watch",
|
||||
"watch:app:main": "clojure -M:dev:shadow-cljs watch main worker storybook",
|
||||
"watch:app:main": "clojure -M:dev:shadow-cljs watch main storybook",
|
||||
"clear:shadow-cache": "rm -rf .shadow-cljs",
|
||||
"watch:app": "yarn run clear:shadow-cache && concurrently \"yarn run watch:app:main\" \"yarn run watch:app:libs\"",
|
||||
"watch:app": "yarn run clear:shadow-cache && yarn run build:app:worker && concurrently \"yarn run watch:app:main\" \"yarn run watch:app:libs\"",
|
||||
"watch": "yarn run watch:app:assets",
|
||||
"watch:storybook": "yarn run build:storybook:assets && concurrently \"storybook dev -p 6006 --no-open\" \"yarn run watch:storybook:assets\"",
|
||||
"watch:storybook:assets": "node ./scripts/watch-storybook.js"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ export class WasmWorkspacePage extends WorkspacePage {
|
||||
}
|
||||
|
||||
async waitForFirstRenderWithoutUI() {
|
||||
await waitForFirstRender();
|
||||
await this.waitForFirstRender();
|
||||
await this.hideUI();
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,22 @@ test("Renders a file with nested frames with inherited blur", async ({
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders a file with nested clipping frames", async ({ page }) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile(
|
||||
"render-wasm/get-file-frame-nested-clipping.json",
|
||||
);
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "44471494-966a-8178-8006-c5bd93f0fe72",
|
||||
pageId: "44471494-966a-8178-8006-c5bd93f0fe73",
|
||||
});
|
||||
await workspace.waitForFirstRenderWithoutUI();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders a clipped frame with a large blur drop shadow", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -9,403 +9,399 @@ test.beforeEach(async ({ page }) => {
|
||||
]);
|
||||
});
|
||||
|
||||
test.describe("Subscriptions: dashboard", () => {
|
||||
test("Team with unlimited subscription has specific icon in menu", async ({
|
||||
test("Team with unlimited subscription has specific icon in menu", async ({
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-subscription.json",
|
||||
);
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-subscription.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-projects?team-id=*",
|
||||
"dashboard/get-projects-second-team.json",
|
||||
);
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
await dashboardPage.goToSecondTeamDashboard();
|
||||
await expect(page.getByTestId("subscription-icon")).toBeVisible();
|
||||
});
|
||||
|
||||
test("The Unlimited subscription has its name in the sidebar dropdown", async ({
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-subscription.json",
|
||||
);
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage-one-editor.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
await dashboardPage.goToDashboard();
|
||||
|
||||
await expect(page.getByTestId("subscription-name")).toHaveText(
|
||||
"Unlimited plan (trial)",
|
||||
);
|
||||
});
|
||||
|
||||
test("When the subscription status is unpaid, the sidebar dropdown displays the name Professional for the Unlimited subscription", async ({
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-unpaid-subscription.json",
|
||||
);
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
await dashboardPage.goToDashboard();
|
||||
|
||||
await expect(page.getByTestId("subscription-name")).toHaveText(
|
||||
"Professional plan",
|
||||
);
|
||||
});
|
||||
|
||||
test("When the subscription status is canceled, the sidebar dropdown displays the name Professional for the Enterprise subscription", async ({
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-enterprise-canceled-subscription.json",
|
||||
);
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
await dashboardPage.goToDashboard();
|
||||
|
||||
await expect(page.getByTestId("subscription-name")).toHaveText(
|
||||
"Professional plan",
|
||||
);
|
||||
});
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-projects?team-id=*",
|
||||
"dashboard/get-projects-second-team.json",
|
||||
);
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
await dashboardPage.goToSecondTeamDashboard();
|
||||
await expect(page.getByTestId("subscription-icon")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("Subscriptions: team members and invitations", () => {
|
||||
test("Team settings has susbscription name and no manage subscription link when is member", async ({
|
||||
test("The Unlimited subscription has its name in the sidebar dropdown", async ({
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in.json",
|
||||
);
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-subscription.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-member.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-projects?team-id=*",
|
||||
"dashboard/get-projects-second-team.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"subscription/get-team-members-subscription-member.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-stats?team-id=*",
|
||||
"dashboard/get-team-stats.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
|
||||
await dashboardPage.goToSecondTeamSettingsSection();
|
||||
await expect(page.getByText("Unlimited (trial)")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Manage your subscription" }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Team settings has susbscription name and manage subscription link when is owner", async ({
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-subscription.json",
|
||||
);
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage-one-editor.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-projects?team-id=*",
|
||||
"dashboard/get-projects-second-team.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"subscription/get-team-members-subscription-owner.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-stats?team-id=*",
|
||||
"dashboard/get-team-stats.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
|
||||
await dashboardPage.goToSecondTeamSettingsSection();
|
||||
|
||||
await expect(page.getByText("Unlimited (trial)")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Manage your subscription" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Members tab has warning message when user has more seats than editors.", async ({
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-subscription.json",
|
||||
);
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-projects?team-id=*",
|
||||
"dashboard/get-projects-second-team.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"subscription/get-team-members-subscription-eight-member.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
|
||||
await dashboardPage.goToSecondTeamMembersSection();
|
||||
|
||||
const ctas = page.getByTestId("cta");
|
||||
await expect(ctas).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByText("Inviting people while on the unlimited plan"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Invitations tab has warning message when user has more seats than editors.", async ({
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-subscription.json",
|
||||
);
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
await dashboardPage.goToDashboard();
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-projects?team-id=*",
|
||||
"dashboard/get-projects-second-team.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"subscription/get-team-members-subscription-eight-member.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-invitations?team-id=*",
|
||||
"subscription/get-team-invitations.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
|
||||
await dashboardPage.goToSecondTeamInvitationsSection();
|
||||
|
||||
const ctas = page.getByTestId("cta");
|
||||
await expect(ctas).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByText("Inviting people while on the unlimited plan"),
|
||||
).toBeVisible();
|
||||
});
|
||||
await expect(page.getByTestId("subscription-name")).toHaveText(
|
||||
"Unlimited plan (trial)",
|
||||
);
|
||||
});
|
||||
|
||||
test("The sidebar dropdown displays the correct subscription name when status is Unpaid", async ({
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-unpaid-subscription.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
await dashboardPage.goToDashboard();
|
||||
|
||||
await expect(page.getByTestId("subscription-name")).toHaveText(
|
||||
"Professional plan",
|
||||
);
|
||||
});
|
||||
|
||||
test("The sidebar dropdown displays the correct subscription name when status is cancelled", async ({
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-enterprise-canceled-subscription.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
await dashboardPage.goToDashboard();
|
||||
|
||||
await expect(page.getByTestId("subscription-name")).toHaveText(
|
||||
"Professional plan",
|
||||
);
|
||||
});
|
||||
|
||||
test("Team settings has susbscription name and no manage subscription link when is member", async ({
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-member.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-projects?team-id=*",
|
||||
"dashboard/get-projects-second-team.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"subscription/get-team-members-subscription-member.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-stats?team-id=*",
|
||||
"dashboard/get-team-stats.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
|
||||
await dashboardPage.goToSecondTeamSettingsSection();
|
||||
await expect(page.getByText("Unlimited (trial)")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Manage your subscription" }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Team settings has susbscription name and manage subscription link when is owner", async ({
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-subscription.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-projects?team-id=*",
|
||||
"dashboard/get-projects-second-team.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"subscription/get-team-members-subscription-owner.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-stats?team-id=*",
|
||||
"dashboard/get-team-stats.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
|
||||
await dashboardPage.goToSecondTeamSettingsSection();
|
||||
|
||||
await expect(page.getByText("Unlimited (trial)")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Manage your subscription" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Members tab has warning message when user has more seats than editors", async ({
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-subscription.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-projects?team-id=*",
|
||||
"dashboard/get-projects-second-team.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"subscription/get-team-members-subscription-eight-member.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
|
||||
await dashboardPage.goToSecondTeamMembersSection();
|
||||
|
||||
const ctas = page.getByTestId("cta");
|
||||
await expect(ctas).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByText("Inviting people while on the unlimited plan"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Invitations tab has warning message when user has more seats than editors", async ({
|
||||
page,
|
||||
}) => {
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"subscription/get-profile-unlimited-subscription.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-info",
|
||||
"subscription/get-team-info-subscriptions.json",
|
||||
);
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.setupDashboardFull();
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"subscription/get-teams-unlimited-subscription-owner.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-projects?team-id=*",
|
||||
"dashboard/get-projects-second-team.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"subscription/get-team-members-subscription-eight-member.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-team-invitations?team-id=*",
|
||||
"subscription/get-team-invitations.json",
|
||||
);
|
||||
|
||||
await dashboardPage.mockRPC(
|
||||
"push-audit-events",
|
||||
"workspace/audit-event-empty.json",
|
||||
);
|
||||
|
||||
await dashboardPage.goToSecondTeamInvitationsSection();
|
||||
|
||||
const ctas = page.getByTestId("cta");
|
||||
await expect(ctas).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByText("Inviting people while on the unlimited plan"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -499,7 +499,7 @@ test.describe("Tokens: Tokens Tab", () => {
|
||||
await valueField.fill("");
|
||||
// TODO: We need to fix this translation
|
||||
await expect(
|
||||
tokensUpdateCreateModal.getByText("Empty field"),
|
||||
tokensUpdateCreateModal.getByText("Token value cannot be empty"),
|
||||
).toBeVisible();
|
||||
await valueSaturationSelector.click({ position: { x: 50, y: 50 } });
|
||||
await expect(valueField).toHaveValue(/^#[A-Fa-f\d]+$/);
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
|
||||
.tool-windows {
|
||||
block-size: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
@@ -124,8 +123,7 @@
|
||||
.inspect-tab-switcher-label {
|
||||
@include use-typography("body-medium");
|
||||
color: var(--color-foreground-primary);
|
||||
flex: 0;
|
||||
min-inline-size: fit-content;
|
||||
flex: 0 1 40%;
|
||||
}
|
||||
|
||||
.inspect-tab-switcher-controls {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
[app.util.object :as obj]
|
||||
[app.util.text.content :as content]
|
||||
[app.util.text.content.styles :as styles]
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn get-contrast-color [background-color]
|
||||
@@ -268,7 +269,12 @@
|
||||
"bottom" "flex-end"
|
||||
nil))
|
||||
|
||||
;;
|
||||
(defn- font-family-from-font-id [font-id]
|
||||
(if (str/includes? font-id "gfont-noto-sans")
|
||||
(let [lang (str/replace font-id #"gfont\-noto\-sans\-" "")]
|
||||
(if (>= (count lang) 3) (str/capital lang) (str/upper lang)))
|
||||
"Noto Color Emoji"))
|
||||
|
||||
;; Text Editor Wrapper
|
||||
;; This is an SVG element that wraps the HTML editor.
|
||||
;;
|
||||
@@ -281,6 +287,10 @@
|
||||
(let [shape-id (dm/get-prop shape :id)
|
||||
modifiers (dm/get-in modifiers [shape-id :modifiers])
|
||||
|
||||
fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false)
|
||||
fallback-families (map (fn [font]
|
||||
(font-family-from-font-id (:font-id font))) fallback-fonts)
|
||||
|
||||
clip-id (dm/str "text-edition-clip" shape-id)
|
||||
|
||||
text-modifier-ref
|
||||
@@ -317,7 +327,7 @@
|
||||
max-height (max height selrect-height)
|
||||
valign (-> shape :content :vertical-align)
|
||||
y (:y selrect)
|
||||
y (if (> height selrect-height)
|
||||
y (if (and valign (> height selrect-height))
|
||||
(case valign
|
||||
"bottom" (- y (- height selrect-height))
|
||||
"center" (- y (/ (- height selrect-height) 2))
|
||||
@@ -341,7 +351,8 @@
|
||||
render-wasm?
|
||||
(obj/merge!
|
||||
#js {"--editor-container-width" (dm/str width "px")
|
||||
"--editor-container-height" (dm/str height "px")})
|
||||
"--editor-container-height" (dm/str height "px")
|
||||
"--fallback-families" (dm/str (str/join ", " fallback-families))})
|
||||
|
||||
(not render-wasm?)
|
||||
(obj/merge!
|
||||
|
||||
@@ -38,30 +38,18 @@
|
||||
(features/use-feature "render-wasm/v1")
|
||||
|
||||
has-invalid-shapes?
|
||||
(if render-wasm-enabled?
|
||||
false
|
||||
(some (fn [shape]
|
||||
(or (cfh/frame-shape? shape)
|
||||
(cfh/text-shape? shape)))
|
||||
shapes-with-children))
|
||||
(some (if render-wasm-enabled?
|
||||
cfh/frame-shape?
|
||||
#(or (cfh/frame-shape? %) (cfh/text-shape? %)))
|
||||
shapes-with-children)
|
||||
|
||||
head-not-group-like?
|
||||
(and (= 1 total-selected)
|
||||
(not is-group?)
|
||||
(not is-bool?))
|
||||
|
||||
disabled-bool-btns
|
||||
(if render-wasm-enabled?
|
||||
false
|
||||
(or (zero? total-selected)
|
||||
has-invalid-shapes?
|
||||
head-not-group-like?))
|
||||
|
||||
disabled-flatten
|
||||
(if render-wasm-enabled?
|
||||
false
|
||||
(or (zero? total-selected)
|
||||
has-invalid-shapes?))
|
||||
disabled-bool-btns (or (zero? total-selected) has-invalid-shapes? head-not-group-like?)
|
||||
disabled-flatten (or (zero? total-selected) has-invalid-shapes?)
|
||||
|
||||
on-change
|
||||
(mf/use-fn
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- token-value-error-fn
|
||||
[{:keys [value]}]
|
||||
(when (or (str/empty? value)
|
||||
(str/blank? value))
|
||||
(tr "workspace.tokens.empty-input")))
|
||||
|
||||
(defn- make-schema
|
||||
[tokens-tree]
|
||||
(sm/schema
|
||||
@@ -44,7 +50,7 @@
|
||||
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
|
||||
#(not (cft/token-name-path-exists? % tokens-tree))]]]
|
||||
|
||||
[:value ::sm/text]
|
||||
[:value [::sm/text {:error/fn token-value-error-fn}]]
|
||||
|
||||
[:resolved-value ::sm/any]
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- token-value-error-fn
|
||||
[{:keys [value]}]
|
||||
(when (or (str/empty? value)
|
||||
(str/blank? value))
|
||||
(tr "workspace.tokens.empty-input")))
|
||||
|
||||
(defn- make-schema
|
||||
[tokens-tree]
|
||||
(sm/schema
|
||||
@@ -44,7 +50,7 @@
|
||||
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
|
||||
#(not (cft/token-name-path-exists? % tokens-tree))]]]
|
||||
|
||||
[:value ::sm/text]
|
||||
[:value [::sm/text {:error/fn token-value-error-fn}]]
|
||||
|
||||
[:resolved-value ::sm/any]
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- token-value-error-fn
|
||||
[{:keys [value]}]
|
||||
(when (or (str/empty? value)
|
||||
(str/blank? value))
|
||||
(tr "workspace.tokens.empty-input")))
|
||||
|
||||
(defn- make-schema
|
||||
[tokens-tree]
|
||||
(sm/schema
|
||||
@@ -44,7 +50,7 @@
|
||||
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
|
||||
#(not (cft/token-name-path-exists? % tokens-tree))]]]
|
||||
|
||||
[:value ::sm/text]
|
||||
[:value [::sm/text {:error/fn token-value-error-fn}]]
|
||||
|
||||
[:resolved-value ::sm/any]
|
||||
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
;; TODO: Review this value vs default-value
|
||||
:value (or value "")
|
||||
:hint-message (:message hint)
|
||||
:variant "comfortable"
|
||||
:slot-start swatch
|
||||
:hint-type (:type hint)})
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
(mf/spread-props props {:on-change on-change
|
||||
:default-value value
|
||||
:hint-message (:message hint)
|
||||
:variant "comfortable"
|
||||
:hint-type (:type hint)})
|
||||
|
||||
props
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
[cuerdas.core :as str]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defn- token-value-error-fn
|
||||
[{:keys [value]}]
|
||||
(when (or (str/empty? value)
|
||||
(str/blank? value))
|
||||
(tr "workspace.tokens.empty-input")))
|
||||
|
||||
(defn- make-schema
|
||||
[tokens-tree]
|
||||
(sm/schema
|
||||
@@ -44,7 +50,7 @@
|
||||
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
|
||||
#(not (cft/token-name-path-exists? % tokens-tree))]]]
|
||||
|
||||
[:value ::sm/text]
|
||||
[:value [::sm/text {:error/fn token-value-error-fn}]]
|
||||
|
||||
[:resolved-value ::sm/any]
|
||||
|
||||
|
||||
@@ -475,9 +475,9 @@
|
||||
(dissoc :style)
|
||||
(merge style)
|
||||
(select-keys allowed-keys))
|
||||
fill-rule (or (-> attrs :fill-rule sr/translate-fill-rule) (-> attrs :fillRule sr/translate-fill-rule))
|
||||
stroke-linecap (or (-> attrs :stroke-linecap sr/translate-stroke-linecap) (-> attrs :strokeLinecap sr/translate-stroke-linecap))
|
||||
stroke-linejoin (or (-> attrs :stroke-linejoin sr/translate-stroke-linejoin) (-> attrs :strokeLinejoin sr/translate-stroke-linejoin))
|
||||
fill-rule (-> (or (:fill-rule attrs) (:fillRule attrs)) sr/translate-fill-rule)
|
||||
stroke-linecap (-> (or (:stroke-linecap attrs) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
|
||||
stroke-linejoin (-> (or (:stroke-linejoin attrs) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
|
||||
fill-none (= "none" (-> attrs :fill))]
|
||||
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
|
||||
|
||||
@@ -785,19 +785,10 @@
|
||||
hidden)))
|
||||
shadows))
|
||||
|
||||
(defn set-shape-text-content
|
||||
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
|
||||
[shape-id content]
|
||||
|
||||
(h/call wasm/internal-module "_clear_shape_text")
|
||||
|
||||
(set-shape-vertical-align (get content :vertical-align))
|
||||
|
||||
(defn fonts-from-text-content [content fallback-fonts-only?]
|
||||
(let [paragraph-set (first (get content :children))
|
||||
paragraphs (get paragraph-set :children)
|
||||
fonts (fonts/get-content-fonts content)
|
||||
total (count paragraphs)]
|
||||
|
||||
(loop [index 0
|
||||
emoji? false
|
||||
langs #{}]
|
||||
@@ -814,20 +805,36 @@
|
||||
emoji? (if emoji? emoji? (t/contains-emoji? text))
|
||||
langs (t/collect-used-languages langs text)]
|
||||
|
||||
(t/write-shape-text spans paragraph text)
|
||||
;; FIXME: this should probably be somewhere else
|
||||
(when fallback-fonts-only? (t/write-shape-text spans paragraph text))
|
||||
|
||||
(recur (inc index)
|
||||
emoji?
|
||||
langs))))
|
||||
|
||||
(let [updated-fonts
|
||||
(-> fonts
|
||||
(-> #{}
|
||||
(cond-> ^boolean emoji? (f/add-emoji-font))
|
||||
(f/add-noto-fonts langs))
|
||||
result (f/store-fonts shape-id updated-fonts)]
|
||||
fallback-fonts (filter #(get % :is-fallback) updated-fonts)]
|
||||
|
||||
(h/call wasm/internal-module "_update_shape_text_layout")
|
||||
(if fallback-fonts-only? updated-fonts fallback-fonts))))))
|
||||
|
||||
result)))))
|
||||
(defn set-shape-text-content
|
||||
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
|
||||
[shape-id content]
|
||||
|
||||
(h/call wasm/internal-module "_clear_shape_text")
|
||||
|
||||
(set-shape-vertical-align (get content :vertical-align))
|
||||
|
||||
(let [fonts (fonts/get-content-fonts content)
|
||||
fallback-fonts (fonts-from-text-content content true)
|
||||
all-fonts (concat fonts fallback-fonts)
|
||||
result (f/store-fonts shape-id all-fonts)]
|
||||
(f/load-fallback-fonts-for-editor! fallback-fonts)
|
||||
(h/call wasm/internal-module "_update_shape_text_layout")
|
||||
result))
|
||||
|
||||
(defn set-shape-grow-type
|
||||
[grow-type]
|
||||
|
||||
@@ -266,6 +266,12 @@
|
||||
|
||||
(store-font-id shape-id font-data asset-id emoji? fallback?)))
|
||||
|
||||
;; FIXME: This is a temporary function to load the fallback fonts for the editor.
|
||||
;; Once we render the editor content within wasm, we can remove this function.
|
||||
(defn load-fallback-fonts-for-editor!
|
||||
[fonts]
|
||||
(doseq [font fonts]
|
||||
(fonts/ensure-loaded! (:font-id font) (:font-variant-id font))))
|
||||
|
||||
(defn store-fonts
|
||||
[shape-id fonts]
|
||||
@@ -277,7 +283,8 @@
|
||||
:font-variant-id "regular"
|
||||
:style 0
|
||||
:weight 400
|
||||
:is-emoji true}))
|
||||
:is-emoji true
|
||||
:is-fallback true}))
|
||||
|
||||
(def noto-fonts
|
||||
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
|
||||
|
||||
@@ -67,15 +67,17 @@ function filterAllowedTypes(options) {
|
||||
* @param {string} type
|
||||
* @returns {boolean}
|
||||
*/
|
||||
return function filter(type) {
|
||||
function filter(type) {
|
||||
if (
|
||||
(!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) &&
|
||||
type === "text/html"
|
||||
type === "text/html"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return allowedTypes.includes(type);
|
||||
};
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,19 +87,22 @@ function filterAllowedTypes(options) {
|
||||
* @returns {Function<AllowedTypesFilterFunction>}
|
||||
*/
|
||||
function filterAllowedItems(options) {
|
||||
|
||||
/**
|
||||
* @param {DataTransferItem}
|
||||
* @returns {boolean}
|
||||
*/
|
||||
return function filter(item) {
|
||||
function filter(item) {
|
||||
if (
|
||||
(!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) &&
|
||||
item.type === "text/html"
|
||||
item.type === "text/html"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return allowedTypes.includes(item.type);
|
||||
};
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
(str v "px")
|
||||
|
||||
(and (= k :font-family) (seq v))
|
||||
(str/quote v)
|
||||
;; pick just first family, avoid quoting twice, and add var(--fallback-families)
|
||||
(str/concat (str/quote (str/unquote (first (str/split v ",")))) ", var(--fallback-families)")
|
||||
|
||||
:else
|
||||
v))
|
||||
@@ -53,7 +54,7 @@
|
||||
(str/slice v 0 -2)
|
||||
|
||||
(= k :font-family)
|
||||
(str/unquote v)
|
||||
(str/unquote (str/replace v ", var(--fallback-families)" ""))
|
||||
|
||||
:else
|
||||
v))
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isLikeParagraph,
|
||||
} from "./Paragraph.js";
|
||||
import { isDisplayBlock, normalizeStyles } from "./Style.js";
|
||||
import { sanitizeFontFamily } from "./Style.js";
|
||||
|
||||
const DEFAULT_FONT_SIZE = "14px";
|
||||
const DEFAULT_FONT_WEIGHT = 400;
|
||||
@@ -87,16 +88,16 @@ export function mapContentFragmentFromDocument(document, root, styleDefaults) {
|
||||
styleDefaults?.getPropertyValue("font-size") ?? DEFAULT_FONT_SIZE,
|
||||
);
|
||||
}
|
||||
const fontFamily = textSpan.style.getPropertyValue("font-family");
|
||||
let fontFamily = textSpan.style.getPropertyValue("font-family");
|
||||
if (!fontFamily) {
|
||||
console.warn("font-family", fontFamily);
|
||||
const fontFamilyValue =
|
||||
fontFamily =
|
||||
styleDefaults?.getPropertyValue("font-family") ?? DEFAULT_FONT_FAMILY;
|
||||
const quotedFontFamily = fontFamilyValue.startsWith('"')
|
||||
? fontFamilyValue
|
||||
: `"${fontFamilyValue}"`;
|
||||
textSpan.style.setProperty("font-family", quotedFontFamily);
|
||||
}
|
||||
|
||||
fontFamily = sanitizeFontFamily(fontFamily);
|
||||
textSpan.style.setProperty("font-family", fontFamily);
|
||||
|
||||
const fontWeight = textSpan.style.getPropertyValue("font-weight");
|
||||
if (!fontWeight) {
|
||||
console.warn("font-weight", fontWeight);
|
||||
@@ -144,18 +145,29 @@ export function htmlToText(html) {
|
||||
tmp.innerHTML = html;
|
||||
|
||||
const blockTags = [
|
||||
"P", "DIV", "SECTION", "ARTICLE", "HEADER", "FOOTER",
|
||||
"UL", "OL", "LI", "TABLE", "TR", "TD", "TH", "PRE"
|
||||
"P",
|
||||
"DIV",
|
||||
"SECTION",
|
||||
"ARTICLE",
|
||||
"HEADER",
|
||||
"FOOTER",
|
||||
"UL",
|
||||
"OL",
|
||||
"LI",
|
||||
"TABLE",
|
||||
"TR",
|
||||
"TD",
|
||||
"TH",
|
||||
"PRE",
|
||||
];
|
||||
|
||||
function walk(node) {
|
||||
let text = "";
|
||||
|
||||
node.childNodes.forEach(child => {
|
||||
node.childNodes.forEach((child) => {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
text += child.textContent;
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
|
||||
if (child.tagName === "BR") {
|
||||
text += "\n";
|
||||
}
|
||||
@@ -178,7 +190,6 @@ export function htmlToText(html) {
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Maps any HTML into a valid content DOM element.
|
||||
*
|
||||
@@ -187,10 +198,14 @@ export function htmlToText(html) {
|
||||
* @param {boolean} [allowHTMLPaste=false]
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
export function mapContentFragmentFromHTML(html, styleDefaults, allowHTMLPaste) {
|
||||
export function mapContentFragmentFromHTML(
|
||||
html,
|
||||
styleDefaults,
|
||||
allowHTMLPaste,
|
||||
) {
|
||||
if (allowHTMLPaste) {
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const parser = new DOMParser();
|
||||
const document = parser.parseFromString(html, "text/html");
|
||||
return mapContentFragmentFromDocument(document, styleDefaults);
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,10 +19,31 @@ const DEFAULT_FONT_WEIGHT = "400";
|
||||
* @param {string} value
|
||||
*/
|
||||
export function sanitizeFontFamily(value) {
|
||||
if (value && value.length > 0 && !value.startsWith('"')) {
|
||||
return `"${value}"`;
|
||||
} else {
|
||||
// NOTE: This is a fix for a bug introduced earlier that have might modified the font-family in the model
|
||||
// adding extra double quotes.
|
||||
if (value && value.startsWith('""')) {
|
||||
//remove the first and last quotes
|
||||
value = value.slice(1).replace(/"([^"]*)$/, "$1");
|
||||
|
||||
// remove quotes from font-family in 1-word font-families
|
||||
// and repeated values
|
||||
value = [
|
||||
...new Set(
|
||||
value
|
||||
.split(", ")
|
||||
.map((x) => (x.includes(" ") ? x : x.replace(/"/g, ""))),
|
||||
),
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
if (!value || value === "") {
|
||||
return "var(--fallback-families)";
|
||||
} else if (value.endsWith(" var(--fallback-families)")) {
|
||||
return value;
|
||||
} else if (value.startsWith('"')) {
|
||||
return `${value}, var(--fallback-families)`;
|
||||
} else {
|
||||
return `"${value}", var(--fallback-families)`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1853,7 +1853,7 @@ msgstr "Select a shape, board or group to inspect their properties and code"
|
||||
|
||||
#: src/app/main/ui/inspect/right_sidebar.cljs:166
|
||||
msgid "inspect.layer-info"
|
||||
msgstr "Select inspect tab"
|
||||
msgstr "Layer info"
|
||||
|
||||
#: src/app/main/ui/inspect/right_sidebar.cljs:137
|
||||
msgid "inspect.multiple-selected"
|
||||
|
||||
@@ -1819,7 +1819,7 @@ msgstr "Jāatlasa apveids, plātne vai kopa, lai apskatītu to īpašības un ko
|
||||
|
||||
#: src/app/main/ui/inspect/right_sidebar.cljs:166
|
||||
msgid "inspect.layer-info"
|
||||
msgstr "Atlasīt izpētīšanas cilni"
|
||||
msgstr "Slāņa informācija"
|
||||
|
||||
#: src/app/main/ui/inspect/right_sidebar.cljs:137
|
||||
msgid "inspect.multiple-selected"
|
||||
|
||||
@@ -38,12 +38,14 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1;
|
||||
const MAX_BLOCKING_TIME_MS: i32 = 32;
|
||||
const NODE_BATCH_THRESHOLD: i32 = 10;
|
||||
|
||||
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
||||
|
||||
pub struct NodeRenderState {
|
||||
pub id: Uuid,
|
||||
// We use this bool to keep that we've traversed all the children inside this node.
|
||||
visited_children: bool,
|
||||
// This is used to clip the content of frames.
|
||||
clip_bounds: Option<(Rect, Option<Corners>, Matrix)>,
|
||||
clip_bounds: Option<ClipStack>,
|
||||
// This is a flag to indicate that we've already drawn the mask of a masked group.
|
||||
visited_mask: bool,
|
||||
// This bool indicates that we're drawing the mask shape.
|
||||
@@ -68,13 +70,26 @@ impl NodeRenderState {
|
||||
/// the clipping region to compensate for coordinate system transformations.
|
||||
/// This is useful for nested coordinate systems or when elements are grouped
|
||||
/// and need relative positioning adjustments.
|
||||
fn append_clip(
|
||||
clip_stack: Option<ClipStack>,
|
||||
clip: (Rect, Option<Corners>, Matrix),
|
||||
) -> Option<ClipStack> {
|
||||
match clip_stack {
|
||||
Some(mut stack) => {
|
||||
stack.push(clip);
|
||||
Some(stack)
|
||||
}
|
||||
None => Some(vec![clip]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_children_clip_bounds(
|
||||
&self,
|
||||
element: &Shape,
|
||||
offset: Option<(f32, f32)>,
|
||||
) -> Option<(Rect, Option<Corners>, Matrix)> {
|
||||
) -> Option<ClipStack> {
|
||||
if self.id.is_nil() || !element.clip() {
|
||||
return self.clip_bounds;
|
||||
return self.clip_bounds.clone();
|
||||
}
|
||||
|
||||
let mut bounds = element.selrect();
|
||||
@@ -95,7 +110,7 @@ impl NodeRenderState {
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Some((bounds, corners, transform))
|
||||
Self::append_clip(self.clip_bounds.clone(), (bounds, corners, transform))
|
||||
}
|
||||
|
||||
/// Calculates the clip bounds for shadow rendering of a given shape.
|
||||
@@ -113,9 +128,9 @@ impl NodeRenderState {
|
||||
&self,
|
||||
element: &Shape,
|
||||
shadow: &Shadow,
|
||||
) -> Option<(Rect, Option<Corners>, Matrix)> {
|
||||
) -> Option<ClipStack> {
|
||||
if self.id.is_nil() {
|
||||
return self.clip_bounds;
|
||||
return self.clip_bounds.clone();
|
||||
}
|
||||
|
||||
// Assert that the shape is either a Frame or Group
|
||||
@@ -136,9 +151,9 @@ impl NodeRenderState {
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Some((bounds, corners, transform))
|
||||
Self::append_clip(self.clip_bounds.clone(), (bounds, corners, transform))
|
||||
}
|
||||
_ => self.clip_bounds,
|
||||
_ => self.clip_bounds.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,6 +383,15 @@ impl RenderState {
|
||||
Self::blur_from_variance(total)
|
||||
}
|
||||
|
||||
fn frame_clip_layer_blur(shape: &Shape) -> Option<Blur> {
|
||||
match shape.shape_type {
|
||||
Type::Frame(_) if shape.clip() => shape.blur.filter(|blur| {
|
||||
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` with `ignore_nested_blurs` temporarily forced to `true`.
|
||||
/// Certain off-screen passes (e.g. shadow masks) must render shapes without
|
||||
/// inheriting ancestor blur. This helper guarantees the flag is restored.
|
||||
@@ -554,7 +578,7 @@ impl RenderState {
|
||||
pub fn render_shape(
|
||||
&mut self,
|
||||
shape: &Shape,
|
||||
clip_bounds: Option<(Rect, Option<Corners>, Matrix)>,
|
||||
clip_bounds: Option<ClipStack>,
|
||||
fills_surface_id: SurfaceId,
|
||||
strokes_surface_id: SurfaceId,
|
||||
innershadows_surface_id: SurfaceId,
|
||||
@@ -574,49 +598,59 @@ impl RenderState {
|
||||
let antialias = shape.should_use_antialias(self.get_scale());
|
||||
|
||||
// set clipping
|
||||
if let Some((bounds, corners, transform)) = clip_bounds {
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas().concat(&transform);
|
||||
});
|
||||
if let Some(clips) = clip_bounds.as_ref() {
|
||||
for (bounds, corners, transform) in clips.iter() {
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas().concat(transform);
|
||||
});
|
||||
|
||||
if let Some(corners) = corners {
|
||||
let rrect = RRect::new_rect_radii(*bounds, corners);
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas()
|
||||
.clip_rrect(rrect, skia::ClipOp::Intersect, antialias);
|
||||
});
|
||||
} else {
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas()
|
||||
.clip_rect(*bounds, skia::ClipOp::Intersect, antialias);
|
||||
});
|
||||
}
|
||||
|
||||
// This renders a red line around clipped
|
||||
// shapes (frames).
|
||||
if self.options.is_debug_visible() {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_style(skia::PaintStyle::Stroke);
|
||||
paint.set_color(skia::Color::from_argb(255, 255, 0, 0));
|
||||
paint.set_stroke_width(4.);
|
||||
self.surfaces
|
||||
.canvas(fills_surface_id)
|
||||
.draw_rect(*bounds, &paint);
|
||||
}
|
||||
|
||||
if let Some(corners) = corners {
|
||||
let rrect = RRect::new_rect_radii(bounds, &corners);
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas()
|
||||
.clip_rrect(rrect, skia::ClipOp::Intersect, antialias);
|
||||
});
|
||||
} else {
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas()
|
||||
.clip_rect(bounds, skia::ClipOp::Intersect, antialias);
|
||||
.concat(&transform.invert().unwrap_or(Matrix::default()));
|
||||
});
|
||||
}
|
||||
|
||||
// This renders a red line around clipped
|
||||
// shapes (frames).
|
||||
if self.options.is_debug_visible() {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_style(skia::PaintStyle::Stroke);
|
||||
paint.set_color(skia::Color::from_argb(255, 255, 0, 0));
|
||||
paint.set_stroke_width(4.);
|
||||
self.surfaces
|
||||
.canvas(fills_surface_id)
|
||||
.draw_rect(bounds, &paint);
|
||||
}
|
||||
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas()
|
||||
.concat(&transform.invert().unwrap_or(Matrix::default()));
|
||||
});
|
||||
}
|
||||
|
||||
// We don't want to change the value in the global state
|
||||
let mut shape: Cow<Shape> = Cow::Borrowed(shape);
|
||||
let frame_has_blur = Self::frame_clip_layer_blur(&shape).is_some();
|
||||
let shape_has_blur = shape.blur.is_some();
|
||||
|
||||
if !self.ignore_nested_blurs {
|
||||
if self.ignore_nested_blurs {
|
||||
if frame_has_blur && shape_has_blur {
|
||||
shape.to_mut().set_blur(None);
|
||||
}
|
||||
} else if !frame_has_blur {
|
||||
if let Some(blur) = self.combined_layer_blur(shape.blur) {
|
||||
shape.to_mut().set_blur(Some(blur));
|
||||
}
|
||||
} else if shape_has_blur {
|
||||
shape.to_mut().set_blur(None);
|
||||
}
|
||||
|
||||
let center = shape.center();
|
||||
@@ -1064,6 +1098,14 @@ impl RenderState {
|
||||
paint.set_blend_mode(element.blend_mode().into());
|
||||
paint.set_alpha_f(element.opacity());
|
||||
|
||||
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
|
||||
let scale = self.get_scale();
|
||||
let sigma = frame_blur.value * scale;
|
||||
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
|
||||
paint.set_image_filter(filter);
|
||||
}
|
||||
}
|
||||
|
||||
// When we're rendering the mask shape we need to set a special blend mode
|
||||
// called 'destination-in' that keeps the drawn content within the mask.
|
||||
// @see https://skia.org/docs/user/api/skblendmode_overview/
|
||||
@@ -1228,7 +1270,7 @@ impl RenderState {
|
||||
shape: &Shape,
|
||||
shape_bounds: &Rect,
|
||||
shadow: &Shadow,
|
||||
clip_bounds: Option<(Rect, Option<Corners>, Matrix)>,
|
||||
clip_bounds: Option<ClipStack>,
|
||||
scale: f32,
|
||||
translation: (f32, f32),
|
||||
extra_layer_blur: Option<Blur>,
|
||||
@@ -1373,13 +1415,11 @@ impl RenderState {
|
||||
let mut is_empty = true;
|
||||
|
||||
while let Some(node_render_state) = self.pending_nodes.pop() {
|
||||
let NodeRenderState {
|
||||
id: node_id,
|
||||
visited_children,
|
||||
clip_bounds,
|
||||
visited_mask,
|
||||
mask,
|
||||
} = node_render_state;
|
||||
let node_id = node_render_state.id;
|
||||
let visited_children = node_render_state.visited_children;
|
||||
let visited_mask = node_render_state.visited_mask;
|
||||
let mask = node_render_state.mask;
|
||||
let clip_bounds = node_render_state.clip_bounds.clone();
|
||||
|
||||
is_empty = false;
|
||||
|
||||
@@ -1462,7 +1502,7 @@ impl RenderState {
|
||||
element,
|
||||
&element.extrect(tree, scale),
|
||||
shadow,
|
||||
clip_bounds,
|
||||
clip_bounds.clone(),
|
||||
scale,
|
||||
translation,
|
||||
None,
|
||||
@@ -1550,37 +1590,40 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((bounds, corners, transform)) = clip_bounds.as_ref() {
|
||||
if let Some(clips) = clip_bounds.as_ref() {
|
||||
let antialias = element.should_use_antialias(scale);
|
||||
let mut total_matrix = Matrix::new_identity();
|
||||
total_matrix.pre_scale((scale, scale), None);
|
||||
total_matrix.pre_translate((translation.0, translation.1));
|
||||
total_matrix.pre_concat(transform);
|
||||
|
||||
self.surfaces.canvas(SurfaceId::Current).save();
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::Current)
|
||||
.concat(&total_matrix);
|
||||
for (bounds, corners, transform) in clips.iter() {
|
||||
let mut total_matrix = Matrix::new_identity();
|
||||
total_matrix.pre_scale((scale, scale), None);
|
||||
total_matrix.pre_translate((translation.0, translation.1));
|
||||
total_matrix.pre_concat(transform);
|
||||
|
||||
if let Some(corners) = corners {
|
||||
let rrect = RRect::new_rect_radii(*bounds, corners);
|
||||
self.surfaces.canvas(SurfaceId::Current).clip_rrect(
|
||||
rrect,
|
||||
skia::ClipOp::Intersect,
|
||||
antialias,
|
||||
);
|
||||
} else {
|
||||
self.surfaces.canvas(SurfaceId::Current).clip_rect(
|
||||
*bounds,
|
||||
skia::ClipOp::Intersect,
|
||||
antialias,
|
||||
);
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::Current)
|
||||
.concat(&total_matrix);
|
||||
|
||||
if let Some(corners) = corners {
|
||||
let rrect = RRect::new_rect_radii(*bounds, corners);
|
||||
self.surfaces.canvas(SurfaceId::Current).clip_rrect(
|
||||
rrect,
|
||||
skia::ClipOp::Intersect,
|
||||
antialias,
|
||||
);
|
||||
} else {
|
||||
self.surfaces.canvas(SurfaceId::Current).clip_rect(
|
||||
*bounds,
|
||||
skia::ClipOp::Intersect,
|
||||
antialias,
|
||||
);
|
||||
}
|
||||
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::Current)
|
||||
.concat(&total_matrix.invert().unwrap_or_default());
|
||||
}
|
||||
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::Current)
|
||||
.concat(&total_matrix.invert().unwrap_or_default());
|
||||
|
||||
self.surfaces
|
||||
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
|
||||
|
||||
@@ -1596,7 +1639,7 @@ impl RenderState {
|
||||
|
||||
self.render_shape(
|
||||
element,
|
||||
clip_bounds,
|
||||
clip_bounds.clone(),
|
||||
SurfaceId::Fills,
|
||||
SurfaceId::Strokes,
|
||||
SurfaceId::InnerShadows,
|
||||
@@ -1614,6 +1657,9 @@ impl RenderState {
|
||||
}
|
||||
|
||||
match element.shape_type {
|
||||
Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => {
|
||||
self.nested_blurs.push(None);
|
||||
}
|
||||
Type::Frame(_) | Type::Group(_) => {
|
||||
self.nested_blurs.push(element.blur);
|
||||
}
|
||||
@@ -1624,7 +1670,7 @@ impl RenderState {
|
||||
self.pending_nodes.push(NodeRenderState {
|
||||
id: node_id,
|
||||
visited_children: true,
|
||||
clip_bounds,
|
||||
clip_bounds: clip_bounds.clone(),
|
||||
visited_mask: false,
|
||||
mask,
|
||||
});
|
||||
@@ -1651,7 +1697,7 @@ impl RenderState {
|
||||
self.pending_nodes.push(NodeRenderState {
|
||||
id: **child_id,
|
||||
visited_children: false,
|
||||
clip_bounds: children_clip_bounds,
|
||||
clip_bounds: children_clip_bounds.clone(),
|
||||
visited_mask: false,
|
||||
mask: false,
|
||||
});
|
||||
|
||||
@@ -885,19 +885,50 @@ impl Shape {
|
||||
scale: f32,
|
||||
) -> Bounds {
|
||||
let mut rect = bounds.to_rect();
|
||||
let include_children = match self.shape_type {
|
||||
Type::Group(_) => true,
|
||||
Type::Frame(_) => !self.clip_content,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if include_children {
|
||||
for child_id in self.children_ids_iter(false) {
|
||||
if let Some(child_shape) = shapes_pool.get(child_id) {
|
||||
let child_extrect = child_shape.calculate_extrect(shapes_pool, scale);
|
||||
rect.join(child_extrect);
|
||||
match self.shape_type {
|
||||
Type::Group(Group { masked: true }) => {
|
||||
let mut mask_rect: Option<math::Rect> = None;
|
||||
let mut content_rect: Option<math::Rect> = None;
|
||||
|
||||
for (index, child_id) in self.children.iter().enumerate() {
|
||||
if let Some(child_shape) = shapes_pool.get(child_id) {
|
||||
let child_extrect = child_shape.calculate_extrect(shapes_pool, scale);
|
||||
|
||||
if index == 0 {
|
||||
mask_rect = Some(child_extrect);
|
||||
} else {
|
||||
match content_rect.as_mut() {
|
||||
Some(r) => r.join(child_extrect),
|
||||
None => content_rect = Some(child_extrect),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (mask_rect, content_rect) {
|
||||
(Some(mut mask), Some(content)) => {
|
||||
if mask.intersect(content) {
|
||||
rect.join(mask);
|
||||
}
|
||||
}
|
||||
(Some(mask), None) | (None, Some(mask)) => {
|
||||
rect.join(mask);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
Type::Group(_) | Type::Frame(_) if !self.clip_content => {
|
||||
for child_id in self.children_ids_iter(false) {
|
||||
if let Some(child_shape) = shapes_pool.get(child_id) {
|
||||
let child_extrect = child_shape.calculate_extrect(shapes_pool, scale);
|
||||
rect.join(child_extrect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Bounds::from_rect(&rect)
|
||||
@@ -1426,6 +1457,7 @@ impl Shape {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::ShapesPool;
|
||||
|
||||
fn any_shape() -> Shape {
|
||||
Shape::new(Uuid::nil())
|
||||
@@ -1485,4 +1517,42 @@ mod tests {
|
||||
assert_eq!(shape.selrect().width(), 20.0);
|
||||
assert_eq!(shape.selrect().height(), 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_extrect_matches_mask_intersection() {
|
||||
let mut pool = ShapesPool::new();
|
||||
pool.initialize(3);
|
||||
|
||||
let group_id = Uuid::new_v4();
|
||||
let mask_id = Uuid::new_v4();
|
||||
let content_id = Uuid::new_v4();
|
||||
|
||||
{
|
||||
let group = pool.add_shape(group_id);
|
||||
group.set_shape_type(Type::Group(Group { masked: true }));
|
||||
group.children = vec![mask_id, content_id];
|
||||
}
|
||||
|
||||
{
|
||||
let mask = pool.add_shape(mask_id);
|
||||
mask.set_shape_type(Type::Rect(Rect::default()));
|
||||
mask.set_selrect(0.0, 0.0, 50.0, 50.0);
|
||||
mask.set_parent(group_id);
|
||||
}
|
||||
|
||||
{
|
||||
let content = pool.add_shape(content_id);
|
||||
content.set_shape_type(Type::Rect(Rect::default()));
|
||||
content.set_selrect(-10.0, -10.0, 110.0, 110.0);
|
||||
content.set_parent(group_id);
|
||||
}
|
||||
|
||||
let group = pool.get(&group_id).expect("group should exist");
|
||||
let extrect = group.calculate_extrect(&pool, 1.0);
|
||||
|
||||
assert_eq!(extrect.left, 0.0);
|
||||
assert_eq!(extrect.top, 0.0);
|
||||
assert_eq!(extrect.right, 50.0);
|
||||
assert_eq!(extrect.bottom, 50.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ pub extern "C" fn set_shape_text_content() {
|
||||
with_current_shape_mut!(state, |shape: &mut Shape| {
|
||||
let raw_text_data = RawParagraph::try_from(&bytes).unwrap();
|
||||
|
||||
if let Err(_) = shape.add_paragraph(raw_text_data.into()) {
|
||||
if shape.add_paragraph(raw_text_data.into()).is_err() {
|
||||
println!("Error with set_shape_text_content on {:?}", shape.id);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user