mirror of
https://github.com/penpot/penpot.git
synced 2026-02-24 18:56:28 -05:00
Compare commits
16 Commits
eva-refact
...
ladybenko-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1495240914 | ||
|
|
2562d31fa3 | ||
|
|
c58054d19c | ||
|
|
c7f644ab2a | ||
|
|
cb5cacbcee | ||
|
|
337cfc2d3e | ||
|
|
c2ee31e791 | ||
|
|
360937f613 | ||
|
|
f6d0414449 | ||
|
|
4d05827fa9 | ||
|
|
48fb9fa6ea | ||
|
|
c74cf3fa37 | ||
|
|
7c1ddd3d7d | ||
|
|
4965f6d859 | ||
|
|
a3cd90da7f | ||
|
|
30106f8524 |
@@ -605,31 +605,31 @@
|
||||
add-undo-change-shape
|
||||
(fn [change-set id]
|
||||
(let [shape (get objects id)]
|
||||
(conj
|
||||
change-set
|
||||
{:type :add-obj
|
||||
:id id
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:frame-id (:frame-id shape)
|
||||
:index (cfh/get-position-on-parent objects id)
|
||||
:obj (cond-> shape
|
||||
(contains? shape :shapes)
|
||||
(assoc :shapes []))})))
|
||||
(cond-> change-set
|
||||
(some? shape)
|
||||
(conj {:type :add-obj
|
||||
:id id
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:frame-id (:frame-id shape)
|
||||
:index (cfh/get-position-on-parent objects id)
|
||||
:obj (cond-> shape
|
||||
(contains? shape :shapes)
|
||||
(assoc :shapes []))}))))
|
||||
|
||||
add-undo-change-parent
|
||||
(fn [change-set id]
|
||||
(let [shape (get objects id)
|
||||
prev-sibling (cfh/get-prev-sibling objects (:id shape))]
|
||||
(conj
|
||||
change-set
|
||||
{:type :mov-objects
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:shapes [id]
|
||||
:after-shape prev-sibling
|
||||
:index 0
|
||||
:ignore-touched true})))]
|
||||
(cond-> change-set
|
||||
(some? shape)
|
||||
(conj {:type :mov-objects
|
||||
:page-id page-id
|
||||
:parent-id (:parent-id shape)
|
||||
:shapes [id]
|
||||
:after-shape prev-sibling
|
||||
:index 0
|
||||
:ignore-touched true}))))]
|
||||
|
||||
(-> changes
|
||||
(update :redo-changes #(reduce add-redo-change % ids))
|
||||
@@ -1150,3 +1150,24 @@
|
||||
[changes]
|
||||
(::page-id (meta changes)))
|
||||
|
||||
|
||||
(defn set-text-content
|
||||
[changes id content prev-content]
|
||||
(assert-page-id! changes)
|
||||
(let [page-id (::page-id (meta changes))
|
||||
|
||||
redo-change
|
||||
{:type :mod-obj
|
||||
:page-id page-id
|
||||
:id id
|
||||
:operations [{:type :set :attr :content :val content}]}
|
||||
|
||||
undo-change
|
||||
{:type :mod-obj
|
||||
:page-id page-id
|
||||
:id id
|
||||
:operations [{:type :set :attr :content :val prev-content}]}]
|
||||
|
||||
(-> changes
|
||||
(update :redo-changes conj redo-change)
|
||||
(update :undo-changes conj undo-change))))
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
[
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"variants/v1",
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/pointer-map",
|
||||
"fdata/objects-map",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
]
|
||||
},
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true
|
||||
},
|
||||
"~:name": "Default",
|
||||
"~:modified-at": "~m1713533116375",
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38e630f7920a",
|
||||
"~:created-at": "~m1713533116375",
|
||||
"~:is-default": true
|
||||
}
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"~:email": "foo@example.com",
|
||||
"~:is-demo": false,
|
||||
"~:auth-backend": "penpot",
|
||||
"~:fullname": "Princesa Leia",
|
||||
"~:modified-at": "~m1713533116365",
|
||||
"~:is-active": true,
|
||||
"~:default-project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
|
||||
"~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
|
||||
"~:is-muted": false,
|
||||
"~:default-team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d",
|
||||
"~:created-at": "~m1713533116365",
|
||||
"~:is-blocked": false,
|
||||
"~:props": {
|
||||
"~:nudge": {
|
||||
"~:big": 10,
|
||||
"~:small": 1
|
||||
},
|
||||
"~:v2-info-shown": true,
|
||||
"~:viewed-tutorial?": false,
|
||||
"~:viewed-walkthrough?": false,
|
||||
"~:onboarding-viewed": true,
|
||||
"~:builtin-templates-collapsed-status":
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
export class BasePage {
|
||||
static async init(page) {
|
||||
await BasePage.mockConfigFlags(page, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks multiple RPC calls in a single call.
|
||||
*
|
||||
|
||||
@@ -2,13 +2,8 @@ import { MockWebSocketHelper } from "../../helpers/MockWebSocketHelper";
|
||||
import BasePage from "./BasePage";
|
||||
|
||||
export class BaseWebSocketPage extends BasePage {
|
||||
/**
|
||||
* This should be called on `test.beforeEach`.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @returns
|
||||
*/
|
||||
static async initWebSockets(page) {
|
||||
static async init(page) {
|
||||
await super.init(page);
|
||||
await MockWebSocketHelper.init(page);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,54 +3,62 @@ import { BaseWebSocketPage } from "./BaseWebSocketPage";
|
||||
|
||||
export class DashboardPage extends BaseWebSocketPage {
|
||||
static async init(page) {
|
||||
await BaseWebSocketPage.initWebSockets(page);
|
||||
await super.init(page);
|
||||
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
await super.mockConfigFlags(page, ["disable-onboarding"]);
|
||||
|
||||
await super.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
"logged-in-user/get-teams-default.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
await super.mockRPC(
|
||||
page,
|
||||
"get-font-variants?team-id=*",
|
||||
"workspace/get-font-variants-empty.json",
|
||||
);
|
||||
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
await super.mockRPC(
|
||||
page,
|
||||
"get-projects?team-id=*",
|
||||
"logged-in-user/get-projects-default.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
await super.mockRPC(
|
||||
page,
|
||||
"get-team-members?team-id=*",
|
||||
"logged-in-user/get-team-members-your-penpot.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
await super.mockRPC(
|
||||
page,
|
||||
"get-team-users?team-id=*",
|
||||
"logged-in-user/get-team-users-single-user.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
await super.mockRPC(
|
||||
page,
|
||||
"get-unread-comment-threads?team-id=*",
|
||||
"logged-in-user/get-team-users-single-user.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
await super.mockRPC(
|
||||
page,
|
||||
"get-team-recent-files?team-id=*",
|
||||
"logged-in-user/get-team-recent-files-empty.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
await super.mockRPC(
|
||||
page,
|
||||
"get-profiles-for-file-comments",
|
||||
"workspace/get-profile-for-file-comments.json",
|
||||
);
|
||||
await BaseWebSocketPage.mockRPC(
|
||||
await super.mockRPC(
|
||||
page,
|
||||
"get-builtin-templates",
|
||||
"logged-in-user/get-built-in-templates-empty.json",
|
||||
);
|
||||
|
||||
await super.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in.json",
|
||||
);
|
||||
}
|
||||
|
||||
static anyTeamId = "c7ce0794-0992-8105-8004-38e630f40f6d";
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { BasePage } from "./BasePage";
|
||||
|
||||
export class LoginPage extends BasePage {
|
||||
static async init(page) {
|
||||
await super.init(page);
|
||||
}
|
||||
|
||||
constructor(page) {
|
||||
super(page);
|
||||
this.loginButton = page.getByRole("button", { name: "Continue" });
|
||||
|
||||
@@ -29,8 +29,13 @@ export class RegisterPage extends BasePage {
|
||||
);
|
||||
}
|
||||
|
||||
static async init(page) {
|
||||
await BasePage.init(page);
|
||||
}
|
||||
|
||||
static async initWithLoggedOutUser(page) {
|
||||
await this.mockRPC(page, "get-profile", "get-profile-anonymous.json");
|
||||
await BasePage.init(page);
|
||||
await BasePage.mockRPC(page, "get-profile", "get-profile-anonymous.json");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { DashboardPage } from "./DashboardPage";
|
||||
|
||||
export class SubscriptionProfilePage extends DashboardPage {
|
||||
static async init(page) {
|
||||
await DashboardPage.initWebSockets(page);
|
||||
await super.init(page);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
await super.mockRPC(
|
||||
page,
|
||||
"get-subscription-usage",
|
||||
"subscription/get-subscription-usage.json",
|
||||
|
||||
@@ -4,16 +4,6 @@ export class ViewerPage extends BaseWebSocketPage {
|
||||
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
|
||||
static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a";
|
||||
|
||||
/**
|
||||
* This should be called on `test.beforeEach`.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @returns
|
||||
*/
|
||||
static async init(page) {
|
||||
await BaseWebSocketPage.initWebSockets(page);
|
||||
}
|
||||
|
||||
async setupLoggedInUser() {
|
||||
await this.mockRPC(
|
||||
"get-profile",
|
||||
|
||||
@@ -45,24 +45,27 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
return this.waitForEditor();
|
||||
}
|
||||
|
||||
stopEditing() {
|
||||
return this.page.keyboard.press("Escape");
|
||||
async stopEditing() {
|
||||
await this.page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async moveToLeft(amount = 0) {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
await this.page.keyboard.press("ArrowLeft");
|
||||
}
|
||||
await this.waitForIdle();
|
||||
}
|
||||
|
||||
async moveToRight(amount = 0) {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
await this.page.keyboard.press("ArrowRight");
|
||||
}
|
||||
await this.waitForIdle();
|
||||
}
|
||||
|
||||
async moveFromStart(offset = 0) {
|
||||
await this.page.keyboard.press("Home");
|
||||
await this.waitForIdle();
|
||||
await this.moveToRight(offset);
|
||||
}
|
||||
|
||||
@@ -103,6 +106,10 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
changeLetterSpacing(newValue) {
|
||||
return this.changeNumericInput(this.letterSpacing, newValue);
|
||||
}
|
||||
|
||||
async waitForIdle() {
|
||||
await this.page.evaluate(() => new Promise((resolve) => globalThis.requestIdleCallback(resolve)));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -112,9 +119,9 @@ export class WorkspacePage extends BaseWebSocketPage {
|
||||
* @returns
|
||||
*/
|
||||
static async init(page) {
|
||||
await BaseWebSocketPage.initWebSockets(page);
|
||||
await super.init(page);
|
||||
|
||||
await BaseWebSocketPage.mockRPCs(page, {
|
||||
await super.mockRPCs(page, {
|
||||
"get-profile": "logged-in-user/get-profile-logged-in.json",
|
||||
"get-team-users?file-id=*":
|
||||
"logged-in-user/get-team-users-single-user.json",
|
||||
|
||||
@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await DashboardPage.init(page);
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in-no-onboarding.json",
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("Dashboard Deleted Page", () => {
|
||||
|
||||
@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await DashboardPage.init(page);
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in-no-onboarding.json",
|
||||
);
|
||||
});
|
||||
|
||||
test("BUG 10421 - Fix libraries context menu", async ({ page }) => {
|
||||
|
||||
@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await DashboardPage.init(page);
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in-no-onboarding.json",
|
||||
);
|
||||
});
|
||||
|
||||
test("BUG 12359 - Selected invitations count is not pluralized", async ({
|
||||
|
||||
@@ -3,11 +3,7 @@ import DashboardPage from "../pages/DashboardPage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await DashboardPage.init(page);
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in-no-onboarding.json",
|
||||
);
|
||||
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-teams",
|
||||
|
||||
@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await DashboardPage.init(page);
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in-no-onboarding.json",
|
||||
);
|
||||
});
|
||||
|
||||
test("Dashboad page has title ", async ({ page }) => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { test, expect } from "@playwright/test";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await LoginPage.init(page);
|
||||
|
||||
const login = new LoginPage(page);
|
||||
await login.initWithLoggedOutUser();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import OnboardingPage from "../pages/OnboardingPage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await DashboardPage.init(page);
|
||||
await DashboardPage.mockConfigFlags(page, ["enable-onboarding"]);
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
|
||||
@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await DashboardPage.init(page);
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in-no-onboarding.json",
|
||||
);
|
||||
});
|
||||
|
||||
test("Navigate to penpot changelog from profile menu", async ({ page }) => {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { test, expect } from "@playwright/test";
|
||||
import { Clipboard } from "../../helpers/Clipboard";
|
||||
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
|
||||
|
||||
const timeToWait = 100;
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await Clipboard.enable(context, Clipboard.Permission.ALL);
|
||||
|
||||
@@ -37,11 +35,13 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.moveButton.click();
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
await workspace.clickAt(190, 150);
|
||||
await workspace.paste("keyboard");
|
||||
|
||||
await workspace.textEditor.stopEditing();
|
||||
|
||||
await expect(workspace.layers.getByText(textToPaste)).toBeVisible();
|
||||
@@ -57,6 +57,7 @@ test("Create a new text shape from pasting text using context menu", async ({
|
||||
});
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.goToWorkspace();
|
||||
await workspace.moveButton.click();
|
||||
|
||||
await Clipboard.writeText(page, textToPaste);
|
||||
|
||||
@@ -138,7 +139,7 @@ test("Update a new text shape appending text by pasting text", async ({
|
||||
await workspace.paste("keyboard");
|
||||
await workspace.textEditor.stopEditing();
|
||||
await workspace.waitForSelectedShapeName("Lorem ipsum dolor sit amet");
|
||||
});
|
||||
});
|
||||
|
||||
test.skip("Update a new text shape prepending text by pasting text", async ({
|
||||
page,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
|
||||
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
|
||||
import { BaseWebSocketPage } from "../pages/BaseWebSocketPage";
|
||||
import { Clipboard } from "../../helpers/Clipboard";
|
||||
|
||||
@@ -7,7 +7,7 @@ test.beforeEach(async ({ page, context }) => {
|
||||
await Clipboard.enable(context, Clipboard.Permission.ALL);
|
||||
|
||||
await WasmWorkspacePage.init(page);
|
||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-variants.json");
|
||||
await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-variants"]);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ context }) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
|
||||
import { presenceFixture } from "../../data/workspace/ws-notifications";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WasmWorkspacePage.init(page);
|
||||
@@ -106,7 +105,7 @@ test("BUG 11006 - Fix history panel shortcut", async ({ page }) => {
|
||||
|
||||
await workspacePage.goToWorkspace();
|
||||
|
||||
await page.keyboard.press("Control+Alt+h");
|
||||
await page.keyboard.press("ControlOrMeta+Alt+h");
|
||||
|
||||
await expect(
|
||||
workspacePage.rightSidebar.getByText("There are no versions yet"),
|
||||
|
||||
@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await DashboardPage.init(page);
|
||||
await DashboardPage.mockRPC(
|
||||
page,
|
||||
"get-profile",
|
||||
"logged-in-user/get-profile-logged-in-no-onboarding.json",
|
||||
);
|
||||
});
|
||||
|
||||
test("User goes to an empty dashboard", async ({ page }) => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { test, expect } from "@playwright/test";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await LoginPage.init(page);
|
||||
|
||||
const login = new LoginPage(page);
|
||||
await login.initWithLoggedOutUser();
|
||||
await login.page.goto("/#/auth/login");
|
||||
|
||||
@@ -197,11 +197,12 @@
|
||||
objects (:objects page)
|
||||
|
||||
undo-id (or (:undo-id options) (js/Symbol))
|
||||
[all-parents changes] (-> (pcb/empty-changes it (:id page))
|
||||
(cls/generate-delete-shapes fdata page objects ids
|
||||
{:ignore-touched (:allow-altering-copies options)
|
||||
:undo-group (:undo-group options)
|
||||
:undo-id undo-id}))]
|
||||
[all-parents changes]
|
||||
(-> (pcb/empty-changes it (:id page))
|
||||
(cls/generate-delete-shapes fdata page objects ids
|
||||
{:ignore-touched (:allow-altering-copies options)
|
||||
:undo-group (:undo-group options)
|
||||
:undo-id undo-id}))]
|
||||
|
||||
(rx/of (dwu/start-undo-transaction undo-id)
|
||||
(dc/detach-comment-thread ids)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[app.common.attrs :as attrs]
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.changes-builder :as pcb]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
@@ -19,6 +20,7 @@
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.common :as dwc]
|
||||
@@ -916,11 +918,11 @@
|
||||
(update-in state [:workspace-text-modifier shape-id] {:position-data position-data}))))
|
||||
|
||||
(defn v2-update-text-shape-content
|
||||
[id content & {:keys [update-name? name finalize? save-undo?]
|
||||
:or {update-name? false name nil finalize? false save-undo? true}}]
|
||||
[id content & {:keys [update-name? name finalize? save-undo? original-content]
|
||||
:or {update-name? false name nil finalize? false save-undo? true original-content nil}}]
|
||||
(ptk/reify ::v2-update-text-shape-content
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(watch [it state _]
|
||||
(if (features/active-feature? state "render-wasm/v1")
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
@@ -950,11 +952,11 @@
|
||||
new-shape))
|
||||
{:save-undo? save-undo? :undo-group (when new-shape? id)})
|
||||
|
||||
(let [modifiers (dwwt/resize-wasm-text-modifiers shape content)
|
||||
options {:undo-group (when new-shape? id)}]
|
||||
(if (and (not= :fixed (:grow-type shape)) finalize?)
|
||||
(dwm/apply-wasm-modifiers modifiers options)
|
||||
(dwm/set-wasm-modifiers modifiers options))))
|
||||
(when-let [modifiers (dwwt/resize-wasm-text-modifiers shape content)]
|
||||
(let [options {:undo-group (when new-shape? id)}]
|
||||
(if (and (not= :fixed (:grow-type shape)) finalize?)
|
||||
(dwm/apply-wasm-modifiers modifiers options)
|
||||
(dwm/set-wasm-modifiers modifiers options)))))
|
||||
|
||||
(when finalize?
|
||||
(rx/concat
|
||||
@@ -970,7 +972,13 @@
|
||||
{:save-undo? false}))
|
||||
(dws/deselect-shape id)
|
||||
(dwsh/delete-shapes #{id})))
|
||||
(rx/of (dwt/finish-transform))))))
|
||||
(rx/of
|
||||
;; This commit is necesary for undo and component propagation
|
||||
;; on finalization
|
||||
(dch/commit-changes
|
||||
(-> (pcb/empty-changes it (:current-page-id state))
|
||||
(pcb/set-text-content id content original-content)))
|
||||
(dwt/finish-transform))))))
|
||||
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
|
||||
@@ -27,27 +27,28 @@
|
||||
(resize-wasm-text-modifiers shape (:content shape)))
|
||||
|
||||
([{:keys [id points selrect grow-type] :as shape} content]
|
||||
(wasm.api/use-shape id)
|
||||
(wasm.api/set-shape-text-content id content)
|
||||
(wasm.api/set-shape-text-images id content)
|
||||
(when id
|
||||
(wasm.api/use-shape id)
|
||||
(wasm.api/set-shape-text-content id content)
|
||||
(wasm.api/set-shape-text-images id content)
|
||||
|
||||
(let [dimension (wasm.api/get-text-dimensions)
|
||||
width-scale (if (#{:fixed :auto-height} grow-type)
|
||||
1.0
|
||||
(/ (:width dimension) (:width selrect)))
|
||||
height-scale (if (= :fixed grow-type)
|
||||
1.0
|
||||
(/ (:height dimension) (:height selrect)))
|
||||
resize-v (gpt/point width-scale height-scale)
|
||||
origin (first points)]
|
||||
(let [dimension (wasm.api/get-text-dimensions)
|
||||
width-scale (if (#{:fixed :auto-height} grow-type)
|
||||
1.0
|
||||
(/ (:width dimension) (:width selrect)))
|
||||
height-scale (if (= :fixed grow-type)
|
||||
1.0
|
||||
(/ (:height dimension) (:height selrect)))
|
||||
resize-v (gpt/point width-scale height-scale)
|
||||
origin (first points)]
|
||||
|
||||
{id
|
||||
{:modifiers
|
||||
(ctm/resize-modifiers
|
||||
resize-v
|
||||
origin
|
||||
(:transform shape (gmt/matrix))
|
||||
(:transform-inverse shape (gmt/matrix)))}})))
|
||||
{id
|
||||
{:modifiers
|
||||
(ctm/resize-modifiers
|
||||
resize-v
|
||||
origin
|
||||
(:transform shape (gmt/matrix))
|
||||
(:transform-inverse shape (gmt/matrix)))}}))))
|
||||
|
||||
(defn resize-wasm-text
|
||||
"Resize a single text shape (auto-width/auto-height) by id.
|
||||
|
||||
@@ -116,6 +116,17 @@
|
||||
(ex/print-throwable cause :prefix "Unexpected Error")
|
||||
(show-not-blocking-error cause))))
|
||||
|
||||
(defmethod ptk/handle-error :wasm-non-blocking
|
||||
[error]
|
||||
(when-let [cause (::instance error)]
|
||||
(show-not-blocking-error cause)))
|
||||
|
||||
(defmethod ptk/handle-error :wasm-critical
|
||||
[error]
|
||||
(when-let [cause (::instance error)]
|
||||
(ex/print-throwable cause :prefix "WASM critical error"))
|
||||
(st/emit! (rt/assign-exception error)))
|
||||
|
||||
;; We receive a explicit authentication error; If the uri is for
|
||||
;; workspace, dashboard, viewer or settings, then assign the exception
|
||||
;; for show the error page. Otherwise this explicitly clears all
|
||||
@@ -327,20 +338,24 @@
|
||||
(str/starts-with? message "invalid props on component")
|
||||
(str/starts-with? message "Unexpected token "))))
|
||||
|
||||
(handle-uncaught [cause]
|
||||
(when cause
|
||||
(set! last-exception cause)
|
||||
(let [data (ex-data cause)
|
||||
type (get data :type)]
|
||||
(if (#{:wasm-critical :wasm-non-blocking} type)
|
||||
(on-error cause)
|
||||
(when-not (is-ignorable-exception? cause)
|
||||
(ex/print-throwable cause :prefix "Uncaught Exception")
|
||||
(ts/schedule #(show-not-blocking-error cause)))))))
|
||||
|
||||
(on-unhandled-error [event]
|
||||
(.preventDefault ^js event)
|
||||
(when-let [cause (unchecked-get event "error")]
|
||||
(set! last-exception cause)
|
||||
(when-not (is-ignorable-exception? cause)
|
||||
(ex/print-throwable cause :prefix "Uncaught Exception")
|
||||
(ts/schedule #(show-not-blocking-error cause)))))
|
||||
(handle-uncaught (unchecked-get event "error")))
|
||||
|
||||
(on-unhandled-rejection [event]
|
||||
(.preventDefault ^js event)
|
||||
(when-let [cause (unchecked-get event "reason")]
|
||||
(set! last-exception cause)
|
||||
(ex/print-throwable cause :prefix "Uncaught Rejection")
|
||||
(ts/schedule #(show-not-blocking-error cause))))]
|
||||
(handle-uncaught (unchecked-get event "reason")))]
|
||||
|
||||
(.addEventListener g/window "error" on-unhandled-error)
|
||||
(.addEventListener g/window "unhandledrejection" on-unhandled-rejection)
|
||||
|
||||
@@ -118,7 +118,8 @@
|
||||
:update-name? update-name?
|
||||
:name generated-name
|
||||
:finalize? true
|
||||
:save-undo? false))))
|
||||
:save-undo? false
|
||||
:original-content original-content))))
|
||||
|
||||
(let [container-node (mf/ref-val container-ref)]
|
||||
(dom/set-style! container-node "opacity" 0)))
|
||||
|
||||
@@ -540,7 +540,7 @@
|
||||
[:values schema:layout-item-props-schema]
|
||||
[:applied-tokens [:maybe [:map-of :keyword :string]]]
|
||||
[:ids [::sm/vec ::sm/uuid]]
|
||||
[:v-sizing {:optional true} [:maybe [:= :fill]]]])
|
||||
[:v-sizing {:optional true} [:maybe [:enum :fill :fix :auto]]]])
|
||||
|
||||
(mf/defc layout-size-constraints*
|
||||
{::mf/private true
|
||||
|
||||
@@ -224,8 +224,9 @@
|
||||
show-gradient-handlers? (= (count selected) 1)
|
||||
show-grids? (contains? layout :display-guides)
|
||||
|
||||
show-frame-outline? (= transform :move)
|
||||
show-frame-outline? (and (= transform :move) (not panning))
|
||||
show-outlines? (and (nil? transform)
|
||||
(not panning)
|
||||
(not edition)
|
||||
(not drawing-obj)
|
||||
(not (#{:comments :path :curve} drawing-tool)))
|
||||
@@ -561,7 +562,7 @@
|
||||
:shift? @shift?}])
|
||||
|
||||
[:> widgets/frame-titles*
|
||||
{:objects (with-meta objects-modified nil)
|
||||
{:objects objects-modified
|
||||
:selected selected
|
||||
:zoom zoom
|
||||
:is-show-artboard-names show-artboard-names?
|
||||
|
||||
@@ -1368,8 +1368,10 @@
|
||||
([base-objects zoom vbox background callback]
|
||||
(let [rgba (sr-clr/hex->u32argb background 1)
|
||||
shapes (into [] (vals base-objects))
|
||||
total-shapes (count shapes)]
|
||||
(h/call wasm/internal-module "_set_canvas_background" rgba)
|
||||
total-shapes (count shapes)
|
||||
result (h/call wasm/internal-module "_set_canvas_background" rgba)]
|
||||
(println "set-canvas-background result:" result)
|
||||
;; (h/call wasm/internal-module "_set_canvas_background" rgba)
|
||||
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
|
||||
(h/call wasm/internal-module "_init_shapes_pool" total-shapes)
|
||||
(set-objects base-objects callback))))
|
||||
|
||||
@@ -7,11 +7,28 @@
|
||||
(ns app.render-wasm.helpers
|
||||
#?(:cljs (:require-macros [app.render-wasm.helpers])))
|
||||
|
||||
(def ^:export error-code
|
||||
"WASM error code constants (must match render-wasm/src/error.rs and mem.rs)."
|
||||
{0x01 :wasm-non-blocking 0x02 :wasm-critical})
|
||||
|
||||
(defmacro call
|
||||
"A helper for easy call wasm defined function in a module."
|
||||
"A helper for easy call wasm defined function in a module.
|
||||
Catches any exception thrown by the WASM function, reads the error code from
|
||||
WASM when available, and rethrows ex-info with :type (:wasm-non-blocking or
|
||||
:wasm-critical) in ex-data. The uncaught-error-handler in app.main.errors
|
||||
routes these to on-error so critical shows Internal Error, non-blocking shows toast."
|
||||
[module name & params]
|
||||
(let [fn-sym (with-meta (gensym "fn-") {:tag 'function})]
|
||||
(let [fn-sym (with-meta (gensym "fn-") {:tag 'function})
|
||||
e-sym (gensym "e")
|
||||
code-sym (gensym "code")]
|
||||
`(let [~fn-sym (cljs.core/unchecked-get ~module ~name)]
|
||||
;; DEBUG
|
||||
;; (println "##" ~name)
|
||||
(~fn-sym ~@params))))
|
||||
(try
|
||||
(~fn-sym ~@params)
|
||||
(catch :default ~e-sym
|
||||
(let [read-code# (cljs.core/unchecked-get ~module "_read_error_code")
|
||||
~code-sym (when read-code# (read-code#))
|
||||
type# (or (get app.render-wasm.helpers/error-code ~code-sym) :wasm-critical)
|
||||
ex# (ex-info (str "WASM error (type: " type# ")")
|
||||
{:fn ~name :type type# :message (.-message ~e-sym) :error-code ~code-sym}
|
||||
~e-sym)]
|
||||
(throw ex#)))))))
|
||||
|
||||
30
render-wasm/Cargo.lock
generated
30
render-wasm/Cargo.lock
generated
@@ -17,6 +17,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -303,6 +309,8 @@ name = "macros"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
@@ -425,6 +433,7 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
name = "render"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bezier-rs",
|
||||
"gl",
|
||||
@@ -432,6 +441,7 @@ dependencies = [
|
||||
"indexmap",
|
||||
"macros",
|
||||
"skia-safe",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -577,6 +587,26 @@ dependencies = [
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.19"
|
||||
|
||||
@@ -19,6 +19,7 @@ name = "render_wasm"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
base64 = "0.22.1"
|
||||
bezier-rs = "0.4.0"
|
||||
gl = "0.14.0"
|
||||
@@ -32,6 +33,7 @@ skia-safe = { version = "0.87.0", default-features = false, features = [
|
||||
"binary-cache",
|
||||
"webp",
|
||||
] }
|
||||
thiserror = "2.0.18"
|
||||
uuid = { version = "1.11.0", features = ["v4", "js"] }
|
||||
|
||||
[profile.release]
|
||||
|
||||
2
render-wasm/macros/Cargo.lock
generated
2
render-wasm/macros/Cargo.lock
generated
@@ -13,6 +13,8 @@ name = "macros"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
[package]
|
||||
name = "macros"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
heck = "0.5.0"
|
||||
proc-macro2 = "1.0"
|
||||
quote = "1.0"
|
||||
syn = "2.0.106"
|
||||
|
||||
@@ -6,9 +6,149 @@ use std::sync;
|
||||
|
||||
use heck::{ToKebabCase, ToPascalCase};
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{parse_macro_input, Block, GenericArgument, ItemFn, ReturnType, Type};
|
||||
|
||||
type Result<T> = std::result::Result<T, String>;
|
||||
|
||||
/// If the type is std::result::Result<T, E> or core::result::Result<T, E>, returns Some((T, E)).
|
||||
/// Otherwise None. The error type E is required to implement std::error::Error and Into<u8>.
|
||||
fn std_result_types(ty: &Type) -> Option<(&Type, &Type)> {
|
||||
let path = match ty {
|
||||
Type::Path(tp) => &tp.path,
|
||||
_ => return None,
|
||||
};
|
||||
let segs: Vec<_> = path.segments.iter().collect();
|
||||
if segs.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let (first, second, last) = (
|
||||
segs[0].ident.to_string(),
|
||||
segs[1].ident.to_string(),
|
||||
&segs[segs.len() - 1],
|
||||
);
|
||||
if last.ident != "Result" {
|
||||
return None;
|
||||
}
|
||||
let ok_std = first == "std" && second == "result";
|
||||
let ok_core = first == "core" && second == "result";
|
||||
if !ok_std && !ok_core {
|
||||
return None;
|
||||
}
|
||||
let args = match &last.arguments {
|
||||
syn::PathArguments::AngleBracketed(a) => &a.args,
|
||||
_ => return None,
|
||||
};
|
||||
if args.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
match (&args[0], &args[1]) {
|
||||
(GenericArgument::Type(t), GenericArgument::Type(e)) => Some((t, e)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// If the type is crate::error::Result<T> or a single-segment Result<T> (e.g. with
|
||||
/// `use crate::error::Result`), returns Some(T). Otherwise None.
|
||||
fn crate_error_result_inner_type(ty: &Type) -> Option<&Type> {
|
||||
let path = match ty {
|
||||
Type::Path(tp) => &tp.path,
|
||||
_ => return None,
|
||||
};
|
||||
let segs: Vec<_> = path.segments.iter().collect();
|
||||
let last = path.segments.last()?;
|
||||
if last.ident != "Result" {
|
||||
return None;
|
||||
}
|
||||
let args = match &last.arguments {
|
||||
syn::PathArguments::AngleBracketed(a) => &a.args,
|
||||
_ => return None,
|
||||
};
|
||||
if args.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
// Accept crate::error::Result<T> or bare Result<T> (from use)
|
||||
let ok = segs.len() == 1
|
||||
|| (segs.len() == 3 && segs[0].ident == "crate" && segs[1].ident == "error");
|
||||
if !ok {
|
||||
return None;
|
||||
}
|
||||
match &args[0] {
|
||||
GenericArgument::Type(t) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attribute macro for WASM-exported functions. The function **must** return
|
||||
/// `std::result::Result<T, E>` where T is a C ABI type and E implements
|
||||
/// `std::error::Error` and `Into<u8>`. The macro:
|
||||
/// - Clears the error code at entry.
|
||||
/// - Runs the body in `std::panic::catch_unwind`.
|
||||
/// - Unwraps the Result: `Ok(x)` → return x; `Err(e)` → set error code in memory and panic
|
||||
/// (so ClojureScript can catch the exception and read the code via `read_error_code`).
|
||||
/// - On panic from the body: sets critical error code (0x02) and resumes unwind.
|
||||
#[proc_macro_attribute]
|
||||
pub fn wasm_error(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let mut input = parse_macro_input!(item as ItemFn);
|
||||
let body = (*input.block).clone();
|
||||
|
||||
let (attrs, boxed_ty) = match &input.sig.output {
|
||||
ReturnType::Type(attrs, boxed_ty) => (attrs, boxed_ty),
|
||||
ReturnType::Default => {
|
||||
return quote! {
|
||||
compile_error!(
|
||||
"#[wasm_error] requires the function to return std::result::Result<T, E> where E: std::error::Error + Into<u8>"
|
||||
);
|
||||
}
|
||||
.into();
|
||||
}
|
||||
};
|
||||
|
||||
let (inner_ty, error_ty) = match std_result_types(boxed_ty) {
|
||||
Some((t, e)) => (t, quote!(#e)),
|
||||
None => match crate_error_result_inner_type(boxed_ty) {
|
||||
Some(t) => (t, quote!(crate::error::Error)),
|
||||
None => {
|
||||
return quote! {
|
||||
compile_error!(
|
||||
"#[wasm_error] requires the function to return std::result::Result<T, E> (or core::result::Result<T, E>), \
|
||||
or crate::error::Result<T>. E must implement std::error::Error and Into<u8>. T must be a C ABI type (u32, u8, bool, (), etc.)"
|
||||
);
|
||||
}
|
||||
.into();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let block: Block = syn::parse2(quote! {
|
||||
{
|
||||
mem::clear_error_code();
|
||||
let __wasm_err_result = std::panic::catch_unwind(|| -> std::result::Result<#inner_ty, #error_ty> {
|
||||
#body
|
||||
});
|
||||
match __wasm_err_result {
|
||||
Ok(__inner) => match __inner {
|
||||
Ok(__val) => __val,
|
||||
Err(__e) => {
|
||||
let _: &dyn std::error::Error = &__e;
|
||||
mem::set_error_code(__e.into());
|
||||
panic!("WASM error");
|
||||
}
|
||||
},
|
||||
Err(__payload) => {
|
||||
mem::set_error_code(0x02); // critical, same as Error::Critical
|
||||
std::panic::resume_unwind(__payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("block parse");
|
||||
|
||||
input.sig.output = ReturnType::Type(attrs.clone(), Box::new(inner_ty.clone()));
|
||||
input.block = Box::new(block);
|
||||
quote! { #input }.into()
|
||||
}
|
||||
|
||||
#[proc_macro_derive(ToJs)]
|
||||
pub fn derive_to_cljs(input: TokenStream) -> TokenStream {
|
||||
let input = syn::parse_macro_input!(input as syn::DeriveInput);
|
||||
|
||||
22
render-wasm/src/error.rs
Normal file
22
render-wasm/src/error.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use thiserror::Error;
|
||||
|
||||
// This is not really dead code, #[wasm_error] macro replaces this by something else.
|
||||
#[allow(dead_code)]
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("[Recoverable] {0}")]
|
||||
RecoverableError(anyhow::Error),
|
||||
#[error("[Critical] {0}")]
|
||||
CriticalError(anyhow::Error),
|
||||
}
|
||||
|
||||
impl From<Error> for u8 {
|
||||
fn from(error: Error) -> Self {
|
||||
match error {
|
||||
Error::RecoverableError(_) => 0x01,
|
||||
Error::CriticalError(_) => 0x02,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod emscripten;
|
||||
mod error;
|
||||
mod math;
|
||||
mod mem;
|
||||
mod options;
|
||||
@@ -14,6 +15,9 @@ mod view;
|
||||
mod wapi;
|
||||
mod wasm;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::error::{Error, Result};
|
||||
use macros::wasm_error;
|
||||
use math::{Bounds, Matrix};
|
||||
use mem::SerializableResult;
|
||||
use shapes::{StructureEntry, StructureEntryType, TransformEntry};
|
||||
@@ -103,10 +107,12 @@ pub extern "C" fn init(width: i32, height: i32) {
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn set_browser(browser: u8) {
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_browser(browser: u8) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.set_browser(browser);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
@@ -131,12 +137,14 @@ pub extern "C" fn set_render_options(debug: u32, dpr: f32) {
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn set_canvas_background(raw_color: u32) {
|
||||
pub extern "C" fn set_canvas_background(raw_color: u32) -> u8 {
|
||||
with_state_mut!(state, {
|
||||
let color = skia::Color::new(raw_color);
|
||||
state.set_background_color(color);
|
||||
state.rebuild_tiles_shallow();
|
||||
});
|
||||
|
||||
0x0f
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
@@ -335,11 +343,13 @@ pub extern "C" fn init_shapes_pool(capacity: usize) {
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) {
|
||||
#[wasm_error]
|
||||
pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||
state.use_shape(id);
|
||||
});
|
||||
Err(Error::CriticalError(anyhow::anyhow!("Shape not found")))
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
|
||||
@@ -5,6 +5,24 @@ use std::sync::Mutex;
|
||||
const LAYOUT_ALIGN: usize = 4;
|
||||
|
||||
static BUFFERU8: Mutex<Option<Vec<u8>>> = Mutex::new(None);
|
||||
static BUFFER_ERROR: Mutex<u8> = Mutex::new(0x00);
|
||||
|
||||
pub fn clear_error_code() {
|
||||
let mut guard = BUFFER_ERROR.lock().unwrap();
|
||||
*guard = 0x00;
|
||||
}
|
||||
|
||||
/// Sets the error buffer from a byte. Used by #[wasm_error] when E: Into<u8>.
|
||||
pub fn set_error_code(code: u8) {
|
||||
let mut guard = BUFFER_ERROR.lock().unwrap();
|
||||
*guard = code;
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn read_error_code() -> u8 {
|
||||
let guard = BUFFER_ERROR.lock().unwrap();
|
||||
*guard
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn alloc_bytes(len: usize) -> *mut u8 {
|
||||
|
||||
@@ -39,6 +39,7 @@ pub use images::*;
|
||||
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
|
||||
const MAX_BLOCKING_TIME_MS: i32 = 32;
|
||||
const NODE_BATCH_THRESHOLD: i32 = 3;
|
||||
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0;
|
||||
|
||||
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
||||
|
||||
@@ -787,6 +788,25 @@ impl RenderState {
|
||||
shape.to_mut().set_blur(None);
|
||||
}
|
||||
|
||||
// For non-text, non-SVG shapes in the normal rendering path, apply blur
|
||||
// via a single save_layer on each render surface
|
||||
// Clip correctness is preserved
|
||||
let blur_sigma_for_layers: Option<f32> = if !fast_mode
|
||||
&& apply_to_current_surface
|
||||
&& fills_surface_id == SurfaceId::Fills
|
||||
&& !matches!(shape.shape_type, Type::Text(_))
|
||||
&& !matches!(shape.shape_type, Type::SVGRaw(_))
|
||||
{
|
||||
if let Some(blur) = shape.blur.filter(|b| !b.hidden) {
|
||||
shape.to_mut().set_blur(None);
|
||||
Some(blur.value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let center = shape.center();
|
||||
let mut matrix = shape.transform;
|
||||
matrix.post_translate(center);
|
||||
@@ -1005,6 +1025,24 @@ impl RenderState {
|
||||
s.canvas().concat(&matrix);
|
||||
});
|
||||
|
||||
// Wrap ALL fill/stroke/shadow rendering so a single GPU blur pass calls
|
||||
let blur_filter_for_layers: Option<skia::ImageFilter> = blur_sigma_for_layers
|
||||
.and_then(|sigma| skia::image_filters::blur((sigma, sigma), None, None, None));
|
||||
if let Some(ref filter) = blur_filter_for_layers {
|
||||
let mut layer_paint = skia::Paint::default();
|
||||
layer_paint.set_image_filter(filter.clone());
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint);
|
||||
self.surfaces
|
||||
.canvas(fills_surface_id)
|
||||
.save_layer(&layer_rec);
|
||||
self.surfaces
|
||||
.canvas(strokes_surface_id)
|
||||
.save_layer(&layer_rec);
|
||||
self.surfaces
|
||||
.canvas(innershadows_surface_id)
|
||||
.save_layer(&layer_rec);
|
||||
}
|
||||
|
||||
let shape = &shape;
|
||||
|
||||
if shape.fills.is_empty()
|
||||
@@ -1057,7 +1095,12 @@ impl RenderState {
|
||||
innershadows_surface_id,
|
||||
);
|
||||
}
|
||||
// bools::debug_render_bool_paths(self, shape, shapes, modifiers, structure);
|
||||
|
||||
if blur_filter_for_layers.is_some() {
|
||||
self.surfaces.canvas(innershadows_surface_id).restore();
|
||||
self.surfaces.canvas(strokes_surface_id).restore();
|
||||
self.surfaces.canvas(fills_surface_id).restore();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1149,6 +1192,11 @@ impl RenderState {
|
||||
let _start = performance::begin_timed_log!("render_preview");
|
||||
performance::begin_measure!("render_preview");
|
||||
|
||||
// Enable fast_mode during preview to skip expensive effects (blur, shadows).
|
||||
// Restore the previous state afterward so the final render is full quality.
|
||||
let current_fast_mode = self.options.is_fast_mode();
|
||||
self.options.set_fast_mode(true);
|
||||
|
||||
// Skip tile rebuilding during preview - we'll do it at the end
|
||||
// Just rebuild tiles for touched shapes and render synchronously
|
||||
self.rebuild_touched_tiles(tree);
|
||||
@@ -1156,6 +1204,8 @@ impl RenderState {
|
||||
// Use the sync render path
|
||||
self.start_render_loop(None, tree, timestamp, true)?;
|
||||
|
||||
self.options.set_fast_mode(current_fast_mode);
|
||||
|
||||
performance::end_measure!("render_preview");
|
||||
performance::end_timed_log!("render_preview", _start);
|
||||
|
||||
@@ -1326,11 +1376,16 @@ 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);
|
||||
// Skip frame-level blur in fast mode (pan/zoom)
|
||||
if !self.options.is_fast_mode() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1517,9 +1572,7 @@ impl RenderState {
|
||||
Self::combine_blur_values(self.combined_layer_blur(shape.blur), extra_layer_blur);
|
||||
let blur_filter = combined_blur
|
||||
.and_then(|blur| skia::image_filters::blur((blur.value, blur.value), None, None, None));
|
||||
// Legacy path is only stable up to 1.0 zoom: the canvas is scaled and the shadow
|
||||
// filter is evaluated in that scaled space, so for scale > 1 it over-inflates blur/spread.
|
||||
// We also disable it when combined layer blur is present to avoid incorrect composition.
|
||||
|
||||
let use_low_zoom_path = scale <= 1.0 && combined_blur.is_none();
|
||||
|
||||
if use_low_zoom_path {
|
||||
@@ -1602,13 +1655,27 @@ impl RenderState {
|
||||
return;
|
||||
}
|
||||
|
||||
let filter_result =
|
||||
filters::render_into_filter_surface(self, bounds, |state, temp_surface| {
|
||||
// Adaptive downscale for large blur values (lossless GPU optimization).
|
||||
// Bounds above were computed from the original sigma so filter surface coverage is correct.
|
||||
// Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8×): beyond that the
|
||||
// filter surface becomes too small and quality degrades noticeably.
|
||||
const MIN_BLUR_DOWNSCALE: f32 = 1.0 / BLUR_DOWNSCALE_THRESHOLD;
|
||||
let blur_downscale = if shadow.blur > BLUR_DOWNSCALE_THRESHOLD {
|
||||
(BLUR_DOWNSCALE_THRESHOLD / shadow.blur).max(MIN_BLUR_DOWNSCALE)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let filter_result = filters::render_into_filter_surface(
|
||||
self,
|
||||
bounds,
|
||||
blur_downscale,
|
||||
|state, temp_surface| {
|
||||
{
|
||||
let canvas = state.surfaces.canvas(temp_surface);
|
||||
|
||||
let mut shadow_paint = skia::Paint::default();
|
||||
shadow_paint.set_image_filter(drop_filter.clone());
|
||||
shadow_paint.set_image_filter(drop_filter);
|
||||
shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
|
||||
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&shadow_paint);
|
||||
@@ -1633,7 +1700,8 @@ impl RenderState {
|
||||
let canvas = state.surfaces.canvas(temp_surface);
|
||||
canvas.restore();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if let Some((mut surface, filter_scale)) = filter_result {
|
||||
let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows);
|
||||
@@ -1720,6 +1788,7 @@ impl RenderState {
|
||||
if shadow_shape.hidden {
|
||||
continue;
|
||||
}
|
||||
|
||||
let nested_clip_bounds =
|
||||
node_render_state.get_nested_shadow_clip_bounds(element, shadow);
|
||||
|
||||
@@ -1780,7 +1849,6 @@ impl RenderState {
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.draw_paint(&paint);
|
||||
|
||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ pub fn render_with_filter_surface<F>(
|
||||
where
|
||||
F: FnOnce(&mut RenderState, SurfaceId),
|
||||
{
|
||||
if let Some((mut surface, scale)) = render_into_filter_surface(render_state, bounds, draw_fn) {
|
||||
if let Some((mut surface, scale)) =
|
||||
render_into_filter_surface(render_state, bounds, 1.0, draw_fn)
|
||||
{
|
||||
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
|
||||
|
||||
// If we scaled down, we need to scale the source rect and adjust the destination
|
||||
@@ -69,9 +71,15 @@ where
|
||||
/// down so that everything fits; the returned `scale` tells the caller how much the
|
||||
/// content was reduced so it can be re-scaled on compositing. The `draw_fn` should
|
||||
/// render the untransformed shape (i.e. in document coordinates) onto `SurfaceId::Filter`.
|
||||
///
|
||||
/// `extra_downscale` is an additional scale factor applied on top of the overflow-fit scale.
|
||||
/// Use values < 1.0 to pre-downscale before applying Gaussian blur filters, which dramatically
|
||||
/// reduces GPU kernel work for large blur sigmas (Gaussian blur is scale-equivariant, so the
|
||||
/// caller must also reduce the sigma proportionally). Pass 1.0 for no extra downscale.
|
||||
pub fn render_into_filter_surface<F>(
|
||||
render_state: &mut RenderState,
|
||||
bounds: Rect,
|
||||
extra_downscale: f32,
|
||||
draw_fn: F,
|
||||
) -> Option<(skia::Surface, f32)>
|
||||
where
|
||||
@@ -86,16 +94,28 @@ where
|
||||
let bounds_width = bounds.width().ceil().max(1.0) as i32;
|
||||
let bounds_height = bounds.height().ceil().max(1.0) as i32;
|
||||
|
||||
// Minimum scale floor for fit_scale alone; prevents extreme downscaling when
|
||||
// the shape is much larger than the filter surface.
|
||||
const MIN_FIT_SCALE: f32 = 0.1;
|
||||
// Absolute minimum for the combined scale (fit × extra_downscale). Below this
|
||||
// the offscreen surface would have sub-pixel dimensions and produce artifacts or
|
||||
// crashes. At 0.03 a shape must be at least ~34 px wide to render as a single
|
||||
// pixel, which is a safe lower bound in practice.
|
||||
const MIN_COMBINED_SCALE: f32 = 0.03;
|
||||
|
||||
// Calculate scale factor if bounds exceed filter surface size
|
||||
let scale = if bounds_width > filter_width || bounds_height > filter_height {
|
||||
let fit_scale = if bounds_width > filter_width || bounds_height > filter_height {
|
||||
let scale_x = filter_width as f32 / bounds_width as f32;
|
||||
let scale_y = filter_height as f32 / bounds_height as f32;
|
||||
// Use the smaller scale to ensure everything fits
|
||||
scale_x.min(scale_y).max(0.1) // Clamp to minimum 0.1 to avoid extreme scaling
|
||||
scale_x.min(scale_y).max(MIN_FIT_SCALE)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// Combine overflow-fit scale with caller-requested extra downscale
|
||||
let scale = (fit_scale * extra_downscale).max(MIN_COMBINED_SCALE);
|
||||
|
||||
{
|
||||
let canvas = render_state.surfaces.canvas(filter_id);
|
||||
canvas.clear(skia::Color::TRANSPARENT);
|
||||
|
||||
@@ -14,7 +14,7 @@ pushd $_SCRIPT_DIR;
|
||||
|
||||
cargo watch \
|
||||
--why \
|
||||
-i "_tmp*"
|
||||
-i "_tmp*" \
|
||||
-x "build $CARGO_PARAMS" \
|
||||
-s "./build" \
|
||||
-s "echo 'DONE\n'";
|
||||
|
||||
Reference in New Issue
Block a user