mirror of
https://github.com/penpot/penpot.git
synced 2026-05-19 14:14:05 -04:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
@@ -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"
|
||||
```
|
||||
|
||||
**Label exclusion rules:**
|
||||
- `release blocker` — Internal release-blocking bugs not relevant to end users
|
||||
- `no changelog` — Chore/refactor work that doesn't need a changelog entry
|
||||
**Exclusion rules:**
|
||||
- `no changelog` label — 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`,
|
||||
`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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`issueType { name }`) to determine which section an entry belongs to.
|
||||
**Do not** use labels or title emoji prefixes as the source of truth — they are
|
||||
often inaccurate or missing.
|
||||
Use the **Issue Type** field (GitHub's native issue type, exposed as
|
||||
`issue_type` in the `gh.py` JSON output) to determine which section an entry
|
||||
belongs to.
|
||||
|
||||
| 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` |
|
||||
| `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
|
||||
GraphQL query with aliases rather than N+1 REST calls:
|
||||
|
||||
```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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
The `gh.py` issues command already includes `issue_type` in every entry's
|
||||
output. **No separate GraphQL query is needed.**
|
||||
|
||||
**Community contribution attribution:** If the issue or its fix PR has the
|
||||
`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.
|
||||
- **Latest version first.** New sections are inserted at the top of the
|
||||
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
|
||||
what broke and what was fixed, not internal implementation details.
|
||||
- **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.
|
||||
- **Only closed issues.** An issue must have `state: "closed"` to appear in
|
||||
the changelog. Open unresolved issues are omitted.
|
||||
- **Excluded labels.** Issues with `release blocker` or `no changelog` labels
|
||||
must be excluded from the changelog.
|
||||
- **Excluded issues.** Issues with `no changelog` label must be excluded.
|
||||
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
|
||||
comma-separated inline: `(PR: [#A](url), [#B](url))`.
|
||||
- **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))
|
||||
- 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))
|
||||
- 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
|
||||
- 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 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))
|
||||
- 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))
|
||||
- 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))
|
||||
@@ -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 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 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)
|
||||
|
||||
@@ -247,13 +247,21 @@
|
||||
(:organization-id claims)
|
||||
(assoc :org-team-id accepted-team-id)))))
|
||||
|
||||
;; If we have not logged-in user, and invitation comes with member-id we
|
||||
;; redirect user to login, if no memeber-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})))
|
||||
(do
|
||||
;; If the user is not logged-in and the token is invalid we throw the error
|
||||
;; Taiga issue #14182
|
||||
(when (nil? invitation)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-token
|
||||
: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
|
||||
|
||||
|
||||
@@ -1062,7 +1062,9 @@ test("BUG: 14136 Apply grid layout padding token to a shape from the sidebar doe
|
||||
await tokenDimensionMd.click();
|
||||
|
||||
// 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 bottomPaddingSection = layoutSection.getByLabel("Bottom padding");
|
||||
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
|
||||
await expect(
|
||||
await topPaddingSection.getByRole("button", { name: "dimension.md" }).textContent()
|
||||
await topPaddingSection
|
||||
.getByRole("button", { name: "dimension.md" })
|
||||
.textContent(),
|
||||
).toBe("16");
|
||||
await expect(
|
||||
await bottomPaddingSection.getByRole("button", { name: "dimension.md" }).textContent()
|
||||
await bottomPaddingSection
|
||||
.getByRole("button", { name: "dimension.md" })
|
||||
.textContent(),
|
||||
).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 }) => {
|
||||
const {
|
||||
workspacePage,
|
||||
|
||||
@@ -169,6 +169,49 @@ test.describe("Tokens - node tree", () => {
|
||||
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 }) => {
|
||||
const { tokensSidebar } = await setupTokensFileRender(page);
|
||||
|
||||
|
||||
@@ -149,7 +149,15 @@
|
||||
(watch [_ state _]
|
||||
(when-let [team-id (or team-id (:current-team-id state))]
|
||||
(->> (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
|
||||
[team-id invitations]
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
(def ^:private xf:without-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
|
||||
;; transform" mode (a drag / resize / rotate gesture in progress).
|
||||
;; Paired with `set-modifiers-start` / `set-modifiers-end` so the
|
||||
@@ -305,6 +309,7 @@
|
||||
;; skip shadows / blur).
|
||||
(ensure-interactive-transform-end!)
|
||||
(wasm.api/clean-modifiers)
|
||||
(vreset! wasm-structure-modifiers-active? false)
|
||||
(set-wasm-props! (dsh/lookup-page-objects state) (:wasm-props state) [])))
|
||||
|
||||
ptk/UpdateEvent
|
||||
@@ -629,34 +634,10 @@
|
||||
(ptk/reify ::set-temporary-modifiers
|
||||
ptk/EffectEvent
|
||||
(effect [_ _ _]
|
||||
(rx/push! ms/wasm-modifiers modifiers))))
|
||||
(rx/push! ms/wasm-modifiers (into {} modifiers)))))
|
||||
|
||||
(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
|
||||
"Shift `selrect`'s center by (tx, ty). Width/height/transform are
|
||||
invariant under pure translation, so only `:center` moves."
|
||||
@@ -688,11 +669,12 @@
|
||||
(ptk/reify ::set-wasm-modifiers
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [property-changes
|
||||
(extract-property-changes modif-tree)]
|
||||
(-> state
|
||||
(assoc :prev-wasm-props (:wasm-props state))
|
||||
(assoc :wasm-props property-changes))))
|
||||
(let [property-changes (extract-property-changes modif-tree)]
|
||||
(if (d/not-empty? property-changes)
|
||||
(-> state
|
||||
(assoc :prev-wasm-props (:wasm-props state))
|
||||
(assoc :wasm-props property-changes))
|
||||
state)))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
@@ -702,29 +684,33 @@
|
||||
;; thread is not blocked. The pair is closed in
|
||||
;; `clear-local-transform`.
|
||||
(ensure-interactive-transform-start!)
|
||||
(wasm.api/clean-modifiers)
|
||||
(let [prev-wasm-props (:prev-wasm-props state)
|
||||
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))
|
||||
(let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
|
||||
translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
|
||||
|
||||
translation?
|
||||
(every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
|
||||
(set-wasm-props! objects prev-wasm-props wasm-props)
|
||||
(when-not translation?
|
||||
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)))
|
||||
(if translation?
|
||||
;; Pure translation: no structure changes needed. If structure
|
||||
;; modifiers were active from a previous non-translation frame
|
||||
;; (e.g. shape hovered over a frame then dragged back out),
|
||||
;; 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)
|
||||
root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries)
|
||||
modifiers
|
||||
(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/set-modifiers modifiers)
|
||||
(let [ids (into [] xf:map-key geometry-entries)
|
||||
selrect
|
||||
(if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers))
|
||||
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
|
||||
(wasm.api/get-selection-rect ids))]
|
||||
(let [ids (into [] xf:map-key geometry-entries)
|
||||
selrect (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers))
|
||||
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
|
||||
(wasm.api/get-selection-rect ids))]
|
||||
(rx/of (set-temporary-selrect selrect)
|
||||
(set-temporary-modifiers modifiers))))))))
|
||||
|
||||
|
||||
@@ -203,7 +203,22 @@
|
||||
(remove-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
|
||||
@@ -582,6 +597,7 @@
|
||||
(pcb/set-token (ctob/get-id token-set)
|
||||
id
|
||||
token'))]
|
||||
(toggle-token-path (str (name token-type) "." (:name token)))
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(ev/event (-> {::ev/name "edit-token" :type token-type}
|
||||
(merge (meta it))))))))))
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
(fn [event]
|
||||
(let [text (dom/get-target-val event)]
|
||||
(mf/set-ref-val! raw-value* text)
|
||||
(mf/set-ref-val! dirty-ref true)
|
||||
(reset! filter-id* text))))
|
||||
|
||||
on-token-apply
|
||||
@@ -389,12 +390,21 @@
|
||||
(reset! is-open* false)))
|
||||
|
||||
(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)
|
||||
(on-blur event))
|
||||
(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
|
||||
(mf/use-fn
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
[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.raw-svg :refer [raw-svg*]]
|
||||
[app.main.ui.ds.product.loader :refer [loader*]]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.viewer.header :as viewer.header]
|
||||
[app.util.dom :as dom]
|
||||
@@ -228,7 +229,8 @@
|
||||
(mf/deps profile)
|
||||
(fn []
|
||||
(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
|
||||
(mf/use-fn
|
||||
@@ -558,8 +560,10 @@
|
||||
auth-error? (= type :authentication)
|
||||
not-found? (= type :not-found)
|
||||
|
||||
authenticated?
|
||||
(is-authenticated? profile)
|
||||
authenticated? (is-authenticated? profile)
|
||||
|
||||
;; Keeps whether the user was authenticated when this component first mounted.
|
||||
initial-authenticated? (mf/with-memo [] authenticated?)
|
||||
|
||||
request-access?
|
||||
(and
|
||||
@@ -575,13 +579,23 @@
|
||||
|
||||
|
||||
(if (or auth-error? not-found?)
|
||||
(if (not authenticated?)
|
||||
(cond
|
||||
(not authenticated?)
|
||||
[:> context-wrapper*
|
||||
{:is-workspace workspace?
|
||||
:is-dashboard dashboard?
|
||||
:is-viewer view?
|
||||
:profile profile}
|
||||
[:> 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)
|
||||
(if request-access?
|
||||
[:> context-wrapper* {:is-workspace workspace?
|
||||
|
||||
@@ -147,7 +147,13 @@
|
||||
|
||||
on-style-change
|
||||
(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))))
|
||||
|
||||
on-needs-layout
|
||||
@@ -219,10 +225,18 @@
|
||||
(= (:vertical-align content) "bottom")]))
|
||||
|
||||
(defn get-color-from-content [content]
|
||||
(let [fills (->> (tree-seq map? :children content)
|
||||
(mapcat :fills)
|
||||
(filter :fill-color))]
|
||||
(some :fill-color fills)))
|
||||
(let [nodes (tree-seq map? :children content)
|
||||
get-color (fn [node]
|
||||
;; Handle both new format (:fills vector) and old/deprecated format
|
||||
;; (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
|
||||
"Returns the appropriate text color based on fill, frame, and background."
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
(if (= active-tab :reference)
|
||||
(get value :reference)
|
||||
value)
|
||||
|
||||
value))
|
||||
|
||||
(mf/defc form*
|
||||
@@ -184,9 +183,10 @@
|
||||
|
||||
on-remap-token
|
||||
(mf/use-fn
|
||||
(mf/deps token)
|
||||
(mf/deps token token-type)
|
||||
(fn [valid-token new-name old-name description]
|
||||
(st/emit!
|
||||
(dwtl/toggle-nested-token-path token-type new-name)
|
||||
(dwtl/update-token (:id token)
|
||||
{:name new-name
|
||||
:value (:value valid-token)
|
||||
@@ -197,9 +197,10 @@
|
||||
|
||||
on-rename-token
|
||||
(mf/use-fn
|
||||
(mf/deps token)
|
||||
(mf/deps token token-type)
|
||||
(fn [valid-token name description]
|
||||
(st/emit!
|
||||
(dwtl/toggle-nested-token-path token-type name)
|
||||
(dwtl/update-token (:id token)
|
||||
{:name name
|
||||
:value (:value valid-token)
|
||||
@@ -209,11 +210,12 @@
|
||||
on-submit
|
||||
(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)
|
||||
(fn [form _event]
|
||||
(fn [form event]
|
||||
(let [name (get-in @form [:clean-data :name])
|
||||
description (get-in @form [:clean-data :description])
|
||||
value (get-in @form [:clean-data :value])
|
||||
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
|
||||
:token-name name
|
||||
:token-description description
|
||||
@@ -235,19 +237,22 @@
|
||||
(st/emit! (modal/show :tokens/remapping-confirmation {:remap-data remap-data
|
||||
:on-remap on-remap
|
||||
:on-rename on-rename}))
|
||||
(st/emit!
|
||||
(if is-create
|
||||
(dwtl/create-token (ctob/make-token {:name name
|
||||
:type token-type
|
||||
:value (:value valid-token)
|
||||
:description description}))
|
||||
(dwtl/update-token (:id token)
|
||||
{:name name
|
||||
:value (:value valid-token)
|
||||
:description description}))
|
||||
(dwtl/open-token-type (:type token))
|
||||
(dwtp/propagate-workspace-tokens)
|
||||
(modal/hide!)))))
|
||||
(do
|
||||
(when is-rename
|
||||
(st/emit! (dwtl/toggle-nested-token-path token-type name)))
|
||||
(st/emit!
|
||||
(if is-create
|
||||
(dwtl/create-token (ctob/make-token {:name name
|
||||
:type token-type
|
||||
:value (:value valid-token)
|
||||
:description description}))
|
||||
(dwtl/update-token (:id token)
|
||||
{:name name
|
||||
:value (:value valid-token)
|
||||
:description description}))
|
||||
(dwtl/open-token-type (:type token))
|
||||
(dwtp/propagate-workspace-tokens)
|
||||
(modal/hide!))))))
|
||||
;; WORKAROUND: display validation errors in the form instead of crashing
|
||||
(fn [{:keys [errors]}]
|
||||
(let [error-messages (wte/humanize-errors errors)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding-inline-start: calc(var(--node-spacing));
|
||||
gap: var(--sp-xs);
|
||||
|
||||
& .node-parent {
|
||||
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-keyboard alt? mod? space? z? shift?)
|
||||
(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-shortcuts path-editing? path-drawing? text-editing? grid-editing?)
|
||||
(hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox)
|
||||
|
||||
@@ -177,13 +177,14 @@
|
||||
(dw/increase-zoom)))))))
|
||||
|
||||
(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
|
||||
zoom-ref (mf/use-ref zoom)
|
||||
mod-ref (mf/use-ref @mod?)
|
||||
selected-ref (mf/use-ref selected)
|
||||
hover-disabled-ref (mf/use-ref hover-disabled?)
|
||||
focus-ref (mf/use-ref focus)
|
||||
transform-ref (mf/use-ref transform)
|
||||
|
||||
last-point-ref (mf/use-var nil)
|
||||
mod-str (mf/use-memo #(rx/subject))
|
||||
@@ -251,6 +252,10 @@
|
||||
(mf/deps focus)
|
||||
#(mf/set-ref-val! focus-ref focus))
|
||||
|
||||
(mf/use-effect
|
||||
(mf/deps transform)
|
||||
#(mf/set-ref-val! transform-ref transform))
|
||||
|
||||
(hooks/use-stream
|
||||
over-shapes-stream-debounced
|
||||
(mf/deps objects)
|
||||
@@ -361,7 +366,9 @@
|
||||
(get objects)))]
|
||||
(reset! hover 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 []
|
||||
;; Clean the cache
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
(defn apply-modifiers-to-selected
|
||||
[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
|
||||
"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))
|
||||
objects
|
||||
(reduce
|
||||
(fn [objs pair]
|
||||
(let [[id t] pair]
|
||||
(if (and (contains? id-set id) (contains? objs id))
|
||||
(fn [objs id]
|
||||
(if-let [t (get wasm-modifiers id)]
|
||||
(if (contains? objs id)
|
||||
(update objs id gsh/apply-transform t)
|
||||
objs)))
|
||||
objs)
|
||||
objs))
|
||||
objects
|
||||
wasm-modifiers)))
|
||||
id-set)))
|
||||
|
||||
|
||||
(defn- outline-wasm-source-ids
|
||||
"Superset of shape ids that `shape-outlines` may look up (all outline usages here)."
|
||||
@@ -142,7 +144,6 @@
|
||||
drawing (mf/deref refs/workspace-drawing)
|
||||
focus (mf/deref refs/workspace-focus-selected)
|
||||
wasm-modifiers (mf/deref refs/workspace-wasm-modifiers)
|
||||
|
||||
workspace-editor-state (mf/deref refs/workspace-editor-state)
|
||||
|
||||
file-id (get file :id)
|
||||
@@ -162,7 +163,6 @@
|
||||
selected-shapes (->> selected
|
||||
(into [] (keep (d/getf objects-modified)))
|
||||
(not-empty))
|
||||
|
||||
;; STATE
|
||||
alt? (mf/use-state false)
|
||||
shift? (mf/use-state false)
|
||||
@@ -370,6 +370,7 @@
|
||||
offset-y (if selecting-first-level-frame?
|
||||
(:y first-shape)
|
||||
(:y selected-frame))
|
||||
|
||||
rule-area-size (/ rulers/ruler-area-size zoom)
|
||||
preview-blend (-> refs/workspace-preview-blend
|
||||
(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-keyboard alt? mod? space? z? shift?)
|
||||
(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-active-frames base-objects hover-ids selected active-frames zoom transform vbox)
|
||||
|
||||
@@ -613,11 +614,10 @@
|
||||
:ref text-editor-ref}]))
|
||||
|
||||
(when show-frame-outline?
|
||||
(let [outlined-frame-id
|
||||
(->> @hover-ids
|
||||
(filter #(cfh/frame-shape? (get base-objects %)))
|
||||
(remove selected)
|
||||
(last))
|
||||
(let [outlined-frame-id (->> @hover-ids
|
||||
(filter #(cfh/frame-shape? (get base-objects %)))
|
||||
(remove selected)
|
||||
(last))
|
||||
outlined-frame (get objects outlined-frame-id)]
|
||||
[:*
|
||||
[:& 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(
|
||||
child: &Shape,
|
||||
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_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) {
|
||||
(Some(AlignSelf::Start), _) => margin_top,
|
||||
(Some(AlignSelf::Center), _) => (cell.height - child_bounds.height()) / 2.0,
|
||||
(Some(AlignSelf::End), _) => margin_bottom + cell.height - child_bounds.height(),
|
||||
(_, AlignItems::Center) => (cell.height - child_bounds.height()) / 2.0,
|
||||
(_, AlignItems::End) => margin_bottom + cell.height - child_bounds.height(),
|
||||
(Some(AlignSelf::Center), _) => (cell.height - child_height) / 2.0,
|
||||
(Some(AlignSelf::End), _) => margin_bottom + cell.height - child_height,
|
||||
(_, AlignItems::Center) => (cell.height - child_height) / 2.0,
|
||||
(_, AlignItems::End) => margin_bottom + cell.height - child_height,
|
||||
_ => margin_top,
|
||||
};
|
||||
|
||||
@@ -684,10 +720,10 @@ fn child_position(
|
||||
|
||||
let hpos = match (cell.justify_self, layout_data.justify_items) {
|
||||
(Some(JustifySelf::Start), _) => margin_left,
|
||||
(Some(JustifySelf::Center), _) => (cell.width - child_bounds.width()) / 2.0,
|
||||
(Some(JustifySelf::End), _) => margin_right + cell.width - child_bounds.width(),
|
||||
(_, JustifyItems::Center) => (cell.width - child_bounds.width()) / 2.0,
|
||||
(_, JustifyItems::End) => margin_right + cell.width - child_bounds.width(),
|
||||
(Some(JustifySelf::Center), _) => (cell.width - child_width) / 2.0,
|
||||
(Some(JustifySelf::End), _) => margin_right + cell.width - child_width,
|
||||
(_, JustifyItems::Center) => (cell.width - child_width) / 2.0,
|
||||
(_, JustifyItems::End) => margin_right + cell.width - child_width,
|
||||
_ => margin_left,
|
||||
};
|
||||
|
||||
@@ -747,11 +783,27 @@ pub fn reflow_grid_layout(
|
||||
let Some(child) = cell.shape else { continue };
|
||||
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();
|
||||
if child.is_layout_horizontal_fill() {
|
||||
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);
|
||||
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 max_width = child.layout_item.and_then(|i| i.max_w).unwrap_or(MAX_SIZE);
|
||||
new_width = new_width.clamp(min_width, max_width);
|
||||
@@ -761,7 +813,12 @@ pub fn reflow_grid_layout(
|
||||
if child.is_layout_vertical_fill() {
|
||||
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);
|
||||
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 max_height = child.layout_item.and_then(|i| i.max_h).unwrap_or(MAX_SIZE);
|
||||
new_height = new_height.clamp(min_height, max_height);
|
||||
@@ -792,7 +849,36 @@ pub fn reflow_grid_layout(
|
||||
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 {
|
||||
transform.post_concat(&Matrix::translate(delta_v));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::iter;
|
||||
|
||||
use crate::performance;
|
||||
@@ -191,6 +192,15 @@ impl ShapesPoolImpl {
|
||||
Some(shape)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
@@ -230,9 +240,6 @@ impl ShapesPoolImpl {
|
||||
}
|
||||
|
||||
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 modifiers_with_idx = HashMap::with_capacity(modifiers.len());
|
||||
|
||||
@@ -242,12 +249,47 @@ impl ShapesPoolImpl {
|
||||
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;
|
||||
|
||||
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
|
||||
// `modifier_uuids` without a clone.
|
||||
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;
|
||||
for uuid in all_ids {
|
||||
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 {
|
||||
if !shape.is_bool() {
|
||||
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__) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
... on Issue {
|
||||
number
|
||||
title
|
||||
state
|
||||
labels(first: 20) { nodes { name } }
|
||||
closedByPullRequestsReferences(first: 5) { nodes { number } }
|
||||
}
|
||||
nodes {
|
||||
... on Issue {
|
||||
number
|
||||
title
|
||||
state
|
||||
issueType { name }
|
||||
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]"``
|
||||
|
||||
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)
|
||||
all_nodes: list[dict] = []
|
||||
@@ -140,10 +141,12 @@ def fetch_milestone_issues(milestone_num: int, states: str) -> list[dict]:
|
||||
for node in issues["nodes"]:
|
||||
if node is None:
|
||||
continue
|
||||
issue_type = node.get("issueType")
|
||||
all_nodes.append({
|
||||
"number": node["number"],
|
||||
"title": node["title"],
|
||||
"state": node["state"],
|
||||
"issue_type": issue_type["name"] if issue_type else None,
|
||||
"labels": [lbl["name"] for lbl in node["labels"]["nodes"]],
|
||||
"closing_prs": [pr["number"] for pr in node["closedByPullRequestsReferences"]["nodes"]],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user