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

This commit is contained in:
Andrey Antukh 2025-11-27 16:11:36 +01:00
commit 0f88253dd5
22 changed files with 1821 additions and 540 deletions

View File

@ -8,8 +8,6 @@ on:
pull_request: pull_request:
types: types:
- opened - opened
- edited
- reopened
- synchronize - synchronize
push: push:
branches: branches:
@ -91,6 +89,30 @@ jobs:
run: | run: |
yarn run lint:scss; 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: test-backend:
name: "Backend Tests" name: "Backend Tests"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04

View File

@ -1,7 +1,8 @@
From: {{profile.fullname}} <{{profile.email}}> / {{profile.id}} From: {{profile.fullname}} <{{profile.email}}> / {{profile.id}}
Subject: {{feedback-subject}} Subject: {{feedback-subject}}
Type: {{feedback-type}} Type: {{feedback-type}}
{%- if feedback-error-href %}
{% if feedback-error-href %}
HREF: {{feedback-error-href}} HREF: {{feedback-error-href}}
{% endif -%} {% endif -%}

View File

@ -57,7 +57,7 @@
:uid uuid/zero}) :uid uuid/zero})
body (t/encode {:events events}) body (t/encode {:events events})
headers {"content-type" "application/transit+json" 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})} "cookie" (u/map->query-string {:auth-token token})}
params {:uri uri params {:uri uri
:timeout 12000 :timeout 12000

View File

@ -9,7 +9,7 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[selmer.parser :as sp])) [selmer.parser :as sp]))
(sp/cache-off!) ;; (sp/cache-off!)
(defn render (defn render
[path context] [path context]

View File

@ -318,3 +318,35 @@
;; check that we have all no objects ;; check that we have all no objects
(let [rows (th/db-exec! ["select * from storage_object where deleted_at is null"])] (let [rows (th/db-exec! ["select * from storage_object where deleted_at is null"])]
(t/is (= 0 (count rows)))))) (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)))))))

View File

@ -17,7 +17,7 @@
org.slf4j/slf4j-api {:mvn/version "2.0.17"} org.slf4j/slf4j-api {:mvn/version "2.0.17"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.40"} 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"} criterium/criterium {:mvn/version "0.4.6"}
metosin/jsonista {:mvn/version "0.3.13"} metosin/jsonista {:mvn/version "0.3.13"}
@ -48,12 +48,8 @@
com.sun.mail/jakarta.mail {:mvn/version "2.0.2"} com.sun.mail/jakarta.mail {:mvn/version "2.0.2"}
org.la4j/la4j {:mvn/version "0.6.0"} 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"} me.flowthing/pp {:mvn/version "2024-11-13.77"}
io.aviso/pretty {:mvn/version "1.4.4"} io.aviso/pretty {:mvn/version "1.4.4"}
environ/environ {:mvn/version "1.2.0"}} environ/environ {:mvn/version "1.2.0"}}
:paths ["src" "vendor" "target/classes"] :paths ["src" "vendor" "target/classes"]

View File

@ -9,10 +9,10 @@
(:refer-clojure :exclude [get-in select-keys str with-open max]) (:refer-clojure :exclude [get-in select-keys str with-open max])
#?(:cljs (:require-macros [app.common.data.macros])) #?(:cljs (:require-macros [app.common.data.macros]))
(:require (:require
#?(:clj [cljs.analyzer.api :as aapi])
#?(:clj [clojure.core :as c] #?(:clj [clojure.core :as c]
:cljs [cljs.core :as c]) :cljs [cljs.core :as c])
[app.common.data :as d] [app.common.data :as d]
[cljs.analyzer.api :as aapi]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(defmacro select-keys (defmacro select-keys
@ -44,42 +44,43 @@
[& params] [& params]
`(str/concat ~@params)) `(str/concat ~@params))
(defmacro export #?(:clj
"A helper macro that allows reexport a var in a current namespace." (defmacro export
[v] "A helper macro that allows reexport a var in a current namespace."
(if (boolean (:ns &env)) [v]
(if (boolean (:ns &env))
;; Code for ClojureScript ;; Code for ClojureScript
(let [mdata (aapi/resolve &env v) (let [mdata (aapi/resolve &env v)
arglists (second (get-in mdata [:meta :arglists])) arglists (second (get-in mdata [:meta :arglists]))
sym (symbol (c/name v)) sym (symbol (c/name v))
andsym (symbol "&") andsym (symbol "&")
procarg #(if (= % andsym) % (gensym "param"))] procarg #(if (= % andsym) % (gensym "param"))]
(if (pos? (count arglists)) (if (pos? (count arglists))
`(def `(def
~(with-meta sym (:meta mdata)) ~(with-meta sym (:meta mdata))
(fn ~@(for [args arglists] (fn ~@(for [args arglists]
(let [args (map procarg args)] (let [args (map procarg args)]
(if (some #(= andsym %) args) (if (some #(= andsym %) args)
(let [[sargs dargs] (split-with #(not= andsym %) args)] (let [[sargs dargs] (split-with #(not= andsym %) args)]
`([~@sargs ~@dargs] (apply ~v ~@sargs ~@(rest dargs)))) `([~@sargs ~@dargs] (apply ~v ~@sargs ~@(rest dargs))))
`([~@args] (~v ~@args))))))) `([~@args] (~v ~@args)))))))
`(def ~(with-meta sym (:meta mdata)) ~v))) `(def ~(with-meta sym (:meta mdata)) ~v)))
;; Code for Clojure ;; Code for Clojure
(let [vr (resolve v) (let [vr (resolve v)
m (meta vr) m (meta vr)
n (:name m) n (:name m)
n (with-meta n n (with-meta n
(cond-> {} (cond-> {}
(:dynamic m) (assoc :dynamic true) (:dynamic m) (assoc :dynamic true)
(:protocol m) (assoc :protocol (:protocol m))))] (:protocol m) (assoc :protocol (:protocol m))))]
`(let [m# (meta ~vr)] `(let [m# (meta ~vr)]
(def ~n (deref ~vr)) (def ~n (deref ~vr))
(alter-meta! (var ~n) merge (dissoc m# :name)) (alter-meta! (var ~n) merge (dissoc m# :name))
;; (when (:macro m#) ;; (when (:macro m#)
;; (.setMacro (var ~n))) ;; (.setMacro (var ~n)))
~vr)))) ~vr)))))
(defmacro fmt (defmacro fmt
"String interpolation helper. Can only be used with strings known at "String interpolation helper. Can only be used with strings known at

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -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> <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> </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> <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> <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>

View File

@ -47,7 +47,7 @@
"watch:app:libs": "node ./scripts/build-libs.js --watch", "watch:app:libs": "node ./scripts/build-libs.js --watch",
"watch:app:main": "clojure -M:dev:shadow-cljs watch main storybook", "watch:app:main": "clojure -M:dev:shadow-cljs watch main storybook",
"clear:shadow-cache": "rm -rf .shadow-cljs", "clear:shadow-cache": "rm -rf .shadow-cljs",
"watch:app": "yarn run clear:shadow-cache && concurrently \"yarn run build:app:worker\" \"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": "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": "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" "watch:storybook:assets": "node ./scripts/watch-storybook.js"

File diff suppressed because it is too large Load Diff

View File

@ -42,7 +42,7 @@ export class WasmWorkspacePage extends WorkspacePage {
} }
async waitForFirstRenderWithoutUI() { async waitForFirstRenderWithoutUI() {
await waitForFirstRender(); await this.waitForFirstRender();
await this.hideUI(); await this.hideUI();
} }

View File

@ -258,6 +258,22 @@ test("Renders a file with nested frames with inherited blur", async ({
await expect(workspace.canvas).toHaveScreenshot(); 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 ({ test("Renders a clipped frame with a large blur drop shadow", async ({
page, page,
}) => { }) => {

View File

@ -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, page,
}) => { "get-profile",
await DashboardPage.mockRPC( "subscription/get-profile-unlimited-subscription.json",
page, );
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
await DashboardPage.mockRPC( 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 ({
page, page,
}) => { "get-subscription-usage",
await DashboardPage.mockRPC( "subscription/get-subscription-usage.json",
page, );
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
await DashboardPage.mockRPC( 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 ({
page, page,
}) => { "get-team-info",
await DashboardPage.mockRPC( "subscription/get-team-info-subscriptions.json",
page, );
"get-profile",
"subscription/get-profile-unlimited-unpaid-subscription.json",
);
await DashboardPage.mockRPC( const dashboardPage = new DashboardPage(page);
page, await dashboardPage.setupDashboardFull();
"get-subscription-usage", await DashboardPage.mockRPC(
"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 ({
page, page,
}) => { "get-teams",
await DashboardPage.mockRPC( "subscription/get-teams-unlimited-subscription-owner.json",
page, );
"get-profile",
"subscription/get-profile-enterprise-canceled-subscription.json",
);
await DashboardPage.mockRPC( await DashboardPage.mockRPC(
page, page,
"get-subscription-usage", "get-projects?team-id=*",
"subscription/get-subscription-usage.json", "dashboard/get-projects-second-team.json",
); );
await dashboardPage.mockRPC(
await DashboardPage.mockRPC( "push-audit-events",
page, "workspace/audit-event-empty.json",
"get-team-info", );
"subscription/get-team-info-subscriptions.json", await dashboardPage.goToSecondTeamDashboard();
); await expect(page.getByTestId("subscription-icon")).toBeVisible();
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.describe("Subscriptions: team members and invitations", () => { test("The Unlimited subscription has its name in the sidebar dropdown", async ({
test("Team settings has susbscription name and no manage subscription link when is member", async ({ page,
}) => {
await DashboardPage.mockRPC(
page, page,
}) => { "get-profile",
await DashboardPage.mockRPC( "subscription/get-profile-unlimited-subscription.json",
page, );
"get-profile",
"logged-in-user/get-profile-logged-in.json",
);
await DashboardPage.mockRPC( 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, page,
}) => { "get-subscription-usage",
await DashboardPage.mockRPC( "subscription/get-subscription-usage-one-editor.json",
page, );
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
await DashboardPage.mockRPC( 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, page,
}) => { "get-team-info",
await DashboardPage.mockRPC( "subscription/get-team-info-subscriptions.json",
page, );
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
await DashboardPage.mockRPC( const dashboardPage = new DashboardPage(page);
page, await dashboardPage.setupDashboardFull();
"get-subscription-usage", await DashboardPage.mockRPC(
"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, page,
}) => { "get-teams",
await DashboardPage.mockRPC( "subscription/get-teams-unlimited-subscription-owner.json",
page, );
"get-profile",
"subscription/get-profile-unlimited-subscription.json",
);
await DashboardPage.mockRPC( await dashboardPage.mockRPC(
page, "push-audit-events",
"get-subscription-usage", "workspace/audit-event-empty.json",
"subscription/get-subscription-usage.json", );
); await dashboardPage.goToDashboard();
await DashboardPage.mockRPC( await expect(page.getByTestId("subscription-name")).toHaveText(
page, "Unlimited plan (trial)",
"get-team-info", );
"subscription/get-team-info-subscriptions.json", });
);
test("The sidebar dropdown displays the correct subscription name when status is Unpaid", async ({
const dashboardPage = new DashboardPage(page); page,
await dashboardPage.setupDashboardFull(); }) => {
await DashboardPage.mockRPC( await DashboardPage.mockRPC(
page, page,
"get-teams", "get-profile",
"subscription/get-teams-unlimited-subscription-owner.json", "subscription/get-profile-unlimited-unpaid-subscription.json",
); );
await DashboardPage.mockRPC( await DashboardPage.mockRPC(
page, page,
"get-projects?team-id=*", "get-subscription-usage",
"dashboard/get-projects-second-team.json", "subscription/get-subscription-usage.json",
); );
await DashboardPage.mockRPC( await DashboardPage.mockRPC(
page, page,
"get-team-members?team-id=*", "get-team-info",
"subscription/get-team-members-subscription-eight-member.json", "subscription/get-team-info-subscriptions.json",
); );
await DashboardPage.mockRPC( const dashboardPage = new DashboardPage(page);
page, await dashboardPage.setupDashboardFull();
"get-team-invitations?team-id=*", await DashboardPage.mockRPC(
"subscription/get-team-invitations.json", page,
); "get-teams",
"subscription/get-teams-unlimited-subscription-owner.json",
await dashboardPage.mockRPC( );
"push-audit-events",
"workspace/audit-event-empty.json", await dashboardPage.mockRPC(
); "push-audit-events",
"workspace/audit-event-empty.json",
await dashboardPage.goToSecondTeamInvitationsSection(); );
await dashboardPage.goToDashboard();
const ctas = page.getByTestId("cta");
await expect(ctas).toHaveCount(2); await expect(page.getByTestId("subscription-name")).toHaveText(
await expect( "Professional plan",
page.getByText("Inviting people while on the unlimited plan"), );
).toBeVisible(); });
});
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();
}); });

View File

@ -317,7 +317,7 @@
max-height (max height selrect-height) max-height (max height selrect-height)
valign (-> shape :content :vertical-align) valign (-> shape :content :vertical-align)
y (:y selrect) y (:y selrect)
y (if (> height selrect-height) y (if (and valign (> height selrect-height))
(case valign (case valign
"bottom" (- y (- height selrect-height)) "bottom" (- y (- height selrect-height))
"center" (- y (/ (- height selrect-height) 2)) "center" (- y (/ (- height selrect-height) 2))

View File

@ -38,30 +38,18 @@
(features/use-feature "render-wasm/v1") (features/use-feature "render-wasm/v1")
has-invalid-shapes? has-invalid-shapes?
(if render-wasm-enabled? (some (if render-wasm-enabled?
false cfh/frame-shape?
(some (fn [shape] #(or (cfh/frame-shape? %) (cfh/text-shape? %)))
(or (cfh/frame-shape? shape) shapes-with-children)
(cfh/text-shape? shape)))
shapes-with-children))
head-not-group-like? head-not-group-like?
(and (= 1 total-selected) (and (= 1 total-selected)
(not is-group?) (not is-group?)
(not is-bool?)) (not is-bool?))
disabled-bool-btns disabled-bool-btns (or (zero? total-selected) has-invalid-shapes? head-not-group-like?)
(if render-wasm-enabled? disabled-flatten (or (zero? total-selected) has-invalid-shapes?)
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?))
on-change on-change
(mf/use-fn (mf/use-fn

View File

@ -475,9 +475,9 @@
(dissoc :style) (dissoc :style)
(merge style) (merge style)
(select-keys allowed-keys)) (select-keys allowed-keys))
fill-rule (or (-> attrs :fill-rule sr/translate-fill-rule) (-> attrs :fillRule sr/translate-fill-rule)) fill-rule (-> (or (:fill-rule attrs) (:fillRule attrs)) sr/translate-fill-rule)
stroke-linecap (or (-> attrs :stroke-linecap sr/translate-stroke-linecap) (-> attrs :strokeLinecap sr/translate-stroke-linecap)) stroke-linecap (-> (or (:stroke-linecap attrs) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
stroke-linejoin (or (-> attrs :stroke-linejoin sr/translate-stroke-linejoin) (-> attrs :strokeLinejoin sr/translate-stroke-linejoin)) stroke-linejoin (-> (or (:stroke-linejoin attrs) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
fill-none (= "none" (-> attrs :fill))] fill-none (= "none" (-> attrs :fill))]
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none))) (h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))

View File

@ -67,15 +67,17 @@ function filterAllowedTypes(options) {
* @param {string} type * @param {string} type
* @returns {boolean} * @returns {boolean}
*/ */
return function filter(type) { function filter(type) {
if ( if (
(!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) && (!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) &&
type === "text/html" type === "text/html"
) { ) {
return false; return false;
} }
return allowedTypes.includes(type); return allowedTypes.includes(type);
}; };
return filter;
} }
/** /**
@ -85,19 +87,22 @@ function filterAllowedTypes(options) {
* @returns {Function<AllowedTypesFilterFunction>} * @returns {Function<AllowedTypesFilterFunction>}
*/ */
function filterAllowedItems(options) { function filterAllowedItems(options) {
/** /**
* @param {DataTransferItem} * @param {DataTransferItem}
* @returns {boolean} * @returns {boolean}
*/ */
return function filter(item) { function filter(item) {
if ( if (
(!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) && (!("allowHTMLPaste" in options) || !options["allowHTMLPaste"]) &&
item.type === "text/html" item.type === "text/html"
) { ) {
return false; return false;
} }
return allowedTypes.includes(item.type); return allowedTypes.includes(item.type);
}; };
return filter;
} }
/** /**

View File

@ -38,12 +38,14 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1;
const MAX_BLOCKING_TIME_MS: i32 = 32; const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 10; const NODE_BATCH_THRESHOLD: i32 = 10;
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
pub struct NodeRenderState { pub struct NodeRenderState {
pub id: Uuid, pub id: Uuid,
// We use this bool to keep that we've traversed all the children inside this node. // We use this bool to keep that we've traversed all the children inside this node.
visited_children: bool, visited_children: bool,
// This is used to clip the content of frames. // 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. // This is a flag to indicate that we've already drawn the mask of a masked group.
visited_mask: bool, visited_mask: bool,
// This bool indicates that we're drawing the mask shape. // 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. /// the clipping region to compensate for coordinate system transformations.
/// This is useful for nested coordinate systems or when elements are grouped /// This is useful for nested coordinate systems or when elements are grouped
/// and need relative positioning adjustments. /// 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( pub fn get_children_clip_bounds(
&self, &self,
element: &Shape, element: &Shape,
offset: Option<(f32, f32)>, offset: Option<(f32, f32)>,
) -> Option<(Rect, Option<Corners>, Matrix)> { ) -> Option<ClipStack> {
if self.id.is_nil() || !element.clip() { if self.id.is_nil() || !element.clip() {
return self.clip_bounds; return self.clip_bounds.clone();
} }
let mut bounds = element.selrect(); let mut bounds = element.selrect();
@ -95,7 +110,7 @@ impl NodeRenderState {
_ => None, _ => 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. /// Calculates the clip bounds for shadow rendering of a given shape.
@ -113,9 +128,9 @@ impl NodeRenderState {
&self, &self,
element: &Shape, element: &Shape,
shadow: &Shadow, shadow: &Shadow,
) -> Option<(Rect, Option<Corners>, Matrix)> { ) -> Option<ClipStack> {
if self.id.is_nil() { if self.id.is_nil() {
return self.clip_bounds; return self.clip_bounds.clone();
} }
// Assert that the shape is either a Frame or Group // Assert that the shape is either a Frame or Group
@ -136,9 +151,9 @@ impl NodeRenderState {
_ => None, _ => 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) 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`. /// Runs `f` with `ignore_nested_blurs` temporarily forced to `true`.
/// Certain off-screen passes (e.g. shadow masks) must render shapes without /// Certain off-screen passes (e.g. shadow masks) must render shapes without
/// inheriting ancestor blur. This helper guarantees the flag is restored. /// inheriting ancestor blur. This helper guarantees the flag is restored.
@ -554,7 +578,7 @@ impl RenderState {
pub fn render_shape( pub fn render_shape(
&mut self, &mut self,
shape: &Shape, shape: &Shape,
clip_bounds: Option<(Rect, Option<Corners>, Matrix)>, clip_bounds: Option<ClipStack>,
fills_surface_id: SurfaceId, fills_surface_id: SurfaceId,
strokes_surface_id: SurfaceId, strokes_surface_id: SurfaceId,
innershadows_surface_id: SurfaceId, innershadows_surface_id: SurfaceId,
@ -574,49 +598,59 @@ impl RenderState {
let antialias = shape.should_use_antialias(self.get_scale()); let antialias = shape.should_use_antialias(self.get_scale());
// set clipping // set clipping
if let Some((bounds, corners, transform)) = clip_bounds { if let Some(clips) = clip_bounds.as_ref() {
self.surfaces.apply_mut(surface_ids, |s| { for (bounds, corners, transform) in clips.iter() {
s.canvas().concat(&transform); 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| { self.surfaces.apply_mut(surface_ids, |s| {
s.canvas() s.canvas()
.clip_rrect(rrect, skia::ClipOp::Intersect, antialias); .concat(&transform.invert().unwrap_or(Matrix::default()));
});
} 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);
}
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 // We don't want to change the value in the global state
let mut shape: Cow<Shape> = Cow::Borrowed(shape); 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) { if let Some(blur) = self.combined_layer_blur(shape.blur) {
shape.to_mut().set_blur(Some(blur)); shape.to_mut().set_blur(Some(blur));
} }
} else if shape_has_blur {
shape.to_mut().set_blur(None);
} }
let center = shape.center(); let center = shape.center();
@ -1064,6 +1098,14 @@ impl RenderState {
paint.set_blend_mode(element.blend_mode().into()); paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity()); 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 // 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. // called 'destination-in' that keeps the drawn content within the mask.
// @see https://skia.org/docs/user/api/skblendmode_overview/ // @see https://skia.org/docs/user/api/skblendmode_overview/
@ -1228,7 +1270,7 @@ impl RenderState {
shape: &Shape, shape: &Shape,
shape_bounds: &Rect, shape_bounds: &Rect,
shadow: &Shadow, shadow: &Shadow,
clip_bounds: Option<(Rect, Option<Corners>, Matrix)>, clip_bounds: Option<ClipStack>,
scale: f32, scale: f32,
translation: (f32, f32), translation: (f32, f32),
extra_layer_blur: Option<Blur>, extra_layer_blur: Option<Blur>,
@ -1373,13 +1415,11 @@ impl RenderState {
let mut is_empty = true; let mut is_empty = true;
while let Some(node_render_state) = self.pending_nodes.pop() { while let Some(node_render_state) = self.pending_nodes.pop() {
let NodeRenderState { let node_id = node_render_state.id;
id: node_id, let visited_children = node_render_state.visited_children;
visited_children, let visited_mask = node_render_state.visited_mask;
clip_bounds, let mask = node_render_state.mask;
visited_mask, let clip_bounds = node_render_state.clip_bounds.clone();
mask,
} = node_render_state;
is_empty = false; is_empty = false;
@ -1462,7 +1502,7 @@ impl RenderState {
element, element,
&element.extrect(tree, scale), &element.extrect(tree, scale),
shadow, shadow,
clip_bounds, clip_bounds.clone(),
scale, scale,
translation, translation,
None, 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 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).save();
self.surfaces for (bounds, corners, transform) in clips.iter() {
.canvas(SurfaceId::Current) let mut total_matrix = Matrix::new_identity();
.concat(&total_matrix); 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 { self.surfaces
let rrect = RRect::new_rect_radii(*bounds, corners); .canvas(SurfaceId::Current)
self.surfaces.canvas(SurfaceId::Current).clip_rrect( .concat(&total_matrix);
rrect,
skia::ClipOp::Intersect, if let Some(corners) = corners {
antialias, let rrect = RRect::new_rect_radii(*bounds, corners);
); self.surfaces.canvas(SurfaceId::Current).clip_rrect(
} else { rrect,
self.surfaces.canvas(SurfaceId::Current).clip_rect( skia::ClipOp::Intersect,
*bounds, antialias,
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 self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None); .draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
@ -1596,7 +1639,7 @@ impl RenderState {
self.render_shape( self.render_shape(
element, element,
clip_bounds, clip_bounds.clone(),
SurfaceId::Fills, SurfaceId::Fills,
SurfaceId::Strokes, SurfaceId::Strokes,
SurfaceId::InnerShadows, SurfaceId::InnerShadows,
@ -1614,6 +1657,9 @@ impl RenderState {
} }
match element.shape_type { match element.shape_type {
Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => {
self.nested_blurs.push(None);
}
Type::Frame(_) | Type::Group(_) => { Type::Frame(_) | Type::Group(_) => {
self.nested_blurs.push(element.blur); self.nested_blurs.push(element.blur);
} }
@ -1624,7 +1670,7 @@ impl RenderState {
self.pending_nodes.push(NodeRenderState { self.pending_nodes.push(NodeRenderState {
id: node_id, id: node_id,
visited_children: true, visited_children: true,
clip_bounds, clip_bounds: clip_bounds.clone(),
visited_mask: false, visited_mask: false,
mask, mask,
}); });
@ -1651,7 +1697,7 @@ impl RenderState {
self.pending_nodes.push(NodeRenderState { self.pending_nodes.push(NodeRenderState {
id: **child_id, id: **child_id,
visited_children: false, visited_children: false,
clip_bounds: children_clip_bounds, clip_bounds: children_clip_bounds.clone(),
visited_mask: false, visited_mask: false,
mask: false, mask: false,
}); });

View File

@ -885,19 +885,50 @@ impl Shape {
scale: f32, scale: f32,
) -> Bounds { ) -> Bounds {
let mut rect = bounds.to_rect(); 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 { match self.shape_type {
for child_id in self.children_ids_iter(false) { Type::Group(Group { masked: true }) => {
if let Some(child_shape) = shapes_pool.get(child_id) { let mut mask_rect: Option<math::Rect> = None;
let child_extrect = child_shape.calculate_extrect(shapes_pool, scale); let mut content_rect: Option<math::Rect> = None;
rect.join(child_extrect);
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) Bounds::from_rect(&rect)
@ -1426,6 +1457,7 @@ impl Shape {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::state::ShapesPool;
fn any_shape() -> Shape { fn any_shape() -> Shape {
Shape::new(Uuid::nil()) Shape::new(Uuid::nil())
@ -1485,4 +1517,42 @@ mod tests {
assert_eq!(shape.selrect().width(), 20.0); assert_eq!(shape.selrect().width(), 20.0);
assert_eq!(shape.selrect().height(), 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);
}
} }

View File

@ -292,7 +292,7 @@ pub extern "C" fn set_shape_text_content() {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
let raw_text_data = RawParagraph::try_from(&bytes).unwrap(); 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); println!("Error with set_shape_text_content on {:?}", shape.id);
} }
}); });