Compare commits

...

43 Commits

Author SHA1 Message Date
alonso.torres
ea023c244e WIP 2026-02-04 09:38:48 +01:00
alonso.torres
b7c7c1c4fe 🐛 Fix several problems with layouts and texts 2026-02-03 17:30:54 +01:00
Alejandro Alonso
bc16b8ddc3 Merge pull request #8198 from penpot/ladybenko-13176-playwright-wasm
🔧 Migrate workspace tests to user the wasm viewport
2026-02-03 13:00:10 +01:00
Alejandro Alonso
b07c98faa5 Merge pull request #8259 from penpot/superalex-improve-shadow-rendering
🎉 Improving shadow rendering performance
2026-02-03 12:59:49 +01:00
Alejandro Alonso
25aff100cf 🎉 Add shadows playground for render wasm 2026-02-03 12:44:43 +01:00
Alejandro Alonso
5be887f10b 🎉 Improve plain shape calculation 2026-02-03 12:44:43 +01:00
Alejandro Alonso
f7403935c8 🎉 Improve shadows rendering performance 2026-02-03 12:33:05 +01:00
Belén Albeza
79be3ab7df 🔧 Fix text editor flaky tests 2026-02-03 10:39:38 +01:00
Andrey Antukh
1325584e1a Merge remote-tracking branch 'origin/staging' into staging-render 2026-02-03 08:24:04 +01:00
Andrey Antukh
0d9b7ca696 Merge tag '2.13.0-RC11' 2026-02-03 08:23:27 +01:00
Andrey Antukh
d215a5c402 Merge tag '2.13.0-RC10' 2026-02-03 08:22:50 +01:00
Belén Albeza
629649aca6 🔧 Fix config playwright syntax 2026-02-02 16:25:16 +01:00
Belén Albeza
cc326f23cf 🔧 Adjust timeout of websocket readiness (playwright) 2026-02-02 16:16:59 +01:00
Belén Albeza
2c4efc6b53 🔧 Fix onboarding test 2026-02-02 16:16:58 +01:00
Belén Albeza
4d5c874b91 🔧 Fix typography token test 2026-02-02 16:16:58 +01:00
Belén Albeza
e3b97638b4 🔧 Fix broken / flaky tests 2026-02-02 16:16:58 +01:00
Belén Albeza
daedc660b9 🔧 Migrate workspace tests to user the wasm viewport 2026-02-02 16:16:58 +01:00
Elena Torró
7681231d8f Merge pull request #8246 from penpot/azazeln28-test-more-text-editor
🔧 Add more Text Editor v2 tests
2026-02-02 15:09:18 +01:00
Aitor Moreno
07b9ef0fd6 🔧 Add more v2 text editor tests 2026-02-02 09:35:28 +01:00
Alejandro Alonso
2ae68d5752 Merge pull request #8244 from penpot/alotor-fix-modifiers-propagation
🐛 Fix problem with modifiers propagation
2026-01-29 17:34:36 +01:00
alonso.torres
913672e5c5 🐛 Fix problem with modifiers propagation 2026-01-29 17:15:01 +01:00
Alejandro Alonso
8c25fb00ac 🐛 Fix auto width/height texts on variant swithching 2026-01-29 12:25:38 +01:00
Alejandro Alonso
6a84215911 🐛 Fix stroke weight visually different with different levels of zoom 2026-01-29 12:18:26 +01:00
Andrey Antukh
f65292a13c 📎 Mark as skip two text editor v2 tests (flaky) 2026-01-29 10:43:29 +01:00
Andrey Antukh
94722fdec2 Ensure .hidePopover fn exist before call 2026-01-29 10:43:29 +01:00
Andrey Antukh
28509e0418 Ensure .stopPropagation fn exists before calling
Also for .stopImmediatePropagation and .preventDefault on
the event instances.
2026-01-29 10:43:29 +01:00
Eva Marco
9569fa2bcb 🐛 Fix error when creating a token with an invalid name (#8216) 2026-01-29 10:41:52 +01:00
Eva Marco
852b31c3a0 🐛 Fix allow spaces on token description (#8234) 2026-01-29 10:40:32 +01:00
Andrés Moya
84b3f5d7c6 🐛 Fix import of shadow tokens 2026-01-29 10:25:22 +01:00
David Barragán Merino
76bd31fe7d 🔧 Fix CORS error 2026-01-28 13:40:45 +01:00
David Barragán Merino
77bbf30ae4 🔧 Fix file name 2026-01-27 21:16:44 +01:00
David Barragán Merino
693b52bf45 📚 Fix links related to penpot plugins 2026-01-27 21:16:44 +01:00
David Barragán Merino
0f51b23ce7 🔧 Deploy plugin styles documentation 2026-01-27 21:16:44 +01:00
David Barragán Merino
ec61aa6b6d 🔧 Add custom domain 2026-01-27 21:16:41 +01:00
Andrey Antukh
abc1773f65 Merge tag '2.13.0-RC9' 2026-01-26 18:12:16 +01:00
David Barragán Merino
93f5e74bb0 🔧 Run all the jobs if the workflow is launched manually 2026-01-26 17:14:15 +01:00
David Barragán Merino
38179ba11e 🔧 Enable secret inheritance 2026-01-26 14:01:22 +01:00
David Barragán Merino
719a95246a 🔧 Define deploy plugin packages workflows 2026-01-26 13:48:33 +01:00
David Barragán Merino
e590cd852d 🔧 Rename wrangle to wrangler 2026-01-26 13:48:33 +01:00
David Barragán Merino
a9741073e5 🔧 Add deploy plugin packages workflow placeholder and wrangle config files 2026-01-26 13:48:33 +01:00
David Barragán Merino
599656c31e 🔧 Fix a typo in an interpolation 2026-01-23 19:52:47 +01:00
David Barragán Merino
16f22a7b5c 🔧 Fixes to the API documentation deployer 2026-01-22 12:10:27 +01:00
David Barragán Merino
a1460115e8 🔧 Deploy penpot api documentation 2026-01-22 12:10:27 +01:00
58 changed files with 1036 additions and 578 deletions

View File

@@ -39,6 +39,9 @@
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
- Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187)
- Fix app freeze when introducing an error on a very long token name [Taiga #13214](https://tree.taiga.io/project/penpot/issue/13214)
- Fix import a file with shadow tokens [Taiga #13229](https://tree.taiga.io/project/penpot/issue/13229)
- Fix allow spaces on token description [Taiga #13184](https://tree.taiga.io/project/penpot/issue/13184)
- Fix error when creating a token with an invalid name [Taiga #13219](https://tree.taiga.io/project/penpot/issue/13219)
## 2.12.1

View File

@@ -2017,7 +2017,9 @@
(let [;; We need to sync only the position relative to the origin of the component.
;; (see update-attrs for a full explanation)
previous-shape (reposition-shape previous-shape prev-root current-root)
touched (get previous-shape :touched #{})]
touched (get previous-shape :touched #{})
text-auto? (and (cfh/text-shape? current-shape)
(contains? #{:auto-height :auto-width} (:grow-type current-shape)))]
(loop [attrs updatable-attrs
roperations [{:type :set-touched :touched (:touched previous-shape)}]
@@ -2026,6 +2028,10 @@
(let [attr-group (get ctk/sync-attrs attr)
skip-operations?
(or
;; For auto text, avoid copying geometry-driven attrs on switch.
(and text-auto?
(contains? #{:points :selrect :width :height :position-data} attr))
;; If the attribute is not valid for the destiny, don't copy it
(not (cts/is-allowed-switch-keep-attr? attr (:type current-shape)))

View File

@@ -407,17 +407,19 @@
(defn change-text
"Changes the content of the text shape to use the text as argument. Will use the styles of the
first paragraph and text that is present in the shape (and override the rest)"
[content text]
[content text & {:as styles}]
(let [root-styles (select-keys content root-attrs)
paragraph-style
(merge
default-text-attrs
styles
(select-keys (->> content (node-seq is-paragraph-node?) first) text-all-attrs))
text-style
(merge
default-text-attrs
styles
(select-keys (->> content (node-seq is-text-node?) first) text-all-attrs))
paragraph-texts

View File

@@ -97,9 +97,12 @@
(def token-types
(into #{} (keys token-type->dtcg-token-type)))
(def token-name-validation-regex
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$")
(def token-name-ref
[:re {:title "TokenNameRef" :gen/gen sg/text}
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$"])
token-name-validation-regex])
(def ^:private schema:color
[:map

View File

@@ -1462,11 +1462,12 @@ Will return a value that matches this schema:
(def ^:private schema:dtcg-node
[:schema {:registry
{::simple-value
[:or :string :int :double]
[:or :string :int :double ::sm/boolean]
::value
[:or
[:ref ::simple-value]
[:vector ::simple-value]
[:vector [:map-of :string ::simple-value]]
[:map-of :string [:or
[:ref ::simple-value]
[:vector ::simple-value]]]]}}

View File

@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import { platform } from "os";
/**
* Read environment variables from file.
@@ -6,6 +7,10 @@ import { defineConfig, devices } from "@playwright/test";
*/
// require('dotenv').config();
const userAgent = platform === 'darwin' ?
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" :
undefined;
/**
* @see https://playwright.dev/docs/test-configuration
*/
@@ -43,12 +48,20 @@ export default defineConfig({
projects: [
{
name: "default",
use: { ...devices["Desktop Chrome"] },
testDir: "./playwright/ui/specs",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1920, height: 1080 }, // Add custom viewport size
video: 'retain-on-failure',
trace: 'retain-on-failure',
}
userAgent,
},
snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}.png",
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.001,
},
},
},
{
name: "ds",

View File

@@ -0,0 +1,7 @@
{
"~:file-id": "~u8d38942d-b01f-800e-8007-79ee6a9bac45",
"~:tag": "component",
"~:object-id": "8d38942d-b01f-800e-8007-79ee6a9bac45/8d38942d-b01f-800e-8007-79ee6a9bac46/6b68aedd-4c5b-80b9-8007-7b38c1d34ce4/component",
"~:media-id": "~ube2dc82e-615b-486b-a193-8768bdb51d7a",
"~:created-at": "~m1769523563389"
}

View File

@@ -253,7 +253,7 @@ export class WorkspacePage extends BaseWebSocketPage {
async #waitForWebSocketReadiness() {
// TODO: find a better event to settle whether the app is ready to receive notifications via ws
await expect(this.pageName).toHaveText("Page 1");
await expect(this.pageName).toHaveText("Page 1", { timeout: 30000 })
}
async sendPresenceMessage(fixture) {
@@ -383,19 +383,46 @@ export class WorkspacePage extends BaseWebSocketPage {
await this.page.keyboard.press("T");
await this.page.waitForTimeout(timeToWait);
await this.clickAndMove(x1, y1, x2, y2);
await this.page.waitForTimeout(timeToWait);
await expect(this.page.getByTestId("text-editor")).toBeVisible();
if (initialText) {
await this.page.keyboard.type(initialText);
}
}
/**
* Copies the selected element into the clipboard.
* Copies the selected element into the clipboard, or copy the
* content of the locator into the clipboard.
*
* @returns {Promise<void>}
*/
async copy() {
return this.page.keyboard.press("Control+C");
async copy(kind = "keyboard", locator = undefined) {
if (kind === "context-menu" && locator) {
await locator.click({ button: "right" });
await this.page.getByText("Copy", { exact: true }).click();
} else {
await this.page.keyboard.press("ControlOrMeta+C");
}
// wait for the clipboard to be updated
await this.page.waitForFunction(async () => {
const content = await navigator.clipboard.readText()
return content !== "";
}, { timeout: 1000 });
}
async cut(kind = "keyboard", locator = undefined) {
if (kind === "context-menu" && locator) {
await locator.click({ button: "right" });
await this.page.getByText("Cut", { exact: true }).click();
} else {
await this.page.keyboard.press("ControlOrMeta+X");
}
// wait for the clipboard to be updated
await this.page.waitForFunction(async () => {
const content = await navigator.clipboard.readText()
return content !== "";
}, { timeout: 1000 });
}
/**
@@ -407,9 +434,9 @@ export class WorkspacePage extends BaseWebSocketPage {
async paste(kind = "keyboard") {
if (kind === "context-menu") {
await this.viewport.click({ button: "right" });
return this.page.getByText("PasteCtrlV").click();
return this.page.getByText("Paste", { exact: true }).click();
}
return this.page.keyboard.press("Control+V");
return this.page.keyboard.press("ControlOrMeta+V");
}
async panOnViewportAt(x, y, width, height) {
@@ -448,11 +475,11 @@ export class WorkspacePage extends BaseWebSocketPage {
const layer = this.layers
.getByTestId("layer-row")
.filter({ hasText: name });
const button = layer.getByRole("button");
const button = layer.getByTestId("toggle-content");
await button.waitFor();
await expect(button).toBeVisible();
await button.click(clickOptions);
await this.page.waitForTimeout(500);
await button.waitFor({ ariaExpanded: true });
}
async expectSelectedLayer(name) {
@@ -495,13 +522,7 @@ export class WorkspacePage extends BaseWebSocketPage {
async clickColorPalette(clickOptions = {}) {
await this.palette
.getByRole("button", { name: "Color Palette (Alt+P)" })
.click(clickOptions);
}
async clickColorPalette(clickOptions = {}) {
await this.palette
.getByRole("button", { name: "Color Palette (Alt+P)" })
.getByRole("button", { name: /Color Palette/ })
.click(clickOptions);
}

View File

@@ -15,6 +15,8 @@ test("User can complete the onboarding", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
const onboardingPage = new OnboardingPage(page);
await dashboardPage.mockConfigFlags(["enable-onboarding"]);
await dashboardPage.goToDashboard();
await expect(
page.getByRole("heading", { name: "Help us get to know you" }),

View File

@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage, WASM_FLAGS } from "../pages/WasmWorkspacePage";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);

View File

@@ -5,7 +5,7 @@ import { WorkspacePage } from "../pages/WorkspacePage";
const timeToWait = 100;
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ONLY_WRITE);
await Clipboard.enable(context, Clipboard.Permission.ALL);
await WorkspacePage.init(page);
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
@@ -110,7 +110,7 @@ test("Update an already created text shape by prepending text", async ({
await workspace.textEditor.stopEditing();
});
test("Update an already created text shape by inserting text in between", async ({
test.skip("Update an already created text shape by inserting text in between", async ({
page,
}) => {
const workspace = new WorkspacePage(page, {
@@ -151,7 +151,7 @@ test("Update a new text shape appending text by pasting text", async ({
await workspace.textEditor.stopEditing();
});
test("Update a new text shape prepending text by pasting text", async ({
test.skip("Update a new text shape prepending text by pasting text", async ({
page,
context,
}) => {

View File

@@ -1,12 +1,19 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
import { WorkspacePage } from "../pages/WorkspacePage";
import { BaseWebSocketPage } from "../pages/BaseWebSocketPage";
import { Clipboard } from "../../helpers/Clipboard";
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ALL);
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-variants.json");
});
test.afterEach(async ({ context }) => {
context.clearPermissions();
});
const setupVariantsFile = async (workspacePage) => {
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
@@ -34,9 +41,9 @@ const setupVariantsFileWithVariant = async (workspacePage) => {
await setupVariantsFile(workspacePage);
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(500);
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.waitForTimeout(500);
// We wait until layer-row starts looking like it an component
@@ -156,7 +163,7 @@ test("User duplicates a variant container", async ({ page }) => {
await variant.container.click();
//Duplicate the variant container
await workspacePage.page.keyboard.press("Control+d");
await workspacePage.page.keyboard.press("ControlOrMeta+d");
const variant_original = await findVariant(workspacePage, 1); // On duplicate, the new item is the first
const variant_duplicate = await findVariant(workspacePage, 0);
@@ -169,25 +176,27 @@ test("User duplicates a variant container", async ({ page }) => {
await validateVariant(variant_duplicate);
});
test("User copy paste a variant container", async ({ page }) => {
test("User copy paste a variant container", async ({ page, context }) => {
const workspacePage = new WorkspacePage(page);
// Access to the read/write clipboard necesary for this functionality
await setupVariantsFileWithVariant(workspacePage);
await workspacePage.mockRPC(
/create-file-object-thumbnail.*/,
"workspace/create-file-object-thumbnail.json",
);
const variant = findVariantNoWait(workspacePage, 0);
// await variant.container.waitFor();
// Select the variant container
await variant.container.click();
await workspacePage.page.waitForTimeout(1000);
// Copy the variant container
await workspacePage.page.keyboard.press("Control+c");
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.copy("keyboard");
// Paste the variant container
await workspacePage.clickAt(400, 400);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variants = workspacePage.layers.getByText("Rectangle");
await expect(variants).toHaveCount(2);
const variantDuplicate = findVariantNoWait(workspacePage, 0);
const variantOriginal = findVariantNoWait(workspacePage, 1);
@@ -212,18 +221,17 @@ test("User cut paste a variant container", async ({ page }) => {
await variant.container.click();
//Cut the variant container
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
await workspacePage.cut("keyboard");
//Paste the variant container
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
await workspacePage.page.waitForTimeout(500);
const variantPasted = await findVariant(workspacePage, 0);
// Expand the layers
await variantPasted.container.locator("button").first().click();
await workspacePage.clickToggableLayer("Rectangle");
// The variants are valid
await validateVariant(variantPasted);
@@ -239,27 +247,34 @@ test("User cut paste a variant container into a board, and undo twice", async ({
//Create a board
await workspacePage.boardButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 100, 100);
// NOTE: this board should not intersect the existing variants, otherwise
// this test is flaky
await workspacePage.clickWithDragViewportAt(200, 200, 100, 100);
await workspacePage.clickAt(495, 495);
const board = await workspacePage.rootShape.locator("Board");
// Select the variant container
await variant.container.click();
// await variant.container.click();
await workspacePage.clickLeafLayer("Rectangle");
//Cut the variant container
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
await workspacePage.cut("keyboard");
await expect(variant.container).not.toBeVisible();
//Select the board
await workspacePage.clickLeafLayer("Board");
//Paste the variant container inside the board
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
await expect(variant.container).toBeVisible();
//Undo twice
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.waitForTimeout(500);
await workspacePage.page.keyboard.press("ControlOrMeta+z");
await expect(variant.container).not.toBeVisible();
await workspacePage.page.keyboard.press("ControlOrMeta+z");
await expect(variant.container).toBeVisible();
const variantAfterUndo = await findVariant(workspacePage, 0);
@@ -276,12 +291,12 @@ test("User copy paste a variant", async ({ page }) => {
// Select the variant1
await variant.variant1.click();
//Cut the variant
await workspacePage.page.keyboard.press("Control+c");
// Copy the variant
await workspacePage.copy("keyboard");
//Paste the variant
// Paste the variant
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const copy = await workspacePage.layers
.getByTestId("layer-row")
@@ -302,11 +317,11 @@ test("User cut paste a variant outside the container", async ({ page }) => {
await variant.variant1.click();
//Cut the variant
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the variant
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const component = await workspacePage.layers
.getByTestId("layer-row")
@@ -324,15 +339,11 @@ test("User drag and drop a variant outside the container", async ({ page }) => {
const variant = await findVariant(workspacePage, 0);
// Drag and drop the variant
await workspacePage.clickWithDragViewportAt(350, 400, 0, 200);
// FIXME: to make this test more resilient, we should get the bounding box of the Value 1 variant
// and use it to calculate the target position
await workspacePage.clickWithDragViewportAt(600, 500, 0, 300);
const component = await workspacePage.layers
.getByTestId("layer-row")
.filter({ has: workspacePage.page.getByText("Rectangle / Value 1") })
.filter({ has: workspacePage.page.getByTestId("icon-component") });
//The component exists and is visible
await expect(component).toBeVisible();
await expect(workspacePage.layers.getByText("Rectangle / Value 1")).toBeVisible();
});
test("User cut paste a component inside a variant", async ({ page }) => {
@@ -345,14 +356,14 @@ test("User cut paste a component inside a variant", async ({ page }) => {
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
//Cut the component
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the component inside the variant
await variant.container.click();
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -376,7 +387,7 @@ test("User cut paste a component with path inside a variant", async ({
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();
@@ -387,11 +398,11 @@ test("User cut paste a component with path inside a variant", async ({
await workspacePage.page.keyboard.press("Enter");
//Cut the component
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the component inside the variant
await variant.container.click();
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -415,7 +426,7 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
//Rename the component
await workspacePage.layers.getByText("Ellipse").dblclick();
@@ -426,7 +437,7 @@ test("User drag and drop a component with path inside a variant", async ({
await workspacePage.page.keyboard.press("Enter");
//Drag and drop the component the component
await workspacePage.clickWithDragViewportAt(510, 510, 0, -200);
await workspacePage.clickWithDragViewportAt(510, 510, 200, 0);
const variant3 = await workspacePage.layers
.getByTestId("layer-row")
@@ -446,8 +457,8 @@ test("User cut paste a variant into another container", async ({ page }) => {
await workspacePage.ellipseShapeButton.click();
await workspacePage.clickWithDragViewportAt(500, 500, 20, 20);
await workspacePage.clickLeafLayer("Ellipse");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
await workspacePage.page.keyboard.press("ControlOrMeta+k");
const variantOrigin = await findVariantNoWait(workspacePage, 1);
@@ -457,11 +468,11 @@ test("User cut paste a variant into another container", async ({ page }) => {
await variantOrigin.variant1.click();
//Cut the variant
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.cut("keyboard");
//Paste the variant
await workspacePage.layers.getByText("Ellipse").first().click();
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.paste("keyboard");
const variant3 = workspacePage.layers
.getByTestId("layer-row")

View File

@@ -1,13 +1,13 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { presenceFixture } from "../../data/workspace/ws-notifications";
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
await WasmWorkspacePage.init(page);
});
test("User loads worskpace with empty file", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -16,7 +16,7 @@ test("User loads worskpace with empty file", async ({ page }) => {
});
test("User opens a file with a bad page id", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace({
@@ -29,7 +29,7 @@ test("User opens a file with a bad page id", async ({ page }) => {
test("User receives presence notifications updates in the workspace", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -41,7 +41,7 @@ test("User receives presence notifications updates in the workspace", async ({
});
test("User draws a rect", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
"update-file?id=*",
@@ -52,13 +52,12 @@ test("User draws a rect", async ({ page }) => {
await workspacePage.rectShapeButton.click();
await workspacePage.clickWithDragViewportAt(128, 128, 200, 100);
const shape = await workspacePage.rootShape.locator("rect");
await expect(shape).toHaveAttribute("width", "200");
await expect(shape).toHaveAttribute("height", "100");
await workspacePage.hideUI();
await expect(workspacePage.canvas).toHaveScreenshot();
});
test("User makes a group", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
/get\-file\?/,
@@ -74,14 +73,14 @@ test("User makes a group", async ({ page }) => {
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.page.keyboard.press("Control+g");
await workspacePage.page.keyboard.press("ControlOrMeta+g");
await workspacePage.expectSelectedLayer("Group");
});
test("Bug 7654 - Toolbar keeps toggling on and off on spacebar press", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -91,10 +90,10 @@ test("Bug 7654 - Toolbar keeps toggling on and off on spacebar press", async ({
await workspacePage.expectHiddenToolbarOptions();
});
test("Bug 7525 - User moves a scrollbar and no selciont rectangle appears", async ({
test("Bug 7525 - User moves a scrollbar and no selection rectangle appears", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
/get\-file\?/,
@@ -110,8 +109,8 @@ test("Bug 7525 - User moves a scrollbar and no selciont rectangle appears", asyn
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
// Move created rect to a corner, in orther to get scrollbars
await workspacePage.panOnViewportAt(128, 128, 300, 300);
// Move created rect to a corner, in order to get scrollbars
await workspacePage.panOnViewportAt(128, 128, 600, 600);
// Check scrollbars appear
const horizontalScrollbar = workspacePage.horizontalScrollbar;
@@ -130,7 +129,7 @@ test("Bug 7525 - User moves a scrollbar and no selciont rectangle appears", asyn
test("User adds a library and its automatically selected in the color palette", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
"link-file-to-library",
@@ -175,7 +174,7 @@ test("User adds a library and its automatically selected in the color palette",
test("Bug 10179 - Drag & drop doesn't add colors to the Recent Colors palette", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
await workspacePage.moveButton.click();
@@ -218,7 +217,7 @@ test("Bug 10179 - Drag & drop doesn't add colors to the Recent Colors palette",
test("Bug 7489 - Workspace-palette items stay hidden when opening with keyboard-shortcut", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
@@ -235,7 +234,7 @@ test("Bug 7489 - Workspace-palette items stay hidden when opening with keyboard-
test("Bug 8784 - Use keyboard arrow to move inside a text input does not change tabs", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.goToWorkspace();
await workspacePage.pageName.click();
@@ -245,7 +244,7 @@ test("Bug 8784 - Use keyboard arrow to move inside a text input does not change
});
test("Bug 9066 - Problem with grid layout", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9066.json");
@@ -273,7 +272,7 @@ test("Bug 9066 - Problem with grid layout", async ({ page }) => {
});
test("User have toolbar", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -282,7 +281,7 @@ test("User have toolbar", async ({ page }) => {
});
test("User have edition menu entries", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -298,7 +297,7 @@ test("User have edition menu entries", async ({ page }) => {
});
test("Copy/paste properties", async ({ page, context }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
@@ -355,23 +354,23 @@ test("Copy/paste properties", async ({ page, context }) => {
});
test("[Taiga #9929] Paste text in workspace", async ({ page, context }) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(() => navigator.clipboard.writeText("Lorem ipsum dolor"));
await workspacePage.viewport.click({ button: "right" });
await page.getByText("PasteCtrlV").click();
await page.getByText(/^Paste/i).click();
await workspacePage.viewport
.getByRole("textbox")
.getByText("Lorem ipsum dolor");
});
test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({
page,
context,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9930.json");
await workspacePage.goToWorkspace({
@@ -379,16 +378,18 @@ test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
pageId: "fb9798e7-a547-80ae-8005-9ffda4a13e2c",
});
const zoom = await page.getByTitle("Zoom");
const zoom = page.getByTitle("Zoom");
await zoom.click();
const zoomIn = await page.getByRole("button", { name: "Zoom in" });
const zoomIn = page.getByRole("button", { name: "Zoom in" });
await zoomIn.click();
await zoomIn.click();
await zoomIn.click();
// Zoom fit all
await page.keyboard.press("Shift+1");
// Select all shapes to display selrect
await workspacePage.page.keyboard.press("ControlOrMeta+a");
const ids = [
"shape-165d1e5a-5873-8010-8005-9ffdbeaeec59",
@@ -410,7 +411,7 @@ test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
const viewportBoundingBox = await workspacePage.viewport.boundingBox();
for (const id of ids) {
const shape = await page.locator(`.ws-shape-wrapper > g#${id}`);
const shape = page.locator(`.viewport-selrect`);
const shapeBoundingBox = await shape.boundingBox();
expect(contains(viewportBoundingBox, shapeBoundingBox)).toBeTruthy();
}
@@ -419,7 +420,7 @@ test("[Taiga #9930] Zoom fit all doesn't fits all", async ({
test("Bug 9877, user navigation to dashboard from header goes to blank page", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.goToWorkspace();
@@ -436,7 +437,7 @@ test("Bug 9877, user navigation to dashboard from header goes to blank page", as
test("Bug 8371 - Flatten option is not visible in context menu", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockGetFile("workspace/get-file-8371.json");
await workspacePage.goToWorkspace({

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>WASM + WebGL2 Canvas</title>
<style>
body {
margin: 0;
background: #111;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
overflow: hidden;
}
canvas {
width: 100%;
height: 100%;
position: absolute;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, addShapeSolidStrokeFill
} from './js/lib.js';
const canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const params = new URLSearchParams(document.location.search);
const shapes = params.get("shapes") || 1000;
initWasmModule().then(Module => {
init(Module);
assignCanvas(canvas);
Module._set_canvas_background(hexToU32ARGB("#FABADA", 1));
Module._init_shapes_pool(shapes + 1);
setupInteraction(canvas);
const children = [];
for (let i = 0; i < shapes; i++) {
const uuid = crypto.randomUUID();
children.push(uuid);
useShape(uuid);
Module._set_parent(0, 0, 0, 0);
Module._set_shape_type(3);
const x1 = getRandomInt(0, canvas.width);
const y1 = getRandomInt(0, canvas.height);
const width = getRandomInt(20, 100);
const height = getRandomInt(20, 100);
Module._set_shape_selrect(x1, y1, x1 + width, y1 + height);
const color = getRandomColor();
const argb = hexToU32ARGB(color, getRandomFloat(0.1, 1.0));
addShapeSolidFill(argb)
Module._add_shape_center_stroke(10, 0, 0, 0);
const argb2 = hexToU32ARGB(color, getRandomFloat(0.1, 1.0));
addShapeSolidStrokeFill(argb2);
// Add shadows
// Shadow 1: drop-shadow, #dedede opacity 0.33, blur 4, spread -2, offsetX 0, offsetY 2
Module._add_shape_shadow(hexToU32ARGB("#dedede", 0.33), 4, -2, 0, 2, 0, false);
// Shadow 2: drop-shadow, #dedede opacity 1, blur 12, spread -8, offsetX 0, offsetY 12
Module._add_shape_shadow(hexToU32ARGB("#dedede", 1), 12, -8, 0, 12, 0, false);
// Shadow 3: inner-shadow, #002046 opacity 0.12, blur 12, spread -8, offsetX 0, offsetY -4
Module._add_shape_shadow(hexToU32ARGB("#002046", 0.12), 12, -8, 0, -4, 1, false);
}
useShape("00000000-0000-0000-0000-000000000000");
setShapeChildren(children);
performance.mark('render:begin');
Module._set_view(1, 0, 0);
Module._render(Date.now());
performance.mark('render:end');
const { duration } = performance.measure('render', 'render:begin', 'render:end');
// alert(`render time: ${duration.toFixed(2)}ms`);
});
</script>
</body>
</html>

View File

@@ -46,7 +46,9 @@
[app.main.data.workspace.thumbnails :as dwt]
[app.main.data.workspace.transforms :as dwtr]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.data.workspace.zoom :as dwz]
[app.main.features :as features]
[app.main.features.pointer-map :as fpmap]
[app.main.refs :as refs]
[app.main.repo :as rp]
@@ -1012,6 +1014,13 @@
updated-objects (pcb/get-objects changes)
new-children-ids (cfh/get-children-ids-with-self updated-objects (:id new-shape))
new-text-ids (->> new-children-ids
(keep (fn [id]
(when-let [child (get updated-objects id)]
(when (and (cfh/text-shape? child)
(not= :fixed (:grow-type child)))
id))))
(vec))
[changes parents-of-swapped]
(if keep-touched?
@@ -1021,6 +1030,9 @@
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(when (and (features/active-feature? state "render-wasm/v1")
(seq new-text-ids))
(dwwt/resize-wasm-text-all new-text-ids))
(ptk/data-event :layout/update {:ids update-layout-ids :undo-group undo-group})
(dwu/commit-undo-transaction undo-id)
(dws/select-shape (:id new-shape) false))))))

View File

@@ -712,8 +712,7 @@
(ctm/rotation-modifiers shape center angle))
modif-tree
(-> (build-modif-tree ids objects get-modifier)
(gm/set-objects-modifiers objects))
(build-modif-tree ids objects get-modifier)
modifiers
(mapv (fn [[id {:keys [modifiers]}]]

View File

@@ -104,7 +104,7 @@
(watch [_ state _]
(let [page-id (or page-id (:current-page-id state))
objects (dsh/lookup-page-objects state page-id)
ids (->> ids (filter #(contains? objects %)))]
ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))]
(if (d/not-empty? ids)
(let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))]
(if (features/active-feature? state "render-wasm/v1")

View File

@@ -11,7 +11,6 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
@@ -29,10 +28,10 @@
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.router :as rt]
[app.render-wasm.api :as wasm.api]
[app.util.text-editor :as ted]
[app.util.text.content.styles :as styles]
[app.util.timers :as ts]
@@ -52,50 +51,6 @@
(declare v2-update-text-shape-content)
(declare v2-update-text-editor-styles)
(defn resize-wasm-text-modifiers
([shape]
(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)
(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)))}})))
(defn resize-wasm-text
[id]
(ptk/reify ::resize-wasm-text
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))))))
(defn resize-wasm-text-all
[ids]
(ptk/reify ::resize-wasm-text-all
ptk/WatchEvent
(watch [_ _ _]
(->> (rx/from ids)
(rx/map resize-wasm-text)))))
;; -- Content helpers
(defn- v2-content-has-text?
@@ -178,7 +133,7 @@
{:undo-group (when new-shape? id)})
(dwm/apply-wasm-modifiers
(resize-wasm-text-modifiers shape content)
(dwwt/resize-wasm-text-modifiers shape content)
{:undo-group (when new-shape? id)})))))
(let [content (d/merge (ted/export-content content)
@@ -821,11 +776,7 @@
(rx/of (v2-update-text-editor-styles id attrs)))
(when (features/active-feature? state "render-wasm/v1")
;; This delay is to give time for the font to be correctly rendered
;; in wasm.
(cond->> (rx/of (resize-wasm-text id))
(contains? attrs :font-id)
(rx/delay 200)))))))
(rx/of (dwwt/resize-wasm-text-debounce id)))))))
ptk/EffectEvent
(effect [_ state _]
@@ -973,11 +924,11 @@
(if (and (not= :fixed (:grow-type shape)) finalize?)
(dwm/apply-wasm-modifiers
(resize-wasm-text-modifiers shape content)
(dwwt/resize-wasm-text-modifiers shape content)
{:undo-group (when new-shape? id)})
(dwm/set-wasm-modifiers
(resize-wasm-text-modifiers shape content)
(dwwt/resize-wasm-text-modifiers shape content)
{:undo-group (when new-shape? id)})))
(when finalize?

View File

@@ -27,9 +27,9 @@
[app.main.data.workspace.colors :as wdc]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.transforms :as dwtr]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.store :as st]
@@ -315,7 +315,7 @@
(and affects-layout?
(features/active-feature? state "render-wasm/v1"))
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(rx/of (dwwt/resize-wasm-text-all shape-ids))))))))
(defn update-line-height
([value shape-ids attributes] (update-line-height value shape-ids attributes nil))
@@ -374,7 +374,7 @@
:page-id page-id}))
(features/active-feature? state "render-wasm/v1")
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(rx/of (dwwt/resize-wasm-text-all shape-ids))))))))
(defn- create-font-family-text-attrs
[value]
@@ -451,7 +451,7 @@
:page-id page-id}))
(features/active-feature? state "render-wasm/v1")
(rx/merge
(rx/of (dwt/resize-wasm-text-all shape-ids))))))))
(rx/of (dwwt/resize-wasm-text-all shape-ids))))))))
(defn update-font-weight
([value shape-ids attributes] (update-font-weight value shape-ids attributes nil))

View File

@@ -406,13 +406,13 @@
(ctm/change-property :grow-type new-grow-type)))
modifiers)))
modif-tree
(-> (dwm/build-modif-tree ids objects get-modifier)
(gm/set-objects-modifiers objects))]
modif-tree (dwm/build-modif-tree ids objects get-modifier)]
(if (features/active-feature? state "render-wasm/v1")
(rx/of (dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true}))
(rx/of (dwm/apply-modifiers* objects modif-tree nil options))))))))
(let [modif-tree (gm/set-objects-modifiers modif-tree objects)]
(rx/of (dwm/apply-modifiers* objects modif-tree nil options)))))))))
(defn change-orientation
"Change orientation of shapes, from the sidebar options form.

View File

@@ -0,0 +1,149 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.data.workspace.wasm-text
"Helpers/events to resize wasm text shapes without depending on workspace.texts.
This exists to avoid circular deps:
workspace.texts -> workspace.libraries -> workspace.texts"
(:require
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.modifiers :as dwm]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.api.fonts :as wasm.fonts]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(defn resize-wasm-text-modifiers
([shape]
(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)
(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)))}})))
(defn resize-wasm-text
"Resize a single text shape (auto-width/auto-height) by id.
No-op if the id is not a text shape or is :fixed."
[id]
(ptk/reify ::resize-wasm-text
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)]
(if (and (some? shape)
(cfh/text-shape? shape)
(not= :fixed (:grow-type shape)))
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape)))
(rx/empty))))))
(defn resize-wasm-text-debounce-commit
[]
(ptk/reify ::resize-wasm-text
ptk/WatchEvent
(watch [_ state _]
(let [ids (get state ::resize-wasm-text-debounce-ids)
objects (dsh/lookup-page-objects state)
modifiers
(reduce
(fn [modifiers id]
(let [shape (get objects id)]
(cond-> modifiers
(and (some? shape)
(cfh/text-shape? shape)
(not= :fixed (:grow-type shape)))
(merge (resize-wasm-text-modifiers shape)))))
{}
ids)]
(if (not (empty? modifiers))
(rx/of (dwm/apply-wasm-modifiers modifiers))
(rx/empty))))))
;; This event will debounce the resize events so, if there are many, they
;; are processed at the same time and not one-by-one. This will improve
;; performance because it's better to make only one layout calculation instead
;; of (potentialy) hundreds.
(defn resize-wasm-text-debounce
[id]
(let [cur-event (js/Symbol)]
(ptk/reify ::resize-wasm-text-debounce
ptk/UpdateEvent
(update [_ state]
(-> state
(update ::resize-wasm-text-debounce-ids (fnil conj []) id)
(cond-> (nil? (::resize-wasm-text-debounce-event state))
(assoc ::resize-wasm-text-debounce-event cur-event))))
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
content (dm/get-in objects [id :content])
fonts (wasm.fonts/get-content-fonts content)
fonts-loaded?
(->> fonts
(every?
(fn [font]
(let [font-data (wasm.fonts/make-font-data font)]
(wasm.fonts/font-stored? font-data (:emoji? font-data))))))]
(cond
(not fonts-loaded?)
(->> (rx/of (resize-wasm-text-debounce id))
(rx/delay 20))
(= (::resize-wasm-text-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
(rx/concat
(rx/merge
(->> stream
(rx/filter (ptk/type? ::resize-wasm-text-debounce))
(rx/debounce 20)
(rx/take 1)
;; (rx/delay 200)
(rx/map #(resize-wasm-text-debounce-commit))
(rx/take-until stopper))
(rx/of (resize-wasm-text-debounce id)))
(rx/of #(dissoc %
::resize-wasm-text-debounce-ids
::resize-wasm-text-debounce-event))))
:else
(rx/empty)))))))
(defn resize-wasm-text-all
"Resize all text shapes (auto-width/auto-height) from a collection of ids."
[ids]
(ptk/reify ::resize-wasm-text-all
ptk/WatchEvent
(watch [_ _ _]
(->> (rx/from ids)
(rx/map resize-wasm-text)))))

View File

@@ -36,10 +36,12 @@
(defn- hide-popover
[node]
(dom/unset-css-property! node "block-size")
(dom/unset-css-property! node "inset-block-start")
(dom/unset-css-property! node "inset-inline-start")
(.hidePopover ^js node))
(when (and (some? node)
(fn? (.-hidePopover node)))
(dom/unset-css-property! node "block-size")
(dom/unset-css-property! node "inset-block-start")
(dom/unset-css-property! node "inset-inline-start")
(.hidePopover ^js node)))
(defn- calculate-placement-bounding-rect
"Given a placement, calcultates the bounding rect for it taking in

View File

@@ -16,7 +16,7 @@
(def context (mf/create-context nil))
(mf/defc form-input*
[{:keys [name] :rest props}]
[{:keys [name trim] :rest props}]
(let [form (mf/use-ctx context)
input-name name
@@ -33,7 +33,7 @@
(mf/deps input-name)
(fn [event]
(let [value (-> event dom/get-target dom/get-input-value)]
(fm/on-input-change form input-name value true))))
(fm/on-input-change form input-name value trim))))
props
(mf/spread-props props {:on-change on-change

View File

@@ -21,7 +21,7 @@
(def ^:private schema:properties-row
[:map
[:term :string]
[:detail :string]
[:detail {:optional true} [:maybe :string]]
[:property {:optional true} :string] ;; CSS valid property
[:token {:optional true} :any] ;; resolved token object
[:copiable {:optional true} :boolean]])

View File

@@ -401,7 +401,8 @@
(dm/fmt "scale(%)" maybe-zoom))}))]
[:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id)
:transform (dm/str transform)}
:transform (dm/str transform)
:data-testid "text-editor"}
[:defs
[:clipPath {:id clip-id}
[:rect {:x x :y y :width width :height height}]]]

View File

@@ -119,6 +119,9 @@
[:button {:class (stl/css-case
:toggle-content true
:inverse expanded?)
:data-testid "toggle-content"
:aria-expanded expanded?
:aria-labelledby (dm/str "layer-name-" id)
:on-click on-toggle-collapse}
deprecated-icon/arrow])

View File

@@ -108,6 +108,7 @@
:on-blur accept-edit
:on-key-down on-key-down
:auto-focus true
:id (dm/str "layer-name-" shape-id)
:default-value (d/nilv default-value "")}]
[:*
[:span
@@ -118,6 +119,7 @@
:hidden is-hidden
:type-comp type-comp
:type-frame type-frame)
:id (dm/str "layer-name-" shape-id)
:style {"--depth" depth "--parent-size" parent-size}
:ref ref
:on-double-click start-edit}

View File

@@ -15,6 +15,7 @@
[app.main.data.workspace.shortcuts :as sc]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -138,7 +139,7 @@
(dwsh/update-shapes ids #(assoc % :grow-type grow-type)))
(when (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwt/resize-wasm-text-all ids)))
(st/emit! (dwwt/resize-wasm-text-all ids)))
;; We asynchronously commit so every sychronous event is resolved first and inside the transaction
(ts/schedule #(st/emit! (dwu/commit-undo-transaction uid))))
(when (some? on-blur) (on-blur))))]

View File

@@ -11,6 +11,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.color :as cl]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.data.tinycolor :as tinycolor]
@@ -51,12 +52,15 @@
;; Both variants provide identical color-picker and text-input behavior, but
;; differ in how they persist the value within the forms nested structure.
(defn- resolve-value
[tokens prev-token token-name value]
(let [token
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
{:value value
:name (if (str/blank? token-name)
:name (if (or (not valid-token-name?) (str/blank? token-name))
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}

View File

@@ -50,9 +50,13 @@
(defn- resolve-value
[tokens prev-token token-name value]
(let [token
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
{:value (cto/split-font-family value)
:name (if (str/blank? token-name)
:name (if (or (not valid-token-name?) (str/blank? token-name))
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}

View File

@@ -8,6 +8,7 @@
(:require
[app.common.data :as d]
[app.common.files.tokens :as cft]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.data.workspace.tokens.format :as dwtf]
@@ -140,9 +141,13 @@
(defn- resolve-value
[tokens prev-token token-name value]
(let [token
(let [valid-token-name?
(and (string? token-name)
(re-matches cto/token-name-validation-regex token-name))
token
{:value value
:name (if (str/blank? token-name)
:name (if (or (not valid-token-name?) (str/blank? token-name))
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
token-name)}
tokens

View File

@@ -230,6 +230,7 @@
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:trim true
:auto-focus true}]
(when (and warning-name-change? (= action "edit"))

View File

@@ -75,32 +75,31 @@
[{:keys [points] :as shape} zoom grid-edition?]
(let [leftmost (->> points (reduce left?))
topmost (->> points (remove #{leftmost}) (reduce top?))
rightmost (->> points (remove #{leftmost topmost}) (reduce right?))
rightmost (->> points (remove #{leftmost topmost}) (reduce right?))]
(when (and (some? leftmost) (some? topmost) (some? rightmost))
(let [left-top (gpt/to-vec leftmost topmost)
left-top-angle (gpt/angle left-top)
left-top (gpt/to-vec leftmost topmost)
left-top-angle (gpt/angle left-top)
top-right (gpt/to-vec topmost rightmost)
top-right-angle (gpt/angle top-right)
top-right (gpt/to-vec topmost rightmost)
top-right-angle (gpt/angle top-right)
;; Choose the position that creates the less angle between left-side and top-side
[label-pos angle h-pos v-pos]
(if (< (mth/abs left-top-angle) (mth/abs top-right-angle))
[leftmost left-top-angle left-top (gpt/perpendicular left-top)]
[topmost top-right-angle top-right (gpt/perpendicular top-right)])
;; Choose the position that creates the less angle between left-side and top-side
[label-pos angle h-pos v-pos]
(if (< (mth/abs left-top-angle) (mth/abs top-right-angle))
[leftmost left-top-angle left-top (gpt/perpendicular left-top)]
[topmost top-right-angle top-right (gpt/perpendicular top-right)])
delta-x (if grid-edition? 40 0)
delta-y (if grid-edition? 50 10)
delta-x (if grid-edition? 40 0)
delta-y (if grid-edition? 50 10)
label-pos
(-> label-pos
(gpt/subtract (gpt/scale (gpt/unit v-pos) (/ delta-y zoom)))
(gpt/subtract (gpt/scale (gpt/unit h-pos) (/ delta-x zoom))))]
(dm/fmt "rotate(% %,%) scale(%, %) translate(%, %)"
;; rotate
angle (:x label-pos) (:y label-pos)
;; scale
(/ 1 zoom) (/ 1 zoom)
;; translate
(* zoom (:x label-pos)) (* zoom (:y label-pos)))))
label-pos
(-> label-pos
(gpt/subtract (gpt/scale (gpt/unit v-pos) (/ delta-y zoom)))
(gpt/subtract (gpt/scale (gpt/unit h-pos) (/ delta-x zoom))))]
(dm/fmt "rotate(% %,%) scale(%, %) translate(%, %)"
;; rotate
angle (:x label-pos) (:y label-pos)
;; scale
(/ 1 zoom) (/ 1 zoom)
;; translate
(* zoom (:x label-pos)) (* zoom (:y label-pos)))))))

View File

@@ -26,6 +26,7 @@
[app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.fonts :refer [fetch-font-css]]
[app.main.router :as rt]
[app.main.store :as st]
@@ -338,9 +339,14 @@
:else
(let [page (dsh/lookup-page @st/state)
shape (-> (cts/setup-shape {:type :text :x 0 :y 0 :grow-type :auto-width})
(update :content txt/change-text text)
(assoc :position-data nil))
shape (-> (cts/setup-shape {:type :text
:x 0 :y 0
:width 1 :height 1
:grow-type :auto-width})
(update :content txt/change-text text
;; Text should be given a color by default
{:fills [{:fill-color "#000000" :fill-opacity 1}]})
(dissoc :position-data))
changes
(-> (cb/empty-changes)
@@ -348,7 +354,9 @@
(cb/with-objects (:objects page))
(cb/add-object shape))]
(st/emit! (ch/commit-changes changes))
(st/emit!
(ch/commit-changes changes)
(dwwt/resize-wasm-text-debounce (:id shape)))
(shape/shape-proxy plugin-id (:id shape)))))
:createShapeFromSvg

View File

@@ -190,11 +190,13 @@
(defn update-text-rect!
[id]
(when wasm/context-initialized?
(mw/emit!
{:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))
(let [dimensions (get-text-dimensions id)
page-id (:current-page-id @st/state)]
(mw/emit!
{:cmd :index/update-text-rect
:page-id page-id
:shape-id id
:dimensions dimensions}))))
(defn- ensure-text-content
@@ -865,10 +867,10 @@
(set-shape-vertical-align (get content :vertical-align))
(let [fonts (f/get-content-fonts content)
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts shape-id all-fonts)]
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts shape-id all-fonts)]
(f/load-fallback-fonts-for-editor! fallback-fonts)
(h/call wasm/internal-module "_update_shape_text_layout")
result))
@@ -1564,7 +1566,7 @@
:text-decoration (get element :text-decoration)
:letter-spacing (get element :letter-spacing)
:font-style (get element :font-style)
:fills (get element :fills)
:fills (d/nilv (get element :fills) [{:fill-color "#000000"}])
:text text}))))))]
(mem/free)

View File

@@ -20,6 +20,7 @@
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[goog.object :as gobj]
[potok.v2.core :as ptk]
[lambdaisland.uri :as u]
[okulary.core :as l]))
@@ -97,9 +98,8 @@
;; IMPORTANT: Only TTF fonts can be stored.
(defn- store-font-buffer
[shape-id font-data font-array-buffer emoji? fallback?]
[font-data font-array-buffer emoji? fallback?]
(let [font-id-buffer (:family-id-buffer font-data)
shape-id-buffer (uuid/get-u32 shape-id)
size (.-byteLength font-array-buffer)
ptr (h/call wasm/internal-module "_alloc_bytes" size)
heap (gobj/get ^js wasm/internal-module "HEAPU8")
@@ -107,10 +107,6 @@
(.set mem (js/Uint8Array. font-array-buffer))
(h/call wasm/internal-module "_store_font"
(aget shape-id-buffer 0)
(aget shape-id-buffer 1)
(aget shape-id-buffer 2)
(aget shape-id-buffer 3)
(aget font-id-buffer 0)
(aget font-id-buffer 1)
(aget font-id-buffer 2)
@@ -119,24 +115,35 @@
(:style font-data)
emoji?
fallback?)
(update-text-layout shape-id)
true))
;; This variable will store the fonts that are currently being fetched
;; so we don't fetch more than once the same font
(def fetching (atom #{}))
(defn- fetch-font
[shape-id font-data font-url emoji? fallback?]
{:key font-url
:callback #(->> (http/send! {:method :get
:uri font-url
:response-type :buffer})
(rx/map (fn [{:keys [body]}]
(store-font-buffer shape-id font-data body emoji? fallback?)))
(rx/catch (fn [cause]
(log/error :hint "Could not fetch font"
:font-url font-url
:cause cause)
(rx/empty))))})
(when-not (contains? @fetching font-url)
(swap! fetching conj font-url)
{:key font-url
:callback
(fn []
(->> (http/send! {:method :get
:uri font-url
:response-type :buffer})
(rx/map (fn [{:keys [body]}]
(swap! fetching disj font-url)
(store-font-buffer font-data body emoji? fallback?)
(update-text-layout shape-id)
;; TODO: Emit font fetched event
(prn ">>Fetched" font-data)
(ptk/data-event :wasm-text/font-fetched {:font font-data})))
(rx/catch (fn [cause]
(swap! fetching disj font-url)
(log/error :hint "Could not fetch font"
:font-url font-url
:cause cause)
(rx/empty)))))}))
(defn- google-font-ttf-url
[font-id font-variant-id font-weight font-style]
@@ -155,20 +162,29 @@
:builtin
(dm/str (u/join cf/public-uri "fonts/" asset-id))))
(defn font-stored?
[font-data emoji?]
(let [id-buffer (uuid/get-u32 (:wasm-id font-data))]
(not= 0 (h/call wasm/internal-module "_is_font_uploaded"
(aget id-buffer 0)
(aget id-buffer 1)
(aget id-buffer 2)
(aget id-buffer 3)
(:weight font-data)
(:style font-data)
emoji?))))
(defn- store-font-id
[shape-id font-data asset-id emoji? fallback?]
(when asset-id
(let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data) (:weight font-data) (:style-name font-data))
(let [uri (font-id->ttf-url
(:font-id font-data) asset-id
(:font-variant-id font-data)
(:weight font-data)
(:style-name font-data))
id-buffer (uuid/get-u32 (:wasm-id font-data))
font-data (assoc font-data :family-id-buffer id-buffer)
font-stored? (not= 0 (h/call wasm/internal-module "_is_font_uploaded"
(aget id-buffer 0)
(aget id-buffer 1)
(aget id-buffer 2)
(aget id-buffer 3)
(:weight font-data)
(:style font-data)
emoji?))]
font-stored? (font-stored? font-data emoji?)]
(when-not font-stored?
(fetch-font shape-id font-data uri emoji? fallback?)))))
@@ -280,8 +296,8 @@
"regular"
font-variant-id))
(defn store-font
[shape-id font]
(defn make-font-data
[font]
(let [font-id (get font :font-id)
font-variant-id (get font :font-variant-id)
normalized-variant-id (when font-variant-id
@@ -307,7 +323,14 @@
:font-variant-id variant-id
:style (serialize-font-style style)
:style-name style
:weight weight}]
:weight weight
:emoji? emoji?
:fallbck? fallback?
:asset-id asset-id}]))
(defn store-font
[shape-id font]
(let [{:keys [asset-id emoji? fallback?] :as font-data} (make-font-data font)]
(store-font-id shape-id font-data asset-id emoji? fallback?)))
;; FIXME: This is a temporary function to load the fallback fonts for the editor.

View File

@@ -106,17 +106,20 @@
(defn stop-propagation
[^js event]
(when event
(when (and (some? event)
(fn? (.-stopPropagation event)))
(.stopPropagation event)))
(defn stop-immediate-propagation
[^js event]
(when event
(when (and (some? event)
(fn? (.-stopImmediatePropagation event)))
(.stopImmediatePropagation event)))
(defn prevent-default
[^js event]
(when event
(when (and (some? event)
(fn? (.-preventDefault event)))
(.preventDefault event)))
(defn get-target

View File

@@ -7,15 +7,16 @@
(ns app.util.keyboard
(:require
[app.config :as cfg]
[app.util.dom :as dom]
[cuerdas.core :as str]))
(defrecord KeyboardEvent [type key shift ctrl alt meta mod editing native-event]
Object
(preventDefault [_]
(.preventDefault native-event))
(dom/prevent-default native-event))
(stopPropagation [_]
(.stopPropagation native-event)))
(dom/stop-propagation native-event)))
(defn keyboard-event?
[o]

View File

@@ -405,12 +405,8 @@ export class TextEditor extends EventTarget {
if (e.inputType in commands) {
const command = commands[e.inputType];
if (!this.#selectionController.startMutation()) {
return;
}
command(e, this, this.#selectionController);
const mutations = this.#selectionController.endMutation();
this.#notifyLayout(LayoutType.FULL, mutations);
this.#notifyLayout(LayoutType.FULL);
}
};
@@ -456,19 +452,12 @@ export class TextEditor extends EventTarget {
if ((e.ctrlKey || e.metaKey) && e.key === "Backspace") {
e.preventDefault();
if (!this.#selectionController.startMutation()) {
return;
}
if (this.#selectionController.isCollapsed) {
this.#selectionController.removeWordBackward();
} else {
this.#selectionController.removeSelected();
}
const mutations = this.#selectionController.endMutation();
this.#notifyLayout(LayoutType.FULL, mutations);
this.#notifyLayout(LayoutType.FULL);
}
};
@@ -476,14 +465,12 @@ export class TextEditor extends EventTarget {
* Notifies that the edited texts needs layout.
*
* @param {'full'|'partial'} type
* @param {CommandMutations} mutations
*/
#notifyLayout(type = LayoutType.FULL, mutations) {
#notifyLayout(type = LayoutType.FULL) {
this.dispatchEvent(
new CustomEvent("needslayout", {
detail: {
type: type,
mutations: mutations,
},
}),
);
@@ -630,10 +617,8 @@ export class TextEditor extends EventTarget {
* @returns {TextEditor}
*/
applyStylesToSelection(styles) {
this.#selectionController.startMutation();
this.#selectionController.applyStyles(styles);
const mutations = this.#selectionController.endMutation();
this.#notifyLayout(LayoutType.FULL, mutations);
this.#notifyLayout(LayoutType.FULL);
this.#changeController.notifyImmediately();
return this;
}

View File

@@ -1,66 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
/**
* Command mutations
*/
export class CommandMutations {
#added = new Set();
#removed = new Set();
#updated = new Set();
constructor(added, updated, removed) {
if (added && Array.isArray(added)) this.#added = new Set(added);
if (updated && Array.isArray(updated)) this.#updated = new Set(updated);
if (removed && Array.isArray(removed)) this.#removed = new Set(removed);
}
get added() {
return this.#added;
}
get removed() {
return this.#removed;
}
get updated() {
return this.#updated;
}
clear() {
this.#added.clear();
this.#removed.clear();
this.#updated.clear();
}
dispose() {
this.#added.clear();
this.#added = null;
this.#removed.clear();
this.#removed = null;
this.#updated.clear();
this.#updated = null;
}
add(node) {
this.#added.add(node);
return this;
}
remove(node) {
this.#removed.add(node);
return this;
}
update(node) {
this.#updated.add(node);
return this;
}
}
export default CommandMutations;

View File

@@ -1,71 +0,0 @@
import { describe, test, expect } from "vitest";
import CommandMutations from "./CommandMutations.js";
describe("CommandMutations", () => {
test("should create a new CommandMutations", () => {
const mutations = new CommandMutations();
expect(mutations).toHaveProperty("added");
expect(mutations).toHaveProperty("updated");
expect(mutations).toHaveProperty("removed");
});
test("should create an initialized new CommandMutations", () => {
const mutations = new CommandMutations([1], [2], [3]);
expect(mutations.added.size).toBe(1);
expect(mutations.updated.size).toBe(1);
expect(mutations.removed.size).toBe(1);
expect(mutations.added.has(1)).toBe(true);
expect(mutations.updated.has(2)).toBe(true);
expect(mutations.removed.has(3)).toBe(true);
});
test("should add an added node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
expect(mutations.added.has(1)).toBe(true);
});
test("should add an updated node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.update(1);
expect(mutations.updated.has(1)).toBe(true);
});
test("should add an removed node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.remove(1);
expect(mutations.removed.has(1)).toBe(true);
});
test("should clear a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
mutations.update(2);
mutations.remove(3);
expect(mutations.added.has(1)).toBe(true);
expect(mutations.added.size).toBe(1);
expect(mutations.updated.has(2)).toBe(true);
expect(mutations.updated.size).toBe(1);
expect(mutations.removed.has(3)).toBe(true);
expect(mutations.removed.size).toBe(1);
mutations.clear();
expect(mutations.added.size).toBe(0);
expect(mutations.added.has(1)).toBe(false);
expect(mutations.updated.size).toBe(0);
expect(mutations.updated.has(1)).toBe(false);
expect(mutations.removed.size).toBe(0);
expect(mutations.removed.has(1)).toBe(false);
});
test("should dispose a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
mutations.update(2);
mutations.remove(3);
mutations.dispose();
expect(mutations.added).toBe(null);
expect(mutations.updated).toBe(null);
expect(mutations.removed).toBe(null);
});
});

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest";
import { insertInto, removeBackward, removeForward, replaceWith } from "./Text";
import { insertInto, removeSlice, removeBackward, removeForward, removeWordBackward, replaceWith, findPreviousWordBoundary } from "./Text";
describe("Text", () => {
test("* should throw when passed wrong parameters", () => {
@@ -51,4 +51,23 @@ describe("Text", () => {
test("`removeForward` should remove string forward from offset 6", () => {
expect(removeForward("Hello, World!", 6)).toBe("Hello,World!");
});
test("`removeSlice` should remove a part of a text", () => {
expect(removeSlice("Hello, World!", 7, 12)).toBe("Hello, !");
});
test("`findPreviousWordBoundary` edge cases", () => {
expect(findPreviousWordBoundary(null)).toBe(0);
expect(findPreviousWordBoundary("Hello, World!", 0)).toBe(0);
expect(findPreviousWordBoundary(" Hello, World!", 3)).toBe(0);
})
test("`removeWordBackward` with no text should return an empty string", () => {
expect(removeWordBackward(null, 0)).toBe("");
});
test("`removeWordBackward` should remove a word backward", () => {
expect(removeWordBackward("Hello, World!", 13)).toBe("Hello, World");
expect(removeWordBackward("Hello, World", 12)).toBe("Hello, ");
});
});

View File

@@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest";
import { getFills } from "./Color.js";
/* @vitest-environment jsdom */
describe("Color", () => {
describe.skip("Color", () => {
test("getFills", () => {
expect(getFills("#aa0000")).toBe(
'[["^ ","~:fill-color","#aa0000","~:fill-opacity",1]]',

View File

@@ -49,7 +49,6 @@ import {
} from "../content/dom/TextNode.js";
import TextNodeIterator from "../content/dom/TextNodeIterator.js";
import TextEditor from "../TextEditor.js";
import CommandMutations from "../commands/CommandMutations.js";
import { isRoot, setRootStyles } from "../content/dom/Root.js";
import { SelectionDirection } from "./SelectionDirection.js";
import { SafeGuard } from "./SafeGuard.js";
@@ -145,13 +144,6 @@ export class SelectionController extends EventTarget {
*/
#debug = null;
/**
* Command Mutations.
*
* @type {CommandMutations}
*/
#mutations = new CommandMutations();
/**
* Style defaults.
*
@@ -449,14 +441,14 @@ export class SelectionController extends EventTarget {
dispose() {
document.removeEventListener("selectionchange", this.#onSelectionChange);
this.#textEditor = null;
this.#currentStyle = null;
this.#options = null;
this.#ranges.clear();
this.#ranges = null;
this.#range = null;
this.#selection = null;
this.#focusNode = null;
this.#anchorNode = null;
this.#mutations.dispose();
this.#mutations = null;
}
/**
@@ -522,28 +514,6 @@ export class SelectionController extends EventTarget {
return true;
}
/**
* Marks the start of a mutation.
*
* Clears all the mutations kept in CommandMutations.
*
* @returns {boolean}
*/
startMutation() {
this.#mutations.clear();
if (!this.#focusNode) return false;
return true;
}
/**
* Marks the end of a mutation.
*
* @returns {CommandMutations}
*/
endMutation() {
return this.#mutations;
}
/**
* Selects all content.
*
@@ -597,11 +567,18 @@ export class SelectionController extends EventTarget {
* @returns {SelectionController}
*/
cursorToEnd() {
const root = this.#textEditor.root;
const range = document.createRange(); //Create a range (a range is a like the selection but invisible)
range.selectNodeContents(this.#textEditor.element);
range.setStart(root.lastChild.firstChild.firstChild, root.lastChild.firstChild.firstChild?.nodeValue?.length ?? 0);
range.setEnd(root.lastChild.firstChild.firstChild, root.lastChild.firstChild.firstChild?.nodeValue?.length ?? 0);
range.collapse(false);
this.#selection.removeAllRanges();
this.#selection.addRange(range);
this.#updateState();
return this;
}
@@ -1340,7 +1317,6 @@ export class SelectionController extends EventTarget {
if (this.focusNode.nodeValue !== removedData) {
this.focusNode.nodeValue = removedData;
this.#mutations.update(this.focusTextSpan);
}
const paragraph = this.focusParagraph;
@@ -1383,7 +1359,6 @@ export class SelectionController extends EventTarget {
this.focusOffset,
newText,
);
this.#mutations.update(this.focusTextSpan);
return this.collapse(this.focusNode, this.focusOffset + newText.length);
}
@@ -1447,7 +1422,6 @@ export class SelectionController extends EventTarget {
this.#textEditor.root.replaceChildren(newParagraph);
return this.collapse(newTextNode, newText.length + 1);
}
this.#mutations.update(this.focusTextSpan);
return this.collapse(this.focusNode, startOffset + newText.length);
}
@@ -1525,8 +1499,6 @@ export class SelectionController extends EventTarget {
const currentParagraph = this.focusParagraph;
const newParagraph = createEmptyParagraph(this.#currentStyle);
currentParagraph.after(newParagraph);
this.#mutations.update(currentParagraph);
this.#mutations.add(newParagraph);
return this.collapse(newParagraph.firstChild.firstChild, 0);
}
@@ -1537,8 +1509,6 @@ export class SelectionController extends EventTarget {
const currentParagraph = this.focusParagraph;
const newParagraph = createEmptyParagraph(this.#currentStyle);
currentParagraph.before(newParagraph);
this.#mutations.update(currentParagraph);
this.#mutations.add(newParagraph);
return this.collapse(currentParagraph.firstChild.firstChild, 0);
}
@@ -1553,8 +1523,6 @@ export class SelectionController extends EventTarget {
this.#focusOffset,
);
this.focusParagraph.after(newParagraph);
this.#mutations.update(currentParagraph);
this.#mutations.add(newParagraph);
return this.collapse(newParagraph.firstChild.firstChild, 0);
}
@@ -1586,10 +1554,6 @@ export class SelectionController extends EventTarget {
this.focusOffset,
);
currentParagraph.after(newParagraph);
this.#mutations.update(currentParagraph);
this.#mutations.add(newParagraph);
// FIXME: Missing collapse?
}
@@ -1610,7 +1574,6 @@ export class SelectionController extends EventTarget {
const previousOffset = isLineBreak(previousTextSpan.firstChild)
? 0
: previousTextSpan.firstChild.nodeValue?.length || 0;
this.#mutations.remove(paragraphToBeRemoved);
return this.collapse(previousTextSpan.firstChild, previousOffset);
}
@@ -1632,8 +1595,6 @@ export class SelectionController extends EventTarget {
} else {
mergeParagraphs(previousParagraph, currentParagraph);
}
this.#mutations.remove(currentParagraph);
this.#mutations.update(previousParagraph);
return this.collapse(previousTextSpan.firstChild, previousOffset);
}
@@ -1647,8 +1608,6 @@ export class SelectionController extends EventTarget {
return;
}
mergeParagraphs(this.focusParagraph, nextParagraph);
this.#mutations.update(currentParagraph);
this.#mutations.remove(nextParagraph);
// FIXME: Missing collapse?
}
@@ -1665,7 +1624,6 @@ export class SelectionController extends EventTarget {
paragraphToBeRemoved.remove();
const nextTextSpan = nextParagraph.firstChild;
const nextOffset = this.focusOffset;
this.#mutations.remove(paragraphToBeRemoved);
return this.collapse(nextTextSpan.firstChild, nextOffset);
}
@@ -1680,7 +1638,6 @@ export class SelectionController extends EventTarget {
for (const textSpan of affectedTextSpans) {
if (textSpan.textContent === "") {
textSpan.remove();
this.#mutations.remove(textSpan);
}
}
@@ -1688,7 +1645,6 @@ export class SelectionController extends EventTarget {
for (const paragraph of affectedParagraphs) {
if (paragraph.children.length === 0) {
paragraph.remove();
this.#mutations.remove(paragraph);
}
}
}

View File

@@ -581,6 +581,136 @@ describe("SelectionController", () => {
expect(textEditorMock.root.textContent).toBe("");
});
test("`insertParagraph` should insert a new paragraph in an empty editor", () => {
const textEditorMock = TextEditorMock.createTextEditorMockEmpty();
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
0,
);
selectionController.insertParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.children.length).toBe(2);
expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(0).dataset.itype).toBe("paragraph");
expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe("span");
expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(1).dataset.itype).toBe("paragraph");
expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.textContent).toBe("");
});
test("`insertParagraph` should insert a new paragraph after a text", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, World!"]
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
"Hello, World!".length
);
selectionController.insertParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.children.length).toBe(2);
expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(0).dataset.itype).toBe(
"paragraph",
);
expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.children.item(0).firstChild.textContent).toBe(
"Hello, World!",
);
expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(1).dataset.itype).toBe(
"paragraph",
);
expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.children.item(1).firstChild.firstChild).toBeInstanceOf(
HTMLBRElement,
);
expect(textEditorMock.root.textContent).toBe("Hello, World!");
});
test("`insertParagraph` should insert a new paragraph before a text", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, World!"],
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection,
);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
0,
);
selectionController.insertParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.children.length).toBe(2);
expect(textEditorMock.root.children.item(0)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(0).dataset.itype).toBe(
"paragraph",
);
expect(textEditorMock.root.children.item(0).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(0).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.children.item(0).firstChild.firstChild).toBeInstanceOf(
HTMLBRElement,
);
expect(textEditorMock.root.children.item(1)).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.item(1).dataset.itype).toBe(
"paragraph",
);
expect(textEditorMock.root.children.item(1).firstChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(textEditorMock.root.children.item(1).firstChild.dataset.itype).toBe(
"span",
);
expect(textEditorMock.root.children.item(1).firstChild.textContent).toBe(
"Hello, World!",
);
expect(textEditorMock.root.textContent).toBe("Hello, World!");
});
test("`mergeBackwardParagraph` should merge two paragraphs in backward direction (backspace)", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, "],
@@ -1027,7 +1157,7 @@ describe("SelectionController", () => {
);
});
test.skip("`removeSelected` multiple paragraphs", () => {
test("`removeSelected` multiple paragraphs", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWith([
["Hello, "],
["\n"],
@@ -1392,7 +1522,10 @@ describe("SelectionController", () => {
root.firstChild.lastChild.firstChild.nodeValue.length - 3,
);
selectionController.applyStyles({
"font-family": "Montserrat, sans-serif",
"font-weight": "bold",
"--fills":
'[["^ ","~:fill-color","#000000","~:fill-opacity",1],["^ ","~:fill-color","#aa0000","~:fill-opacity",1]]',
});
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.length).toBe(1);
@@ -1492,4 +1625,68 @@ describe("SelectionController", () => {
"ld!",
);
});
test("`selectAll` should select everything", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraphWith(["Hello, "], {
"font-style": "italic",
}),
createParagraphWith(["World!"], {
"font-style": "oblique",
}),
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
textEditorMock.element.focus();
selectionController.selectAll();
expect(selectionController.anchorNode).toBe(
root.firstChild.firstChild.firstChild
);
expect(selectionController.focusNode).toBe(
root.lastChild.firstChild.firstChild,
);
});
test("`cursorToEnd` should move cursor to the end", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraphWith(["Hello, "], {
"font-style": "italic",
}),
createParagraphWith(["World!"], {
"font-style": "oblique",
}),
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
textEditorMock.element.focus();
selectionController.cursorToEnd();
expect(selectionController.focusNode).toBe(root.lastChild.firstChild.firstChild);
expect(selectionController.focusAtEnd).toBeTruthy();
})
test("`dispose` should release every held reference", () => {
const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraphWith(["Hello, "], {
"font-style": "italic",
}),
createParagraphWith(["World!"], {
"font-style": "oblique",
}),
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(textEditorMock, selection);
focus(
selection,
textEditorMock,
root.firstChild.firstChild.firstChild,
0
);
selectionController.dispose();
expect(selectionController.selection).toBe(null);
expect(selectionController.currentStyle).toBe(null);
expect(selectionController.options).toBe(null);
});
});

View File

@@ -346,6 +346,14 @@ pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) {
});
}
#[no_mangle]
pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) {
with_state_mut!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d);
state.touch_shape(shape_id);
});
}
#[no_mangle]
pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) {
with_state_mut!(state, {

View File

@@ -22,7 +22,7 @@ pub use surfaces::{SurfaceId, Surfaces};
use crate::performance;
use crate::shapes::{
all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Stroke, Type,
all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Type,
};
use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
use crate::tiles::{self, PendingTiles, TileRect};
@@ -1512,6 +1512,16 @@ 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 {
// Match pre-commit behavior: scale blur/spread with zoom for low zoom levels.
transformed_shadow.to_mut().blur = shadow.blur * scale;
transformed_shadow.to_mut().spread = shadow.spread * scale;
}
let mut transform_matrix = shape.transform;
let center = shape.center();
@@ -1526,28 +1536,20 @@ impl RenderState {
let world_offset = (mapped.x, mapped.y);
// The opacity of fills and strokes shouldn't affect the shadow,
// so we paint everything black with the same opacity
plain_shape.to_mut().clear_fills();
// so we paint everything black with the same opacity.
let plain_shape_mut = plain_shape.to_mut();
plain_shape_mut.clear_fills();
if shape.has_fills() {
plain_shape
.to_mut()
.add_fill(Fill::Solid(SolidColor(skia::Color::BLACK)));
plain_shape_mut.add_fill(Fill::Solid(SolidColor(skia::Color::BLACK)));
}
plain_shape.to_mut().clear_strokes();
for stroke in shape.strokes.iter() {
plain_shape.to_mut().add_stroke(Stroke {
fill: Fill::Solid(SolidColor(skia::Color::BLACK)),
width: stroke.width,
style: stroke.style,
cap_end: stroke.cap_end,
cap_start: stroke.cap_start,
kind: stroke.kind,
});
// Reuse existing strokes and only override their fill color.
for stroke in plain_shape_mut.strokes.iter_mut() {
stroke.fill = Fill::Solid(SolidColor(skia::Color::BLACK));
}
plain_shape.to_mut().clear_shadows();
plain_shape.to_mut().blur = None;
plain_shape_mut.clear_shadows();
plain_shape_mut.blur = None;
let Some(drop_filter) = transformed_shadow.get_drop_shadow_filter() else {
return;
@@ -1556,6 +1558,39 @@ impl RenderState {
let mut bounds = drop_filter.compute_fast_bounds(shape_bounds);
// Account for the shadow offset so the temporary surface fully contains the shifted blur.
bounds.offset(world_offset);
// Early cull if the shadow bounds are outside the render area.
if !bounds.intersects(self.render_area) {
return;
}
if use_low_zoom_path {
let mut shadow_paint = skia::Paint::default();
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);
let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows);
drop_canvas.save_layer(&layer_rec);
drop_canvas.scale((scale, scale));
drop_canvas.translate(translation);
self.with_nested_blurs_suppressed(|state| {
state.render_shape(
&plain_shape,
clip_bounds,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
false,
Some(shadow.offset),
None,
);
});
self.surfaces.canvas(SurfaceId::DropShadows).restore();
return;
}
let filter_result =
filters::render_into_filter_surface(self, bounds, |state, temp_surface| {

View File

@@ -27,8 +27,8 @@ fn draw_stroke_on_rect(
// - The same rect if it's a center stroke
// - A bigger rect if it's an outer stroke
// - A smaller rect if it's an outer stroke
let stroke_rect = stroke.outer_rect(rect);
let mut paint = stroke.to_paint(selrect, svg_attrs, scale, antialias);
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
@@ -63,8 +63,8 @@ fn draw_stroke_on_circle(
// - The same oval if it's a center stroke
// - A bigger oval if it's an outer stroke
// - A smaller oval if it's an outer stroke
let stroke_rect = stroke.outer_rect(rect);
let mut paint = stroke.to_paint(selrect, svg_attrs, scale, antialias);
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
@@ -131,7 +131,6 @@ pub fn draw_stroke_on_path(
selrect: &Rect,
path_transform: Option<&Matrix>,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
antialias: bool,
@@ -142,7 +141,7 @@ pub fn draw_stroke_on_path(
let is_open = path.is_open();
let mut paint: skia_safe::Handle<_> =
stroke.to_stroked_paint(is_open, selrect, svg_attrs, scale, antialias);
stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias);
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
@@ -166,7 +165,6 @@ pub fn draw_stroke_on_path(
canvas,
is_open,
svg_attrs,
scale,
blur,
antialias,
);
@@ -218,7 +216,6 @@ fn handle_stroke_caps(
canvas: &skia::Canvas,
is_open: bool,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
blur: Option<&ImageFilter>,
antialias: bool,
) {
@@ -233,8 +230,7 @@ fn handle_stroke_caps(
let first_point = points.first().unwrap();
let last_point = points.last().unwrap();
let mut paint_stroke =
stroke.to_stroked_paint(is_open, selrect, svg_attrs, scale, antialias);
let mut paint_stroke = stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias);
if let Some(filter) = blur {
paint_stroke.set_image_filter(filter.clone());
@@ -405,7 +401,7 @@ fn draw_image_stroke_in_container(
// Draw the stroke based on the shape type, we are using this stroke as
// a "selector" of the area of the image we want to show.
let outer_rect = stroke.outer_rect(container);
let outer_rect = stroke.aligned_rect(container, scale);
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
@@ -450,8 +446,7 @@ fn draw_image_stroke_in_container(
}
}
let is_open = p.is_open();
let mut paint =
stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, scale, antialias);
let mut paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias);
canvas.draw_path(&path, &paint);
if stroke.render_kind(is_open) == StrokeKind::Outer {
// Small extra inner stroke to overlap with the fill
@@ -466,7 +461,6 @@ fn draw_image_stroke_in_container(
canvas,
is_open,
svg_attrs,
scale,
shape.image_filter(1.).as_ref(),
antialias,
);
@@ -662,7 +656,6 @@ fn render_internal(
&selrect,
path_transform.as_ref(),
svg_attrs,
scale,
shadow,
shape.image_filter(1.).as_ref(),
antialias,
@@ -685,14 +678,13 @@ pub fn render_text_paths(
shadow: Option<&ImageFilter>,
antialias: bool,
) {
let scale = render_state.get_scale();
let canvas = render_state
.surfaces
.canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::Strokes));
let selrect = &shape.selrect;
let svg_attrs = shape.svg_attrs.as_ref();
let mut paint: skia_safe::Handle<_> =
stroke.to_text_stroked_paint(false, selrect, svg_attrs, scale, antialias);
stroke.to_text_stroked_paint(false, selrect, svg_attrs, antialias);
if let Some(filter) = shadow {
paint.set_image_filter(filter.clone());

View File

@@ -1074,6 +1074,10 @@ impl Shape {
self.children.first()
}
pub fn children_count(&self) -> usize {
self.children_ids_iter(false).count()
}
pub fn children_ids(&self, include_hidden: bool) -> Vec<Uuid> {
if include_hidden {
return self.children.iter().rev().copied().collect();

View File

@@ -264,7 +264,7 @@ fn propagate_transform(
// If this is a layout and we're only moving don't need to reflow
if shape.has_layout() && is_resize {
entries.push_back(Modifier::reflow(shape.id));
entries.push_back(Modifier::reflow(shape.id, false));
}
if let Some(parent) = shape.parent_id.and_then(|id| shapes.get(&id)) {
@@ -272,7 +272,7 @@ fn propagate_transform(
// if the current transformation is not a move propagation.
// If it's a move propagation we don't need to reflow, the parent is already changed.
if (parent.has_layout() || parent.is_group_like()) && (is_resize || !is_propagate) {
entries.push_back(Modifier::reflow(parent.id));
entries.push_back(Modifier::reflow(parent.id, false));
}
}
}
@@ -282,7 +282,7 @@ fn propagate_reflow(
state: &State,
entries: &mut VecDeque<Modifier>,
bounds: &mut HashMap<Uuid, Bounds>,
layout_reflows: &mut Vec<Uuid>,
layout_reflows: &mut HashSet<Uuid>,
reflown: &mut HashSet<Uuid>,
modifiers: &HashMap<Uuid, Matrix>,
) {
@@ -300,20 +300,7 @@ fn propagate_reflow(
Type::Frame(Frame {
layout: Some(_), ..
}) => {
let mut skip_reflow = false;
if shape.is_layout_horizontal_fill() || shape.is_layout_vertical_fill() {
if let Some(parent_id) = shape.parent_id {
if parent_id != Uuid::nil() && !reflown.contains(&parent_id) {
// If this is a fill layout but the parent has not been reflown yet
// we wait for the next iteration for reflow
skip_reflow = true;
}
}
}
if !skip_reflow {
layout_reflows.push(*id);
}
layout_reflows.insert(*id);
}
Type::Group(Group { masked: true }) => {
let children_ids = shape.children_ids(true);
@@ -340,7 +327,7 @@ fn propagate_reflow(
if let Some(parent) = shape.parent_id.and_then(|id| shapes.get(&id)) {
if parent.has_layout() || parent.is_group_like() {
entries.push_back(Modifier::reflow(parent.id));
entries.push_back(Modifier::reflow(parent.id, false));
}
}
}
@@ -382,19 +369,20 @@ pub fn propagate_modifiers(
let mut entries: VecDeque<_> = modifiers
.iter()
.map(|entry| {
// If we receibe a identity matrix we force a reflow
// If we receive a identity matrix we force a reflow
if math::identitish(&entry.transform) {
Modifier::Reflow(entry.id)
Modifier::Reflow(entry.id, false)
} else {
Modifier::Transform(*entry)
}
})
.collect();
let shapes = &state.shapes;
let mut modifiers = HashMap::<Uuid, Matrix>::new();
let mut bounds = HashMap::<Uuid, Bounds>::new();
let mut reflown = HashSet::<Uuid>::new();
let mut layout_reflows = Vec::<Uuid>::new();
let mut layout_reflows = HashSet::<Uuid>::new();
// We first propagate the transforms to the children and then after
// recalculate the layouts. The layout can create further transforms that
@@ -412,25 +400,43 @@ pub fn propagate_modifiers(
&mut bounds,
&mut modifiers,
),
Modifier::Reflow(id) => propagate_reflow(
&id,
state,
&mut entries,
&mut bounds,
&mut layout_reflows,
&mut reflown,
&modifiers,
),
Modifier::Reflow(id, force_reflow) => {
if force_reflow {
reflown.remove(&id);
}
propagate_reflow(
&id,
state,
&mut entries,
&mut bounds,
&mut layout_reflows,
&mut reflown,
&modifiers,
)
},
}
}
for id in layout_reflows.iter() {
let mut layout_reflows_vec: Vec<Uuid> = layout_reflows.into_iter().collect();
// We sort the reflows so they are process first the ones that are more
// deep in the tree structure. This way we can be sure that the children layouts
// are already reflowed.
layout_reflows_vec.sort_unstable_by(|id_a, id_b| {
let da = shapes.get_depth(id_a);
let db = shapes.get_depth(id_b);
db.cmp(&da)
});
let mut bounds_temp = bounds.clone();
for id in layout_reflows_vec.iter() {
if reflown.contains(id) {
continue;
}
reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds);
reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds_temp);
}
layout_reflows = Vec::new();
layout_reflows = HashSet::new();
}
modifiers

View File

@@ -61,6 +61,7 @@ impl LayoutAxis {
layout_data: &LayoutData,
flex_data: &FlexData,
) -> Self {
let num_child = shape.children_count();
if flex_data.is_row() {
Self {
main_size: layout_bounds.width(),
@@ -73,8 +74,8 @@ impl LayoutAxis {
padding_across_end: layout_data.padding_bottom,
gap_main: layout_data.column_gap,
gap_across: layout_data.row_gap,
is_auto_main: shape.is_layout_horizontal_auto(),
is_auto_across: shape.is_layout_vertical_auto(),
is_auto_main: num_child > 0 && shape.is_layout_horizontal_auto(),
is_auto_across: num_child > 0 && shape.is_layout_vertical_auto(),
}
} else {
Self {
@@ -88,8 +89,8 @@ impl LayoutAxis {
padding_across_end: layout_data.padding_right,
gap_main: layout_data.row_gap,
gap_across: layout_data.column_gap,
is_auto_main: shape.is_layout_vertical_auto(),
is_auto_across: shape.is_layout_horizontal_auto(),
is_auto_main: num_child > 0 && shape.is_layout_vertical_auto(),
is_auto_across: num_child > 0 && shape.is_layout_horizontal_auto(),
}
}
}
@@ -345,7 +346,10 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat
let mut size =
track.across_size - child.margin_across_start - child.margin_across_end;
size = size.clamp(child.min_across_size, child.max_across_size);
size = f32::min(size, layout_axis.across_space());
if !layout_axis.is_auto_across {
size = f32::min(size, layout_axis.across_space());
}
child.across_size = size;
}
}
@@ -620,9 +624,12 @@ pub fn reflow_flex_layout(
let mut transform = Matrix::default();
let mut force_reflow = false;
if (new_width - child_bounds.width()).abs() > MIN_SIZE
|| (new_height - child_bounds.height()).abs() > MIN_SIZE
{
// When the child is fill we need to force a reflow
force_reflow = true;
transform.post_concat(&math::resize_matrix(
layout_bounds,
child_bounds,
@@ -637,7 +644,7 @@ pub fn reflow_flex_layout(
result.push_back(Modifier::transform_propagate(child.id, transform));
if child.has_layout() {
result.push_back(Modifier::reflow(child.id));
result.push_back(Modifier::reflow(child.id, force_reflow));
}
shape_anchor = next_anchor(

View File

@@ -765,9 +765,12 @@ pub fn reflow_grid_layout(
let mut transform = Matrix::default();
let mut force_reflow = false;
if (new_width - child_bounds.width()).abs() > MIN_SIZE
|| (new_height - child_bounds.height()).abs() > MIN_SIZE
{
// When the child is a fill it needs to be reflown
force_reflow = true;
transform.post_concat(&math::resize_matrix(
&layout_bounds,
&child_bounds,
@@ -793,7 +796,7 @@ pub fn reflow_grid_layout(
result.push_back(Modifier::transform_propagate(child.id, transform));
if child.has_layout() {
result.push_back(Modifier::reflow(child.id));
result.push_back(Modifier::reflow(child.id, force_reflow));
}
}

View File

@@ -1,3 +1,4 @@
use crate::math::is_close_to;
use crate::shapes::fills::{Fill, SolidColor};
use skia_safe::{self as skia, Rect};
@@ -144,6 +145,15 @@ impl Stroke {
}
}
pub fn aligned_rect(&self, rect: &Rect, scale: f32) -> Rect {
let stroke_rect = self.outer_rect(rect);
if self.kind != StrokeKind::Center {
return stroke_rect;
}
align_rect_to_half_pixel(&stroke_rect, self.width, scale)
}
pub fn outer_corners(&self, corners: &Corners) -> Corners {
let offset = match self.kind {
StrokeKind::Center => 0.0,
@@ -162,7 +172,6 @@ impl Stroke {
&self,
rect: &Rect,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
antialias: bool,
) -> skia::Paint {
let mut paint = self.fill.to_paint(rect, antialias);
@@ -171,7 +180,7 @@ impl Stroke {
let width = match self.kind {
StrokeKind::Inner => self.width,
StrokeKind::Center => self.width,
StrokeKind::Outer => self.width + (1. / scale),
StrokeKind::Outer => self.width,
};
paint.set_stroke_width(width);
@@ -230,10 +239,9 @@ impl Stroke {
is_open: bool,
rect: &Rect,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
antialias: bool,
) -> skia::Paint {
let mut paint = self.to_paint(rect, svg_attrs, scale, antialias);
let mut paint = self.to_paint(rect, svg_attrs, antialias);
match self.render_kind(is_open) {
StrokeKind::Inner => {
paint.set_stroke_width(2. * paint.stroke_width());
@@ -254,10 +262,9 @@ impl Stroke {
is_open: bool,
rect: &Rect,
svg_attrs: Option<&SvgAttrs>,
scale: f32,
antialias: bool,
) -> skia::Paint {
let mut paint = self.to_paint(rect, svg_attrs, scale, antialias);
let mut paint = self.to_paint(rect, svg_attrs, antialias);
match self.render_kind(is_open) {
StrokeKind::Inner => {
paint.set_stroke_width(2. * paint.stroke_width());
@@ -284,6 +291,38 @@ impl Stroke {
}
}
fn align_rect_to_half_pixel(rect: &Rect, stroke_width: f32, scale: f32) -> Rect {
if scale <= 0.0 {
return *rect;
}
let stroke_pixels = stroke_width * scale;
let stroke_pixels_rounded = stroke_pixels.round();
if !is_close_to(stroke_pixels, stroke_pixels_rounded) {
return *rect;
}
if (stroke_pixels_rounded as i32) % 2 == 0 {
return *rect;
}
let left_px = rect.left * scale;
let top_px = rect.top * scale;
let target_frac = 0.5;
let dx_px = target_frac - (left_px - left_px.floor());
let dy_px = target_frac - (top_px - top_px.floor());
if is_close_to(dx_px, 0.0) && is_close_to(dy_px, 0.0) {
return *rect;
}
Rect::from_xywh(
rect.left + (dx_px / scale),
rect.top + (dy_px / scale),
rect.width(),
rect.height(),
)
}
fn cap_margin_for_cap(cap: Option<StrokeCap>, width: f32) -> f32 {
match cap {
Some(StrokeCap::LineArrow)

View File

@@ -8,7 +8,7 @@ use skia::Matrix;
#[derive(PartialEq, Debug, Clone)]
pub enum Modifier {
Transform(TransformEntry),
Reflow(Uuid),
Reflow(Uuid, bool),
}
impl Modifier {
@@ -18,8 +18,8 @@ impl Modifier {
pub fn parent(id: Uuid, transform: Matrix) -> Self {
Modifier::Transform(TransformEntry::parent(id, transform))
}
pub fn reflow(id: Uuid) -> Self {
Modifier::Reflow(id)
pub fn reflow(id: Uuid, force_reflow: bool) -> Self {
Modifier::Reflow(id, force_reflow)
}
}

View File

@@ -177,6 +177,26 @@ impl ShapesPoolImpl {
}
}
// Given an id, returns the depth in the tree-shaped structure
// of shapes.
pub fn get_depth(&self, id: &Uuid) -> usize {
if id == &Uuid::nil() {
return 0;
}
let Some(idx) = self.uuid_to_idx.get(id) else {
return 0;
};
let shape = &self.shapes[*idx];
let Some(parent_id) = shape.parent_id else {
return 0;
};
self.get_depth(&parent_id) + 1
}
#[allow(dead_code)]
pub fn iter(&self) -> std::slice::Iter<'_, Shape> {
self.shapes.iter()

View File

@@ -31,21 +31,17 @@ impl From<RawFontStyle> for FontStyle {
#[no_mangle]
pub extern "C" fn store_font(
a1: u32,
b1: u32,
c1: u32,
d1: u32,
a2: u32,
b2: u32,
c2: u32,
d2: u32,
a: u32,
b: u32,
c: u32,
d: u32,
weight: u32,
style: u8,
is_emoji: bool,
is_fallback: bool,
) {
with_state_mut!(state, {
let id = uuid_from_u32_quartet(a2, b2, c2, d2);
let id = uuid_from_u32_quartet(a, b, c, d);
let font_bytes = mem::bytes();
let font_style = RawFontStyle::from(style);
@@ -57,9 +53,6 @@ pub extern "C" fn store_font(
.add(family, &font_bytes, is_emoji, is_fallback);
mem::free_bytes();
let shape_id = uuid_from_u32_quartet(a1, b1, c1, d1);
state.touch_shape(shape_id);
});
}

View File

@@ -384,6 +384,7 @@ pub extern "C" fn update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) {
if let Some(shape) = state.shapes.get_mut(&shape_id) {
update_text_layout(shape);
}
state.touch_shape(shape_id);
});
}