mirror of
https://github.com/penpot/penpot.git
synced 2026-05-30 12:18:13 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
197c7c0f9a
@ -45,12 +45,12 @@ python3 tools/gh.py issues "2.16.0" --state all
|
|||||||
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Label exclusion rules:**
|
**Exclusion rules:**
|
||||||
- `release blocker` — Internal release-blocking bugs not relevant to end users
|
- `no changelog` label — Chore/refactor work that doesn't need a changelog entry
|
||||||
- `no changelog` — Chore/refactor work that doesn't need a changelog entry
|
- `Task` issue type — Internal chores are not user-facing; filter these out after fetching
|
||||||
|
|
||||||
The script outputs JSON with each entry containing `number`, `title`, `state`,
|
The script outputs JSON with each entry containing `number`, `title`, `state`,
|
||||||
`labels`, and `closing_prs` (the PRs that fix each issue).
|
`issue_type`, `labels`, and `closing_prs` (the PRs that fix each issue).
|
||||||
|
|
||||||
### 3. Identify missing entries (optional)
|
### 3. Identify missing entries (optional)
|
||||||
|
|
||||||
@ -84,34 +84,27 @@ The `prs` command returns JSON with `number`, `title`, `body`, `state`,
|
|||||||
`merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in
|
`merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in
|
||||||
batches of 50 via GraphQL to stay within API limits.
|
batches of 50 via GraphQL to stay within API limits.
|
||||||
|
|
||||||
### 5. Categorize entries
|
### 5. Categorize entries — strictly by issue type, never by labels or emoji
|
||||||
|
|
||||||
Use the **Issue Type** field (GitHub's native issue type, accessible via GraphQL
|
Use the **Issue Type** field (GitHub's native issue type, exposed as
|
||||||
`issueType { name }`) to determine which section an entry belongs to.
|
`issue_type` in the `gh.py` JSON output) to determine which section an entry
|
||||||
**Do not** use labels or title emoji prefixes as the source of truth — they are
|
belongs to.
|
||||||
often inaccurate or missing.
|
|
||||||
|
|
||||||
| Issue Type (`issueType.name`) | Changelog section |
|
> **⚠️ CRITICAL: Never use labels or title emoji prefixes for categorization.**
|
||||||
|------------------------------|-------------------|
|
> Labels like `bug` and `enhancement`, as well as title prefixes like `:bug:`
|
||||||
|
> and `:sparkles:`, are frequently inaccurate, missing, or contradictory to the
|
||||||
|
> actual issue type. The `issue_type` field from `gh.py` is the single source
|
||||||
|
> of truth.
|
||||||
|
|
||||||
|
| `issue_type` value | Changelog section |
|
||||||
|
|--------------------|-------------------|
|
||||||
| `Bug` | `### :bug: Bugs fixed` |
|
| `Bug` | `### :bug: Bugs fixed` |
|
||||||
| `Feature` or `Enhancement` | `### :sparkles: New features & Enhancements` |
|
| `Feature` or `Enhancement` | `### :sparkles: New features & Enhancements` |
|
||||||
| No type set | Fetch the issue and check its labels as a fallback: `bug` label → bugs section, otherwise default to enhancements |
|
| `Task` | **Exclude** — internal chores are not user-facing |
|
||||||
|
| `null` (not set) | Check labels as a fallback: `bug` label → bugs, otherwise enhancements |
|
||||||
|
|
||||||
To fetch Issue Types for all issues in a milestone efficiently, use a single
|
The `gh.py` issues command already includes `issue_type` in every entry's
|
||||||
GraphQL query with aliases rather than N+1 REST calls:
|
output. **No separate GraphQL query is needed.**
|
||||||
|
|
||||||
```graphql
|
|
||||||
query {
|
|
||||||
repository(owner: "penpot", name: "penpot") {
|
|
||||||
i123: issue(number: 123) {
|
|
||||||
number state milestone { number } issueType { name }
|
|
||||||
}
|
|
||||||
i456: issue(number: 456) {
|
|
||||||
number state milestone { number } issueType { name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Community contribution attribution:** If the issue or its fix PR has the
|
**Community contribution attribution:** If the issue or its fix PR has the
|
||||||
`community contribution` label, add an attribution `(by @<github_username>)`
|
`community contribution` label, add an attribution `(by @<github_username>)`
|
||||||
@ -224,7 +217,7 @@ Read the top of `CHANGES.md` and confirm:
|
|||||||
can find the code changes.
|
can find the code changes.
|
||||||
- **Latest version first.** New sections are inserted at the top of the
|
- **Latest version first.** New sections are inserted at the top of the
|
||||||
changelog, below the `# CHANGELOG` header.
|
changelog, below the `# CHANGELOG` header.
|
||||||
- **Issue Type determines section.** Use GitHub's `issueType` field (Bug → `:bug:`, Feature/Enhancement → `:sparkles:`) to categorize entries. Ignore labels and title emoji prefixes — they are unreliable for categorization.
|
- **Issue Type determines section — exclusively.** Use the `issue_type` field from `gh.py` output (Bug → `:bug:`, Feature/Enhancement → `:sparkles:`). **Do not** use labels (`bug`, `enhancement`) or title emoji prefixes (`:bug:`, `:sparkles:`) — they are frequently wrong or contradictory. The `issue_type` is the single source of truth.
|
||||||
- **User-facing descriptions.** Write from the user's perspective — describe
|
- **User-facing descriptions.** Write from the user's perspective — describe
|
||||||
what broke and what was fixed, not internal implementation details.
|
what broke and what was fixed, not internal implementation details.
|
||||||
- **Community attribution.** When the issue or fix PR has the
|
- **Community attribution.** When the issue or fix PR has the
|
||||||
@ -233,8 +226,9 @@ Read the top of `CHANGES.md` and confirm:
|
|||||||
issue author) for the attribution.
|
issue author) for the attribution.
|
||||||
- **Only closed issues.** An issue must have `state: "closed"` to appear in
|
- **Only closed issues.** An issue must have `state: "closed"` to appear in
|
||||||
the changelog. Open unresolved issues are omitted.
|
the changelog. Open unresolved issues are omitted.
|
||||||
- **Excluded labels.** Issues with `release blocker` or `no changelog` labels
|
- **Excluded issues.** Issues with `no changelog` label must be excluded.
|
||||||
must be excluded from the changelog.
|
Issues with `issue_type: "Task"` must also be excluded — they are internal
|
||||||
|
chores, not user-facing changes.
|
||||||
- **Multiple PRs per issue.** If multiple PRs fix the same issue, list them
|
- **Multiple PRs per issue.** If multiple PRs fix the same issue, list them
|
||||||
comma-separated inline: `(PR: [#A](url), [#B](url))`.
|
comma-separated inline: `(PR: [#A](url), [#B](url))`.
|
||||||
- **Duplicate removal.** If an entry already exists in a prior version section,
|
- **Duplicate removal.** If an entry already exists in a prior version section,
|
||||||
|
|||||||
@ -82,6 +82,7 @@
|
|||||||
- Restore deleted team files in bulk instead of per file (by @Dexterity104) [#9246](https://github.com/penpot/penpot/issues/9246) (PR: [#9248](https://github.com/penpot/penpot/pull/9248))
|
- Restore deleted team files in bulk instead of per file (by @Dexterity104) [#9246](https://github.com/penpot/penpot/issues/9246) (PR: [#9248](https://github.com/penpot/penpot/pull/9248))
|
||||||
- Preserve Inkscape labels when pasting SVGs (by @jeffrey701) [#7869](https://github.com/penpot/penpot/issues/7869) (PR: [#9252](https://github.com/penpot/penpot/pull/9252))
|
- Preserve Inkscape labels when pasting SVGs (by @jeffrey701) [#7869](https://github.com/penpot/penpot/issues/7869) (PR: [#9252](https://github.com/penpot/penpot/pull/9252))
|
||||||
- Add Alt+click to expand layer subtree (by @MilosM348) [#7736](https://github.com/penpot/penpot/issues/7736) (PR: [#9179](https://github.com/penpot/penpot/pull/9179))
|
- Add Alt+click to expand layer subtree (by @MilosM348) [#7736](https://github.com/penpot/penpot/issues/7736) (PR: [#9179](https://github.com/penpot/penpot/pull/9179))
|
||||||
|
- Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068))
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
### :bug: Bugs fixed
|
||||||
- Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063))
|
- Add Shift+Numpad aliases for zoom shortcuts (by @RenzoMXD) [#2457](https://github.com/penpot/penpot/issues/2457) (PR: [#9063](https://github.com/penpot/penpot/pull/9063))
|
||||||
@ -89,7 +90,6 @@
|
|||||||
- Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949))
|
- Add guide locking and fix locked element selection in viewer (by @Dexterity104) [#8358](https://github.com/penpot/penpot/issues/8358) (PR: [#8949](https://github.com/penpot/penpot/pull/8949))
|
||||||
- Add natural sorting on token names [#8635](https://github.com/penpot/penpot/issues/8635) (PR: [#8672](https://github.com/penpot/penpot/pull/8672))
|
- Add natural sorting on token names [#8635](https://github.com/penpot/penpot/issues/8635) (PR: [#8672](https://github.com/penpot/penpot/pull/8672))
|
||||||
- Fix warnings for unsupported token $type (by @Dexterity104) [#8790](https://github.com/penpot/penpot/issues/8790) (PR: [#8873](https://github.com/penpot/penpot/pull/8873))
|
- Fix warnings for unsupported token $type (by @Dexterity104) [#8790](https://github.com/penpot/penpot/issues/8790) (PR: [#8873](https://github.com/penpot/penpot/pull/8873))
|
||||||
- Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068))
|
|
||||||
- Apply styles to selection (by @AzazelN28) [#9661](https://github.com/penpot/penpot/issues/9661) (PR: [#8625](https://github.com/penpot/penpot/pull/8625))
|
- Apply styles to selection (by @AzazelN28) [#9661](https://github.com/penpot/penpot/issues/9661) (PR: [#8625](https://github.com/penpot/penpot/pull/8625))
|
||||||
- Fix Alt/Option to draw shapes from center point (by @offreal) [#8360](https://github.com/penpot/penpot/issues/8360) (PR: [#8361](https://github.com/penpot/penpot/pull/8361))
|
- Fix Alt/Option to draw shapes from center point (by @offreal) [#8360](https://github.com/penpot/penpot/issues/8360) (PR: [#8361](https://github.com/penpot/penpot/pull/8361))
|
||||||
- Fix library update button freezing [#9330](https://github.com/penpot/penpot/issues/9330) (PR: [#9513](https://github.com/penpot/penpot/pull/9513))
|
- Fix library update button freezing [#9330](https://github.com/penpot/penpot/issues/9330) (PR: [#9513](https://github.com/penpot/penpot/pull/9513))
|
||||||
@ -158,6 +158,7 @@
|
|||||||
- Fix numeric input changes not saved when clicking on viewport [#9491](https://github.com/penpot/penpot/issues/9491) (PR: [#9548](https://github.com/penpot/penpot/pull/9548))
|
- Fix numeric input changes not saved when clicking on viewport [#9491](https://github.com/penpot/penpot/issues/9491) (PR: [#9548](https://github.com/penpot/penpot/pull/9548))
|
||||||
- Fix resize cursor appearing on login and register buttons [#9505](https://github.com/penpot/penpot/issues/9505) (PR: [#9590](https://github.com/penpot/penpot/pull/9590))
|
- Fix resize cursor appearing on login and register buttons [#9505](https://github.com/penpot/penpot/issues/9505) (PR: [#9590](https://github.com/penpot/penpot/pull/9590))
|
||||||
- Fix version restore restoring first previewed version instead of selected one [#9588](https://github.com/penpot/penpot/issues/9588) (PR: [#9626](https://github.com/penpot/penpot/pull/9626))
|
- Fix version restore restoring first previewed version instead of selected one [#9588](https://github.com/penpot/penpot/issues/9588) (PR: [#9626](https://github.com/penpot/penpot/pull/9626))
|
||||||
|
- Fix incorrect error message when applying tokens while editing text [#9620](https://github.com/penpot/penpot/issues/9620) (PR: [#9708](https://github.com/penpot/penpot/pull/9708))
|
||||||
|
|
||||||
|
|
||||||
## 2.15.4 (Unreleased)
|
## 2.15.4 (Unreleased)
|
||||||
|
|||||||
@ -247,13 +247,21 @@
|
|||||||
(:organization-id claims)
|
(:organization-id claims)
|
||||||
(assoc :org-team-id accepted-team-id)))))
|
(assoc :org-team-id accepted-team-id)))))
|
||||||
|
|
||||||
;; If we have not logged-in user, and invitation comes with member-id we
|
(do
|
||||||
;; redirect user to login, if no memeber-id is present and in the invitation
|
;; If the user is not logged-in and the token is invalid we throw the error
|
||||||
;; token and registration is enabled, we redirect user the the register page.
|
;; Taiga issue #14182
|
||||||
{:invitation-token token
|
(when (nil? invitation)
|
||||||
:iss :team-invitation
|
(ex/raise :type :validation
|
||||||
:redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register)
|
:code :invalid-token
|
||||||
:state :pending})))
|
:hint "no invitation associated with the token"))
|
||||||
|
|
||||||
|
;; If we have not logged-in user, and invitation comes with member-id we
|
||||||
|
;; redirect user to login, if no member-id is present and in the invitation
|
||||||
|
;; token and registration is enabled, we redirect user the the register page.
|
||||||
|
{:invitation-token token
|
||||||
|
:iss :team-invitation
|
||||||
|
:redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register)
|
||||||
|
:state :pending}))))
|
||||||
|
|
||||||
;; --- Default
|
;; --- Default
|
||||||
|
|
||||||
|
|||||||
@ -1062,7 +1062,9 @@ test("BUG: 14136 Apply grid layout padding token to a shape from the sidebar doe
|
|||||||
await tokenDimensionMd.click();
|
await tokenDimensionMd.click();
|
||||||
|
|
||||||
// Expand padding to all sides
|
// Expand padding to all sides
|
||||||
await layoutSection.getByRole('button', { name: 'Show 4 sided padding options' }).click();
|
await layoutSection
|
||||||
|
.getByRole("button", { name: "Show 4 sided padding options" })
|
||||||
|
.click();
|
||||||
const topPaddingSection = layoutSection.getByLabel("Top padding");
|
const topPaddingSection = layoutSection.getByLabel("Top padding");
|
||||||
const bottomPaddingSection = layoutSection.getByLabel("Bottom padding");
|
const bottomPaddingSection = layoutSection.getByLabel("Bottom padding");
|
||||||
await expect(topPaddingSection).toBeVisible();
|
await expect(topPaddingSection).toBeVisible();
|
||||||
@ -1073,13 +1075,131 @@ test("BUG: 14136 Apply grid layout padding token to a shape from the sidebar doe
|
|||||||
|
|
||||||
// Check if the value of the attribute is still correct
|
// Check if the value of the attribute is still correct
|
||||||
await expect(
|
await expect(
|
||||||
await topPaddingSection.getByRole("button", { name: "dimension.md" }).textContent()
|
await topPaddingSection
|
||||||
|
.getByRole("button", { name: "dimension.md" })
|
||||||
|
.textContent(),
|
||||||
).toBe("16");
|
).toBe("16");
|
||||||
await expect(
|
await expect(
|
||||||
await bottomPaddingSection.getByRole("button", { name: "dimension.md" }).textContent()
|
await bottomPaddingSection
|
||||||
|
.getByRole("button", { name: "dimension.md" })
|
||||||
|
.textContent(),
|
||||||
).toBe("16");
|
).toBe("16");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("BUG: 14200, Tokens in sets are applied when clicking on Save during creation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Setup the workspace with token features enabled
|
||||||
|
const {
|
||||||
|
workspacePage,
|
||||||
|
tokensSidebar,
|
||||||
|
tokenContextMenuForToken,
|
||||||
|
tokenThemesSetsSidebar,
|
||||||
|
tokenSetGroupItems,
|
||||||
|
tokensUpdateCreateModal,
|
||||||
|
} = await setupTokensFileRender(page, {
|
||||||
|
flags: ["enable-token-combobox", "enable-feature-token-input"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeSetInput = async (sidebar, setName, finalKey = "Enter") => {
|
||||||
|
const setInput = sidebar.locator("input:focus");
|
||||||
|
await expect(setInput).toBeVisible();
|
||||||
|
await setInput.fill(setName);
|
||||||
|
await setInput.press(finalKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSet = async (sidebar, setName, finalKey = "Enter") => {
|
||||||
|
const tokensTabButton = sidebar
|
||||||
|
.getByRole("button", { name: "Add set" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await changeSetInput(sidebar, setName, (finalKey = "Enter"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select rectangle layer
|
||||||
|
await page.getByRole("tab", { name: "Layers" }).click();
|
||||||
|
|
||||||
|
await workspacePage.layers
|
||||||
|
.getByTestId("layer-row")
|
||||||
|
.filter({ hasText: "Rectangle" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Tokens" }).click();
|
||||||
|
|
||||||
|
// Create nested token set and activate it
|
||||||
|
await createSet(tokenThemesSetsSidebar, "set/first");
|
||||||
|
|
||||||
|
await tokenThemesSetsSidebar.getByRole("button", { name: "first" }).click();
|
||||||
|
|
||||||
|
await tokenThemesSetsSidebar
|
||||||
|
.getByRole("button", { name: "first" })
|
||||||
|
.getByRole("checkbox")
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Create token in nested set
|
||||||
|
await unfoldTokenType(tokensSidebar, "Border radius");
|
||||||
|
|
||||||
|
// Create border token
|
||||||
|
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||||
|
await tokensTabPanel
|
||||||
|
.getByRole("button", { name: `Add Token: Border radius` })
|
||||||
|
.click();
|
||||||
|
await expect(tokensUpdateCreateModal).toBeVisible();
|
||||||
|
|
||||||
|
const nameField = tokensUpdateCreateModal.getByLabel("Name");
|
||||||
|
await nameField.fill("border");
|
||||||
|
|
||||||
|
const valueField = tokensUpdateCreateModal.getByRole("combobox", {
|
||||||
|
name: "Value",
|
||||||
|
});
|
||||||
|
await valueField.fill("20");
|
||||||
|
|
||||||
|
const submitButton = tokensUpdateCreateModal.getByRole("button", {
|
||||||
|
name: "Save",
|
||||||
|
});
|
||||||
|
await submitButton.click();
|
||||||
|
await expect(tokensUpdateCreateModal).not.toBeVisible();
|
||||||
|
|
||||||
|
// Check "border" token is not applied while creating.
|
||||||
|
|
||||||
|
const borderRadiusSection = page.getByRole("region", {
|
||||||
|
name: "Border radius section",
|
||||||
|
});
|
||||||
|
await expect(borderRadiusSection).toBeVisible();
|
||||||
|
|
||||||
|
// Check if token pill is visible on design tab on right sidebar
|
||||||
|
const borderTokenPill = borderRadiusSection.getByRole("button", {
|
||||||
|
name: "border",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(borderTokenPill).not.toBeVisible();
|
||||||
|
|
||||||
|
//Create new set and activate it
|
||||||
|
|
||||||
|
await createSet(tokenThemesSetsSidebar, "set/other");
|
||||||
|
|
||||||
|
await tokenThemesSetsSidebar.getByRole("button", { name: "other" }).click();
|
||||||
|
|
||||||
|
await tokenThemesSetsSidebar
|
||||||
|
.getByRole("button", { name: "other" })
|
||||||
|
.getByRole("checkbox")
|
||||||
|
.click();
|
||||||
|
|
||||||
|
//Create the same token in new set
|
||||||
|
await unfoldTokenType(tokensSidebar, "Border radius");
|
||||||
|
await tokensTabPanel
|
||||||
|
.getByRole("button", { name: `Add Token: Border radius` })
|
||||||
|
.click();
|
||||||
|
await expect(tokensUpdateCreateModal).toBeVisible();
|
||||||
|
await nameField.fill("border");
|
||||||
|
await valueField.fill("50");
|
||||||
|
await valueField.press("Enter");
|
||||||
|
await expect(tokensUpdateCreateModal).not.toBeVisible();
|
||||||
|
await expect(borderRadiusSection).toBeVisible();
|
||||||
|
await expect(borderTokenPill).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("BUG: 14191, Apply tokens from different set", async ({ page }) => {
|
test("BUG: 14191, Apply tokens from different set", async ({ page }) => {
|
||||||
const {
|
const {
|
||||||
workspacePage,
|
workspacePage,
|
||||||
|
|||||||
@ -169,6 +169,49 @@ test.describe("Tokens - node tree", () => {
|
|||||||
await expect(darkerNodeToken).toBeVisible();
|
await expect(darkerNodeToken).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Renaming a token into a collapsed group auto-expands that group", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { tokensSidebar, tokensUpdateCreateModal, tokenContextMenuForToken } =
|
||||||
|
await setupTokensFileRender(page);
|
||||||
|
|
||||||
|
// Create tokens in two separate groups
|
||||||
|
await createToken(page, "Color", "dark.base", "Value", "#000000");
|
||||||
|
await createToken(page, "Color", "light.accent", "Value", "#ffffff");
|
||||||
|
|
||||||
|
const lightGroup = tokensSidebar.getByRole("button", {
|
||||||
|
name: "light",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collapse the light group so its children are hidden
|
||||||
|
await lightGroup.click();
|
||||||
|
|
||||||
|
const lightAccentToken = tokensSidebar.getByRole("button", {
|
||||||
|
name: "accent",
|
||||||
|
});
|
||||||
|
await expect(lightAccentToken).not.toBeVisible();
|
||||||
|
|
||||||
|
// Open the edit modal for the dark.base token
|
||||||
|
const darkBaseToken = tokensSidebar.getByRole("button", { name: "base" });
|
||||||
|
await darkBaseToken.click({ button: "right" });
|
||||||
|
await tokenContextMenuForToken.getByText("Edit token").click();
|
||||||
|
|
||||||
|
await expect(tokensUpdateCreateModal).toBeVisible();
|
||||||
|
|
||||||
|
// Rename to move it into the collapsed light group
|
||||||
|
const nameField = tokensUpdateCreateModal.getByLabel("Name");
|
||||||
|
await nameField.fill("light.base");
|
||||||
|
await tokensUpdateCreateModal
|
||||||
|
.getByRole("button", { name: "Save" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// After rename, light group should be auto-expanded and both tokens visible
|
||||||
|
await expect(lightGroup).toBeVisible();
|
||||||
|
await expect(lightAccentToken).toBeVisible();
|
||||||
|
await expect(tokensSidebar.getByRole("button", { name: "base" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("User removes node and all child tokens", async ({ page }) => {
|
test("User removes node and all child tokens", async ({ page }) => {
|
||||||
const { tokensSidebar } = await setupTokensFileRender(page);
|
const { tokensSidebar } = await setupTokensFileRender(page);
|
||||||
|
|
||||||
|
|||||||
@ -149,7 +149,15 @@
|
|||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(when-let [team-id (or team-id (:current-team-id state))]
|
(when-let [team-id (or team-id (:current-team-id state))]
|
||||||
(->> (rp/cmd! :get-team-members {:team-id team-id})
|
(->> (rp/cmd! :get-team-members {:team-id team-id})
|
||||||
(rx/map (partial members-fetched team-id))))))))
|
(rx/map (partial members-fetched team-id))
|
||||||
|
(rx/catch (fn [cause]
|
||||||
|
(let [{:keys [type]} (ex-data cause)]
|
||||||
|
(if (= :not-found type)
|
||||||
|
(do
|
||||||
|
(log/warn :hint "fetch-members: team not found, skipping"
|
||||||
|
:team-id (str team-id))
|
||||||
|
(rx/empty))
|
||||||
|
(rx/throw cause)))))))))))
|
||||||
|
|
||||||
(defn- invitations-fetched
|
(defn- invitations-fetched
|
||||||
[team-id invitations]
|
[team-id invitations]
|
||||||
|
|||||||
@ -40,6 +40,10 @@
|
|||||||
(def ^:private xf:without-uuid-zero
|
(def ^:private xf:without-uuid-zero
|
||||||
(remove #(= % uuid/zero)))
|
(remove #(= % uuid/zero)))
|
||||||
|
|
||||||
|
;; Lets set-wasm-modifiers call clean-modifiers only on the
|
||||||
|
;; non-translation→translation transition instead of every frame.
|
||||||
|
(def ^:private wasm-structure-modifiers-active? (volatile! false))
|
||||||
|
|
||||||
;; Tracks whether the WASM renderer is currently in "interactive
|
;; Tracks whether the WASM renderer is currently in "interactive
|
||||||
;; transform" mode (a drag / resize / rotate gesture in progress).
|
;; transform" mode (a drag / resize / rotate gesture in progress).
|
||||||
;; Paired with `set-modifiers-start` / `set-modifiers-end` so the
|
;; Paired with `set-modifiers-start` / `set-modifiers-end` so the
|
||||||
@ -305,6 +309,7 @@
|
|||||||
;; skip shadows / blur).
|
;; skip shadows / blur).
|
||||||
(ensure-interactive-transform-end!)
|
(ensure-interactive-transform-end!)
|
||||||
(wasm.api/clean-modifiers)
|
(wasm.api/clean-modifiers)
|
||||||
|
(vreset! wasm-structure-modifiers-active? false)
|
||||||
(set-wasm-props! (dsh/lookup-page-objects state) (:wasm-props state) [])))
|
(set-wasm-props! (dsh/lookup-page-objects state) (:wasm-props state) [])))
|
||||||
|
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
@ -629,34 +634,10 @@
|
|||||||
(ptk/reify ::set-temporary-modifiers
|
(ptk/reify ::set-temporary-modifiers
|
||||||
ptk/EffectEvent
|
ptk/EffectEvent
|
||||||
(effect [_ _ _]
|
(effect [_ _ _]
|
||||||
(rx/push! ms/wasm-modifiers modifiers))))
|
(rx/push! ms/wasm-modifiers (into {} modifiers)))))
|
||||||
|
|
||||||
(def ^:private xf:map-key (map key))
|
(def ^:private xf:map-key (map key))
|
||||||
|
|
||||||
(defn- expand-translation-entry
|
|
||||||
"Expand one translation-only geometry entry into [descendant-id matrix]
|
|
||||||
pairs covering the moved shape's full subtree (every descendant gets
|
|
||||||
the same matrix)."
|
|
||||||
[[id data] objects subtree-ids-by-id]
|
|
||||||
(let [m (:transform data)
|
|
||||||
sub (or (get subtree-ids-by-id id)
|
|
||||||
(cfh/get-children-ids-with-self objects id))]
|
|
||||||
(map (fn [sid] [sid m]) sub)))
|
|
||||||
|
|
||||||
(defn- expand-translation-modifiers
|
|
||||||
"Pure translation propagates as identity to descendants: every shape in
|
|
||||||
the subtree gets the same matrix. Builds the flat [id matrix] list
|
|
||||||
directly, skipping the WASM tree walk + FFI roundtrip used by
|
|
||||||
`propagate-modifiers` for the general (resize/rotate) case.
|
|
||||||
|
|
||||||
Only safe when pixel-snap is off: WASM applies pixel correction
|
|
||||||
per-shape (different scale/translation per descendant), which we
|
|
||||||
can't replicate cheaply on the CLJS side."
|
|
||||||
[geometry-entries objects subtree-ids-by-id]
|
|
||||||
(into []
|
|
||||||
(mapcat #(expand-translation-entry % objects subtree-ids-by-id))
|
|
||||||
geometry-entries))
|
|
||||||
|
|
||||||
(defn- translate-selrect
|
(defn- translate-selrect
|
||||||
"Shift `selrect`'s center by (tx, ty). Width/height/transform are
|
"Shift `selrect`'s center by (tx, ty). Width/height/transform are
|
||||||
invariant under pure translation, so only `:center` moves."
|
invariant under pure translation, so only `:center` moves."
|
||||||
@ -688,11 +669,12 @@
|
|||||||
(ptk/reify ::set-wasm-modifiers
|
(ptk/reify ::set-wasm-modifiers
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [property-changes
|
(let [property-changes (extract-property-changes modif-tree)]
|
||||||
(extract-property-changes modif-tree)]
|
(if (d/not-empty? property-changes)
|
||||||
(-> state
|
(-> state
|
||||||
(assoc :prev-wasm-props (:wasm-props state))
|
(assoc :prev-wasm-props (:wasm-props state))
|
||||||
(assoc :wasm-props property-changes))))
|
(assoc :wasm-props property-changes))
|
||||||
|
state)))
|
||||||
|
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
@ -702,29 +684,33 @@
|
|||||||
;; thread is not blocked. The pair is closed in
|
;; thread is not blocked. The pair is closed in
|
||||||
;; `clear-local-transform`.
|
;; `clear-local-transform`.
|
||||||
(ensure-interactive-transform-start!)
|
(ensure-interactive-transform-start!)
|
||||||
(wasm.api/clean-modifiers)
|
(let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
|
||||||
(let [prev-wasm-props (:prev-wasm-props state)
|
translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
|
||||||
wasm-props (:wasm-props state)
|
|
||||||
objects (dsh/lookup-page-objects state)
|
|
||||||
snap-pixel?
|
|
||||||
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
|
|
||||||
|
|
||||||
translation?
|
(if translation?
|
||||||
(every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
|
;; Pure translation: no structure changes needed. If structure
|
||||||
(set-wasm-props! objects prev-wasm-props wasm-props)
|
;; modifiers were active from a previous non-translation frame
|
||||||
(when-not translation?
|
;; (e.g. shape hovered over a frame then dragged back out),
|
||||||
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)))
|
;; clear them now so the shape is not clipped by the old frame.
|
||||||
|
(when @wasm-structure-modifiers-active?
|
||||||
|
(wasm.api/clean-modifiers)
|
||||||
|
(vreset! wasm-structure-modifiers-active? false))
|
||||||
|
(let [objects (dsh/lookup-page-objects state)]
|
||||||
|
(set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state))
|
||||||
|
(wasm.api/clean-modifiers)
|
||||||
|
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
|
||||||
|
(vreset! wasm-structure-modifiers-active? true)))
|
||||||
(let [geometry-entries (parse-geometry-modifiers modif-tree)
|
(let [geometry-entries (parse-geometry-modifiers modif-tree)
|
||||||
|
root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries)
|
||||||
modifiers
|
modifiers
|
||||||
(if (and translation? (not snap-pixel?))
|
(if (and translation? (not snap-pixel?))
|
||||||
(expand-translation-modifiers geometry-entries objects subtree-ids-by-id)
|
root-modifiers
|
||||||
(wasm.api/propagate-modifiers geometry-entries snap-pixel?))]
|
(wasm.api/propagate-modifiers geometry-entries snap-pixel?))]
|
||||||
(wasm.api/set-modifiers modifiers)
|
(wasm.api/set-modifiers modifiers)
|
||||||
(let [ids (into [] xf:map-key geometry-entries)
|
(let [ids (into [] xf:map-key geometry-entries)
|
||||||
selrect
|
selrect (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers))
|
||||||
(if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers))
|
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
|
||||||
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
|
(wasm.api/get-selection-rect ids))]
|
||||||
(wasm.api/get-selection-rect ids))]
|
|
||||||
(rx/of (set-temporary-selrect selrect)
|
(rx/of (set-temporary-selrect selrect)
|
||||||
(set-temporary-modifiers modifiers))))))))
|
(set-temporary-modifiers modifiers))))))))
|
||||||
|
|
||||||
|
|||||||
@ -203,7 +203,22 @@
|
|||||||
(remove-path path paths)
|
(remove-path path paths)
|
||||||
(add-path path paths))))))))
|
(add-path path paths))))))))
|
||||||
|
|
||||||
|
(defn toggle-nested-token-path
|
||||||
|
[token-type new-name]
|
||||||
|
(ptk/reify ::toggle-nested-token-path
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [type-str (name token-type)
|
||||||
|
segments (str/split new-name ".")
|
||||||
|
n-groups (dec (count segments))]
|
||||||
|
(if (pos? n-groups)
|
||||||
|
(update-in state [:workspace-tokens :folded-token-paths]
|
||||||
|
(fn [paths]
|
||||||
|
(reduce (fn [ps i]
|
||||||
|
(remove-path (str type-str "." (str/join "." (take i segments))) ps))
|
||||||
|
(or paths [])
|
||||||
|
(range 1 (inc n-groups)))))
|
||||||
|
state)))))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; TOKENS Actions
|
;; TOKENS Actions
|
||||||
@ -582,6 +597,7 @@
|
|||||||
(pcb/set-token (ctob/get-id token-set)
|
(pcb/set-token (ctob/get-id token-set)
|
||||||
id
|
id
|
||||||
token'))]
|
token'))]
|
||||||
|
(toggle-token-path (str (name token-type) "." (:name token)))
|
||||||
(rx/of (dch/commit-changes changes)
|
(rx/of (dch/commit-changes changes)
|
||||||
(ev/event (-> {::ev/name "edit-token" :type token-type}
|
(ev/event (-> {::ev/name "edit-token" :type token-type}
|
||||||
(merge (meta it))))))))))
|
(merge (meta it))))))))))
|
||||||
|
|||||||
@ -332,6 +332,7 @@
|
|||||||
(fn [event]
|
(fn [event]
|
||||||
(let [text (dom/get-target-val event)]
|
(let [text (dom/get-target-val event)]
|
||||||
(mf/set-ref-val! raw-value* text)
|
(mf/set-ref-val! raw-value* text)
|
||||||
|
(mf/set-ref-val! dirty-ref true)
|
||||||
(reset! filter-id* text))))
|
(reset! filter-id* text))))
|
||||||
|
|
||||||
on-token-apply
|
on-token-apply
|
||||||
@ -389,12 +390,21 @@
|
|||||||
(reset! is-open* false)))
|
(reset! is-open* false)))
|
||||||
|
|
||||||
(when (mf/ref-val dirty-ref)
|
(when (mf/ref-val dirty-ref)
|
||||||
(apply-value (mf/ref-val raw-value*)))
|
(apply-value (mf/ref-val raw-value*))
|
||||||
|
(mf/set-ref-val! dirty-ref false))
|
||||||
(when (fn? on-blur)
|
(when (fn? on-blur)
|
||||||
(on-blur event))
|
(on-blur event))
|
||||||
(dom/blur! (mf/ref-val ref))))
|
(dom/blur! (mf/ref-val ref))))
|
||||||
|
|
||||||
handle-unmount (h/use-ref-callback handle-blur)
|
commit-pending-on-unmount
|
||||||
|
(mf/use-fn
|
||||||
|
(mf/deps apply-value)
|
||||||
|
(fn []
|
||||||
|
(when (mf/ref-val dirty-ref)
|
||||||
|
(apply-value (mf/ref-val raw-value*))
|
||||||
|
(mf/set-ref-val! dirty-ref false))))
|
||||||
|
|
||||||
|
handle-unmount (h/use-ref-callback commit-pending-on-unmount)
|
||||||
|
|
||||||
on-key-down
|
on-key-down
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
|
||||||
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
|
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
|
||||||
|
[app.main.ui.ds.product.loader :refer [loader*]]
|
||||||
[app.main.ui.icons :as deprecated-icon]
|
[app.main.ui.icons :as deprecated-icon]
|
||||||
[app.main.ui.viewer.header :as viewer.header]
|
[app.main.ui.viewer.header :as viewer.header]
|
||||||
[app.util.dom :as dom]
|
[app.util.dom :as dom]
|
||||||
@ -228,7 +229,8 @@
|
|||||||
(mf/deps profile)
|
(mf/deps profile)
|
||||||
(fn []
|
(fn []
|
||||||
(let [team-id (:default-team-id profile)]
|
(let [team-id (:default-team-id profile)]
|
||||||
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))
|
(st/emit! (rt/assign-exception nil)
|
||||||
|
(dcm/go-to-dashboard-recent :team-id team-id)))))
|
||||||
|
|
||||||
on-success
|
on-success
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
@ -558,8 +560,10 @@
|
|||||||
auth-error? (= type :authentication)
|
auth-error? (= type :authentication)
|
||||||
not-found? (= type :not-found)
|
not-found? (= type :not-found)
|
||||||
|
|
||||||
authenticated?
|
authenticated? (is-authenticated? profile)
|
||||||
(is-authenticated? profile)
|
|
||||||
|
;; Keeps whether the user was authenticated when this component first mounted.
|
||||||
|
initial-authenticated? (mf/with-memo [] authenticated?)
|
||||||
|
|
||||||
request-access?
|
request-access?
|
||||||
(and
|
(and
|
||||||
@ -575,13 +579,23 @@
|
|||||||
|
|
||||||
|
|
||||||
(if (or auth-error? not-found?)
|
(if (or auth-error? not-found?)
|
||||||
(if (not authenticated?)
|
(cond
|
||||||
|
(not authenticated?)
|
||||||
[:> context-wrapper*
|
[:> context-wrapper*
|
||||||
{:is-workspace workspace?
|
{:is-workspace workspace?
|
||||||
:is-dashboard dashboard?
|
:is-dashboard dashboard?
|
||||||
:is-viewer view?
|
:is-viewer view?
|
||||||
:profile profile}
|
:profile profile}
|
||||||
[:> login-modal* {}]]
|
[:> login-modal* {}]]
|
||||||
|
|
||||||
|
;; The user was not authenticated when exception-page first
|
||||||
|
;; mounted, but they have just logged in via the login modal.
|
||||||
|
;; Show a loading indicator to prevent briefly flashing the
|
||||||
|
;; "no permission" dialog.
|
||||||
|
(not initial-authenticated?)
|
||||||
|
[:> loader* {:title (tr "labels.loading") :overlay true}]
|
||||||
|
|
||||||
|
:else
|
||||||
(when (get info :loaded false)
|
(when (get info :loaded false)
|
||||||
(if request-access?
|
(if request-access?
|
||||||
[:> context-wrapper* {:is-workspace workspace?
|
[:> context-wrapper* {:is-workspace workspace?
|
||||||
|
|||||||
@ -147,7 +147,13 @@
|
|||||||
|
|
||||||
on-style-change
|
on-style-change
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(let [styles (styles/get-styles-from-event event)]
|
(let [styles (styles/get-styles-from-event event)
|
||||||
|
fills (:fills styles)
|
||||||
|
fill-color (when (sequential? fills) (some :fill-color fills))]
|
||||||
|
;; Dynamically update the caret color as the cursor moves between spans
|
||||||
|
(when-let [container-node (mf/ref-val container-ref)]
|
||||||
|
(dom/set-style! container-node "--text-editor-caret-color"
|
||||||
|
(or fill-color text-color)))
|
||||||
(st/emit! (dwt/v2-update-text-editor-styles shape-id styles))))
|
(st/emit! (dwt/v2-update-text-editor-styles shape-id styles))))
|
||||||
|
|
||||||
on-needs-layout
|
on-needs-layout
|
||||||
@ -219,10 +225,18 @@
|
|||||||
(= (:vertical-align content) "bottom")]))
|
(= (:vertical-align content) "bottom")]))
|
||||||
|
|
||||||
(defn get-color-from-content [content]
|
(defn get-color-from-content [content]
|
||||||
(let [fills (->> (tree-seq map? :children content)
|
(let [nodes (tree-seq map? :children content)
|
||||||
(mapcat :fills)
|
get-color (fn [node]
|
||||||
(filter :fill-color))]
|
;; Handle both new format (:fills vector) and old/deprecated format
|
||||||
(some :fill-color fills)))
|
;; (direct :fill-color on the content node — pre-fills-refactor files)
|
||||||
|
(or (some :fill-color (:fills node))
|
||||||
|
(:fill-color node)))]
|
||||||
|
;; Prefer inline (leaf) text nodes over paragraph nodes. The paragraph's :fills
|
||||||
|
;; tracks the last-typed color, so using it directly would make the caret take
|
||||||
|
;; the last span's color rather than the first visible span's color.
|
||||||
|
;; Inline nodes have no :type; they are identified by the presence of :text.
|
||||||
|
(or (->> nodes (filter #(contains? % :text)) (some get-color))
|
||||||
|
(->> nodes (some get-color)))))
|
||||||
|
|
||||||
(defn get-default-text-color
|
(defn get-default-text-color
|
||||||
"Returns the appropriate text color based on fill, frame, and background."
|
"Returns the appropriate text color based on fill, frame, and background."
|
||||||
|
|||||||
@ -51,7 +51,6 @@
|
|||||||
(if (= active-tab :reference)
|
(if (= active-tab :reference)
|
||||||
(get value :reference)
|
(get value :reference)
|
||||||
value)
|
value)
|
||||||
|
|
||||||
value))
|
value))
|
||||||
|
|
||||||
(mf/defc form*
|
(mf/defc form*
|
||||||
@ -184,9 +183,10 @@
|
|||||||
|
|
||||||
on-remap-token
|
on-remap-token
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps token)
|
(mf/deps token token-type)
|
||||||
(fn [valid-token new-name old-name description]
|
(fn [valid-token new-name old-name description]
|
||||||
(st/emit!
|
(st/emit!
|
||||||
|
(dwtl/toggle-nested-token-path token-type new-name)
|
||||||
(dwtl/update-token (:id token)
|
(dwtl/update-token (:id token)
|
||||||
{:name new-name
|
{:name new-name
|
||||||
:value (:value valid-token)
|
:value (:value valid-token)
|
||||||
@ -197,9 +197,10 @@
|
|||||||
|
|
||||||
on-rename-token
|
on-rename-token
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps token)
|
(mf/deps token token-type)
|
||||||
(fn [valid-token name description]
|
(fn [valid-token name description]
|
||||||
(st/emit!
|
(st/emit!
|
||||||
|
(dwtl/toggle-nested-token-path token-type name)
|
||||||
(dwtl/update-token (:id token)
|
(dwtl/update-token (:id token)
|
||||||
{:name name
|
{:name name
|
||||||
:value (:value valid-token)
|
:value (:value valid-token)
|
||||||
@ -209,11 +210,12 @@
|
|||||||
on-submit
|
on-submit
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps validate-token token tokens token-type value-subfield value-type active-tab on-remap-token on-rename-token is-create)
|
(mf/deps validate-token token tokens token-type value-subfield value-type active-tab on-remap-token on-rename-token is-create)
|
||||||
(fn [form _event]
|
(fn [form event]
|
||||||
(let [name (get-in @form [:clean-data :name])
|
(let [name (get-in @form [:clean-data :name])
|
||||||
description (get-in @form [:clean-data :description])
|
description (get-in @form [:clean-data :description])
|
||||||
value (get-in @form [:clean-data :value])
|
value (get-in @form [:clean-data :value])
|
||||||
value-for-validation (get-value-for-validator active-tab value value-subfield value-type)]
|
value-for-validation (get-value-for-validator active-tab value value-subfield value-type)]
|
||||||
|
(dom/stop-propagation event)
|
||||||
(->> (validate-token {:token-value value-for-validation
|
(->> (validate-token {:token-value value-for-validation
|
||||||
:token-name name
|
:token-name name
|
||||||
:token-description description
|
:token-description description
|
||||||
@ -235,19 +237,22 @@
|
|||||||
(st/emit! (modal/show :tokens/remapping-confirmation {:remap-data remap-data
|
(st/emit! (modal/show :tokens/remapping-confirmation {:remap-data remap-data
|
||||||
:on-remap on-remap
|
:on-remap on-remap
|
||||||
:on-rename on-rename}))
|
:on-rename on-rename}))
|
||||||
(st/emit!
|
(do
|
||||||
(if is-create
|
(when is-rename
|
||||||
(dwtl/create-token (ctob/make-token {:name name
|
(st/emit! (dwtl/toggle-nested-token-path token-type name)))
|
||||||
:type token-type
|
(st/emit!
|
||||||
:value (:value valid-token)
|
(if is-create
|
||||||
:description description}))
|
(dwtl/create-token (ctob/make-token {:name name
|
||||||
(dwtl/update-token (:id token)
|
:type token-type
|
||||||
{:name name
|
:value (:value valid-token)
|
||||||
:value (:value valid-token)
|
:description description}))
|
||||||
:description description}))
|
(dwtl/update-token (:id token)
|
||||||
(dwtl/open-token-type (:type token))
|
{:name name
|
||||||
(dwtp/propagate-workspace-tokens)
|
:value (:value valid-token)
|
||||||
(modal/hide!)))))
|
:description description}))
|
||||||
|
(dwtl/open-token-type (:type token))
|
||||||
|
(dwtp/propagate-workspace-tokens)
|
||||||
|
(modal/hide!))))))
|
||||||
;; WORKAROUND: display validation errors in the form instead of crashing
|
;; WORKAROUND: display validation errors in the form instead of crashing
|
||||||
(fn [{:keys [errors]}]
|
(fn [{:keys [errors]}]
|
||||||
(let [error-messages (wte/humanize-errors errors)
|
(let [error-messages (wte/humanize-errors errors)
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding-inline-start: calc(var(--node-spacing));
|
padding-inline-start: calc(var(--node-spacing));
|
||||||
|
gap: var(--sp-xs);
|
||||||
|
|
||||||
& .node-parent {
|
& .node-parent {
|
||||||
flex: 1 0 100%;
|
flex: 1 0 100%;
|
||||||
|
|||||||
@ -310,7 +310,7 @@
|
|||||||
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
|
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
|
||||||
(hooks/setup-keyboard alt? mod? space? z? shift?)
|
(hooks/setup-keyboard alt? mod? space? z? shift?)
|
||||||
(hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover
|
(hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover
|
||||||
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only?)
|
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform)
|
||||||
(hooks/setup-viewport-modifiers modifiers base-objects)
|
(hooks/setup-viewport-modifiers modifiers base-objects)
|
||||||
(hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?)
|
(hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?)
|
||||||
(hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox)
|
(hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox)
|
||||||
|
|||||||
@ -177,13 +177,14 @@
|
|||||||
(dw/increase-zoom)))))))
|
(dw/increase-zoom)))))))
|
||||||
|
|
||||||
(defn setup-hover-shapes
|
(defn setup-hover-shapes
|
||||||
[page-id move-stream objects selected mod? hover measure-hover hover-ids hover-top-frame-id hover-disabled? focus zoom show-measures? read-only?]
|
[page-id move-stream objects selected mod? hover measure-hover hover-ids hover-top-frame-id hover-disabled? focus zoom show-measures? read-only? transform]
|
||||||
(let [;; We use ref so we don't recreate the stream on a change
|
(let [;; We use ref so we don't recreate the stream on a change
|
||||||
zoom-ref (mf/use-ref zoom)
|
zoom-ref (mf/use-ref zoom)
|
||||||
mod-ref (mf/use-ref @mod?)
|
mod-ref (mf/use-ref @mod?)
|
||||||
selected-ref (mf/use-ref selected)
|
selected-ref (mf/use-ref selected)
|
||||||
hover-disabled-ref (mf/use-ref hover-disabled?)
|
hover-disabled-ref (mf/use-ref hover-disabled?)
|
||||||
focus-ref (mf/use-ref focus)
|
focus-ref (mf/use-ref focus)
|
||||||
|
transform-ref (mf/use-ref transform)
|
||||||
|
|
||||||
last-point-ref (mf/use-var nil)
|
last-point-ref (mf/use-var nil)
|
||||||
mod-str (mf/use-memo #(rx/subject))
|
mod-str (mf/use-memo #(rx/subject))
|
||||||
@ -251,6 +252,10 @@
|
|||||||
(mf/deps focus)
|
(mf/deps focus)
|
||||||
#(mf/set-ref-val! focus-ref focus))
|
#(mf/set-ref-val! focus-ref focus))
|
||||||
|
|
||||||
|
(mf/use-effect
|
||||||
|
(mf/deps transform)
|
||||||
|
#(mf/set-ref-val! transform-ref transform))
|
||||||
|
|
||||||
(hooks/use-stream
|
(hooks/use-stream
|
||||||
over-shapes-stream-debounced
|
over-shapes-stream-debounced
|
||||||
(mf/deps objects)
|
(mf/deps objects)
|
||||||
@ -361,7 +366,9 @@
|
|||||||
(get objects)))]
|
(get objects)))]
|
||||||
(reset! hover hover-shape)
|
(reset! hover hover-shape)
|
||||||
(reset! measure-hover measure-hover-shape)
|
(reset! measure-hover measure-hover-shape)
|
||||||
(reset! hover-ids ids)))
|
;; Skip hover-ids update during drag
|
||||||
|
(when (not= :move (mf/ref-val transform-ref))
|
||||||
|
(reset! hover-ids ids))))
|
||||||
|
|
||||||
(fn []
|
(fn []
|
||||||
;; Clean the cache
|
;; Clean the cache
|
||||||
|
|||||||
@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
(defn apply-modifiers-to-selected
|
(defn apply-modifiers-to-selected
|
||||||
[selected objects modifiers]
|
[selected objects modifiers]
|
||||||
(apply-modifiers-to-objects objects (select-keys (into {} modifiers) selected)))
|
(apply-modifiers-to-objects objects (select-keys modifiers selected)))
|
||||||
|
|
||||||
(defn- apply-wasm-modifiers-to-ids
|
(defn- apply-wasm-modifiers-to-ids
|
||||||
"Like `apply-modifiers-to-objects`, but only updates ids in `id-set`. During WASM
|
"Like `apply-modifiers-to-objects`, but only updates ids in `id-set`. During WASM
|
||||||
@ -87,13 +87,15 @@
|
|||||||
(if (or (empty? wasm-modifiers) (empty? id-set))
|
(if (or (empty? wasm-modifiers) (empty? id-set))
|
||||||
objects
|
objects
|
||||||
(reduce
|
(reduce
|
||||||
(fn [objs pair]
|
(fn [objs id]
|
||||||
(let [[id t] pair]
|
(if-let [t (get wasm-modifiers id)]
|
||||||
(if (and (contains? id-set id) (contains? objs id))
|
(if (contains? objs id)
|
||||||
(update objs id gsh/apply-transform t)
|
(update objs id gsh/apply-transform t)
|
||||||
objs)))
|
objs)
|
||||||
|
objs))
|
||||||
objects
|
objects
|
||||||
wasm-modifiers)))
|
id-set)))
|
||||||
|
|
||||||
|
|
||||||
(defn- outline-wasm-source-ids
|
(defn- outline-wasm-source-ids
|
||||||
"Superset of shape ids that `shape-outlines` may look up (all outline usages here)."
|
"Superset of shape ids that `shape-outlines` may look up (all outline usages here)."
|
||||||
@ -142,7 +144,6 @@
|
|||||||
drawing (mf/deref refs/workspace-drawing)
|
drawing (mf/deref refs/workspace-drawing)
|
||||||
focus (mf/deref refs/workspace-focus-selected)
|
focus (mf/deref refs/workspace-focus-selected)
|
||||||
wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
|
wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
|
||||||
|
|
||||||
workspace-editor-state (mf/deref refs/workspace-editor-state)
|
workspace-editor-state (mf/deref refs/workspace-editor-state)
|
||||||
|
|
||||||
file-id (get file :id)
|
file-id (get file :id)
|
||||||
@ -162,7 +163,6 @@
|
|||||||
selected-shapes (->> selected
|
selected-shapes (->> selected
|
||||||
(into [] (keep (d/getf objects-modified)))
|
(into [] (keep (d/getf objects-modified)))
|
||||||
(not-empty))
|
(not-empty))
|
||||||
|
|
||||||
;; STATE
|
;; STATE
|
||||||
alt? (mf/use-state false)
|
alt? (mf/use-state false)
|
||||||
shift? (mf/use-state false)
|
shift? (mf/use-state false)
|
||||||
@ -370,6 +370,7 @@
|
|||||||
offset-y (if selecting-first-level-frame?
|
offset-y (if selecting-first-level-frame?
|
||||||
(:y first-shape)
|
(:y first-shape)
|
||||||
(:y selected-frame))
|
(:y selected-frame))
|
||||||
|
|
||||||
rule-area-size (/ rulers/ruler-area-size zoom)
|
rule-area-size (/ rulers/ruler-area-size zoom)
|
||||||
preview-blend (-> refs/workspace-preview-blend
|
preview-blend (-> refs/workspace-preview-blend
|
||||||
(mf/deref))
|
(mf/deref))
|
||||||
@ -495,7 +496,7 @@
|
|||||||
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
|
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
|
||||||
(hooks/setup-keyboard alt? mod? space? z? shift?)
|
(hooks/setup-keyboard alt? mod? space? z? shift?)
|
||||||
(hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover
|
(hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover
|
||||||
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only?)
|
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform)
|
||||||
(hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?)
|
(hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?)
|
||||||
(hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox)
|
(hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox)
|
||||||
|
|
||||||
@ -613,11 +614,10 @@
|
|||||||
:ref text-editor-ref}]))
|
:ref text-editor-ref}]))
|
||||||
|
|
||||||
(when show-frame-outline?
|
(when show-frame-outline?
|
||||||
(let [outlined-frame-id
|
(let [outlined-frame-id (->> @hover-ids
|
||||||
(->> @hover-ids
|
(filter #(cfh/frame-shape? (get base-objects %)))
|
||||||
(filter #(cfh/frame-shape? (get base-objects %)))
|
(remove selected)
|
||||||
(remove selected)
|
(last))
|
||||||
(last))
|
|
||||||
outlined-frame (get objects outlined-frame-id)]
|
outlined-frame (get objects outlined-frame-id)]
|
||||||
[:*
|
[:*
|
||||||
[:& outline/shape-outlines
|
[:& outline/shape-outlines
|
||||||
|
|||||||
@ -651,6 +651,37 @@ pub fn grid_cell_data<'a>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns `(h_min, v_min, h_size, v_size)` — the child's bounding box expressed in the
|
||||||
|
// layout frame's own coordinate system (projected onto its `hv`/`vv` unit vectors).
|
||||||
|
//
|
||||||
|
// Using the frame axes rather than screen x/y is necessary when the parent grid frame
|
||||||
|
// is itself rotated: in that case `max_x - min_x` is the screen-AABB width, which
|
||||||
|
// differs from the width measured along the frame's horizontal axis.
|
||||||
|
fn child_frame_aabb(child_bounds: &Bounds, hv: Vector, vv: Vector) -> (f32, f32, f32, f32) {
|
||||||
|
let corners = child_bounds.points();
|
||||||
|
let mut h_min = f32::INFINITY;
|
||||||
|
let mut h_max = f32::NEG_INFINITY;
|
||||||
|
let mut v_min = f32::INFINITY;
|
||||||
|
let mut v_max = f32::NEG_INFINITY;
|
||||||
|
for p in &corners {
|
||||||
|
let h = hv.x * p.x + hv.y * p.y;
|
||||||
|
let v = vv.x * p.x + vv.y * p.y;
|
||||||
|
if h < h_min {
|
||||||
|
h_min = h;
|
||||||
|
}
|
||||||
|
if h > h_max {
|
||||||
|
h_max = h;
|
||||||
|
}
|
||||||
|
if v < v_min {
|
||||||
|
v_min = v;
|
||||||
|
}
|
||||||
|
if v > v_max {
|
||||||
|
v_max = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(h_min, v_min, h_max - h_min, v_max - v_min)
|
||||||
|
}
|
||||||
|
|
||||||
fn child_position(
|
fn child_position(
|
||||||
child: &Shape,
|
child: &Shape,
|
||||||
layout_bounds: &Bounds,
|
layout_bounds: &Bounds,
|
||||||
@ -667,12 +698,17 @@ fn child_position(
|
|||||||
let margin_right = layout_item.map(|i| i.margin_right).unwrap_or(0.0);
|
let margin_right = layout_item.map(|i| i.margin_right).unwrap_or(0.0);
|
||||||
let margin_bottom = layout_item.map(|i| i.margin_bottom).unwrap_or(0.0);
|
let margin_bottom = layout_item.map(|i| i.margin_bottom).unwrap_or(0.0);
|
||||||
|
|
||||||
|
// Project corners onto the frame's own axes so that both a rotated child *and* a
|
||||||
|
// rotated parent frame are handled correctly. For an axis-aligned frame this
|
||||||
|
// reduces to max_x-min_x / max_y-min_y, so non-rotated layouts are unaffected.
|
||||||
|
let (_, _, child_width, child_height) = child_frame_aabb(child_bounds, hv, vv);
|
||||||
|
|
||||||
let vpos = match (cell.align_self, layout_data.align_items) {
|
let vpos = match (cell.align_self, layout_data.align_items) {
|
||||||
(Some(AlignSelf::Start), _) => margin_top,
|
(Some(AlignSelf::Start), _) => margin_top,
|
||||||
(Some(AlignSelf::Center), _) => (cell.height - child_bounds.height()) / 2.0,
|
(Some(AlignSelf::Center), _) => (cell.height - child_height) / 2.0,
|
||||||
(Some(AlignSelf::End), _) => margin_bottom + cell.height - child_bounds.height(),
|
(Some(AlignSelf::End), _) => margin_bottom + cell.height - child_height,
|
||||||
(_, AlignItems::Center) => (cell.height - child_bounds.height()) / 2.0,
|
(_, AlignItems::Center) => (cell.height - child_height) / 2.0,
|
||||||
(_, AlignItems::End) => margin_bottom + cell.height - child_bounds.height(),
|
(_, AlignItems::End) => margin_bottom + cell.height - child_height,
|
||||||
_ => margin_top,
|
_ => margin_top,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -684,10 +720,10 @@ fn child_position(
|
|||||||
|
|
||||||
let hpos = match (cell.justify_self, layout_data.justify_items) {
|
let hpos = match (cell.justify_self, layout_data.justify_items) {
|
||||||
(Some(JustifySelf::Start), _) => margin_left,
|
(Some(JustifySelf::Start), _) => margin_left,
|
||||||
(Some(JustifySelf::Center), _) => (cell.width - child_bounds.width()) / 2.0,
|
(Some(JustifySelf::Center), _) => (cell.width - child_width) / 2.0,
|
||||||
(Some(JustifySelf::End), _) => margin_right + cell.width - child_bounds.width(),
|
(Some(JustifySelf::End), _) => margin_right + cell.width - child_width,
|
||||||
(_, JustifyItems::Center) => (cell.width - child_bounds.width()) / 2.0,
|
(_, JustifyItems::Center) => (cell.width - child_width) / 2.0,
|
||||||
(_, JustifyItems::End) => margin_right + cell.width - child_bounds.width(),
|
(_, JustifyItems::End) => margin_right + cell.width - child_width,
|
||||||
_ => margin_left,
|
_ => margin_left,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -747,11 +783,27 @@ pub fn reflow_grid_layout(
|
|||||||
let Some(child) = cell.shape else { continue };
|
let Some(child) = cell.shape else { continue };
|
||||||
let child_bounds = bounds.find(child);
|
let child_bounds = bounds.find(child);
|
||||||
|
|
||||||
|
// Compute frame-axis projections once; used for both sizing and positioning.
|
||||||
|
let hv = layout_bounds.hv(1.0);
|
||||||
|
let vv = layout_bounds.vv(1.0);
|
||||||
|
let (h_min, v_min, child_frame_w, child_frame_h) = child_frame_aabb(&child_bounds, hv, vv);
|
||||||
|
|
||||||
|
// resize_matrix scales the child in the parent's local frame coordinate system
|
||||||
|
// by (new_width / child_bounds.width()) in the h-axis and
|
||||||
|
// (new_height / child_bounds.height()) in the v-axis. For a rotated child the
|
||||||
|
// frame-projected extent differs from the intrinsic bounds dimensions, so we
|
||||||
|
// back-calculate the intrinsic target that will produce the desired
|
||||||
|
// frame-projected extent.
|
||||||
let mut new_width = child_bounds.width();
|
let mut new_width = child_bounds.width();
|
||||||
if child.is_layout_horizontal_fill() {
|
if child.is_layout_horizontal_fill() {
|
||||||
let margin_left = child.layout_item.map(|i| i.margin_left).unwrap_or(0.0);
|
let margin_left = child.layout_item.map(|i| i.margin_left).unwrap_or(0.0);
|
||||||
let margin_right = child.layout_item.map(|i| i.margin_right).unwrap_or(0.0);
|
let margin_right = child.layout_item.map(|i| i.margin_right).unwrap_or(0.0);
|
||||||
new_width = cell.width - margin_left - margin_right;
|
let target_frame_w = cell.width - margin_left - margin_right;
|
||||||
|
new_width = if child_frame_w > MIN_SIZE {
|
||||||
|
target_frame_w * child_bounds.width() / child_frame_w
|
||||||
|
} else {
|
||||||
|
target_frame_w
|
||||||
|
};
|
||||||
let min_width = child.layout_item.and_then(|i| i.min_w).unwrap_or(MIN_SIZE);
|
let min_width = child.layout_item.and_then(|i| i.min_w).unwrap_or(MIN_SIZE);
|
||||||
let max_width = child.layout_item.and_then(|i| i.max_w).unwrap_or(MAX_SIZE);
|
let max_width = child.layout_item.and_then(|i| i.max_w).unwrap_or(MAX_SIZE);
|
||||||
new_width = new_width.clamp(min_width, max_width);
|
new_width = new_width.clamp(min_width, max_width);
|
||||||
@ -761,7 +813,12 @@ pub fn reflow_grid_layout(
|
|||||||
if child.is_layout_vertical_fill() {
|
if child.is_layout_vertical_fill() {
|
||||||
let margin_top = child.layout_item.map(|i| i.margin_top).unwrap_or(0.0);
|
let margin_top = child.layout_item.map(|i| i.margin_top).unwrap_or(0.0);
|
||||||
let margin_bottom = child.layout_item.map(|i| i.margin_bottom).unwrap_or(0.0);
|
let margin_bottom = child.layout_item.map(|i| i.margin_bottom).unwrap_or(0.0);
|
||||||
new_height = cell.height - margin_top - margin_bottom;
|
let target_frame_h = cell.height - margin_top - margin_bottom;
|
||||||
|
new_height = if child_frame_h > MIN_SIZE {
|
||||||
|
target_frame_h * child_bounds.height() / child_frame_h
|
||||||
|
} else {
|
||||||
|
target_frame_h
|
||||||
|
};
|
||||||
let min_height = child.layout_item.and_then(|i| i.min_h).unwrap_or(MIN_SIZE);
|
let min_height = child.layout_item.and_then(|i| i.min_h).unwrap_or(MIN_SIZE);
|
||||||
let max_height = child.layout_item.and_then(|i| i.max_h).unwrap_or(MAX_SIZE);
|
let max_height = child.layout_item.and_then(|i| i.max_h).unwrap_or(MAX_SIZE);
|
||||||
new_height = new_height.clamp(min_height, max_height);
|
new_height = new_height.clamp(min_height, max_height);
|
||||||
@ -792,7 +849,36 @@ pub fn reflow_grid_layout(
|
|||||||
cell,
|
cell,
|
||||||
);
|
);
|
||||||
|
|
||||||
let delta_v = Vector::new_points(&child_bounds.nw, &position);
|
// Compute the child's reference point in the frame's coordinate system.
|
||||||
|
// For a rotated parent frame, (min_x, min_y) is wrong because it is the
|
||||||
|
// screen-AABB corner, not the frame-axis-aligned corner.
|
||||||
|
// child_ref = h_min * hv + v_min * vv gives the world-space point whose
|
||||||
|
// projections onto hv/vv are the child's minima along those axes —
|
||||||
|
// the "top-left in frame coordinates".
|
||||||
|
//
|
||||||
|
// For fill axes, resize_matrix scales local-x/y by (new_w / child_bounds.width())
|
||||||
|
// anchored at nw. This shifts h_min/v_min: the post-resize minimum is
|
||||||
|
// h_min_new = nw_h + (h_min - nw_h) * scale_w
|
||||||
|
// We must translate FROM this post-resize minimum, not the pre-resize one.
|
||||||
|
let nw_h = hv.x * child_bounds.nw.x + hv.y * child_bounds.nw.y;
|
||||||
|
let nw_v = vv.x * child_bounds.nw.x + vv.y * child_bounds.nw.y;
|
||||||
|
let h_anchor = if child.is_layout_horizontal_fill() && child_bounds.width() > MIN_SIZE {
|
||||||
|
let scale_w = new_width / child_bounds.width();
|
||||||
|
nw_h + (h_min - nw_h) * scale_w
|
||||||
|
} else {
|
||||||
|
h_min
|
||||||
|
};
|
||||||
|
let v_anchor = if child.is_layout_vertical_fill() && child_bounds.height() > MIN_SIZE {
|
||||||
|
let scale_h = new_height / child_bounds.height();
|
||||||
|
nw_v + (v_min - nw_v) * scale_h
|
||||||
|
} else {
|
||||||
|
v_min
|
||||||
|
};
|
||||||
|
let child_ref = Point::new(
|
||||||
|
h_anchor * hv.x + v_anchor * vv.x,
|
||||||
|
h_anchor * hv.y + v_anchor * vv.y,
|
||||||
|
);
|
||||||
|
let delta_v = Vector::new_points(&child_ref, &position);
|
||||||
|
|
||||||
if delta_v.x.abs() > MIN_SIZE || delta_v.y.abs() > MIN_SIZE {
|
if delta_v.x.abs() > MIN_SIZE || delta_v.y.abs() > MIN_SIZE {
|
||||||
transform.post_concat(&Matrix::translate(delta_v));
|
transform.post_concat(&Matrix::translate(delta_v));
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::iter;
|
use std::iter;
|
||||||
|
|
||||||
use crate::performance;
|
use crate::performance;
|
||||||
@ -191,6 +192,15 @@ impl ShapesPoolImpl {
|
|||||||
Some(shape)
|
Some(shape)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if let Some(cell) = self.modified_shape_cache.get(&idx) {
|
||||||
|
return Some(cell.get_or_init(|| {
|
||||||
|
if let Some(m) = self.find_nearest_ancestor_modifier(idx) {
|
||||||
|
shape.transformed(Some(&m), None)
|
||||||
|
} else {
|
||||||
|
shape.clone()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
Some(shape)
|
Some(shape)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -230,9 +240,6 @@ impl ShapesPoolImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_modifiers(&mut self, modifiers: HashMap<Uuid, skia::Matrix>) {
|
pub fn set_modifiers(&mut self, modifiers: HashMap<Uuid, skia::Matrix>) {
|
||||||
// Convert HashMap<Uuid, V> to HashMap<usize, V> using indices
|
|
||||||
// Initialize the cache cells for affected shapes
|
|
||||||
|
|
||||||
let mut ids = Vec::<Uuid>::new();
|
let mut ids = Vec::<Uuid>::new();
|
||||||
let mut modifiers_with_idx = HashMap::with_capacity(modifiers.len());
|
let mut modifiers_with_idx = HashMap::with_capacity(modifiers.len());
|
||||||
|
|
||||||
@ -242,12 +249,47 @@ impl ShapesPoolImpl {
|
|||||||
ids.push(uuid);
|
ids.push(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expand every root modifier to its full descendant subtree.
|
||||||
|
// When CLJS sends only root shapes (translation on drag), descendants
|
||||||
|
// need the same matrix.
|
||||||
|
// For resize/rotate, propagate-modifiers already includes all descendants.
|
||||||
|
// Descendants are NOT pushed into `ids` / `modifier_uuids`: tile invalidation
|
||||||
|
// via rebuild_modifier_tiles only runs for roots, which is sufficient because
|
||||||
|
// descendants always lie inside the parent's bounding box and are therefore
|
||||||
|
// covered by the parent's old/new tile ranges.
|
||||||
|
let root_pairs: Vec<(usize, skia::Matrix)> = ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|uuid| {
|
||||||
|
let idx = self.uuid_to_idx.get(uuid).copied()?;
|
||||||
|
let matrix = modifiers_with_idx.get(&idx).copied()?;
|
||||||
|
Some((idx, matrix))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut descendants_idxs: Vec<usize> = Vec::new();
|
||||||
|
for (root_idx, matrix) in root_pairs {
|
||||||
|
for descendant_idx in self.collect_all_descendants(root_idx) {
|
||||||
|
if let std::collections::hash_map::Entry::Vacant(e) =
|
||||||
|
modifiers_with_idx.entry(descendant_idx)
|
||||||
|
{
|
||||||
|
e.insert(matrix);
|
||||||
|
descendants_idxs.push(descendant_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.modifiers = modifiers_with_idx;
|
self.modifiers = modifiers_with_idx;
|
||||||
|
|
||||||
|
for descendant_idx in descendants_idxs {
|
||||||
|
self.modified_shape_cache
|
||||||
|
.insert(descendant_idx, OnceCell::new());
|
||||||
|
}
|
||||||
|
|
||||||
// Compute ancestors before consuming `ids` so we can move it into
|
// Compute ancestors before consuming `ids` so we can move it into
|
||||||
// `modifier_uuids` without a clone.
|
// `modifier_uuids` without a clone.
|
||||||
let all_ids = shapes::all_with_ancestors(&ids, self, true);
|
let all_ids = shapes::all_with_ancestors(&ids, self, true);
|
||||||
// Keep modifier_uuids in sync so modifier_ids() is O(K) not O(N_shapes).
|
// rebuild_modifier_tiles doesn't process every descendant individually.
|
||||||
self.modifier_uuids = ids;
|
self.modifier_uuids = ids;
|
||||||
for uuid in all_ids {
|
for uuid in all_ids {
|
||||||
if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() {
|
if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() {
|
||||||
@ -363,6 +405,41 @@ impl ShapesPoolImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_all_descendants(&self, idx: usize) -> Vec<usize> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut queue: VecDeque<&Uuid> = VecDeque::new();
|
||||||
|
let shape = &self.shapes[idx];
|
||||||
|
for child_id in shape.children_ids_iter(false) {
|
||||||
|
queue.push_back(child_id);
|
||||||
|
}
|
||||||
|
while let Some(child_id) = queue.pop_front() {
|
||||||
|
if let Some(&child_idx) = self.uuid_to_idx.get(child_id) {
|
||||||
|
result.push(child_idx);
|
||||||
|
let child_shape = &self.shapes[child_idx];
|
||||||
|
for grandchild_id in child_shape.children_ids_iter(false) {
|
||||||
|
queue.push_back(grandchild_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_nearest_ancestor_modifier(&self, idx: usize) -> Option<Matrix> {
|
||||||
|
let mut current_idx = idx;
|
||||||
|
loop {
|
||||||
|
let shape = &self.shapes[current_idx];
|
||||||
|
let parent_id = shape.parent_id?;
|
||||||
|
if parent_id == Uuid::nil() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let &parent_idx = self.uuid_to_idx.get(&parent_id)?;
|
||||||
|
if let Some(matrix) = self.modifiers.get(&parent_idx) {
|
||||||
|
return Some(*matrix);
|
||||||
|
}
|
||||||
|
current_idx = parent_idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn to_update_bool(&self, shape: &Shape) -> bool {
|
fn to_update_bool(&self, shape: &Shape) -> bool {
|
||||||
if !shape.is_bool() {
|
if !shape.is_bool() {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
21
tools/gh.py
21
tools/gh.py
@ -80,14 +80,15 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) {
|
|||||||
issues(first: 100, after: $cursor, states: __STATES__) {
|
issues(first: 100, after: $cursor, states: __STATES__) {
|
||||||
totalCount
|
totalCount
|
||||||
pageInfo { hasNextPage endCursor }
|
pageInfo { hasNextPage endCursor }
|
||||||
nodes {
|
nodes {
|
||||||
... on Issue {
|
... on Issue {
|
||||||
number
|
number
|
||||||
title
|
title
|
||||||
state
|
state
|
||||||
labels(first: 20) { nodes { name } }
|
issueType { name }
|
||||||
closedByPullRequestsReferences(first: 5) { nodes { number } }
|
labels(first: 20) { nodes { name } }
|
||||||
}
|
closedByPullRequestsReferences(first: 5) { nodes { number } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -120,7 +121,7 @@ def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]:
|
|||||||
states: GraphQL states enum array literal, e.g. ``"[CLOSED]"`` or ``"[OPEN CLOSED]"``
|
states: GraphQL states enum array literal, e.g. ``"[CLOSED]"`` or ``"[OPEN CLOSED]"``
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of {number, title, state, labels: [str], closing_prs: [int]}
|
List of {number, title, state, issue_type: str|None, labels: [str], closing_prs: [int]}
|
||||||
"""
|
"""
|
||||||
query = GQL_ISSUES_QUERY.replace("__STATES__", states)
|
query = GQL_ISSUES_QUERY.replace("__STATES__", states)
|
||||||
all_nodes: list[dict] = []
|
all_nodes: list[dict] = []
|
||||||
@ -140,10 +141,12 @@ def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]:
|
|||||||
for node in issues["nodes"]:
|
for node in issues["nodes"]:
|
||||||
if node is None:
|
if node is None:
|
||||||
continue
|
continue
|
||||||
|
issue_type = node.get("issueType")
|
||||||
all_nodes.append({
|
all_nodes.append({
|
||||||
"number": node["number"],
|
"number": node["number"],
|
||||||
"title": node["title"],
|
"title": node["title"],
|
||||||
"state": node["state"],
|
"state": node["state"],
|
||||||
|
"issue_type": issue_type["name"] if issue_type else None,
|
||||||
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
|
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
|
||||||
"closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]],
|
"closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]],
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user